Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions packages/sim-cli/scripts/print-command-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]))
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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 } : {}),
}
}
)
Expand Down
71 changes: 71 additions & 0 deletions packages/sim-cli/src/contract/reference.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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))
Expand All @@ -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(
Expand Down Expand Up @@ -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<Command | undefined>(
(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',
Expand Down
Loading