diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index f49167905ab..37f562b115c 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -142,7 +142,7 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( const reference = op ? commandReference(OPENAPI_DOCS, op, jsonFields, cursorSlot(op) !== null) : {} - const called = calledOperations(command) ?? (operation ? [operation] : []) + const called = calledOperations(command) ?? [] return { path: cmdPath, description: command.description(), diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 295f93110eb..28c2cdf0c84 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -45,17 +45,16 @@ import { writeCredentialsProfile, } from '../config/index' import { ProfileOverrideError, redact } from '../config/profile' -import { clientFrom, globalsOf, profileFrom } from '../context' +import { globalsOf, profileFrom } from '../context' import { setSoftExitCode } from '../embed-context' -import { - type GetMetaResponse, - type GetWorkspaceResponse, - type ListWorkspacesResponse, - V2_OPERATIONS, +import type { + GetMetaResponse, + GetWorkspaceResponse, + ListWorkspacesResponse, } from '../generated/v2-api' -import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client' +import { SimApiError } from '../http/client' import { type Column, printList, printRecord, safeOneLine, text } from '../output/render' -import { callsOperations } from '../runtime/called-operations' +import { callsOperations, type OperationClient } from '../runtime/called-operations' type SelectableWorkspace = ListWorkspacesResponse['data'][number] @@ -265,18 +264,18 @@ function requireStoredAuthentication(profile: ResolvedProfile): string { } async function getWorkspaceById( - client: Pick, + client: OperationClient<'getWorkspace'>, workspaceId: string ): Promise { - const operation = V2_OPERATIONS.getWorkspace - const response = await client.request( - resolvePath(operation.path, { workspaceId }), - { method: operation.method } - ) + const response = await client.request('getWorkspace', { + params: { workspaceId }, + }) return response.data } -async function chooseWorkspace(client: Pick): Promise { +async function chooseWorkspace( + client: OperationClient<'listWorkspaces'> +): Promise { if (!process.stdin.isTTY) { throw new SimApiError( 'No workspace provided. Pass --workspace when creating a profile non-interactively.', @@ -284,9 +283,7 @@ async function chooseWorkspace(client: Pick): Promise(client, operation.path, { - method: operation.method, + const workspaces = await client.requestAllPages('listWorkspaces', { query: { sortBy: 'name', sortOrder: 'asc' }, pageSize: 100, limit: MAX_INTERACTIVE_WORKSPACES + 1, @@ -323,14 +320,16 @@ async function chooseWorkspace(client: Pick): Promise', 'Name for the new profile') .option('-w, --workspace ', 'Existing workspace to use; omit for an interactive picker') .action(async (profileName: string, _options: unknown, command: Command) => { validateNewProfileName(profileName) - const { client, profile } = clientFrom(command) + const { client, profile } = connectAdd() const authProfile = requireStoredAuthentication(profile) const credential = readStoredCredential(authProfile) const workspaceId = globalsOf(command).workspace @@ -866,7 +865,7 @@ const WHOAMI_EXIT_CODES = { * whose metadata endpoint is unavailable. */ async function verifyProfile( - client: Pick, + client: OperationClient<'getMeta' | 'getWorkspace'>, profile: ResolvedProfile ): Promise { if (!profile.apiKey && !profile.oauth) { @@ -881,11 +880,8 @@ async function verifyProfile( let keyType: KeyType | null = null let authenticated: boolean | null = null - const metaOperation = V2_OPERATIONS.getMeta try { - const response = await client.request(metaOperation.path, { - method: metaOperation.method, - }) + const response = await client.request('getMeta') const reportedKeyType = toRecord(toRecord(response).data).keyType if ( reportedKeyType === 'personal' || @@ -918,12 +914,10 @@ async function verifyProfile( } } - const operation = V2_OPERATIONS.getWorkspace try { - const response = await client.request( - resolvePath(operation.path, { workspaceId: profile.workspaceId }), - { method: operation.method } - ) + const response = await client.request('getWorkspace', { + params: { workspaceId: profile.workspaceId }, + }) const { id, name, memberCount } = response.data // Projected field by field: the record carries display fields the machine // output has no business inventing a contract for. @@ -969,10 +963,12 @@ function presentVerification(verification: Verification): string { export function whoamiCommand(): Command { const whoami = new Command('whoami') + const connectWhoami = callsOperations(whoami, ['getMeta', 'getWorkspace']) + return whoami .description('Show the resolved profile, where each setting came from, and whether it works') .option('--no-verify', 'Skip the API check and only print the resolved settings') - .action(async (options: { verify: boolean }, command: Command) => { - const { client, profile } = clientFrom(command) + .action(async (options: { verify: boolean }) => { + const { client, profile } = connectWhoami() const { sources } = profile const authentication = presentAuthentication(sources.credential) @@ -1035,7 +1031,6 @@ export function whoamiCommand(): Command { const exitCode = WHOAMI_EXIT_CODES[verification.status] if (exitCode !== 0) setSoftExitCode(exitCode) }) - return callsOperations(whoami, ['getMeta', 'getWorkspace']) } interface ProfileRow { diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index 7833084d91b..bbd54d19364 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -1,5 +1,4 @@ import { type Command, Option } from 'commander' -import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' import { type CreateCredentialConnectionResponse, @@ -9,7 +8,7 @@ import { } from '../generated/v2-api' import { SimApiError } from '../http/client' import { describeOperation } from '../runtime/build' -import { callsOperations, runsOperation } from '../runtime/called-operations' +import { apiCommand, type Connection } from '../runtime/called-operations' import { coerce } from '../runtime/request' import { renderResult } from '../runtime/result' @@ -108,15 +107,12 @@ async function credentialValues( } async function createServiceAccount( - command: Command, + { client, profile }: Connection<'listCredentialProviders' | 'createServiceAccountCredential'>, providerId: string, options: CreateServiceAccountOptions ): Promise { - const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() - const discovery = V2_OPERATIONS.listCredentialProviders - const catalog = await client.request(discovery.path, { - method: discovery.method, + const catalog = await client.request('listCredentialProviders', { query: { workspaceId }, }) const provider = serviceAccountProvider(catalog.data, providerId) @@ -125,19 +121,20 @@ async function createServiceAccount( } const credentialFields = await credentialValues(provider, options.credentials) - const operation = V2_OPERATIONS.createServiceAccountCredential - const response = await client.request(operation.path, { - method: operation.method, - body: { - workspaceId, - type: 'service_account', - providerId, - displayName: options.name, - ...(options.description ? { description: options.description } : {}), - ...(options.id ? { id: options.id } : {}), - credentials: JSON.stringify(credentialFields), - }, - }) + const response = await client.request( + 'createServiceAccountCredential', + { + body: { + workspaceId, + type: 'service_account', + providerId, + displayName: options.name, + ...(options.description ? { description: options.description } : {}), + ...(options.id ? { id: options.id } : {}), + credentials: JSON.stringify(credentialFields), + }, + } + ) renderResult( 'createServiceAccountCredential', @@ -147,16 +144,19 @@ async function createServiceAccount( ) } -async function createConnectionLink(command: Command, body: ConnectionBody): Promise { - const { client, profile } = clientFrom(command) - const operation = V2_OPERATIONS.createCredentialConnection - const response = await client.request(operation.path, { - method: operation.method, - body: { - workspaceId: client.requireWorkspace(), - ...body, - }, - }) +async function createConnectionLink( + { client, profile }: Connection<'createCredentialConnection'>, + body: ConnectionBody +): Promise { + const response = await client.request( + 'createCredentialConnection', + { + body: { + workspaceId: client.requireWorkspace(), + ...body, + }, + } + ) renderResult('createCredentialConnection', profile.output, response.data, CONNECTION_RESULT) } @@ -194,8 +194,13 @@ export function attachCredentialCommands(program: Command): void { acceptNameOnUpdate(credentials) - const create = credentials - .command('create') + const [create, connectCreate] = apiCommand( + credentials, + 'create', + ['listCredentialProviders', 'createServiceAccountCredential'], + { runs: 'createServiceAccountCredential' } + ) + create .argument('', 'Service-account provider to create a credential for') .description( describeOperation( @@ -217,14 +222,12 @@ export function attachCredentialCommands(program: Command): void { '--id ', 'Client-generated credential ID when provider discovery requires it' ) - .action((providerId: string, options: CreateServiceAccountOptions, command: Command) => - createServiceAccount(command, providerId, options) + .action((providerId: string, options: CreateServiceAccountOptions) => + createServiceAccount(connectCreate(), providerId, options) ) - callsOperations(create, ['listCredentialProviders', 'createServiceAccountCredential']) - runsOperation(create, 'createServiceAccountCredential') - credentials - .command('connect') + const [connect, connectLink] = apiCommand(credentials, 'connect', ['createCredentialConnection']) + connect .argument('', 'OAuth provider to connect') .description( describeOperation( @@ -233,12 +236,14 @@ export function attachCredentialCommands(program: Command): void { ) ) .requiredOption('--name ', 'Name shown for the new credential in Sim (required)') - .action(async (providerId: string, options: { name: string }, command: Command) => - createConnectionLink(command, { providerId, displayName: options.name }) + .action(async (providerId: string, options: { name: string }) => + createConnectionLink(connectLink(), { providerId, displayName: options.name }) ) - credentials - .command('reconnect') + const [reconnect, reconnectLink] = apiCommand(credentials, 'reconnect', [ + 'createCredentialConnection', + ]) + reconnect .argument('', 'Existing OAuth credential to re-authorize') .description( describeOperation( @@ -246,7 +251,5 @@ export function attachCredentialCommands(program: Command): void { 'Create a short-lived link for reconnecting an OAuth credential' ) ) - .action((credentialId: string, _options: unknown, command: Command) => - createConnectionLink(command, { credentialId }) - ) + .action((credentialId: string) => createConnectionLink(reconnectLink(), { credentialId })) } diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index daea2db8df1..c896519ab28 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -2,12 +2,11 @@ import type { Command } from 'commander' import { embedStore } from '#sim-cli/embed-context' import { writeStderr, writeStdout } from '#sim-cli/output/io' import { styles } from '#sim-cli/output/presentation' -import { clientFrom } from '../../context' -import { type ChatResponse, V2_OPERATIONS } from '../../generated/v2-api' +import type { ChatResponse } from '../../generated/v2-api' import { SimApiError } from '../../http/client' import { readNdjson } from '../../http/ndjson' import { sanitize } from '../../output/render' -import { callsOperations } from '../../runtime/called-operations' +import { apiCommand } from '../../runtime/called-operations' import { printProtocolResult } from './result' /** The final payload, as `POST /api/v2/chat` answers it. */ @@ -99,8 +98,8 @@ function ignoreBrokenPipe(stream: NodeJS.WriteStream): () => void { */ export function attachChat(program: Command): void { - const chat = program - .command('chat') + const [chat, connectChat] = apiCommand(program, 'chat', ['chat']) + chat .description('Ask Sim and print the reply') .argument('', 'What to ask Sim') .allowExcessArguments(false) @@ -119,7 +118,7 @@ Examples: $ sim --output json chat "Summarize yesterday's failed runs" | jq -r '.content' ` ) - .action(async (message: string, options: ChatOptions, command: Command) => { + .action(async (message: string, options: ChatOptions) => { /** * Refused here so the refusal names what the caller typed: the route * answers in its own field names, and this command builds its request by @@ -138,11 +137,10 @@ Examples: ) } - const { client, profile } = clientFrom(command) + const { client, profile } = connectChat() const workspaceId = client.requireWorkspace() - const response = await client.requestRaw(V2_OPERATIONS.chat.path, { - method: 'POST', + const response = await client.requestRaw('chat', { body: { workspaceId, message, @@ -198,5 +196,4 @@ Examples: restorePipeHandling?.() } }) - callsOperations(chat, ['chat']) } diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index c9d47649e30..7012a541606 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -6,11 +6,9 @@ import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' import { writeStdout } from '#sim-cli/output/io' -import { clientFrom } from '../../context' import { embedStore } from '../../embed-context' -import { V2_OPERATIONS } from '../../generated/v2-api' -import { isRequestTimeout, RAISE_TIMEOUT_HINT, resolvePath, SimApiError } from '../../http/client' -import { callsOperations } from '../../runtime/called-operations' +import { isRequestTimeout, RAISE_TIMEOUT_HINT, SimApiError } from '../../http/client' +import { apiCommand, type Connection } from '../../runtime/called-operations' import { printProtocolResult } from './result' function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { @@ -283,16 +281,16 @@ interface DownloadOutputOptions { force?: boolean } -type DownloadOperation = (typeof V2_OPERATIONS)['downloadFile' | 'downloadFileVersion'] +type DownloadOperation = 'downloadFile' | 'downloadFileVersion' /** * Streams a binary v2 download to stdout or atomically to `--output-file`. Shared by every * command that downloads file bytes, so each gets the same terminal guard and overwrite rules. */ -async function downloadToOutput( - command: Command, - operation: DownloadOperation, - pathParams: Record, +async function downloadToOutput( + connectDownload: () => Connection, + operation: Operation, + params: Record, options: DownloadOutputOptions ): Promise { const target = options.outputFile @@ -301,10 +299,10 @@ async function downloadToOutput( throw new SimApiError('--force requires --output-file ', 0) } - const { client, profile } = clientFrom(command) + const { client, profile } = connectDownload() const workspaceId = client.requireWorkspace() - const response = await client.requestRaw(resolvePath(operation.path, pathParams), { - method: operation.method, + const response = await client.requestRaw(operation, { + params, query: { workspaceId }, }) if (!response.body) { @@ -330,37 +328,35 @@ async function downloadToOutput( const savedTarget = await saveToFile(response.body, target, Boolean(options.force)) printProtocolResult(profile.output, { - id: pathParams.fileId, + id: params.fileId, path: savedTarget, status: 'saved', }) } export function attachFileGet(files: Command): void { - const get = files - .command('get') + const [get, connectGet] = apiCommand(files, 'get', ['downloadFile']) + get .argument('', 'File whose content to read') .allowExcessArguments(false) .description('Download a file’s content to stdout or a local file') .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') - .action((fileId: string, options: DownloadOutputOptions, command: Command) => - downloadToOutput(command, V2_OPERATIONS.downloadFile, { fileId }, options) + .action((fileId: string, options: DownloadOutputOptions) => + downloadToOutput(connectGet, 'downloadFile', { fileId }, options) ) - callsOperations(get, ['downloadFile']) } export function attachFileVersionDownload(versions: Command): void { - const download = versions - .command('download') + const [download, connectDownload] = apiCommand(versions, 'download', ['downloadFileVersion']) + download .argument('', 'File identifier.') .argument('', 'Version number.') .allowExcessArguments(false) .description('Download the content of one version of a file') .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') - .action((fileId: string, version: string, options: DownloadOutputOptions, command: Command) => - downloadToOutput(command, V2_OPERATIONS.downloadFileVersion, { fileId, version }, options) + .action((fileId: string, version: string, options: DownloadOutputOptions) => + downloadToOutput(connectDownload, 'downloadFileVersion', { fileId, version }, options) ) - callsOperations(download, ['downloadFileVersion']) } diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 2a7ad8e052c..3c9cf421dec 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,48 +1,51 @@ import type { Command } from 'commander' -import { clientFrom } from '../../context' import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' -import { V2_OPERATIONS } from '../../generated/v2-api' +import { apiCommand } from '../../runtime/called-operations' import { encodeFolderPath } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' export function attachFileUpload(files: Command): void { - files - .command('upload') + const [upload, connectUpload] = apiCommand(files, 'upload', [ + 'createFileUpload', + 'createFileUploadPartUrls', + 'completeFileUpload', + 'abortFileUpload', + ]) + upload .argument('', 'Local file to upload') .allowExcessArguments(false) .description('Upload a file to the workspace') .option('--folder ', 'Folder path as shown in the app; defaults to the root folder') .option('--name ', 'Store it under a different name') - .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { - const { client, profile } = clientFrom(command) + .action(async (path: string, options: { folder?: string; name?: string }) => { + const { client, profile } = connectUpload() const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) - const created = await client.request( - V2_OPERATIONS.createFileUpload.path, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - // `` above is a LOCAL file and must stay untouched; only the - // destination folder is a wire-encoded API path. - ...(options.folder !== undefined - ? { folderPath: encodeFolderPath(options.folder) } - : {}), - }, - } - ) + const created = await client.request('createFileUpload', { + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + // `` above is a LOCAL file and must stay untouched; only the + // destination folder is a wire-encoded API path. + ...(options.folder !== undefined ? { folderPath: encodeFolderPath(options.folder) } : {}), + }, + }) const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( + const completed: CompleteFileUploadResponse['data'] = await finishUploadSession( client, workspaceId, { - basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + operations: { + parts: 'createFileUploadPartUrls', + complete: 'completeFileUpload', + abort: 'abortFileUpload', + }, + params: { uploadId: session.id }, uploadToken, transfer, size, diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index b93ad1e7e71..836298364b1 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,11 +1,11 @@ import { type Command, Option } from 'commander' -import { clientFrom } from '../../context' import type { CompleteKnowledgeDocumentUploadResponse, CreateKnowledgeDocumentUploadBody, CreateKnowledgeDocumentUploadResponse, } from '../../generated/v2-api' import { SimApiError } from '../../http/client' +import { apiCommand } from '../../runtime/called-operations' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' @@ -60,8 +60,13 @@ const LANGUAGE_TAG_HELP = 'Document language tag: hyphen-separated letter and digit subtags, for example en or en-US' export function attachKnowledgeDocumentUpload(documents: Command): void { - documents - .command('upload') + const [upload, connectUpload] = apiCommand(documents, 'upload', [ + 'createKnowledgeDocumentUpload', + 'createKnowledgeDocumentUploadPartUrls', + 'completeKnowledgeDocumentUpload', + 'abortKnowledgeDocumentUpload', + ]) + upload .argument('', 'Knowledge base to upload into') .argument('', 'Local file to upload') .allowExcessArguments(false) @@ -71,19 +76,14 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { .addOption(new Option('--recipe ', 'Document processing recipe').choices(UPLOAD_RECIPES)) .option('--lang ', LANGUAGE_TAG_HELP) .action( - async ( - knowledgeBaseId: string, - path: string, - options: KnowledgeDocumentUploadOptions, - command: Command - ) => { - const { client, profile } = clientFrom(command) + async (knowledgeBaseId: string, path: string, options: KnowledgeDocumentUploadOptions) => { + const { client, profile } = connectUpload() const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) const created = await client.request( - `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + 'createKnowledgeDocumentUpload', { - method: 'POST', + params: { knowledgeBaseId }, body: { workspaceId, name, @@ -94,21 +94,23 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { } ) const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession< - CompleteKnowledgeDocumentUploadResponse['data'] - >( - client, - workspaceId, - { - basePath: `/api/v2/knowledge/${encodeURIComponent( - knowledgeBaseId - )}/documents/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) + const completed: CompleteKnowledgeDocumentUploadResponse['data'] = + await finishUploadSession( + client, + workspaceId, + { + operations: { + parts: 'createKnowledgeDocumentUploadPartUrls', + complete: 'completeKnowledgeDocumentUpload', + abort: 'abortKnowledgeDocumentUpload', + }, + params: { knowledgeBaseId, uploadId: session.id }, + uploadToken, + transfer, + size, + }, + path + ) if (!completed.document) { throw new Error(`Knowledge upload ${session.id} completed without a document`) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-export.ts b/packages/sim-cli/src/commands/protocol/knowledge-export.ts index 253a712cffa..d5b1c6fe29c 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-export.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-export.ts @@ -1,10 +1,8 @@ import { basename } from 'node:path' import type { Command } from 'commander' -import { clientFrom } from '../../context' import { embedStore } from '../../embed-context' -import { V2_OPERATIONS } from '../../generated/v2-api' -import { resolvePath, SimApiError } from '../../http/client' -import { callsOperations } from '../../runtime/called-operations' +import { SimApiError } from '../../http/client' +import { apiCommand } from '../../runtime/called-operations' import { isTerminalSafeContentType, saveToFile, streamToStdout } from './files-get' import { printProtocolResult } from './result' @@ -45,8 +43,8 @@ function safeBaseName(name: string): string | null { } export function attachKnowledgeExport(knowledge: Command): void { - const exportCommand = knowledge - .command('export') + const [exportCommand, connectExport] = apiCommand(knowledge, 'export', ['exportKnowledgeBase']) + exportCommand .argument('', 'Knowledge base to export') .allowExcessArguments(false) .description('Export a knowledge base as a .simkb.zip bundle') @@ -59,17 +57,16 @@ export function attachKnowledgeExport(knowledge: Command): void { '--no-vectors', 'Leave chunk vectors out of the bundle, so an import re-embeds every chunk' ) - .action(async (knowledgeBaseId: string, options: KnowledgeExportOptions, command: Command) => { + .action(async (knowledgeBaseId: string, options: KnowledgeExportOptions) => { const writesToStdout = options.outputFile === '-' if (writesToStdout && options.force) { throw new SimApiError('--force requires --output-file ', 0) } - const { client, profile } = clientFrom(command) + const { client, profile } = connectExport() const workspaceId = client.requireWorkspace() - const operation = V2_OPERATIONS.exportKnowledgeBase - const response = await client.requestRaw(resolvePath(operation.path, { knowledgeBaseId }), { - method: operation.method, + const response = await client.requestRaw('exportKnowledgeBase', { + params: { knowledgeBaseId }, query: { workspaceId, vectors: options.vectors }, }) if (!response.body) { @@ -107,5 +104,4 @@ export function attachKnowledgeExport(knowledge: Command): void { vectors: options.vectors, }) }) - callsOperations(exportCommand, ['exportKnowledgeBase']) } diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.ts b/packages/sim-cli/src/commands/protocol/logs-follow.ts index 02043521703..0a812451347 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.ts @@ -4,11 +4,10 @@ import { dump } from 'js-yaml' import { printLine, writeStderr } from '#sim-cli/output/io' import { hasProgressTerminal, styles } from '#sim-cli/output/presentation' import type { OutputFormat } from '../../config/index' -import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' import type { ColumnSpec } from '../../contract/types' import { type ListLogsResponse, V2_OPERATIONS } from '../../generated/v2-api' -import { SimApiError, type SimClient } from '../../http/client' +import { SimApiError } from '../../http/client' import { bool, bytes, @@ -19,6 +18,7 @@ import { timestamp, visibleWidth, } from '../../output/render' +import { apiCommand, type OperationClient } from '../../runtime/called-operations' import { encodeFolderPath } from '../../runtime/request' /** One run, as `GET /api/v2/logs` returns it. */ @@ -421,8 +421,7 @@ interface PollBatch { } async function collectUnprinted( - client: Pick, - path: string, + client: OperationClient<'listLogs'>, query: Record, state: FollowState, pageSize: number, @@ -433,7 +432,7 @@ async function collectUnprinted( let truncated = false for (let page = 0; page < maxPages; page += 1) { - const response: ListLogsResponse = await client.request(path, { + const response: ListLogsResponse = await client.request('listLogs', { query: { ...query, limit: pageSize, cursor }, }) const page_rows = response?.data ?? [] @@ -497,8 +496,8 @@ function inSeconds(ms: number): number { * take the same arguments and render the same columns. */ export function attachLogsFollow(logs: Command): void { - logs - .command('follow') + const [follow, connectFollow] = apiCommand(logs, 'follow', ['listLogs']) + follow .description('Watch runs live as they arrive, printing each new run once') .option('--workflow ', 'Only follow runs of this workflow (repeatable)', collect, []) .option( @@ -536,12 +535,11 @@ Examples: $ sim --output json logs follow | jq -r '.runId' ` ) - .action(async (options: FollowOptions, command: Command) => { + .action(async (options: FollowOptions) => { const lines = nonNegativeInteger(options.lines, '--lines') const delay = intervalMs(options.interval) - const { client, profile } = clientFrom(command) - const path = V2_OPERATIONS.listLogs.path + const { client, profile } = connectFollow() const query = { workspaceId: client.requireWorkspace(), workflowIds: options.workflow?.length ? options.workflow.join(',') : undefined, @@ -570,7 +568,7 @@ Examples: // The backlog page doubles as the seed: every run on it is recorded and // its oldest start time becomes the floor, so even `-n 0` anchors the // follow to now instead of replaying the workspace's whole history. - const seed = await collectUnprinted(client, path, query, state, Math.max(lines, 1), 1) + const seed = await collectUnprinted(client, query, state, Math.max(lines, 1), 1) remember(state, seed.rows) state.floor = seed.rows.at(-1)?.startedAt ?? null // The API clamps `limit` into 1–1000 rather than rejecting it, so a @@ -594,14 +592,7 @@ Examples: let fresh: PollBatch try { - fresh = await collectUnprinted( - client, - path, - query, - state, - POLL_PAGE_SIZE, - MAX_PAGES_PER_POLL - ) + fresh = await collectUnprinted(client, query, state, POLL_PAGE_SIZE, MAX_PAGES_PER_POLL) } catch (error) { if (!isTransient(error)) throw error failures += 1 diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 71650fbaa18..5620b9a3654 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -1,25 +1,56 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OutputFormat, ResolvedProfile } from '../../config/index' import { buildGeneratedCommands } from '../../runtime/build' import { attachProtocolCommands } from './index' -const { mockRequest, output } = vi.hoisted(() => ({ - mockRequest: vi.fn(), +interface SentRequest { + method: string + url: URL + body: unknown +} + +interface Wire { + output: { format: OutputFormat } + sent: SentRequest[] + respond: (url: URL) => unknown +} + +const wire: Wire = vi.hoisted(() => ({ output: { format: 'json' }, + sent: [], + respond: () => ({ data: [], nextCursor: null }), })) -vi.mock('../../context', () => ({ - clientFrom: () => ({ - client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, - profile: { - workspaceId: 'ws_local', - output: output.format, - name: 'default', - apiKey: 'k', - endpoint: 'https://sim.example', +vi.mock('../../context', async () => { + const { SimClient } = await import('../../http/client') + return { + clientFrom: () => { + const profile: ResolvedProfile = { + name: 'default', + authProfile: 'default', + endpoint: 'https://sim.example', + apiKey: 'k', + oauth: null, + workspaceId: 'ws_local', + output: wire.output.format, + sources: { endpoint: 'flag', credential: 'flag', workspaceId: 'flag', output: 'flag' }, + transport: async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + wire.sent.push({ + method: init?.method ?? 'GET', + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }) + return new Response(JSON.stringify(wire.respond(url)), { + headers: { 'content-type': 'application/json' }, + }) + }, + } + return { client: new SimClient(profile), profile } }, - }), -})) + } +}) function program(): Command { const root = new Command('sim').exitOverride() @@ -34,31 +65,37 @@ function program(): Command { } beforeEach(() => { - mockRequest.mockReset() - output.format = 'json' + wire.sent.length = 0 + wire.respond = () => ({ data: [], nextCursor: null }) + wire.output.format = 'json' }) +function sentTo(path: string): SentRequest[] { + return wire.sent.filter((request) => request.url.pathname === path) +} + describe('resource directory', () => { it('encodes the path both commands take, as every contract-driven flag does', async () => { // These two build their own request, so `buildRequest`'s encoding never ran // for them: `--folder '/Folder 1'` worked while `ls '/Folder 1'` was // rejected as non-canonical, and `mkdir` disagreed with the `folders // create` the README calls its long form. - mockRequest.mockResolvedValue({ data: [], nextCursor: null }) vi.spyOn(console, 'log').mockImplementation(() => {}) await program().parseAsync(['node', 'sim', 'table', 'ls', '/Q1 (draft)']) - expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { - query: expect.objectContaining({ parentPath: '/Q1%20%28draft%29' }), - }) + const [listed] = sentTo('/api/v2/tables/folders') + expect(listed.method).toBe('GET') + expect(listed.url.searchParams.get('parentPath')).toBe('/Q1%20%28draft%29') - mockRequest.mockReset() - mockRequest.mockResolvedValue({ data: { folder: {} } }) + wire.sent.length = 0 + wire.respond = () => ({ data: { folder: {} } }) await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Q1 (draft)']) - expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { - method: 'POST', - body: { workspaceId: 'ws_local', path: '/Q1%20%28draft%29' }, - }) + expect(sentTo('/api/v2/tables/folders')).toEqual([ + expect.objectContaining({ + method: 'POST', + body: { workspaceId: 'ws_local', path: '/Q1%20%28draft%29' }, + }), + ]) }) it('decodes folder paths for the human formats but leaves json on the wire form', async () => { @@ -66,8 +103,8 @@ describe('resource directory', () => { // format never reached it: the sibling `folders list` printed `/Folder 2` // while `ls` printed `/Folder%202` for the same folder, one column away // from the decoded `name` it prints beside it. - mockRequest.mockImplementation(async (path: string) => { - if (path === '/api/v2/tables/folders') { + wire.respond = (url) => { + if (url.pathname === '/api/v2/tables/folders') { return { data: [ { @@ -91,18 +128,18 @@ describe('resource directory', () => { ], nextCursor: null, } - }) + } const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - output.format = 'text' + wire.output.format = 'text' await program().parseAsync(['node', 'sim', 'table', 'ls', '/Folder 2']) expect(logged.join('\n')).toContain('/Folder 2/New folder') expect(logged.join('\n')).not.toContain('%20') logged.length = 0 - output.format = 'json' + wire.output.format = 'json' await program().parseAsync(['node', 'sim', 'table', 'ls', '/Folder 2']) const entries = JSON.parse(logged[0]) as Array<{ kind: string; ref: string }> expect(entries.find((entry) => entry.kind === 'folder')?.ref).toBe('/Folder%202/New%20folder') diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index 6c02d8e5a2f..eb86fec8669 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -1,5 +1,4 @@ import { type Command, Option } from 'commander' -import { clientFrom } from '../../context' import { type ListFileFoldersResponse, type ListFilesResponse, @@ -10,10 +9,10 @@ import { type ListWorkflowFoldersResponse, type ListWorkflowsResponse, V2_OPERATIONS, - type V2OperationName, } from '../../generated/v2-api' -import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { SimApiError, type V2Page } from '../../http/client' import { type Column, printList, text, timestamp } from '../../output/render' +import { apiCommand, type OperationClient } from '../../runtime/called-operations' import { DEFAULT_PAGE_SIZE } from '../../runtime/options' import { encodeFolderPath } from '../../runtime/request' import { decodeFolderPath, renderResult } from '../../runtime/result' @@ -92,12 +91,10 @@ const COLUMNS: Column[] = [ { header: 'updated', value: (entry) => timestamp(entry.updatedAt) }, ] -function operationPath(operation: V2OperationName): string { - return V2_OPERATIONS[operation].path -} +type ListingOperation = ResourceDirectoryConfig['resources'] | FolderListOperation async function listResources( - client: SimClient, + client: OperationClient, config: ResourceDirectoryConfig, workspaceId: string, folderPath: string, @@ -105,15 +102,14 @@ async function listResources( limit: number ): Promise { const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } - const path = operationPath(config.resources) const paginated = 'cursor' in V2_OPERATIONS[config.resources].query if (!paginated) { - const page = await client.request>(path, { query }) + const page = await client.request>(config.resources, { query }) return page.data.slice(0, limit) } - return requestAllPages(client, path, { + return client.requestAllPages(config.resources, { query, pageSize: DEFAULT_PAGE_SIZE, limit, @@ -121,13 +117,13 @@ async function listResources( } async function listFolders( - client: SimClient, + client: OperationClient, operation: FolderListOperation, workspaceId: string, parentPath: string, search: string | undefined ): Promise { - const page = await client.request>(operationPath(operation), { + const page = await client.request>(operation, { query: { workspaceId, parentPath, search, sortBy: 'name', sortOrder: 'asc' }, }) return page.data @@ -163,16 +159,15 @@ export function attachResourceDirectoryCommands( group: Command, config: ResourceDirectoryConfig ): void { - group - .command('ls') - .argument('[path]', 'Folder path to list; defaults to the root folder') + const [ls, connectLs] = apiCommand(group, 'ls', [config.resources, config.folders]) + ls.argument('[path]', 'Folder path to list; defaults to the root folder') .allowExcessArguments(false) .description(`List ${config.kind} resources and child folders together`) .option('--search ', 'Filter folders and resources by name') .addOption( new Option('--limit ', 'Maximum combined items to return (0 for everything)').default('0') ) - .action(async (path: string | undefined, options: ListOptions, command: Command) => { + .action(async (path: string | undefined, options: ListOptions) => { const rawLimit = Number(options.limit) if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) @@ -183,7 +178,7 @@ export function attachResourceDirectoryCommands( // applies to every contract-driven folder flag has to be applied here too // — otherwise `--folder '/Folder 1'` works and `ls '/Folder 1'` does not. const folderPath = encodeFolderPath(path ?? '/') - const { client, profile } = clientFrom(command) + const { client, profile } = connectLs() const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ listFolders(client, config.folders, workspaceId, folderPath, options.search), @@ -194,16 +189,14 @@ export function attachResourceDirectoryCommands( printList(profile.output, shown, COLUMNS) }) - group - .command('mkdir') + const [mkdir, connectMkdir] = apiCommand(group, 'mkdir', [config.createFolder]) + mkdir .argument('', 'Folder path to create; the leading / is optional') .allowExcessArguments(false) .description(`Create a ${config.kind} directory at a path`) - .action(async (path: string, _options: Record, command: Command) => { - const { client, profile } = clientFrom(command) - const operation = V2_OPERATIONS[config.createFolder] - const result = await client.request<{ data?: unknown }>(operation.path, { - method: operation.method, + .action(async (path: string) => { + const { client, profile } = connectMkdir() + const result = await client.request<{ data?: unknown }>(config.createFolder, { body: { workspaceId: client.requireWorkspace(), path: encodeFolderPath(path) }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 7bc3b6df21a..4c5a5a9dd6c 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -2,14 +2,13 @@ import { setTimeout as sleep } from 'node:timers/promises' import { type Command, Option } from 'commander' import { writeStderr } from '#sim-cli/output/io' import { hasProgressTerminal, styles } from '#sim-cli/output/presentation' -import { clientFrom } from '../../context' import type { CompleteTableImportResponse, CreateTableImportResponse, GetTableImportResponse, } from '../../generated/v2-api' -import { V2_OPERATIONS } from '../../generated/v2-api' -import { SimApiError, type SimClient } from '../../http/client' +import { SimApiError } from '../../http/client' +import { apiCommand, type OperationClient } from '../../runtime/called-operations' import { coerce, encodeFolderPath, type FieldSpec } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' @@ -93,7 +92,7 @@ function progressLine(job: TableImport): string { } async function watchImport( - client: SimClient, + client: OperationClient<'getTableImport'>, workspaceId: string, job: TableImport ): Promise { @@ -102,10 +101,10 @@ async function watchImport( while (!IMPORT_SETTLED.has(current.status)) { await sleep(IMPORT_POLL_MS) - const next = await client.request<{ data: TableImport }>( - `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, - { query: { workspaceId } } - ) + const next = await client.request<{ data: TableImport }>('getTableImport', { + params: { importId: current.id }, + query: { workspaceId }, + }) current = next.data const line = progressLine(current) if (hasProgressTerminal() && line !== reported) { @@ -144,8 +143,14 @@ function validateTargetOptions(options: ImportOptions): boolean { } export function attachTableImport(tables: Command): void { - tables - .command('import') + const [importCommand, connectImport] = apiCommand(tables, 'import', [ + 'createTableImport', + 'createTableImportPartUrls', + 'completeTableImport', + 'cancelTableImport', + 'getTableImport', + ]) + importCommand .argument('[path]', 'Local CSV file to import; omit when using --file-id') .allowExcessArguments(false) .description('Import a CSV, into a new table by default') @@ -170,8 +175,8 @@ export function attachTableImport(tables: Command): void { // required for a single shape of the command. .option('-y, --yes', 'Confirm this destructive operation (required with --mode replace)') .option('--no-wait', 'Return once the import is queued instead of watching it') - .action(async (path: string | undefined, options: ImportOptions, command: Command) => { - const { client, profile } = clientFrom(command) + .action(async (path: string | undefined, options: ImportOptions) => { + const { client, profile } = connectImport() const workspaceId = client.requireWorkspace() if (Boolean(path) === Boolean(options.fileId)) { @@ -219,41 +224,43 @@ export function attachTableImport(tables: Command): void { } } - const started = await client.request( - V2_OPERATIONS.createTableImport.path, - { - method: 'POST', - body: { - workspaceId, - source, - target, - ...(options.mapping - ? { mapping: await jsonFlag(options.mapping, 'mapping', 'object') } - : {}), - ...(options.createColumns - ? { createColumns: await jsonFlag(options.createColumns, 'create-columns', 'array') } - : {}), - ...(options.timezone ? { timezone: options.timezone } : {}), - }, - } - ) + const started = await client.request('createTableImport', { + body: { + workspaceId, + source, + target, + ...(options.mapping + ? { mapping: await jsonFlag(options.mapping, 'mapping', 'object') } + : {}), + ...(options.createColumns + ? { createColumns: await jsonFlag(options.createColumns, 'create-columns', 'array') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + }) let job: TableImport = started.data.session if (path) { if (!local || !started.data.uploadToken || !started.data.transfer) { throw new Error('Local table import did not return an upload transfer') } - job = await finishUploadSession( + const completed: CompleteTableImportResponse['data'] = await finishUploadSession( client, workspaceId, { - basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + operations: { + parts: 'createTableImportPartUrls', + complete: 'completeTableImport', + abort: 'cancelTableImport', + }, + params: { importId: job.id }, uploadToken: started.data.uploadToken, transfer: started.data.transfer, size: local.size, }, path ) + job = completed } if (!options.wait) { diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 1a94ceb8cd2..d51b1423d50 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -2,13 +2,13 @@ import { isRecordLike } from '@sim/utils/object' import type { Command } from 'commander' import { writeStderr } from '#sim-cli/output/io' import { styles } from '#sim-cli/output/presentation' -import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' import { embedStore } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' import { SimApiError } from '../../http/client' import { readNdjson } from '../../http/ndjson' import { safeOneLine, sanitize } from '../../output/render' +import { connect } from '../../runtime/called-operations' import { executeOperation, runFailureMessage } from '../../runtime/execute' import { retypeApiError } from '../../runtime/naming' import { buildRequest } from '../../runtime/request' @@ -169,14 +169,14 @@ function withEmbeddedFileReferences( /** Runs synchronously while keeping idle-limited HTTP paths active. */ async function runWithResultStream(workflowId: string, command: Command): Promise { const flags = command.optsWithGlobals() as Record - const { client, profile } = clientFrom(command) + const { client, profile } = connect(command, ['executeWorkflow']) const operation = V2_OPERATIONS.executeWorkflow as OperationSpec const commandSpec = CLI_CONTRACT.executeWorkflow ?? {} try { const request = await buildRequest('executeWorkflow', [workflowId], flags, profile.workspaceId) - const response = await client.requestRaw(request.path, { - method: operation.method, + const response = await client.requestRaw('executeWorkflow', { + params: request.params, query: request.query, body: withEmbeddedFileReferences(request.body), headers: { ...request.headers, accept: WORKFLOW_RESULT_STREAM_CONTENT_TYPE }, @@ -369,12 +369,12 @@ async function followRun(workflowId: string, command: Command): Promise { const includeToolCalls = flags.includeToolCalls === true const negotiates = includeThinking || includeToolCalls - const { client, profile } = clientFrom(command) + const { client, profile } = connect(command, ['executeWorkflow']) const operation = V2_OPERATIONS.executeWorkflow as OperationSpec const request = await buildRequest('executeWorkflow', [workflowId], flags, profile.workspaceId) - const response = await client.requestRaw(request.path, { - method: 'POST', + const response = await client.requestRaw('executeWorkflow', { + params: request.params, query: request.query, body: { ...withEmbeddedFileReferences(request.body), diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-get.ts b/packages/sim-cli/src/commands/protocol/workflow-run-get.ts index d8f0be2942a..d08ecb18a71 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-get.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-get.ts @@ -1,9 +1,13 @@ import { isRecordLike } from '@sim/utils/object' import type { Command } from 'commander' -import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' import { V2_OPERATIONS } from '../../generated/v2-api' -import { resolvePath, SimApiError, type SimClient } from '../../http/client' +import { SimApiError } from '../../http/client' +import { + type Connection, + callsOperations, + type OperationClient, +} from '../../runtime/called-operations' import { retypeApiError } from '../../runtime/naming' import { buildRequest, readListValues } from '../../runtime/request' import { renderResult } from '../../runtime/result' @@ -47,11 +51,11 @@ interface WorkflowBlock { * since the run still resolves — to its id, which is what the recording keyed * on — and a block deleted since does not, which the refusal lists. */ -async function loadWorkflowBlocks(client: SimClient, workflowId: string): Promise { - const operation = V2_OPERATIONS.getWorkflowState - const raw = await client.request(resolvePath(operation.path, { workflowId }), { - method: operation.method, - }) +async function loadWorkflowBlocks( + client: OperationClient<'getWorkflowState'>, + workflowId: string +): Promise { + const raw = await client.request('getWorkflowState', { params: { workflowId } }) const state = isRecordLike(raw) && isRecordLike(raw.data) ? raw.data : raw const blocks = isRecordLike(state) && isRecordLike(state.blocks) ? Object.entries(state.blocks) : [] @@ -162,9 +166,13 @@ function keyByTyped(payload: unknown, typedBy: ReadonlyMap): unk * builder, the same result renderer, and the same field-spelling retype on a * server refusal. */ -async function readRunByName(runId: string, typed: string[], command: Command): Promise { +async function readRunByName( + runId: string, + typed: string[], + command: Command, + { client, profile }: Connection<'getWorkflowRun' | 'getWorkflowState'> +): Promise { const flags = command.optsWithGlobals() as Record - const { client, profile } = clientFrom(command) const operation = V2_OPERATIONS.getWorkflowRun as OperationSpec const spec = CLI_CONTRACT.getWorkflowRun ?? {} @@ -187,8 +195,8 @@ async function readRunByName(runId: string, typed: string[], command: Command): let result: { data?: unknown } | undefined try { - result = await client.request<{ data?: unknown }>(request.path, { - method: operation.method, + result = await client.request<{ data?: unknown }>('getWorkflowRun', { + params: request.params, headers: request.headers, query: request.query, body: request.body, @@ -228,6 +236,7 @@ export function attachWorkflowRunGet(runs: Command): void { ) } const previous = held as (args: unknown[]) => unknown + const connectGet = callsOperations(get, ['getWorkflowRun', 'getWorkflowState']) get.action(async (runId: string, _options: unknown, command: Command): Promise => { const raw: unknown = (command.optsWithGlobals() as Record).selectOutput @@ -243,6 +252,6 @@ export function attachWorkflowRunGet(runs: Command): void { await previous(command.processedArgs) return } - await readRunByName(runId, typed, command) + await readRunByName(runId, typed, command, connectGet()) }) } diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts index f5785d1c7c0..f4282227d29 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts @@ -3,12 +3,12 @@ import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { type Command, Option } from 'commander' import { printError, writeStderr } from '#sim-cli/output/io' import { hasProgressTerminal, styles } from '#sim-cli/output/presentation' -import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' import type { CommandSpec } from '../../contract/types' import { setSoftExitCode } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' -import { resolvePath, SimApiError } from '../../http/client' +import { SimApiError } from '../../http/client' +import { apiCommand } from '../../runtime/called-operations' import { renderResult } from '../../runtime/result' /** @@ -211,8 +211,8 @@ function runSpec(): CommandSpec { /** Adds `workflows runs wait` — poll one run until it stops moving. */ export function attachWorkflowRunWait(runs: Command): void { - runs - .command('wait') + const [wait, connectWait] = apiCommand(runs, 'wait', ['getWorkflowRun']) + wait .argument('', V2_OPERATIONS.getWorkflowRun.pathParamDocs?.runId) .allowExcessArguments(false) .description('Wait for a run to reach a terminal state, then show it') @@ -225,69 +225,62 @@ export function attachWorkflowRunWait(runs: Command): void { `Give up after this many seconds, or 0 to wait indefinitely (default: ${DEFAULT_WAIT_TIMEOUT_SECONDS}). Bounds the whole wait; SIM_TIMEOUT_SECONDS bounds one request` ) ) - .action( - async ( - runId: string, - options: { workflow: string; waitTimeout?: string }, - command: Command - ) => { - const timeoutSeconds = - options.waitTimeout === undefined - ? DEFAULT_WAIT_TIMEOUT_SECONDS - : parseWaitTimeout(options.waitTimeout) + .action(async (runId: string, options: { workflow: string; waitTimeout?: string }) => { + const timeoutSeconds = + options.waitTimeout === undefined + ? DEFAULT_WAIT_TIMEOUT_SECONDS + : parseWaitTimeout(options.waitTimeout) - const { client, profile } = clientFrom(command) - const operation = V2_OPERATIONS.getWorkflowRun - const path = resolvePath(operation.path, { workflowId: options.workflow, runId }) + const { client, profile } = connectWait() + const params = { workflowId: options.workflow, runId } - const startedAt = Date.now() - const deadline = - timeoutSeconds === 0 ? Number.POSITIVE_INFINITY : startedAt + timeoutSeconds * 1000 - const progress = waitProgress() - let delayMs = FIRST_POLL_DELAY_MS + const startedAt = Date.now() + const deadline = + timeoutSeconds === 0 ? Number.POSITIVE_INFINITY : startedAt + timeoutSeconds * 1000 + const progress = waitProgress() + let delayMs = FIRST_POLL_DELAY_MS - // `finally`, because a request that throws part-way through would - // otherwise leave `running — waiting 12s…` sitting on the line the error - // is then written onto. - try { - while (true) { - const raw = await client.request(path, { method: operation.method }) - const snapshot = readRun(raw) - const outcome = classify(snapshot) + // `finally`, because a request that throws part-way through would + // otherwise leave `running — waiting 12s…` sitting on the line the error + // is then written onto. + try { + while (true) { + const raw = await client.request('getWorkflowRun', { params }) + const snapshot = readRun(raw) + const outcome = classify(snapshot) - if (outcome) { - progress.finish() - renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) - const message = explain(outcome, runId, options.workflow, snapshot) - if (message) printError(styles().red(message)) - setSoftExitCode(WAIT_EXIT_CODES[outcome]) - return - } + if (outcome) { + progress.finish() + renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) + const message = explain(outcome, runId, options.workflow, snapshot) + if (message) printError(styles().red(message)) + setSoftExitCode(WAIT_EXIT_CODES[outcome]) + return + } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - progress.finish() - renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) - printError( - styles().red( - `Timed out after ${timeoutSeconds}s waiting for run ${runId} (status: ${snapshot.status}${ - snapshot.resumeAt ? `, resuming at ${snapshot.resumeAt}` : '' - }). Raise ${WAIT_TIMEOUT_FLAG}, or set it to 0 to wait indefinitely.` - ) + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + progress.finish() + renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) + printError( + styles().red( + `Timed out after ${timeoutSeconds}s waiting for run ${runId} (status: ${snapshot.status}${ + snapshot.resumeAt ? `, resuming at ${snapshot.resumeAt}` : '' + }). Raise ${WAIT_TIMEOUT_FLAG}, or set it to 0 to wait indefinitely.` ) - setSoftExitCode(WAIT_EXIT_CODES.timeout) - return - } - - progress.advance(snapshot.status, Date.now() - startedAt) - // Clamped to the time left so the last sleep of a bounded wait ends - // at the deadline instead of overshooting it by a whole interval. - await sleep(Math.min(delayMs, remainingMs)) - delayMs = Math.min(delayMs * POLL_BACKOFF_FACTOR, MAX_POLL_DELAY_MS) + ) + setSoftExitCode(WAIT_EXIT_CODES.timeout) + return } - } finally { - progress.finish() + + progress.advance(snapshot.status, Date.now() - startedAt) + // Clamped to the time left so the last sleep of a bounded wait ends + // at the deadline instead of overshooting it by a whole interval. + await sleep(Math.min(delayMs, remainingMs)) + delayMs = Math.min(delayMs * POLL_BACKOFF_FACTOR, MAX_POLL_DELAY_MS) } + } finally { + progress.finish() } - ) + }) } diff --git a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts index 16636b87755..96ad018e1b2 100644 --- a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts +++ b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { GetWorkspaceOperationResponse } from '../../generated/v2-api' import { SimApiError, SimClient } from '../../http/client' +import { OperationClient } from '../../runtime/called-operations' import { assertWorkspaceOperationOutcome, waitWorkspaceOperation } from './workspace-operation-wait' const report: GetWorkspaceOperationResponse['data'] = { @@ -14,8 +15,8 @@ const report: GetWorkspaceOperationResponse['data'] = { issues: [], } -function fixtureClient() { - return new SimClient({ +function fixtureClient(): OperationClient<'getWorkspaceOperation'> { + const http = new SimClient({ name: 'fixture', endpoint: 'https://fixture.invalid', authProfile: 'fixture', @@ -25,6 +26,11 @@ function fixtureClient() { output: 'json', sources: { endpoint: 'default', credential: 'env', workspaceId: 'env', output: 'default' }, }) + return new OperationClient( + http, + new Set(['getWorkspaceOperation']), + 'sim workspaces operations wait' + ) } afterEach(() => { diff --git a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts index b3d9d62e943..0ccb22f5309 100644 --- a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts +++ b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts @@ -1,9 +1,9 @@ import { sleep } from '@sim/utils/helpers' import type { Command } from 'commander' -import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' -import { type GetWorkspaceOperationResponse, V2_OPERATIONS } from '../../generated/v2-api' -import { resolvePath, SimApiError, type SimClient } from '../../http/client' +import type { GetWorkspaceOperationResponse } from '../../generated/v2-api' +import { SimApiError } from '../../http/client' +import { apiCommand, type OperationClient } from '../../runtime/called-operations' import { renderResult } from '../../runtime/result' type WorkspaceOperation = GetWorkspaceOperationResponse['data'] @@ -79,7 +79,7 @@ export function assertWorkspaceOperationOutcome( /** Polls only the existing operation; uncertain mutations are never retried with a new ID. */ export async function waitWorkspaceOperation( - client: SimClient, + client: OperationClient<'getWorkspaceOperation'>, workspaceId: string, operationId: string, timeoutSeconds: number, @@ -87,7 +87,6 @@ export async function waitWorkspaceOperation( ): Promise<{ report: WorkspaceOperation; timedOut: boolean }> { const deadline = timeoutSeconds === 0 ? Number.POSITIVE_INFINITY : Date.now() + timeoutSeconds * 1000 - const path = resolvePath(V2_OPERATIONS.getWorkspaceOperation.path, { workspaceId, operationId }) let report = initial let delay = 1000 for (;;) { @@ -112,7 +111,8 @@ export async function waitWorkspaceOperation( } try { const nextReport = readWorkspaceOperation( - await client.request(path, { + await client.request('getWorkspaceOperation', { + params: { workspaceId, operationId }, signal: Number.isFinite(remaining) ? AbortSignal.timeout(Math.max(1, Math.ceil(remaining))) : undefined, @@ -161,17 +161,17 @@ export async function waitWorkspaceOperation( } export function attachWorkspaceOperationWait(operations: Command): void { - operations - .command('wait') + const [wait, connectWait] = apiCommand(operations, 'wait', ['getWorkspaceOperation']) + wait .argument('', 'Operation ID returned by import, fork, push, or pull') .allowExcessArguments(false) .description( 'Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout' ) .option('--wait-timeout ', 'Maximum total wait (default 3600; 0 waits indefinitely)') - .action(async (operationId: string, options: { waitTimeout?: string }, command: Command) => { + .action(async (operationId: string, options: { waitTimeout?: string }) => { const timeout = workspaceWaitTimeout(options.waitTimeout) - const { client, profile } = clientFrom(command) + const { client, profile } = connectWait() const workspaceId = client.requireWorkspace() const result = await waitWorkspaceOperation(client, workspaceId, operationId, timeout) renderResult( diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts index 6d25662663a..a4dddf561b8 100644 --- a/packages/sim-cli/src/commands/secrets.ts +++ b/packages/sim-cli/src/commands/secrets.ts @@ -2,11 +2,11 @@ import { type Command, Option } from 'commander' import { exitCli } from '#sim-cli/embed-context' import { printError } from '#sim-cli/output/io' import { styles } from '#sim-cli/output/presentation' -import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' import { type SetSecretResponse, V2_OPERATIONS } from '../generated/v2-api' -import { resolvePath, SimApiError } from '../http/client' +import { SimApiError } from '../http/client' import { describeOperation } from '../runtime/build' +import { apiCommand, type Connection } from '../runtime/called-operations' import { readArgumentSource } from '../runtime/request' import { renderResult } from '../runtime/result' import { promptSecret, SecretInputCancelledError } from '../terminal/secret-input' @@ -118,7 +118,7 @@ async function readSecretValue(options: SetSecretOptions): Promise Connection<'setSecret'>, redactionSpellings: ReadonlySet ): Promise { if (redactionSpellings.size > 1) { @@ -130,10 +130,9 @@ async function setSecret( const description = validateWorkspaceOnlyFlag('description', options.description, options.scope) const unredacted = validateWorkspaceOnlyFlag('unredacted', options.unredacted, options.scope) const value = await readSecretValue(options) - const { client, profile } = clientFrom(command) - const operation = V2_OPERATIONS.setSecret - const response = await client.request(resolvePath(operation.path, { name }), { - method: operation.method, + const { client, profile } = connectSet() + const response = await client.request('setSecret', { + params: { name }, body: { workspaceId: client.requireWorkspace(), scope: options.scope, @@ -161,8 +160,8 @@ export function attachSecretCommands(program: Command): void { */ const redactionSpellings = new Set() - secrets - .command('set') + const [set, connectSet] = apiCommand(secrets, 'set', ['setSecret']) + set .argument('', 'Secret name, as referenced in workflows') .description( describeOperation( @@ -190,7 +189,7 @@ export function attachSecretCommands(program: Command): void { .option('--no-unredacted', 'Send --unredacted as false') .on('option:unredacted', () => redactionSpellings.add('--unredacted')) .on('option:no-unredacted', () => redactionSpellings.add('--no-unredacted')) - .action((name: string, options: SetSecretOptions, command: Command) => - setSecret(name, options, command, redactionSpellings) + .action((name: string, options: SetSecretOptions) => + setSecret(name, options, connectSet, redactionSpellings) ) } diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index ef9c9e16447..d40e1095f24 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -36,6 +36,7 @@ export function profileFrom(command: Command, extra: ProfileOverrides = {}): Res }) } +/** The raw client behind a command. Only `connect` calls it: commands reach the API declared. */ export function clientFrom(command: Command): { client: SimClient; profile: ResolvedProfile } { const profile = profileFrom(command) return { client: new SimClient(profile, { refreshOAuth: refreshStoredOAuth }), profile } diff --git a/packages/sim-cli/src/contract/reference.test.ts b/packages/sim-cli/src/contract/reference.test.ts index d2785533a23..99d3bd3e68f 100644 --- a/packages/sim-cli/src/contract/reference.test.ts +++ b/packages/sim-cli/src/contract/reference.test.ts @@ -94,6 +94,7 @@ describe('CLI reference producer', () => { commands.find((entry) => entry.path.join(' ') === path)?.mothershipUnavailable expect(flag('meta status')).toBe(true) expect(flag('whoami')).toBe(true) + expect(flag('profiles add')).toBe(true) expect(flag('files versions download')).toBe(true) expect(flag('chat')).toBe(true) expect(flag('workflows run')).toBeUndefined() diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index d3a8dc47914..e3ca431f120 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec, CommandVariantSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' -import { runsOperation } from './called-operations' +import { callsOperations } from './called-operations' import { deriveCommandPath } from './derive' import { executeOperation } from './execute' import { retypeApiError } from './naming' @@ -249,7 +249,12 @@ function configureOperation( spec: CommandSpec ): Command { const operationSpec = V2_OPERATIONS[operation] as OperationSpec - runsOperation(command, operation) + // A workspace mutation is followed by reading its operation receipt under `--wait`. + callsOperations( + command, + spec.workspaceOperation ? [operation, 'getWorkspaceOperation'] : [operation], + { runs: operation } + ) command.allowExcessArguments(false) for (const alias of spec.aliases ?? []) command.alias(alias) diff --git a/packages/sim-cli/src/runtime/called-operations.test.ts b/packages/sim-cli/src/runtime/called-operations.test.ts new file mode 100644 index 00000000000..d1601e1fa92 --- /dev/null +++ b/packages/sim-cli/src/runtime/called-operations.test.ts @@ -0,0 +1,90 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { V2OperationName } from '../generated/v2-api' +import { SimClient } from '../http/client' +import { OperationClient } from './called-operations' + +interface SentRequest { + method: string + url: string +} + +/** A real client whose transport records what reaches the wire. */ +function wireClient(operations: V2OperationName[], sent: SentRequest[]) { + const http = new SimClient({ + name: 'fixture', + authProfile: 'fixture', + endpoint: 'https://sim.example', + apiKey: 'fixture-key', + oauth: null, + workspaceId: 'ws-1', + output: 'json', + sources: { endpoint: 'flag', credential: 'flag', workspaceId: 'flag', output: 'flag' }, + transport: async (input, init) => { + sent.push({ method: init?.method ?? 'GET', url: String(input) }) + return new Response(JSON.stringify({ data: {} }), { + headers: { 'content-type': 'application/json' }, + }) + }, + }) + return new OperationClient(http, new Set(operations), 'sim secrets set') +} + +describe('a declared operation client', () => { + /** + * The generated handler reaches its client through the command, so its calls are typed + * as any operation. A call outside the declaration has to be refused before it goes + * out, or the inventory would describe a command that calls more than it says. + */ + it('refuses an operation its command did not declare, before any request', async () => { + const sent: SentRequest[] = [] + + await expect(wireClient(['setSecret'], sent).request('listWorkspaces')).rejects.toThrow( + '"sim secrets set" calls listWorkspaces, which it does not declare' + ) + expect(sent).toEqual([]) + }) + + it('sends a declared operation to its route with its method, encoding each id', async () => { + const sent: SentRequest[] = [] + + await wireClient(['setSecret'], sent).request('setSecret', { + params: { name: 'a/b?c' }, + body: { value: 'x' }, + }) + + expect(sent).toEqual([{ method: 'PUT', url: 'https://sim.example/api/v2/secrets/a%2Fb%3Fc' }]) + }) +}) + +/** + * The inventory is only as true as the declarations, and a declaration only binds a + * command that cannot reach the API another way. A raw client is the one way around + * it, so building one stays confined to the places that wrap it in a declaration. + */ +it('leaves no command a raw client to call the API with', () => { + const root = join(import.meta.dirname, '..') + const construct = 'new SimClient(' + const connectRaw = 'clientFrom(' + const allowed: Record = { + 'context.ts': [construct, connectRaw], + 'runtime/called-operations.ts': [connectRaw], + // The public embed API hands server code a client of its own; no command uses it. + 'embed.ts': [construct], + // Rebuilt for cleanup through `over`, which keeps the session's declaration. + 'transfer/upload-session.ts': [construct], + } + const raw: string[] = [] + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.endsWith('.test.ts')) continue + const file = join(entry.parentPath, entry.name) + const name = relative(root, file) + if (name.startsWith('test/') || name.startsWith('generated/')) continue + const text = readFileSync(file, 'utf8') + for (const call of [construct, connectRaw]) { + if (text.includes(call) && !allowed[name]?.includes(call)) raw.push(`${name}: ${call}`) + } + } + expect(raw).toEqual([]) +}) diff --git a/packages/sim-cli/src/runtime/called-operations.ts b/packages/sim-cli/src/runtime/called-operations.ts index ca9e2f4f4f2..21ccb325479 100644 --- a/packages/sim-cli/src/runtime/called-operations.ts +++ b/packages/sim-cli/src/runtime/called-operations.ts @@ -1,38 +1,165 @@ import type { Command } from 'commander' -import type { V2OperationName } from '../generated/v2-api' +import type { ResolvedProfile } from '../config/index' +import { clientFrom } from '../context' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { + type RequestAllPagesOptions, + type RequestOptions, + requestAllPages, + resolvePath, + type SimClient, + type WorkspaceOptions, +} from '../http/client' +import { commandPath } from './derive' const CALLED_OPERATIONS = new WeakMap() const RUNS_OPERATION = new WeakMap() /** - * Records the one v2 operation whose request and response a command presents as its - * own: every generated command, and a hand-written one that prints a single call's result. + * The one operation whose request and response a command presents as its own, as its + * declaration named it: every generated command, and a hand-written one that prints a + * single call's result. * - * The command inventory reads it here rather than mapping paths back to operations: a - * derived path can equal another operation's (`undeployWorkflow` derives - * `workflows deploy`), and a reverse lookup by path then describes the wrong one. + * The inventory reads it here rather than mapping paths back to operations: a derived + * path can equal another operation's (`undeployWorkflow` derives `workflows deploy`), and + * a reverse lookup by path then describes the wrong one. */ -export function runsOperation(command: Command, operation: V2OperationName): Command { - RUNS_OPERATION.set(command, operation) - return command -} - export function operationOf(command: Command): V2OperationName | undefined { return RUNS_OPERATION.get(command) } +/** The v2 operations a command declared it calls; absent when it calls none. */ +export function calledOperations(command: Command): readonly V2OperationName[] | undefined { + return CALLED_OPERATIONS.get(command) +} + +/** Options for a call to a named operation; `params` fills its `[param]` path segments. */ +type OperationOptions = Omit & { + params?: Record +} + +type ClientCalls = Pick + /** - * Records the v2 operations a hand-written command calls. + * How a command reaches the API: each call names its operation, and only the operations + * the command declared are accepted. * - * A hand-written command is opaque until it says what it calls, and the inventory needs - * that to describe it truthfully, such as whether every call is open to a Mothership - * caller. + * Route and method come from the operation table, so no call can address an endpoint + * the declaration leaves out. That keeps the inventory's view of a command, and with it + * whether Mothership may run it, true without a list anyone has to keep in sync. */ -export function callsOperations(command: Command, operations: readonly V2OperationName[]): Command { +export class OperationClient { + constructor( + private readonly client: ClientCalls, + private readonly operations: ReadonlySet, + private readonly caller: string + ) {} + + requireWorkspace(explicit?: string, options?: WorkspaceOptions): string { + return this.client.requireWorkspace(explicit, options) + } + + async request( + operation: Operation, + { params, ...options }: OperationOptions = {} + ): Promise { + return this.client.request(this.route(operation, params), { + ...options, + method: V2_OPERATIONS[operation].method, + }) + } + + async requestRaw( + operation: Operation, + { params, ...options }: OperationOptions = {} + ): Promise { + return this.client.requestRaw(this.route(operation, params), { + ...options, + method: V2_OPERATIONS[operation].method, + }) + } + + async requestAllPages( + operation: Operation, + { params, ...options }: OperationOptions + ): Promise { + return requestAllPages(this.client, this.route(operation, params), { + ...options, + method: V2_OPERATIONS[operation].method, + }) + } + + /** + * The same declared operations over another client. A request's own signal cannot + * stand in: the client also honours its profile's signal, which is what a cleanup + * after cancellation has to escape. + */ + over(client: ClientCalls): OperationClient { + return new OperationClient(client, this.operations, this.caller) + } + + private route(operation: V2OperationName, params?: Record): string { + if (!this.operations.has(operation)) { + throw new Error(`"${this.caller}" calls ${operation}, which it does not declare`) + } + return resolvePath(V2_OPERATIONS[operation].path, params) + } +} + +export interface Connection { + client: OperationClient + profile: ResolvedProfile +} + +function caller(command: Command): string { + return `sim ${commandPath(command).join(' ')}` +} + +/** + * Connects a command by its declaration, for code handed the command rather than the + * declaration: the generated handler, and the protocols layered onto a generated leaf. + * Naming the operations a caller relies on types the client to them and refuses a + * declaration that leaves one out. + */ +export function connect( + command: Command, + expects: readonly Operation[] = [] +): Connection { + const operations = new Set(CALLED_OPERATIONS.get(command)) + const missing = operations.size === 0 ? 'what it calls' : expects.find((o) => !operations.has(o)) + if (missing) { + throw new Error(`"${caller(command)}" calls the API without declaring ${missing}`) + } + const { client, profile } = clientFrom(command) + return { client: new OperationClient(client, operations, caller(command)), profile } +} + +interface Declaration { + /** The one operation whose result the command prints as its own. */ + runs?: NoInfer +} + +/** + * Declares every v2 operation a command can call, and returns how it connects: a client + * typed to exactly these operations, so a call to any other does not compile. + */ +export function callsOperations( + command: Command, + operations: readonly Operation[], + { runs }: Declaration = {} +): () => Connection { CALLED_OPERATIONS.set(command, operations) - return command + if (runs) RUNS_OPERATION.set(command, runs) + return () => connect(command, operations) } -export function calledOperations(command: Command): readonly V2OperationName[] | undefined { - return CALLED_OPERATIONS.get(command) +/** Adds a subcommand that calls the API, declared before anything else is attached. */ +export function apiCommand( + parent: Command, + name: string, + operations: readonly Operation[], + declaration: Declaration = {} +): [Command, () => Connection] { + const command = parent.command(name) + return [command, callsOperations(command, operations, declaration)] } diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts index eb0d17a94e8..5ce39c5d9c6 100644 --- a/packages/sim-cli/src/runtime/derive.ts +++ b/packages/sim-cli/src/runtime/derive.ts @@ -1,3 +1,4 @@ +import type { Command } from 'commander' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' /** @@ -68,3 +69,12 @@ export function kebab(value: string): string { export function camel(flag: string): string { return flag.replace(/-([a-z])/g, (_match, character: string) => character.toUpperCase()) } + +/** The command's own name and its ancestors', root excluded, in typing order. */ +export function commandPath(command: Command): string[] { + const names: string[] = [] + for (let current: Command | null = command; current?.parent; current = current.parent) { + names.unshift(current.name()) + } + return names +} diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index 56de623fcef..109042264f8 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -6,8 +6,10 @@ import { type CreateWorkspaceInvitationsResponse, type GetWorkspaceOperationResponse, V2_OPERATIONS, + type V2OperationName, } from '../generated/v2-api' import { SimApiError } from '../http/client' +import { callsOperations } from './called-operations' import { BULK_OUTCOME_CHECKS, executeOperation } from './execute' import type { OperationSpec } from './types' @@ -26,6 +28,13 @@ vi.mock('../context', () => ({ }), })) +/** A leaf that declares what it calls, as `configureOperation` declares every generated one. */ +function leaf(...operations: V2OperationName[]): Command { + const host = new Command('leaf') + callsOperations(host, operations) + return host +} + const EXECUTE_WORKFLOW: OperationSpec = { method: 'POST', path: '/api/v2/workflows/[workflowId]/execute', @@ -84,7 +93,7 @@ const DELETE_TABLE_ROWS: OperationSpec = { /** Invokes a generated command that takes both a path positional and flags. */ function _invokeRowDelete(flags: Record) { - const host = new Command('leaf') + const host = leaf('deleteTableRows') return executeOperation('deleteTableRows', {}, DELETE_TABLE_ROWS, ['tbl_1', flags, host]) } @@ -94,7 +103,7 @@ function invokeWithFlags( spec: OperationSpec, flags: Record ) { - const host = new Command('leaf') + const host = leaf(operation) return executeOperation(operation, {}, spec, [flags, host]) } @@ -104,7 +113,7 @@ function invoke( spec: OperationSpec, ...positional: string[] ) { - const host = new Command('leaf') + const host = leaf(operation) return executeOperation(operation, {}, spec, [...positional, {}, host]) } @@ -124,7 +133,7 @@ const PUBLIC_NOTE = /** Publishes a chat past its `--yes` gate with the required fields and the given extras. */ function publishChat(flags: Record) { - const host = new Command('leaf') + const host = leaf('replaceWorkflowChatDeployment') return executeOperation( 'replaceWorkflowChatDeployment', CLI_CONTRACT.replaceWorkflowChatDeployment ?? {}, @@ -194,7 +203,7 @@ describe('workspace mutation receipt identity', () => { 'pushWorkspace', CLI_CONTRACT.pushWorkspace!, V2_OPERATIONS.pushWorkspace, - [{ ...flags, wait }, new Command('leaf')] + [{ ...flags, wait }, leaf('pushWorkspace', 'getWorkspaceOperation')] ) } @@ -249,7 +258,7 @@ describe('selector pagination metadata', () => { await executeOperation('listSelector', CLI_CONTRACT.listSelector!, V2_OPERATIONS.listSelector, [ { selectorKey: 'gmail.labels', context: '{"oauthCredential":"connection-1"}', limit: '0' }, - new Command('leaf'), + leaf('listSelector'), ]) expect(request).toHaveBeenCalledTimes(2) @@ -373,7 +382,7 @@ describe('workspace invitation batch outcomes', () => { 'createWorkspaceInvitations', CLI_CONTRACT.createWorkspaceInvitations!, V2_OPERATIONS.createWorkspaceInvitations, - [{ emails: ['first@example.com', 'second@example.com'] }, new Command('leaf')] + [{ emails: ['first@example.com', 'second@example.com'] }, leaf('createWorkspaceInvitations')] ) } @@ -411,7 +420,7 @@ describe('workspace invitation batch outcomes', () => { describe('a bulk call that touched nothing', () => { function updateChunks(flags: Record) { - const host = new Command('leaf') + const host = leaf('bulkUpdateKnowledgeChunks') return executeOperation('bulkUpdateKnowledgeChunks', {}, BULK_UPDATE_CHUNKS, [ 'kb_1', 'doc_1', @@ -421,7 +430,7 @@ describe('a bulk call that touched nothing', () => { } function _indexFiles(flags: Record) { - const host = new Command('leaf') + const host = leaf('addWorkspaceFilesToKnowledgeBase') return executeOperation('addWorkspaceFilesToKnowledgeBase', {}, ADD_WORKSPACE_FILES, [ 'kb_1', flags, diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 79686b15593..4397175fcdb 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -8,11 +8,11 @@ import { waitWorkspaceOperation, workspaceWaitTimeout, } from '../commands/protocol/workspace-operation-wait' -import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' import type { GetWorkspaceOperationResponse, V2OperationName } from '../generated/v2-api' import { assertCursorAdvances, pageProgress, SimApiError, type V2Page } from '../http/client' import { safeOneLine } from '../output/render' +import { connect } from './called-operations' import { camel } from './derive' import { DEFAULT_PAGE_SIZE, defaultListLimit } from './options' import { warnRenamedFlag } from './renamed' @@ -432,7 +432,7 @@ export async function executeOperation( throw new SimApiError('--all-workspaces cannot be combined with --workspace', 0) } - const { client, profile } = clientFrom(host) + const { client, profile } = connect(host) const hasWorkspaceField = Boolean( (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) @@ -499,8 +499,8 @@ export async function executeOperation( do { const pageSize = Math.min(DEFAULT_PAGE_SIZE, limit - rows.length) const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} - const page: V2Page = await client.request(request.path, { - method: operationSpec.method, + const page: V2Page = await client.request(operation, { + params: request.params, headers: request.headers, query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, body: @@ -529,8 +529,8 @@ export async function executeOperation( let result: { data?: unknown } try { - result = await client.request<{ data?: unknown }>(request.path, { - method: operationSpec.method, + result = await client.request<{ data?: unknown }>(operation, { + params: request.params, headers: request.headers, query: request.query, body: request.body, diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index d013f30e62e..32bd27c3e21 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -30,12 +30,6 @@ describe('buildRequest', () => { expect(built.body).toBeUndefined() }) - it('percent-encodes path params so an id cannot retarget the request', async () => { - expect((await buildRequest('getTable', ['a/b?c'], {}, WORKSPACE)).path).toBe( - '/api/v2/tables/a%2Fb%3Fc' - ) - }) - it('still sends an explicit zero, which is a value the caller chose', async () => { expect((await buildRequest('listLogs', [], { minCost: '0' }, WORKSPACE)).query).toMatchObject({ minCost: 0, diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 230f9bbd2e1..65cf2a31848 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -515,7 +515,8 @@ const NO_WORKSPACE_FALLBACK = 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' export interface BuiltRequest { - path: string + /** The values filling the operation's `[param]` path segments, encoded when routed. */ + params: Record query: Record body: Record | undefined /** Contract-declared request headers, absent when the operation declares none. */ @@ -561,7 +562,7 @@ export async function buildRequest( const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} const spec: OperationSpec = V2_OPERATIONS[operation] - let path = spec.path + const params: Record = {} let positionalIndex = 0 for (const param of spec.pathParams) { const pathFlag = commandSpec.pathFlags?.[param] @@ -585,8 +586,7 @@ export async function buildRequest( 0 ) } - // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. - path = path.replace(`[${param}]`, encodeURIComponent(value)) + params[param] = value } const query: Record = {} @@ -760,7 +760,7 @@ export async function buildRequest( throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0) } return boundedRequest({ - path, + params, query, body: { ...body, [variant.property]: parsed }, ...headerSlot, @@ -774,7 +774,7 @@ export async function buildRequest( throw new SimApiError('--body must be a JSON object', 0) } return boundedRequest({ - path, + params, query, body: { ...body, ...(parsed as Record) }, ...headerSlot, @@ -782,7 +782,7 @@ export async function buildRequest( } return boundedRequest({ - path, + params, query, /** * A declared JSON body is still an object when all of its fields are optional. diff --git a/packages/sim-cli/src/telemetry/invocation.ts b/packages/sim-cli/src/telemetry/invocation.ts index 821917f2672..9130441ea72 100644 --- a/packages/sim-cli/src/telemetry/invocation.ts +++ b/packages/sim-cli/src/telemetry/invocation.ts @@ -3,6 +3,7 @@ import { cliVersion } from '#sim-cli/version' import { profileFrom } from '../context' import { isCi } from '../environment' import { SimApiError } from '../http/client' +import { commandPath } from '../runtime/derive' import { detectCodingAgent, NO_CODING_AGENT } from './coding-agent' import { telemetryStatus } from './policy' import { loadTelemetryState, nextSession, type TelemetryState, writeTelemetryState } from './state' @@ -135,15 +136,6 @@ interface RecordedInvocation { noticeShown: boolean } -/** The command's own name and its ancestors', root excluded, in typing order. */ -function commandPath(command: Command): string[] { - const names: string[] = [] - for (let current: Command | null = command; current?.parent; current = current.parent) { - names.unshift(current.name()) - } - return names -} - /** * The flags typed on the command line, on the leaf and every ancestor, so * root globals like `--output` count. Only source `cli`: a value that came diff --git a/packages/sim-cli/src/terminal.ts b/packages/sim-cli/src/terminal.ts index 38c25fe5f49..f19dd41e7b2 100644 --- a/packages/sim-cli/src/terminal.ts +++ b/packages/sim-cli/src/terminal.ts @@ -3,7 +3,7 @@ import { dump } from 'js-yaml' import { styles } from '#sim-cli/output/presentation' import { CliUpdateError } from '#sim-cli/update/install' import { ProfileConfigError } from './config/index' -import { clientFrom } from './context' +import { profileFrom } from './context' import { formatApiErrorDetails, isRequestTimeout, @@ -36,7 +36,7 @@ function explainFailure(error: unknown, program: Command): number | null { if (error instanceof SimApiError) { let output = program.opts().output try { - output = clientFrom(program).profile.output + output = profileFrom(program).output } catch { /** Preserve the original error when configuration is invalid. */ } diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index 1d8c2713b59..2f477cbfe23 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -1,6 +1,8 @@ import { openAsBlob } from 'node:fs' import { type EmbeddedFileSnapshot, embeddedProfile, embedStore } from '../embed-context' +import type { V2OperationName } from '../generated/v2-api' import { SimApiError, SimClient } from '../http/client' +import type { OperationClient } from '../runtime/called-operations' import { embeddedFileKey } from './local-file' import { StreamingUpload } from './streaming-upload' @@ -22,8 +24,11 @@ export type UploadTransfer = partCount: number } -export interface UploadSession { - basePath: string +export interface UploadSession { + /** The operations that drive this kind of session past its creation. */ + operations: { parts: Operation; complete: Operation; abort: Operation } + /** The path parameters that address the session. */ + params: Record uploadToken: string transfer: UploadTransfer size: number @@ -60,10 +65,10 @@ async function uploadBytes( if (file instanceof StreamingUpload) file.assertConsumed(end) } -async function uploadParts( - client: SimClient, +async function uploadParts( + client: OperationClient, workspaceId: string, - session: UploadSession, + session: UploadSession, transfer: Extract, file: Blob | StreamingUpload ): Promise { @@ -87,9 +92,9 @@ async function uploadParts( } const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `${session.basePath}/parts`, + session.operations.parts, { - method: 'POST', + params: session.params, query: { workspaceId }, headers: { 'upload-token': session.uploadToken }, body: { partNumbers }, @@ -119,10 +124,10 @@ async function uploadParts( } /** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ -export async function finishUploadSession( - client: SimClient, +export async function finishUploadSession( + client: OperationClient, workspaceId: string, - session: UploadSession, + session: UploadSession, path: string ): Promise { let snapshot: EmbeddedFileSnapshot | undefined @@ -164,8 +169,8 @@ export async function finishUploadSession( await streamed?.close() await snapshot?.dispose().catch(() => {}) - const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { - method: 'POST', + const completed = await client.request<{ data: T }>(session.operations.complete, { + params: session.params, query: { workspaceId }, headers: { 'upload-token': session.uploadToken }, }) @@ -175,11 +180,11 @@ export async function finishUploadSession( /** Stop cancels transfer work, but cleanup needs its own short-lived request signal. */ const profile = embeddedProfile() const cleanupClient = profile - ? new SimClient({ ...profile, signal: AbortSignal.timeout(5_000) }) + ? client.over(new SimClient({ ...profile, signal: AbortSignal.timeout(5_000) })) : client await cleanupClient - .request(session.basePath, { - method: 'DELETE', + .request(session.operations.abort, { + params: session.params, query: { workspaceId }, headers: { 'upload-token': session.uploadToken }, })