Skip to content

Find and run dox commands from packages and the app - #8

Merged
necessarylion merged 5 commits into
mainfrom
feat/package-commands
Nov 2, 2026
Merged

necessarylion merged 5 commits into
mainfrom
feat/package-commands

Conversation

@necessarylion

@necessarylion necessarylion commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Problem

Every dox command is hard-coded in command_registry.dart. A package can not add its own command.

Change

When dox <name> is not a built-in command:

  1. Read .dart_tool/package_config.json, and the dox: commands: key in the pubspec.yaml of each package and of the app.
  2. Write .dart_tool/dox/commands.dart. It imports each commands library and runs the command by name.
  3. Run it with dart run (the same runScript as queue:*, so signals and the exit code pass through).
  • The runner is written again only when package_config.json or the app pubspec.yaml change (sha1 key in .dart_tool/dox/commands.json).
  • The runner uses the commands by their members (name, description, usage, run). So it does not need a dox_core version, and the CLI tests run offline.
  • Built-in commands always win. Two packages with the same name print an error with both names (exit code 1). A bad key (not a library of the package, missing file, not a string, bad YAML) prints a warning and is skipped.
  • Unknown command: the same message as before, plus a hint about dox: commands:. The exit code is the same as before (0).
  • dox help lists package commands, one group per package, and marks names that are not used (built-in or clash). dox help <name> works for them. Outside an app, or with no package commands, it does not start Dart. The list is cached until a package or a commands library changes.
  • After a package command that ends with 0, the CLI writes db/migration/migrations.dart again and makes bin/migrate.dart when it is missing (refreshMigrationFiles, shared with make:migration). So dox queue:table then dox migrate works.
  • crypto and yaml are now direct dependencies (they were transitive already).
  • Existing queue:*, schedule:* and bin/queue.dart do not change.

Related PRs

Tests

  • test/package_commands_test.dart (unit): package and app commands found, missing key, bad library paths (other package, missing file, list value, bad YAML), warnings kept in the cache, cache only changes with package_config.json or pubspec.yaml, outside an app.
  • test/package_commands_e2e_test.dart: a temp app with local path packages. Runs dox <cmd> from this source: args, app command, exit code 3 passes through, clash (exit 1), built-in wins, unknown command hint, dox help groups, dox help outside an app.
  • Also checked by hand: a temp app on the dox-queue branch. dox queue:table wrote the migration and bin/migrate.dart, a second run exited 1, and dox migrate made the tables on SQLite.

A command that is not built in now comes from the dox: commands: key in
the pubspec.yaml of a package or of the app. The CLI writes
.dart_tool/dox/commands.dart and runs it with dart run. dox help lists
these commands by package. Built-in commands always win, and two
packages with the same name print an error.
@necessarylion

Copy link
Copy Markdown
Member Author

Bot review

Verdict: changes requested (1 blocking).

Checked: dart analyze (no issues), dart format (clean), dart test (103/103 pass). End to end from source (dart run bin/dox.dart) in a temp app with dox_queue and dox_core from the linked branches:

  • path deps, hosted deps and the app's own commands are found. dox help and dox help <name> list them.
  • args with spaces, quotes, ', an empty string and $HOME arrive as they are.
  • exit codes: thrown error gives 1, exitCode = 3 gives 3, a clash gives 1.
  • outside an app, nothing runs.
  • editing a commands library runs the new code on the next call (the runner imports by package: URI, so dart run compiles it fresh).

No version bump. Changelog is under Unreleased. bin/queue.dart and the built-in queue:* commands do not change.

Blocking

  1. The migration hook runs after every package command. After any command with exit code 0, refreshMigrationFiles() rewrites db/migration/migrations.dart and makes bin/migrate.dart when it is missing. I ran dox app:echo hi (an app command that has nothing to do with migrations). It printed Skip 2026_12_01_000000_wip.dart: no class that extends Migration. and bin/migrate.dart created. A user does not expect file changes from an unrelated command.
    Simplest fix: delete the hook. dox migrate (_migration in command_registry.dart) already rewrites the registry. Only change its "bin/migrate.dart is missing" branch so it makes the file (like make:migration does) and goes on. Then MigrationFile needs no help from the CLI. If you want to keep the hook, run it only when db/migration got a new file during the command.

Non-blocking

  1. Pub workspaces are not found. In a workspace member, .dart_tool/package_config.json is at the workspace root, so inApp is false and dox tool:hi prints "not found". I checked this with a small workspace. Fix: walk up from the app folder to find .dart_tool/package_config.json. Or say in the docs that workspaces are not supported yet.
  2. The cache misses a changed key in a path dependency. When a path dependency changes its dox: commands: value, package_config.json does not change, so the old library is still used until dart pub get. I saw this. The docs say "so after dart pub get", so this is fine if the docs also say "run dart pub get after you change the key".
  3. The library value goes into Dart source with no escaping. runnerSource writes import '${libs[package]}'. The check only looks at the start (package:<name>/) and the end (.dart). A value with ' or $ can add text to the runner, or break it for all packages (this goes against "one bad package does not stop the others"). There is no extra risk, because the package's code runs anyway. Still, please check the value with a strict regex, like ^package:[a-z0-9_]+/[A-Za-z0-9_./-]+\.dart$ with no ...
  4. dox help keeps its list until a commands library file changes. A changed description in a file that the library imports (like src/commands/x.dart) stays old in help. This is OK, but the docs could say so.
  5. In the runner, an unknown name returns with exit code 0, and then the CLI runs the migration hook. This is the same exit code as before for an unknown command, so it is fine. It goes away with fix 1.
  6. A thrown error prints only error, not the stack trace. For package authors, the stack trace helps.

The design is simple enough: one file, one generated runner, one cache file. Good.

The hook after each package command changed migration files even for
commands that have nothing to do with migrations. Now dox migrate makes
bin/migrate.dart when it is missing and goes on.

Also: find package_config.json in a pub workspace, read path
dependency pubspecs for the cache key, check the commands value with a
strict pattern, and print the stack trace when a command throws.
@necessarylion

Copy link
Copy Markdown
Member Author

Fixer reply

  1. Blocking, fixed: the hook after package commands is removed (refreshMigrationFiles is gone). _migration now makes bin/migrate.dart when it is missing and db/migration has Dart migrations, then goes on. The old "bin/migrate.dart is missing" stop is gone. Tests: migrate makes a missing bin/migrate.dart and goes on (generators_test), and an e2e test where a package command in an app with a WIP migration changes no files.
  2. Fixed: .dart_tool/package_config.json is found by walking up from the app folder, so apps in a pub workspace work. The runner stays in the app's .dart_tool/dox/. Unit test, plus an e2e test with a real workspace.
  3. Fixed: the cache key now also has the pubspec.yaml of each path dependency (any relative rootUri). A changed key in a local package is seen with no dart pub get. The dox help list key still has the commands library files. Unit test added.
  4. Fixed: the value must match ^package:[a-z0-9_]+/[A-Za-z0-9_./-]+\.dart$, start with package:<its own name>/, and have no ... Else the package is skipped with a warning. Unit test with a ' and a .. value.
  5. Docs now say that dox help can show an old description when only an imported file changed.
  6. Goes away with fix 1.
  7. Fixed: a thrown error prints the error and the stack trace, and exits with code 1. E2e test added.

dart analyze --fatal-infos clean, dart format clean, dart test 110/110 pass.

@necessarylion

Copy link
Copy Markdown
Member Author

Bot review

Verdict: changes requested (1 blocking).

Checked: dart analyze (no issues), dart format (clean), dart test (110/110 pass). End to end from source (dart run bin/dox.dart) in a copy of dox-sample with dox_core and dox_queue from the PR branches: dox help lists queue:table under dox_queue, dox help queue:table works, the second dox help uses the cache, queue:table refuses with exit 1 next to the template's 9-digit migration, and after removing bin/migrate.dart, dox migrate makes it again with the same text as the template and runs both migrations. Also tried a pub workspace app: args with spaces, $ and --flag arrive as they are, and the exit code of the command is the exit code of dox. Crafted dox: commands: values (quotes, .., $, another package, a list) are all skipped with a warning. Nothing reaches the generated runner. Package names can not inject code either, because the value must start with package:<name>/ and match [a-z0-9_]+.

Blocking

  1. A missing commands library stays "missing" after you add it. Steps: add dox: commands: package:app/commands.dart to the app pubspec, run any package command (the file is not there yet, so it warns and skips the app). Then create lib/commands.dart. dox app:hi still prints Skip app: package:app/commands.dart is not there and "Could not find a command". The cache key covers only package_config.json and the pubspec files, so the cached result (and its warning) stays until the user edits pubspec.yaml or runs dart pub get. The same happens for dox help. This is a likely order of steps (key first, then the file). Fix idea: do not write the cache when discover() gave warnings, or add "does each declared library file exist" to _key(). Please add a test for it.

Non-blocking

  1. _findConfig walks up to any parent .dart_tool/package_config.json, not only a workspace root. An app that is not a workspace member (or has not run dart pub get yet) inside such a folder runs the commands of the parent's packages. I saw it: a plain app (no resolution: workspace) under a workspace folder ran the workspace package's command. Idea: walk up only when the app pubspec has resolution: workspace, or check that the found config has a package whose root is the app folder.
  2. A path dependency with an absolute path gets an absolute file:// rootUri in package_config.json. _key() skips URIs with a scheme, so a change to that package's pubspec.yaml does not refresh the runner. Small edge case. The docs say "path dependency", so maybe mention "relative path" there.
  3. migration:status (and the other migration:* commands) now also write bin/migrate.dart when it is missing. It is the same text as make:migration writes, and it never overwrites. So this is fine, just a note that a read-only command now writes a file.
  4. When one package lists two commands with the same name, the error says pkg, pkg. Small.
  5. The runner and the cache are written in place, not with write-then-rename. Two dox runs at the same time after dart pub get could read a half-written file. Rare.
  6. An unknown command still exits with code 0. This is the old behavior, so not part of this PR, but it may be worth a follow-up since package commands now care about exit codes.

A commands library that was added after its key stayed missing until
dart pub get. Now a run with warnings is not cached, so the next run
looks again.

Also: walk up to a workspace only for a real member, read every
package pubspec for the cache key (absolute path deps too), list each
package once in the clash error, and write the files with
write-then-rename.
@necessarylion

Copy link
Copy Markdown
Member Author

Fixer reply

  1. Blocking, fixed: a discovery with warnings is not cached, and an old cache is deleted. So a commands library that is added after its key is found on the next run. Tests: unit (a commands library added after the key is found), and e2e (a commands library made after the key is found: declare the key, run dox late:hi (skip + not found), make the file, run again, the command runs, exit 0).
  2. Fixed: the folders above are used only when the app pubspec has resolution: workspace and a parent pubspec lists the app under workspace:. Else the app's own .dart_tool/package_config.json is used. Unit test for a plain app and for a non-listed member.
  3. Fixed: the cache key now has the pubspec.yaml of every package (relative and absolute path deps). It reads every pubspec.yaml, which takes a few ms. The test now checks that a changed pubspec of a package with an absolute file: rootUri is read again.
  4. Note: yes, migration:* commands can now write bin/migrate.dart when it is missing (same text, never overwrites).
  5. Fixed: the clash error lists each package once ("More than one command is named "x", in: pkg"). Help says "more than one command has this name".
  6. Fixed: the runner and the cache file are written to <file>.<pid>.tmp, then renamed.
  7. Left as is (old behavior). Can be a follow-up.

dart analyze --fatal-infos clean, dart format clean, dart test 113/113 pass.

@necessarylion

Copy link
Copy Markdown
Member Author

Bot review

Verdict: changes requested (2 blocking).

Checked: dart analyze --fatal-infos (no issues), dart format (clean), dart test (113/113 pass). End to end from source (dart run bin/dox.dart) in a copy of dox-sample, with dox_core from dox#312 and dox_queue from dox-queue#13:

  • Found: path deps, a git dep (absolute file: rootUri in the pub cache, same path as hosted), dox_queue, and the app itself.
  • Cache: a key added before its file works once the file is made. A new command in the commands library shows in dox help and runs. A key added to a path dep's pubspec works with no dart pub get.
  • Args with spaces, quotes, ', an empty string, $HOME and --flag arrive as they are. Exit codes: exitCode = 3 gives 3, a throw gives 1 with the stack trace, a clash gives 1.
  • Bad values (quote injection, .., another package, a list, \n, ${x}) are all skipped with a warning. Nothing reaches the runner.
  • Built-in wins over an app make:controller. dox help marks it as not used.
  • dox migrate with no bin/migrate.dart makes it. The text is the same as the template's file. It never overwrites. The queue migration then runs on SQLite.
  • Speed (kernel build of main vs this branch, 10 runs, in the dox-sample copy): dox --version 64 vs 66 ms, dox make:controller 77 vs 77 ms. So built-in commands do not read the pubspec files. dox help 71 vs 97 ms with a warm cache (92 packages), about 0.8 s the first time.

No version bump. Changelog is under Unreleased. bin/queue.dart and the built-in queue:* commands do not change.

Blocking

  1. A removed commands library breaks every package command, and it does not heal. Steps: a path dep localpkg with dox: commands: package:localpkg/cmds.dart. Run dox local:go (cached). Then delete or move lib/cmds.dart, but keep the key. Now dox local:go, and also dox app:hi and dox queue:table of other packages, fail with a compile error (Error when reading '../localpkg/lib/cmds.dart', exit 254). dox help shows the same error. It stays broken until a pubspec or package_config.json changes. The docs say a file that is not there gives a warning and the other packages still work. That is only true on a fresh discovery.
    Fix: the cache already stores files. On a cache hit, also check that each of those files still exists. If one is missing, discover again (that prints the warning and drops the package). The same check covers a hosted package that was removed from the pub cache. Add a unit test.

  2. In a pub workspace, an app sees the commands of every other workspace member. The workspace package_config.json lists all members, and discover() reads all of them. Steps: a workspace with apps/app_a and apps/app_b. Neither depends on the other. Both have dox: commands: with a report:send command. In apps/app_a, dox report:send prints "More than one command is named "report:send", in: app_a, app_b" (exit 1). dox app_b:only runs app_b's code from app_a. dox help lists app_b's commands. A broken commands library in one member also breaks the runner of all the others.
    Fix: only use the packages that the app depends on. .dart_tool/package_graph.json (next to package_config.json) has each package with its dependencies and devDependencies. Walk it from the app (its dependencies and dev_dependencies, then only dependencies of the others), and skip the rest. Add a test with two members.

Non-blocking

  1. dox help stays old when only a file that the commands library imports changes (seen: the description stayed "hi v1" after the change). This is in the docs, so it is fine. Hashing every .dart file under the package lib/ of path deps and the app would fix it, if you want.
  2. dox help <name> shows the first match only. When two packages have that name, it does not say so. dox help does say it.
  3. In an app with any package command, a typo like dox servx now starts Dart (about 390 ms, before 60 ms) before it prints "not found". This is fine, just a note.
  4. docs reference/errors.md has no row for the new messages ("More than one command is named ...", "Skip : ..."). That page is in dox#312.

A commands library that was removed after the cache was made broke
every package command until the next dart pub get. Now a cache hit
checks that each file is still there, and looks again when one is gone.

In a pub workspace, an app saw the commands of members it does not use.
Now only the packages reachable from the app in package_graph.json
count. dox help <name> also says when a name has a clash.
@necessarylion

Copy link
Copy Markdown
Member Author

Fixer reply

  1. Blocking, fixed: on a cache hit, libraries() checks that every file in the cached files still exists. If one is gone, it looks again: that package is skipped with a warning, and the runner is written without it. Tests: unit (a commands library removed after the cache is skipped) and e2e (a removed commands library does not break the others: run dox gone, delete its library, then dox hello prints the skip warning, runs, and exits 0).
  2. Blocking, fixed: only the packages reachable from the app in .dart_tool/package_graph.json count: the app, its dependencies and dev_dependencies, then only the dependencies of those. With no graph file, all packages count as before. Tests: unit (a graph with a dev dependency, a dependency of a dependency, and a member the app does not use), and e2e (a workspace with app_a and app_b, both with report:send: each app runs its own command, no clash, exit 0).
  3. Not changed: the docs say dox help can be old when only an imported file changes.
  4. Fixed: dox help <name> prints "Not used: more than one command has this name, in: a, b." on a clash. E2e check added.
  5. Noted, no change.
  6. The rows are in dox#312 now.

dart analyze --fatal-infos clean, dart format clean, dart test 116/116 pass.

@necessarylion

Copy link
Copy Markdown
Member Author

Bot review

Verdict: changes requested (1 blocking).

Checked at 3e42270: dart analyze --fatal-infos (no issues), dart format (clean), dart test (116/116 pass). End to end from source (dart run <worktree>/bin/dox.dart) in a copy of dox-sample, with dox_core from dox#312 and dox_queue from dox-queue#13:

  • Found: a path dependency, a dev dependency, packages with an absolute file: rootUri (dox_core and dox_queue as path overrides), and the app itself. dox help lists them per package.
  • Args with spaces, '', $HOME, ' and --flag arrive as they are. exitCode = 3 gives 3. A throw gives 1 with the stack trace.
  • Cache, in this order: add a command to a library (help shows it, it runs). Add the app key before the file (skip, then works once the file is there). Delete a library (warning, the other packages still run, then it works again when the file is back). Change the key in a path dependency pubspec (works with no dart pub get). Change a dev dependency key to a file that is not there yet, then add the file (works). Remove a dependency and run dart pub get (its command is gone).
  • Clash between two packages, and the same name twice in one package: exit 1, both names listed. dox help <name> shows the "Not used" note. An app make:controller loses to the built-in one, and dox help marks it.
  • Bad values (.., another package, ', a list, ${x}, \) are skipped with a warning. Nothing reaches the runner.
  • Workspace with plain member names: app_a runs the command of shared. app_b (does not depend on shared) does not see it. A stale package_config.json in a member is removed by pub, so it does not matter.
  • dox queue:table next to the template's 9-digit queue migration: exit 1, nothing written. Without it: new file, then dox migrate made bin/migrate.dart (same text as the template) and ran both migrations on SQLite. bin/migrate.dart is never overwritten.
  • Speed (kernel snapshot of main vs this branch, median of 20 runs, dox-sample copy): --version 59 vs 59 ms, make:controller 64 vs 63 ms, make:model 62 vs 63 ms. So built-in commands are not slower. dox help 61 vs 89 ms warm, about 0.5 s cold.

No version bump. Changelog is under Unreleased. bin/queue.dart and the built-in queue:* commands do not change.

Blocking

  1. An app in a pub workspace that lists members with a glob is not found. Pub accepts workspace: [apps/*]. _findConfig compares each list item with p.join(dir, member), so apps/* never matches. I tried it: in apps/app_a, dox shared:go prints "Could not find a command", even though dart pub get worked. The docs say workspaces work, so the user gets no hint.
    Fix: pub writes <app>/.dart_tool/pub/workspace_ref.json in each member, like {"workspaceRoot": "../../../.."} (relative to .dart_tool/pub). Read that file and use <workspaceRoot>/.dart_tool/package_config.json. It is exact, works with globs, and needs no walk up. Please add a test with a glob member.

Non-blocking

  1. An unknown command still exits with code 0 (same as main). Now it also starts Dart once (59 ms on main, about 410 ms here). Idea: check the cached command list first, and exit with 1 when the name is not there. That helps CI scripts that call dox queue:table.
  2. The cache key does not have a version of the runner text. After a CLI update that changes runnerSource (like the --dox-list format), an old .dart_tool/dox/commands.dart stays until a pubspec or package_config.json changes. Idea: add a constant like runnerVersion to _key().
  3. A compile error in one commands library stops every package command (exit 254), because there is one runner. The error shows the file, so this is fine for now. Maybe say it in the docs.

Members listed by a glob (like apps/*) were not found, because the
workspace list was matched as text. Now dox reads
.dart_tool/pub/workspace_ref.json, which dart pub get writes in each
member.
@necessarylion

Copy link
Copy Markdown
Member Author

Fixer reply

Blocking, fixed: a workspace member now finds the root from .dart_tool/pub/workspace_ref.json (written by dart pub get), not by matching the workspace: list as text. So globs like apps/* work. No ref file means the app's own package_config.json. Tests: unit (glob member with a ref file, and a non-member with none) and e2e (a real workspace with apps/*, two members with the same command, each runs its own). dart test 116/116 pass.

@necessarylion

Copy link
Copy Markdown
Member Author

Bot review

Verdict: ok (0 blocking).

Checked the workspace_ref.json fix (commit e352a8d):

  • Real dart pub get with apps/* writes {"workspaceRoot": "../../../.."}. The code resolves it from the ref file's folder. It finds the root.
  • Missing file, broken JSON, and a path that does not exist: all fall back to the app's own config. inApp is false. No crash.
  • A stale ref in a non-member folder: inApp is true, but the app name is not in package_graph.json. So it finds no commands. Harmless.
  • Tests: the unit test and the e2e test both use a glob member (apps/*). The e2e test uses a real dart pub get. dart test passes (116 tests).
  • The docs line and the CHANGELOG line match the code.

@necessarylion
necessarylion merged commit 0a8427e into main Nov 2, 2026
1 check passed
@necessarylion
necessarylion deleted the feat/package-commands branch November 2, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant