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'/);