diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index 7fe511d2080..861e273c7ce 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -60,6 +60,17 @@ interface InventoryCommand extends CommandReference { mothershipUnavailable?: true /** In a group hidden from `--help` until launch; the worker exposes it only when Sim enables it. */ unpublished?: true + /** + * The commander aliases of each `path` segment, aligned with `path` (`kb` for `knowledge`, + * `ls` for `list`), so the worker reads `sim kb folders ls` as the command it runs. Absent + * when no segment has one. + */ + pathAliases?: string[][] + /** + * Paths this command used to have (`tables count create` for `tables rows count`). The CLI + * still runs each as this command, hidden from help, so the worker reads it as this one. + */ + renamedFrom?: string[][] } function isHiddenCommand(command: Command): boolean { @@ -75,10 +86,17 @@ function subcommands(command: Command, atRoot: boolean): Command[] { ) } -function collectLeaves(command: Command, prefix: string[]): { path: string[]; command: Command }[] { - const children = subcommands(command, prefix.length === 0) - if (children.length === 0) return [{ path: prefix, command }] - return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()])) +interface Leaf { + path: string[] + /** The commands `path` names, outermost first. */ + chain: Command[] + command: Command +} + +function collectLeaves(command: Command, chain: Command[]): Leaf[] { + const children = subcommands(command, chain.length === 0) + if (children.length === 0) return [{ path: chain.map((node) => node.name()), chain, command }] + return children.flatMap((child) => collectLeaves(child, [...chain, child])) } /** @@ -135,7 +153,7 @@ function mothershipRefuses(operations: readonly V2OperationName[]): boolean { const program = buildProgram() const inventory: InventoryCommand[] = collectLeaves(program, []).map( - ({ path: cmdPath, command }) => { + ({ path: cmdPath, chain, command }) => { const args: InventoryArgument[] = command.registeredArguments.map((argument) => ({ name: argument.name(), required: argument.required, @@ -173,6 +191,13 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( ? commandReference(OPENAPI_DOCS, op, jsonFields, cursorSlot(op) !== null) : {} const called = calledOperations(command) ?? [] + const pathAliases = chain.map((node) => node.aliases()) + const contract = operation ? CLI_CONTRACT[operation] : undefined + const renamedFrom = + contract?.renamedFrom && + (contract.command === undefined || contract.command === cmdPath.join(' ')) + ? contract.renamedFrom.map((from) => from.split(' ')) + : [] return { path: cmdPath, description: command.description(), @@ -181,6 +206,8 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( ...reference, ...(mothershipRefuses(called) ? { mothershipUnavailable: true as const } : {}), ...(UNPUBLISHED_GROUPS.has(cmdPath[0] ?? '') ? { unpublished: true as const } : {}), + ...(pathAliases.some((aliases) => aliases.length > 0) ? { pathAliases } : {}), + ...(renamedFrom.length > 0 ? { renamedFrom } : {}), } } ) diff --git a/packages/sim-cli/src/contract/reference.test.ts b/packages/sim-cli/src/contract/reference.test.ts index 478d9e185f7..0ed4d6c4001 100644 --- a/packages/sim-cli/src/contract/reference.test.ts +++ b/packages/sim-cli/src/contract/reference.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import type { Command } from 'commander' import { describe, expect, it } from 'vitest' import { commandReference, @@ -9,6 +10,7 @@ import { import { runEmbeddedCli } from '#sim-cli/embed' import { V2_OPERATIONS } from '#sim-cli/generated/v2-api' import { buildProgram, UNPUBLISHED_GROUPS } from '#sim-cli/program' +import { operationOf } from '#sim-cli/runtime/called-operations' import { cursorSlot } from '#sim-cli/runtime/request' const ROOT = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -19,10 +21,30 @@ interface InventoryEntry { body?: string mothershipUnavailable?: true unpublished?: true + pathAliases?: string[][] + renamedFrom?: string[][] } let produced: InventoryEntry[] | undefined +/** The requests the embedded CLI sends for `argv`, answered with one generic success. */ +async function requests(argv: string[]) { + const seen: string[] = [] + const result = await runEmbeddedCli(['--output', 'json', ...argv], { + endpoint: 'https://sim.internal.test', + apiKey: 'test-key', + workspaceId: 'a2e3ab27-2f9d-4b8a-a2f2-3c47a1b0c9d1', + transport: async (input, init) => { + seen.push(`${init?.method ?? 'GET'} ${String(input)} ${String(init?.body ?? '')}`) + return new Response(JSON.stringify({ data: { id: 'folder-1', count: 0 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + return { exitCode: result.exitCode, stderr: result.stderr, seen } +} + /** Read the actual producer, including command aliases and generated API documents. */ function inventory(): InventoryEntry[] { produced ??= JSON.parse( @@ -118,6 +140,55 @@ describe('CLI reference producer', () => { expect(flag('files get')).toBeUndefined() }) + it('publishes the aliases of each path segment, which the real CLI runs as the command', async () => { + const commands = inventory() + const aliasesOf = (path: string) => + commands.find((entry) => entry.path.join(' ') === path)?.pathAliases + expect(aliasesOf('files move')).toEqual([['file'], ['mv']]) + expect(aliasesOf('knowledge folders list')).toEqual([['kb'], [], ['ls']]) + expect(aliasesOf('workflows deploy')).toEqual([['workflow'], []]) + expect(aliasesOf('blocks get')).toBeUndefined() + + const canonical = await requests(['files', 'move', '--file-ids', 'f1', '--to', 'folder-1']) + expect(canonical.exitCode, canonical.stderr).toBe(0) + expect(canonical.seen.length).toBeGreaterThan(0) + expect(await requests(['file', 'mv', '--file-ids', 'f1', '--to', 'folder-1'])).toEqual( + canonical + ) + }) + + it('publishes each renamed path on the command the real CLI still runs it as', async () => { + const commands = inventory() + const renamed = commands.flatMap((entry) => + (entry.renamedFrom ?? []).map((from) => ({ from, to: entry.path })) + ) + expect(renamed.map(({ from }) => from.join(' ')).sort()).toEqual([ + 'files restore create', + 'tables columns run', + 'tables count create', + 'tables rows find', + 'workflows deployment list', + ]) + const program = buildProgram() + const resolve = (path: readonly string[]) => + path.reduce( + (node, name) => + node?.commands.find((child) => child.name() === name || child.aliases().includes(name)), + program + ) + for (const { from, to } of renamed) { + const old = resolve(from) + expect(old, from.join(' ')).toBeDefined() + expect(old && operationOf(old), from.join(' ')).toBe(operationOf(resolve(to) ?? program)) + } + + const current = await requests(['tables', 'rows', 'count', 'table-1']) + expect(current.exitCode, current.stderr).toBe(0) + const retired = await requests(['tables', 'count', 'create', 'table-1']) + expect(retired.exitCode, retired.stderr).toBe(0) + expect(retired.seen).toEqual(current.seen) + }) + it('the real CLI accepts both row bodies and prints their distinct 201 payloads', async () => { const identity = { endpoint: 'https://sim.internal.test',