import type { Blueprint } from '@seamapi/blueprint' import { firstSentence, toPlainText } from 'lib/render/text.js' type Endpoint = Blueprint['routes'][number]['endpoints'][number] type Parameter = Endpoint['request']['parameters'][number] export interface CommandFlag { /** Long form without the leading `--`, or `null` for short-only flags. */ long: string | null /** Short form without the leading `-`, or `null` when there is none. */ short: string | null description: string /** Known values for the flag, used to complete and document its argument. */ values: string[] /** Whether the flag is followed by a value. */ takesValue: boolean isRequired: boolean } /** * Whether a command is part of the CLI itself or calls a Seam API endpoint. */ export type CommandKind = 'cli' | 'api' /** * A value written after the command rather than behind a flag, e.g., the URL * in `seam select endpoint `. At most one, always required: it is the * one thing the command is about. */ export interface CommandPositional { /** Name shown in the usage line, without the angle brackets. */ name: string description: string } export interface CommandDefinition { path: string[] kind: CommandKind /** One line naming what the command does. */ title: string /** Longer prose about the command, empty when there is none to add. */ description: string flags: CommandFlag[] /** The value the command takes after its path, when it takes one. */ positional?: CommandPositional } export interface Subcommand { name: string /** 'api' when the name holds any command that calls the Seam API. */ kind: CommandKind description: string } export interface CommandGroup { /** Command path completed by this group, empty for `seam` itself. */ path: string[] subcommands: Subcommand[] } export interface CommandSpec { /** Every invocable command, sorted by command path. */ commands: CommandDefinition[] /** Every incomplete command path, sorted by command path. */ groups: CommandGroup[] /** Flags accepted regardless of the command. */ globalFlags: CommandFlag[] } export const globalFlags: CommandFlag[] = [ { long: 'endpoint', short: null, description: 'Seam API endpoint to run this one command against, instead of the selected one.', values: [], takesValue: true, isRequired: false, }, { long: 'help', short: 'h', description: 'Display this help guide.', values: [], takesValue: false, isRequired: false, }, { long: 'interactive', short: 'i', description: 'Always prompt to review and edit properties, prefilled with the given arguments.', values: [], takesValue: false, isRequired: false, }, { long: 'json', short: null, description: 'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with --no-json.', values: [], takesValue: false, isRequired: false, }, { long: 'raw', short: null, description: 'Pass request parameters as an inline JSON object.', values: [], takesValue: true, isRequired: false, }, { long: 'non-interactive', short: 'y', description: 'Never prompt: exit with an error if the command or any required property is missing.', values: [], takesValue: false, isRequired: false, }, { long: 'remote-schema', short: null, description: 'Use the schema served by the Seam API.', values: [], takesValue: false, isRequired: false, }, { long: 'update', short: null, description: 'Force an update of the cached Seam API schema.', values: [], takesValue: false, isRequired: false, }, { long: 'version', short: null, description: 'Print the CLI version.', values: [], takesValue: false, isRequired: false, }, { long: 'workspace-id', short: null, description: 'Workspace to run this one command against, instead of the selected one.', values: [], takesValue: true, isRequired: false, }, ] export const flagTokens = (flag: CommandFlag): string[] => { const tokens = [] if (flag.long != null) tokens.push(`--${flag.long}`) if (flag.short != null) tokens.push(`-${flag.short}`) return tokens } /** * Derive the command spec from the API schema, merged with the commands * the CLI declares itself (see `commands/registry.ts`, the single source of * those declarations). */ export const getCommandSpec = ( blueprint: Blueprint, localCommands: CommandDefinition[] = [], ): CommandSpec => { const commands = sortByPath( dedupeByPath([ ...blueprint.routes .flatMap((route) => route.endpoints) .map(toCommandDefinition) // Command and flag names end up unquoted or single-quoted in shell // scripts, so drop any the definitions should never contain rather // than emit something a shell could read as syntax. .filter((command) => command.path.every(isSafeToken)), ...localCommands, ]), ) return { commands, groups: toCommandGroups(commands), globalFlags } } export const findCommand = ( spec: CommandSpec, path: string[], ): CommandDefinition | undefined => spec.commands.find((command) => isSamePath(command.path, path)) export const findGroup = ( spec: CommandSpec, path: string[], ): CommandGroup | undefined => spec.groups.find((group) => isSamePath(group.path, path)) export const isSamePath = (a: string[], b: string[]): boolean => a.length === b.length && a.every((word, index) => word === b[index]) export const stringFlag = (long: string, description: string): CommandFlag => ({ long, short: null, description, values: [], takesValue: true, isRequired: false, }) const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => { const description = toPlainText(endpoint.description) return { path: toCommandPath(endpoint.path), kind: 'api', title: endpoint.title === '' ? firstSentence(description) : toPlainText(endpoint.title), description, flags: [...endpoint.request.parameters] .map(toCommandFlag) .filter((flag) => flag.long == null || isSafeToken(flag.long)) .sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)), } } const toCommandFlag = (parameter: Parameter): CommandFlag => ({ long: toFlagName(parameter.name), short: null, description: toPlainText(parameter.description), values: toFlagValues(parameter), takesValue: true, isRequired: parameter.isRequired, }) const toFlagValues = (parameter: Parameter): string[] => { let values: string[] = [] if (parameter.format === 'enum') { values = parameter.values.map(({ name }) => name).filter(isSafeToken) } else if (parameter.format === 'list' && parameter.itemFormat === 'enum') { values = parameter.itemEnumValues .map(({ name }) => name) .filter(isSafeToken) } else if (parameter.format === 'boolean') { // Nothing marks parameters as boolean-only flags, so minimist reads the // next argument as the value. values = ['true', 'false'] } return parameter.isNullable ? [...values, 'null'] : values } /** * Whether a word is safe to write into a shell script. Command, flag, and * enum names come from the API schema and are embedded unquoted or * single-quoted in completion scripts, so never emit one that a shell could * read as syntax. */ const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token) interface GroupEntry { isCommand: boolean kind: CommandKind description: string } const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => { const groups = new Map>() for (const command of commands) { for (const [depth, name] of command.path.entries()) { const key = command.path.slice(0, depth).join(' ') const entries = groups.get(key) ?? new Map() groups.set(key, entries) // An entry is an API command if any command it holds calls the API. const kind = command.kind === 'api' ? 'api' : (entries.get(name)?.kind ?? 'cli') // A command and a group may share a name, e.g., a hypothetical // `seam devices` alongside `seam devices list`. Prefer the command // title, since it describes what running the name does. if (depth === command.path.length - 1) { entries.set(name, { isCommand: true, kind, description: command.title, }) continue } const entry = entries.get(name) entries.set(name, { isCommand: entry?.isCommand ?? false, kind, description: entry?.description ?? '', }) } } // Groups have no description of their own in the API schema, so name // the commands they hold instead. Leave the list whole: help wraps it, and // completion shortens it to fit a menu column. const summarizeGroup = (key: string): string => [...(groups.get(key)?.keys() ?? [])].join(', ') return [...groups] .map(([key, entries]) => ({ path: key === '' ? [] : key.split(' '), subcommands: [...entries] .map(([name, entry]) => ({ name, kind: entry.kind, description: entry.isCommand ? entry.description : summarizeGroup(key === '' ? name : `${key} ${name}`), })) .sort((a, b) => compare(a.name, b.name)), })) .sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) } const dedupeByPath = (commands: CommandDefinition[]): CommandDefinition[] => { const byPath = new Map() for (const command of commands) { const key = command.path.join(' ') if (byPath.has(key)) continue byPath.set(key, command) } return [...byPath.values()] } const sortByPath = (commands: CommandDefinition[]): CommandDefinition[] => [...commands].sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) const compare = (a: string | null, b: string | null): number => (a ?? '') < (b ?? '') ? -1 : (a ?? '') > (b ?? '') ? 1 : 0 const toCommandPath = (path: string): string[] => path.replace(/^\//, '').split('/').map(toFlagName) const toFlagName = (name: string): string => name.replace(/_/g, '-')