From 723af3d424d2ef3f88778418475e364c45578742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 04:25:39 +0000 Subject: [PATCH 1/2] fix(cli): environments/*.ts command sources no longer spell os projects in live --help output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #10967 static override examples arrays, JSDoc headers, and exported class names in packages/cli/src/commands/environments/{bind,create,list,show,switch}.ts still spelled the pre-v5.0-rename `os projects ` — oclif prints examples verbatim as part of --help, so a user copy-pasting straight from `os environments bind --help` hit `Error: Command projects:bind not found.` - examples arrays and JSDoc headers: os projects -> os environments (all five files, 21 occurrences). - exported class names renamed to match their file-path-derived command id (ProjectsBind -> EnvironmentsBind, etc.) -- oclif's pattern-strategy loader derives a command's id purely from its file path, confirmed by reading processCommandIds() in @oclif/core and by building the CLI and running --help/a real invocation on all five commands after the rename. - environments.test.ts's imports and describe title updated to match, and gains a pin: every examples entry on these five commands is checked against the CLI's actual file-tree-derived command-id set (not a grep for the literal string "os projects"), so a future topic rename that misses an examples string fails a test instead of shipping. Anti-vacuity and reverse-verification (both the pre-fix line as a specimen, and a live edit-run-restore cycle) are documented in the test file's own comment. --- .../environments-command-source-naming.md | 22 ++ .../cli/src/commands/environments/bind.ts | 12 +- .../cli/src/commands/environments/create.ts | 14 +- .../environments/environments.test.ts | 196 ++++++++++++++++-- .../cli/src/commands/environments/list.ts | 12 +- .../cli/src/commands/environments/show.ts | 10 +- .../cli/src/commands/environments/switch.ts | 10 +- 7 files changed, 229 insertions(+), 47 deletions(-) create mode 100644 .changeset/environments-command-source-naming.md diff --git a/.changeset/environments-command-source-naming.md b/.changeset/environments-command-source-naming.md new file mode 100644 index 0000000000..abf87a3619 --- /dev/null +++ b/.changeset/environments-command-source-naming.md @@ -0,0 +1,22 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `environments/*.ts` command sources no longer spell `os projects` in live `--help` output (#10967) + +`static override examples` is printed verbatim as part of oclif's `--help`, and all five +commands under `packages/cli/src/commands/environments/` (`bind`, `create`, `list`, `show`, +`switch`) still spelled the pre-v5.0-rename `os projects ` there — a user copy-pasting +straight from `os environments bind --help` hit `Error: Command projects:bind not found.` +(exit 2), the same dead command #10927 fixed in `packages/cli/README.md`, this time sourced +from the CLI binary itself. + +`examples` arrays and JSDoc headers now say `os environments `. The exported default +class on each file is renamed to match its real, file-path-derived command id +(`ProjectsBind` → `EnvironmentsBind`, etc.) — oclif's pattern-strategy loader derives a +command's id purely from its file path, never from the class name, so this rename changes +no runtime resolution; verified by building the CLI and running `--help` on all five +commands, plus one real invocation, after the rename. `environments.test.ts`'s imports and +`describe` title are updated to match, and gain a pin: every `examples` entry on these five +commands is checked against the CLI's actual file-tree-derived command-id set, so a future +topic rename that misses an `examples` string fails a test instead of shipping. diff --git a/packages/cli/src/commands/environments/bind.ts b/packages/cli/src/commands/environments/bind.ts index 8d2683a45b..d8cb782abd 100644 --- a/packages/cli/src/commands/environments/bind.ts +++ b/packages/cli/src/commands/environments/bind.ts @@ -9,7 +9,7 @@ import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** - * `os projects bind` — bind a locally-compiled artifact to an existing + * `os environments bind` — bind a locally-compiled artifact to an existing * multi-environment server project. * * Equivalent to `PATCH /api/v1/cloud/environments/` with @@ -20,13 +20,13 @@ import { formatOutput } from '../../utils/output-formatter.js'; * Use `--build` to compile `objectstack.config.ts` first so the artifact * reflects the latest source. */ -export default class ProjectsBind extends Command { +export default class EnvironmentsBind extends Command { static override description = 'Bind a local objectstack artifact to an existing project'; static override examples = [ - '$ os projects bind --artifact ./dist/objectstack.json', - '$ os projects bind --artifact ./dist/objectstack.json --build', - '$ os projects bind --reseed', + '$ os environments bind --artifact ./dist/objectstack.json', + '$ os environments bind --artifact ./dist/objectstack.json --build', + '$ os environments bind --reseed', ]; static override args = { @@ -59,7 +59,7 @@ export default class ProjectsBind extends Command { }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsBind); + const { args, flags } = await this.parse(EnvironmentsBind); try { const artifactRel = flags.artifact ?? './dist/objectstack.json'; diff --git a/packages/cli/src/commands/environments/create.ts b/packages/cli/src/commands/environments/create.ts index 7dc50c25f9..3d1fe9bfe3 100644 --- a/packages/cli/src/commands/environments/create.ts +++ b/packages/cli/src/commands/environments/create.ts @@ -7,21 +7,21 @@ import { formatOutput } from '../../utils/output-formatter.js'; import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; /** - * `os projects create` — provision a new project. + * `os environments create` — provision a new project. * * Delegates to `ProjectProvisioningService.provisionProject` on the server. * On success, optionally activates the new project for the current session * and persists `activeEnvironmentId` into `~/.objectstack/credentials.json` * (unless `--no-activate` is passed). */ -export default class ProjectsCreate extends Command { +export default class EnvironmentsCreate extends Command { static override description = 'Provision a new project'; static override examples = [ - '$ os projects create --org 00000000-0000-0000-0000-000000000000 --name Staging', - '$ os projects create --org $ORG --name Dev --plan free', - '$ os projects create --org $ORG --name "Clone" --clone-from --no-activate', - '$ os projects create --org $ORG --name CRM --artifact ./examples/app-crm/dist/objectstack.json', + '$ os environments create --org 00000000-0000-0000-0000-000000000000 --name Staging', + '$ os environments create --org $ORG --name Dev --plan free', + '$ os environments create --org $ORG --name "Clone" --clone-from --no-activate', + '$ os environments create --org $ORG --name CRM --artifact ./examples/app-crm/dist/objectstack.json', ]; static override flags = { @@ -56,7 +56,7 @@ export default class ProjectsCreate extends Command { }; async run(): Promise { - const { flags } = await this.parse(ProjectsCreate); + const { flags } = await this.parse(EnvironmentsCreate); try { const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); diff --git a/packages/cli/src/commands/environments/environments.test.ts b/packages/cli/src/commands/environments/environments.test.ts index ef5dcc1139..b1915bf3ad 100644 --- a/packages/cli/src/commands/environments/environments.test.ts +++ b/packages/cli/src/commands/environments/environments.test.ts @@ -1,13 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import ProjectsList from './list.js'; -import ProjectsShow from './show.js'; -import ProjectsCreate from './create.js'; -import ProjectsSwitch from './switch.js'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import type { Command } from '@oclif/core'; +import EnvironmentsBind from './bind.js'; +import EnvironmentsList from './list.js'; +import EnvironmentsShow from './show.js'; +import EnvironmentsCreate from './create.js'; +import EnvironmentsSwitch from './switch.js'; /** - * Metadata-only smoke tests for the `os projects ...` commands. We do + * Metadata-only smoke tests for the `os environments ...` commands. We do * not run the commands end-to-end (that would require an oclif Config * with hooks wired up); instead we assert that each command is a * well-formed oclif Command class with the flags / args we expect. @@ -18,31 +23,31 @@ import ProjectsSwitch from './switch.js'; * and the Chrome DevTools MCP smoke test in the PR description. */ -describe('os projects commands', () => { +describe('os environments commands', () => { describe('list', () => { it('has the expected description and flags', () => { - expect(ProjectsList.description).toMatch(/list/i); - expect(ProjectsList.flags).toHaveProperty('org'); - expect(ProjectsList.flags).toHaveProperty('status'); - expect(ProjectsList.flags).toHaveProperty('format'); + expect(EnvironmentsList.description).toMatch(/list/i); + expect(EnvironmentsList.flags).toHaveProperty('org'); + expect(EnvironmentsList.flags).toHaveProperty('status'); + expect(EnvironmentsList.flags).toHaveProperty('format'); }); }); describe('show', () => { it('requires an id arg', () => { - expect(ProjectsShow.args).toHaveProperty('id'); - expect((ProjectsShow.args as any).id.required).toBe(true); + expect(EnvironmentsShow.args).toHaveProperty('id'); + expect((EnvironmentsShow.args as any).id.required).toBe(true); }); }); describe('create', () => { it('requires --org and --name', () => { - expect((ProjectsCreate.flags as any).org.required).toBe(true); - expect((ProjectsCreate.flags as any).name.required).toBe(true); + expect((EnvironmentsCreate.flags as any).org.required).toBe(true); + expect((EnvironmentsCreate.flags as any).name.required).toBe(true); }); it('activates by default with --no-activate opt-out', () => { - const flag = (ProjectsCreate.flags as any).activate; + const flag = (EnvironmentsCreate.flags as any).activate; expect(flag.default).toBe(true); expect(flag.allowNo).toBe(true); }); @@ -50,14 +55,169 @@ describe('os projects commands', () => { describe('switch', () => { it('requires an id arg', () => { - expect(ProjectsSwitch.args).toHaveProperty('id'); - expect((ProjectsSwitch.args as any).id.required).toBe(true); + expect(EnvironmentsSwitch.args).toHaveProperty('id'); + expect((EnvironmentsSwitch.args as any).id.required).toBe(true); }); it('calls the activate endpoint by default with --no-remote opt-out', () => { - const flag = (ProjectsSwitch.flags as any).remote; + const flag = (EnvironmentsSwitch.flags as any).remote; expect(flag.default).toBe(true); expect(flag.allowNo).toBe(true); }); }); }); + +/** + * Pin (#10967): every `examples` entry on these five commands names a + * command id THIS CLI ACTUALLY REGISTERS. + * + * ## The failure this exists to refuse + * + * `static override examples` is printed verbatim as part of `--help`, and + * oclif's `pattern`-strategy command loader derives a command's id purely + * from its file PATH — a `commandsDir`-relative `topic/.../command.js` + * becomes id `topic:...:command` (`processCommandIds` in the installed + * `@oclif/core`'s `lib/config/plugin.js`: `id = [...topics, command] + * .filter(Boolean).join(sep)`, `topics` = the file's directory segments, + * `command` = the basename unless it is literally `index`). The exported + * class name plays NO part in that derivation. So a topic-directory rename + * (`projects/` → `environments/`, v5.0) leaves every command resolving + * exactly as before while any `examples`/JSDoc string spelling the OLD + * topic silently stops being true — not a parse error, not a type error, + * nothing a build catches. A user who copy-pastes the stale line hits + * `Error: Command projects:bind not found.` (exit 2). That is the shape + * #10967 fixed under this directory; this pin targets the MECHANISM (an + * example naming an id this CLI does not register), not the literal string + * `os projects`, so it keeps working for a topic nobody has renamed yet — + * a grep for `os projects` would go green the instant someone renamed this + * topic again to something else, or a different topic entirely. + * + * ## Why the registered-id set is derived from SOURCE, not the built plugin + * + * The direct route — `Config.load()` against the built CLI and reading + * `config.commandIDs` — is unreachable from this suite: `turbo.json` + * declares `test`'s `dependsOn` as `["^build"]`, dependencies' builds only, + * never this package's OWN, so `packages/cli/dist` (what oclif's pattern + * strategy actually scans) is not guaranteed to exist when this file runs — + * the same constraint `child-env-source-loader.pin.test.ts` documents for + * the same reason. So `registeredCommandIds()` below re-derives the id set + * from `src/commands/**\/*.ts` using oclif's own algorithm instead: + * `src/commands` is a 1:1 mirror of `dist/commands` (the build renames or + * relocates nothing), so the set computed here is the one oclif will + * register once the package is actually built — verified below by asserting + * it contains known ids from several unrelated topics, including the + * `index.ts` special case (`migrate/index.ts` → id `migrate`, no trailing + * command segment). What this does NOT cover: a future build step that + * renamed or dropped a file between `src` and `dist` would be invisible + * here — nothing in this package's build does that today. + * + * ## Why only these five files' `examples` are asserted on + * + * The id universe above is built from the WHOLE command tree (so a match + * only succeeds against a real, currently-registered command), but the + * PROPERTY is only checked for the five files #10967 touches. Building the + * universe from the full tree and then asserting on it repo-wide would have + * been stronger, but going wide surfaced a live, PRE-EXISTING instance of + * this exact defect class outside this directory (`register.ts` / `whoami.ts` + * / `logout.ts`, root-level commands whose `examples` still say `os auth + * whoami` though no `auth` topic has ever existed for them — confirmed via + * `--help`: `Error: Command auth:whoami not found.`) — filed as its own + * card (#11221) rather than folded in here, since fixing it is outside what + * #10967 dispatched. Scoping the assertion to the five fixed files keeps + * this pin honest about what it currently guards without silently taking on + * that unrelated repair. + */ +describe('#10967 pin: examples resolve to a real command id', () => { + const ENVIRONMENTS_DIR = fileURLToPath(new URL('.', import.meta.url)); + const COMMANDS_ROOT = path.resolve(ENVIRONMENTS_DIR, '..'); + + /** oclif's own `topicSeparator` for this CLI (`packages/cli/package.json` → `oclif.topicSeparator`). */ + const TOPIC_SEPARATOR = ' '; + + const isCommandSource = (name: string): boolean => + name.endsWith('.ts') + && !name.endsWith('.d.ts') + && !/\.(test|pin\.test|contract\.test|integration\.test)\.ts$/.test(name); + + function registeredCommandIds(): Set { + const ids = new Set(); + const walk = (dir: string, topics: string[]): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + walk(path.join(dir, entry.name), [...topics, entry.name]); + continue; + } + if (!isCommandSource(entry.name)) continue; + const base = entry.name.slice(0, -'.ts'.length); + const command = base === 'index' ? undefined : base; + const id = [...topics, command].filter((s): s is string => Boolean(s)).join(TOPIC_SEPARATOR); + ids.add(id); + } + }; + walk(COMMANDS_ROOT, []); + return ids; + } + + /** + * True when `line` (a raw `examples` string) invokes a registered command. + * Only handles the `$ os ` shape every environments/*.ts + * example uses — some OTHER commands in this package (`start.ts`, + * `verify.ts`) use `<%= config.bin %>` templating instead, which is not + * modeled here because none of the five files this pin covers use it. + */ + function namesARegisteredCommand(line: string, ids: ReadonlySet): boolean { + const stripped = line.replace(/^\$\s*/, '').trim(); + if (!stripped.startsWith('os ')) return false; + const rest = stripped.slice('os '.length); + for (const id of ids) { + if (rest === id || rest.startsWith(`${id} `)) return true; + } + return false; + } + + const registeredIds = registeredCommandIds(); + + it('the scan reached real code across multiple topics (not vacuously empty)', () => { + expect(registeredIds.has('environments list')).toBe(true); + expect(registeredIds.has('environments bind')).toBe(true); + expect(registeredIds.has('migrate')).toBe(true); // the index.ts special case + expect(registeredIds.has('data query')).toBe(true); + expect(registeredIds.size).toBeGreaterThan(30); + }); + + it('anti-vacuity: a known-good example is correctly cleared, not just never rejected', () => { + expect(namesARegisteredCommand('$ os environments list', registeredIds)).toBe(true); + expect(namesARegisteredCommand('$ os environments bind --reseed', registeredIds)).toBe(true); + }); + + it('reverse-verification: the PRE-FIX line is rejected, and rejected for the declared reason', () => { + const preFix = '$ os projects bind --artifact ./dist/objectstack.json'; + // The reason it must fail: no file tree produces this id any more. + expect(registeredIds.has('projects bind')).toBe(false); + expect(registeredIds.has('projects')).toBe(false); + expect(namesARegisteredCommand(preFix, registeredIds)).toBe(false); + }); + + const commandsUnderTest: Record = { + 'bind.ts': EnvironmentsBind, + 'create.ts': EnvironmentsCreate, + 'list.ts': EnvironmentsList, + 'show.ts': EnvironmentsShow, + 'switch.ts': EnvironmentsSwitch, + }; + + for (const [file, Cmd] of Object.entries(commandsUnderTest)) { + it(`${file}: every examples entry names a registered command`, () => { + const examples = (Cmd.examples ?? []).filter((e): e is string => typeof e === 'string'); + expect(examples.length, `${file} has no string examples — nothing for this pin to check`).toBeGreaterThan(0); + + const unresolved = examples.filter((line) => !namesARegisteredCommand(line, registeredIds)); + expect( + unresolved, + `${file} has an examples entry naming a command id this CLI does not register — a user ` + + 'who copy-pastes it from --help gets "Command ... not found." Rename it to match the ' + + 'file\'s real topic, or move the file if the topic itself is what should change.', + ).toEqual([]); + }); + } +}); diff --git a/packages/cli/src/commands/environments/list.ts b/packages/cli/src/commands/environments/list.ts index 165102f827..892dd9f7af 100644 --- a/packages/cli/src/commands/environments/list.ts +++ b/packages/cli/src/commands/environments/list.ts @@ -6,19 +6,19 @@ import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** - * `os projects list` — list projects visible to the current session. + * `os environments list` — list projects visible to the current session. * * Filters by organization via `--org`. Output format is the same * table/json/yaml shape used by other metadata commands, for a * consistent DX. */ -export default class ProjectsList extends Command { +export default class EnvironmentsList extends Command { static override description = 'List projects visible to the current session'; static override examples = [ - '$ os projects list', - '$ os projects list --org 00000000-0000-0000-0000-000000000000', - '$ os projects list --format json', + '$ os environments list', + '$ os environments list --org 00000000-0000-0000-0000-000000000000', + '$ os environments list --format json', ]; static override flags = { @@ -35,7 +35,7 @@ export default class ProjectsList extends Command { }; async run(): Promise { - const { flags } = await this.parse(ProjectsList); + const { flags } = await this.parse(EnvironmentsList); try { const { client, token, environmentId: activeId } = await createApiClient({ diff --git a/packages/cli/src/commands/environments/show.ts b/packages/cli/src/commands/environments/show.ts index 758cbde498..6c4b4f726b 100644 --- a/packages/cli/src/commands/environments/show.ts +++ b/packages/cli/src/commands/environments/show.ts @@ -6,17 +6,17 @@ import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** - * `os projects show ` — show detailed information for a single project. + * `os environments show ` — show detailed information for a single project. * * Renders the project row plus its database, active credential, and * membership row (same shape as `client.projects.get(id)`). */ -export default class ProjectsShow extends Command { +export default class EnvironmentsShow extends Command { static override description = 'Show detailed information for a project'; static override examples = [ - '$ os projects show 00000000-0000-0000-0000-000000000001', - '$ os projects show proj-123 --format json', + '$ os environments show 00000000-0000-0000-0000-000000000001', + '$ os environments show proj-123 --format json', ]; static override args = { @@ -35,7 +35,7 @@ export default class ProjectsShow extends Command { }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsShow); + const { args, flags } = await this.parse(EnvironmentsShow); try { const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); diff --git a/packages/cli/src/commands/environments/switch.ts b/packages/cli/src/commands/environments/switch.ts index 758889aaa6..dcbe0648f6 100644 --- a/packages/cli/src/commands/environments/switch.ts +++ b/packages/cli/src/commands/environments/switch.ts @@ -6,7 +6,7 @@ import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; /** - * `os projects switch ` — set the active project for this CLI session. + * `os environments switch ` — set the active project for this CLI session. * * Calls `POST /api/v1/cloud/environments/:id/activate` to update the * server-side session, then persists `activeEnvironmentId` into @@ -14,12 +14,12 @@ import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; * client they create via `createApiClient`) automatically target this * project. */ -export default class ProjectsSwitch extends Command { +export default class EnvironmentsSwitch extends Command { static override description = 'Activate a project for subsequent CLI calls'; static override examples = [ - '$ os projects switch 00000000-0000-0000-0000-000000000001', - '$ os projects switch proj-123 --no-remote', + '$ os environments switch 00000000-0000-0000-0000-000000000001', + '$ os environments switch proj-123 --no-remote', ]; static override args = { @@ -37,7 +37,7 @@ export default class ProjectsSwitch extends Command { }; async run(): Promise { - const { args, flags } = await this.parse(ProjectsSwitch); + const { args, flags } = await this.parse(EnvironmentsSwitch); try { const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); From b84a25665443e1720d8d33ef292b9391034c31f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 04:46:08 +0000 Subject: [PATCH 2/2] fix(cli): widen #10967's examples-resolve pin to every command file PM review on #11227: the pin's population was a hardcoded 5-file map -- exactly the set already correct -- so it could not catch this defect class returning anywhere else, including in the register.ts/whoami.ts/logout.ts files #11221 already tracks live. Widen it to every command source under packages/cli/src/commands/**, walked the same way registeredCommandIds() already walks the tree for the id universe. examples are now read via TypeScript AST (extractExamples), not by importing every command module, to avoid making this file's cost and failure surface track the whole package's transitive import graph. extractExamples/stripInvocationPrefix now handle every invocation shape actually present in the package: plain '$ os ...', the oclif help-template form '<%= config.bin %> ...', either prefixed by one or more ENV=value assignments (including a double-quoted value containing spaces), and the { command, description } object form start.ts uses twice. The three #11221-owned files are carved out via an EXCLUDED map (file -> reason), matching the pattern in packages/create-objectstack/src/starter-comments-self-contained.test.ts's EXCLUDED: a filtered main assertion, plus a second it.each that re-runs the same predicate over the excluded files and asserts it still finds an unresolved entry -- so when #11221 lands, that assertion goes red and says to delete the entry, instead of the exclusion silently living forever. Verified: the widened pin passes (71 tests, up from 14). Reverse-verified the exclusion is load-bearing, not decorative -- temporarily cleared EXCLUDED and confirmed all three files fail by name for the expected reason (their real, currently-unresolved os auth ... examples), then restored. Re-verified the original bind.ts reverse-verification still works under the new population (a reintroduced stale example fails only that one file, all others still pass). Re-ran the full local gate battery (18 gates) clean. --- .../environments/environments.test.ts | 267 ++++++++++++++---- 1 file changed, 207 insertions(+), 60 deletions(-) diff --git a/packages/cli/src/commands/environments/environments.test.ts b/packages/cli/src/commands/environments/environments.test.ts index b1915bf3ad..cc566beb3c 100644 --- a/packages/cli/src/commands/environments/environments.test.ts +++ b/packages/cli/src/commands/environments/environments.test.ts @@ -1,10 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { readdirSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import type { Command } from '@oclif/core'; +import ts from 'typescript'; import EnvironmentsBind from './bind.js'; import EnvironmentsList from './list.js'; import EnvironmentsShow from './show.js'; @@ -24,6 +24,15 @@ import EnvironmentsSwitch from './switch.js'; */ describe('os environments commands', () => { + describe('bind', () => { + it('has the expected description and flags', () => { + expect(EnvironmentsBind.description).toMatch(/bind/i); + expect(EnvironmentsBind.flags).toHaveProperty('artifact'); + expect(EnvironmentsBind.flags).toHaveProperty('build'); + expect(EnvironmentsBind.flags).toHaveProperty('reseed'); + }); + }); + describe('list', () => { it('has the expected description and flags', () => { expect(EnvironmentsList.description).toMatch(/list/i); @@ -68,7 +77,7 @@ describe('os environments commands', () => { }); /** - * Pin (#10967): every `examples` entry on these five commands names a + * Pin (#10967): every `examples` entry on EVERY CLI command source names a * command id THIS CLI ACTUALLY REGISTERS. * * ## The failure this exists to refuse @@ -82,15 +91,15 @@ describe('os environments commands', () => { * `command` = the basename unless it is literally `index`). The exported * class name plays NO part in that derivation. So a topic-directory rename * (`projects/` → `environments/`, v5.0) leaves every command resolving - * exactly as before while any `examples`/JSDoc string spelling the OLD - * topic silently stops being true — not a parse error, not a type error, - * nothing a build catches. A user who copy-pastes the stale line hits - * `Error: Command projects:bind not found.` (exit 2). That is the shape - * #10967 fixed under this directory; this pin targets the MECHANISM (an + * exactly as before while any `examples` string spelling the OLD topic + * silently stops being true — not a parse error, not a type error, nothing + * a build catches. A user who copy-pastes the stale line hits `Error: + * Command projects:bind not found.` (exit 2). That is the shape #10967 + * fixed under `environments/*.ts`; this pin targets the MECHANISM (an * example naming an id this CLI does not register), not the literal string * `os projects`, so it keeps working for a topic nobody has renamed yet — * a grep for `os projects` would go green the instant someone renamed this - * topic again to something else, or a different topic entirely. + * topic again to something else, or renamed a DIFFERENT topic entirely. * * ## Why the registered-id set is derived from SOURCE, not the built plugin * @@ -111,21 +120,56 @@ describe('os environments commands', () => { * renamed or dropped a file between `src` and `dist` would be invisible * here — nothing in this package's build does that today. * - * ## Why only these five files' `examples` are asserted on + * ## Population: EVERY command source, via AST — not via `import` + * + * The property is checked against every non-test file under + * `packages/cli/src/commands/**`, not just the five #10967 touches — a + * five-file population is exactly the set that is already correct, so it + * cannot catch the defect class returning anywhere else (it did not catch + * it in `register.ts`/`whoami.ts`/`logout.ts`, discovered by hand below). + * `examples` is read via TypeScript AST (`extractExamples`), not by + * `import`ing all ~60 command modules and reading `Cmd.examples` off the + * live class: several commands pull heavy transitive graphs at module load + * (database drivers, `@objectstack/client`, `@objectstack/runtime`), so + * importing every one just to read one static array would make this file's + * cost and failure surface track the whole package's import graph instead + * of the property being tested — the same reasoning + * `child-env-source-loader.pin.test.ts` gives for reading command sources + * as text/AST rather than executing them. * - * The id universe above is built from the WHOLE command tree (so a match - * only succeeds against a real, currently-registered command), but the - * PROPERTY is only checked for the five files #10967 touches. Building the - * universe from the full tree and then asserting on it repo-wide would have - * been stronger, but going wide surfaced a live, PRE-EXISTING instance of - * this exact defect class outside this directory (`register.ts` / `whoami.ts` - * / `logout.ts`, root-level commands whose `examples` still say `os auth - * whoami` though no `auth` topic has ever existed for them — confirmed via - * `--help`: `Error: Command auth:whoami not found.`) — filed as its own - * card (#11221) rather than folded in here, since fixing it is outside what - * #10967 dispatched. Scoping the assertion to the five fixed files keeps - * this pin honest about what it currently guards without silently taking on - * that unrelated repair. + * `extractExamples` handles every invocation shape actually present in this + * package (catalogued by hand across all command sources before writing + * this): a plain string (`'$ os topic cmd ...'`), the oclif help-template + * form (`'<%= config.bin %> cmd ...'` — `config.bin` is `"os"`, + * `package.json`'s `oclif.bin`), either of those prefixed by one or more + * `ENV=value` assignments (`'$ OS_CLOUD_URL=http://localhost:4000 os + * package publish'`, including a double-quoted value containing spaces — + * `start.ts`'s `OS_ARTIFACT_URL="...#sha256=<64 hex chars>" <%= config.bin + * %> start`), and the `{ command, description }` object form (`start.ts`, + * two entries). An entry matching NONE of these is graded a failure with + * its own message (not silently skipped) — "prefer failing to falling + * back" (AGENTS.md, Route & surface ownership §3): a shape this pin cannot + * parse is exactly the shape that could hide a stale command undetected. + * + * ## The one exclusion, and why it must self-retire + * + * `register.ts` / `whoami.ts` / `logout.ts` are root-level commands whose + * `examples` say `os auth register` / `os auth whoami` / `os auth logout`, + * though no `auth` topic has ever existed for them (confirmed via + * `--help`: `Error: Command auth:whoami not found.`) — the same defect + * class as #10967, found by scanning the whole tree, but not #10967's to + * fix (outside its dispatched file surface). Filed as #11221. `EXCLUDED` + * carves exactly those three files out of the main assertion below, but a + * silent, permanent exemption is its own defect — a file excluded here + * stops being checked by this pin forever, even after #11221 lands and the + * excluded condition no longer holds. So a second `it.each` re-runs the + * SAME predicate over the excluded files and asserts it still finds an + * unresolved entry: when #11221's fix removes the last one, that assertion + * goes red on purpose, and the failure message says to delete the entry. + * The pattern (map-of-reason + filtered main assertion + a "still needs + * its exclusion" retiring assertion) matches + * `packages/create-objectstack/src/starter-comments-self-contained.test.ts`'s + * `EXCLUDED`, which has retired this same way before (#11022). */ describe('#10967 pin: examples resolve to a real command id', () => { const ENVIRONMENTS_DIR = fileURLToPath(new URL('.', import.meta.url)); @@ -139,48 +183,135 @@ describe('#10967 pin: examples resolve to a real command id', () => { && !name.endsWith('.d.ts') && !/\.(test|pin\.test|contract\.test|integration\.test)\.ts$/.test(name); - function registeredCommandIds(): Set { - const ids = new Set(); - const walk = (dir: string, topics: string[]): void => { + /** Every command source file, as a path relative to `COMMANDS_ROOT` (posix separators). */ + function commandSourceFiles(): string[] { + const out: string[] = []; + const walk = (dir: string, prefix: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { - walk(path.join(dir, entry.name), [...topics, entry.name]); + walk(path.join(dir, entry.name), rel); continue; } if (!isCommandSource(entry.name)) continue; - const base = entry.name.slice(0, -'.ts'.length); - const command = base === 'index' ? undefined : base; - const id = [...topics, command].filter((s): s is string => Boolean(s)).join(TOPIC_SEPARATOR); - ids.add(id); + out.push(rel); } }; - walk(COMMANDS_ROOT, []); + walk(COMMANDS_ROOT, ''); + return out.sort(); + } + + function registeredCommandIds(): Set { + const ids = new Set(); + for (const rel of commandSourceFiles()) { + const parts = rel.split('/'); + const base = parts[parts.length - 1].slice(0, -'.ts'.length); + const topics = parts.slice(0, -1); + const command = base === 'index' ? undefined : base; + const id = [...topics, command].filter((s): s is string => Boolean(s)).join(TOPIC_SEPARATOR); + ids.add(id); + } return ids; } /** - * True when `line` (a raw `examples` string) invokes a registered command. - * Only handles the `$ os ` shape every environments/*.ts - * example uses — some OTHER commands in this package (`start.ts`, - * `verify.ts`) use `<%= config.bin %>` templating instead, which is not - * modeled here because none of the five files this pin covers use it. + * The `examples` entries a command source declares, read from the AST — + * string literals directly, and a `command` property off any object-shaped + * entry (`start.ts`'s two annotated examples). See the file header for why + * this reads source instead of importing the command class. */ - function namesARegisteredCommand(line: string, ids: ReadonlySet): boolean { - const stripped = line.replace(/^\$\s*/, '').trim(); - if (!stripped.startsWith('os ')) return false; - const rest = stripped.slice('os '.length); + function extractExamples(absPath: string): string[] { + const text = readFileSync(absPath, 'utf8'); + const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true); + const found: string[] = []; + + const stringValue = (node: ts.Node): string | undefined => + ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined; + + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyDeclaration(node) + && node.name && ts.isIdentifier(node.name) && node.name.text === 'examples' + && node.initializer && ts.isArrayLiteralExpression(node.initializer) + ) { + for (const el of node.initializer.elements) { + const direct = stringValue(el); + if (direct !== undefined) { + found.push(direct); + continue; + } + if (ts.isObjectLiteralExpression(el)) { + for (const prop of el.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === 'command') { + const value = stringValue(prop.initializer); + if (value !== undefined) found.push(value); + } + } + } + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return found; + } + + /** A leading `ENV_VAR=value ` assignment — value may be double- or single-quoted (and contain spaces). */ + const ENV_ASSIGNMENT = /^[A-Z_][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+/; + const BIN_PREFIXES = ['os ', '<%= config.bin %> '] as const; + + /** + * Strips a recognised CLI-invocation prefix (`$ `, then zero or more `ENV=value` + * assignments, then the bin name) and returns what follows — the command + * tokens. `undefined` means the line does not match any recognised shape. + */ + function stripInvocationPrefix(raw: string): string | undefined { + let s = raw.trim(); + if (s.startsWith('$ ')) s = s.slice(2); + while (ENV_ASSIGNMENT.test(s)) s = s.replace(ENV_ASSIGNMENT, ''); + for (const prefix of BIN_PREFIXES) { + if (s.startsWith(prefix)) return s.slice(prefix.length); + } + return undefined; + } + + /** True when `raw` (an `examples` entry) invokes a registered command. */ + function namesARegisteredCommand(raw: string, ids: ReadonlySet): boolean { + const rest = stripInvocationPrefix(raw); + if (rest === undefined) return false; for (const id of ids) { if (rest === id || rest.startsWith(`${id} `)) return true; } return false; } + /** + * Files with a KNOWN, currently-live instance of this defect class this + * card does not own the fix for (#11221). The retiring assertion below + * proves each entry is still load-bearing, not decorative. + */ + const EXCLUDED = new Map([ + ['register.ts', '#11221: examples say `os auth register`, no `auth` topic exists'], + ['whoami.ts', '#11221: examples say `os auth whoami`, no `auth` topic exists'], + ['logout.ts', '#11221: examples say `os auth logout`, no `auth` topic exists'], + ]); + + const sourceFiles = commandSourceFiles(); const registeredIds = registeredCommandIds(); - it('the scan reached real code across multiple topics (not vacuously empty)', () => { + it('the walk reached real command sources across multiple topics (not vacuously empty)', () => { + expect(sourceFiles).toContain('environments/bind.ts'); + expect(sourceFiles).toContain('migrate/index.ts'); // the index.ts special case + expect(sourceFiles).toContain('data/query.ts'); + expect(sourceFiles).toContain('start.ts'); // the object-shaped `examples` entries + expect(sourceFiles.length).toBeGreaterThan(30); + }); + + it('the registered-id set derived from the walk carries the same known ids', () => { expect(registeredIds.has('environments list')).toBe(true); expect(registeredIds.has('environments bind')).toBe(true); - expect(registeredIds.has('migrate')).toBe(true); // the index.ts special case + expect(registeredIds.has('migrate')).toBe(true); expect(registeredIds.has('data query')).toBe(true); expect(registeredIds.size).toBeGreaterThan(30); }); @@ -188,6 +319,11 @@ describe('#10967 pin: examples resolve to a real command id', () => { it('anti-vacuity: a known-good example is correctly cleared, not just never rejected', () => { expect(namesARegisteredCommand('$ os environments list', registeredIds)).toBe(true); expect(namesARegisteredCommand('$ os environments bind --reseed', registeredIds)).toBe(true); + // The other two recognised invocation shapes, cleared the same way. + expect(namesARegisteredCommand('<%= config.bin %> verify --rls', registeredIds)).toBe(true); + expect( + namesARegisteredCommand('$ OS_CLOUD_URL=http://localhost:4000 os package publish', registeredIds), + ).toBe(true); }); it('reverse-verification: the PRE-FIX line is rejected, and rejected for the declared reason', () => { @@ -198,26 +334,37 @@ describe('#10967 pin: examples resolve to a real command id', () => { expect(namesARegisteredCommand(preFix, registeredIds)).toBe(false); }); - const commandsUnderTest: Record = { - 'bind.ts': EnvironmentsBind, - 'create.ts': EnvironmentsCreate, - 'list.ts': EnvironmentsList, - 'show.ts': EnvironmentsShow, - 'switch.ts': EnvironmentsSwitch, - }; - - for (const [file, Cmd] of Object.entries(commandsUnderTest)) { - it(`${file}: every examples entry names a registered command`, () => { - const examples = (Cmd.examples ?? []).filter((e): e is string => typeof e === 'string'); - expect(examples.length, `${file} has no string examples — nothing for this pin to check`).toBeGreaterThan(0); + it('anti-vacuity: the walk actually found examples entries to check, not just files', () => { + const total = sourceFiles + .filter((f) => !EXCLUDED.has(f)) + .reduce((n, f) => n + extractExamples(path.join(COMMANDS_ROOT, f)).length, 0); + expect(total).toBeGreaterThan(50); + }); + it.each(sourceFiles.filter((f) => !EXCLUDED.has(f)))( + '%s: every examples entry names a registered command', + (rel) => { + const examples = extractExamples(path.join(COMMANDS_ROOT, rel)); const unresolved = examples.filter((line) => !namesARegisteredCommand(line, registeredIds)); expect( unresolved, - `${file} has an examples entry naming a command id this CLI does not register — a user ` - + 'who copy-pastes it from --help gets "Command ... not found." Rename it to match the ' - + 'file\'s real topic, or move the file if the topic itself is what should change.', + `${rel} has an examples entry that does not resolve to a command this CLI registers -- ` + + 'either it names an id this CLI does not have (a user who copy-pastes it from --help ' + + 'gets "Command ... not found."), or it uses an invocation shape this pin does not ' + + 'recognise ($ os ..., <%= config.bin %> ..., each optionally prefixed by ENV=value ' + + 'assignments) -- extend stripInvocationPrefix if a new, legitimate shape is needed.', ).toEqual([]); - }); - } + }, + ); + + it.each([...EXCLUDED.keys()])('%s still needs its exclusion (owned by #11221)', (rel) => { + const examples = extractExamples(path.join(COMMANDS_ROOT, rel)); + const unresolved = examples.filter((line) => !namesARegisteredCommand(line, registeredIds)); + expect( + unresolved.length, + `${rel} no longer has an unresolved examples entry -- remove it from EXCLUDED above so it ` + + 'is scanned like every other command source. An exclusion kept past its cause is how a ' + + 'file stops being checked without anyone deciding to stop checking it.', + ).toBeGreaterThan(0); + }); });