diff --git a/docs/features/cli.md b/docs/features/cli.md
index 0c3c1f8e3..81d715d8a 100644
--- a/docs/features/cli.md
+++ b/docs/features/cli.md
@@ -96,7 +96,13 @@ Rules of the contract:
`.gitignore` says which of them it COMMITS.** Narrowing `surfaces` to silence the
gate also stops `meta docs` producing those pages, which is usually not what you
want.
-- **Unknown/invalid flag → exit 2** with usage.
+- **Unknown/invalid flag → exit 2**, refused by name with that command's valid flags listed
+ — one wording in every port's CLI (`unknown flag --x for \`meta gen\`. Valid flags: …`).
+ A numeric flag given a non-number (`--limit abc`) is the same usage error.
+- **Exit codes agree across the CLIs** (`meta`, `dotnet meta`, `metaobjects`): `0` success,
+ including a no-op re-run; `1` a runtime failure, including metadata that does not load,
+ in every command; `2` a usage error. `--version`, `-v` and `-V` print the bare version
+ in each.
- **The overlay authoring lint runs on every `meta verify`, not gated on any
subverb** (FR-023) — a top-level `(type, resolutionKey)` declared in two or more
collection files (dependency artifacts included) where more than one declaration
diff --git a/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs
new file mode 100644
index 000000000..b7c24ce9c
--- /dev/null
+++ b/server/csharp/MetaObjects.Cli.Tests/CliSurfaceTests.cs
@@ -0,0 +1,75 @@
+using Xunit;
+
+namespace MetaObjects.Cli.Tests;
+
+///
+/// The command-line surface every port's CLI shares with the Node meta: the three
+/// version spellings, --help as output rather than an error, and an unknown flag
+/// refused by name with the command's valid flags listed (exit 2). gen and
+/// docs used to drop an unknown flag and exit 0 as though it had been honoured.
+/// Driven through the built assembly, because Program.cs's argument parsing is
+/// top-level statements no test can call directly.
+///
+public sealed class CliSurfaceTests : IDisposable
+{
+ private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-cli-surface-" + Guid.NewGuid().ToString("N"));
+
+ public CliSurfaceTests() => Directory.CreateDirectory(_tmp);
+
+ public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
+
+ [Theory]
+ [InlineData("--version")]
+ [InlineData("-v")]
+ [InlineData("-V")]
+ public void A_version_flag_prints_the_bare_version_and_exits_0(string flag)
+ {
+ var (exit, stdout, stderr) = CliProcess.Run(_tmp, flag);
+ Assert.True(exit == 0, $"exit={exit}\nstderr={stderr}");
+ Assert.Matches(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$", stdout.Trim());
+ }
+
+ [Fact]
+ public void Help_is_output_on_stdout_and_exits_0()
+ {
+ var (exit, stdout, _) = CliProcess.Run(_tmp, "--help");
+ Assert.Equal(0, exit);
+ Assert.Contains("usage: dotnet meta ", stdout);
+ // --namespace defaults (GenCommand.DefaultNamespace); the banner must not show it as required.
+ Assert.Contains("--out [--namespace ]", stdout);
+ }
+
+ [Theory]
+ [InlineData("gen", "usage: dotnet meta gen")]
+ [InlineData("verify", "usage: dotnet meta verify")]
+ [InlineData("docs", "usage: dotnet meta docs")]
+ [InlineData("fmt", "usage: dotnet meta fmt")]
+ [InlineData("eject", "usage: dotnet meta eject")]
+ public void A_command_help_prints_that_commands_usage_and_exits_0(string command, string expected)
+ {
+ var (exit, stdout, _) = CliProcess.Run(_tmp, command, "--help");
+ Assert.Equal(0, exit);
+ Assert.Contains(expected, stdout);
+ }
+
+ [Theory]
+ [InlineData("gen", "--generators", "x", "--out", "o")]
+ [InlineData("docs", "--out", "o", "x")]
+ [InlineData("verify", "x")]
+ [InlineData("fmt")]
+ [InlineData("eject", "entity")]
+ public void An_unknown_flag_is_refused_by_name_with_the_valid_flags_listed(string command, params string[] rest)
+ {
+ var (exit, _, stderr) = CliProcess.Run(_tmp, [command, .. rest, "--bogus"]);
+ Assert.Equal(2, exit);
+ Assert.Contains($"unknown flag --bogus for `dotnet meta {command}`. Valid flags: ", stderr);
+ }
+
+ [Fact]
+ public void A_value_flag_with_no_value_says_so_rather_than_calling_it_unknown()
+ {
+ var (exit, _, stderr) = CliProcess.Run(_tmp, "gen", "x", "--out");
+ Assert.Equal(2, exit);
+ Assert.Contains("--out needs a value", stderr);
+ }
+}
diff --git a/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs
index a381a90ff..f1f0badad 100644
--- a/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs
+++ b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs
@@ -201,6 +201,18 @@ public void Codegen_without_committed_output_is_exit2()
Assert.NotNull(r.Codegen!.Error);
}
+ [Fact]
+ public void Codegen_with_metadata_that_does_not_load_is_exit1_not_the_usage_exit2()
+ {
+ // Metadata that does not load is a runtime failure: exit 1, the code gen, fmt,
+ // docs and the templates gate already used and every other port uses. It used to
+ // share exit 2 with "no --out given", a usage error.
+ File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), """{ "metadata.root": { "children": [ """);
+ var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
+ Assert.Equal(1, r.ExitCode);
+ Assert.Contains("did not load", r.Codegen!.Error);
+ }
+
// -------------------- the namespace-inference footgun --------------------
// gen used a CUSTOM namespace; verify --codegen WITHOUT --namespace must infer
// it from the committed output (else every file would spuriously drift on the
@@ -363,7 +375,8 @@ public void Codegen_load_failure_names_the_offending_attr_and_node()
]}}
""");
var r = VerifyCommand.RunSubverbs(TemplatesOpts(templates: false, codegen: true));
- Assert.Equal(2, r.ExitCode);
+ // A load failure exits 1 (runtime), as the Node `meta verify` does for the same file.
+ Assert.Equal(1, r.ExitCode);
Assert.NotNull(r.Codegen?.Error);
Assert.Contains("noSuchAttr", r.Codegen!.Error);
Assert.Contains("Thing", r.Codegen!.Error);
diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs
index eea1dd603..5c4a8a8fa 100644
--- a/server/csharp/MetaObjects.Cli/Program.cs
+++ b/server/csharp/MetaObjects.Cli/Program.cs
@@ -10,12 +10,24 @@
using MetaObjects.Codegen;
using MetaObjects.Config;
-if (args.Length == 0)
+// `--version`, `-v` and `-V` print the bare version and exit 0 — the same three spellings
+// every port's CLI answers. Checked before anything else so a version probe never parses
+// a command.
+if (args.Length == 1 && args[0] is "--version" or "-v" or "-V")
{
- Console.Error.WriteLine(
+ Console.WriteLine(EjectCommand.ToolVersion());
+ return 0;
+}
+
+// `--help`/`-h` print the command reference on stdout and exit 0: asked-for help is
+// output, not an error. A bare `dotnet meta` is still a usage error (stderr, exit 2).
+bool helpRequested = args.Length > 0 && args[0] is "--help" or "-h";
+if (args.Length == 0 || helpRequested)
+{
+ (helpRequested ? Console.Out : Console.Error).WriteLine(
"usage: dotnet meta [options]\n" +
" commands:\n" +
- " gen --out --namespace [--emit-abstract-shapes]\n" +
+ " gen --out [--namespace ] [--emit-abstract-shapes]\n" +
" [--generators ] [--template-root ]\n" +
" generate EF Core code from metadata\n" +
" gen --list list available generators (stable names) and exit\n" +
@@ -37,8 +49,18 @@
" see `dotnet meta gen --list`\n" +
" fmt [] [--check] rewrite metadata into canonical form (#304);\n" +
" --check lists drift, exits non-zero, changes nothing\n" +
- " agent-docs see `npx meta agent-docs`");
- return 2;
+ " agent-docs see `npx meta agent-docs`\n" +
+ " --version, -v, -V print the version\n" +
+ " --help that command's usage");
+ return helpRequested ? 0 : 2;
+}
+
+// ` --help` answers with that command's usage on stdout, exit 0, before the
+// command parses anything — so help is never refused as an unknown flag.
+if (args.Length > 1 && CommandUsage.TryGetValue(args[0], out var usage) && args[1..].Any(a => a is "--help" or "-h"))
+{
+ Console.WriteLine(usage);
+ return 0;
}
return args[0] switch
@@ -77,6 +99,9 @@ static int RunGen(string[] rest)
else if (rest[i] == "--baseline" && i + 1 < rest.Length) baseline = rest[++i];
else if (rest[i].StartsWith("--baseline=", StringComparison.Ordinal)) baseline = rest[i]["--baseline=".Length..];
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
+ // An unrecognised flag used to be dropped here: `gen ... --bogus` generated and
+ // exited 0, as if the flag had been honoured.
+ else return RefuseFlag("gen", rest[i], GenValueFlags, GenBoolFlags);
}
// How a field with NO explicit `@column` becomes a physical column name. The
@@ -229,6 +254,7 @@ static int RunDocs(string[] rest)
else if (rest[i] == "--project" && i + 1 < rest.Length) project = rest[++i];
else if (rest[i] == "--model-base-url" && i + 1 < rest.Length) modelBaseUrl = rest[++i];
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
+ else return RefuseFlag("docs", rest[i], DocsValueFlags, []);
}
// Usage-first — see the identical comment in RunGen above; a missing --out
@@ -284,12 +310,7 @@ static int RunFmt(string[] rest)
foreach (var a in rest)
{
if (a == "--check") check = true;
- else if (a.StartsWith('-'))
- {
- Console.Error.WriteLine($"dotnet meta fmt: unknown option \"{a}\"");
- Console.Error.WriteLine("usage: dotnet meta fmt [] [--check]");
- return 2;
- }
+ else if (a.StartsWith('-')) return RefuseFlag("fmt", a, [], ["--check"]);
else metadataDir ??= a;
}
@@ -366,12 +387,7 @@ static int RunEject(string[] rest)
if (rest[i] == "--force") force = true;
else if (rest[i] == "--root" && i + 1 < rest.Length) root = rest[++i];
else if (!rest[i].StartsWith('-')) names.Add(rest[i]);
- else
- {
- Console.Error.WriteLine($"dotnet meta eject: unknown option \"{rest[i]}\"");
- Console.Error.WriteLine("usage: dotnet meta eject ... [--force] [--root ]");
- return 2;
- }
+ else return RefuseFlag("eject", rest[i], ["--root"], ["--force"]);
}
var result = EjectCommand.Run(names, root ?? Directory.GetCurrentDirectory(), force);
@@ -430,6 +446,26 @@ static ResolvedMetadata ResolveMetadataDirOrExit(string? metadataDir)
}
}
+///
+/// The one refusal for a flag a command does not accept, in every command — the same
+/// shape the Node meta CLI uses. A known value flag with its value missing says
+/// so; anything else is named as unknown and the command's valid flags are listed, so the
+/// refusal corrects itself in one step. Exit 2 (usage).
+///
+static int RefuseFlag(string command, string flag, string[] valueFlags, string[] boolFlags)
+{
+ if (valueFlags.Contains(flag))
+ {
+ Console.Error.WriteLine($"dotnet meta {command}: {flag} needs a value");
+ return 2;
+ }
+ var valid = valueFlags.Concat(boolFlags).Order(StringComparer.Ordinal);
+ Console.Error.WriteLine(
+ $"unknown flag {flag} for `dotnet meta {command}`. Valid flags: {string.Join(", ", valid)} " +
+ "(also accepted everywhere: --help)");
+ return 2;
+}
+
static int Unknown(string cmd)
{
Console.Error.WriteLine($"dotnet meta: unknown command \"{cmd}\"");
@@ -495,12 +531,7 @@ static int RunVerify(string[] rest)
// wrong strategy and every one reports spurious drift on an otherwise-clean
// project.
else if (a == "--column-naming" && i + 1 < rest.Length) columnNamingRaw = rest[++i];
- else if (a.StartsWith('-'))
- {
- Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\"");
- Console.Error.WriteLine("usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint]");
- return 2;
- }
+ else if (a.StartsWith('-')) return RefuseFlag("verify", a, VerifyValueFlags, VerifyBoolFlags);
else if (metadataDir is null) metadataDir = a;
// A second positional is the templates root for a BARE verify
// (`verify `) — keeps the historical default
@@ -651,3 +682,27 @@ static int RunVerify(string[] rest)
return codegenHandedOff ? Math.Max(result.ExitCode, codegenHandoffExit) : result.ExitCode;
}
+
+/// The flags each command parses, for . Kept beside the
+/// parsers' own branches; a flag added to one must be added here.
+partial class Program
+{
+ static readonly string[] GenValueFlags =
+ ["--out", "--namespace", "--generators", "--template-root", "--template-spec", "--column-naming", "--baseline"];
+ static readonly string[] GenBoolFlags = ["--list", "--emit-abstract-shapes"];
+ static readonly string[] DocsValueFlags = ["--out", "--namespace", "--project", "--model-base-url"];
+ static readonly string[] VerifyValueFlags =
+ ["--prompts", "--out", "--namespace", "--generators", "--template-root", "--column-naming"];
+ static readonly string[] VerifyBoolFlags = ["--templates", "--codegen", "--db", "--lax", "--no-field-lint"];
+
+ /// Each command's usage, printed by dotnet meta <command> --help.
+ static readonly Dictionary CommandUsage = new()
+ {
+ ["gen"] = "usage: dotnet meta gen --out [--namespace ] [--generators ] [--template-root ] [--template-spec ] [--emit-abstract-shapes] [--column-naming literal|snake_case|kebab-case] [--baseline default|adopt]\n" +
+ " dotnet meta gen --list",
+ ["verify"] = "usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--generators ] [--template-root ] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint]",
+ ["docs"] = "usage: dotnet meta docs --out [--namespace ] [--project ] [--model-base-url ]",
+ ["fmt"] = "usage: dotnet meta fmt [] [--check]",
+ ["eject"] = "usage: dotnet meta eject ... [--force] [--root ]",
+ };
+}
diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs
index d08f22b35..9cd680990 100644
--- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs
+++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs
@@ -197,9 +197,12 @@ public static SubverbResult RunSubverbs(Options opts)
Codegen.CodegenDrift.Result? codegenResult = null;
if (runCodegen)
{
- codegenResult = RunCodegenDrift(opts);
- // error (nothing to diff against) → exit 2; drift → exit 1; clean → 0.
- int codegenExit = codegenResult.Error is not null ? 2 : (codegenResult.Clean ? 0 : 1);
+ codegenResult = RunCodegenDrift(opts, out var codegenLoadFailed);
+ // usage error (nothing to diff against) → exit 2; metadata that does not load →
+ // exit 1, the code every port's gen/verify/fmt uses for it; drift → 1; clean → 0.
+ int codegenExit = codegenResult.Error is null
+ ? (codegenResult.Clean ? 0 : 1)
+ : (codegenLoadFailed ? 1 : 2);
exit = Math.Max(exit, codegenExit);
}
@@ -237,10 +240,13 @@ private static LoadResult LoadMetadata(Options opts) => opts.MetadataFiles is {
/// Run the codegen-drift gate: load metadata, resolve the generator suite (default
/// or the --generators selection), and diff a fresh regen against the
/// committed --out dir. Loader / unknown-generator problems surface as a
- /// drift (exit 2), never a throw.
+ /// drift , never a throw;
+ /// says the metadata itself did not load (exit 1, not
+ /// the usage exit 2 a missing --out or an unknown generator gets).
///
- private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
+ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts, out bool loadFailed)
{
+ loadFailed = false;
if (opts.OutDir is null)
return new Codegen.CodegenDrift.Result
{
@@ -250,7 +256,8 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
};
var load = LoadMetadata(opts);
- if (load.Errors.Count > 0)
+ loadFailed = load.Errors.Count > 0;
+ if (loadFailed)
return new Codegen.CodegenDrift.Result
{
Clean = false,
diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py
index d3403307e..006ff222a 100644
--- a/server/python/src/metaobjects/cli.py
+++ b/server/python/src/metaobjects/cli.py
@@ -2409,6 +2409,7 @@ def _build_parser() -> argparse.ArgumentParser:
"owned by the Node `meta` CLI (ADR-0015) — there is no `migrate` "
"subcommand here."
),
+ epilog="--version, -v, -V print the version and exit",
)
sub = parser.add_subparsers(dest="command", required=True)
@@ -2759,10 +2760,53 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
+# The three spellings that print the bare version — the same set every port's CLI answers.
+VERSION_FLAGS = ("--version", "-v", "-V")
+
+
+def _refuse_extras(parser: argparse.ArgumentParser, command: str, extras: list[str]) -> int:
+ """Refuse what a subcommand did not consume, naming the subcommand and its flags.
+
+ argparse attributes a subcommand's unknown flag to the TOP-LEVEL parser: it printed
+ ``usage: metaobjects [-h] {gen,docs,...}`` and ``unrecognized arguments: --x``, which
+ names neither the command nor a flag that would have worked. This is the one wording
+ every port's CLI uses for it, and it exits 2.
+ """
+ subparser = next(
+ (
+ action.choices.get(command)
+ for action in parser._actions # noqa: SLF001 — argparse exposes no public accessor
+ if isinstance(action, argparse._SubParsersAction) # noqa: SLF001
+ ),
+ None,
+ )
+ first = extras[0]
+ if not first.startswith("-") or subparser is None:
+ print(f"unexpected argument {first!r} for `metaobjects {command}`", file=sys.stderr)
+ return 2
+ valid = sorted(
+ ", ".join(action.option_strings)
+ for action in subparser._actions # noqa: SLF001
+ if action.option_strings and "--help" not in action.option_strings
+ )
+ print(
+ f"unknown flag {first} for `metaobjects {command}`. Valid flags: {', '.join(valid)} "
+ "(also accepted everywhere: --help)",
+ file=sys.stderr,
+ )
+ return 2
+
+
def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns the process exit code (does not call ``sys.exit``)."""
+ raw = sys.argv[1:] if argv is None else argv
+ if len(raw) == 1 and raw[0] in VERSION_FLAGS:
+ print(installed_metaobjects_version())
+ return 0
parser = _build_parser()
- args = parser.parse_args(argv)
+ args, extras = parser.parse_known_args(raw)
+ if extras:
+ return _refuse_extras(parser, args.command, extras)
return int(args.func(args))
diff --git a/server/python/tests/codegen/test_cli_surface.py b/server/python/tests/codegen/test_cli_surface.py
new file mode 100644
index 000000000..05548afca
--- /dev/null
+++ b/server/python/tests/codegen/test_cli_surface.py
@@ -0,0 +1,66 @@
+"""The command-line surface the `metaobjects` console-script shares with every port's CLI.
+
+- `--version`, `-v` and `-V` print the bare version and exit 0. None of the three was
+ accepted: each died as "the following arguments are required: command" (exit 2).
+- an unknown flag is refused by name, for the subcommand it was given to, with that
+ subcommand's valid flags listed (exit 2). argparse attributed it to the TOP-LEVEL
+ parser — "usage: metaobjects [-h] {gen,docs,...}" / "unrecognized arguments: --x" —
+ naming neither the command nor a flag that would have worked.
+- metadata that does not load exits 1 in gen, verify and fmt, as it does in the Node
+ `meta` and `dotnet meta`.
+"""
+from __future__ import annotations
+
+import importlib.metadata
+import re
+from pathlib import Path
+
+import pytest
+
+from metaobjects.cli import main
+
+
+@pytest.mark.parametrize("flag", ["--version", "-v", "-V"])
+def test_a_version_flag_prints_the_bare_version(flag: str, capsys: pytest.CaptureFixture[str]) -> None:
+ assert main([flag]) == 0
+ out = capsys.readouterr().out.strip()
+ assert out == importlib.metadata.version("metaobjects")
+ assert re.fullmatch(r"\d+\.\d+\.\d+([.\-+][0-9A-Za-z.]+)?", out)
+
+
+@pytest.mark.parametrize(
+ ("argv", "command", "a_valid_flag"),
+ [
+ (["gen"], "gen", "--out"),
+ (["verify"], "verify", "--codegen"),
+ (["fmt"], "fmt", "--check"),
+ (["docs", "--out", "o"], "docs", "--api-subdir"),
+ (["eject", "entity"], "eject", "--force"),
+ ],
+)
+def test_an_unknown_flag_names_the_subcommand_and_its_valid_flags(
+ argv: list[str], command: str, a_valid_flag: str, capsys: pytest.CaptureFixture[str]
+) -> None:
+ assert main([*argv, "--bogus"]) == 2
+ err = capsys.readouterr().err
+ assert f"unknown flag --bogus for `metaobjects {command}`. Valid flags: " in err
+ assert a_valid_flag in err
+ assert "unrecognized arguments" not in err
+
+
+@pytest.mark.parametrize(
+ "argv",
+ [
+ ["gen", "--generators", "entity", "--out", "OUT"],
+ ["verify", "--codegen", "--generators", "entity", "--out", "OUT"],
+ ["fmt"],
+ ],
+)
+def test_metadata_that_does_not_load_exits_1(argv: list[str], tmp_path: Path) -> None:
+ meta = tmp_path / "meta"
+ meta.mkdir()
+ (meta / "meta.bad.json").write_text('{ "metadata.root": { "children": [ ')
+ out = tmp_path / "out"
+ out.mkdir()
+ args = [a.replace("OUT", str(out)) for a in argv]
+ assert main([args[0], str(meta), *args[1:]]) == 1
diff --git a/server/python/tests/codegen/test_cli_verify_subverbs.py b/server/python/tests/codegen/test_cli_verify_subverbs.py
index 70ccc384a..cd9e61779 100644
--- a/server/python/tests/codegen/test_cli_verify_subverbs.py
+++ b/server/python/tests/codegen/test_cli_verify_subverbs.py
@@ -287,11 +287,9 @@ def test_db_is_rejected_exit_2(tmp_path: Path, capsys) -> None:
def test_invalid_flag_exit_2() -> None:
- import pytest
-
- with pytest.raises(SystemExit) as exc:
- main(["verify", "--generators", GEN_SUITE, "--bogus", "x"])
- assert exc.value.code == 2
+ # Refused by `main` itself (named, with verify's valid flags), not by argparse's
+ # top-level SystemExit — so it is a return code, not an exception.
+ assert main(["verify", "--generators", GEN_SUITE, "--bogus", "x"]) == 2
# --- aggregation: combining --codegen + --templates -------------------------
diff --git a/server/typescript/packages/cli/README.md b/server/typescript/packages/cli/README.md
index db844c068..035ea164e 100644
--- a/server/typescript/packages/cli/README.md
+++ b/server/typescript/packages/cli/README.md
@@ -92,7 +92,7 @@ These apply to every command:
Running `meta` with no arguments prints a concise status line (whether a `metaobjects/` directory is present) plus the most relevant next-step commands, rather than the full manual.
-**Exit codes:** `0` success (including idempotent no-op runs), `1` runtime error, `2` usage error (bad flag, missing required argument, invalid `--format`). For agent-friendliness, structured errors and next-step hints are emitted on **stdout** in the active `--format` (not stderr), so callers can parse them without scraping stderr.
+**Exit codes:** `0` success (including idempotent no-op runs, such as `meta init` on an initialized project), `1` runtime error (including metadata that does not load, in every command), `2` usage error (bad flag, missing required argument, invalid `--format`). An unknown flag is refused by name with the command's valid flags listed. Under `--format json|toon` stdout carries exactly one document — narration goes to stderr — and `meta verify` and `meta migrate` put their errors in that document too; the other commands print errors on stderr.
## Commands
@@ -169,7 +169,7 @@ Flags:
- `--allow ` — destructive-change permissions: `drop-column,drop-table,type-change,drop-index,drop-fk,drop-check,drop-view,drop-view-cascade,adopt-view,nullable-to-not-null,drop-identity-default`
- `--on-ambiguous abort|rename|drop-add` (default `abort`) — non-interactive
- `--rename-table [schema.]old=new` / `--rename-column [schema.]table.old=new` — declare a rename (repeatable), so the migration is `RENAME` instead of drop+add whatever the rename heuristic makes of the names. Refused if nothing matches. See [Renames and populated tables](../../../../docs/features/migrations-and-drift.md#renames-and-populated-tables).
-- `--dry-run` — print SQL pair to stdout, write nothing
+- `--dry-run` — print the SQL pair, write nothing (under `--format json|toon` the pair is the document's `sql` field, so stdout stays one parseable document)
- `--apply` — after writing migration files, immediately apply all pending migrations against the DB (runs `up.sql` for each unapplied entry, tracked in the migration ledger). Mutually exclusive with `--rollback`. Postgres and SQLite only (D1 uses `--apply` to invoke `wrangler d1 migrations apply` instead).
- `--rollback ` — roll back applied migrations newer than `` by running their `down.sql` in reverse order, ledger-tracked. Pass an empty string (`--rollback ""`) to roll back everything. Mutually exclusive with `--apply`. Postgres and SQLite only.
diff --git a/server/typescript/packages/cli/bin/meta.ts b/server/typescript/packages/cli/bin/meta.ts
index 37dede66f..f84015c72 100644
--- a/server/typescript/packages/cli/bin/meta.ts
+++ b/server/typescript/packages/cli/bin/meta.ts
@@ -2,5 +2,18 @@
// Note: this .ts source file is executed by Bun in the workspace (not Node).
// The shebang stays as `node` so tsc copies it unchanged to dist/bin/meta.js,
// keeping the published CLI runnable by Node for npm consumers.
-import { run } from "../src/index.js";
-run(process.argv.slice(2)).then((code) => process.exit(code));
+//
+// A bare version probe is answered here, from a leaf module that imports only node
+// builtins, BEFORE the command graph loads. `src/index.ts` statically pulls in the sdk
+// and codegen-ts, so answering `--version` there cost ~10x a bare `node` start on every
+// probe. Everything else — including a version flag in any other position — falls
+// through to `run()`, which stays the single owner of the general case.
+import { cliVersion, isVersionFlag } from "../src/lib/version.js";
+
+const argv = process.argv.slice(2);
+if (argv.length === 1 && isVersionFlag(argv[0])) {
+ console.log(cliVersion());
+ process.exit(0);
+}
+const { run } = await import("../src/index.js");
+process.exit(await run(argv));
diff --git a/server/typescript/packages/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts
index 682ac01f7..32920b89b 100644
--- a/server/typescript/packages/cli/src/commands/docs.ts
+++ b/server/typescript/packages/cli/src/commands/docs.ts
@@ -14,6 +14,7 @@
import { resolve as resolvePath, basename, dirname } from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { log } from "../lib/log.js";
+import { unknownFlagMessage } from "../lib/strict-args.js";
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
import { collectionLoadOptions } from "../lib/collection-load-options.js";
import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk";
@@ -125,6 +126,12 @@ function parseLayout(v: string | undefined, flag: string): DocsLayout {
return v;
}
+/** The flags `parseDocsArgs` below accepts, for the unknown-flag refusal. */
+const DOCS_FLAGS: readonly string[] = [
+ "--out, -o", "--layout", "--model", "--api", "--requirements", "--agent", "--metamodel",
+ "--site", "--scaffold-site", "--base-url", "--templates", "--prompts",
+];
+
function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
let projectRoot: string | undefined;
let out: string | undefined;
@@ -190,7 +197,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
} else if (a.startsWith("--prompts=")) {
prompts = a.slice("--prompts=".length);
} else if (a.startsWith("-")) {
- throw new Error(`unknown flag: ${a}`);
+ throw new Error(unknownFlagMessage("docs", a, DOCS_FLAGS));
} else if (projectRoot === undefined) {
projectRoot = a;
} else {
@@ -528,7 +535,7 @@ export async function docsCommand(
});
} catch (err) {
reportLoadError(log, "docs: failed to load metadata", err);
- return 2;
+ return 1;
}
// Build the same GenContext the codegen runner builds for docsFile(). The
diff --git a/server/typescript/packages/cli/src/commands/eject.ts b/server/typescript/packages/cli/src/commands/eject.ts
index 481ee5b92..65cf485df 100644
--- a/server/typescript/packages/cli/src/commands/eject.ts
+++ b/server/typescript/packages/cli/src/commands/eject.ts
@@ -323,37 +323,82 @@ export async function runtimeCopyLines(cwd: string): Promise {
* gate green. Marking each owned copy `identical` / `differs` makes it a one-command
* answer instead of a diff nobody thinks to run.
*/
-async function listOutput(cwd: string): Promise {
- const lines: string[] = [];
- lines.push("Ejectable generators (copy any of these into codegen/generators/ and own it):");
- lines.push("");
- let anyStale = false;
+/** One reference generator as `--list` reports it: is a copy owned, and how far it moved. */
+interface GeneratorListRow {
+ name: string;
+ package: string;
+ /** `available` = not ejected; `owned` = ejected but the reference could not be read. */
+ status: "available" | "owned" | "identical" | "reformatted" | "differs";
+ behind: number;
+ own: number;
+}
+
+/** One `SOURCES` entry and the rows walked for it — the grouping the text list prints. */
+interface GeneratorListGroup {
+ packageName: string;
+ rows: GeneratorListRow[];
+}
+
+async function generatorListGroups(cwd: string): Promise {
+ const groups: GeneratorListGroup[] = [];
for (const source of SOURCES) {
- lines.push(`${source.packageName}:`);
+ const rows: GeneratorListRow[] = [];
+ groups.push({ packageName: source.packageName, rows });
for (const name of source.names) {
+ const row: GeneratorListRow = { name, package: source.packageName, status: "available", behind: 0, own: 0 };
+ rows.push(row);
const abs = join(cwd, OWNED_GENERATORS_DIR, `${name}.ts`);
- if (!(await fileExists(abs))) {
- lines.push(` ${name}`);
- continue;
- }
+ if (!(await fileExists(abs))) continue;
let ref: string;
try {
ref = await readFile(join(source.root(), `${name}.ts`), "utf8");
} catch {
- lines.push(` ${name} [owned]`);
+ row.status = "owned";
continue;
}
- const owned = await readFile(abs, "utf8");
- const cmp = await compareOwnedCopy(owned, ref);
- if (cmp.verdict === "identical") {
- lines.push(` ${name} [owned — identical to the reference]`);
- } else if (cmp.verdict === "reformatted") {
- lines.push(` ${name} [owned — same content as the reference, your formatting]`);
+ const cmp = await compareOwnedCopy(await readFile(abs, "utf8"), ref);
+ if (cmp.verdict === "identical" || cmp.verdict === "reformatted") {
+ row.status = cmp.verdict;
} else {
- anyStale = true;
+ row.status = "differs";
+ row.behind = cmp.referenceOnly;
+ row.own = cmp.localOnly;
+ }
+ }
+ }
+ return groups;
+}
+
+/** `--list` as one structured document: the generators and the shipped libraries. */
+async function listData(cwd: string): Promise> {
+ const generators = (await generatorListGroups(cwd)).flatMap((g) => g.rows);
+ return {
+ generators,
+ libraries: ejectableLibraryNames(),
+ help: [
+ "run `meta eject ...` to copy a generator into codegen/generators/ and own it",
+ "`meta eject --list --format text` also reports owned runtime copies and ejected-library drift",
+ ],
+ };
+}
+
+async function listOutput(cwd: string): Promise {
+ const lines: string[] = [];
+ lines.push("Ejectable generators (copy any of these into codegen/generators/ and own it):");
+ lines.push("");
+ const groups = await generatorListGroups(cwd);
+ const anyStale = groups.some((g) => g.rows.some((r) => r.status === "differs"));
+ for (const group of groups) {
+ lines.push(`${group.packageName}:`);
+ for (const r of group.rows) {
+ if (r.status === "available") lines.push(` ${r.name}`);
+ else if (r.status === "owned") lines.push(` ${r.name} [owned]`);
+ else if (r.status === "identical") lines.push(` ${r.name} [owned — identical to the reference]`);
+ else if (r.status === "reformatted") lines.push(` ${r.name} [owned — same content as the reference, your formatting]`);
+ else {
lines.push(
- ` ${name} [owned — DIFFERS: ${cmp.referenceOnly} line(s) behind, ` +
- `${cmp.localOnly} line(s) of your own]`,
+ ` ${r.name} [owned — DIFFERS: ${r.behind} line(s) behind, ` +
+ `${r.own} line(s) of your own]`,
);
}
}
@@ -624,7 +669,8 @@ export async function ejectCommand(
}
if (flags.list) {
- log.info(await listOutput(cwd));
+ if (fmt === "text") log.info(await listOutput(cwd));
+ else emitStructured(await listData(cwd), fmt);
return 0;
}
diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts
index ee95f3dbd..8f60c9253 100644
--- a/server/typescript/packages/cli/src/commands/gen.ts
+++ b/server/typescript/packages/cli/src/commands/gen.ts
@@ -27,7 +27,7 @@ import {
buildCatalogListing, renderCatalogText, wiredGeneratorNames, ownedGeneratorNames,
declaredDepsOf,
} from "../lib/catalog-listing.js";
-import { emitStructured } from "../lib/format.js";
+import { emitStructured, narrate } from "../lib/format.js";
import { composeCatalog } from "../lib/catalog.js";
import { describeError } from "../lib/error-text.js";
import { findRuntimeBoundaryCrossings, runtimeBoundaryWarnings } from "../lib/runtime-boundary-advisory.js";
@@ -151,7 +151,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
metadata = await loadMemory(genCollection.configDir, loadOptions);
} catch (err) {
reportLoadError(log, "failed to load metadata", err);
- return 2;
+ return 1;
}
// ADR-0023: gen loads leniently, so an unknown attribute (`isAbstrakt: true`, a
@@ -216,7 +216,9 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
// fresh `meta init`, so calling it a problem would make every new project start with
// one. It says what to do next and disappears the moment anything is wired.
if ((forgeConfig.generators?.length ?? 0) === 0) {
- log.info(
+ // stdout only in text format: a structured run carries the same pointer in the
+ // document's `help`, and a sentence in front of the document breaks `| jq`.
+ narrate(fmt,
"\nNothing is generated until you choose it — `generators: []` is what `meta init` " +
"scaffolds, by design.\n" +
" meta gen --list --probe the catalog, with how many files each generator " +
@@ -436,7 +438,7 @@ async function listCatalogCommand(
});
} catch (err) {
reportLoadError(log, "failed to load metadata", err);
- return 2;
+ return 1;
}
opts = {
diff --git a/server/typescript/packages/cli/src/commands/generator.ts b/server/typescript/packages/cli/src/commands/generator.ts
index 808ad7776..8c4fc8999 100644
--- a/server/typescript/packages/cli/src/commands/generator.ts
+++ b/server/typescript/packages/cli/src/commands/generator.ts
@@ -13,9 +13,9 @@
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
-import { parseArgs } from "node:util";
import { catalogEntry } from "../lib/catalog.js";
import { log } from "../lib/log.js";
+import { parseCommandArgs } from "../lib/strict-args.js";
import { dependencyNotesForTemplate } from "./eject.js";
import {
CONFIG_FILE,
@@ -333,7 +333,7 @@ export async function generatorCommand(args: string[], cwd: string): Promise {
@@ -585,9 +590,10 @@ export async function init(opts: InitOptions): Promise {
}
if (exists && !opts.force && !opts.refreshDocs) {
- throw new Error(
- "metaobjects/ or .metaobjects/ already exists; use --force to overwrite scaffold files (existing records are preserved), or --refresh-docs to update only agent docs",
- );
+ if (metaobjectsExists) result.preserved.push(DEFAULT_METADATA_DIR);
+ if (agentDirExists) result.preserved.push(DEFAULT_METAOBJECTS_DIR);
+ result.alreadyInitialized = true;
+ return result;
}
const dirs = [
@@ -603,7 +609,10 @@ export async function init(opts: InitOptions): Promise {
".metaobjects/config.json",
".metaobjects/.gitignore",
);
- result.created.push(".metaobjects/AGENTS.md", ".metaobjects/CLAUDE.md", ".claude/skills/metaobjects-*", AGENT_CONTEXT_MANIFEST_PATH);
+ // The agent context forecasts itself: writeAgentContext is dry-run aware and
+ // reports exactly the paths a real run writes — the root CLAUDE.md/AGENTS.md wiring
+ // included, which a hand-kept list here used to leave out.
+ await writeAgentContext(opts, result);
result.created.push(OWNED_GENERATORS_DIR, CODEGEN_TSCONFIG_REL);
result.created.push("metaobjects.config.ts", ".gitignore");
return result;
@@ -884,9 +893,19 @@ export async function initCommand(args: string[], cwd: string): Promise
configOnly: flags.configOnly,
});
+ if (result.alreadyInitialized) {
+ log.info(
+ `meta init: already initialized (${result.preserved.join(" and ")} exist) — nothing written (no-op).\n` +
+ " meta init --refresh-docs update the agent docs after a CLI upgrade\n" +
+ " meta init --force re-scaffold the project files (your metadata is preserved)",
+ );
+ return 0;
+ }
+
if (flags.printOnly) {
log.info("Would create:");
for (const path of result.created) log.info(` ${path}`);
+ for (const w of result.warnings) log.info(` ${w}`);
return 0;
}
diff --git a/server/typescript/packages/cli/src/commands/migrate.ts b/server/typescript/packages/cli/src/commands/migrate.ts
index a83c71bf5..a4a7b6a25 100644
--- a/server/typescript/packages/cli/src/commands/migrate.ts
+++ b/server/typescript/packages/cli/src/commands/migrate.ts
@@ -5,11 +5,11 @@ import { spawn } from "node:child_process";
import { parseMigrateArgs } from "../lib/args.js";
import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
import type { ResolvedMigrateConfig } from "../lib/config.js";
-import { formatMigrateResult, formatMigrateResultToon, type BlockedEntry, type AmbiguousEntry } from "../lib/output.js";
+import { formatMigrateResult, formatMigrateResultToon, type BlockedEntry, type AmbiguousEntry, type MigrateResultShape, migrateResultToData } from "../lib/output.js";
import { formatMigrateResultJson } from "../lib/output-json.js";
import type { OutputFormat } from "../lib/format.js";
-import { toonEncode } from "../lib/format.js";
-import { buildKyselyFromUrl, redactUrl } from "../lib/kysely.js";
+import { emitStructured, narrate, toonEncode } from "../lib/format.js";
+import { buildKyselyFromUrl, inferDialect, redactUrl } from "../lib/kysely.js";
import { log } from "../lib/log.js";
import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk";
import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
@@ -135,7 +135,7 @@ MIGRATE FLAGS:
--d1 D1 binding name from wrangler.toml (only with --dialect d1)
--remote Target remote D1 instead of local (only with --dialect d1)
--yes Skip the --remote --apply confirmation pause
- --dry-run Print SQL to stdout, don't write
+ --dry-run Print the SQL, don't write (json/toon: in the document's sql field)
--help, -h Print this help
EXAMPLES:
@@ -234,10 +234,28 @@ function logOutOfScope(
fromDependencies: readonly string[],
fmt: OutputFormat,
): void {
- for (const msg of exclusionNotes("migrate", names, fromDependencies)) {
- if (fmt === "text") log.info(msg);
- else log.warn(msg);
- }
+ for (const msg of exclusionNotes("migrate", names, fromDependencies)) narrate(fmt, msg);
+}
+
+/**
+ * End a migrate path that reports in prose. Text format prints the lines on stdout as it
+ * always has. A structured format sends the same lines to STDERR and puts exactly ONE
+ * document on stdout — `doc` — so `--format json | jq` always has something to parse
+ * and never a sentence in front of it (the rule `logOutOfScope` states above).
+ */
+function finishMigrate(fmt: OutputFormat, lines: readonly string[], doc: Record): void {
+ for (const line of lines) narrate(fmt, line);
+ if (fmt !== "text") emitStructured(doc, fmt);
+}
+
+/** The result fields both the offline and the d1 pipeline report when they have none of their own. */
+function migrateResultDefaults(dryRun: boolean): Pick {
+ return { blocked: [], ambiguous: [], writtenPaths: [], dryRun };
+}
+
+/** The `-- UP -- / -- DOWN --` preview a dry run prints in text format. */
+function sqlPreview(sql: { up: string; down: string }): string {
+ return `-- UP --\n${sql.up}\n\n-- DOWN --\n${sql.down}`;
}
/**
@@ -671,7 +689,7 @@ export async function migrateCommand(
// every applied migration NEWER than (target retained), in reverse
// order, ledger-tracked + advisory-locked. postgres/sqlite only.
if (config.rollback !== undefined) {
- return await runRollback(config, metaRoot);
+ return await runRollback(config, metaRoot, fmt);
}
// Best-effort load of metaobjects.config.ts to pick up consumer-supplied
@@ -705,7 +723,7 @@ export async function migrateCommand(
});
} catch (err) {
reportLoadError(log, "failed to load metadata", err);
- return 2;
+ return 1;
}
warnReferentialActionConflicts(metadata, collection);
@@ -719,6 +737,8 @@ export async function migrateCommand(
let exitCode = 0;
let writtenPaths: string[] = [];
+ /** A dry run's SQL: printed in text format, carried in the document otherwise. */
+ let dryRunSql: { up: string; down: string } | undefined;
let appliedNames: string[] = [];
let applyFailed = false;
let blocked: BlockedEntry[] = [];
@@ -896,6 +916,11 @@ export async function migrateCommand(
if (exitCode === 0 && emitted) {
if (config.slug === undefined) {
log.error(`migrate: --slug required when there are changes (e.g., --slug add-user-shipping)`);
+ emitStructuredError(
+ "migrate: --slug required when there are changes",
+ "re-run with --slug ; for a new database whose migrations are already committed, run `meta migrate apply-pending --db `",
+ fmt,
+ );
// The common way to land here is a NEW database (a fresh clone, CI, another
// environment) whose migrations are already committed: the diff against an empty
// database is every table, so it asks to author a new migration. That database
@@ -909,7 +934,8 @@ export async function migrateCommand(
}
if (config.dryRun) {
- log.info(`-- UP --\n${emitted.up}\n\n-- DOWN --\n${emitted.down}`);
+ dryRunSql = { up: emitted.up, down: emitted.down };
+ if (fmt === "text") log.info(sqlPreview(dryRunSql));
} else {
const outDir = resolveFormatOutDir(config, metaRoot);
await mkdir(outDir, { recursive: true });
@@ -1025,6 +1051,7 @@ export async function migrateCommand(
applied: appliedNames,
applyFailed,
warnings: hazardWarnings,
+ ...(dryRunSql !== undefined ? { sql: dryRunSql } : {}),
};
const output =
fmt === "toon" ? formatMigrateResultToon(migrateResult)
@@ -1033,10 +1060,12 @@ export async function migrateCommand(
log.info(output);
if (config.apply && exitCode === 0) {
+ // The document's summary already says what was applied; this line is narration,
+ // so a structured run sends it to stderr rather than after the document.
if (appliedNames.length > 0) {
- log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
+ narrate(fmt, `migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
} else {
- log.info(`migrate: no pending migrations to apply`);
+ narrate(fmt, `migrate: no pending migrations to apply`);
}
}
return exitCode;
@@ -1151,19 +1180,23 @@ export async function runBaseline(
});
} catch (err) {
reportLoadError(log, "migrate baseline: failed to load metadata", err);
- return 2;
+ return 1;
}
const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: baselineStrategy });
snapshot = baselineFromMetadata(metadata, config.dialect, baselineStrategy, baselineViews);
}
if (config.dryRun) {
- log.info(`migrate baseline (dry-run): would write schema snapshot ${path}`);
+ finishMigrate(fmt, [`migrate baseline (dry-run): would write schema snapshot ${path}`], {
+ snapshot: path, written: [], summary: "baseline preview only (nothing written)", help: ["re-run without --dry-run to write the snapshot"],
+ });
return 0;
}
await writeSnapshot(path, snapshot);
- log.info(`migrate: wrote schema snapshot ${path}`);
+ finishMigrate(fmt, [`migrate: wrote schema snapshot ${path}`], {
+ snapshot: path, written: [path], summary: "wrote schema snapshot", help: ["commit the snapshot; later runs diff against it"],
+ });
return 0;
}
@@ -1263,8 +1296,26 @@ export async function runOfflineGenerate(
* `genRoot`. Defaults to `metaRoot`, the co-located case. */
genRoot: string = metaRoot,
): Promise {
+ // `--dialect` is optional wherever a URL is known: the offline path reads the
+ // committed snapshot rather than the database, but `--db` (or DATABASE_URL /
+ // migrate.databaseUrl) still names which dialect that snapshot is for. The help has
+ // always said "auto-detected from URL scheme"; this path used to refuse instead.
+ if (config.dialect === undefined && config.databaseUrl !== undefined) {
+ try {
+ config = { ...config, dialect: inferDialect(config.databaseUrl) };
+ } catch (err) {
+ log.error(`migrate: ${describeError(err)}`);
+ emitStructuredError(`migrate: ${describeError(err)}`, "pass --dialect sqlite|postgres|d1", fmt);
+ return 2;
+ }
+ }
if (config.dialect === undefined) {
- log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
+ log.error(`migrate: --dialect required for offline generation — pass --dialect sqlite|postgres, or --db to infer it (or use --from-db)`);
+ emitStructuredError(
+ "migrate: --dialect required for offline generation",
+ "pass --dialect sqlite|postgres, or --db to infer it from the URL scheme",
+ fmt,
+ );
return 2;
}
// Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
@@ -1290,7 +1341,7 @@ export async function runOfflineGenerate(
});
} catch (err) {
reportLoadError(log, "migrate: failed to load metadata", err);
- return 2;
+ return 1;
}
warnReferentialActionConflicts(metadata, collection);
@@ -1374,19 +1425,30 @@ export async function runOfflineGenerate(
const { diff: diffResult, nextSnapshot, expected: governedExpected } = plan;
logOutOfScope(plan.outOfScope, plan.importedOutOfScope ?? [], fmt);
+ const offlineResult = (extra: Partial): MigrateResultShape => ({
+ dialect: offlineDialect,
+ displayUrl: "",
+ changeCounts: summarizeChanges(diffResult.changes),
+ ...migrateResultDefaults(config.dryRun),
+ format: config.format,
+ ...extra,
+ });
if (diffResult.blocked.length > 0) {
- logBlocked(blockedEntriesFor(diffResult.blocked, diffResult.changes));
+ const blocked = blockedEntriesFor(diffResult.blocked, diffResult.changes);
+ logBlocked(blocked);
+ if (fmt !== "text") emitStructured(migrateResultToData(offlineResult({ blocked })), fmt);
return 1;
}
if (diffResult.changes.length === 0) {
- log.info(`migrate: no changes`);
+ finishMigrate(fmt, [`migrate: no changes`], migrateResultToData(offlineResult({})));
return 0;
}
if (config.slug === undefined) {
log.error(`migrate: --slug required when there are changes (e.g., --slug add-user-shipping)`);
+ emitStructuredError("migrate: --slug required when there are changes", "re-run with --slug ", fmt);
return 2;
}
- warnDataHazards(diffResult.hazards);
+ const offlineWarnings = warnDataHazards(diffResult.hazards);
const emitResult = emit(diffResult.changes, {
dialect: config.dialect,
@@ -1396,7 +1458,8 @@ export async function runOfflineGenerate(
});
if (config.dryRun) {
- log.info(`-- UP --\n${emitResult.up}\n\n-- DOWN --\n${emitResult.down}`);
+ const sql = { up: emitResult.up, down: emitResult.down };
+ finishMigrate(fmt, [sqlPreview(sql)], migrateResultToData(offlineResult({ sql, warnings: offlineWarnings })));
return 0;
}
@@ -1417,8 +1480,11 @@ export async function runOfflineGenerate(
{ dir: writeDir, slug: config.slug },
);
await writeSnapshot(path, nextSnapshot);
- log.info(`migrate: wrote ${res.upPath}`);
- log.info(`migrate: wrote ${res.downPath}`);
+ finishMigrate(
+ fmt,
+ [`migrate: wrote ${res.upPath}`, `migrate: wrote ${res.downPath}`],
+ migrateResultToData(offlineResult({ writtenPaths: [res.upPath, res.downPath], warnings: offlineWarnings })),
+ );
return 0;
}
@@ -1432,6 +1498,7 @@ export async function runOfflineGenerate(
async function runRollback(
config: ResolvedMigrateConfig,
metaRoot: string,
+ fmt: OutputFormat,
): Promise {
// databaseUrl is guaranteed defined by the caller's guard above.
const databaseUrl = config.databaseUrl as string;
@@ -1459,11 +1526,10 @@ async function runRollback(
try {
const result = await rollbackTo(kysely.db, outDir, target, { dialect });
- if (result.rolledBack.length > 0) {
- log.info(`migrate: rolled back ${result.rolledBack.length} migration(s): ${result.rolledBack.join(", ")}`);
- } else {
- log.info(`migrate: nothing to roll back${target ? ` newer than '${target}'` : ""}`);
- }
+ const summary = result.rolledBack.length > 0
+ ? `rolled back ${result.rolledBack.length} migration(s): ${result.rolledBack.join(", ")}`
+ : `nothing to roll back${target ? ` newer than '${target}'` : ""}`;
+ finishMigrate(fmt, [`migrate: ${summary}`], { rolledBack: result.rolledBack, summary });
return 0;
} catch (err) {
log.error(`migrate: rollback failed: ${describeError(err)}`);
@@ -1560,7 +1626,7 @@ async function runD1Migrate(
});
} catch (err) {
reportLoadError(log, "migrate: failed to load metadata", err);
- return 2;
+ return 1;
}
warnReferentialActionConflicts(metadata, collection);
@@ -1641,13 +1707,23 @@ async function runD1Migrate(
// BEGIN/COMMIT + PRAGMA that recreate-and-copy emits). There is no separate
// view-migration emitter; introspectD1 now reads view bodies so unchanged views
// produce no change and body changes emit a DROP+CREATE.
+ const d1Result = (extra: Partial): MigrateResultShape => ({
+ dialect: "d1",
+ displayUrl: binding.binding,
+ changeCounts,
+ // No `format` key here on purpose: every d1 output this build ships was produced
+ // without it; adding it would change bytes the tests pin.
+ ...migrateResultDefaults(config.dryRun),
+ ...extra,
+ });
if (diffResult.changes.length === 0) {
- log.info(`migrate: no schema changes for d1 binding '${binding.binding}'`);
+ finishMigrate(fmt, [`migrate: no schema changes for d1 binding '${binding.binding}'`], migrateResultToData(d1Result({})));
return 0;
}
if (config.slug === undefined) {
log.error(`migrate: --slug required when there are changes`);
+ emitStructuredError("migrate: --slug required when there are changes", "re-run with --slug ", fmt);
return 2;
}
@@ -1671,7 +1747,8 @@ async function runD1Migrate(
const migrationsDir = resolveD1OutDir(config, metaRoot, binding.migrations_dir);
if (config.dryRun) {
- log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
+ const sql = { up: combinedUp, down: combinedDown };
+ finishMigrate(fmt, [sqlPreview(sql)], migrateResultToData(d1Result({ sql })));
return 0;
}
@@ -1679,11 +1756,15 @@ async function runD1Migrate(
{ up: combinedUp, down: combinedDown },
{ dir: migrationsDir, slug: config.slug },
);
- log.info(`migrate: wrote ${writeResult.upPath}`);
- log.info(`migrate: wrote ${writeResult.downPath}`);
- for (const [kind, count] of Object.entries(changeCounts)) {
- log.info(` ${kind}: ${count}`);
- }
+ finishMigrate(
+ fmt,
+ [
+ `migrate: wrote ${writeResult.upPath}`,
+ `migrate: wrote ${writeResult.downPath}`,
+ ...Object.entries(changeCounts).map(([kind, count]) => ` ${kind}: ${count}`),
+ ],
+ migrateResultToData(d1Result({ writtenPaths: [writeResult.upPath, writeResult.downPath] })),
+ );
// 7. Optional --apply: run `wrangler d1 migrations apply`.
if (config.d1.autoApply) {
@@ -1707,7 +1788,8 @@ async function runWranglerApply(
yes: boolean,
): Promise {
if (remote && !yes) {
- log.info(
+ // Progress, not output: stderr, so it never lands in front of a document on stdout.
+ log.warn(
`Applying to remote D1 '${databaseName}' (binding=${bindingName}) in 2s — Ctrl+C to abort or pass --yes to skip this pause.`,
);
await new Promise((r) => setTimeout(r, 2000));
diff --git a/server/typescript/packages/cli/src/commands/types.ts b/server/typescript/packages/cli/src/commands/types.ts
index edea7d6e3..36f4a1664 100644
--- a/server/typescript/packages/cli/src/commands/types.ts
+++ b/server/typescript/packages/cli/src/commands/types.ts
@@ -25,6 +25,7 @@ import { defaultLoadMemoryProviders } from "@metaobjectsdev/sdk";
import { log } from "../lib/log.js";
import { emitStructured, type OutputFormat } from "../lib/format.js";
import { describeError } from "../lib/error-text.js";
+import { unknownFlagMessage } from "../lib/strict-args.js";
interface TypesFlags {
query: string | null;
@@ -73,6 +74,21 @@ three TEXT display controls — do not change it. The terse line's [base] / [ts-
markers are the sharedRoot / tsOnly fields there, a closed-enum attr carries its
allowedValues, and no match is an empty matches list rather than a prose hint.`;
+/** The flags `parse` below accepts, for the unknown-flag refusal. */
+const TYPES_FLAGS: readonly string[] = ["--all", "--desc", "--detail", "--no-headers", "--limit", "--type", "--kind"];
+
+/**
+ * `--limit `: a non-negative integer, 0 meaning unlimited. Anything else is refused
+ * (exit 2) rather than read as a number: `Number("abc") || 0` used to turn a typo into
+ * `0`, the UNLIMITED sentinel, so `--limit abc` silently printed all 500-odd rows.
+ */
+function parseTypesLimit(raw: string | undefined): number {
+ if (raw === undefined || !/^\d+$/.test(raw)) {
+ throw new Error(`invalid --limit '${raw ?? ""}'; expected a non-negative integer (0 = unlimited)`);
+ }
+ return Number(raw);
+}
+
function parse(args: string[]): TypesFlags {
const f: TypesFlags = {
query: null, desc: false, kind: new Set(), type: null,
@@ -91,12 +107,12 @@ function parse(args: string[]): TypesFlags {
// one the removed `--json` broke.
else if (a === "--format") i++;
else if (a.startsWith("--format=")) { /* value is inline; nothing to consume */ }
- else if (a === "--limit") { f.limit = Math.max(0, Number(args[++i] ?? "20") || 0); f.limitExplicit = true; }
+ else if (a === "--limit") { f.limit = parseTypesLimit(args[++i]); f.limitExplicit = true; }
else if (a === "--type") f.type = (args[++i] ?? "").toLowerCase() || null;
else if (a === "--kind") {
for (const k of (args[++i] ?? "").split(","))
if (k === "type" || k === "subtype" || k === "attr") f.kind.add(k);
- } else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
+ } else if (a.startsWith("-")) throw new Error(unknownFlagMessage("types", a, TYPES_FLAGS));
else if (f.query === null) f.query = a;
else throw new Error(`unexpected argument: ${a}`);
}
diff --git a/server/typescript/packages/cli/src/commands/upgrade.ts b/server/typescript/packages/cli/src/commands/upgrade.ts
index 7cb2bef95..6b6a3c261 100644
--- a/server/typescript/packages/cli/src/commands/upgrade.ts
+++ b/server/typescript/packages/cli/src/commands/upgrade.ts
@@ -30,6 +30,7 @@ import {
} from "@metaobjectsdev/metadata";
import { log } from "../lib/log.js";
import { describeError } from "../lib/error-text.js";
+import { unknownFlagMessage } from "../lib/strict-args.js";
/** YAML authoring (ADR-0006). Rewritten by the `yaml`-backed arm, loaded on demand below. */
const YAML_EXTENSIONS = new Set([".yaml", ".yml"]);
@@ -40,6 +41,9 @@ interface UpgradeFlags {
projectRoot?: string;
}
+/** The flags `parseArgs` below accepts, for the unknown-flag refusal. */
+const UPGRADE_FLAGS: readonly string[] = ["--apply", "--to"];
+
function parseArgs(argv: string[]): UpgradeFlags {
const flags: UpgradeFlags = { apply: false };
for (let i = 0; i < argv.length; i++) {
@@ -52,7 +56,7 @@ function parseArgs(argv: string[]): UpgradeFlags {
} else if (a.startsWith("--to=")) flags.maxVersion = a.slice("--to=".length);
else if (a === "--help" || a === "-h") throw new Error("__help__");
else if (!a.startsWith("-")) flags.projectRoot = a;
- else throw new Error(`unknown option: ${a}`);
+ else throw new Error(unknownFlagMessage("upgrade", a, UPGRADE_FLAGS));
}
return flags;
}
diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts
index c3c2cdee7..92404b10f 100644
--- a/server/typescript/packages/cli/src/commands/verify.ts
+++ b/server/typescript/packages/cli/src/commands/verify.ts
@@ -379,6 +379,9 @@ export async function verifyCommand(
// subverb so ANY kind of drift fails CI. Each gate only runs when its mode is
// selected; an unselected gate contributes 0.
const templateExit = runTemplates ? runTemplateVerify() : 0;
+ // Set when the schema gate could not reach or read the database: a failure, but not
+ // drift, and the payload must not report it as drift.
+ let schemaRunError: string | undefined;
const schemaExit = await runSchemaVerify();
const codegenExit = runCodegen ? await runCodegenVerify() : 0;
const docsExit = runDocs ? await runDocsVerify() : 0;
@@ -464,6 +467,7 @@ export async function verifyCommand(
names: nameSection,
deprecations: deprecationSection,
fields: fieldSection,
+ errors: schemaRunError !== undefined ? [{ gate: "schema", error: schemaRunError }] : [],
}),
fmt,
);
@@ -1269,7 +1273,8 @@ export async function verifyCommand(
try {
kysely = await buildKyselyFromUrl(flags.db as string, flags.dialect as Dialect | undefined);
} catch (err) {
- log.error(`verify: ${describeError(err)}`);
+ schemaRunError = describeError(err);
+ log.error(`verify: ${schemaRunError}`);
return 1;
}
@@ -1295,7 +1300,8 @@ export async function verifyCommand(
...importedOption(collection),
});
} catch (err) {
- log.error(`verify: failed to introspect ${kysely.displayUrl}: ${describeError(err)}`);
+ schemaRunError = `failed to introspect ${kysely.displayUrl}: ${describeError(err)}`;
+ log.error(`verify: ${schemaRunError}`);
return 1;
}
@@ -1561,7 +1567,7 @@ export async function verifyCommand(
// and the loader's own remedies. It printed a bare message plus a hand-rolled
// suggestions read — the half-true rule this file's sibling comment warns about.
reportLoadError(log, "verify --codegen: failed to load this package's metadata", err);
- return 2;
+ return 1;
}
}
@@ -1935,14 +1941,23 @@ function buildVerifyPayload(input: {
names: AdvisorySection;
deprecations: AdvisorySection;
fields: AdvisorySection;
+ /** Gates that could not run at all (an unreachable database) — failures, not drift. */
+ errors?: readonly { gate: string; error: string }[];
}): Record {
const ran = input.gates.filter((g) => g.ran);
const failed = ran.filter((g) => !g.ok);
- const parts: string[] = [
- failed.length === 0
- ? `${ran.length} gate(s) ran, all clean`
- : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
- ];
+ const errors = input.errors ?? [];
+ const drifted = failed.filter((g) => !errors.some((e) => e.gate === g.gate));
+ const parts: string[] = failed.length === 0
+ ? [`${ran.length} gate(s) ran, all clean`]
+ : [
+ ...(drifted.length > 0
+ ? [`${drifted.length} of ${ran.length} gate(s) failed (${drifted.map((g) => g.gate).join(", ")})`]
+ : []),
+ ...(errors.length > 0
+ ? [`${errors.length} gate(s) could not run (${errors.map((e) => e.gate).join(", ")})`]
+ : []),
+ ];
if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
// "advisory", not "anti-pattern": the section also carries unindexed foreign keys, a
// missing baseUrl and the other non-scanner rows — `help` breaks the count down by kind.
@@ -1964,8 +1979,9 @@ function buildVerifyPayload(input: {
parts.push(`${input.fields.total} field authoring finding(s)`);
}
- const help: string[] = [];
- if (failed.length > 0) {
+ const help: string[] = errors.map((e) =>
+ `the ${e.gate} gate could not run, which is not drift: ${e.error} — fix the connection and re-run`);
+ if (drifted.length > 0) {
help.push(
`the failing gate's drift DETAIL is printed as text on stderr — this payload carries the verdict only`,
);
@@ -2007,6 +2023,7 @@ function buildVerifyPayload(input: {
return {
verify: input.gates,
+ ...(errors.length > 0 ? { errors } : {}),
exitCode: input.exitCode,
summary: parts.join("; "),
help,
diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts
index 7bc7f6517..9cf7ef350 100644
--- a/server/typescript/packages/cli/src/index.ts
+++ b/server/typescript/packages/cli/src/index.ts
@@ -1,6 +1,6 @@
import { resolve } from "node:path";
import { log } from "./lib/log.js";
-import { cliVersion } from "./lib/version.js";
+import { cliVersion, isVersionFlag } from "./lib/version.js";
import { resolveFormat, isValidFormat, VALID_FORMATS, type OutputFormat } from "./lib/format.js";
import { resolveCollection } from "@metaobjectsdev/sdk";
import { GENERATOR_HELP } from "./commands/generator-help.js";
@@ -47,7 +47,7 @@ COMMANDS:
upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
prompt-snapshot Snapshot rendered template.* output; --check gates drift
migrate Diff metadata vs live DB; emit migration SQL files
- --version, -v Print version
+ --version, -v, -V Print version
--help, -h Print this help
GLOBAL OPTIONS:
@@ -178,9 +178,9 @@ MIGRATE FLAGS:
--remote Target remote D1 instead of local (only with --dialect d1)
--apply Run 'wrangler d1 migrations apply' after writing files
--yes Skip the --remote --apply confirmation pause
- --dry-run Print SQL to stdout, don't write
+ --dry-run Print the SQL, don't write (json/toon: in the document's sql field)
-See https://metaobjects.com for docs.
+See https://metaobjects.dev for docs.
`;
/** Focused per-subcommand usage slices shown by ` --help`. */
@@ -616,7 +616,7 @@ export async function run(argv: string[]): Promise {
}
// Intercept per-subcommand --help / -h before dispatching (mirrors migrate's own pattern).
- if (cmd !== undefined && cmd !== "--help" && cmd !== "-h" && cmd !== "--version" && cmd !== "-v") {
+ if (cmd !== undefined && cmd !== "--help" && cmd !== "-h" && !isVersionFlag(cmd)) {
if (rest.includes("--help") || rest.includes("-h")) {
const helpText = COMMAND_HELP[cmd];
if (helpText !== undefined) {
@@ -663,6 +663,7 @@ export async function run(argv: string[]): Promise {
return 0;
case "--version":
case "-v":
+ case "-V":
log.info(VERSION);
return 0;
case "init": {
diff --git a/server/typescript/packages/cli/src/lib/args.ts b/server/typescript/packages/cli/src/lib/args.ts
index 5b33f547c..b0815b662 100644
--- a/server/typescript/packages/cli/src/lib/args.ts
+++ b/server/typescript/packages/cli/src/lib/args.ts
@@ -1,4 +1,4 @@
-import { parseArgs } from "node:util";
+import { parseCommandArgs } from "./strict-args.js";
import type { DeclaredRename } from "@metaobjectsdev/migrate-ts";
import { parseAdvisoryLimit } from "./advisory.js";
@@ -36,7 +36,7 @@ export const INIT_OPTIONS = {
} as const;
export function parseInitArgs(argv: string[]): InitFlags {
- const { values } = parseArgs({
+ const { values } = parseCommandArgs("init", {
args: argv,
options: INIT_OPTIONS,
strict: true,
@@ -79,7 +79,7 @@ export const AGENT_DOCS_OPTIONS = {
} as const;
export function parseAgentDocsArgs(argv: string[]): AgentDocsFlags {
- const { values } = parseArgs({
+ const { values } = parseCommandArgs("agent-docs", {
args: argv,
options: AGENT_DOCS_OPTIONS,
strict: true,
@@ -135,7 +135,7 @@ export const GEN_OPTIONS = {
} as const;
export function parseGenArgs(argv: string[]): GenFlags {
- const { values, positionals } = parseArgs({
+ const { values, positionals } = parseCommandArgs("gen", {
args: argv,
options: GEN_OPTIONS,
strict: true,
@@ -179,7 +179,7 @@ export const EXPORT_OPTIONS = {
} as const;
export function parseExportArgs(argv: string[]): ExportFlags {
- const { values } = parseArgs({
+ const { values } = parseCommandArgs("export", {
args: argv,
options: EXPORT_OPTIONS,
strict: true,
@@ -207,7 +207,7 @@ export const FMT_OPTIONS = {
} as const;
export function parseFmtArgs(argv: string[]): FmtFlags {
- const { values, positionals } = parseArgs({
+ const { values, positionals } = parseCommandArgs("fmt", {
args: argv,
options: FMT_OPTIONS,
strict: true,
@@ -490,7 +490,7 @@ function parseAllowTokens(raw: string | string[] | undefined): AllowToken[] {
}
export function parseVerifyArgs(argv: string[]): VerifyFlags {
- const { values } = parseArgs({
+ const { values } = parseCommandArgs("verify", {
args: argv,
options: VERIFY_OPTIONS,
strict: true,
@@ -570,7 +570,7 @@ export const PROMPT_SNAPSHOT_OPTIONS = {
} as const;
export function parsePromptSnapshotArgs(argv: string[]): PromptSnapshotFlags {
- const { values } = parseArgs({
+ const { values } = parseCommandArgs("prompt-snapshot", {
args: argv,
options: PROMPT_SNAPSHOT_OPTIONS,
strict: true,
@@ -641,7 +641,7 @@ export const MIGRATE_OPTIONS = {
} as const;
export function parseMigrateArgs(argv: string[]): MigrateFlags {
- const { values, positionals } = parseArgs({
+ const { values, positionals } = parseCommandArgs("migrate", {
args: argv,
options: MIGRATE_OPTIONS,
strict: true,
@@ -758,7 +758,7 @@ export const EJECT_OPTIONS = {
} as const;
export function parseEjectArgs(argv: string[]): EjectFlags {
- const { values, positionals } = parseArgs({
+ const { values, positionals } = parseCommandArgs("eject", {
args: argv,
options: EJECT_OPTIONS,
strict: true,
@@ -820,7 +820,7 @@ export const DEPS_OPTIONS = {
} as const;
export function parseDepsArgs(argv: string[]): DepsFlags {
- const { values, positionals } = parseArgs({
+ const { values, positionals } = parseCommandArgs("deps", {
args: argv,
options: DEPS_OPTIONS,
strict: true,
diff --git a/server/typescript/packages/cli/src/lib/format.ts b/server/typescript/packages/cli/src/lib/format.ts
index 41749ea3f..dd3a7e8f9 100644
--- a/server/typescript/packages/cli/src/lib/format.ts
+++ b/server/typescript/packages/cli/src/lib/format.ts
@@ -1,4 +1,5 @@
import { encode } from "@toon-format/toon";
+import { log } from "./log.js";
export type OutputFormat = "toon" | "json" | "text";
@@ -35,3 +36,14 @@ export function emitStructured(payload: unknown, fmt: OutputFormat): void {
if (fmt === "json") console.log(JSON.stringify(payload, null, 2));
else if (fmt === "toon") console.log(toonEncode(payload));
}
+
+/**
+ * One narration line, on the other side of the split {@link emitStructured} states:
+ * stdout in text format, stderr in a structured run, so a document never has a
+ * sentence in front of it. Every command that prints prose alongside a document
+ * routes it through here.
+ */
+export function narrate(fmt: OutputFormat, line: string): void {
+ if (fmt === "text") log.info(line);
+ else log.warn(line);
+}
diff --git a/server/typescript/packages/cli/src/lib/output.ts b/server/typescript/packages/cli/src/lib/output.ts
index 36c6aa3f6..cbc41fddb 100644
--- a/server/typescript/packages/cli/src/lib/output.ts
+++ b/server/typescript/packages/cli/src/lib/output.ts
@@ -203,6 +203,8 @@ export interface MigrateResultShape {
* caller sees the risk before applying.
*/
warnings?: string[];
+ /** A dry run's SQL. Text format prints it as a preview; JSON/toon carry it here. */
+ sql?: { up: string; down: string };
}
export function formatMigrateResult(result: MigrateResultShape, _opts: FormatOptions): string {
@@ -329,6 +331,7 @@ export function migrateResultToData(result: MigrateResultShape): {
summary: string;
help: string[];
warnings?: string[];
+ sql?: { up: string; down: string };
} {
const changeEntries = Object.entries(result.changeCounts).filter(([, v]) => v > 0);
const changes = changeEntries.map(([kind, count]) => ({ kind, count }));
@@ -395,6 +398,7 @@ export function migrateResultToData(result: MigrateResultShape): {
changes, written: result.writtenPaths, summary, help,
// Only when present, so a run with no hazard keeps its existing shape.
...(warnings.length > 0 ? { warnings } : {}),
+ ...(result.sql !== undefined ? { sql: result.sql } : {}),
};
}
diff --git a/server/typescript/packages/cli/src/lib/strict-args.ts b/server/typescript/packages/cli/src/lib/strict-args.ts
new file mode 100644
index 000000000..0a0f33797
--- /dev/null
+++ b/server/typescript/packages/cli/src/lib/strict-args.ts
@@ -0,0 +1,52 @@
+import { parseArgs, type ParseArgsConfig } from "node:util";
+
+/**
+ * The flags every command accepts beyond its own table. `--cwd` and `--format` are
+ * stripped by the dispatcher before a command parses anything, and `--help` is
+ * answered there too, so a command's own table never lists them — but an agent told
+ * "these are the valid flags" must not then be refused for using one of them.
+ */
+export const GLOBAL_FLAGS: readonly string[] = ["--help", "--cwd", "--format"];
+
+/**
+ * The one wording of an unknown-flag refusal, for every command.
+ *
+ * It names the command and lists that command's valid flags inline, so the refusal
+ * corrects itself in one step: the caller's deterministic next move after "unknown
+ * option" is `meta --help`, and this folds that lookup into the error. It
+ * used to come in three spellings (`Unknown option '--x'. To specify a positional
+ * argument starting with a '-', place it at the end of the command after '--' …`,
+ * `unknown flag: --x`, `unknown option: --x`), the first being Node's own parser text,
+ * and none of them said which flags would have worked.
+ */
+export function unknownFlagMessage(command: string, flag: string, valid: readonly string[]): string {
+ return `unknown flag ${flag} for \`meta ${command}\`. Valid flags: ${valid.join(", ")} ` +
+ `(also accepted everywhere: ${GLOBAL_FLAGS.join(", ")})`;
+}
+
+/** The `--long` (and `-s`) spellings an option table accepts, in table order. */
+export function flagNames(options: ParseArgsConfig["options"]): string[] {
+ const names: string[] = [];
+ for (const [long, opt] of Object.entries(options ?? {})) {
+ if (long === "help") continue; // a global flag — listed once, in GLOBAL_FLAGS
+ names.push(opt.short !== undefined ? `--${long}, -${opt.short}` : `--${long}`);
+ }
+ return names;
+}
+
+/**
+ * `node:util` `parseArgs` with the unknown-flag refusal translated into
+ * {@link unknownFlagMessage}. Every other parse error passes through unchanged.
+ */
+export function parseCommandArgs(
+ command: string,
+ config: T,
+): ReturnType> {
+ try {
+ return parseArgs(config);
+ } catch (err) {
+ if ((err as { code?: unknown }).code !== "ERR_PARSE_ARGS_UNKNOWN_OPTION") throw err;
+ const flag = /Unknown option '([^']+)'/.exec((err as Error).message)?.[1] ?? "(unrecognized)";
+ throw new Error(unknownFlagMessage(command, flag, flagNames(config.options)));
+ }
+}
diff --git a/server/typescript/packages/cli/src/lib/version.ts b/server/typescript/packages/cli/src/lib/version.ts
index b9fcf7b01..06baa6ad7 100644
--- a/server/typescript/packages/cli/src/lib/version.ts
+++ b/server/typescript/packages/cli/src/lib/version.ts
@@ -25,3 +25,10 @@ export function cliVersion(): string {
}
return "0.0.0";
}
+
+/** The three spellings that print the bare version: `--version`, `-v` and `-V`. */
+const VERSION_FLAGS: readonly string[] = ["--version", "-v", "-V"];
+
+export function isVersionFlag(arg: string | undefined): boolean {
+ return arg !== undefined && VERSION_FLAGS.includes(arg);
+}
diff --git a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap
index 9dd93b382..9b18bde00 100644
--- a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap
+++ b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap
@@ -27,7 +27,7 @@ COMMANDS:
upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
prompt-snapshot Snapshot rendered template.* output; --check gates drift
migrate Diff metadata vs live DB; emit migration SQL files
- --version, -v Print version
+ --version, -v, -V Print version
--help, -h Print this help
GLOBAL OPTIONS:
@@ -158,8 +158,8 @@ MIGRATE FLAGS:
--remote Target remote D1 instead of local (only with --dialect d1)
--apply Run 'wrangler d1 migrations apply' after writing files
--yes Skip the --remote --apply confirmation pause
- --dry-run Print SQL to stdout, don't write
+ --dry-run Print the SQL, don't write (json/toon: in the document's sql field)
-See https://metaobjects.com for docs.
+See https://metaobjects.dev for docs.
"
`;
diff --git a/server/typescript/packages/cli/test/advisory-structured-output.test.ts b/server/typescript/packages/cli/test/advisory-structured-output.test.ts
index e06a411c4..b10f72f22 100644
--- a/server/typescript/packages/cli/test/advisory-structured-output.test.ts
+++ b/server/typescript/packages/cli/test/advisory-structured-output.test.ts
@@ -352,7 +352,7 @@ describe("text mode caps, --limit raises it, and every site honors one constant"
const bad = await capture(["verify", "--limit", "nonsense", "--cwd", dir]);
expect(bad.exit).toBe(2);
// The MESSAGE is asserted, not only the code: an unrecognised flag also
- // exits 2 ("Unknown option '--limit'"), so a code-only assertion would pass
+ // exits 2 ("unknown flag --limit"), so a code-only assertion would pass
// just as well on a build where --limit does not exist at all.
expect(bad.err).toContain("invalid --limit 'nonsense'");
expect(bad.err).toContain("all");
diff --git a/server/typescript/packages/cli/test/agent-output-contract.test.ts b/server/typescript/packages/cli/test/agent-output-contract.test.ts
new file mode 100644
index 000000000..9b021e761
--- /dev/null
+++ b/server/typescript/packages/cli/test/agent-output-contract.test.ts
@@ -0,0 +1,269 @@
+// The output contract an agent driving `meta` over a pipe relies on.
+//
+// Each block pins a defect found by running the published CLI against a scratch project:
+// - `--format json` must put exactly one parseable document on stdout. `meta gen` on a
+// fresh project printed a prose pointer ahead of the payload, `meta migrate` printed a
+// status line after it, and `migrate --dry-run` printed raw SQL instead of it — so
+// `meta gen --format json | jq` failed outright.
+// - an unknown flag names the command and lists its valid flags, in one wording, and
+// exits 2; it used to come in three spellings, one of them Node's parser text.
+// - `types --limit abc` was read as `0`, the UNLIMITED sentinel, and printed every row.
+// - `verify --db` reported a database it could not reach as schema drift.
+// - the offline `migrate` path refused to infer the dialect its help says it infers.
+// - `-V` was refused, and `--version` loaded the whole command graph first.
+
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { run } from "../src/index.js";
+import { cliVersion } from "../src/lib/version.js";
+
+// Commands lazily import heavy modules (migrate-ts, codegen) on first dispatch.
+const TIMEOUT_MS = 60_000;
+
+const SHOP_JSON = JSON.stringify({
+ "metadata.root": {
+ package: "shop",
+ children: [
+ {
+ "object.entity": {
+ name: "Customer",
+ children: [
+ { "source.rdb": { "@table": "customers" } },
+ { "field.long": { name: "id" } },
+ { "field.string": { name: "email", "@maxLength": 200 } },
+ { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
+ ],
+ },
+ },
+ ],
+ },
+});
+
+interface Captured {
+ exit: number;
+ out: string;
+ err: string;
+}
+
+async function capture(args: string[]): Promise {
+ const out: string[] = [];
+ const err: string[] = [];
+ const origLog = console.log;
+ const origError = console.error;
+ console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); };
+ console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); };
+ try {
+ const exit = await run(args);
+ return { exit, out: out.join("\n"), err: err.join("\n") };
+ } finally {
+ console.log = origLog;
+ console.error = origError;
+ }
+}
+
+let dir: string;
+
+beforeAll(async () => {
+ dir = mkdtempSync(join(tmpdir(), "meta-agent-contract-"));
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "app", type: "module", private: true }));
+ expect(await run(["init", "--quiet", "--cwd", dir])).toBe(0);
+ writeFileSync(join(dir, "metaobjects", "meta.shop.json"), SHOP_JSON);
+}, TIMEOUT_MS);
+
+afterAll(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("--format json puts exactly one parseable document on stdout", () => {
+ // Order matters: the from-db apply writes the snapshot the later offline runs diff against.
+ const db = () => `file:${join(dir, "dev.sqlite")}`;
+ const cases: Array<{ name: string; args: () => string[]; check?: (doc: Record, r: Captured) => void }> = [
+ { name: "gen with no generators wired", args: () => ["gen"] },
+ { name: "gen --dry-run", args: () => ["gen", "--dry-run"] },
+ { name: "gen --list", args: () => ["gen", "--list"] },
+ { name: "verify", args: () => ["verify"] },
+ { name: "verify --codegen", args: () => ["verify", "--codegen"] },
+ { name: "types (no match)", args: () => ["types", "zzz-no-such-construct"] },
+ { name: "types (match)", args: () => ["types", "field.string"] },
+ { name: "deps list", args: () => ["deps", "list"] },
+ { name: "eject --list", args: () => ["eject", "--list"] },
+ { name: "migrate offline, no snapshot yet (error document)", args: () => ["migrate", "--dialect", "sqlite", "--dry-run"] },
+ { name: "migrate baseline --dry-run", args: () => ["migrate", "baseline", "--dialect", "sqlite", "--dry-run"] },
+ {
+ name: "migrate --from-db --dry-run carries the SQL in the document",
+ args: () => ["migrate", "--from-db", "--db", db(), "--slug", "init", "--dry-run"],
+ check: (doc) => {
+ const sql = doc.sql as { up: string; down: string };
+ expect(sql.up).toMatch(/CREATE TABLE/i);
+ expect(sql.down).toMatch(/DROP TABLE/i);
+ },
+ },
+ {
+ name: "migrate --from-db --apply",
+ args: () => ["migrate", "--from-db", "--db", db(), "--slug", "init", "--apply"],
+ check: (doc, r) => {
+ expect(doc.summary).toContain("applied 1 migration(s)");
+ expect(r.err).toContain("migrate: applied 1 migration(s)");
+ },
+ },
+ {
+ name: "migrate --from-db --apply again (no-op)",
+ args: () => ["migrate", "--from-db", "--db", db(), "--slug", "init", "--apply"],
+ check: (doc) => expect(doc.summary).toBe("no schema changes"),
+ },
+ {
+ name: "migrate offline with no changes",
+ args: () => ["migrate", "--dialect", "sqlite", "--slug", "x", "--dry-run"],
+ check: (doc) => expect(doc.summary).toBe("no schema changes"),
+ },
+ {
+ name: "verify --db against a database it cannot open",
+ args: () => ["verify", "--db", "file:/nonexistent-meta-dir/x.sqlite"],
+ check: (doc) => {
+ expect(doc.errors).toEqual([{ gate: "schema", error: expect.any(String) }]);
+ expect(doc.summary).toContain("1 gate(s) could not run (schema)");
+ expect(doc.summary).not.toContain("failed (schema)");
+ expect((doc.help as string[]).join("\n")).toContain("not drift");
+ },
+ },
+ ];
+
+ for (const c of cases) {
+ test(c.name, async () => {
+ const r = await capture([...c.args(), "--format", "json", "--cwd", dir]);
+ let doc: Record;
+ try {
+ doc = JSON.parse(r.out) as Record;
+ } catch {
+ throw new Error(`stdout is not one JSON document (exit ${r.exit}):\n${r.out}`);
+ }
+ c.check?.(doc, r);
+ }, TIMEOUT_MS);
+ }
+
+ test("the offline migrate path infers the dialect from --db, as its help says", async () => {
+ const r = await capture(["migrate", "--db", db(), "--slug", "x", "--dry-run", "--format", "json", "--cwd", dir]);
+ expect(r.exit).toBe(0);
+ expect(r.err).not.toContain("--dialect required");
+ expect((JSON.parse(r.out) as { summary: string }).summary).toBe("no schema changes");
+ }, TIMEOUT_MS);
+
+ test("text format still prints the dry-run SQL preview", async () => {
+ writeFileSync(
+ join(dir, "metaobjects", "meta.shop.json"),
+ SHOP_JSON.replace('{"field.string":{"name":"email"', '{"field.string":{"name":"phone"}},{"field.string":{"name":"email"'),
+ );
+ const r = await capture(["migrate", "--dialect", "sqlite", "--slug", "add-phone", "--dry-run", "--format", "text", "--cwd", dir]);
+ expect(r.exit).toBe(0);
+ expect(r.out).toContain("-- UP --");
+ expect(r.out).toMatch(/ADD COLUMN "phone"/);
+ writeFileSync(join(dir, "metaobjects", "meta.shop.json"), SHOP_JSON);
+ }, TIMEOUT_MS);
+});
+
+describe("an unknown flag names the command and lists its valid flags (exit 2)", () => {
+ const commands: Array<{ argv: string[]; command: string; aValidFlag: string }> = [
+ { argv: ["init"], command: "init", aValidFlag: "--force" },
+ { argv: ["agent-docs"], command: "agent-docs", aValidFlag: "--server" },
+ { argv: ["gen"], command: "gen", aValidFlag: "--dry-run" },
+ { argv: ["verify"], command: "verify", aValidFlag: "--codegen" },
+ { argv: ["migrate"], command: "migrate", aValidFlag: "--slug" },
+ { argv: ["export"], command: "export", aValidFlag: "--out" },
+ { argv: ["fmt"], command: "fmt", aValidFlag: "--check" },
+ { argv: ["eject"], command: "eject", aValidFlag: "--list" },
+ { argv: ["deps", "sync"], command: "deps", aValidFlag: "--dry-run" },
+ { argv: ["prompt-snapshot"], command: "prompt-snapshot", aValidFlag: "--check" },
+ { argv: ["generator", "new", "x"], command: "generator", aValidFlag: "--scope" },
+ { argv: ["types"], command: "types", aValidFlag: "--limit" },
+ { argv: ["docs"], command: "docs", aValidFlag: "--out, -o" },
+ { argv: ["upgrade"], command: "upgrade", aValidFlag: "--apply" },
+ ];
+ for (const c of commands) {
+ test(`meta ${c.argv.join(" ")} --bogus`, async () => {
+ const r = await capture([...c.argv, "--bogus", "--cwd", dir]);
+ expect(r.exit).toBe(2);
+ const all = `${r.out}\n${r.err}`;
+ expect(all).toContain(`unknown flag --bogus for \`meta ${c.command}\`. Valid flags: `);
+ expect(all).toContain(c.aValidFlag);
+ // Node's own parser wording must not leak through.
+ expect(all).not.toContain("To specify a positional argument");
+ }, TIMEOUT_MS);
+ }
+});
+
+describe("numeric flags refuse non-numbers (exit 2)", () => {
+ test("types --limit abc", async () => {
+ const r = await capture(["types", "--limit", "abc"]);
+ expect(r.exit).toBe(2);
+ expect(r.err).toContain("invalid --limit 'abc'");
+ }, TIMEOUT_MS);
+
+ test("types --limit 0 still means unlimited", async () => {
+ expect((await capture(["types", "--limit", "0"])).exit).toBe(0);
+ }, TIMEOUT_MS);
+
+ for (const cmd of ["gen", "verify"]) {
+ test(`${cmd} --limit abc`, async () => {
+ const r = await capture([cmd, "--limit", "abc", "--cwd", dir]);
+ expect(r.exit).toBe(2);
+ expect(r.err).toContain("invalid --limit 'abc'");
+ }, TIMEOUT_MS);
+ }
+});
+
+describe("metadata that does not load exits 1 in every command", () => {
+ test("gen, verify, docs, migrate and export agree", async () => {
+ const broken = join(dir, "metaobjects", "meta.broken.json");
+ writeFileSync(broken, '{ "metadata.root": { "children": [ ');
+ try {
+ for (const argv of [["gen"], ["verify"], ["docs"], ["migrate", "--dialect", "sqlite"], ["export"]]) {
+ const r = await capture([...argv, "--cwd", dir]);
+ expect({ argv, exit: r.exit }).toEqual({ argv, exit: 1 });
+ }
+ } finally {
+ rmSync(broken);
+ }
+ }, TIMEOUT_MS);
+});
+
+describe("the scaffold and its re-run", () => {
+ test("a fresh init passes fmt --check", async () => {
+ const fresh = mkdtempSync(join(tmpdir(), "meta-fresh-fmt-"));
+ try {
+ expect(await run(["init", "--quiet", "--cwd", fresh])).toBe(0);
+ expect((await capture(["fmt", "--check", "--cwd", fresh])).exit).toBe(0);
+ } finally {
+ rmSync(fresh, { recursive: true, force: true });
+ }
+ }, TIMEOUT_MS);
+
+ test("init on an initialized project is a no-op that exits 0", async () => {
+ const r = await capture(["init", "--cwd", dir]);
+ expect(r.exit).toBe(0);
+ expect(r.out).toContain("already initialized");
+ expect(r.out).toContain("no-op");
+ }, TIMEOUT_MS);
+});
+
+describe("version", () => {
+ for (const flag of ["--version", "-v", "-V"]) {
+ test(`run(${flag}) prints the bare version`, async () => {
+ const r = await capture([flag]);
+ expect(r.exit).toBe(0);
+ expect(r.out).toBe(cliVersion());
+ }, TIMEOUT_MS);
+ }
+
+ test("the bin answers a bare version flag before loading the command graph", () => {
+ const bin = readFileSync(resolve(import.meta.dirname, "..", "bin", "meta.ts"), "utf8");
+ // A static import of the dispatcher would evaluate the sdk and codegen-ts before the
+ // version check — the whole graph paid on every probe.
+ expect(bin).not.toMatch(/^import .*["']\.\.\/src\/index\.js["']/m);
+ expect(bin).toContain('await import("../src/index.js")');
+ const proc = Bun.spawnSync(["bun", resolve(import.meta.dirname, "..", "bin", "meta.ts"), "-V"]);
+ expect(proc.exitCode).toBe(0);
+ expect(proc.stdout.toString().trim()).toBe(cliVersion());
+ }, TIMEOUT_MS);
+});
diff --git a/server/typescript/packages/cli/test/cli.test.ts b/server/typescript/packages/cli/test/cli.test.ts
index 602005104..f5484f67d 100644
--- a/server/typescript/packages/cli/test/cli.test.ts
+++ b/server/typescript/packages/cli/test/cli.test.ts
@@ -31,7 +31,7 @@ describe("parseInitArgs", () => {
expect(parseInitArgs(["--config-only"])).toEqual({ ...defaultInitFlags, configOnly: true });
});
test("throws on unknown flag", () => {
- expect(() => parseInitArgs(["--foo"])).toThrow(/Unknown option '--foo'/);
+ expect(() => parseInitArgs(["--foo"])).toThrow(/unknown flag --foo for `meta init`\. Valid flags: .*--force/);
});
});
diff --git a/server/typescript/packages/cli/test/init.test.ts b/server/typescript/packages/cli/test/init.test.ts
index 9c7184847..06180df69 100644
--- a/server/typescript/packages/cli/test/init.test.ts
+++ b/server/typescript/packages/cli/test/init.test.ts
@@ -289,9 +289,22 @@ describe("init() — the owned-codegen tier is empty on purpose", () => {
});
describe("init() — re-run safety", () => {
- test("throws when metaobjects/ exists and --force is not set", async () => {
+ test("is a no-op when metaobjects/ exists and --force is not set", async () => {
mkdirSync(join(cwd, "metaobjects"));
- await expect(init({ cwd })).rejects.toThrow(/already exists/);
+ const result = await init({ cwd });
+ expect(result.alreadyInitialized).toBe(true);
+ expect(result.created).toEqual([]);
+ expect(result.preserved).toEqual(["metaobjects"]);
+ expect(existsSync(join(cwd, ".metaobjects"))).toBe(false);
+ });
+
+ test("a second init on a fully initialized project writes nothing", async () => {
+ await init({ cwd });
+ const configBefore = readFileSync(join(cwd, "metaobjects.config.ts"), "utf8");
+ const second = await init({ cwd });
+ expect(second.alreadyInitialized).toBe(true);
+ expect(second.created).toEqual([]);
+ expect(readFileSync(join(cwd, "metaobjects.config.ts"), "utf8")).toBe(configBefore);
});
test("succeeds when --force is set", async () => {
@@ -303,6 +316,24 @@ describe("init() — re-run safety", () => {
expect(existsSync(join(cwd, "metaobjects", "entity-preserve-me.json"))).toBe(true);
});
+ test("--print-only forecasts the root CLAUDE.md a real run writes", async () => {
+ // wireRoot: the CLI's default (parseInitArgs); a direct init() call opts in explicitly.
+ const forecast = await init({ cwd, printOnly: true, wireRoot: true });
+ expect(existsSync(join(cwd, "CLAUDE.md"))).toBe(false);
+ const real = await init({ cwd, wireRoot: true });
+ expect(existsSync(join(cwd, "CLAUDE.md"))).toBe(true);
+ expect(forecast.created.some((p) => p.startsWith("CLAUDE.md"))).toBe(true);
+ // Every file the real run created was forecast.
+ for (const p of real.created) expect(forecast.created).toContain(p.replace("(created", "(would be created"));
+ });
+
+ test("--print-only forecasts wiring an existing root AGENTS.md", async () => {
+ writeFileSync(join(cwd, "AGENTS.md"), "# mine\n");
+ const forecast = await init({ cwd, printOnly: true, wireRoot: true });
+ expect(forecast.warnings.join("\n")).toContain("would be wired @.metaobjects/AGENTS.md into AGENTS.md");
+ expect(readFileSync(join(cwd, "AGENTS.md"), "utf8")).toBe("# mine\n");
+ });
+
test("--print-only writes nothing to disk", async () => {
const result = await init({ cwd, printOnly: true });
expect(result.created.length).toBeGreaterThan(0);
@@ -315,9 +346,9 @@ describe("initCommand argv wrapper", () => {
test("returns 0 on success", async () => {
expect(await initCommand([], cwd)).toBe(0);
});
- test("returns 1 when metaobjects/ exists without --force", async () => {
+ test("returns 0 (no-op) when metaobjects/ exists without --force", async () => {
mkdirSync(join(cwd, "metaobjects"));
- expect(await initCommand([], cwd)).toBe(1);
+ expect(await initCommand([], cwd)).toBe(0);
});
test("returns 2 on unknown flag", async () => {
expect(await initCommand(["--foo"], cwd)).toBe(2);
diff --git a/server/typescript/packages/cli/test/integration/gen-parse-error.test.ts b/server/typescript/packages/cli/test/integration/gen-parse-error.test.ts
index 8a38b78f5..21f0a0d0c 100644
--- a/server/typescript/packages/cli/test/integration/gen-parse-error.test.ts
+++ b/server/typescript/packages/cli/test/integration/gen-parse-error.test.ts
@@ -80,7 +80,9 @@ describe("meta gen — does not mask ParseErrors", () => {
console.error = (...args: unknown[]) => { stderr.push(String(args[0])); };
try {
const exit = await run(["gen", "--cwd", root]);
- expect(exit).toBe(2);
+ // Metadata that does not load is a runtime failure (exit 1) in every command and
+ // every port — not the usage code (2) it once shared with a bad flag.
+ expect(exit).toBe(1);
const joined = stderr.join("\n");
// The real ParseError must surface…
expect(joined).toContain("no such relationship");
diff --git a/server/typescript/packages/cli/test/integration/migrate-dry-run.test.ts b/server/typescript/packages/cli/test/integration/migrate-dry-run.test.ts
index 3e57eda2f..61bed6be1 100644
--- a/server/typescript/packages/cli/test/integration/migrate-dry-run.test.ts
+++ b/server/typescript/packages/cli/test/integration/migrate-dry-run.test.ts
@@ -17,7 +17,9 @@ describe("meta migrate --dry-run", () => {
console.log = (msg: string) => { captured.push(msg); };
try {
- const exit = await run(["migrate", "--from-db", "--cwd", root, "--db", dbUrl, "--slug", "initial", "--dry-run"]);
+ // `--format text`: the SQL preview is the text rendering. A structured run carries
+ // the same SQL in the document's `sql` field instead (agent-output-contract.test.ts).
+ const exit = await run(["migrate", "--from-db", "--cwd", root, "--db", dbUrl, "--slug", "initial", "--dry-run", "--format", "text"]);
expect(exit).toBe(0);
const stdout = captured.join("\n");
diff --git a/server/typescript/packages/cli/test/unit/args-deps.test.ts b/server/typescript/packages/cli/test/unit/args-deps.test.ts
index a72508bb6..032ae653a 100644
--- a/server/typescript/packages/cli/test/unit/args-deps.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-deps.test.ts
@@ -56,6 +56,6 @@ describe("parseDepsArgs", () => {
});
test("an unknown flag is a usage error", () => {
- expect(() => parseDepsArgs(["sync", "--foo"])).toThrow(/Unknown option '--foo'/);
+ expect(() => parseDepsArgs(["sync", "--foo"])).toThrow(/unknown flag --foo for `meta deps`\. Valid flags: /);
});
});
diff --git a/server/typescript/packages/cli/test/unit/args-export.test.ts b/server/typescript/packages/cli/test/unit/args-export.test.ts
index 6263608fe..d5b889464 100644
--- a/server/typescript/packages/cli/test/unit/args-export.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-export.test.ts
@@ -19,7 +19,7 @@ describe("parseExportArgs", () => {
});
test("unknown flag throws", () => {
- expect(() => parseExportArgs(["--foo"])).toThrow(/Unknown option '--foo'/);
+ expect(() => parseExportArgs(["--foo"])).toThrow(/unknown flag --foo for `meta export`\. Valid flags: .*--out/);
});
test("positionals are rejected", () => {
diff --git a/server/typescript/packages/cli/test/unit/args-fmt.test.ts b/server/typescript/packages/cli/test/unit/args-fmt.test.ts
index 0f3c9fd19..edf2cedc1 100644
--- a/server/typescript/packages/cli/test/unit/args-fmt.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-fmt.test.ts
@@ -25,6 +25,6 @@ describe("parseFmtArgs", () => {
});
test("unknown flag throws", () => {
- expect(() => parseFmtArgs(["--bogus"])).toThrow(/Unknown option '--bogus'/);
+ expect(() => parseFmtArgs(["--bogus"])).toThrow(/unknown flag --bogus for `meta fmt`\. Valid flags: .*--check/);
});
});
diff --git a/server/typescript/packages/cli/test/unit/args-gen.test.ts b/server/typescript/packages/cli/test/unit/args-gen.test.ts
index 6128d5dbe..956fdfd85 100644
--- a/server/typescript/packages/cli/test/unit/args-gen.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-gen.test.ts
@@ -57,6 +57,6 @@ describe("parseGenArgs", () => {
});
test("unknown flag throws", () => {
- expect(() => parseGenArgs(["--foo"])).toThrow(/Unknown option '--foo'/);
+ expect(() => parseGenArgs(["--foo"])).toThrow(/unknown flag --foo for `meta gen`\. Valid flags: .*--dry-run/);
});
});
diff --git a/server/typescript/packages/cli/test/unit/args-migrate.test.ts b/server/typescript/packages/cli/test/unit/args-migrate.test.ts
index 1fedef582..21c18d674 100644
--- a/server/typescript/packages/cli/test/unit/args-migrate.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-migrate.test.ts
@@ -81,7 +81,7 @@ describe("parseMigrateArgs", () => {
});
test("unknown flag throws", () => {
- expect(() => parseMigrateArgs(["--foo"])).toThrow(/Unknown option '--foo'/);
+ expect(() => parseMigrateArgs(["--foo"])).toThrow(/unknown flag --foo for `meta migrate`\. Valid flags: .*--slug/);
});
});
diff --git a/server/typescript/packages/cli/test/unit/args-prompt-snapshot.test.ts b/server/typescript/packages/cli/test/unit/args-prompt-snapshot.test.ts
index 4e7b8e10b..3ca85a74c 100644
--- a/server/typescript/packages/cli/test/unit/args-prompt-snapshot.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-prompt-snapshot.test.ts
@@ -15,7 +15,7 @@ describe("parsePromptSnapshotArgs", () => {
});
});
test("throws on an unknown flag", () => {
- expect(() => parsePromptSnapshotArgs(["--bogus"])).toThrow(/Unknown option '--bogus'/);
+ expect(() => parsePromptSnapshotArgs(["--bogus"])).toThrow(/unknown flag --bogus for `meta prompt-snapshot`\. Valid flags: .*--check/);
});
test("throws on a positional argument", () => {
expect(() => parsePromptSnapshotArgs(["extra"])).toThrow(/Unexpected argument 'extra'/);
diff --git a/server/typescript/packages/cli/test/unit/args-verify.test.ts b/server/typescript/packages/cli/test/unit/args-verify.test.ts
index f5cbd2cfb..38d850fa5 100644
--- a/server/typescript/packages/cli/test/unit/args-verify.test.ts
+++ b/server/typescript/packages/cli/test/unit/args-verify.test.ts
@@ -148,7 +148,7 @@ describe("parseVerifyArgs", () => {
);
});
test("throws on an unknown flag", () => {
- expect(() => parseVerifyArgs(["--bogus"])).toThrow(/Unknown option '--bogus'/);
+ expect(() => parseVerifyArgs(["--bogus"])).toThrow(/unknown flag --bogus for `meta verify`\. Valid flags: .*--codegen/);
});
test("throws on a positional argument", () => {
expect(() => parseVerifyArgs(["extra"])).toThrow(/Unexpected argument 'extra'/);