diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 6b610b94be1..107c3de232e 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -129,6 +129,13 @@ jobs: - name: Desktop bridge contract audit run: bun run check:desktop-bridge + # The CLI's view of the v2 API is generated from the same Zod contracts + # the routes validate against, so a contract change that skips + # `generate:cli-api` would ship a client describing endpoints the server + # no longer has. + - name: Sim CLI API generation up to date + run: bun run check:cli-api + # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index b270c7567cf..ff7902092a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), -})) +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetPermissions, +})) + import { POST } from '@/app/api/cli/auth/approve/route' const REQUEST = 'a'.repeat(43) @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) + mockGetPermissions.mockResolvedValue('admin') }) it('records the approval for the signed-in user', async () => { @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => { ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'copilot', + workspaceId: undefined, + workspaceBound: false, + }) + }) + + it('defaults to the copilot scope so pre-scope terminals keep working', async () => { + await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })) + expect(mockCreateApproval).toHaveBeenCalledWith( + 'user-1', + REQUEST, + CHALLENGE, + expect.objectContaining({ scope: 'copilot' }) + ) + }) + + it('records a workspace binding when the approver is a workspace admin', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it("records a non-admin's pick as a default without binding the key to it", async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('refuses to bind a key to a workspace the approver is not admin of', async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace the approver is not a member of', async () => { + mockGetPermissions.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(404) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses bindKeyToWorkspace with no workspaceId', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace binding on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() }) it('rejects an unauthenticated caller', async () => { @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => { await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) ) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything()) }) it('rejects a malformed challenge', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 8099914be91..3c361a9bf45 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI') * The approving user comes from the session and nothing else — a client-supplied * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. + * + * Workspace binding is authorized here rather than at poll time: the poll is + * unauthenticated by necessity, so it has no session to check a permission + * against. Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) - logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + + if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { + return NextResponse.json( + { error: 'workspaceId is only valid for the platform scope' }, + { status: 400 } + ) + } + + if (bindKeyToWorkspace && !workspaceId) { + return NextResponse.json( + { error: 'bindKeyToWorkspace requires a workspaceId' }, + { status: 400 } + ) + } + + if (workspaceId) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + // Reading the workspace at all requires membership. Without this, the + // terminal could be handed the id of a workspace the approver cannot see — + // harmless for the key, but it would silently become the profile default and + // every later command would 403 with no explanation. + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Minting a workspace key is an admin action wherever else it is offered; + // the terminal is not a lower bar. Rejected outright rather than downgraded + // to a personal key, so the CLI never quietly stores a different credential + // than the browser said it would. + if (bindKeyToWorkspace && permission !== 'admin') { + return NextResponse.json( + { error: 'Workspace admin permission is required to issue a workspace API key' }, + { status: 403 } + ) + } + } + + await createApproval(session.user.id, requestId, challenge, { + scope, + workspaceId, + workspaceBound: bindKeyToWorkspace, + }) + logger.info('Recorded CLI authorization approval', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + workspaceBound: bindKeyToWorkspace, + }) return NextResponse.json({ ok: true }) }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 89e7422a450..81709bd411b 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -9,12 +9,16 @@ const { mockCompleteApproval, mockReleaseMint, mockGenerateCopilotApiKey, + mockCreatePersonalApiKey, + mockCreateWorkspaceApiKey, mockEnforceIpRateLimit, } = vi.hoisted(() => ({ mockPollApproval: vi.fn(), mockCompleteApproval: vi.fn(), mockReleaseMint: vi.fn(), mockGenerateCopilotApiKey: vi.fn(), + mockCreatePersonalApiKey: vi.fn(), + mockCreateWorkspaceApiKey: vi.fn(), mockEnforceIpRateLimit: vi.fn(), })) @@ -29,6 +33,11 @@ vi.mock('@/lib/copilot/server/api-keys', () => ({ CopilotApiKeyError: class extends Error {}, })) +vi.mock('@/lib/api-key/orchestration', () => ({ + performCreatePersonalApiKey: mockCreatePersonalApiKey, + performCreateWorkspaceApiKey: mockCreateWorkspaceApiKey, +})) + vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit, })) @@ -42,11 +51,31 @@ function pollRequest(body: Record) { return createMockRequest('POST', body) } +/** What `pollApproval` returns for an approval recorded at the given scope. */ +function approved(overrides: Record = {}) { + return { + status: 'approved', + userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, + ...overrides, + } +} + describe('POST /api/cli/auth/poll', () => { beforeEach(() => { vi.clearAllMocks() mockEnforceIpRateLimit.mockResolvedValue(null) mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCreatePersonalApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-2', name: 'CLI', key: 'sim_personal', createdAt: new Date() }, + }) + mockCreateWorkspaceApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-3', name: 'CLI', key: 'sim_workspace', createdAt: new Date() }, + }) mockCompleteApproval.mockResolvedValue(undefined) mockReleaseMint.mockResolvedValue(undefined) }) @@ -60,20 +89,89 @@ describe('POST /api/cli/auth/poll', () => { }) it('mints, then consumes the approval, once approved', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) - expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + // Second precision, not day: a date-only name made the second login of the + // day fail after the user had already approved in the browser. + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith( + 'user-1', + expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/) + ) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) + it('mints a personal platform key when the approval carries no workspace', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: null, + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', source: 'cli' }) + ) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints a workspace-scoped key when the approval carries a workspace', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-3', apiKey: 'sim_workspace' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(mockCreateWorkspaceApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: 'ws-1', source: 'cli' }) + ) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + + it('returns the picked workspace with a personal key when the approval is unbound', async () => { + // A non-admin still picked a workspace in the browser; the terminal needs it + // as its default even though the key is not scoped to it. + mockPollApproval.mockResolvedValue(approved({ scope: 'platform', workspaceId: 'ws-1' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + }) + + it('scope comes from the approval, never from the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'copilot' })) + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, scope: 'platform' }) + ) + await expect(response.json()).resolves.toMatchObject({ scope: 'copilot' }) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(500) @@ -81,14 +179,30 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCompleteApproval).not.toHaveBeenCalled() }) + it('releases the reservation when a platform mint fails', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + mockCreatePersonalApiKey.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A personal API key named "CLI" already exists.', + }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(409) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockCompleteApproval.mockRejectedValue(new Error('redis blip')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // A cleanup failure must not release the mint lock — that would allow a re-mint. expect(mockReleaseMint).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c5a7610f9de..c3e6e8c00c3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + performCreatePersonalApiKey, + performCreateWorkspaceApiKey, +} from '@/lib/api-key/orchestration' +import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' @@ -23,9 +28,64 @@ const POLL_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -/** Keys are named for the day they were issued, matching what the CLI prints. */ +/** + * Names a minted key for the instant it was issued, e.g. `CLI (2026-07-30 + * 15:42:07Z)`. + * + * Second precision, not day: key names are unique per owner, so a date-only + * name made the second login of the day fail outright with "a key named … + * already exists" — after the user had already approved in the browser. UTC so + * the name is unambiguous in a shared workspace list and sorts chronologically. + */ function cliKeyName(): string { - return `CLI (${new Date().toISOString().slice(0, 10)})` + return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` +} + +/** + * Mints from the key space the approval recorded. + * + * A name collision is still surfaced rather than retried under a suffixed name: + * with second precision it means something genuinely unexpected, and silently + * accumulating near-identical rows would hide it. + */ +async function mintForGrant( + grant: ApprovalGrant +): Promise< + { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +> { + const name = cliKeyName() + + if (grant.scope === 'copilot') { + try { + const key = await generateCopilotApiKey(grant.userId, name) + return { ok: true, key } + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' } + } + } + + // `workspaceId` alone only names the terminal's default workspace; binding the + // key to it is a separate, admin-gated decision made at approval. + const result = + grant.workspaceBound && grant.workspaceId + ? await performCreateWorkspaceApiKey({ + workspaceId: grant.workspaceId, + userId: grant.userId, + name, + source: 'cli', + }) + : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) + + if (!result.success || !result.key) { + return { + ok: false, + status: result.errorCode === 'conflict' ? 409 : 500, + message: result.error ?? 'Failed to generate API key', + } + } + + return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } } /** @@ -49,17 +109,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ status: 'pending' }) } - let key: Awaited> - try { - key = await generateCopilotApiKey(result.userId, cliKeyName()) - } catch (error) { + const minted = await mintForGrant(result) + if (!minted.ok) { // Mint failed — release the reservation so a later poll can retry. await releaseMint(requestId) - const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: status ?? 500 } - ) + return NextResponse.json({ error: minted.message }, { status: minted.status }) } // Mint succeeded — the key exists. Consuming the approval is best-effort: a @@ -71,6 +125,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: result.userId, }) }) - logger.info('Minted CLI key on approved poll', { userId: result.userId }) - return NextResponse.json({ status: 'complete', key }) + logger.info('Minted CLI key on approved poll', { + userId: result.userId, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) + return NextResponse.json({ + status: 'complete', + key: minted.key, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index cd5f2eb83ca..b6776b51db6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,14 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,70 +71,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { name } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal'))) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A personal API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - userId, - workspaceId: null, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created personal API key: ${name}`, + const result = await performCreatePersonalApiKey({ + userId, + name, + actorName: session.user.name, + actorEmail: session.user.email, request, }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } captureServerEvent(userId, 'api_key_created', { key_name: name, scope: 'personal', }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) + return NextResponse.json({ key: result.key }) } catch (error) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index f13d1e0cffa..57a849b97b0 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -1,3 +1,5 @@ +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' + /** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ @@ -12,6 +14,10 @@ export interface CliAuthRequest { challenge: string /** Printed by the CLI, rendered for eyeball comparison. Never sent to the API. */ pairing: string + /** Which key space the terminal is asking for. */ + scope: CliAuthScope + /** Workspace the terminal suggests preselecting. A hint only — never authority. */ + suggestedWorkspaceId: string | null } export type CliAuthRequestResolution = @@ -22,6 +28,8 @@ interface RawCliAuthParams { request: string | null challenge: string | null pairing: string | null + scope: CliAuthScope + workspace: string | null } /** @@ -32,6 +40,8 @@ export function resolveCliAuthRequest({ request, challenge, pairing, + scope, + workspace, }: RawCliAuthParams): CliAuthRequestResolution { if (!request || !challenge || !pairing) { return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } @@ -45,5 +55,8 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'The pairing code is malformed.' } } - return { valid: true, request: { request, challenge, pairing } } + return { + valid: true, + request: { request, challenge, pairing, scope, suggestedWorkspaceId: workspace || null }, + } } diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx new file mode 100644 index 00000000000..01d908daf05 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockApprove, mockUseWorkspaces, mockPush } = vi.hoisted(() => ({ + mockApprove: vi.fn(), + mockUseWorkspaces: vi.fn(), + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [ + { + request: 'a'.repeat(43), + challenge: 'b'.repeat(43), + pairing: 'ABCD-2345', + scope: 'platform', + workspace: null, + }, + ], +})) + +vi.mock('@/hooks/queries/cli-auth', () => ({ + useApproveCliAuth: () => ({ + mutate: mockApprove, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), +})) + +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspaces, +})) + +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' + +let container: HTMLDivElement +let root: Root + +function render() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) +} + +/** The primary CTA is the only button whose label mentions connecting. */ +function connectButton(): HTMLButtonElement { + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const button = buttons.find((b) => /connect/i.test(b.textContent ?? '')) + if (!button) throw new Error('Connect button not found') + return button +} + +const LOADED = { + isPending: false, + isError: false, + data: { + workspaces: [ + { id: 'ws_admin', name: 'Acme', permissions: 'admin' }, + { id: 'ws_member', name: 'Other', permissions: 'write' }, + ], + lastActiveWorkspaceId: 'ws_admin', + }, +} + +describe('CliAuthView workspace loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Connect until the workspace list resolves', () => { + // The regression: while pending, the picker falls back to the personal + // option, so an early click approved a personal key when the same click a + // moment later would have bound the key to the user's workspace. + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(connectButton().disabled).toBe(true) + expect(container.textContent).toContain('Loading workspaces') + expect(container.textContent).not.toContain('No workspace (personal key)') + }) + + it('does not present the personal-key wording as the answer while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).not.toContain('Issues a personal key') + }) + + it('enables Connect and preselects the last active workspace once loaded', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('only reach Acme') + }) + + it('binds the key to the workspace when the approver is an admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: true, + }), + expect.anything() + ) + }) + + it('still lets the user connect when the workspace list fails', () => { + // A personal key is degraded but usable; blocking entirely would strand a + // terminal on a transient list failure. + mockUseWorkspaces.mockReturnValue({ isPending: false, isError: true, data: undefined }) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Could not load your workspaces') + }) +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 96ad0648ffc..080b872f297 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -1,5 +1,7 @@ 'use client' +import { useMemo, useState } from 'react' +import { ChipSelect, type ChipSelectOption, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -7,6 +9,10 @@ import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/comp import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' + +/** Sentinel for the "not bound to a workspace" row; an empty string reads as unselected. */ +const PERSONAL_VALUE = '__personal__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -22,8 +28,20 @@ export function CliAuthView() { const router = useRouter() const [params] = useQueryStates(cliAuthParsers) const approve = useApproveCliAuth() + const [selected, setSelected] = useState(null) const resolution = resolveCliAuthRequest(params) + const isPlatform = resolution.valid && resolution.request.scope === 'platform' + + const workspaces = useWorkspacesWithMetadata(isPlatform) + + const options = useMemo(() => { + const rows: ChipSelectOption[] = (workspaces.data?.workspaces ?? []).map((workspace) => ({ + label: workspace.name, + value: workspace.id, + })) + return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,40 @@ export function CliAuthView() { const { request } = resolution + /** + * Approval must wait for the workspace list. + * + * Until it arrives there is no selection to show, and the fallback would read + * as "No workspace (personal key)" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click approve a personal key + * with no default workspace, when a moment later the same click would have + * bound the key to the user's workspace. Blocking is the only way the card + * can promise what it is about to do. + */ + const loadingWorkspaces = isPlatform && workspaces.isPending + + /** + * The terminal's suggestion, then the user's last active workspace. Derived at + * render rather than synced into state through an effect, so the first paint + * after the list loads already shows the right row. + * + * The suggestion only counts when it resolves to a workspace the user + * actually has. It comes from a profile the CLI wrote earlier, so it can name + * a workspace they have since left or one that no longer exists — and being + * merely truthy, it used to shadow the last-active fallback and leave the card + * on "no workspace" with a perfectly good one available. + */ + const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId) + ? request.suggestedWorkspaceId + : null + const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null + const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + + // Only an admin can bind a key to a workspace. Anything less still gets a + // usable credential — a personal key — but the card says which one before the + // click rather than after, so nothing unexpected lands in the config file. + const bindsToWorkspace = chosen?.permissions === 'admin' + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {loadingWorkspaces + ? 'Checking which workspaces you can issue a key for…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : bindsToWorkspace + ? `Issues a key that can only reach ${chosen.name}.` + : chosen + ? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.' + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // The picked workspace travels either way — it is the terminal's + // default. Only `bindKeyToWorkspace` narrows the key itself. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: isPlatform && bindsToWorkspace, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..abc058f7704 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -48,7 +48,11 @@ export default async function CliAuthPage({ request: resolution.request.request, challenge: resolution.request.challenge, pairing: resolution.request.pairing, + scope: resolution.request.scope, }) + if (resolution.request.suggestedWorkspaceId) { + query.set('workspace', resolution.request.suggestedWorkspaceId) + } redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index e62b286e594..375a1c77ab3 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,16 +1,27 @@ -import { createSearchParamsCache, parseAsString } from 'nuqs/server' +import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ +export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const /** * Co-located, typed URL query params for the CLI key handoff. Read-only for the * life of the page, so there is no `urlKeys` companion. * - * Nullable with no defaults: a missing value is an invalid request, not a state - * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + * `request`/`challenge`/`pairing` are nullable with no defaults: a missing value + * is an invalid request, not a state to fall back from. `resolveCliAuthRequest` + * validates them; never trusted as-is. + * + * `scope` defaults to `copilot` so a terminal built against the original handoff + * — which sent no scope — still lands on the key space it expects. `workspace` + * is only a preselection hint for the picker; the workspace that ends up bound + * to the key is the one the user confirms, and it is re-authorized server-side. */ export const cliAuthParsers = { request: parseAsString, challenge: parseAsString, pairing: parseAsString, + scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), + workspace: parseAsString, } as const /** diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index 90bd6a11b1b..755459a1a8e 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -169,7 +169,10 @@ function readAttachedBlockContext(error: unknown): { blockType?: string } { if (!(error instanceof Error)) return {} - const attached = error as unknown as AttachedBlockContext + // Widen rather than erase: the value is an Error, it just may carry extra + // fields attached at throw time. Casting through `unknown` would discard + // that, and trips the double-cast ratchet for no benefit. + const attached = error as Error & Partial return { blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index 934766fa4e4..ad52b304662 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -1,13 +1,25 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createWorkspaceApiKey } from '@/lib/api-key/auth' +import { generateShortId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { createApiKey, createWorkspaceApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { PlatformEvents } from '@/lib/core/telemetry' const logger = createLogger('ApiKeyOrchestration') export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal' +export interface CreatedApiKey { + id: string + name: string + key: string + createdAt: Date +} + export interface PerformCreateWorkspaceApiKeyParams { workspaceId: string userId: string @@ -21,11 +33,106 @@ export interface PerformCreateWorkspaceApiKeyResult { success: boolean error?: string errorCode?: ApiKeyOrchestrationErrorCode - key?: { - id: string - name: string - key: string - createdAt: Date + key?: CreatedApiKey +} + +export interface PerformCreatePersonalApiKeyParams { + userId: string + name: string + source?: string + actorName?: string | null + actorEmail?: string | null + /** Forwarded to the audit record so the entry carries the caller's IP/UA. */ + request?: Request +} + +export interface PerformCreatePersonalApiKeyResult { + success: boolean + error?: string + errorCode?: ApiKeyOrchestrationErrorCode + key?: CreatedApiKey +} + +/** + * Issues a personal API key for the given user. + * + * The single issuer for every caller — the settings route, which authenticates + * by session, and the CLI key exchange, which authenticates by a redeemed + * approval. Keeping name-collision handling, audit, and telemetry here means the + * two surfaces can never drift. + */ +export async function performCreatePersonalApiKey( + params: PerformCreatePersonalApiKeyParams +): Promise { + try { + const existing = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.userId, params.userId), + eq(apiKey.name, params.name), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + errorCode: 'conflict', + error: `A personal API key named "${params.name}" already exists. Please choose a different name.`, + } + } + + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [created] = await db + .insert(apiKey) + .values({ + id: generateShortId(), + userId: params.userId, + workspaceId: null, + name: params.name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) + + recordAudit({ + workspaceId: null, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.PERSONAL_API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: created.id, + resourceName: params.name, + description: `Created personal API key: ${params.name}`, + metadata: { + keyName: params.name, + keyType: 'personal', + source: params.source ?? 'settings', + }, + request: params.request, + }) + + logger.info('Created personal API key', { userId: params.userId, keyId: created.id }) + + return { success: true, key: { ...created, key: plainKey } } + } catch (error) { + logger.error('Failed to create personal API key', { error }) + return { success: false, errorCode: 'internal', error: toError(error).message } } } diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index 56b40ce5953..d37d7a597e0 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' /** @@ -14,9 +15,41 @@ import { defineRouteContract } from '@/lib/api/contracts/types' /** BASE64URL, 43 chars (a 32-byte token or SHA-256 digest), no padding. */ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/, message) +/** + * Which key space the exchange mints from. + * + * `copilot` — a Sim Agent key, for the conversational surface. + * `platform` — a Sim API key (`x-api-key`), the credential the public `/api/v1` + * and `/api/v2` endpoints accept. These are separate key spaces: a copilot key + * does not authenticate a platform request, or vice versa. + * + * Defaulted to `copilot` so terminals built against the original exchange keep + * working without sending the field. + */ +export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') +export type CliAuthScope = z.output + export const approveCliAuthBodySchema = z.object({ request: base64Url43('request must be a base64url request id'), challenge: base64Url43('challenge must be a base64url-encoded SHA-256 digest'), + scope: cliAuthScopeSchema, + /** + * Platform scope only: the workspace the user picked in the browser. Returned + * to the terminal so it can store it as the profile's default — the user chose + * it by name, and asking them to go find its id afterwards would be absurd. + * + * Recorded whether or not the key ends up bound to it; see + * {@link bindKeyToWorkspace}. + */ + workspaceId: workspaceIdSchema.optional(), + /** + * Mint a key scoped to {@link workspaceId} rather than a personal key. Only a + * workspace admin may ask for this, and the approve route rejects anything + * less rather than silently downgrading — the browser has already told the + * user which kind of key they are about to get, so a mismatch here means the + * request did not come from that UI. + */ + bindKeyToWorkspace: z.boolean().optional().default(false), }) export type ApproveCliAuthBody = z.input @@ -49,6 +82,26 @@ export const pollCliAuthContract = defineRouteContract({ z.object({ status: z.literal('complete'), key: z.object({ id: z.string(), apiKey: z.string() }), + /** + * Echoes what the approving user actually consented to. The CLI asked + * for a scope in the browser URL, but the approval is what binds it — + * a client that assumed its own request was honored could file a + * copilot key under a platform profile and fail every later call with + * an opaque 401. + */ + scope: z.enum(['copilot', 'platform']), + /** + * The workspace the user picked, for the terminal to store as its + * default. Present for a personal key too — the choice is about which + * workspace the profile targets, not about what the key can reach. + */ + workspaceId: z.string().nullable(), + /** + * Whether the key itself is scoped to {@link workspaceId}. A bound key + * can reach nothing else, so the terminal must not offer to point the + * profile somewhere the credential cannot follow. + */ + workspaceBound: z.boolean(), }), ]), }, diff --git a/apps/sim/lib/cli-auth/approval-store.test.ts b/apps/sim/lib/cli-auth/approval-store.test.ts index 47d3bd06e9b..b9edabfb582 100644 --- a/apps/sim/lib/cli-auth/approval-store.test.ts +++ b/apps/sim/lib/cli-auth/approval-store.test.ts @@ -46,15 +46,53 @@ describe('cli-auth approval store', () => { expect(JSON.parse(value)).toEqual({ challenge: CHALLENGE, userId: 'user-1', + scope: 'copilot', createdAt: expect.any(Number), }) expect([px, ttl]).toEqual(['PX', 120_000]) }) + + it('records the consented scope and workspace', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('omits the workspace fields entirely when no workspace was picked', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { scope: 'platform' }) + const record = JSON.parse(mockSet.mock.calls[0][1]) + expect(record).not.toHaveProperty('workspaceId') + expect(record).not.toHaveProperty('workspaceBound') + }) + + it('records a picked workspace as unbound unless binding was asked for', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) }) describe('pollApproval', () => { - const storedApproval = () => - JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + const storedApproval = (overrides: Record = {}) => + JSON.stringify({ + challenge: CHALLENGE, + userId: 'user-1', + scope: 'copilot', + createdAt: Date.now(), + ...overrides, + }) it('returns pending when no approval exists yet', async () => { mockGet.mockResolvedValue(null) @@ -68,6 +106,9 @@ describe('cli-auth approval store', () => { await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ status: 'approved', userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // NX lock on the mint key; TTL matches the approval so they expire together // and a failed cleanup can't leave a re-mintable window. Record not deleted here. @@ -77,6 +118,37 @@ describe('cli-auth approval store', () => { expect(mockDel).not.toHaveBeenCalled() }) + it('returns the recorded scope and workspace binding', async () => { + mockGet.mockResolvedValue( + storedApproval({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ + status: 'approved', + userId: 'user-1', + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('reports an unbound workspace pick as a default, not a key scope', async () => { + mockGet.mockResolvedValue(storedApproval({ scope: 'platform', workspaceId: 'ws-1' })) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('treats a record written before scopes existed as a copilot approval', async () => { + mockGet.mockResolvedValue( + JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ scope: 'copilot' }) + }) + it('does NOT touch the record when the secret is wrong', async () => { mockGet.mockResolvedValue(storedApproval()) await expect(pollApproval(REQUEST, 'c'.repeat(43))).resolves.toEqual({ status: 'pending' }) diff --git a/apps/sim/lib/cli-auth/approval-store.ts b/apps/sim/lib/cli-auth/approval-store.ts index 607f6b38b73..7b07fe8cd19 100644 --- a/apps/sim/lib/cli-auth/approval-store.ts +++ b/apps/sim/lib/cli-auth/approval-store.ts @@ -1,5 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Base64Url, sha256Hex } from '@sim/security/hash' +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' import { getRedisClient } from '@/lib/core/config/redis' /** @@ -29,10 +30,29 @@ interface ApprovalRecord { challenge: string /** Always taken from the approving user's session, never from a request body. */ userId: string + /** + * Which key space to mint from, fixed at approval time. Recording it here + * rather than reading it from the poll body is what makes the browser consent + * binding: the poll carries only a secret, so it cannot widen what the user + * agreed to. Absent on records written before the field existed — those are + * copilot approvals. + */ + scope?: CliAuthScope + /** Platform scope only: the workspace the user picked, for the terminal's default. */ + workspaceId?: string + /** Whether to mint a key scoped to `workspaceId`. Admin-verified at approval. */ + workspaceBound?: boolean createdAt: number } -export type PollResult = { status: 'pending' } | { status: 'approved'; userId: string } +export interface ApprovalGrant { + userId: string + scope: CliAuthScope + workspaceId: string | null + workspaceBound: boolean +} + +export type PollResult = { status: 'pending' } | ({ status: 'approved' } & ApprovalGrant) function requireRedis() { const redis = getRedisClient() @@ -61,10 +81,21 @@ function mintLockKey(requestId: string): string { export async function createApproval( userId: string, requestId: string, - challenge: string + challenge: string, + grant: { scope: CliAuthScope; workspaceId?: string; workspaceBound?: boolean } = { + scope: 'copilot', + } ): Promise { const redis = requireRedis() - const record: ApprovalRecord = { challenge, userId, createdAt: Date.now() } + const record: ApprovalRecord = { + challenge, + userId, + scope: grant.scope, + ...(grant.workspaceId + ? { workspaceId: grant.workspaceId, workspaceBound: grant.workspaceBound === true } + : {}), + createdAt: Date.now(), + } await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS) } @@ -97,7 +128,13 @@ export async function pollApproval(requestId: string, pollSecret: string): Promi const reserved = await redis.set(mintLockKey(requestId), '1', 'PX', MINT_LOCK_TTL_MS, 'NX') if (reserved !== 'OK') return { status: 'pending' } - return { status: 'approved', userId: record.userId } + return { + status: 'approved', + userId: record.userId, + scope: record.scope ?? 'copilot', + workspaceId: record.workspaceId ?? null, + workspaceBound: record.workspaceBound === true, + } } /** Consumes the approval after a successful mint — single-use from here on. */ diff --git a/bun.lock b/bun.lock index 4b9d3da396e..357dd186f2d 100644 --- a/bun.lock +++ b/bun.lock @@ -589,6 +589,25 @@ "vitest": "^4.1.0", }, }, + "packages/sim-cli": { + "name": "@sim/cli", + "version": "0.1.0", + "bin": { + "sim": "dist/index.js", + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4", + }, + }, "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", @@ -1712,7 +1731,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.3", "", { "os": "android", "cpu": "arm" }, "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.3", "", { "os": "android", "cpu": "arm64" }, "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.3", "", { "os": "none", "cpu": "arm64" }, "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.10", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA=="], @@ -1744,6 +1811,8 @@ "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], + "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -2446,6 +2515,8 @@ "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -2462,7 +2533,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2474,6 +2545,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -2694,6 +2767,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], @@ -3392,6 +3467,8 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], @@ -3756,6 +3833,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], @@ -4046,6 +4125,8 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rollup": ["rollup@4.62.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.3", "@rollup/rollup-android-arm64": "4.62.3", "@rollup/rollup-darwin-arm64": "4.62.3", "@rollup/rollup-darwin-x64": "4.62.3", "@rollup/rollup-freebsd-arm64": "4.62.3", "@rollup/rollup-freebsd-x64": "4.62.3", "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", "@rollup/rollup-linux-arm-musleabihf": "4.62.3", "@rollup/rollup-linux-arm64-gnu": "4.62.3", "@rollup/rollup-linux-arm64-musl": "4.62.3", "@rollup/rollup-linux-loong64-gnu": "4.62.3", "@rollup/rollup-linux-loong64-musl": "4.62.3", "@rollup/rollup-linux-ppc64-gnu": "4.62.3", "@rollup/rollup-linux-ppc64-musl": "4.62.3", "@rollup/rollup-linux-riscv64-gnu": "4.62.3", "@rollup/rollup-linux-riscv64-musl": "4.62.3", "@rollup/rollup-linux-s390x-gnu": "4.62.3", "@rollup/rollup-linux-x64-gnu": "4.62.3", "@rollup/rollup-linux-x64-musl": "4.62.3", "@rollup/rollup-openbsd-x64": "4.62.3", "@rollup/rollup-openharmony-arm64": "4.62.3", "@rollup/rollup-win32-arm64-msvc": "4.62.3", "@rollup/rollup-win32-ia32-msvc": "4.62.3", "@rollup/rollup-win32-x64-gnu": "4.62.3", "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -4234,6 +4315,8 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], @@ -4320,8 +4403,12 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], @@ -4456,6 +4543,8 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], @@ -4648,6 +4737,8 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@electric-sql/client/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4840,6 +4931,8 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4926,6 +5019,8 @@ "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -5256,6 +5351,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -5286,6 +5383,10 @@ "vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "vite-node/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5414,6 +5515,28 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], diff --git a/package.json b/package.json index d1ad2b78427..cb1273fb128 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "check:migrations": "bun run scripts/check-migrations-safety.ts", "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", + "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", + "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md new file mode 100644 index 00000000000..bdfe45410da --- /dev/null +++ b/packages/sim-cli/README.md @@ -0,0 +1,224 @@ +# Sim CLI + +Talk to the [Sim](https://sim.ai) API from your terminal. + +```bash +npm install -g @sim/cli +sim login +sim workflows list +``` + +## Profiles + +Profiles work like the AWS CLI: one identity and one set of defaults per named +profile, selected with `--profile` or `SIM_PROFILE`. This is what lets you keep +production and a local dev stack side by side without re-authenticating. + +Non-secret settings live in `~/.sim/config`: + +```ini +[default] +endpoint = https://sim.ai +workspace = ws_abc123 +output = table + +[profile dev] +endpoint = http://localhost:3000 +workspace = ws_local +``` + +Keys live in `~/.sim/credentials`, written `0600`: + +```ini +[default] +api_key = sim_… + +[dev] +api_key = sim_… +``` + +The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentials +— is the AWS convention, kept so existing habits and tooling carry over. + +```bash +sim configure --set-endpoint http://localhost:3000 --profile dev +sim configure --set-workspace ws_local --profile dev +sim profiles # list them; * marks the active one +sim whoami # resolved values, and where each came from +``` + +## Where settings come from + +Each setting resolves independently, first match wins: + +| Rank | Source | +| --- | --- | +| 1 | Command-line flag (`--endpoint`, `--workspace`) | +| 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | +| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | +| 4 | Built-in default (`https://sim.ai`, `table`) | + +Formats are listed under [Output formats](#output-formats). + +`sim whoami` prints the winning source per setting, which is usually the fastest +way to explain a surprising result. + +For CI, skip `sim login` entirely and set `SIM_API_KEY` and `SIM_WORKSPACE` — +nothing needs to touch the filesystem. `SIM_CONFIG_DIR` relocates both files if +you need to keep them somewhere other than `~/.sim`. + +## Logging in + +`sim login` uses the same browser handoff shape as `gh auth login`: the terminal +prints a pairing code and a URL, you approve in a browser, and the key comes back +over the CLI's own connection. Nothing redeemable crosses the browser leg, and +there is no loopback listener — so it works over SSH and inside containers. + +``` +$ sim login --profile dev --endpoint http://localhost:3000 + +Pairing code: K7M2-P9XT +Confirm this code matches what the browser shows before approving. + +http://localhost:3000/cli/auth?request=…&scope=platform +Waiting for approval… + +✓ Logged in. Key stored in /Users/you/.sim/credentials + Workspace-scoped key, pinned to ws_local. +``` + +The approval page is where you pick the workspace — the terminal has no key yet, +so it cannot list them for you. Whichever you pick becomes the profile's default +`workspace`, so you never have to go look up its id. + +What the key itself can reach depends on your role in that workspace, and the +page says which you are about to get before you approve: + +| Your role | Key issued | Reach | +| --- | --- | --- | +| Workspace admin | Workspace-scoped | That workspace only | +| Anything else | Personal | Every workspace you can access; `--workspace` overrides the default | + +`sim login --workspace ` preselects a workspace in the picker, and an +existing profile's workspace preselects itself on re-login. + +`sim logout` removes the stored key. It does not revoke it — do that in +Settings → API keys. + +## Commands + +```bash +sim workflows list [--folder ] [--deployed] [--limit ] +sim workflows get +sim workflows deploy|undeploy|rollback + +sim logs list [--level error] [--workflow …] [--trigger …] [--start ] +sim logs get +sim logs execution + +sim tables list +sim tables get +sim tables columns +sim tables rows [--filter ] [--sort …] [--limit ] +sim tables insert --data +sim tables delete-rows (--row … | --filter ) --yes + +sim files list +sim files download [-o ] +sim files delete + +sim knowledge list +sim knowledge get +sim knowledge documents [--search ] +sim knowledge search --kb … +``` + +### Filtering table rows + +`--filter` takes the same predicate tree the API uses — `all` (AND) or `any` +(OR) groups of `{field, op, value}` conditions, nestable. It's JSON because the +grammar is a tree; there's no honest flag encoding for it. + +```bash +sim tables rows tbl_123 \ + --filter '{"all":[{"field":"status","op":"eq","value":"open"}, + {"field":"score","op":"gt","value":10}]}' \ + --sort score:desc --limit 50 +``` + +Row columns are discovered at runtime from the returned data, unioned across the +page so a sparse row doesn't hide a column. + +Deletions require an explicit selector *and* `--yes`; there is no "delete +everything" default. + +### Output formats + +Output format is a **profile setting**, not a per-command flag — there is no +`--output`. Set it once with `sim configure --set-output `, or override +ambiently with `SIM_OUTPUT` for a one-off or for CI: + +| Format | For | +| --- | --- | +| `table` | reading (default) | +| `json` | piping into `jq` | +| `yaml` | piping into anything that reads YAML | +| `text` | shell loops — tab-separated, no header, no colour | + +`json` and `yaml` emit the API's **raw** values, not the table's formatting — a +duration stays `1500`, not `"1.5s"` — so switching format never changes the data. +`text` uses the rendered cells, since it is meant for shell plumbing rather than +parsing. + +```bash +sim configure --set-output json # for this profile, from now on +sim configure --set-output text --profile scripts # a profile dedicated to scripting + +SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId' +SIM_OUTPUT=yaml sim logs list --level error > logs.yaml + +SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do + echo "$id $name" +done +``` + +An absent value is an em-dash in `table` and an **empty field** in `text`, so +emptiness tests downstream behave. + +A bad `SIM_OUTPUT` or `output =` is ignored and falls back to `table`. Both are +ambient — set once, then read by every later command — so one bad value should +not break the CLI outright. + +## How this stays in sync with the API + +`src/generated/v2-api.ts` is generated from the Zod route contracts in +`apps/sim/lib/api/contracts/v2/**` — the same contracts the routes validate +against, so a shape that disagrees with them is a shape the server would reject. +It holds every response/request type plus the operation table (method, path, +path params) the client dispatches through. + +```bash +bun run generate:cli-api # regenerate after changing a contract +bun run check:cli-api # CI: fails if the generated file is stale +bun run check:openapi # CI: fails if the docs and contracts disagree +``` + +The generated file contains only type declarations and one const — no imports — +so the `packages/*` must not import `apps/*` boundary is preserved; the script +does the crossing at build time. + +The OpenAPI documents under `apps/docs` are deliberately **not** generated. They +carry hand-written descriptions, examples, and error responses that Zod schemas +don't encode, so regenerating them would trade real documentation for mechanical +accuracy. `check:openapi` reconciles them against the same contracts instead — +field by field, and it parses every documented example with the real Zod schema — +so the prose survives while drift still fails the build. + +## Notes + +- Commands talk to the `/api/v2` surface, which returns `{ data }` and + `{ data, nextCursor }`. List commands auto-page up to `--limit`. + +## License + +Apache-2.0 diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json new file mode 100644 index 00000000000..15f721ae031 --- /dev/null +++ b/packages/sim-cli/package.json @@ -0,0 +1,46 @@ +{ + "name": "@sim/cli", + "version": "0.1.0", + "description": "Sim CLI - talk to the Sim API from your terminal", + "type": "module", + "bin": { + "sim": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "prepublishOnly": "bun run build" + }, + "files": [ + "dist" + ], + "keywords": [ + "sim", + "ai", + "agents", + "cli", + "workflow" + ], + "author": "Sim", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts new file mode 100644 index 00000000000..80df1109946 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildApprovalUrl, createAuthRequest, pollForKey } from './device-flow.js' + +const ENDPOINT = 'https://sim.test' + +function reply(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) as Response +} + +const COMPLETE = { + status: 'complete', + key: { id: 'k1', apiKey: 'sim_abc' }, + scope: 'platform', + workspaceId: 'ws_1', + workspaceBound: true, +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +/** Drives the poll loop without waiting out its real 2s interval. */ +async function poll(responses: Array<() => Response>) { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => responses[call++]()) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const auth = createAuthRequest() + return { result: await pollForKey(ENDPOINT, auth), calls: () => call } +} + +describe('pollForKey', () => { + it('returns the key once the approval completes', async () => { + const { result } = await poll([() => reply(200, COMPLETE)]) + expect(result).toMatchObject({ apiKey: 'sim_abc', scope: 'platform', workspaceBound: true }) + }) + + it('keeps polling while the approval is pending', async () => { + const { result, calls } = await poll([ + () => reply(200, { status: 'pending' }), + () => reply(200, { status: 'pending' }), + () => reply(200, COMPLETE), + ]) + expect(calls()).toBe(3) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a 5xx, because the server released the approval for a later poll', async () => { + // The regression: treating every non-429 as terminal threw away an approval + // the user had already granted in the browser. + const { result } = await poll([ + () => reply(500, { error: 'Failed to generate API key' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a same-second name conflict', async () => { + const { result } = await poll([ + () => reply(409, { error: 'A personal API key named "CLI (…)" already exists.' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a rate-limited poll', async () => { + const { result } = await poll([() => reply(429, {}), () => reply(200, COMPLETE)]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('survives a transport failure without ending the login', async () => { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + if (call++ === 0) throw new Error('ECONNRESET') + return reply(200, COMPLETE) + }) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const result = await pollForKey(ENDPOINT, createAuthRequest()) + expect(result.apiKey).toBe('sim_abc') + }) + + it('gives up on a deliberate refusal rather than spinning to the timeout', async () => { + await expect( + poll([() => reply(400, { error: 'verifier must be a base64url secret' })]) + ).rejects.toThrow('verifier must be a base64url secret') + }) + + it('gives up on a 403', async () => { + await expect(poll([() => reply(403, { error: 'Forbidden' })])).rejects.toThrow('Forbidden') + }) +}) + +describe('createAuthRequest', () => { + it('mints a 43-character base64url request id, challenge, and secret', () => { + const auth = createAuthRequest() + for (const value of [auth.request, auth.challenge, auth.pollSecret]) { + expect(value).toMatch(/^[A-Za-z0-9\-_]{43}$/) + } + }) + + it('uses a pairing alphabet with no look-alike characters', () => { + // The code is compared across two screens; O/0 and I/1 would defeat that. + for (let i = 0; i < 50; i++) { + expect(createAuthRequest().pairing).toMatch( + /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/ + ) + } + }) + + it('never puts the poll secret in the browser URL', () => { + const auth = createAuthRequest() + const url = buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1') + expect(url).toContain(encodeURIComponent(auth.challenge)) + expect(url).not.toContain(auth.pollSecret) + }) +}) diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts new file mode 100644 index 00000000000..31fb0a5b5d7 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -0,0 +1,174 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto' +import { SimApiError } from '../http/client.js' + +/** + * The terminal half of the CLI key handoff. + * + * Shaped like OAuth's device authorization grant: the CLI mints a rendezvous id + * and a secret, sends only the secret's SHA-256 challenge through the browser, + * and redeems the key over its own TLS connection. The browser leg therefore + * never carries anything redeemable, and no loopback listener is required — + * which matters because the terminal is often not on the same machine as the + * browser (SSH, containers, remote dev boxes). + */ + +/** No look-alike characters: the human is comparing this across two screens. */ +const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +const POLL_INTERVAL_MS = 2000 +const POLL_TIMEOUT_MS = 15 * 60 * 1000 + +/** + * Poll statuses that leave the approval still redeemable, so the login should + * keep waiting rather than making the user restart the browser handoff. + * + * The poll route releases its mint reservation on any mint failure — its own + * comment says "a later poll can retry" — so giving up on those threw away an + * approval the user had already granted. A transient 5xx or a same-second name + * conflict (409) is exactly that case. + * + * 429 is the poll cadence hitting the per-IP bucket, not a refusal. + * + * Everything else stays terminal: 400 means a malformed request id or verifier, + * and 401/403/404 mean the server is refusing on purpose. Retrying those just + * spins until the 15-minute timeout. + */ +const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504]) + +export type CliAuthScope = 'copilot' | 'platform' + +export interface AuthRequest { + /** Semi-public rendezvous handle; travels in the browser URL. */ + request: string + /** Never leaves this process until the poll redeems it. */ + pollSecret: string + /** BASE64URL(SHA256(pollSecret)), registered when the user approves. */ + challenge: string + /** Printed for the user to compare against the browser. Never sent to the API. */ + pairing: string +} + +export interface MintedKey { + id: string + apiKey: string + scope: CliAuthScope + /** The workspace picked in the browser — the profile's default target. */ + workspaceId: string | null + /** Whether the key can *only* reach that workspace. */ + workspaceBound: boolean +} + +/** 32 bytes of entropy, base64url — 43 characters, exactly what the contract accepts. */ +function token(): string { + return randomBytes(32).toString('base64url') +} + +function pairingCode(): string { + const draw = (count: number) => + Array.from({ length: count }, () => PAIRING_ALPHABET[randomInt(PAIRING_ALPHABET.length)]).join( + '' + ) + return `${draw(4)}-${draw(4)}` +} + +export function createAuthRequest(): AuthRequest { + const pollSecret = token() + return { + request: token(), + pollSecret, + challenge: createHash('sha256').update(pollSecret, 'utf8').digest('base64url'), + pairing: pairingCode(), + } +} + +export function buildApprovalUrl( + endpoint: string, + auth: AuthRequest, + scope: CliAuthScope, + workspaceId?: string +): string { + const url = new URL('/cli/auth', endpoint) + url.searchParams.set('request', auth.request) + url.searchParams.set('challenge', auth.challenge) + url.searchParams.set('pairing', auth.pairing) + url.searchParams.set('scope', scope) + if (workspaceId) url.searchParams.set('workspace', workspaceId) + return url.toString() +} + +interface PollResponse { + status: 'pending' | 'complete' + key?: { id: string; apiKey: string } + scope?: CliAuthScope + workspaceId?: string | null + workspaceBound?: boolean +} + +/** + * Polls until the user approves in the browser. + * + * Transport failures are swallowed and retried rather than aborting the login: + * a laptop that slept, a VPN reconnecting, or a deploy rolling the server mid- + * wait are all recoverable, and the approval sits in Redis with its own TTL. A + * non-2xx *response*, by contrast, is the server refusing on purpose and is + * surfaced immediately. + */ +export async function pollForKey( + endpoint: string, + auth: AuthRequest, + signal?: AbortSignal +): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + if (signal?.aborted) throw new SimApiError('Login cancelled.', 0) + + let response: Response | null = null + try { + response = await fetch(new URL('/api/cli/auth/poll', endpoint), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ request: auth.request, verifier: auth.pollSecret }), + signal, + }) + } catch { + response = null + } + + if (response) { + const raw = await response.text() + + if (!response.ok) { + if (!RETRYABLE_POLL_STATUSES.has(response.status)) { + let message = `Login failed with status ${response.status}` + try { + const body = JSON.parse(raw) as { error?: unknown } + if (typeof body.error === 'string') message = body.error + else if (body.error && typeof body.error === 'object') { + const detail = (body.error as { message?: unknown }).message + if (typeof detail === 'string') message = detail + } + } catch {} + throw new SimApiError(message, response.status) + } + } else { + const body = JSON.parse(raw) as PollResponse + if (body.status === 'complete' && body.key) { + return { + id: body.key.id, + apiKey: body.key.apiKey, + // Older servers answer without these; a key from a server that does + // not know about scopes is a copilot key by definition. + scope: body.scope ?? 'copilot', + workspaceId: body.workspaceId ?? null, + workspaceBound: body.workspaceBound === true, + } + } + } + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + + throw new SimApiError('Timed out waiting for browser approval.', 0) +} diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts new file mode 100644 index 00000000000..ded0de9440e --- /dev/null +++ b/packages/sim-cli/src/commands/auth.ts @@ -0,0 +1,203 @@ +import { spawn } from 'node:child_process' +import chalk from 'chalk' +import { Command } from 'commander' +import { + buildApprovalUrl, + type CliAuthScope, + createAuthRequest, + pollForKey, +} from '../auth/device-flow.js' +import { + credentialsPath, + deleteProfile, + listProfiles, + readCredentialsProfile, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' +import { printRecord } from '../output/render.js' + +/** + * Best-effort browser launch. Failure is not an error: the URL is always printed + * first, so a headless box, an SSH session, or a machine with no handler just + * falls through to the user pasting it somewhere. + */ +function openBrowser(url: string): void { + /** + * Windows needs `cmd /c start "" `. + * + * `start` is a cmd builtin, so it needs a shell — but its first quoted + * argument is the *window title*, and node quotes the URL because of the `?` + * and `&` in the query. Passing the URL alone therefore opens a console + * titled with the handoff link and no browser at all. The empty `""` takes + * the title slot so the URL lands where it belongs. + */ + const [command, args] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]] + + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true }) + child.on('error', () => {}) + child.unref() + } catch {} +} + +function maskKey(key: string): string { + return key.length <= 10 ? '•'.repeat(key.length) : `${key.slice(0, 6)}…${key.slice(-4)}` +} + +export function loginCommand(): Command { + return new Command('login') + .description('Authorize this terminal and store an API key for the profile') + .option('--scope ', 'Key space to mint from: platform or copilot', 'platform') + .option('--no-browser', 'Print the URL instead of opening a browser') + .action(async (options: { scope: string; browser: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.scope !== 'platform' && options.scope !== 'copilot') { + throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) + } + const scope = options.scope as CliAuthScope + + const auth = createAuthRequest() + const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(profile.name)}` + ) + console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) + console.log(chalk.dim('Confirm this code matches what the browser shows before approving.\n')) + console.log(url) + + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for approval…')) + + const key = await pollForKey(profile.endpoint, auth) + + if (key.scope !== scope) { + // The approval, not the request, decides the scope. Storing a copilot + // key where a platform key belongs would fail every later call with an + // unexplained 401, so refuse now with the reason. + throw new SimApiError( + `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 0 + ) + } + + writeCredentialsProfile(profile.name, key.apiKey) + + // The workspace picked in the browser becomes the profile's default, + // whether or not the key is scoped to it. The user chose it by name — + // making them look up its id afterwards would waste the one moment the + // answer was already on screen. + const settings: Record = { endpoint: profile.endpoint } + if (key.workspaceId) settings.workspace = key.workspaceId + writeConfigProfile(profile.name, settings) + + console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + if (key.workspaceBound && key.workspaceId) { + console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) + } else if (key.workspaceId) { + console.log( + chalk.dim( + ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + ) + ) + } else if (!profile.workspaceId) { + console.log( + chalk.dim( + ' Personal key with no default workspace. Set one with: sim configure --set-workspace ' + ) + ) + } + }) +} + +export function logoutCommand(): Command { + return new Command('logout') + .description("Remove the profile's stored API key") + .option('--all', 'Remove the profile entirely, including its settings') + .action((options: { all?: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.all) { + const removed = deleteProfile(profile.name) + if (!removed.config && !removed.credentials) { + console.log(chalk.dim(`Nothing stored for profile "${profile.name}".`)) + return + } + console.log(chalk.green(`✓ Removed profile "${profile.name}".`)) + return + } + + if (!readCredentialsProfile(profile.name).api_key) { + console.log(chalk.dim(`No stored key for profile "${profile.name}".`)) + return + } + + writeCredentialsProfile(profile.name, null) + console.log(chalk.green(`✓ Removed the stored key for profile "${profile.name}".`)) + // The key still exists server-side; leaving that unsaid invites the + // assumption that logging out revoked it. + console.log(chalk.dim(' The key itself is still active — revoke it in Settings → API keys.')) + }) +} + +export function whoamiCommand(): Command { + return new Command('whoami') + .description('Show the resolved profile and where each setting came from') + .action((_options: unknown, command: Command) => { + const profile = profileFrom(command) + const { sources } = profile + + const annotate = (value: string, source: string) => + source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` + + printRecord( + profile.output, + [ + ['Profile', profile.name], + ['Endpoint', annotate(profile.endpoint, sources.endpoint)], + [ + 'API key', + profile.apiKey + ? annotate(maskKey(profile.apiKey), sources.apiKey) + : chalk.yellow('not logged in'), + ], + ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], + ['Output', annotate(profile.output, sources.output)], + ], + { + profile: profile.name, + endpoint: profile.endpoint, + workspaceId: profile.workspaceId, + output: profile.output, + authenticated: Boolean(profile.apiKey), + sources, + } + ) + }) +} + +export function profilesCommand(): Command { + return new Command('profiles') + .description('List the profiles defined in the config and credentials files') + .action((_options: unknown, command: Command) => { + const profiles = listProfiles() + if (profiles.length === 0) { + console.log(chalk.dim('No profiles yet. Run: sim login')) + return + } + + const active = profileFrom(command).name + for (const name of profiles) { + const marker = name === active ? chalk.green('*') : ' ' + const hasKey = Boolean(readCredentialsProfile(name).api_key) + console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}`) + } + }) +} diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts new file mode 100644 index 00000000000..af804495396 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { + configPath, + OUTPUT_FORMATS, + readConfigProfile, + writeConfigProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' + +/** + * Non-secret profile settings. Credentials are deliberately not settable here — + * they arrive through `sim login`, which is the only path that mints a key with + * a recorded consent behind it. + */ +export function configureCommand(): Command { + return new Command('configure') + .description("Set a profile's endpoint, default workspace, or output format") + .option('--set-endpoint ', 'Sim deployment to talk to') + .option('--set-workspace ', 'Default workspace for workspace-scoped commands') + .option('--set-output ', `Default output format (${OUTPUT_FORMATS.join(' | ')})`) + .option('--unset ', 'Remove settings (endpoint, workspace, output)') + .action( + ( + options: { + setEndpoint?: string + setWorkspace?: string + setOutput?: string + unset?: string[] + }, + command: Command + ) => { + const profile = profileFrom(command) + const updates: Record = {} + + if (options.setEndpoint) updates.endpoint = options.setEndpoint.replace(/\/+$/, '') + if (options.setWorkspace) updates.workspace = options.setWorkspace + if (options.setOutput) { + if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { + throw new SimApiError( + `Unknown output format "${options.setOutput}". Use one of: ${OUTPUT_FORMATS.join(', ')}`, + 0 + ) + } + updates.output = options.setOutput + } + + for (const key of options.unset ?? []) { + if (!['endpoint', 'workspace', 'output'].includes(key)) { + throw new SimApiError(`Cannot unset "${key}". Use endpoint, workspace, or output.`, 0) + } + updates[key] = null + } + + if (Object.keys(updates).length === 0) { + const current = readConfigProfile(profile.name) + if (Object.keys(current).length === 0) { + console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) + return + } + for (const [key, value] of Object.entries(current)) { + console.log(`${chalk.dim(`${key}:`)} ${value}`) + } + return + } + + writeConfigProfile(profile.name, updates) + console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) + } + ) +} diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts new file mode 100644 index 00000000000..10f3d08b5fd --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -0,0 +1,118 @@ +import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { attachHandWritten, streamToFile } from './hand-written.js' + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function bodyOf(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) +} + +describe('streamToFile', () => { + it('writes the body to disk', async () => { + const target = join(dir, 'out.txt') + await streamToFile(bodyOf(['hello ', 'world']), createWriteStream(target, { flags: 'wx' })) + expect(existsSync(target)).toBe(true) + }) + + it('refuses to clobber an existing file, naming --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + // The destination usually comes from the server's content-disposition, so a + // silent truncate could destroy a file the caller never named. + await expect( + streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'wx' })) + ).rejects.toThrow(/already exists.*--force/s) + }) + + it('overwrites when the caller asked for it', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + await streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'w' })) + expect(existsSync(target)).toBe(true) + }) + + it.skipIf(!existsSync('/dev/full'))( + 'rejects when the final flush fails instead of reporting success', + async () => { + // `end`'s callback receives the flush error; passing `resolve` straight in + // made that error the resolution value, so a truncated download printed + // "Saved". /dev/full only errors at flush time, which is the exact path. + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) +}) + +describe('tables import argument guards', () => { + function importCommand(): Command { + const root = new Command('sim').exitOverride() + attachHandWritten(root) + const walk = (command: Command) => { + command.exitOverride() + command.commands.forEach(walk) + } + walk(root) + return root + } + + async function run(argv: string[]) { + await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) + } + + it('refuses to guess the source', async () => { + // A new table is a safe default; where the bytes are is not inferable. + await expect(run([])).rejects.toThrow(/exactly one of /) + await expect(run(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) + }) + + it('rejects existing-table flags when creating one', async () => { + // Ignoring these would let `--mode replace` read as honoured while a new + // table is created beside the one it was meant to overwrite. + await expect(run(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--create-columns', '{}'])).rejects.toThrow(/applies to --table-id/) + }) + + it('rejects new-table flags when importing into an existing one', async () => { + await expect(run(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ + ) + await expect(run(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + /--table-id already names the destination/ + ) + }) + + it('asks for a name when there is no file name to take one from', async () => { + await expect(run(['--file-id', 'w_1'])).rejects.toThrow(/--name /) + }) + + it('checks all of that before touching the filesystem', async () => { + // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. + await expect(run(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) + }) +}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts new file mode 100644 index 00000000000..80142fba0a6 --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -0,0 +1,633 @@ +import { once } from 'node:events' +import { createWriteStream, openAsBlob, type WriteStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import chalk from 'chalk' +import type { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { QueryRowsResponse } from '../generated/v2-api.js' +import { SimApiError, type SimClient } from '../http/client.js' +import { type Column, printList, sanitize, text } from '../output/render.js' +import { coerce } from '../runtime/request.js' + +/** + * Commands the generated runtime cannot produce. + * + * Kept deliberately small — each entry needs a reason that generation could not + * satisfy even in principle, not merely "not migrated yet". They attach onto the + * groups the runtime already built, so `sim files --help` lists them alongside + * the generated leaves rather than in a second group. + */ + +type Row = QueryRowsResponse['data'][number] + +/** + * Streams a fetch body to disk, honouring backpressure. + * + * An explicit reader loop rather than `Readable.fromWeb`: the DOM + * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares + * are structurally incompatible under this TS config, and bridging them needs a + * cast that would erase exactly the typing this keeps honest. + */ +export async function streamToFile( + body: ReadableStream, + file: WriteStream +): Promise { + // Registered before the first write, not after the loop. `createWriteStream` + // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no + // listener attached it is an unhandled 'error' event that takes down the + // process instead of failing the download. + const failed = new Promise((_resolve, reject) => { + file.once('error', reject) + }) + + const pump = (async () => { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the buffer is full; waiting for `drain` + // is what stops a large file being buffered entirely in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + // `end`'s callback receives the error from a failed final flush (ENOSPC is + // the common one, since the bytes may not hit disk until here). Passing + // `resolve` directly made that error the resolution *value*, so the pump + // fulfilled and the command printed "Saved" for a truncated file. + await new Promise((resolve, reject) => { + file.end((error?: Error | null) => (error ? reject(error) : resolve())) + }) + })() + + try { + await Promise.race([pump, failed]) + } catch (error) { + file.destroy() + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new SimApiError( + `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, + 0 + ) + } + throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) + } +} + +/** + * Row `data` is name-keyed and user-defined, so columns exist only at runtime. + * Keys are unioned across the page rather than read off the first row — a + * sparse row would otherwise hide every column it happens to omit. + */ +function rowColumns(rows: Row[]): Column[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row.data)) { + if (seen.has(key)) continue + seen.add(key) + keys.push(key) + } + } + + return [ + { header: 'id', value: (row) => row.id }, + ...keys.map((key) => ({ + // A table's column names are user-defined, so the header is remote + // content just as much as the cell beneath it. + header: sanitize(key), + value: (row: Row) => { + const value = row.data[key] + if (value === null || value === undefined) return text(null) + // User-defined cell data is remote content; strip terminal controls. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) + }, + })), + ] +} + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = program.command(name) + return created +} + +/** + * The server stores whatever content type the part carries, falling back to + * `application/octet-stream`, and that type is what later decides whether the + * workspace renders a file or offers it as a download. Node does not ship a + * mime table, so the common cases are listed and everything else falls back. + */ +const CONTENT_TYPES: Record = { + css: 'text/css', + csv: 'text/csv', + gif: 'image/gif', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + webp: 'image/webp', + yaml: 'application/yaml', + yml: 'application/yaml', + zip: 'application/zip', +} + +function contentTypeFor(name: string): string { + const dot = name.lastIndexOf('.') + const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase() + return CONTENT_TYPES[extension] ?? 'application/octet-stream' +} + +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +/** + * What a transfer needs to send its bytes, however it was started. + * + * File uploads and table imports are the same handshake against different + * paths — identical part-URL and complete bodies, the same `upload-token` + * header — so one implementation drives both. `basePath` is the transfer's own + * resource; `/parts` and `/complete` hang off it and DELETE aborts it. + */ +interface Transfer { + basePath: string + uploadToken: string + partSize: number + partCount: number + size: number +} + +interface FileUpload { + id: string + uploadToken: string + partSize: number + partCount: number + file: { id: string } | null +} + +/** The parts endpoint signs at most this many URLs per request. */ +const PART_URL_BATCH = 100 + +/** + * Sends every part of a file to the storage URLs the API signs for it, and + * returns what `complete` needs to reassemble them. + * + * URLs are requested in batches because each one is short-lived: signing all + * 640 possible parts up front would leave the last ones expired by the time a + * slow connection reached them. + * + * Parts go out one at a time. Concurrency would be faster, but a failure + * mid-flight has to abort the whole transfer anyway, and a sequential loop + * makes "which part failed" unambiguous. + */ +async function uploadParts( + client: SimClient, + workspaceId: string, + transfer: Transfer, + blob: Blob +): Promise> { + const completed: Array<{ partNumber: number; etag?: string }> = [] + + for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { + const partNumbers = [] + for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { + partNumbers.push(n) + } + + const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( + `${transfer.basePath}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * transfer.partSize + // `Blob.slice` is a view over the file on disk, so only the part being + // sent is ever read — the point of not buffering the upload. + const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) + + // boundary-raw-fetch: storage-signed URL on another origin, not the API + const response = await fetch(part.url, { + method: 'PUT', + headers: part.headers, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + + // S3-compatible stores identify a part by the ETag they return; the API + // treats it as optional because not every backend sends one. + const etag = response.headers.get('etag')?.replace(/"/g, '') + completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) + } + } + + return completed +} + +/** + * Runs a started transfer to completion: send the parts, then complete it. + * + * Anything that fails in between aborts the transfer, because a half-finished + * one holds storage the server would otherwise keep until it expires. A failed + * abort is swallowed — the original failure is what the caller needs to see. + */ +async function finishTransfer( + client: SimClient, + workspaceId: string, + transfer: Transfer, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, transfer, blob) + + const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { parts }, + }) + return completed.data + } catch (error) { + await client + .request(transfer.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} + +interface TableImport { + id: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + tableId: string | null + rowsProcessed: number + error: string | null + upload: { uploadToken: string; partSize: number; partCount: number } | null +} + +interface ImportOptions { + name?: string + tableId?: string + mode?: string + folderId?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + /** commander sets this false for `--no-wait`. */ + wait: boolean +} + +/** + * Turns a file name into a legal table name. + * + * Table names are identifiers — `^[A-Za-z_][A-Za-z0-9_]*$`, 128 max — so the + * obvious `basename(path)` would reject most real files: `2026-sales.csv` and + * `customer data.csv` both fail. Runs of anything else collapse to a single + * underscore, and a leading digit gets one in front, so a default derived from + * the file is a name the server actually accepts. + */ +function tableNameFrom(fileName: string): string { + const stem = fileName.replace(/\.[^.]+$/, '') + const cleaned = stem.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!cleaned) return 'imported_table' + return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) +} + +/** How often to ask an in-progress import where it got to. */ +const IMPORT_POLL_MS = 1500 + +/** Statuses the server will not move away from. */ +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +/** + * Parses a JSON flag through the same path the generated commands use, so + * `@file` and `@-` work here too rather than only on generated flags. + */ +function jsonFlag(raw: string, flagName: string): unknown { + return coerce(raw, { kind: 'object' }, { json: true }, flagName) +} + +/** + * Polls an import until it settles. + * + * The transfer only queues the work: rows are parsed server-side afterwards, so + * a command that returned at `complete` would report success for an import that + * goes on to fail on a malformed row. + */ +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS)) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + + // Only on a terminal, and only when it moves: the line rewrites itself with + // a carriage return, which in a redirected log is just escape noise. + if (process.stderr.isTTY && current.rowsProcessed !== reported) { + reported = current.rowsProcessed + process.stderr.write(`\r${chalk.dim(`${current.status}… ${reported} rows`)}\u001b[K`) + } + } + + if (process.stderr.isTTY && reported >= 0) process.stderr.write('\r\u001b[K') + return current +} + +/** Size and name checks every local-file transfer needs before starting one. */ +async function localFile(path: string, override?: string): Promise<{ name: string; size: number }> { + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + // A zero-byte transfer has no parts to send; the server cannot accept one. + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} + +export function attachHandWritten(program: Command): void { + // ── files upload ── a presigned multipart handshake, not one request ────── + group(program, 'files') + .command('upload ') + .description('Upload a file to the workspace') + .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--name ', 'Store it under a different name') + .action( + async (path: string, options: { folderId?: string; name?: string }, command: Command) => { + const { client } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + + const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folderId ? { folderId: options.folderId } : {}), + }, + }) + const upload = created.data + + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + console.log(chalk.green(`✓ Uploaded ${name} (${completed.file?.id ?? completed.id})`)) + } + ) + + // ── tables import ── a transfer, then an async job to watch ────────────── + group(program, 'tables') + .command('import [path]') + .description('Import a CSV, into a new table by default') + .option('--name ', 'Name for the new table (defaults to the file name)') + .option('--table-id ', 'Import into this existing table instead of creating one') + .option('--mode ', 'How to write into --table-id (default: append)') + .option('--folder-id ', 'Folder for the new table') + .option('--file-id ', 'Import a file already in the workspace instead of a local path') + .option('--mapping ', 'Column mapping (--table-id only)') + .option('--create-columns ', 'Columns to create (--table-id only)') + .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + .option('--no-wait', 'Return once the import is queued instead of watching it') + .action(async (path: string | undefined, options: ImportOptions, command: Command) => { + const { client } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + // The one thing that cannot be inferred: the bytes are either local or + // already in the workspace, and neither implies the other. + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + + const intoExisting = Boolean(options.tableId) + + // Flags that only mean something for one target. Silently ignoring them + // would let `--mode replace` read as honoured while a new table is + // created beside the one it was meant to overwrite. + const misplaced = intoExisting + ? ([ + ['--name', options.name], + ['--folder-id', options.folderId], + ] as const) + : ([ + ['--mode', options.mode], + ['--mapping', options.mapping], + ['--create-columns', options.createColumns], + ] as const) + for (const [flag, value] of misplaced) { + if (value === undefined) continue + throw new SimApiError( + intoExisting + ? `${flag} applies to a new table; --table-id already names the destination` + : `${flag} applies to --table-id: a new table takes its name and columns from the CSV`, + 0 + ) + } + + const local = path ? await localFile(path, undefined) : null + const source = local + ? { + type: 'upload', + name: local.name, + contentType: contentTypeFor(local.name), + size: local.size, + } + : { type: 'workspace_file', fileId: options.fileId } + + let target: Record + if (intoExisting) { + target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } + } else { + // A local file names the table; a workspace file id does not, and + // guessing one from an id would produce nonsense. + const name = options.name ?? (local ? tableNameFrom(local.name) : undefined) + if (!name) { + throw new SimApiError('Pass --name to say what the new table is called', 0) + } + target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } + } + + const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + }) + + let job = started.data + + // A workspace_file source has nothing to upload — the bytes are already + // there, and the server starts the job without a transfer. + if (path && job.upload) { + job = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + uploadToken: job.upload.uploadToken, + partSize: job.upload.partSize, + partCount: job.upload.partCount, + size: local?.size ?? 0, + }, + path + ) + } + + if (!options.wait) { + console.log(chalk.green(`✓ Import ${job.id} ${job.status}`)) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + console.log( + chalk.green( + `✓ Imported ${finished.rowsProcessed} rows${finished.tableId ? ` into ${finished.tableId}` : ''}` + ) + ) + }) + + // ── files download ── the response is binary, not the JSON envelope ──────── + group(program, 'files') + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .option('--force', 'Overwrite the destination if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + // `wx` fails rather than truncating: a download that silently replaces an + // existing file is unrecoverable, and the name often comes from the + // server's content-disposition rather than anything the caller typed. + await streamToFile( + response.body, + createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) + ) + console.log(chalk.green(`✓ Saved ${target}`)) + } + ) + + // ── tables rows list ── columns come from user-defined row data ─────────── + const tables = group(program, 'tables') + const rows = + tables.commands.find((command) => command.name() === 'rows') ?? tables.command('rows') + rows + .command('list ') + .description('List rows, with columns discovered from the data') + .option('--limit ', 'Maximum rows to return (0 for everything)', '100') + .action(async (tableId: string, options: { limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const parsed = Number.parseInt(options.limit, 10) + if (Number.isNaN(parsed) || parsed < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + const limit = parsed === 0 ? Number.POSITIVE_INFINITY : parsed + + const collected: Row[] = [] + let cursor: string | null = null + do { + const page = (await client.request(`/api/v2/tables/${encodeURIComponent(tableId)}/rows`, { + query: { workspaceId: client.requireWorkspace(), cursor }, + })) as QueryRowsResponse + collected.push(...page.data) + cursor = page.nextCursor + } while (cursor && collected.length < limit) + + const page = Number.isFinite(limit) ? collected.slice(0, limit) : collected + printList(profile.output, page, rowColumns(page)) + }) +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts new file mode 100644 index 00000000000..5a11e311370 --- /dev/null +++ b/packages/sim-cli/src/config/index.ts @@ -0,0 +1,17 @@ +export { configDir, configPath, credentialsPath } from './paths.js' +export { + DEFAULT_ENDPOINT, + DEFAULT_PROFILE, + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + readConfigProfile, + readCredentialsProfile, + resolveProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts new file mode 100644 index 00000000000..ba3a93fb84c --- /dev/null +++ b/packages/sim-cli/src/config/ini.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + getSection, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' + +const SAMPLE = `# top-level note +[default] +endpoint = https://sim.ai +workspace = ws_1 + +[profile dev] +# points at the local stack +endpoint = http://localhost:3000 +` + +describe('ini', () => { + it('reads keys out of a section', () => { + expect(getSection(parseIni(SAMPLE), 'default')).toEqual({ + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + }) + + it('reads a section whose name contains a space', () => { + expect(getSection(parseIni(SAMPLE), 'profile dev')).toEqual({ + endpoint: 'http://localhost:3000', + }) + }) + + it('returns null for a section that is not there', () => { + expect(getSection(parseIni(SAMPLE), 'profile nope')).toBeNull() + }) + + it('lists sections in file order', () => { + expect(listSections(parseIni(SAMPLE))).toEqual(['default', 'profile dev']) + }) + + it('preserves comments and untouched keys through a write', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile dev', { workspace: 'ws_local' }) + const out = serializeIni(doc) + + expect(out).toContain('# top-level note') + expect(out).toContain('# points at the local stack') + expect(out).toContain('endpoint = http://localhost:3000') + expect(out).toContain('workspace = ws_local') + }) + + it('updates a key in place rather than appending a duplicate', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { endpoint: 'https://staging.sim.ai' }) + const out = serializeIni(doc) + + expect(out).not.toContain('https://sim.ai\n') + expect(out.match(/endpoint = /g)).toHaveLength(2) // one per section, not three + }) + + it('removes a key when the value is null', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { workspace: null }) + expect(getSection(parseIni(serializeIni(doc)), 'default')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('creates a section that does not exist yet', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile prod', { endpoint: 'https://sim.ai' }) + expect(getSection(parseIni(serializeIni(doc)), 'profile prod')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('does not accumulate blank lines across repeated writes', () => { + let text = SAMPLE + for (let i = 0; i < 5; i++) { + const doc = parseIni(text) + setSectionValues(doc, 'default', { workspace: `ws_${i}` }) + text = serializeIni(doc) + } + expect(text).not.toContain('\n\n\n') + }) + + it('keeps a comment containing "=" as a comment', () => { + const doc = parseIni('[default]\n# note: a = b\nendpoint = https://sim.ai\n') + expect(getSection(doc, 'default')).toEqual({ endpoint: 'https://sim.ai' }) + expect(serializeIni(doc)).toContain('# note: a = b') + }) + + it('removes a whole section', () => { + const doc = parseIni(SAMPLE) + expect(removeSection(doc, 'profile dev')).toBe(true) + expect(removeSection(doc, 'profile dev')).toBe(false) + expect(listSections(doc)).toEqual(['default']) + }) + + it('round-trips an empty document without emitting a stray newline', () => { + expect(serializeIni(parseIni(''))).toBe('') + }) +}) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts new file mode 100644 index 00000000000..6b220b82267 --- /dev/null +++ b/packages/sim-cli/src/config/ini.ts @@ -0,0 +1,130 @@ +/** + * A minimal INI reader/writer for the AWS-style `~/.sim/config` and + * `~/.sim/credentials` files. + * + * Parsing keeps every line it did not understand — comments, blank lines, + * unrecognized keys — and writing re-emits them in place. These are files people + * hand-edit, so a round trip through `sim login` must not silently delete the + * comment above someone's staging endpoint. + * + * Deliberately not a general INI implementation: no nested sections, no `[a.b]` + * paths, no quoting rules beyond trimming. The format only has to carry a + * handful of flat string settings. + */ + +type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string } + +interface Section { + name: string + entries: Entry[] +} + +export interface IniDocument { + /** Lines before the first section header. */ + preamble: string[] + sections: Section[] +} + +const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ +const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ + +export function parseIni(text: string): IniDocument { + const doc: IniDocument = { preamble: [], sections: [] } + let current: Section | null = null + + for (const line of text.split('\n')) { + const sectionMatch = SECTION_PATTERN.exec(line) + if (sectionMatch) { + current = { name: sectionMatch[1].trim(), entries: [] } + doc.sections.push(current) + continue + } + + if (!current) { + doc.preamble.push(line) + continue + } + + const kvMatch = KV_PATTERN.exec(line) + // A `#`/`;` comment can contain `=`, so the comment check must come first. + if (kvMatch && !/^\s*[#;]/.test(line)) { + current.entries.push({ kind: 'kv', key: kvMatch[1], value: kvMatch[2] }) + } else { + current.entries.push({ kind: 'raw', text: line }) + } + } + + return doc +} + +export function serializeIni(doc: IniDocument): string { + const lines: string[] = [...doc.preamble] + + for (const section of doc.sections) { + // Keep exactly one blank line between sections without accumulating them + // across repeated writes. + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + if (lines.length > 0) lines.push('') + lines.push(`[${section.name}]`) + for (const entry of section.entries) { + lines.push(entry.kind === 'kv' ? `${entry.key} = ${entry.value}` : entry.text) + } + } + + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + return lines.length > 0 ? `${lines.join('\n')}\n` : '' +} + +export function getSection(doc: IniDocument, name: string): Record | null { + const section = doc.sections.find((s) => s.name === name) + if (!section) return null + + const values: Record = {} + for (const entry of section.entries) { + if (entry.kind === 'kv') values[entry.key] = entry.value + } + return values +} + +export function listSections(doc: IniDocument): string[] { + return doc.sections.map((s) => s.name) +} + +/** + * Upserts values into a section, creating it when absent. A `null` value removes + * the key. Existing keys are updated where they sit so surrounding comments keep + * describing the line they were written above. + */ +export function setSectionValues( + doc: IniDocument, + name: string, + values: Record +): void { + let section = doc.sections.find((s) => s.name === name) + if (!section) { + section = { name, entries: [] } + doc.sections.push(section) + } + + for (const [key, value] of Object.entries(values)) { + const index = section.entries.findIndex((e) => e.kind === 'kv' && e.key === key) + + if (value === null) { + if (index !== -1) section.entries.splice(index, 1) + continue + } + + if (index === -1) { + section.entries.push({ kind: 'kv', key, value }) + } else { + section.entries[index] = { kind: 'kv', key, value } + } + } +} + +export function removeSection(doc: IniDocument, name: string): boolean { + const index = doc.sections.findIndex((s) => s.name === name) + if (index === -1) return false + doc.sections.splice(index, 1) + return true +} diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts new file mode 100644 index 00000000000..158a356d57c --- /dev/null +++ b/packages/sim-cli/src/config/paths.ts @@ -0,0 +1,21 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * Where the CLI keeps its state. `SIM_CONFIG_DIR` overrides the location + * wholesale, which is what lets tests and CI point at a scratch directory + * instead of the invoking user's real credentials. + */ +export function configDir(): string { + return process.env.SIM_CONFIG_DIR || join(homedir(), '.sim') +} + +/** Non-secret per-profile settings. Safe to commit to a dotfiles repo. */ +export function configPath(): string { + return process.env.SIM_CONFIG_FILE || join(configDir(), 'config') +} + +/** API keys, written 0600. Kept apart from `config` so the two can be handled differently. */ +export function credentialsPath(): string { + return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials') +} diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts new file mode 100644 index 00000000000..48661750b7c --- /dev/null +++ b/packages/sim-cli/src/config/profile.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configPath, credentialsPath } from './paths.js' +import { + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + resolveProfile, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' + +let dir: string +const ENV_KEYS = ['SIM_PROFILE', 'SIM_ENDPOINT', 'SIM_API_KEY', 'SIM_WORKSPACE', 'SIM_OUTPUT'] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-cli-')) + process.env.SIM_CONFIG_DIR = dir + for (const key of ENV_KEYS) delete process.env[key] +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + process.env.SIM_CONFIG_DIR = undefined + for (const key of ENV_KEYS) delete process.env[key] +}) + +describe('profile resolution', () => { + it('falls back to built-in defaults with nothing configured', () => { + const profile = resolveProfile() + expect(profile.name).toBe('default') + expect(profile.endpoint).toBe('https://sim.ai') + expect(profile.apiKey).toBeNull() + expect(profile.output).toBe('table') + expect(profile.sources.apiKey).toBe('unset') + }) + + it('reads settings and credentials for the default profile', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_1' }) + writeCredentialsProfile('default', 'sim_key') + + const profile = resolveProfile() + expect(profile.endpoint).toBe('https://a.example') + expect(profile.workspaceId).toBe('ws_1') + expect(profile.apiKey).toBe('sim_key') + expect(profile.sources).toMatchObject({ endpoint: 'config', apiKey: 'credentials' }) + }) + + it('namespaces a non-default profile as [profile x] in config but [x] in credentials', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'sim_dev') + + expect(readFileSync(configPath(), 'utf8')).toContain('[profile dev]') + expect(readFileSync(credentialsPath(), 'utf8')).toContain('[dev]') + expect(readFileSync(credentialsPath(), 'utf8')).not.toContain('[profile dev]') + }) + + it('keeps profiles isolated from one another', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_a' }) + writeCredentialsProfile('default', 'key_a') + writeConfigProfile('dev', { endpoint: 'http://localhost:3000', workspace: 'ws_b' }) + writeCredentialsProfile('dev', 'key_b') + + expect(resolveProfile()).toMatchObject({ workspaceId: 'ws_a', apiKey: 'key_a' }) + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + workspaceId: 'ws_b', + apiKey: 'key_b', + }) + }) + + it('lets a flag beat the environment, and the environment beat the file', () => { + writeConfigProfile('default', { endpoint: 'https://file.example' }) + + expect(resolveProfile().endpoint).toBe('https://file.example') + + process.env.SIM_ENDPOINT = 'https://env.example' + expect(resolveProfile()).toMatchObject({ endpoint: 'https://env.example' }) + expect(resolveProfile().sources.endpoint).toBe('env') + + expect(resolveProfile({ endpoint: 'https://flag.example' })).toMatchObject({ + endpoint: 'https://flag.example', + }) + expect(resolveProfile({ endpoint: 'https://flag.example' }).sources.endpoint).toBe('flag') + }) + + it('selects the profile from SIM_PROFILE when no flag is given', () => { + writeCredentialsProfile('dev', 'key_dev') + process.env.SIM_PROFILE = 'dev' + expect(resolveProfile()).toMatchObject({ name: 'dev', apiKey: 'key_dev' }) + expect(resolveProfile({ profile: 'default' }).name).toBe('default') + }) + + it('strips a trailing slash so paths do not double up', () => { + expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') + }) + + it('ignores an unrecognized output format instead of failing the whole resolve', () => { + // Both output sources are ambient — set once, then every later command reads + // them — so a bad value falls back rather than breaking the CLI outright. + process.env.SIM_OUTPUT = 'xml' + expect(resolveProfile().output).toBe('table') + + process.env.SIM_OUTPUT = undefined + writeConfigProfile('default', { output: 'xml' }) + expect(resolveProfile().output).toBe('table') + }) + + it('takes the output format from the profile, and lets the env override it', () => { + // There is deliberately no `--output` flag: format is a profile setting. + writeConfigProfile('default', { output: 'yaml' }) + expect(resolveProfile()).toMatchObject({ output: 'yaml', sources: { output: 'config' } }) + + process.env.SIM_OUTPUT = 'json' + expect(resolveProfile()).toMatchObject({ output: 'json', sources: { output: 'env' } }) + }) + + it('accepts every documented output format from the environment', () => { + for (const format of OUTPUT_FORMATS) { + process.env.SIM_OUTPUT = format + expect(resolveProfile().output).toBe(format) + } + }) + + it('writes credentials 0600 even when the file already existed world-readable', () => { + writeFileSync(credentialsPath(), '', { mode: 0o644 }) + writeCredentialsProfile('default', 'sim_key') + expect(statSync(credentialsPath()).mode & 0o777).toBe(0o600) + }) + + it('lists profiles from both files without duplicating', () => { + writeConfigProfile('default', { endpoint: 'https://a.example' }) + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('ci', 'key') + + expect(listProfiles()).toEqual(['ci', 'default', 'dev']) + }) + + it('deletes a profile from both files', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + + expect(deleteProfile('dev')).toEqual({ config: true, credentials: true }) + expect(listProfiles()).toEqual([]) + expect(deleteProfile('dev')).toEqual({ config: false, credentials: false }) + }) + + it('clears just the key when the credential is removed', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('dev', null) + + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + apiKey: null, + endpoint: 'http://localhost:3000', + }) + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts new file mode 100644 index 00000000000..48fca121498 --- /dev/null +++ b/packages/sim-cli/src/config/profile.ts @@ -0,0 +1,215 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getSection, + type IniDocument, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' +import { configPath, credentialsPath } from './paths.js' + +export const DEFAULT_PROFILE = 'default' +export const DEFAULT_ENDPOINT = 'https://sim.ai' + +/** + * Output formats, in the order `--help` lists them. + * + * `table` is for reading, `json`/`yaml` for piping into a parser, and `text` is + * the one for shell loops: tab-separated, no header, no colour, so `cut`/`awk`/ + * `while read` work without a JSON tool on the box. + */ +export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const +export type OutputFormat = (typeof OUTPUT_FORMATS)[number] + +/** Everything a command needs to make a call, after the resolution chain runs. */ +export interface ResolvedProfile { + name: string + endpoint: string + apiKey: string | null + workspaceId: string | null + output: OutputFormat + /** Where each value came from, for `sim whoami` to explain surprising results. */ + sources: { + endpoint: SettingSource + apiKey: SettingSource + workspaceId: SettingSource + output: SettingSource + } +} + +export type SettingSource = 'flag' | 'env' | 'config' | 'credentials' | 'default' | 'unset' + +export interface ProfileOverrides { + profile?: string + endpoint?: string + apiKey?: string + workspaceId?: string +} + +/** + * AWS's asymmetry, reproduced deliberately: the config file namespaces + * non-default profiles as `[profile dev]` while the credentials file uses a bare + * `[dev]`. It is a wart, but matching it means muscle memory and existing + * tooling carry over. + */ +function configSectionName(profile: string): string { + return profile === DEFAULT_PROFILE ? DEFAULT_PROFILE : `profile ${profile}` +} + +function readIni(path: string): IniDocument { + if (!existsSync(path)) return { preamble: [], sections: [] } + return parseIni(readFileSync(path, 'utf8')) +} + +function writeIni(path: string, doc: IniDocument, secret: boolean): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, serializeIni(doc), { mode: secret ? 0o600 : 0o644 }) + // `writeFileSync`'s mode only applies when it creates the file, so an existing + // credentials file written before this ran (or created by a hand `touch`) + // keeps its old, possibly world-readable, permissions without this. + if (secret) chmodSync(path, 0o600) +} + +export function readConfigProfile(profile: string): Record { + return getSection(readIni(configPath()), configSectionName(profile)) ?? {} +} + +export function readCredentialsProfile(profile: string): Record { + return getSection(readIni(credentialsPath()), profile) ?? {} +} + +/** Every profile named by either file, deduplicated and sorted. */ +export function listProfiles(): string[] { + const names = new Set() + + for (const section of listSections(readIni(configPath()))) { + if (section === DEFAULT_PROFILE) names.add(DEFAULT_PROFILE) + else if (section.startsWith('profile ')) names.add(section.slice('profile '.length).trim()) + } + for (const section of listSections(readIni(credentialsPath()))) { + names.add(section) + } + + return [...names].sort() +} + +export function writeConfigProfile(profile: string, values: Record): void { + const doc = readIni(configPath()) + setSectionValues(doc, configSectionName(profile), values) + writeIni(configPath(), doc, false) +} + +export function writeCredentialsProfile(profile: string, apiKey: string | null): void { + const doc = readIni(credentialsPath()) + setSectionValues(doc, profile, { api_key: apiKey }) + writeIni(credentialsPath(), doc, true) +} + +/** Drops the profile from both files. Returns whether anything was removed. */ +export function deleteProfile(profile: string): { config: boolean; credentials: boolean } { + const configDoc = readIni(configPath()) + const config = removeSection(configDoc, configSectionName(profile)) + if (config) writeIni(configPath(), configDoc, false) + + const credentialsDoc = readIni(credentialsPath()) + const credentials = removeSection(credentialsDoc, profile) + if (credentials) writeIni(credentialsPath(), credentialsDoc, true) + + return { config, credentials } +} + +function normalizeEndpoint(endpoint: string): string { + // A trailing slash here produces `https://sim.ai//api/v2/...`, which some + // proxies 404 rather than normalize. + return endpoint.replace(/\/+$/, '') +} + +function parseOutput(value: string | undefined): OutputFormat | null { + return value && (OUTPUT_FORMATS as readonly string[]).includes(value) + ? (value as OutputFormat) + : null +} + +/** + * Resolves one setting through the precedence chain, reporting where it landed. + * Order is flags → environment → files → built-in default, the same order every + * profile-based CLI uses: the more specific and more ephemeral the source, the + * higher it wins. + */ +function resolve( + candidates: Array<[SettingSource, T | null | undefined]>, + fallback: T | null, + fallbackSource: SettingSource +): { value: T | null; source: SettingSource } { + for (const [source, value] of candidates) { + if (value !== null && value !== undefined && value !== '') return { value, source } + } + return { value: fallback, source: fallbackSource } +} + +export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile { + const name = overrides.profile || process.env.SIM_PROFILE || DEFAULT_PROFILE + const config = readConfigProfile(name) + const credentials = readCredentialsProfile(name) + + const endpoint = resolve( + [ + ['flag', overrides.endpoint], + ['env', process.env.SIM_ENDPOINT], + ['config', config.endpoint], + ], + DEFAULT_ENDPOINT, + 'default' + ) + + const apiKey = resolve( + [ + ['flag', overrides.apiKey], + ['env', process.env.SIM_API_KEY], + ['credentials', credentials.api_key], + ], + null, + 'unset' + ) + + const workspaceId = resolve( + [ + ['flag', overrides.workspaceId], + ['env', process.env.SIM_WORKSPACE], + ['config', config.workspace], + ], + null, + 'unset' + ) + + /** + * No flag tier: output format is a profile setting, not a per-command one. + * `SIM_OUTPUT` stays as the one-off escape hatch (`SIM_OUTPUT=json sim … | jq`) + * and as the file-less path for CI, but there is deliberately no `--output`. + */ + const output = resolve( + [ + ['env', parseOutput(process.env.SIM_OUTPUT)], + ['config', parseOutput(config.output)], + ], + 'table', + 'default' + ) + + return { + name, + endpoint: normalizeEndpoint(endpoint.value as string), + apiKey: apiKey.value, + workspaceId: workspaceId.value, + output: output.value as OutputFormat, + sources: { + endpoint: endpoint.source, + apiKey: apiKey.source, + workspaceId: workspaceId.source, + output: output.source, + }, + } +} diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts new file mode 100644 index 00000000000..7486100815f --- /dev/null +++ b/packages/sim-cli/src/context.ts @@ -0,0 +1,34 @@ +import type { Command } from 'commander' +import { type ProfileOverrides, type ResolvedProfile, resolveProfile } from './config/index.js' +import { SimClient } from './http/client.js' + +/** Global flags, shared by every subcommand. */ +export interface GlobalOptions { + profile?: string + endpoint?: string + workspace?: string +} + +/** + * Commander stores globals on the root command, not on the leaf that ran, so a + * subcommand handler has to walk up to find them. `optsWithGlobals()` does that + * walk; reading `command.opts()` alone silently drops `--profile`. + */ +export function globalsOf(command: Command): GlobalOptions { + return command.optsWithGlobals() as GlobalOptions +} + +export function profileFrom(command: Command, extra: ProfileOverrides = {}): ResolvedProfile { + const globals = globalsOf(command) + return resolveProfile({ + profile: globals.profile, + endpoint: globals.endpoint, + workspaceId: globals.workspace, + ...extra, + }) +} + +export function clientFrom(command: Command): { client: SimClient; profile: ResolvedProfile } { + const profile = profileFrom(command) + return { client: new SimClient(profile), profile } +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts new file mode 100644 index 00000000000..c74c16c4132 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.ts @@ -0,0 +1,332 @@ +import type { CliContract } from './types.js' + +/** + * The CLI contract for the v2 surface. + * + * Read this as a diff against what is already derivable — an operation absent + * from this table still gets a command, built entirely from the generated + * operation table. Only the entries below needed a human. + * + * Derived by default: + * listTables → sim tables list + * getKnowledgeDocument → sim knowledge documents get + * upsertTableRow → sim tables upsert + */ +export const CLI_CONTRACT: CliContract = { + // ─── Name collisions: REST overloads one path for single and bulk ───────── + // The derived name is identical for both, so the bulk form is renamed. AWS's + // `batch-` prefix rather than a `--all` flag: the plural is a different and + // more dangerous operation, and it should be a different word. + deleteTableRows: { + command: 'tables rows batch-delete', + describe: 'Delete rows matching a filter, or an explicit list of ids', + flags: { rowIds: { name: 'row', list: true }, filter: { json: true } }, + confirm: 'This deletes every matching row and cannot be undone.', + }, + updateRowsByFilter: { + command: 'tables rows batch-update', + describe: 'Update every row matching a filter', + flags: { filter: { json: true }, data: { json: true } }, + confirm: 'This updates every matching row and cannot be undone.', + }, + // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. + undeployWorkflow: { + command: 'workflows undeploy', + describe: 'Take a workflow out of deployment', + }, + + // ─── Destructive single-resource operations ─────────────────────────────── + deleteTable: { confirm: 'This deletes the table and all of its rows.' }, + deleteTableRow: { confirm: 'This deletes the row.' }, + deleteTableColumn: { confirm: 'This deletes the column and its values in every row.' }, + deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, + deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, + deleteFile: { confirm: 'This archives the file.' }, + deleteSkill: { confirm: 'This deletes the skill.' }, + deleteCustomTool: { confirm: 'This deletes the custom tool.' }, + deleteMcpServer: { + confirm: 'This removes the MCP server and the tools it provides.', + }, + deleteCredential: { + confirm: 'This deletes the credential; anything authenticating with it stops working.', + }, + deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' }, + deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, + deleteWorkflowGroup: { + // Not just the grouping: the documented behaviour is that every column the + // group fed goes with it, values included. + confirm: 'This deletes the group, every column it fed, and the values in them.', + }, + deleteFolder: { + // The route archives the folder *and cascades to its contents*, so this is + // the broadest delete on the surface — the message says so rather than + // reading like a single-item removal. + confirm: 'This archives the folder and everything inside it.', + }, + + // ─── Fields whose type misdescribes their meaning ───────────────────────── + // `z.string()` that the route splits on commas. No generator can infer this. + listLogs: { + flags: { + workflowIds: { name: 'workflow', list: true }, + folderIds: { name: 'folder', list: true }, + triggers: { name: 'trigger', list: true }, + }, + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'execution', path: 'executionId' }, + ], + }, + searchKnowledge: { + // Accepts a string or an array on the wire; the CLI always sends the array. + flags: { knowledgeBaseIds: { name: 'kb', list: true }, tagFilters: { json: true } }, + columns: [ + { header: 'score', path: 'similarity' }, + { header: 'document', path: 'documentName' }, + { header: 'chunk', path: 'chunkIndex' }, + { header: 'content' }, + ], + }, + + // ─── Friendlier flag names ──────────────────────────────────────────────── + upsertTableRow: { + describe: 'Insert a row, or update the one that conflicts on a unique column', + flags: { + data: { json: true }, + conflictTarget: { name: 'on', describe: 'Unique column to resolve the conflict against' }, + }, + columns: [{ header: 'id' }, { header: 'operation' }], + }, + queryRows: { + command: 'tables rows query', + flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + // A row's cells live under `data`; without this the table showed an id and + // two timestamps per row and none of the content anyone ran the query for. + expand: 'data', + }, + + // ─── Output columns for list commands ───────────────────────────────────── + listTables: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'rows', path: 'rowCount' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkflows: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'deployed', path: 'isDeployed', format: 'bool' }, + { header: 'runs', path: 'runCount' }, + { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, + ], + }, + listFiles: { + columns: [ + { header: 'id' }, + { header: 'name' }, + // Now that files live in folders, which one is the difference between two + // identically-named rows. + { header: 'folder', path: 'folderPath' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + listKnowledgeBases: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'docs', path: 'docCount' }, + { header: 'tokens', path: 'tokenCount' }, + { header: 'model', path: 'embeddingModel' }, + ], + }, + listKnowledgeDocuments: { + columns: [ + { header: 'id' }, + { header: 'filename' }, + { header: 'size', path: 'fileSize', format: 'bytes' }, + { header: 'status', path: 'processingStatus' }, + { header: 'chunks', path: 'chunkCount' }, + ], + }, + // Without these the inferred fallback dumps every scalar field — 20 columns + // for an MCP server, including `hasOauthClientSecret`. + listMcpServers: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'transport' }, + { header: 'url' }, + { header: 'status', path: 'connectionStatus' }, + { header: 'tools', path: 'toolCount' }, + { header: 'enabled', format: 'bool' }, + ], + }, + listSkills: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCustomTools: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listFolders: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'parent', path: 'parentId' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCredentials: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'provider' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + + listAuditLogs: { + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'actor', path: 'actorEmail' }, + { header: 'action' }, + { header: 'resource', path: 'resourceName' }, + ], + }, + + // ─── The expanded files surface ─────────────────────────────────────────── + // Every one of these derives badly. `/files/move` and `/files/bulk-archive` + // are verbs sitting where the deriver expects a sub-resource, so it made them + // groups holding a lone `create`; and `GET /files/[id]/share` fetches one + // share, which the deriver read as a collection and named `list`. + bulkArchiveFileItems: { + // `batch-` for the bulk form, matching `tables rows batch-delete`. + command: 'files batch-archive', + describe: 'Archive several files and folders at once', + confirm: 'This archives every listed file and folder, and everything inside those folders.', + }, + moveFileItems: { + command: 'files move', + describe: 'Move files and folders into another folder', + }, + renameFile: { + // Derived to `files update`, which contradicted its own summary. + command: 'files rename', + describe: 'Rename a file', + }, + restoreFile: { + command: 'files restore', + describe: 'Restore an archived file', + }, + updateFileContent: { + command: 'files set-content', + describe: 'Replace a file’s contents', + }, + getFileShare: { + command: 'files share get', + describe: 'Show a file’s share settings', + }, + upsertFileShare: { + command: 'files share set', + describe: 'Enable or disable sharing for a file', + }, + + // ─── The expanded tables surface ────────────────────────────────────────── + // `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment + // path all put a verb where the deriver expects a sub-resource, so each became + // a group holding a lone `create`. + cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' }, + findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate' }, + restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, + runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' }, + runRowEnrichment: { + command: 'tables rows enrich', + describe: 'Run one row’s enrichment group', + }, + + // The handshake behind `sim tables import`. Its halfway states hold storage + // and a half-sent import is not something to leave reachable, so the steps + // stay hidden — unlike `get` and `cancel`, which are useful on their own for + // an import already running. + createTableImport: { hidden: true }, + createTableImportPartUrls: { hidden: true }, + completeTableImport: { hidden: true }, + cancelTableImport: { command: 'tables imports cancel' }, + cancelTableExport: { command: 'tables exports cancel' }, + tableExportDownload: { + // GET, but it returns a signed URL rather than a listing. + command: 'tables exports download', + describe: 'Get the download URL for a finished export', + }, + + // ─── Documents, not records ─────────────────────────────────────────────── + // The payload is the artifact: `sim workflows export > wf.json` has to + // produce something `sim workflows import` accepts back. + exportWorkflow: { + describe: 'Print a workflow as a portable JSON document', + document: true, + }, + + // ─── Execution ──────────────────────────────────────────────────────────── + // The derived names land badly here: `/execute` and `/cancel` are verbs in + // the path, but neither is in the action list, so POST would derive + // `workflows execute create` and `workflows cancel create`. + executeWorkflow: { + command: 'workflows run', + describe: 'Run a deployed workflow and wait for the result', + flags: { + input: { json: true, describe: 'Trigger input as JSON' }, + selectedOutputs: { name: 'output', list: true }, + // SSE, not JSON — the generic client cannot consume it. A `sim workflows + // run --follow` that renders the stream is a separate, hand-written + // command; advertising a flag that breaks the response is worse than + // not offering it yet. + stream: { omit: true }, + }, + }, + getWorkflowExecution: { + command: 'workflows executions get', + describe: 'Show the status of one execution', + }, + cancelWorkflowExecution: { + command: 'workflows executions cancel', + describe: 'Cancel a running execution', + // Not `confirm`-gated: cancelling is recoverable (re-run it), and the + // whole point is to stop something that is already going wrong. + }, + + // ─── Not a terminal-shaped operation ────────────────────────────────────── + // Multipart upload; `sim knowledge documents upload ` would need its own + // file-reading command rather than a generated flag surface. + uploadKnowledgeDocument: { hidden: true }, + + // ─── Steps of a transfer, not commands ──────────────────────────────────── + // Uploading is now a presigned multipart handshake: create the upload, ask for + // part URLs in batches, PUT each part to storage, then complete with the + // ETags — and abort if any of it fails. Exposing the steps individually would + // advertise a protocol whose halfway states leak storage, so `sim files + // upload` drives the whole sequence and these stay out of the surface. + createFileUpload: { hidden: true }, + createFileUploadPartUrls: { hidden: true }, + completeFileUpload: { hidden: true }, + abortFileUpload: { hidden: true }, +} diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts new file mode 100644 index 00000000000..2460a351b4d --- /dev/null +++ b/packages/sim-cli/src/contract/types.ts @@ -0,0 +1,111 @@ +import type { V2OperationName } from '../generated/v2-api.js' + +/** + * The CLI contract: how the terminal surface maps onto the v2 API. + * + * Most of a command is derivable and is NOT stated here. Method, path, path + * params, field types, enum values, defaults, and required-ness all come from + * the generated operation table, which comes from the Zod route contracts. The + * command name itself derives from ` ` for 41 of + * the 44 operations. + * + * This file carries only what a schema cannot say: + * + * - `command` — when the derived name collides or reads badly. REST overloads + * one path for single and bulk (`DELETE /rows` vs `DELETE /rows/[rowId]`), so + * those need a human to pick `delete` vs `batch-delete`. + * - `flags` — when a field's *type* misdescribes its *meaning*. `workflowIds` + * is `z.string()` that the route splits on commas; nothing in the schema says + * "list". Also friendlier aliases (`conflictTarget` → `--on`). + * - `columns` — which of a response's fields belong in a table. Editorial. + * - `confirm` — which operations are destructive enough to demand `--yes`. + * + * An operation with nothing unusual needs no entry at all. + */ + +/** How one request field is exposed as a flag. */ +export interface FlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased field name. */ + name?: string + /** Short alias, e.g. `w` for `--workspace`. */ + short?: string + /** + * Accept the flag more than once. + * + * Only says that several values are allowed — how they reach the wire is + * decided by the field's kind, not here. A `string` field is one the route + * splits on commas (`workflowIds`), so the values are joined; anything else + * genuinely wants an array (`rowIds`, `knowledgeBaseIds`). Conflating the two + * turned multi-value `--kb` and `--row` into a single bogus value. + * + * Still needed on the string case because "this string is really a list" is + * invisible to any type-driven generator. + */ + list?: boolean + /** Take a JSON string. Implied for object/array/unknown fields. */ + json?: boolean + /** Overrides the help text otherwise taken from the OpenAPI description. */ + describe?: string + /** + * Never expose this field as a flag, and never send it. + * + * For request fields the terminal cannot honor — `stream: true` switches the + * response to SSE, which the JSON client would try to `JSON.parse`. Offering + * the flag would advertise a mode that breaks; a bespoke streaming command + * owns that instead. + */ + omit?: boolean +} + +/** A column in table-mode output. */ +export interface ColumnSpec { + /** Header, and the default path into the row when `value` is omitted. */ + header: string + /** Dot path into the row. Defaults to `header`. */ + path?: string + /** Rendering hint; `auto` inspects the value. */ + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' +} + +export interface CommandSpec { + /** + * Command path, space-separated. Omit to accept the derived + * ` [sub-resource] ` name. + */ + command?: string + /** One-line help. Falls back to the OpenAPI summary for the operation. */ + describe?: string + /** Per-field flag overrides, keyed by the contract's field name. */ + flags?: Record + /** Columns for table output. Omit on non-list commands to print a record. */ + columns?: ColumnSpec[] + /** + * Require `--yes`. The message should say what is about to be destroyed — + * the point is that the caller can tell whether they meant it. + */ + confirm?: string + /** + * Discover table columns from inside this nested field as well as from the + * row's own scalars. + * + * For rows whose real content sits in a wrapper the server chose — a table + * row's user-defined cells live under `data` — the inferred columns would + * otherwise be `id` and two timestamps, because a nested object cannot be a + * column. Only meaningful when `columns` is absent. + */ + expand?: string + /** + * The response IS a document, not a record to look at. + * + * `workflows export` exists to be redirected into a file and fed back to + * `import`, so a key/value view of it is wrong at any fidelity — the useful + * artifact is the payload itself. Document commands emit raw JSON (or YAML + * when the profile says so) whatever the profile's display format is. + */ + document?: boolean + /** Keep the operation out of the CLI surface entirely. */ + hidden?: boolean +} + +/** The contract: operation name → how it appears in the terminal. */ +export type CliContract = Partial> diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts new file mode 100644 index 00000000000..bec61da9beb --- /dev/null +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -0,0 +1,5381 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Emitted from the Zod route contracts in + * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`. + * Regenerate with `bun run generate:cli-api`; CI fails when this file is + * stale, so edit the contract rather than this file. + * + * Contains only type declarations and one const table — no imports, so the + * `packages/* must not import apps/*` boundary is preserved. + */ + +/** `DELETE /api/v2/files/uploads/[uploadId]` */ +export type AbortFileUploadParams = { + uploadId: string +} + +export type AbortFileUploadQuery = { + workspaceId: string +} + +export type AbortFileUploadHeaders = { + 'upload-token': string +} + +export type AbortFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/tables/[tableId]/columns` */ +export type AddTableColumnParams = { + tableId: string +} + +export type AddTableColumnBody = { + workspaceId: string + column: { + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + position?: number + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + } +} + +export type AddTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `POST /api/v2/tables/[tableId]/groups` */ +export type AddWorkflowGroupParams = { + tableId: string +} + +export type AddWorkflowGroupBody = { + workspaceId: string + group: { + id?: string + workflowId?: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + outputColumns: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + autoRun?: boolean +} + +export type AddWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `POST /api/v2/files/bulk-archive` */ +export type BulkArchiveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array +} + +export type BulkArchiveFileItemsResponse = { + data: { + deletedItems: { + files: number + folders: number + } + } +} + +/** `DELETE /api/v2/tables/exports/[exportId]` */ +export type CancelTableExportParams = { + exportId: string +} + +export type CancelTableExportQuery = { + workspaceId: string +} + +export type CancelTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `DELETE /api/v2/tables/imports/[importId]` */ +export type CancelTableImportParams = { + importId: string +} + +export type CancelTableImportQuery = { + workspaceId: string +} + +export type CancelTableImportHeaders = { + 'upload-token'?: string +} + +export type CancelTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/cancel-runs` */ +export type CancelTableRunsParams = { + tableId: string +} + +export type CancelTableRunsBody = { + workspaceId: string + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: Array +} + +export type CancelTableRunsResponse = { + data: { + cancelled: number + } +} + +/** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ +export type CancelWorkflowExecutionParams = { + id: string + executionId: string +} + +export type CancelWorkflowExecutionResponse = { + data: { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + } +} + +/** `POST /api/v2/files/uploads/[uploadId]/complete` */ +export type CompleteFileUploadParams = { + uploadId: string +} + +export type CompleteFileUploadQuery = { + workspaceId: string +} + +export type CompleteFileUploadBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteFileUploadHeaders = { + 'upload-token': string +} + +export type CompleteFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/complete` */ +export type CompleteTableImportParams = { + importId: string +} + +export type CompleteTableImportQuery = { + workspaceId: string +} + +export type CompleteTableImportBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteTableImportHeaders = { + 'upload-token': string +} + +export type CompleteTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/credentials` */ +export type CreateCredentialBody = { + workspaceId: string + type: 'env_workspace' | 'env_personal' | 'service_account' + displayName?: string + description?: string + providerId?: string + envKey?: string + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type CreateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/custom-tools` */ +export type CreateCustomToolBody = { + workspaceId: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string +} + +export type CreateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/files/uploads` */ +export type CreateFileUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string +} + +export type CreateFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/files/uploads/[uploadId]/parts` */ +export type CreateFileUploadPartUrlsParams = { + uploadId: string +} + +export type CreateFileUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateFileUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateFileUploadPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateFileUploadPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + +/** `POST /api/v2/folders` */ +export type CreateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name: string + parentId?: string | null + sortOrder?: number +} + +export type CreateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + +/** `POST /api/v2/knowledge` */ +export type CreateKnowledgeBaseBody = { + workspaceId: string + name: string + description?: string + chunkingConfig?: { + maxSize?: number + minSize?: number + overlap?: number + } +} + +export type CreateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/mcp-servers` */ +export type CreateMcpServerBody = { + workspaceId: string + name: string + description?: string + transport?: 'streamable-http' + url: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type CreateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `POST /api/v2/skills` */ +export type CreateSkillBody = { + workspaceId: string + name: string + description: string + content: string +} + +export type CreateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + +/** `POST /api/v2/tables` */ +export type CreateTableBody = { + name: string + description?: string + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + }> + } + workspaceId: string + folderId?: string | null +} + +export type CreateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/tables/[tableId]/exports` */ +export type CreateTableExportParams = { + tableId: string +} + +export type CreateTableExportBody = { + workspaceId: string + format?: 'csv' | 'json' +} + +export type CreateTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports` */ +export type CreateTableImportBody = { + workspaceId: string + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + mapping?: unknown + createColumns?: unknown + timezone?: string +} + +export type CreateTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/parts` */ +export type CreateTableImportPartUrlsParams = { + importId: string +} + +export type CreateTableImportPartUrlsQuery = { + workspaceId: string +} + +export type CreateTableImportPartUrlsBody = { + partNumbers: Array +} + +export type CreateTableImportPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateTableImportPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + +/** `POST /api/v2/tables/[tableId]/rows` */ +export type CreateTableRowsParams = { + tableId: string +} + +export type CreateTableRowsBody = + | { + workspaceId: string + rows: Array + } + | { + workspaceId: string + data: unknown + afterRowId?: string + beforeRowId?: string + } + +export type CreateTableRowsResponse = + | { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } + } + | { + data: { + rows: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + insertedCount: number + } + } + +/** `POST /api/v2/tables/[tableId]/views` */ +export type CreateTableViewParams = { + tableId: string +} + +export type CreateTableViewBody = { + workspaceId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } +} + +type CreateTableViewResponseRef0 = + | { + all: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type CreateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: CreateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/workflows` */ +export type CreateWorkflowBody = { + workspaceId: string + name: string + description?: string | null + folderId?: string | null +} + +export type CreateWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + } +} + +/** `DELETE /api/v2/credentials/[id]` */ +export type DeleteCredentialParams = { + id: string +} + +export type DeleteCredentialQuery = { + workspaceId: string +} + +export type DeleteCredentialResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/custom-tools/[id]` */ +export type DeleteCustomToolParams = { + id: string +} + +export type DeleteCustomToolQuery = { + workspaceId: string +} + +export type DeleteCustomToolResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/files/[fileId]` */ +export type DeleteFileParams = { + fileId: string +} + +export type DeleteFileQuery = { + workspaceId: string +} + +export type DeleteFileResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/folders/[id]` */ +export type DeleteFolderParams = { + id: string +} + +export type DeleteFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type DeleteFolderResponse = { + data: { + id: string + deleted: true + deletedItems?: { + folders: number + workflows?: number + files?: number + knowledgeBases?: number + tables?: number + } + } +} + +/** `DELETE /api/v2/knowledge/[id]` */ +export type DeleteKnowledgeBaseParams = { + id: string +} + +export type DeleteKnowledgeBaseQuery = { + workspaceId: string +} + +export type DeleteKnowledgeBaseResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ +export type DeleteKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type DeleteKnowledgeDocumentQuery = { + workspaceId: string +} + +export type DeleteKnowledgeDocumentResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/mcp-servers/[id]` */ +export type DeleteMcpServerParams = { + id: string +} + +export type DeleteMcpServerQuery = { + workspaceId: string +} + +export type DeleteMcpServerResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/skills/[id]` */ +export type DeleteSkillParams = { + id: string +} + +export type DeleteSkillQuery = { + workspaceId: string +} + +export type DeleteSkillResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]` */ +export type DeleteTableParams = { + tableId: string +} + +export type DeleteTableQuery = { + workspaceId: string +} + +export type DeleteTableResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/tables/[tableId]/columns` */ +export type DeleteTableColumnParams = { + tableId: string +} + +export type DeleteTableColumnBody = { + workspaceId: string + columnName: string +} + +export type DeleteTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ +export type DeleteTableRowParams = { + tableId: string + rowId: string +} + +export type DeleteTableRowQuery = { + workspaceId: string +} + +export type DeleteTableRowResponse = { + data: { + deletedCount: number + deletedRowIds: Array + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows` */ +export type DeleteTableRowsParams = { + tableId: string +} + +export type DeleteTableRowsBody = { + workspaceId: string + filter?: unknown + limit?: number + rowIds?: Array +} + +export type DeleteTableRowsResponse = { + data: { + deletedCount: number + deletedRowIds: Array + requestedCount?: number + missingRowIds?: Array + } +} + +/** `DELETE /api/v2/tables/[tableId]/views/[viewId]` */ +export type DeleteTableViewParams = { + tableId: string + viewId: string +} + +export type DeleteTableViewQuery = { + workspaceId: string +} + +export type DeleteTableViewResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/workflows/[id]` */ +export type DeleteWorkflowParams = { + id: string +} + +export type DeleteWorkflowResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]/groups` */ +export type DeleteWorkflowGroupParams = { + tableId: string +} + +export type DeleteWorkflowGroupBody = { + workspaceId: string + groupId: string +} + +export type DeleteWorkflowGroupResponse = { + data: { + id: string + deleted: true + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `POST /api/v2/workflows/[id]/deploy` */ +export type DeployWorkflowParams = { + id: string +} + +export type DeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version?: number + } +} + +/** `GET /api/v2/files/[fileId]` */ +export type DownloadFileParams = { + fileId: string +} + +export type DownloadFileQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileResponse = never + +/** `POST /api/v2/workflows/[id]/execute` */ +export type ExecuteWorkflowParams = { + id: string +} + +export type ExecuteWorkflowBody = { + input?: Record + async?: boolean + stream?: boolean + selectedOutputs?: Array + includeThinking?: boolean + includeToolCalls?: boolean + includeFileBase64?: boolean + base64MaxBytes?: number +} + +export type ExecuteWorkflowResponse = { + data: { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + startedAt?: string + endedAt?: string + durationMs?: number + } +} + +/** `GET /api/v2/workflows/[id]/export` */ +export type ExportWorkflowParams = { + id: string +} + +export type ExportWorkflowResponse = { + data: { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + } + state: { + blocks: Record< + string, + { + id: string + type: string + name: string + position: { + x: number + y: number + } + subBlocks: Record< + string, + { + id: string + type: string + value: unknown + } + > + outputs: Record + enabled: boolean + horizontalHandles?: boolean + height?: number + advancedMode?: boolean + triggerMode?: boolean + data?: { + parentId?: string + extent?: 'parent' + width?: number + height?: number + collection?: unknown + count?: number + loopType?: 'for' | 'forEach' | 'while' | 'doWhile' + whileCondition?: string + doWhileCondition?: string + parallelType?: 'collection' | 'count' + batchSize?: number + type?: string + canonicalModes?: Record + } + locked?: boolean + } + > + edges: Array<{ + id: string + source: string + target: string + sourceHandle: unknown + targetHandle: unknown + type?: string + animated?: boolean + style?: Record + data?: Record + label?: string + labelStyle?: Record + labelShowBg?: boolean + labelBgStyle?: Record + labelBgPadding?: unknown[] + labelBgBorderRadius?: number + markerStart?: string + markerEnd?: string + }> + loops?: Record< + string, + { + id: string + nodes: Array + iterations: number + loopType: 'for' | 'forEach' | 'while' | 'doWhile' + forEachItems?: Array | Record | string + whileCondition?: string + doWhileCondition?: string + enabled?: boolean + locked?: boolean + } + > + parallels?: Record< + string, + { + id: string + nodes: Array + distribution?: Array | Record | string + count?: number + parallelType?: 'count' | 'collection' + batchSize?: number + enabled?: boolean + locked?: boolean + } + > + variables?: Record< + string, + { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown + } + > + metadata?: { + name?: string + description?: string + sortOrder?: number + exportedAt?: string + } + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows/find` */ +export type FindTableRowsParams = { + tableId: string +} + +export type FindTableRowsBody = { + workspaceId: string + q: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> +} + +export type FindTableRowsResponse = { + data: { + matches: Array<{ + ordinal: number + rowId: string + column: string + }> + truncated: boolean + } +} + +/** `GET /api/v2/audit-logs/[id]` */ +export type GetAuditLogParams = { + id: string +} + +export type GetAuditLogResponse = { + data: { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + } +} + +/** `GET /api/v2/credentials/[id]` */ +export type GetCredentialParams = { + id: string +} + +export type GetCredentialQuery = { + workspaceId: string +} + +export type GetCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/custom-tools/[id]` */ +export type GetCustomToolParams = { + id: string +} + +export type GetCustomToolQuery = { + workspaceId: string +} + +export type GetCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/logs/executions/[executionId]` */ +export type GetExecutionParams = { + executionId: string +} + +export type GetExecutionResponse = { + data: { + executionId: string + workflowId: string | null + workflowState: unknown + executionMetadata: { + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + } + } +} + +/** `GET /api/v2/files/[fileId]/share` */ +export type GetFileShareParams = { + fileId: string +} + +export type GetFileShareQuery = { + workspaceId: string +} + +export type GetFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } | null + } +} + +/** `GET /api/v2/folders/[id]` */ +export type GetFolderParams = { + id: string +} + +export type GetFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type GetFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + +/** `GET /api/v2/knowledge/[id]` */ +export type GetKnowledgeBaseParams = { + id: string +} + +export type GetKnowledgeBaseQuery = { + workspaceId: string +} + +export type GetKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ +export type GetKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type GetKnowledgeDocumentQuery = { + workspaceId: string +} + +export type GetKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + processingError: string | null + processingStartedAt: string | null + processingCompletedAt: string | null + connectorId: string | null + connectorType: string | null + sourceUrl: string | null + } + } +} + +/** `GET /api/v2/logs/[id]` */ +export type GetLogParams = { + id: string +} + +export type GetLogResponse = { + data: { + id: string + workflowId: string | null + executionId: string + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + files: Array | null + workflow: { + id: string | null + name: string + description: string | null + folderId: string | null + userId: string | null + workspaceId: string | null + createdAt: string | null + updatedAt: string | null + deleted: boolean + } + executionData: unknown + cost: { + total: number + } | null + createdAt: string + } +} + +/** `GET /api/v2/mcp-servers/[id]` */ +export type GetMcpServerParams = { + id: string +} + +export type GetMcpServerQuery = { + workspaceId: string +} + +export type GetMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `GET /api/v2/skills/[id]` */ +export type GetSkillParams = { + id: string +} + +export type GetSkillQuery = { + workspaceId: string +} + +export type GetSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + +/** `GET /api/v2/tables/[tableId]` */ +export type GetTableParams = { + tableId: string +} + +export type GetTableQuery = { + workspaceId: string +} + +export type GetTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/tables/exports/[exportId]` */ +export type GetTableExportParams = { + exportId: string +} + +export type GetTableExportQuery = { + workspaceId: string +} + +export type GetTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `GET /api/v2/tables/imports/[importId]` */ +export type GetTableImportParams = { + importId: string +} + +export type GetTableImportQuery = { + workspaceId: string +} + +export type GetTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ +export type GetTableRowParams = { + tableId: string + rowId: string +} + +export type GetTableRowQuery = { + workspaceId: string +} + +export type GetTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/tables/[tableId]/views/[viewId]` */ +export type GetTableViewParams = { + tableId: string + viewId: string +} + +export type GetTableViewQuery = { + workspaceId: string +} + +type GetTableViewResponseRef0 = + | { + all: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type GetTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: GetTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/billing/usage` */ +export type GetUsageSummaryQuery = { + workspaceId?: string +} + +export type GetUsageSummaryResponse = { + data: { + period: { + start: string + end: string + } + totalCredits: number + bySourceCredits: Record + limitCredits: number + plan: string + } +} + +/** `GET /api/v2/workflows/[id]` */ +export type GetWorkflowParams = { + id: string +} + +export type GetWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + variables: Record + inputs: Array<{ + name: string + type: string + description?: string + }> + } +} + +/** `GET /api/v2/workflows/[id]/executions/[executionId]` */ +export type GetWorkflowExecutionParams = { + id: string + executionId: string +} + +export type GetWorkflowExecutionQuery = { + includeOutput?: 'true' | 'false' + selectedOutputs?: string +} + +export type GetWorkflowExecutionResponse = { + data: { + executionId: string + workflowId: string + status: 'queued' | 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: { + pausedAt: string + resumeAt: string | null + pauseKind: 'time' | 'human' | null + blockedOnBlockId: string | null + automaticResumeWaitingReason: string | null + pausedExecutionId: string + pausePointCount: number + resumedCount: number + } | null + cost: { + total: number + } | null + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + output: unknown | null + blockOutputs: Record | null + } +} + +/** `GET /api/v2/workflows/[id]/versions/[version]` */ +export type GetWorkflowVersionParams = { + id: string + version: number +} + +export type GetWorkflowVersionResponse = { + data: { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + state: unknown + } +} + +/** `POST /api/v2/workflows/import` */ +export type ImportWorkflowBody = { + workspaceId: string + folderId?: string + name?: string + description?: string + workflow: string | Record +} + +export type ImportWorkflowResponse = { + data: { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + createdAt: string + updatedAt: string + } +} + +/** `GET /api/v2/audit-logs` */ +export type ListAuditLogsQuery = { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorId?: string + startDate?: string + endDate?: string + includeDeparted?: 'true' | 'false' + limit?: number + cursor?: string +} + +export type ListAuditLogsResponse = { + data: Array<{ + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/credentials` */ +export type ListCredentialsQuery = { + workspaceId: string + type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + providerId?: string + search?: string + sortBy?: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListCredentialsResponse = { + data: Array<{ + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/custom-tools` */ +export type ListCustomToolsQuery = { + workspaceId: string + search?: string + sortBy?: 'title' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListCustomToolsResponse = { + data: Array<{ + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/files` */ +export type ListFilesQuery = { + workspaceId: string + scope?: 'active' | 'archived' + folderId?: string + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +export type ListFilesResponse = { + data: Array<{ + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/folders` */ +export type ListFoldersQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + scope?: 'active' | 'archived' + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListFoldersResponse = { + data: Array<{ + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +export type ListKnowledgeBasesQuery = { + workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListKnowledgeBasesResponse = { + data: Array<{ + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/documents` */ +export type ListKnowledgeDocumentsParams = { + id: string +} + +export type ListKnowledgeDocumentsQuery = { + workspaceId: string + limit?: number + search?: string + enabledFilter?: 'all' | 'enabled' | 'disabled' + sortBy?: + | 'filename' + | 'fileSize' + | 'tokenCount' + | 'chunkCount' + | 'uploadedAt' + | 'processingStatus' + | 'enabled' + sortOrder?: 'asc' | 'desc' + cursor?: string +} + +export type ListKnowledgeDocumentsResponse = { + data: Array<{ + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/logs` */ +export type ListLogsQuery = { + workspaceId: string + workflowIds?: string + folderIds?: string + triggers?: string + level?: 'info' | 'error' + startDate?: string + endDate?: string + executionId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + details?: 'basic' | 'full' + includeTraceSpans?: boolean + includeFinalOutput?: boolean + limit?: number + cursor?: string + order?: 'desc' | 'asc' +} + +export type ListLogsResponse = { + data: Array<{ + id: string + workflowId: string | null + executionId: string + deploymentVersionId: string | null + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + files: Array | null + workflow?: { + id: string | null + name: string + description: string | null + deleted: boolean + } + finalOutput?: unknown + traceSpans?: unknown + }> + nextCursor: string | null +} + +/** `GET /api/v2/mcp-servers` */ +export type ListMcpServersQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListMcpServersResponse = { + data: Array<{ + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + }> + nextCursor: string | null +} + +/** `GET /api/v2/skills` */ +export type ListSkillsQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListSkillsResponse = { + data: Array<{ + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/rows` */ +export type ListTableRowsParams = { + tableId: string +} + +export type ListTableRowsQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +export type ListTableRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables` */ +export type ListTablesQuery = { + workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +export type ListTablesResponse = { + data: Array<{ + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/views` */ +export type ListTableViewsParams = { + tableId: string +} + +export type ListTableViewsQuery = { + workspaceId: string +} + +type ListTableViewsResponseRef0 = + | { + all: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type ListTableViewsResponse = { + data: Array<{ + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: ListTableViewsResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/billing/usage/logs` */ +export type ListUsageLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId?: string + period?: '1d' | '7d' | '30d' | 'all' | 'custom' + startDate?: string + endDate?: string + limit?: number + cursor?: string +} + +export type ListUsageLogsResponse = { + data: Array<{ + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workflowName: string | null + creditCost: number + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/groups` */ +export type ListWorkflowGroupsParams = { + tableId: string +} + +export type ListWorkflowGroupsQuery = { + workspaceId: string +} + +export type ListWorkflowGroupsResponse = { + data: Array<{ + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + }> + nextCursor: string | null +} + +/** `GET /api/v2/workflows` */ +export type ListWorkflowsQuery = { + workspaceId: string + folderId?: string + deployedOnly?: boolean + limit?: number + cursor?: string + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' + sortOrder?: 'asc' | 'desc' +} + +export type ListWorkflowsResponse = { + data: Array<{ + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/workflows/[id]/versions` */ +export type ListWorkflowVersionsParams = { + id: string +} + +export type ListWorkflowVersionsQuery = { + limit?: number + cursor?: string +} + +export type ListWorkflowVersionsResponse = { + data: Array<{ + id: string + version: number + name?: string | null + description?: string | null + isActive: boolean + createdAt: string + deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null + }> + nextCursor: string | null +} + +/** `POST /api/v2/files/move` */ +export type MoveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array + targetFolderId?: string | null +} + +export type MoveFileItemsResponse = { + data: { + movedItems: { + files: number + folders: number + } + } +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsBody = { + workspaceId: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> + limit?: number + cursor?: string +} + +export type QueryRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileBody = { + workspaceId: string + name: string +} + +export type RenameFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + +/** `POST /api/v2/files/[fileId]/restore` */ +export type RestoreFileParams = { + fileId: string +} + +export type RestoreFileBody = { + workspaceId: string +} + +export type RestoreFileResponse = { + data: { + id: string + restored: true + } +} + +/** `POST /api/v2/tables/[tableId]/restore` */ +export type RestoreTableParams = { + tableId: string +} + +export type RestoreTableBody = { + workspaceId: string +} + +export type RestoreTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/workflows/[id]/rollback` */ +export type RollbackWorkflowParams = { + id: string +} + +export type RollbackWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version: number + } +} + +/** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type RunRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type RunRowEnrichmentBody = { + workspaceId: string +} + +export type RunRowEnrichmentResponse = { + data: { + dispatchId: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/columns/run` */ +export type RunTableColumnParams = { + tableId: string +} + +export type RunTableColumnBody = { + workspaceId: string + groupIds: Array + runMode?: 'all' | 'incomplete' + rowIds?: Array + filter?: unknown + excludeRowIds?: Array + limit?: { + type: 'rows' + max: number + } +} + +export type RunTableColumnResponse = { + data: { + dispatchId: string | null + } +} + +/** `POST /api/v2/knowledge/search` */ +export type SearchKnowledgeBody = { + workspaceId: string + knowledgeBaseIds: string | Array + query?: string + topK?: number + tagFilters?: Array<{ + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator?: string + value: string | number | boolean + valueTo?: string | number + }> + searchMode?: 'vector' | 'hybrid' | null +} + +export type SearchKnowledgeResponse = { + data: { + results: Array<{ + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record + similarity: number + }> + query: string + knowledgeBaseIds: Array + topK: number + totalResults: number + } +} + +/** `GET /api/v2/tables/exports/[exportId]/download` */ +export type TableExportDownloadParams = { + exportId: string +} + +export type TableExportDownloadQuery = { + workspaceId: string +} + +export type TableExportDownloadResponse = { + data: { + url: string + fileName: string + expiresAt: string + } +} + +/** `DELETE /api/v2/workflows/[id]/deploy` */ +export type UndeployWorkflowParams = { + id: string +} + +export type UndeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + } +} + +/** `PATCH /api/v2/credentials/[id]` */ +export type UpdateCredentialParams = { + id: string +} + +export type UpdateCredentialBody = { + workspaceId: string + displayName?: string + description?: string | null + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type UpdateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/custom-tools/[id]` */ +export type UpdateCustomToolParams = { + id: string +} + +export type UpdateCustomToolBody = { + workspaceId: string + title?: string + schema?: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code?: string +} + +export type UpdateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `PUT /api/v2/files/[fileId]/content` */ +export type UpdateFileContentParams = { + fileId: string +} + +export type UpdateFileContentBody = { + workspaceId: string + content: string + encoding?: 'utf-8' | 'base64' +} + +export type UpdateFileContentResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + +/** `PATCH /api/v2/folders/[id]` */ +export type UpdateFolderParams = { + id: string +} + +export type UpdateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name?: string + locked?: boolean + parentId?: string | null + sortOrder?: number +} + +export type UpdateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + +/** `PUT /api/v2/knowledge/[id]` */ +export type UpdateKnowledgeBaseParams = { + id: string +} + +export type UpdateKnowledgeBaseBody = { + workspaceId: string + name?: string + description?: string + chunkingConfig?: { + maxSize: number + minSize: number + overlap: number + } +} + +export type UpdateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/mcp-servers/[id]` */ +export type UpdateMcpServerParams = { + id: string +} + +export type UpdateMcpServerBody = { + workspaceId: string + name?: string + description?: string + transport?: 'streamable-http' + url?: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type UpdateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `PUT /api/v2/tables/[tableId]/rows` */ +export type UpdateRowsByFilterParams = { + tableId: string +} + +export type UpdateRowsByFilterBody = { + workspaceId: string + filter: unknown + data: unknown + limit?: number +} + +export type UpdateRowsByFilterResponse = { + data: { + updatedCount: number + updatedRowIds: Array + } +} + +/** `PATCH /api/v2/skills/[id]` */ +export type UpdateSkillParams = { + id: string +} + +export type UpdateSkillBody = { + workspaceId: string + name?: string + description?: string + content?: string +} + +export type UpdateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + +/** `PATCH /api/v2/tables/[tableId]` */ +export type UpdateTableParams = { + tableId: string +} + +export type UpdateTableBody = { + workspaceId: string + name?: string + folderId?: string | null +} + +export type UpdateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/tables/[tableId]/columns` */ +export type UpdateTableColumnParams = { + tableId: string +} + +export type UpdateTableColumnBody = { + workspaceId: string + columnName: string + updates: { + name?: string + type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + } +} + +export type UpdateTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ +export type UpdateTableRowParams = { + tableId: string + rowId: string +} + +export type UpdateTableRowBody = { + workspaceId: string + data: unknown +} + +export type UpdateTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/tables/[tableId]/views/[viewId]` */ +export type UpdateTableViewParams = { + tableId: string + viewId: string +} + +export type UpdateTableViewBody = { + workspaceId: string + name?: string + config?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + configPatch?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault?: boolean +} + +type UpdateTableViewResponseRef0 = + | { + all: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type UpdateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/workflows/[id]` */ +export type UpdateWorkflowParams = { + id: string +} + +export type UpdateWorkflowBody = { + name?: string + description?: string | null + folderId?: string | null +} + +export type UpdateWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + } +} + +/** `PATCH /api/v2/tables/[tableId]/groups` */ +export type UpdateWorkflowGroupParams = { + tableId: string +} + +export type UpdateWorkflowGroupBody = { + workspaceId: string + groupId: string + workflowId?: string + name?: string + dependencies?: { + columns?: Array + } + outputs?: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + newOutputColumns?: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + mappingUpdates?: Array<{ + columnName: string + blockId: string + path: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + type?: 'manual' | 'enrichment' + autoRun?: boolean +} + +export type UpdateWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + +/** `POST /api/v2/knowledge/[id]/documents` */ +export type UploadKnowledgeDocumentParams = { + id: string +} + +export type UploadKnowledgeDocumentQuery = { + workspaceId: string +} + +export type UploadKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } + } +} + +/** `PUT /api/v2/files/[fileId]/share` */ +export type UpsertFileShareParams = { + fileId: string +} + +export type UpsertFileShareBody = { + workspaceId: string + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +export type UpsertFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows/upsert` */ +export type UpsertTableRowParams = { + tableId: string +} + +export type UpsertTableRowBody = { + workspaceId: string + data: unknown + conflictTarget?: string +} + +export type UpsertTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + operation: 'insert' | 'update' + } +} + +/** + * Every v2 operation, keyed by name. + * + * `query` and `body` describe each field well enough for the CLI to build a + * flag for it and coerce the string argv gives back: its kind, whether it is + * required, its enum values, and its server-side default. A slot the contract + * does not declare — or one whose shape is a union with no flat field list — + * is absent, and the runtime falls back to taking it as JSON. + * + * `summary` is the operation's one-line description, lifted from the OpenAPI + * specs so `--help` reuses prose that is already written and already checked. + */ +export const V2_OPERATIONS = { + abortFileUpload: { + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Abort File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + addTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Add Column', + body: { + workspaceId: { kind: 'string', required: true }, + column: { kind: 'object', required: true }, + }, + }, + addWorkflowGroup: { + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Add Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + group: { kind: 'object', required: true }, + outputColumns: { kind: 'array', required: true }, + autoRun: { kind: 'boolean', default: false }, + }, + }, + bulkArchiveFileItems: { + method: 'POST', + path: '/api/v2/files/bulk-archive', + pathParams: [] as const, + responseMode: 'json', + summary: 'Archive Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + }, + }, + cancelTableExport: { + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Cancel Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableImport: { + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Cancel Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableRuns: { + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Cancel Column Runs', + body: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['all', 'row'] as const }, + rowId: { kind: 'string' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, + }, + }, + cancelWorkflowExecution: { + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + summary: 'Cancel an execution', + }, + completeFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Complete File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, + completeTableImport: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Complete Table Import Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, + createCredential: { + method: 'POST', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Credential', + body: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + required: true, + values: ['env_workspace', 'env_personal', 'service_account'] as const, + }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + providerId: { kind: 'string' }, + envKey: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + createCustomTool: { + method: 'POST', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string', required: true }, + schema: { kind: 'object', required: true }, + code: { kind: 'string', required: true }, + }, + }, + createFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + folderId: { kind: 'string' }, + }, + }, + createFileUploadPartUrls: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Create File Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, + createFolder: { + method: 'POST', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string', required: true }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, + createKnowledgeBase: { + method: 'POST', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + }, + }, + createMcpServer: { + method: 'POST', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + createSkill: { + method: 'POST', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + }, + }, + createTable: { + method: 'POST', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Table', + body: { + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + schema: { kind: 'object', required: true }, + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + }, + }, + createTableExport: { + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create Table Export', + body: { + workspaceId: { kind: 'string', required: true }, + format: { kind: 'enum', values: ['csv', 'json'] as const, default: 'csv' }, + }, + }, + createTableImport: { + method: 'POST', + path: '/api/v2/tables/imports', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Table Import', + body: { + workspaceId: { kind: 'string', required: true }, + source: { kind: 'unknown', required: true }, + target: { kind: 'unknown', required: true }, + mapping: { kind: 'unknown' }, + createColumns: { kind: 'unknown' }, + timezone: { kind: 'string' }, + }, + }, + createTableImportPartUrls: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Create Table Import Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, + createTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create Rows', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, + }, + createTableView: { + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create View', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + config: { kind: 'object', required: true }, + }, + }, + createWorkflow: { + method: 'POST', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Workflow', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, + deleteCredential: { + method: 'DELETE', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteCustomTool: { + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteFile: { + method: 'DELETE', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Delete File', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteFolder: { + method: 'DELETE', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, + deleteKnowledgeBase: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteKnowledgeDocument: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + summary: 'Delete Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteMcpServer: { + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteSkill: { + method: 'DELETE', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteTable: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteTableColumn: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + }, + }, + deleteTableRow: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + summary: 'Delete Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteTableRows: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Rows', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown' }, + limit: { kind: 'integer' }, + rowIds: { kind: 'array' }, + }, + }, + deleteTableView: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Delete View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Workflow', + }, + deleteWorkflowGroup: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + }, + }, + deployWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Deploy Workflow', + }, + downloadFile: { + method: 'GET', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'binary', + summary: 'Download File', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + executeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Execute a workflow', + body: { + input: { kind: 'object' }, + async: { kind: 'boolean', default: false }, + stream: { kind: 'boolean', default: false }, + selectedOutputs: { kind: 'array' }, + includeThinking: { kind: 'boolean', default: false }, + includeToolCalls: { kind: 'boolean', default: false }, + includeFileBase64: { kind: 'boolean' }, + base64MaxBytes: { kind: 'integer' }, + }, + }, + exportWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]/export', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Export a workflow', + }, + findTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Find Rows', + body: { + workspaceId: { kind: 'string', required: true }, + q: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + }, + }, + getAuditLog: { + method: 'GET', + path: '/api/v2/audit-logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Audit Log', + }, + getCredential: { + method: 'GET', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getCustomTool: { + method: 'GET', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getExecution: { + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + pathParams: ['executionId'] as const, + responseMode: 'json', + summary: 'Get Execution', + }, + getFileShare: { + method: 'GET', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Get File Share', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getFolder: { + method: 'GET', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, + getKnowledgeBase: { + method: 'GET', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getKnowledgeDocument: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + summary: 'Get Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getLog: { + method: 'GET', + path: '/api/v2/logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Log', + }, + getMcpServer: { + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getSkill: { + method: 'GET', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTable: { + method: 'GET', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Get Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableExport: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Get Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableImport: { + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Get Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableRow: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + summary: 'Get Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableView: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Get View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getUsageSummary: { + method: 'GET', + path: '/api/v2/billing/usage', + pathParams: [] as const, + responseMode: 'json', + summary: 'Get Usage Summary', + query: { + workspaceId: { kind: 'string' }, + }, + }, + getWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Workflow', + }, + getWorkflowExecution: { + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + summary: 'Get execution status', + query: { + includeOutput: { kind: 'enum', values: ['true', 'false'] as const }, + selectedOutputs: { kind: 'string' }, + }, + }, + getWorkflowVersion: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + pathParams: ['id', 'version'] as const, + responseMode: 'json', + summary: 'Get Workflow Version', + }, + importWorkflow: { + method: 'POST', + path: '/api/v2/workflows/import', + pathParams: [] as const, + responseMode: 'json', + summary: 'Import a workflow', + body: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + name: { kind: 'string' }, + description: { kind: 'string' }, + workflow: { kind: 'unknown', required: true }, + }, + }, + listAuditLogs: { + method: 'GET', + path: '/api/v2/audit-logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Audit Logs', + query: { + action: { kind: 'string' }, + resourceType: { kind: 'string' }, + resourceId: { kind: 'string' }, + workspaceId: { kind: 'string' }, + actorId: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listCredentials: { + method: 'GET', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Credentials', + query: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, + }, + providerId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['displayName', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, + listCustomTools: { + method: 'GET', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Custom Tools', + query: { + workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['title', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, + listFiles: { + method: 'GET', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Files', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listFolders: { + method: 'GET', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listKnowledgeBases: { + method: 'GET', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Knowledge Bases', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listKnowledgeDocuments: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Documents', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer', default: 50 }, + search: { kind: 'string' }, + enabledFilter: { + kind: 'enum', + values: ['all', 'enabled', 'disabled'] as const, + default: 'all', + }, + sortBy: { + kind: 'enum', + values: [ + 'filename', + 'fileSize', + 'tokenCount', + 'chunkCount', + 'uploadedAt', + 'processingStatus', + 'enabled', + ] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + cursor: { kind: 'string' }, + }, + }, + listLogs: { + method: 'GET', + path: '/api/v2/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Logs', + query: { + workspaceId: { kind: 'string', required: true }, + workflowIds: { kind: 'string' }, + folderIds: { kind: 'string' }, + triggers: { kind: 'string' }, + level: { kind: 'enum', values: ['info', 'error'] as const }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + executionId: { kind: 'string' }, + minDurationMs: { kind: 'number' }, + maxDurationMs: { kind: 'number' }, + minCost: { kind: 'number' }, + maxCost: { kind: 'number' }, + model: { kind: 'string' }, + details: { kind: 'enum', values: ['basic', 'full'] as const, default: 'basic' }, + includeTraceSpans: { kind: 'boolean' }, + includeFinalOutput: { kind: 'boolean' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + }, + }, + listMcpServers: { + method: 'GET', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'List MCP Servers', + query: { + workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, + listSkills: { + method: 'GET', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Skills', + query: { + workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, + listTableRows: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List rows', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, + }, + listTables: { + method: 'GET', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listTableViews: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Views', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listUsageLogs: { + method: 'GET', + path: '/api/v2/billing/usage/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Usage Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', + ] as const, + }, + workspaceId: { kind: 'string' }, + period: { + kind: 'enum', + values: ['1d', '7d', '30d', 'all', 'custom'] as const, + default: '30d', + }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listWorkflowGroups: { + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Workflow Groups', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listWorkflows: { + method: 'GET', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workflows', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + deployedOnly: { kind: 'boolean' }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt', 'runCount'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listWorkflowVersions: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Workflow Versions', + query: { + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + moveFileItems: { + method: 'POST', + path: '/api/v2/files/move', + pathParams: [] as const, + responseMode: 'json', + summary: 'Move Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + targetFolderId: { kind: 'string' }, + }, + }, + queryRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/query', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Query Rows', + body: { + workspaceId: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, + }, + renameFile: { + method: 'PATCH', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Rename File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + }, + }, + restoreFile: { + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Restore File', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, + restoreTable: { + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Restore Table', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, + rollbackWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Rollback Workflow', + }, + runRowEnrichment: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + pathParams: ['tableId', 'rowId', 'groupId'] as const, + responseMode: 'json', + summary: 'Run Enrichment For One Row', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, + runTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Run Column Groups', + body: { + workspaceId: { kind: 'string', required: true }, + groupIds: { kind: 'array', required: true }, + runMode: { kind: 'enum', values: ['all', 'incomplete'] as const, default: 'all' }, + rowIds: { kind: 'array' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, + limit: { kind: 'object' }, + }, + }, + searchKnowledge: { + method: 'POST', + path: '/api/v2/knowledge/search', + pathParams: [] as const, + responseMode: 'json', + summary: 'Search Knowledge', + body: { + workspaceId: { kind: 'string', required: true }, + knowledgeBaseIds: { kind: 'unknown', required: true }, + query: { kind: 'string' }, + topK: { kind: 'number', default: 10 }, + tagFilters: { kind: 'array' }, + searchMode: { kind: 'enum', default: 'vector' }, + }, + }, + tableExportDownload: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Download Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + undeployWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Undeploy Workflow', + }, + updateCredential: { + method: 'PATCH', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Credential', + body: { + workspaceId: { kind: 'string', required: true }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + updateCustomTool: { + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string' }, + schema: { kind: 'object' }, + code: { kind: 'string' }, + }, + }, + updateFileContent: { + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Replace File Content', + body: { + workspaceId: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, + updateFolder: { + method: 'PATCH', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string' }, + locked: { kind: 'boolean' }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, + updateKnowledgeBase: { + method: 'PUT', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object' }, + }, + }, + updateMcpServer: { + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string' }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + updateRowsByFilter: { + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Rows by Filter', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown', required: true }, + data: { kind: 'unknown', required: true }, + limit: { kind: 'integer' }, + }, + }, + updateSkill: { + method: 'PATCH', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + content: { kind: 'string' }, + }, + }, + updateTable: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Table', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, + updateTableColumn: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + updates: { kind: 'object', required: true }, + }, + }, + updateTableRow: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + summary: 'Update Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + }, + }, + updateTableView: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Update View', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + config: { kind: 'object' }, + configPatch: { kind: 'object' }, + isDefault: { kind: 'boolean' }, + }, + }, + updateWorkflow: { + method: 'PATCH', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, + updateWorkflowGroup: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + workflowId: { kind: 'string' }, + name: { kind: 'string' }, + dependencies: { kind: 'object' }, + outputs: { kind: 'array' }, + newOutputColumns: { kind: 'array' }, + mappingUpdates: { kind: 'array' }, + inputMappings: { kind: 'array' }, + deploymentMode: { kind: 'enum', values: ['live', 'deployed'] as const }, + type: { kind: 'enum', values: ['manual', 'enrichment'] as const }, + autoRun: { kind: 'boolean' }, + }, + }, + uploadKnowledgeDocument: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Upload Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + upsertFileShare: { + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Enable or Disable File Share', + body: { + workspaceId: { kind: 'string', required: true }, + isActive: { kind: 'boolean', required: true }, + authType: { kind: 'enum', values: ['public', 'password', 'email', 'sso'] as const }, + password: { kind: 'string' }, + allowedEmails: { kind: 'array' }, + }, + }, + upsertTableRow: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Upsert Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + conflictTarget: { kind: 'string' }, + }, + }, +} as const + +export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts new file mode 100644 index 00000000000..9e36ea232db --- /dev/null +++ b/packages/sim-cli/src/http/client.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { resolvePath, SimApiError } from './client.js' + +describe('resolvePath', () => { + it('substitutes a path parameter', () => { + expect(resolvePath('/api/v2/tables/[tableId]/rows', { tableId: 'tbl_1' })).toBe( + '/api/v2/tables/tbl_1/rows' + ) + }) + + it('substitutes several parameters', () => { + expect( + resolvePath('/api/v2/knowledge/[id]/documents/[documentId]', { id: 'kb', documentId: 'doc' }) + ).toBe('/api/v2/knowledge/kb/documents/doc') + }) + + it('percent-encodes values so an id cannot retarget the request', () => { + // An unencoded `/` or `?` here would silently address a different endpoint. + expect(resolvePath('/api/v2/tables/[tableId]', { tableId: 'a/b?c=d' })).toBe( + '/api/v2/tables/a%2Fb%3Fc%3Dd' + ) + }) + + it('throws rather than sending a URL with a literal [param] in it', () => { + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow(SimApiError) + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow('tableId') + }) + + it('leaves a parameterless path alone', () => { + expect(resolvePath('/api/v2/tables')).toBe('/api/v2/tables') + }) +}) + +describe('generated operation table', () => { + const names = Object.keys(V2_OPERATIONS) as V2OperationName[] + + it('covers the operations the commands rely on', () => { + // Named explicitly: if a contract is renamed, the generator happily emits + // the new name and only this test catches that a command lost its endpoint. + for (const required of [ + 'listTables', + 'getTable', + 'queryRows', + 'createTableRows', + 'deleteTableRows', + 'listWorkflows', + 'getWorkflow', + 'deployWorkflow', + 'undeployWorkflow', + 'rollbackWorkflow', + 'listLogs', + 'getLog', + 'getExecution', + 'listFiles', + 'deleteFile', + 'listKnowledgeBases', + 'getKnowledgeBase', + 'listKnowledgeDocuments', + 'searchKnowledge', + ] satisfies V2OperationName[]) { + expect(names).toContain(required) + } + }) + + it('declares every path parameter its path contains', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + const inPath = [...spec.path.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) + expect(spec.pathParams, `${name} path params`).toEqual(inPath) + } + }) + + it('only targets the public v2 surface with real HTTP verbs', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + expect(spec.path, name).toMatch(/^\/api\/v2\//) + expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], name).toContain(spec.method) + } + }) + + it('has no two operations sharing a method and path', () => { + const seen = new Map() + for (const name of names) { + const spec = V2_OPERATIONS[name] + const key = `${spec.method} ${spec.path}` + expect(seen.get(key), `${key} claimed by both ${seen.get(key)} and ${name}`).toBeUndefined() + seen.set(key, name) + } + }) +}) + +describe('destructive operations are gated', () => { + /** + * `DELETE /workflows/[id]/deploy` is an undeploy — reversible by redeploying, + * and the contract renames it accordingly. Everything else that deletes is + * gated behind `--yes`. + */ + const NOT_DESTRUCTIVE = new Set([ + 'undeployWorkflow', + // Each of these stops something in flight rather than destroying something + // kept: an upload that has not been completed owns nothing but its own + // parts, and a cancelled import or export can simply be started again. + 'abortFileUpload', + 'cancelTableImport', + 'cancelTableExport', + ]) + + it('every DELETE carries a confirmation message', () => { + // Without this, a new v2 domain arrives through generation with working + // delete commands and no gate — which is exactly what happened when the + // MCP/skills/folders/credentials endpoints landed. + const ungated = (Object.keys(V2_OPERATIONS) as V2OperationName[]).filter( + (name) => + V2_OPERATIONS[name].method === 'DELETE' && + !NOT_DESTRUCTIVE.has(name) && + !CLI_CONTRACT[name]?.confirm + ) + expect(ungated).toEqual([]) + }) + + it('states what is destroyed, not just that something is', () => { + for (const [name, spec] of Object.entries(CLI_CONTRACT)) { + if (!spec?.confirm) continue + expect(spec.confirm, name).toMatch(/^This /) + expect(spec.confirm.length, name).toBeGreaterThan(20) + } + }) +}) diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts new file mode 100644 index 00000000000..96b640a43ac --- /dev/null +++ b/packages/sim-cli/src/http/client.ts @@ -0,0 +1,247 @@ +import type { ResolvedProfile } from '../config/index.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' + +/** + * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed + * as a clean message and a non-zero exit; anything else escapes as a stack + * trace, which is the signal that the CLI itself is broken rather than the + * request. + */ +export class SimApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details?: unknown + ) { + super(message) + this.name = 'SimApiError' + } +} + +/** `{ data }` — a single resource. */ +interface V2DataEnvelope { + data: T +} + +/** `{ data, nextCursor }` — one page of a list. */ +export interface V2Page { + data: T[] + nextCursor: string | null +} + +export type QueryValue = string | number | boolean | null | undefined + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query?: Record + body?: unknown + /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ + headers?: Record +} + +function buildUrl(endpoint: string, path: string, query?: Record): string { + const url = new URL(`${endpoint}${path}`) + for (const [key, value] of Object.entries(query ?? {})) { + if (value === null || value === undefined || value === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/** + * Pulls a human-readable message out of whatever the server returned. + * + * v2 answers with `{ error: { code, message } }`, but a request can also be + * turned away before it reaches a v2 route — by the v1 auth middleware + * (`{ error }`), or by a proxy that returns HTML. Each of those still has to + * produce a sentence rather than `[object Object]`. + */ +function toApiError(status: number, raw: string): SimApiError { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + const text = raw.trim() + return new SimApiError( + text ? truncate(text, 300) : `Request failed with status ${status}`, + status + ) + } + + const body = parsed as { error?: unknown; message?: unknown } + + if (body.error && typeof body.error === 'object') { + const error = body.error as { code?: unknown; message?: unknown; details?: unknown } + return new SimApiError( + typeof error.message === 'string' ? error.message : `Request failed with status ${status}`, + status, + typeof error.code === 'string' ? error.code : null, + error.details + ) + } + + if (typeof body.error === 'string') return new SimApiError(body.error, status) + if (typeof body.message === 'string') return new SimApiError(body.message, status) + + return new SimApiError(`Request failed with status ${status}`, status) +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +export class SimClient { + constructor(private readonly profile: ResolvedProfile) {} + + private requireAuth(): string { + if (!this.profile.apiKey) { + throw new SimApiError( + `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, + 0 + ) + } + return this.profile.apiKey + } + + /** + * The workspace every workspace-scoped command defaults to. + * + * Checks the key first even though it does not need one: commands resolve the + * workspace while building their query, so without this a brand-new install + * is told to set a workspace when the actual first step is logging in. + */ + requireWorkspace(explicit?: string): string { + this.requireAuth() + const workspaceId = explicit ?? this.profile.workspaceId + if (!workspaceId) { + throw new SimApiError( + `No workspace set for profile "${this.profile.name}". Pass --workspace, or run: sim configure --profile ${this.profile.name} --set-workspace `, + 0 + ) + } + return workspaceId + } + + async request(path: string, options: RequestOptions = {}): Promise { + const apiKey = this.requireAuth() + + const url = buildUrl(this.profile.endpoint, path, options.query) + const hasBody = options.body !== undefined + + let response: Response + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers: { + 'x-api-key': apiKey, + accept: 'application/json', + ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...options.headers, + }, + body: hasBody ? JSON.stringify(options.body) : undefined, + }) + } catch (cause) { + throw new SimApiError( + `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, + 0 + ) + } + + const raw = await response.text() + + if (!response.ok) { + const error = toApiError(response.status, raw) + if (response.status === 401) { + error.message = `${error.message} — run: sim login --profile ${this.profile.name}` + } + if (response.status === 404) { + // The v2 surface is behind a rollout flag that answers 404 when the + // caller is not in the cohort — deliberately indistinguishable from a + // missing resource, so the CLI cannot tell which happened. Offered as a + // possibility rather than a diagnosis; a plain bad id 404s identically. + error.message = `${error.message}\n If every command returns this, the v2 API may not be enabled for your account yet.` + } + throw error + } + + if (!raw) return undefined as T + return JSON.parse(raw) as T + } + + /** Unwraps `{ data }`. */ + async getData(path: string, options: RequestOptions = {}): Promise { + const body = await this.request>(path, options) + return body.data + } + + /** One page of `{ data, nextCursor }`. */ + async getPage(path: string, options: RequestOptions = {}): Promise> { + return this.request>(path, options) + } + + /** + * Walks a cursor list until it is exhausted or `max` items are collected. + * + * `max` is required rather than optional: an unbounded auto-pager against a + * workspace with a million logs will happily fill memory and hammer the rate + * limiter, so the caller always states a ceiling. + */ + async collect(path: string, options: RequestOptions, max: number): Promise { + const items: T[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await this.getPage(path, { + ...options, + query: { ...options.query, cursor }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < max) + + return items.slice(0, max) + } + + /** + * Calls a generated operation by name. + * + * Method and path come from `V2_OPERATIONS`, so a route that moves or changes + * verb in a contract moves here on the next `generate:cli-api` rather than + * failing at runtime against a URL the CLI still remembers. + */ + async call( + operation: K, + options: OperationOptions = {} + ): Promise { + const spec = V2_OPERATIONS[operation] + return this.request(resolvePath(spec.path, options.pathParams), { + method: spec.method as RequestOptions['method'], + query: options.query, + body: options.body, + }) + } +} + +export interface OperationOptions { + pathParams?: Record + query?: Record + body?: unknown +} + +/** + * Substitutes `[id]`-style path segments. + * + * Values are percent-encoded: table and workspace ids are opaque, and a `/` or + * `?` inside one would otherwise silently retarget the request at a different + * endpoint. + */ +export function resolvePath(template: string, params: Record = {}): string { + return template.replace(/\[([^\]]+)\]/g, (_match, key: string) => { + const value = params[key] + if (value === undefined) { + throw new SimApiError(`Missing path parameter "${key}" for ${template}`, 0) + } + return encodeURIComponent(value) + }) +} diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts new file mode 100644 index 00000000000..6d2185e70d8 --- /dev/null +++ b/packages/sim-cli/src/index.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import chalk from 'chalk' +import { Command } from 'commander' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' +import { configureCommand } from './commands/configure.js' +import { attachHandWritten } from './commands/hand-written.js' +import { SimApiError } from './http/client.js' +import { buildGeneratedCommands } from './runtime/build.js' + +const program = new Command() + +program + .name('sim') + .description('Talk to the Sim API from your terminal') + .version('0.1.0') + .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') + .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') + .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') + +program.addCommand(loginCommand()) +program.addCommand(logoutCommand()) +program.addCommand(whoamiCommand()) +program.addCommand(profilesCommand()) +program.addCommand(configureCommand()) + +/** + * Leaves owned by hand-written commands, which the generated runtime skips. + * + * Each is here because generation genuinely cannot produce it, not because it + * has not been migrated: `files download` streams binary rather than JSON, and + * `tables rows list` discovers its columns from user-defined row data at + * runtime with a nested `data` object the generic renderer would flatten badly. + */ +const HAND_WRITTEN = new Set(['files download', 'tables rows list']) + +for (const command of buildGeneratedCommands(HAND_WRITTEN)) { + program.addCommand(command) +} + +// Added after the generated groups so their leaves merge into the same group +// object rather than creating a duplicate top-level command. +attachHandWritten(program) + +program.addHelpText( + 'after', + ` +Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in +~/.sim/credentials (0600). Select one with --profile or SIM_PROFILE. + +Examples: + $ sim login Authorize the default profile + $ sim login --profile dev --endpoint http://localhost:3000 + $ sim workflows list + $ sim logs list --level error --limit 20 + $ sim configure --set-output json Output format is a profile setting + $ sim knowledge search "refund policy" --kb kb_123 + $ sim workflows export wf_123 > wf.json JSON flags read files with @ + $ sim workflows import --workflow @wf.json + $ sim whoami --profile dev +` +) + +/** + * Anything the CLI can explain prints as one line and exits 1. An unexpected + * error keeps its stack trace — that is a bug in the CLI, and hiding it behind a + * friendly message would make it unreportable. + */ +async function main() { + try { + await program.parseAsync(process.argv) + } catch (error) { + if (error instanceof SimApiError) { + console.error(chalk.red(`Error: ${error.message}`)) + if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + process.exit(1) + } + throw error + } +} + +main() diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts new file mode 100644 index 00000000000..57e78bc8395 --- /dev/null +++ b/packages/sim-cli/src/output/render.test.ts @@ -0,0 +1,319 @@ +import chalk, { Chalk } from 'chalk' +import { load } from 'js-yaml' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + bytes, + type Column, + duration, + printList, + printRecord, + sanitize, + text, + timestamp, + visibleWidth, +} from './render.js' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +/** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ +const coloured = new Chalk({ level: 1 }) + +let logged: string[] + +beforeEach(() => { + logged = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface Row { + name: string + status: string +} + +const COLUMNS: Column[] = [ + { header: 'name', value: (row) => row.name }, + { header: 'status', value: (row) => row.status }, +] + +describe('visibleWidth', () => { + it('ignores ANSI colour codes', () => { + expect(visibleWidth(coloured.red('error'))).toBe(5) + expect(visibleWidth(coloured.dim(coloured.green('ok')))).toBe(2) + }) + + it('counts plain text as-is', () => { + expect(visibleWidth('error')).toBe(5) + }) + + it('sees a wrapped string as wider than nothing but no wider than its text', () => { + // The regression this guards: a pattern that misses the ESC byte leaves it + // in the string and inflates the width, drifting every coloured column. + expect(visibleWidth(coloured.red('x'))).toBe(1) + }) +}) + +describe('printList', () => { + it('starts the second column at the same visible offset on every line', () => { + printList( + 'table', + [ + { name: 'alpha', status: coloured.red('error') }, + { name: 'b', status: coloured.green('ok') }, + ], + COLUMNS + ) + + const lines = logged[0].split('\n') + expect(lines).toHaveLength(3) // header + two rows + + // Where the status column begins, measured in visible characters: strip the + // colour, then drop the first word and the padding after it. If padding had + // counted ANSI bytes, the coloured rows would disagree with the header. + const statusOffsets = lines.map((line) => { + const plain = line.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') + return plain.length - plain.replace(/^\S+\s+/, '').length + }) + + expect(statusOffsets).toEqual([7, 7, 7]) // 'alpha' (5) + 2-space separator + }) + + it('says so instead of printing an empty table', () => { + printList('table', [], COLUMNS) + expect(logged[0]).toContain('No results.') + }) + + it('prints the raw rows for json, not the formatted cells', () => { + printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) + + it('prints the raw rows for yaml too', () => { + printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) + + it('keeps machine formats identical in content — only the encoding differs', () => { + const rows = [{ name: 'alpha', status: 'error' }] + printList('json', rows, COLUMNS) + printList('yaml', rows, COLUMNS) + expect(load(logged[1])).toEqual(JSON.parse(logged[0])) + }) + + it('does not fold long yaml values across lines', () => { + // Folding is valid YAML but breaks line-oriented greps and is miserable to read. + const long = 'x'.repeat(300) + printList('yaml', [{ name: long, status: 'ok' }], COLUMNS) + expect(logged[0]).toContain(long) + }) + + it('emits tab-separated cells with no header for text', () => { + printList( + 'text', + [ + { name: 'alpha', status: 'error' }, + { name: 'b', status: 'ok' }, + ], + COLUMNS + ) + expect(logged).toEqual(['alpha\terror', 'b\tok']) + }) + + it('strips colour from text output so cut and awk see plain fields', () => { + printList('text', [{ name: 'alpha', status: coloured.red('error') }], COLUMNS) + expect(logged[0]).toBe('alpha\terror') + }) + + it('renders an absent value as an empty text field, not a dash', () => { + // `cut -f2` returning a literal em-dash would read as a value to every + // downstream emptiness test. + printList('text', [{ name: 'alpha', status: text(null) }], COLUMNS) + expect(logged[0]).toBe('alpha\t') + }) + + it('prints nothing at all for an empty text list', () => { + printList('text', [], COLUMNS) + expect(logged).toEqual([]) + }) +}) + +describe('printRecord', () => { + it('prints the raw object for json, ignoring the field list', () => { + printRecord('json', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints the raw object for yaml, ignoring the field list', () => { + printRecord('yaml', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(load(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints label-tab-value for text', () => { + printRecord('text', [['ID', 'abc']], {}) + expect(logged[0]).toBe('ID\tabc') + }) + + it('prints one aligned line per field for table', () => { + printRecord( + 'table', + [ + ['ID', 'abc'], + ['Name', 'alpha'], + ], + {} + ) + expect(logged).toHaveLength(2) + expect(logged[0]).toContain('abc') + expect(logged[1]).toContain('alpha') + }) +}) + +describe('formatters', () => { + it('renders absent values as a dash rather than "null"', () => { + for (const value of [null, undefined, '']) { + expect(visibleWidth(text(value))).toBe(1) + expect(chalk.reset(text(value))).not.toContain('null') + } + }) + + it('scales bytes to a readable unit', () => { + expect(bytes(512)).toBe('512 B') + expect(bytes(2048)).toBe('2.0 KB') + expect(bytes(0)).toBe('0 B') + }) + + it('scales durations across the ms/s/m boundaries', () => { + expect(duration(999)).toBe('999ms') + expect(duration(1500)).toBe('1.5s') + expect(duration(90_000)).toBe('1m30s') + }) +}) + +describe('sanitize', () => { + // Remote content — knowledge document text, table cell values, workflow names — + // reaches an interactive terminal through the human-readable renderers. + it('removes an OSC window-title sequence', () => { + expect(sanitize(`${ESC}]0;pwned\u0007hello`)).toBe('hello') + }) + + it('removes OSC terminated by ST rather than BEL', () => { + expect(sanitize(`${ESC}]0;pwned${ESC}\\hello`)).toBe('hello') + }) + + it('removes cursor movement that would overwrite what was already printed', () => { + expect(sanitize(`before${ESC}[2A${ESC}[2Kafter`)).toBe('beforeafter') + }) + + it('removes a full terminal reset', () => { + expect(sanitize(`${ESC}creset`)).toBe('reset') + }) + + it('removes non-SGR CSI, which the old SGR-only pattern left executable', () => { + // The reported hole: stripping only `ESC [ … m` passed everything else through. + expect(sanitize(`${ESC}[6n`)).toBe('') + expect(sanitize(`${ESC}[?1049h`)).toBe('') + }) + + it('removes bare C0 and C1 control characters', () => { + expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd') + }) + + it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => { + expect(sanitize('a\u001bdb')).toBe('ab') + }) + + it('keeps tabs and newlines, which are legitimate content', () => { + expect(sanitize('a\tb\nc')).toBe('a\tb\nc') + }) + + it('leaves ordinary text untouched', () => { + expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days') + }) + + it('is applied to values passing through text()', () => { + expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') + }) + + it('is applied to a table header, not only its cells', () => { + // A table's column names are user-defined, so the header is remote content + // too — sanitizing cells alone left the sequences executable one row up. + const hostile = `${ESC}]0;pwned${BEL}email` + printList('table', [{ v: 'a@b.co' }], [{ header: hostile, value: () => 'a@b.co' }]) + expect(logged[0]).not.toContain(ESC) + expect(logged[0]).toContain('EMAIL') + }) + + it('is applied to an unparseable timestamp, which is echoed verbatim', () => { + // The invalid-date branch returns the server's own string, so it was a way + // past every other formatter. + expect(timestamp(`${ESC}]0;pwned\u0007not-a-date`)).toBe('not-a-date') + }) + + it('still formats a valid timestamp normally', () => { + expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22') + }) +}) + +describe('cells stay on their own line', () => { + const rows = [{ note: 'first\nsecond', tabbed: 'a\tb' }] + const columns: Column<(typeof rows)[number]>[] = [ + { header: 'note', value: (row) => row.note }, + { header: 'tabbed', value: (row) => row.tabbed }, + ] + + function captured(format: 'table' | 'text' | 'json'): string[] { + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList(format, rows, columns) + spy.mockRestore() + return lines + } + + it('collapses a newline inside a table cell', () => { + // One newline pushed the rest of the row onto the next line and every + // column after it lost its alignment. + const table = captured('table').join('\n') + expect(table.split('\n')).toHaveLength(2) + expect(table).toContain('first second') + }) + + it('collapses a tab in text mode, so cut -f still sees real fields', () => { + const [line] = captured('text') + expect(line.split('\t')).toHaveLength(2) + expect(line).toBe('first second\ta b') + }) + + it('leaves json untouched', () => { + expect(JSON.parse(captured('json').join('\n'))).toEqual([ + { note: 'first\nsecond', tabbed: 'a\tb' }, + ]) + }) + + it('clamps a very wide cell in table mode only', () => { + const wide = [{ blob: 'x'.repeat(500) }] + const cols: Column<(typeof wide)[number]>[] = [{ header: 'blob', value: (row) => row.blob }] + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList('table', wide, cols) + printList('text', wide, cols) + spy.mockRestore() + + // The table arrives as one string: header line, then the clamped body line. + const [header, body] = lines[0].split('\n') + expect(header.trim()).toBe('BLOB') + expect(body).toMatch(/…$/) + expect(body.length).toBeLessThan(100) + // `text` feeds pipelines; truncating there would corrupt the data. + expect(lines[1]).toHaveLength(500) + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts new file mode 100644 index 00000000000..011c9a8155b --- /dev/null +++ b/packages/sim-cli/src/output/render.ts @@ -0,0 +1,273 @@ +import chalk from 'chalk' +import { dump } from 'js-yaml' +import type { OutputFormat } from '../config/index.js' + +export interface Column { + header: string + value: (row: T) => string +} + +/** The glyph standing in for "no value", before colour is applied. */ +const EMPTY_GLYPH = '—' + +/** Cell text for values that have no useful rendering, kept visually quiet. */ +const EMPTY = chalk.dim(EMPTY_GLYPH) + +/** + * Escape sequences and control characters that must never reach a terminal + * from server-supplied data. + * + * Covers CSI (`ESC [ … final`), OSC (`ESC ] … BEL|ST`), single-character escapes + * such as `ESC c` (full reset), and the bare C0/C1 control range. Anything a + * knowledge document, table cell, or workflow name contains is remote content — + * a document could set the window title, move the cursor to overwrite what was + * already printed, reset the terminal, or on some emulators drive clipboard and + * paste controls. + * + * Matching only SGR (`… m`) was the hole: it stripped colour and left every + * other sequence executable. + */ +const ESC = String.fromCharCode(27) +const CONTROL_PATTERN = new RegExp( + [ + `${ESC}\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\)?`, // OSC … BEL or ST + `${ESC}\\[[0-9;?]*[ -/]*[@-~]`, // CSI … final byte + // Any other ESC + printable: `ESC c` (full reset), `ESC 7`/`ESC 8` (cursor + // save/restore), `ESC (0` (line-drawing charset), and the rest. ESC is never + // legitimate content, so the whole two-byte form goes. OSC and CSI are + // matched above, so they win at the same position. + `${ESC}[ -~]`, + `${ESC}`, // a lone ESC with nothing valid after it + '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1, keeping \t and \n + ].join('|'), + 'g' +) + +/** + * Removes terminal control sequences from a server-supplied string. + * + * Applied where API values become display text, so the colour the CLI adds + * afterwards still works — sanitizing the finished cell would strip our own + * formatting too. + */ +export function sanitize(value: string): string { + return value.replace(CONTROL_PATTERN, '') +} + +export function text(value: unknown): string { + if (value === null || value === undefined || value === '') return EMPTY + return sanitize(String(value)) +} + +/** ISO timestamps are the wire format everywhere; show them without the milliseconds. */ +export function timestamp(value: string | null | undefined): string { + if (!value) return EMPTY + const date = new Date(value) + // Sanitized on the way out: an unparseable value is echoed verbatim, and it is + // still server-supplied, so this branch was a way to smuggle control sequences + // past every other formatter. + if (Number.isNaN(date.getTime())) return sanitize(String(value)) + return date.toISOString().replace('T', ' ').slice(0, 19) +} + +export function bool(value: boolean | null | undefined): string { + if (value === null || value === undefined) return EMPTY + return value ? chalk.green('yes') : chalk.dim('no') +} + +export function bytes(value: number | null | undefined): string { + if (value === null || value === undefined) return EMPTY + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = value + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}` +} + +export function duration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) return EMPTY + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` +} + +/** + * Matches an ANSI SGR sequence (`ESC [ … m`). + * + * Built from a char code rather than written as a literal so the source carries + * no raw ESC byte — an invisible control character inside a regex literal is the + * kind of thing an editor, a formatter, or a patch tool silently eats, and the + * only symptom would be columns drifting by one space per coloured cell. + */ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +/** + * Visible width of a cell, ignoring ANSI colour codes. + * + * Padding on the raw string would count the escape sequences as characters and + * skew every coloured column, so widths are measured on the stripped text while + * the coloured text is what gets printed. + */ +export function visibleWidth(value: string): number { + return value.replace(ANSI_PATTERN, '').length +} + +/** + * Plain text for a rendered cell. + * + * The empty placeholder collapses to an actual empty field: `cut -f3` returning + * a literal `—` for a null would be worse than useless, since every downstream + * emptiness test would read it as a value. + */ +function stripAnsi(value: string): string { + const plain = value.replace(ANSI_PATTERN, '') + return plain === EMPTY_GLYPH ? '' : plain +} + +function pad(value: string, width: number): string { + return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) +} + +/** + * Flattens a cell onto one line. + * + * `sanitize` keeps `\t` and `\n` on purpose — they are legitimate content, and + * json/yaml must round-trip them. Every *display* format is line-oriented + * though: one newline inside a table cell pushes the rest of the row into the + * next line and every column after it loses its alignment, and in `text` mode a + * stray tab invents a field that `cut -f` then reads as real. A table row of a + * workflow's Slack output did exactly this. + * + * Applied to finished cells only, so it cannot reach the machine formats. + */ +function oneLine(value: string): string { + return value.replace(/\s*[\r\n\t]+\s*/g, ' ') +} + +/** + * Widest a single table column may render. + * + * A table row can hold a whole LLM response; at full width one such cell sets + * the column width for every row and pushes everything after it off-screen. + * `text`, `json` and `yaml` are untouched — this is a legibility cap on the + * human view, and the other three formats exist for the whole value. + */ +const MAX_CELL_WIDTH = 60 + +function clampCell(value: string): string { + // ANSI-bearing cells come from the short formatters (`yes`/`no`, the empty + // glyph); slicing one mid-escape would corrupt it, and none are ever wide. + if (visibleWidth(value) <= MAX_CELL_WIDTH || value !== value.replace(ANSI_PATTERN, '')) { + return value + } + return `${value.slice(0, MAX_CELL_WIDTH - 1)}…` +} + +function renderTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) return chalk.dim('No results.') + + // A header can be a user-defined column name (a table's own columns), so it is + // remote content and gets the same treatment as a cell. Doing it here rather + // than only at each call site means a future column source cannot reopen this. + const headers = columns.map((column) => sanitize(column.header)) + const cells = rows.map((row) => columns.map((column) => clampCell(oneLine(column.value(row))))) + const widths = columns.map((_column, index) => + Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) + ) + + const header = headers + .map((label, index) => chalk.dim(pad(label.toUpperCase(), widths[index]))) + .join(' ') + .trimEnd() + + const body = cells.map((line) => + line + .map((cell, index) => pad(cell, widths[index])) + .join(' ') + .trimEnd() + ) + + return [header, ...body].join('\n') +} + +/** + * Renders the machine-readable formats from the RAW value. + * + * Deliberately not the table's formatted cells: `--output json` piped into `jq` + * must yield the API's own field names and types, so a `1500` stays a number + * rather than becoming the `"1.5s"` the table would show. `yaml` follows the + * same rule, so switching format never changes the data. + * + * Returns null when the format wants the human rendering instead. + */ +function renderMachine(format: OutputFormat, raw: unknown): string | null { + if (format === 'json') return JSON.stringify(raw, null, 2) + // `lineWidth: 0` disables YAML's line folding — a wrapped value is technically + // valid but is miserable to eyeball and breaks naive line-oriented greps. + if (format === 'yaml') return dump(raw, { lineWidth: 0, noRefs: true }).trimEnd() + return null +} + +/** + * Prints a list in the profile's output format. + * + * `text` emits the table's cells tab-separated with no header and no colour — + * the shape `cut -f2` and `while read` expect. It uses the formatted cells + * rather than the raw values on purpose: it is a human-ish format for shell + * plumbing, and a raw ISO timestamp or byte count is worse in that context. + */ +export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { + const machine = renderMachine(format, rows) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const row of rows) { + console.log(columns.map((column) => oneLine(stripAnsi(column.value(row)))).join('\t')) + } + return + } + + console.log(renderTable(rows, columns)) +} + +/** + * Prints a payload whose value IS the deliverable — `workflows export`, which + * is meant to be redirected to a file and fed back to `import`. + * + * `table` and `text` are display formats: they flatten, truncate and colour, so + * neither can round-trip a document. Rather than emit something that looks like + * an export but cannot be re-imported, those two fall back to JSON. Only `yaml` + * is honoured, because it round-trips. + */ +export function printDocument(format: OutputFormat, raw: unknown): void { + console.log( + format === 'yaml' ? (renderMachine('yaml', raw) as string) : JSON.stringify(raw, null, 2) + ) +} + +/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ +export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { + const machine = renderMachine(format, raw) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const [label, value] of fields) { + console.log(`${label}\t${oneLine(stripAnsi(value))}`) + } + return + } + + const width = Math.max(...fields.map(([label]) => label.length)) + for (const [label, value] of fields) { + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) + } +} diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts new file mode 100644 index 00000000000..15cc616340e --- /dev/null +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -0,0 +1,393 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from './build.js' + +/** + * Drives commands through commander's own parsing rather than calling + * `buildRequest` directly. + * + * The unit tests below `request.ts` fed flag values in already-keyed by flag + * name, which is not what commander produces — it camelCases every multi-word + * flag. That gap let `--min-duration-ms` and every other multi-word flag be + * silently dropped while the tests passed. Parsing real argv is the only way to + * catch that class of bug. + */ + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + // Recursively, not just on the root: a parse error raised by a leaf (an + // unknown option, an excess argument) exits the process otherwise, which a + // test cannot assert on. + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +async function run(argv: string[]) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await program().parseAsync(['node', 'sim', ...argv]) + return mockRequest.mock.calls[0] +} + +describe('commands parsed through commander', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('carries a multi-word flag all the way to the request', async () => { + // The regression: commander stores this as `minDurationMs`, so a lookup by + // `min-duration-ms` found nothing and the filter never reached the API. + const [, options] = await run(['logs', 'list', '--min-duration-ms', '250']) + expect(options.query).toMatchObject({ minDurationMs: 250 }) + }) + + it('carries every multi-word flag on a command, not just the first', async () => { + const [, options] = await run([ + 'logs', + 'list', + '--min-duration-ms', + '10', + '--max-duration-ms', + '20', + '--min-cost', + '1', + '--execution-id', + 'exec_1', + ]) + expect(options.query).toMatchObject({ + minDurationMs: 10, + maxDurationMs: 20, + minCost: 1, + executionId: 'exec_1', + }) + }) + + it('applies a contract flag alias', async () => { + const [path, options] = await run([ + 'tables', + 'upsert', + 'tbl_1', + '--data', + '{"a":1}', + '--on', + 'email', + ]) + expect(path).toBe('/api/v2/tables/tbl_1/rows/upsert') + expect(options.body).toMatchObject({ conflictTarget: 'email', data: { a: 1 } }) + }) + + it('comma-joins a repeated list flag', async () => { + const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) + expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) + }) + + it('injects the profile workspace without a flag', async () => { + const [, options] = await run(['tables', 'list']) + expect(options.query).toMatchObject({ workspaceId: 'ws_local' }) + }) + + it('sends a boolean flag only when present', async () => { + const [, withFlag] = await run(['workflows', 'list', '--deployed-only']) + expect(withFlag.query).toMatchObject({ deployedOnly: true }) + + const [, without] = await run(['workflows', 'list']) + expect(without.query).not.toHaveProperty('deployedOnly') + }) + + it('refuses a destructive command without --yes, before any request', async () => { + await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--row', 'a'])).rejects.toThrow( + /cannot be undone/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + +describe('single-resource rendering', () => { + async function lines(argv: string[], data: unknown, format = 'json'): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + captured.push(line) + }) + output.format = format + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('unwraps the single-key envelope a resource is returned in', async () => { + // `createMcpServer` answers `{ data: { mcpServer: {...} } }`. Rendering that + // as-is found one key holding an object, filtered it out as non-scalar, and + // printed nothing at all — the server was created and the CLI said so + // nowhere. Same silent-empty class as the body-cursor bug below. + const printed = await lines( + [ + 'mcp-servers', + 'create', + '--name', + 'Deepwiki', + '--transport', + 'streamable-http', + '--url', + 'https://mcp.deepwiki.com/mcp', + ], + { mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true } }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/mcp-1/) + expect(printed.join('\n')).toMatch(/Deepwiki/) + }) + + it('renders nested fields instead of dropping them', async () => { + // `workflows export` printed `version` and `exportedAt` and nothing else: + // the record builder kept only scalars, so `workflow` and `state` — the + // entire export — vanished with no indication anything was missing. + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/inputs/) + expect(printed.join('\n')).toMatch(/email/) + }) + + it('truncates a nested value rather than flooding the terminal', async () => { + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } }, + 'text' + ) + + const stateLine = printed.find((line) => line.startsWith('state')) ?? '' + expect(stateLine.length).toBeLessThan(300) + expect(stateLine).toMatch(/…$/) + }) + + it('emits a document command as JSON whatever the display format is', async () => { + // Redirecting this to a file has to yield something `import` accepts, so + // `table`/`text` — which flatten and truncate — must not be honoured here. + const printed = await lines( + ['workflows', 'export', 'wf_1'], + { version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } }, + 'text' + ) + + expect(JSON.parse(printed.join('\n'))).toEqual({ + version: '1.0', + exportedAt: 'now', + workflow: { id: 'wf_1' }, + state: { blocks: {} }, + }) + }) + + it('leaves a payload with sibling keys intact', async () => { + // `upsertTableRow` returns `{ row, operation }` — two real fields, not an + // envelope. Unwrapping there would drop whether it inserted or updated. + const printed = await lines(['tables', 'upsert', 'tbl_1', '--data', '{}'], { + row: { id: 'r1' }, + operation: 'inserted', + }) + + expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) + }) +}) + +describe('pagination slot', () => { + it('pages a body-cursor operation and renders its rows', async () => { + // `queryRows` is a POST whose cursor is in the body, not the query. Reading + // only the query made it take the single-request path and print nothing. + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'r2' }], nextCursor: null }) + const lines: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + + expect(mockRequest).toHaveBeenCalledTimes(2) + // Second call resumes from the cursor — in the body, where the contract puts it. + expect(mockRequest.mock.calls[1][1].body).toMatchObject({ cursor: 'c1' }) + expect(mockRequest.mock.calls[1][1].query).not.toHaveProperty('cursor') + // And the rows actually render rather than printing an empty record. + expect(JSON.parse(lines[0])).toEqual([{ id: 'r1' }, { id: 'r2' }]) + }) + + it('keeps a query-cursor operation on the query slot', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'logs', 'list']) + + expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) + }) +}) + +describe('rows whose content sits in a wrapper', () => { + it('discovers columns from the expanded field', async () => { + // `tables rows query` returned a table of ids and timestamps: a row's cells + // live under `data`, and column inference skipped it for being an object. + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [ + { id: 'r1', data: { url: 'https://a', title: 'A' }, createdAt: 'now' }, + { id: 'r2', data: { url: 'https://b', extra: 'E' }, createdAt: 'now' }, + ], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + output.format = 'json' + + // Unioned across the page: `extra` appears only on the second row. + expect(lines[0]).toContain('https://a') + expect(lines[0]).toContain('A') + expect(lines[1]).toContain('E') + }) +}) + +describe('boolean flags', () => { + it('takes an explicit value when the field is required', async () => { + // As a presence-only flag this could only ever send `true`: `--is-active + // false` turned sharing ON and reported success, with the `false` dropped + // as a stray argument. + const [, options] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'false', + '--auth-type', + 'public', + ]) + expect(options.body).toMatchObject({ isActive: false }) + + const [, on] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'true', + '--auth-type', + 'public', + ]) + expect(on.body).toMatchObject({ isActive: true }) + }) + + it('negates an optional boolean, which omitting it cannot do', async () => { + // Omitting `enabled` means "leave it alone"; there was no way to say false, + // so an MCP server could not be disabled or a folder unlocked. + const [, off] = await run(['mcp-servers', 'update', 'mcp_1', '--no-enabled']) + expect(off.body).toMatchObject({ enabled: false }) + + const [, on] = await run(['mcp-servers', 'update', 'mcp_1', '--enabled']) + expect(on.body).toMatchObject({ enabled: true }) + + const [, absent] = await run(['mcp-servers', 'update', 'mcp_1', '--name', 'x']) + expect(absent.body).not.toHaveProperty('enabled') + }) + + it('rejects an argument the command has no meaning for', async () => { + await expect(run(['mcp-servers', 'update', 'mcp_1', '--enabled', 'bogus'])).rejects.toThrow( + /too many arguments/ + ) + }) +}) + +describe('bodies and fields the generator cannot flatten', () => { + it('sends a union body whole, with the profile workspace merged in', async () => { + // `createTableRows` is `z.union([batch, single])`, so there is no field list + // to build flags from. The command exposed nothing at all and sent no body, + // and every call failed with "Request body must be valid JSON". + const [path, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"rows":[{"city":"Paris"}]}', + ]) + + expect(path).toBe('/api/v2/tables/tbl_1/rows') + // Both branches require `workspaceId`, and it comes from the profile. + expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] }) + }) + + it('lets the caller override a shared field', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"workspaceId":"ws_other","rows":[]}', + ]) + expect(options.body).toMatchObject({ workspaceId: 'ws_other' }) + }) + + it('refuses a union body that is not an object', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--body', '[1,2]'])).rejects.toThrow( + /--body must be a JSON object/ + ) + }) + + it('leaves a non-numeric `limit` alone', async () => { + // `runTableColumn` takes `limit: { type, max }`. The pager claimed the name + // regardless of type, turning it into `--limit ` that defaulted to 100, + // so every call failed with "expected object, received number". + const [, omitted] = await run(['tables', 'columns', 'run', 'tbl_1', '--group-ids', '["g1"]']) + expect(omitted.body).not.toHaveProperty('limit') + + const [, given] = await run([ + 'tables', + 'columns', + 'run', + 'tbl_1', + '--group-ids', + '["g1"]', + '--limit', + '{"type":"rows","max":5}', + ]) + expect(given.body).toMatchObject({ limit: { type: 'rows', max: 5 } }) + }) + + it('still gives paginated lists their numeric --limit', async () => { + const [, options] = await run(['files', 'list', '--limit', '7']) + expect(options.query).toMatchObject({ limit: 7 }) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts new file mode 100644 index 00000000000..a1da31835c5 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.ts @@ -0,0 +1,458 @@ +import { Command, Option } from 'commander' +import { clientFrom } from '../context.js' +import { CLI_CONTRACT } from '../contract/commands.js' +import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { SimApiError, type V2Page } from '../http/client.js' +import { + bytes, + type Column, + duration, + printDocument, + printList, + printRecord, + sanitize, + text, + timestamp, +} from '../output/render.js' +import { deriveCommandPath } from './derive.js' +import { + buildRequest, + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + takesJson, +} from './request.js' + +/** Default page size when a list command is run without `--limit`. */ +const DEFAULT_LIMIT = 100 + +/** Reads `a.b.c` out of a row, tolerating a missing link anywhere along the way. */ +function at(row: unknown, path: string): unknown { + return path + .split('.') + .reduce( + (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), + row + ) +} + +function renderCell(value: unknown, format: ColumnSpec['format']): string { + switch (format) { + case 'timestamp': + return timestamp(value as string | null) + case 'bytes': + return bytes(value as number | null) + case 'duration': + return duration(value as number | null) + case 'bool': + return value === null || value === undefined ? text(null) : value ? 'yes' : 'no' + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + default: + if (value === null || value === undefined || value === '') return text(null) + // Server-supplied: strip terminal control sequences before it can reach a tty. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) + } +} + +/** + * How wide a nested value may get before a record line stops being readable. + * A workflow's `state` serializes to tens of kilobytes on one line. + */ +const NESTED_CELL_WIDTH = 160 + +/** + * A field in a record view. + * + * Nested values are rendered, not skipped: a record that quietly omits half of + * what the server sent is worse than a long line, because nothing tells the + * caller anything is missing. Long ones are cut with an ellipsis — visibly + * partial, and `sim configure --set-output json` prints them whole. + */ +function recordCell(value: unknown): string { + const rendered = renderCell(value, 'auto') + return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered +} + +function columnsFrom(specs: ColumnSpec[]): Column[] { + return specs.map((spec) => ({ + header: spec.header, + value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), + })) +} + +/** + * Columns for a list command with none declared in the contract. + * + * Row shapes are only known at runtime here — a table's `data` is user-defined — + * so the keys are unioned across the page rather than read off the first row, + * which would let a sparse row hide every column it happens to omit. Nested + * values are skipped: they render as JSON blobs and make the table unreadable — + * unless the contract names one with `expand`, which is how a row's cells reach + * the table. + */ +function inferColumns(rows: unknown[], expand?: string): Column[] { + const paths: Array<{ path: string; header: string }> = [] + const seen = new Set() + + for (const row of rows) { + if (!row || typeof row !== 'object') continue + for (const [key, value] of Object.entries(row)) { + if (seen.has(key)) continue + if (value !== null && typeof value === 'object') continue + seen.add(key) + paths.push({ path: key, header: key }) + } + } + + // The wrapper named by `expand` holds the only content the caller cares about; + // the loop above skipped it for being an object, which is how `tables rows + // query` came back showing nothing but ids and timestamps. + if (expand) { + const nested = new Set() + for (const row of rows) { + const container = at(row, expand) + if (!container || typeof container !== 'object' || Array.isArray(container)) continue + for (const key of Object.keys(container)) { + if (nested.has(key)) continue + nested.add(key) + // A user-defined key that shadows a top-level one is shown by its full + // path, so two different values never appear under one header. + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ + // The key itself is remote data when the rows are user-defined, and the + // header is printed just like a cell — sanitizing values but not headers + // left the same control sequences executable one row higher. + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), + })) +} + +/** + * Unwraps the single-key envelope several v2 responses put their resource in — + * `{ mcpServer }`, `{ knowledgeBase }`, `{ row }`, `{ document }`, `{ table }`. + * + * Without this the record renderer sees one key whose value is an object, + * filters it out as non-scalar, and prints nothing at all: `sim mcp-servers + * create` exited 0 having created the server and said nothing about it. + * + * Only a lone key is unwrapped. A payload with siblings (`{ row, operation }` + * from upsert) is a real multi-field result and is rendered as it stands. + */ +function unwrapResource(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data + const entries = Object.entries(data) + if (entries.length !== 1) return data + const [, value] = entries[0] + return value && typeof value === 'object' && !Array.isArray(value) ? value : data +} + +/** Whether the operation's body is one the generator could not describe field by field. */ +function opaqueBody(spec: object): boolean { + return (spec as { opaqueBody?: boolean }).opaqueBody === true +} + +/** The operation's one-line help, taken from the OpenAPI summary at generation time. */ +function summaryFor(operation: V2OperationName): string | undefined { + return (V2_OPERATIONS[operation] as { summary?: string }).summary +} + +/** + * Which request slot carries the pagination cursor, or null for a non-list + * operation. + * + * Both slots have to be checked: most lists take `cursor` as a query param, but + * `queryRows` is a POST whose whole filter — cursor included — is in the body. + * Looking only at the query made it fall through to the single-request path, + * which then rendered its array of rows through `printRecord` and printed + * nothing at all, and never auto-paged. + */ +function cursorSlot(operation: V2OperationName): 'query' | 'body' | null { + const spec = V2_OPERATIONS[operation] as { + query?: Record + body?: Record + } + if (spec.query && 'cursor' in spec.query) return 'query' + if (spec.body && 'cursor' in spec.body) return 'body' + return null +} + +/** Adds the flags a field needs, or nothing when the contract omits it. */ +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + // Never a flag: it comes from the profile, and `cursor`/`limit` are owned by + // the auto-pager rather than exposed as raw request fields. + if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return + + const flag = flagSpecFor(operation, field) + if (flag.omit) return + + const name = flagNameFor(operation, field) + const short = flag.short ? `-${flag.short}, ` : '' + + // The pager owns `--limit`, but only where `limit` means a page size. The + // name is not reserved: `runTableColumn` takes `limit: { type, max }`, and + // claiming it here turned that into a numeric flag that defaulted to 100 and + // made every invocation fail with "expected object, received number". + if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { + command.option( + `--limit `, + 'Maximum items to return (0 for everything)', + String(DEFAULT_LIMIT) + ) + return + } + + if (descriptor.kind === 'boolean') { + // A required boolean is a state to set, not a switch to flip on: it takes + // the value explicitly. As a presence-only flag it could only ever send + // `true`, so `--is-active false` set sharing ON — commander read the flag as + // true and dropped the `false` as a stray argument. + if (descriptor.required) { + command.addOption( + new Option(`${short}--${name} `, flag.describe ?? `Set ${field}`).choices([ + 'true', + 'false', + ]) + ) + return + } + + // Optional booleans stay presence-flags — `--deployed-only` reads better + // than `--deployed-only true` — but every one of them also gets a negation, + // because for a state field (`enabled`, `locked`) omitting the flag means + // "leave it alone", which is not the same as setting it false. Without this + // there was no way to disable an MCP server or unlock a folder. + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + command.option(`--no-${name}`, `Set ${field} to false`) + return + } + + const takesList = flag.list === true + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? `` : wantsJson ? `` : `` + const describe = + (flag.describe ?? + (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)) + + // Otherwise the only way to discover `@file` is to read the source. A JSON + // document big enough to want a file is exactly when help gets consulted. + (wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '') + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (descriptor.values && !takesList) option.choices([...descriptor.values]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + command.addOption(option) +} + +/** + * Builds one leaf command for an operation. + * + * The action closure is the whole runtime: coerce and assemble the request, + * auto-page it when the response is a cursor list, then render through whatever + * the contract says about columns. + */ +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + const operationSpec = V2_OPERATIONS[operation] as { + method: string + pathParams: readonly string[] + query?: Record + body?: Record + } + + // `new Command('upsert ')` would make the whole string the command's + // NAME, so `sim tables upsert` would never match it and would silently fall + // through to the group's help. Arguments have to be declared separately. + const command = new Command(leafName) + // Commander ignores arguments beyond those declared. That silence is how + // `--is-active false` ran as though the `false` had never been typed; an + // argument the command has no meaning for is a mistake worth stopping on. + command.allowExcessArguments(false) + for (const param of operationSpec.pathParams) { + command.argument(`<${param}>`) + } + + command.description( + spec.describe ?? + summaryFor(operation) ?? + `${operationSpec.method} ${V2_OPERATIONS[operation].path}` + ) + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + addFieldOption(command, operation, field, descriptor) + } + } + + // A body the generator could not break into fields is offered whole. The + // union behind `tables rows create` (one row, or a batch) has no field list + // to build flags from, and without this the command sent no body at all and + // the server rejected the request as malformed JSON. + if (opaqueBody(operationSpec)) { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin)' + ) + } + + if (spec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } + + command.action(async (...invocation: unknown[]) => { + // commander passes positionals, then the options object, then the Command. + const host = invocation[invocation.length - 1] as Command + const flags = invocation[invocation.length - 2] as Record + const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + + if (spec.confirm && !flags.yes) { + throw new SimApiError(`${spec.confirm} Re-run with --yes to confirm.`, 0) + } + + const { client, profile } = clientFrom(host) + // `requireWorkspace` checks the key first on purpose, so a fresh install is + // told to log in rather than to set a workspace it cannot use yet. Reading + // `profile.workspaceId` directly skipped that ordering. + const needsWorkspace = Boolean( + (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || + (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) + ) + const request = buildRequest( + operation, + positional, + flags, + needsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) + + const paging = cursorSlot(operation) + if (paging) { + const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + if (Number.isNaN(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + // 0 means everything; Infinity lets the loop run until the cursor dries up. + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + + const rows: unknown[] = [] + let cursor: string | null = null + do { + // The cursor goes back in whichever slot the contract declared it. + const page: V2Page = await client.request(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: paging === 'query' ? { ...request.query, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) } + : request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows + printList( + profile.output, + page, + spec.columns ? columnsFrom(spec.columns) : inferColumns(page, spec.expand) + ) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: request.query, + body: request.body, + }) + const raw = result?.data ?? result + + if (spec.document) { + printDocument(profile.output, raw) + return + } + + const data = unwrapResource(raw) + + if (Array.isArray(data)) { + // Reached when a non-paginated operation answers with a collection. + // `printRecord` would silently print nothing for an array. + printList( + profile.output, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) + return + } + + // Every field, nested ones included. Filtering to scalars here is what made + // `workflows export` print its two timestamps and drop the actual workflow. + const fields: Array<[string, string]> = + data && typeof data === 'object' + ? Object.entries(data).map(([key, value]) => [key, recordCell(value)]) + : [] + + printRecord(profile.output, fields, data) + }) + + return command +} + +/** + * Builds every command the contract and the generated operation table describe. + * + * Iterates `V2_OPERATIONS`, not the contract — an operation added to a Zod + * contract shows up here after `generate:cli-api` with no CLI edit at all. The + * contract is consulted only for the things a schema cannot say. + * + * `reserved` are groups owned by hand-written commands (`files download` streams + * binary, `logs get` prints a trace). A generated leaf never displaces one. + */ +export function buildGeneratedCommands(reserved: ReadonlySet): Command[] { + const groups = new Map() + + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = CLI_CONTRACT[operation] ?? {} + if (spec.hidden) continue + // Non-JSON responses (binary downloads) need a bespoke consumer. + if (V2_OPERATIONS[operation].responseMode !== 'json') continue + + const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) + const [groupName, ...rest] = segments + const leafName = rest.join(' ') || 'run' + + if (reserved.has(`${groupName} ${leafName}`)) continue + + let group = groups.get(groupName) + if (!group) { + group = new Command(groupName) + groups.set(groupName, group) + } + + // A multi-word leaf (`rows batch-delete`) nests one more level so help reads + // as a tree rather than a flat list of hyphenated names. + if (rest.length > 1) { + const [subName, ...tail] = rest + let sub = group.commands.find((candidate) => candidate.name() === subName) + if (!sub) { + sub = new Command(subName) + group.addCommand(sub) + } + sub.addCommand(buildLeaf(operation, spec, tail.join(' '))) + continue + } + + group.addCommand(buildLeaf(operation, spec, leafName)) + } + + return [...groups.values()].sort((a, b) => a.name().localeCompare(b.name())) +} diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts new file mode 100644 index 00000000000..f91aac678e1 --- /dev/null +++ b/packages/sim-cli/src/runtime/derive.ts @@ -0,0 +1,70 @@ +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' + +/** + * Trailing path segments that read as verbs rather than sub-resources, so + * `/tables/[id]/rows/upsert` derives `tables upsert` instead of + * `tables rows upsert create`. + * + * `execute` and `cancel` are deliberately absent: they are verbs, but their + * derived names read badly enough that the contract names them explicitly, and + * listing them here would produce `workflows execute` — close, but not the + * `workflows run` the contract asks for. Keeping them out means the contract is + * the only place that decision lives. + */ +const ACTION_SEGMENTS = new Set([ + 'upsert', + 'query', + 'search', + 'export', + 'import', + 'deploy', + 'rollback', +]) + +/** + * Derives a command path from an operation's route. + * + * ` [sub-resource] `, where the verb comes from the method and + * whether the path ends in a parameter (an item) or not (a collection). This + * covers 41 of the 47 operations; the rest are named in the CLI contract. + */ +export function deriveCommandPath(operation: V2OperationName): string[] { + const spec = V2_OPERATIONS[operation] + const segments = spec.path.replace('/api/v2/', '').split('/') + const resource = segments[0] + const nouns = segments.slice(1).filter((segment) => !segment.startsWith('[')) + const last = nouns[nouns.length - 1] + + if (last && ACTION_SEGMENTS.has(last)) return [resource, last] + + const isItem = spec.path.endsWith(']') + const verb = + spec.method === 'GET' + ? isItem + ? 'get' + : 'list' + : spec.method === 'POST' + ? 'create' + : spec.method === 'DELETE' + ? 'delete' + : 'update' + + return last ? [resource, last, verb] : [resource, verb] +} + +/** `conflictTarget` → `conflict-target`. */ +export function kebab(value: string): string { + return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) +} + +/** + * `min-duration-ms` → `minDurationMs`, the key commander actually stores. + * + * Commander camelCases every multi-word flag when it builds its options object, + * so a lookup by the flag's own name finds nothing and the value is silently + * dropped — no error, the field just never reaches the API. Every read of a + * parsed flag has to go through this. + */ +export function camel(flag: string): string { + return flag.replace(/-([a-z])/g, (_match, character: string) => character.toUpperCase()) +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts new file mode 100644 index 00000000000..286c7bd8d8b --- /dev/null +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -0,0 +1,190 @@ +import { rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { SimApiError } from '../http/client.js' +import { deriveCommandPath } from './derive.js' +import { buildRequest, coerce, type FieldSpec } from './request.js' + +const WORKSPACE = 'ws_local' + +describe('buildRequest', () => { + it('substitutes path params from positional args and injects the workspace', () => { + expect(buildRequest('upsertTableRow', ['tbl_1'], { data: '{"a":1}' }, WORKSPACE)).toEqual({ + path: '/api/v2/tables/tbl_1/rows/upsert', + query: {}, + body: { workspaceId: WORKSPACE, data: { a: 1 } }, + }) + }) + + it('puts the workspace in whichever slot the contract declares it', () => { + // Same field, different slot: body for upsert above, query here. + const built = buildRequest('listTables', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.body).toBeUndefined() + }) + + it('maps a contract flag alias back to its field name', () => { + const built = buildRequest('upsertTableRow', ['t'], { data: '{}', on: 'email' }, WORKSPACE) + expect(built.body).toMatchObject({ conflictTarget: 'email' }) + }) + + it('comma-joins a list flag the route splits, which the type calls a string', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + // Keys here are camelCase because that is what commander stores — feeding + // flag-shaped keys is what let the camelCase mismatch through review. + it('coerces numeric flags out of the strings argv gives', () => { + const built = buildRequest('listLogs', [], { minDurationMs: '250' }, WORKSPACE) + expect(built.query.minDurationMs).toBe(250) + }) + + it('omits absent optional fields so the server applies its own default', () => { + const built = buildRequest('listLogs', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.query).not.toHaveProperty('order') + }) + + it('never sends a field the contract marked omit', () => { + // `stream` would switch the response to SSE, which the JSON client cannot read. + const built = buildRequest('executeWorkflow', ['wf_1'], { stream: true }, WORKSPACE) + expect(built.body ?? {}).not.toHaveProperty('stream') + }) + + it('percent-encodes path params so an id cannot retarget the request', () => { + expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') + }) + + describe('failures, all before any network call', () => { + it('rejects a missing path arg', () => { + expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') + }) + + it('rejects a missing required flag', () => { + expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( + '--data is required' + ) + }) + + it('rejects malformed JSON, naming the flag the caller typed', () => { + expect(() => buildRequest('upsertTableRow', ['t'], { data: '{oops' }, WORKSPACE)).toThrow( + '--data must be valid JSON' + ) + }) + + it('rejects a value outside an enum', () => { + expect(() => buildRequest('listLogs', [], { level: 'warn' }, WORKSPACE)).toThrow( + '--level must be one of: info, error' + ) + }) + + it('rejects a non-numeric number', () => { + expect(() => buildRequest('listLogs', [], { minCost: 'lots' }, WORKSPACE)).toThrow( + '--min-cost must be a number' + ) + }) + + it('explains an unset workspace in terms of how to set one', () => { + expect(() => buildRequest('listTables', [], {}, null)).toThrow(SimApiError) + expect(() => buildRequest('listTables', [], {}, null)).toThrow( + 'sim configure --set-workspace' + ) + }) + }) +}) + +describe('deriveCommandPath', () => { + it('derives collection and item verbs from the method and path shape', () => { + expect(deriveCommandPath('listTables')).toEqual(['tables', 'list']) + expect(deriveCommandPath('getTable')).toEqual(['tables', 'get']) + expect(deriveCommandPath('createTable')).toEqual(['tables', 'create']) + expect(deriveCommandPath('deleteTable')).toEqual(['tables', 'delete']) + }) + + it('nests a sub-resource', () => { + expect(deriveCommandPath('getKnowledgeDocument')).toEqual(['knowledge', 'documents', 'get']) + expect(deriveCommandPath('listTableRows')).toEqual(['tables', 'rows', 'list']) + }) + + it('treats a verb-like trailing segment as the command name', () => { + expect(deriveCommandPath('upsertTableRow')).toEqual(['tables', 'upsert']) + expect(deriveCommandPath('searchKnowledge')).toEqual(['knowledge', 'search']) + }) +}) + +describe('repeated flags encode per the field kind, not uniformly', () => { + it('joins a string field the route splits', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + it('keeps an array field as an array', () => { + // Joining these produced a string where the wire wants an array, so + // `--row a b` failed validation — and so did a single `--row a`. + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1', 'r2'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1', 'r2']) + }) + + it('keeps a single repeated value as a one-element array, not a bare string', () => { + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1']) + }) + + it('sends the array branch of a string-or-array union', () => { + // `knowledgeBaseIds` accepts either; joining made "kb_1,kb_2" a single id. + const built = buildRequest( + 'searchKnowledge', + [], + { kb: ['kb_1', 'kb_2'], query: 'refunds' }, + WORKSPACE + ) + expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) + }) +}) + +describe('JSON flags that name a file', () => { + const field: FieldSpec = { kind: 'object' } + + it('reads @path', () => { + const path = join(tmpdir(), 'sim-cli-arg.json') + writeFileSync(path, '{"version":"1.0","state":{"blocks":{}}}') + expect(coerce(`@${path}`, field, {}, 'workflow')).toEqual({ + version: '1.0', + state: { blocks: {} }, + }) + rmSync(path) + }) + + it('still accepts inline JSON', () => { + expect(coerce('{"a":1}', field, {}, 'workflow')).toEqual({ a: 1 }) + }) + + it('names the file it could not read', () => { + expect(() => coerce('@/nope/missing.json', field, {}, 'workflow')).toThrow( + /cannot read \/nope\/missing\.json/ + ) + }) + + it('says which file the bad JSON came from', () => { + const path = join(tmpdir(), 'sim-cli-bad.json') + writeFileSync(path, 'not json') + expect(() => coerce(`@${path}`, field, {}, 'workflow')).toThrow(/read from .*sim-cli-bad\.json/) + rmSync(path) + }) + + it('points at @ when a bare filename was passed instead', () => { + // `--workflow export.json` is the natural first guess; "must be valid JSON" + // alone never reveals that passing a file is supported at all. + const path = join(tmpdir(), 'sim-cli-bare.json') + writeFileSync(path, '{}') + expect(() => coerce(path, field, {}, 'workflow')).toThrow(new RegExp(`pass it as @${path}`)) + rmSync(path) + expect(() => coerce('export.json', field, {}, 'workflow')).toThrow(/pass @path/) + }) + + it('does not suggest a path for malformed inline JSON', () => { + expect(() => coerce('{"a":', field, {}, 'workflow')).not.toThrow(/@path/) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts new file mode 100644 index 00000000000..fa7743bde31 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.ts @@ -0,0 +1,279 @@ +import { existsSync, readFileSync, readSync } from 'node:fs' +import { CLI_CONTRACT } from '../contract/commands.js' +import type { FlagSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { type QueryValue, SimApiError } from '../http/client.js' +import { camel, kebab } from './derive.js' + +/** One request field, as the generator describes it. */ +export interface FieldSpec { + kind: 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown' + required?: boolean + values?: readonly string[] + default?: unknown +} + +/** + * The workspace never becomes a flag. + * + * It is the one field every workspace-scoped operation declares, and it comes + * from the profile — surfacing it as `--workspace-id` on 30-odd commands would + * duplicate the global `--workspace` and invite the two to disagree. + */ +export const PROFILE_INJECTED_FIELD = 'workspaceId' + +/** Kinds the CLI can only accept as a JSON string. */ +const JSON_KINDS = new Set(['object', 'array', 'unknown']) + +export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec { + return CLI_CONTRACT[operation]?.flags?.[field] ?? {} +} + +/** The flag name a field is exposed under, honouring any contract override. */ +export function flagNameFor(operation: V2OperationName, field: string): string { + return flagSpecFor(operation, field).name ?? kebab(field) +} + +export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { + return flag.json === true || JSON_KINDS.has(field.kind) +} + +/** + * Drains stdin synchronously. + * + * `readFileSync(0)` looks like the obvious way to do this and fails on the one + * case that matters: a pipe is opened non-blocking, so a single read of an + * upstream process that has not written yet returns EAGAIN rather than waiting, + * and `export … | import --workflow @-` died with a raw stack trace. Reading in + * a loop and treating EAGAIN as "not ready yet" is what makes a pipe work. + * + * `Atomics.wait` is the only synchronous sleep available; without it the retry + * spins a core for as long as the writer takes. + */ +function readStdin(): string { + const idle = new Int32Array(new SharedArrayBuffer(4)) + const buffer = Buffer.alloc(64 * 1024) + const chunks: Buffer[] = [] + + for (;;) { + let read: number + try { + read = readSync(0, buffer, 0, buffer.length, null) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EAGAIN') { + Atomics.wait(idle, 0, 0, 5) + continue + } + // Some platforms report end-of-input on a pipe as EOF rather than 0. + if (code === 'EOF') break + throw error + } + if (read === 0) break + chunks.push(Buffer.from(buffer.subarray(0, read))) + } + + return Buffer.concat(chunks).toString('utf8') +} + +/** + * Resolves a JSON flag's argument, which may name a file instead of carrying + * the document inline. + * + * `@path` reads the file and `@-` reads stdin, the curl convention. A workflow + * export is hundreds of lines, and the shell makes passing that literally + * unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the + * quoted form is easy to get wrong. `@` cannot collide with a real value + * because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. + */ +function readJsonArgument(raw: string, flagName: string): { text: string; from: string } { + if (!raw.startsWith('@')) return { text: raw, from: '' } + + const path = raw.slice(1) + if (path === '-') { + if (process.stdin.isTTY) { + throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0) + } + try { + return { text: readStdin(), from: ' (read from stdin)' } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0) + } + } + + try { + return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0) + } +} + +/** + * Points at `@` when a value that failed to parse looks like a filename. + * + * `--workflow export.json` is the natural first guess, and "must be valid JSON" + * alone gives no clue that passing a file is even supported. + */ +function pathHint(raw: string): string { + if (raw.startsWith('@') || /^\s*[[{"\-\d]|^\s*(true|false|null)/.test(raw)) return '' + return existsSync(raw) + ? `. ${raw} is a file — pass it as @${raw}` + : '. To read a file, pass @path (or @- for stdin)' +} + +/** + * Turns the string argv provides into the value the contract expects. + * + * Every failure names the flag rather than the field, because the flag is what + * the caller typed — and every one of these is caught before any request is + * made, so a typo costs nothing. + */ +export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown { + if (raw === undefined) return undefined + + /** + * A repeated flag. `list` says the CLI accepts several values; the *wire* + * encoding follows the field's own kind, because the two are not the same + * question: + * + * - `string` — the route splits on commas (`workflowIds`, `folderIds`, + * `triggers`), so the values are joined. + * - anything else — the wire genuinely wants an array (`rowIds`, + * `selectedOutputs`) or a string-or-array union whose array branch is the + * right one (`knowledgeBaseIds`). Joining those produced a single bogus id + * or failed validation outright. + */ + if (flag.list) { + const values = Array.isArray(raw) ? raw : [raw] + return field.kind === 'string' ? values.join(',') : values + } + + if (takesJson(field, flag)) { + if (typeof raw !== 'string') return raw + const source = readJsonArgument(raw, flagName) + try { + return JSON.parse(source.text) + } catch (error) { + throw new SimApiError( + `--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`, + 0 + ) + } + } + + if (field.kind === 'number' || field.kind === 'integer') { + const value = Number(raw) + if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + return value + } + + if (field.kind === 'boolean') return raw === true || raw === 'true' + + if (field.kind === 'enum' && field.values && !field.values.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${field.values.join(', ')}`, 0) + } + + return raw +} + +export interface BuiltRequest { + path: string + query: Record + body: Record | undefined +} + +/** + * A query string can only carry scalars. Every v2 query field is one today, but + * a structured field could be added — serializing it here keeps that a working + * request rather than `[object Object]`. + */ +function asQueryValue(value: unknown): QueryValue { + if (value === null || value === undefined) return undefined + if (typeof value === 'object') return JSON.stringify(value) + return value as QueryValue +} + +/** + * Assembles one operation's HTTP request from positional args, parsed flags, + * and the profile's workspace. + * + * Path params come from positional arguments in declared order; every other + * field is looked up by its flag name in the slot the contract declares it in, + * so a field that moved from query to body moves here on the next regeneration. + */ +export function buildRequest( + operation: V2OperationName, + positional: string[], + flags: Record, + workspaceId: string | null +): BuiltRequest { + const spec = V2_OPERATIONS[operation] as { + method: string + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + } + + let path = spec.path + spec.pathParams.forEach((param, index) => { + const value = positional[index] + if (value === undefined) throw new SimApiError(`Missing <${param}>`, 0) + // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. + path = path.replace(`[${param}]`, encodeURIComponent(value)) + }) + + const query: Record = {} + const body: Record = {} + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { + const flag = flagSpecFor(operation, field) + if (flag.omit) continue + + const flagName = flagNameFor(operation, field) + // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the + // flag's own name silently finds nothing. + const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[camel(flagName)] + const value = coerce(raw ?? undefined, descriptor, flag, flagName) + + if (value === undefined) { + if (descriptor.required) { + throw new SimApiError( + field === PROFILE_INJECTED_FIELD + ? 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + : `--${flagName} is required`, + 0 + ) + } + // Omitted rather than sent as null: the server applies its own default, + // and sending an explicit undefined would override it with nothing. + continue + } + + if (slot === 'query') query[field] = asQueryValue(value) + else body[field] = value + } + } + + // A union body comes in whole through `--body`, merged over the fields the + // branches share. Replacing outright dropped the profile's `workspaceId`, + // which both branches require, so every insert came back as invalid input. + // The caller's JSON still wins on any key it sets. + if (spec.opaqueBody) { + const raw = flags.body + if (typeof raw !== 'string') throw new SimApiError('--body is required', 0) + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--body must be a JSON object', 0) + } + return { path, query, body: { ...body, ...(parsed as Record) } } + } + + return { + path, + query, + body: Object.keys(body).length > 0 ? body : undefined, + } +} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json new file mode 100644 index 00000000000..69711cab009 --- /dev/null +++ b/packages/sim-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sim/tsconfig/library-build.json", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sim-cli/vitest.config.ts b/packages/sim-cli/vitest.config.ts new file mode 100644 index 00000000000..ceafc241202 --- /dev/null +++ b/packages/sim-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts new file mode 100644 index 00000000000..6b93778c00f --- /dev/null +++ b/scripts/generate-v2-cli-api.ts @@ -0,0 +1,552 @@ +#!/usr/bin/env bun +/** + * Generates the Sim CLI's view of the public v2 API from the Zod route + * contracts, so the terminal and the server cannot describe the same endpoint + * differently. + * + * The contracts under `apps/sim/lib/api/contracts/v2/**` are the single source + * of truth: the routes validate against them, so a shape that disagrees with a + * contract is a shape the server would reject. Everything downstream is derived + * rather than restated. + * + * The CLI cannot import the contracts directly — `packages/*` must never depend + * on `apps/*` (scripts/check-monorepo-boundaries.ts). This script bridges that + * at build time instead: it reads the contracts here and emits a file of plain + * type declarations with no imports at all, so nothing about the package + * boundary changes. + * + * Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They + * carry hand-written descriptions, examples, and error responses that Zod + * schemas do not encode. `scripts/check-openapi-specs.ts` reconciles those + * against the same contracts instead, field by field, so the prose survives + * while drift still fails CI. + * + * Usage: + * bun run scripts/generate-v2-cli-api.ts # write the generated file + * bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale + */ + +import { spawnSync } from 'node:child_process' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') +const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') +const DOCS_DIR = path.join(ROOT, 'apps/docs') + +/** + * OpenAPI documents to read operation summaries from, discovered rather than + * listed — same reason as {@link contractModules}. + * + * A new spec file (`openapi-v2-resources.json` arrived with the MCP/skills/ + * folders/credentials endpoints) would otherwise go unread, and the only symptom + * would be `--help` quietly falling back to `METHOD /path` for a whole domain. + * + * `openapi.json` is the retired single-document spec, superseded by the split + * files; it is excluded by name because it still exists on disk and would + * contribute stale duplicates. + */ +function specFiles(): string[] { + return readdirSync(DOCS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith('openapi') && + entry.name.endsWith('.json') && + entry.name !== 'openapi.json' + ) + .map((entry) => entry.name) + .sort() +} + +/** + * `METHOD /api/v2/{id}/…` → the spec's one-line summary. + * + * The contracts carry validation, not prose, so `--help` text has to come from + * somewhere else. The specs already hold a hand-written summary per operation + * and `check:openapi` guarantees every contract has one, so reading them here + * reuses documentation that is already written and already verified rather than + * inventing a second place to describe the same endpoint. + */ +function loadSummaries(): Map { + const summaries = new Map() + + for (const file of specFiles()) { + let spec: Record + try { + spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) + } catch { + // A missing spec is not fatal: the CLI falls back to `METHOD path`, and + // `check:openapi` is what actually enforces the specs' presence. + continue + } + + for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods as Record)) { + const summary = operation?.summary + if (typeof summary === 'string') { + summaries.set(`${method.toUpperCase()} ${specPath}`, summary) + } + } + } + } + + return summaries +} + +/** + * Every contract module under `contracts/v2`, discovered rather than listed. + * + * A hardcoded list is the wrong shape for this: adding a v2 domain would leave + * its operations silently absent from the CLI, with no error and nothing in + * `--check` to notice, because the generated file would still match a generator + * that never looked. Discovery makes a new domain appear on the next + * regeneration, which is the property the whole pipeline is built on. + * + * `shared.ts` holds the response-envelope helpers, not contracts; it is skipped + * because it exports no route contract, not because it is named here. + */ +function contractModules(): string[] { + return readdirSync(CONTRACTS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + entry.name !== 'index.ts' + ) + .map((entry) => entry.name.replace(/\.ts$/, '')) + .sort() +} + +interface RouteContract { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + headers?: z.ZodType + response: { mode: string; schema?: z.ZodType } +} + +interface Operation { + /** `listTables` — derived from the export name. */ + name: string + domain: string + contract: RouteContract +} + +function isRouteContract(value: unknown): value is RouteContract { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.method === 'string' && + typeof candidate.path === 'string' && + typeof candidate.response === 'object' + ) +} + +/** `v2ListTablesContract` → `listTables`. */ +function operationName(exportName: string): string { + const stripped = exportName.replace(/^v2/, '').replace(/Contract$/, '') + return stripped.charAt(0).toLowerCase() + stripped.slice(1) +} + +function pascal(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1) +} + +async function collectOperations(): Promise { + const operations: Operation[] = [] + + for (const domain of contractModules()) { + const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) + for (const [exportName, value] of Object.entries(mod)) { + if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue + operations.push({ name: operationName(exportName), domain, contract: value }) + } + } + + // Import order is stable, but sort anyway so a reordered export list does not + // show up as a spurious diff in the generated file. + return operations.sort((a, b) => a.name.localeCompare(b.name)) +} + +type JsonSchema = Record + +/** + * Emits a TypeScript type for the subset of JSON Schema that `z.toJSONSchema` + * produces from these contracts. + * + * Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is + * a known, narrow subset (no `patternProperties`, no draft-04 quirks), and the + * output is committed and read by humans, so controlling the formatting is + * worth more here than covering spec corners that never appear. An unhandled + * construct throws rather than degrading to `any` — silence is how a generated + * client drifts from its server. + * + * `refs` maps a `$defs` key to the TypeScript alias hoisted for it. Zod factors + * a schema out into `$defs` when it is recursive, which the table view's filter + * grammar is — a predicate holds predicates — so it cannot be inlined. + */ +function toTypeScript(schema: JsonSchema, indent = 0, refs?: Map): string { + if (typeof schema.$ref === 'string') { + const key = schema.$ref.replace('#/$defs/', '') + const name = refs?.get(key) + if (!name) throw new Error(`Unresolved $ref: ${schema.$ref}`) + return name + } + + const pad = ' '.repeat(indent + 1) + const closePad = ' '.repeat(indent) + + if (schema.const !== undefined) return JSON.stringify(schema.const) + if (schema.enum) return schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ') + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + return variants.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' | ') + } + + if (schema.allOf) { + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' & ') + } + + switch (schema.type) { + case 'string': + return 'string' + case 'number': + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + case 'array': + return schema.items ? `Array<${toTypeScript(schema.items, indent, refs)}>` : 'unknown[]' + case 'object': { + const properties: Record = schema.properties ?? {} + const required: string[] = schema.required ?? [] + const keys = Object.keys(properties) + + if (keys.length === 0) { + // A bare object with only `additionalProperties` is a record. + const value = + schema.additionalProperties && typeof schema.additionalProperties === 'object' + ? toTypeScript(schema.additionalProperties, indent, refs) + : 'unknown' + return `Record` + } + + const lines = keys.map((key) => { + const optional = required.includes(key) ? '' : '?' + const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key) + return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1, refs)}` + }) + return `{\n${lines.join('\n')}\n${closePad}}` + } + } + + // `z.unknown()` / `z.any()` render as an empty schema. + if (Object.keys(schema).filter((k) => k !== '$schema').length === 0) return 'unknown' + + throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) +} + +/** + * A type plus any aliases that must be declared before it. + * + * A recursive schema cannot be written inline, so Zod lifts it into `$defs` and + * points at it; those become real named types, which TypeScript resolves + * recursively without complaint. + */ +interface GeneratedType { + type: string + declarations: string[] +} + +function schemaToType(schema: z.ZodType, io: 'input' | 'output', name: string): GeneratedType { + const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema + const defs = json.$defs as Record | undefined + if (!defs) return { type: toTypeScript(json), declarations: [] } + + // Named after the type that owns them, so two operations lifting their own + // `__schema0` cannot collide in the single generated module. + const refs = new Map(Object.keys(defs).map((key, index) => [key, `${name}Ref${index}`])) + const declarations = Object.entries(defs).map( + ([key, def]) => `type ${refs.get(key)} = ${toTypeScript(def, 0, refs)}\n` + ) + + const { $defs, ...root } = json + return { type: toTypeScript(root, 0, refs), declarations } +} + +/** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */ +function pathParams(routePath: string): string[] { + return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) +} + +/** + * The kind a request field reduces to for the CLI's purposes. + * + * Everything from argv arrives as a string, so this is what tells the runtime + * how to turn `"50"` into `50`, a bare `--flag` into `true`, and `'{"a":1}'` + * into an object. `unknown` covers `z.unknown()`/`z.any()`, which the CLI can + * only accept as JSON. + */ +type FieldKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'enum' + | 'array' + | 'object' + | 'unknown' + +function fieldKind(schema: JsonSchema): FieldKind { + if (schema.enum) return 'enum' + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + // Nullable is spelled as a union with `null`; a single non-null branch is + // the field's real kind. A genuine multi-branch union has no single flag + // shape, so it falls through to `unknown` and is taken as JSON. + const concrete = variants.filter((v: JsonSchema) => v.type !== 'null') + return concrete.length === 1 ? fieldKind(concrete[0]) : 'unknown' + } + + const type = Array.isArray(schema.type) + ? schema.type.find((t: string) => t !== 'null') + : schema.type + + switch (type) { + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'array': + case 'object': + return type + default: + return 'unknown' + } +} + +/** + * Describes one request slot's fields for the runtime that builds flags. + * + * Emitted as data rather than baked into types because the CLI has to *iterate* + * these at startup to construct commands — a type alone cannot be walked. + */ +/** + * Whether the slot is a union, whose branches the CLI cannot turn into flags. + * + * Distinct from "the map came out empty": the shared fields of a union are + * emitted as a map, so emptiness alone no longer identifies one, and the + * runtime still has to know the rest of the body must come in as JSON. + */ +function isUnionSlot(schema: z.ZodType): boolean { + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf) +} + +function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null { + if (!schema) return null + + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + let properties: Record = json.properties ?? {} + let required = new Set(json.required ?? []) + + // A union has no properties of its own, but the fields every branch agrees on + // are still known and still have to be sent — `workspaceId` is required by + // both branches of the row-insert body and comes from the profile, so + // dropping it left `tables rows create` rejected as invalid input. + if (Object.keys(properties).length === 0) { + const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined + if (branches?.length) { + const shared = branches.reduce( + (keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined), + Object.keys(branches[0].properties ?? {}) + ) + properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]])) + required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key)))) + } + } + + const keys = Object.keys(properties) + + // A union body (e.g. single-row vs batch insert) has no flat field list. The + // caller marks it `opaqueBody` so the runtime can offer the whole body as one + // JSON flag instead. + if (keys.length === 0) return null + + const lines = keys.map((key) => { + const property = properties[key] + const parts = [`kind: '${fieldKind(property)}'`] + if (required.has(key)) parts.push('required: true') + if (property.enum) { + parts.push( + `values: [${property.enum.map((v: unknown) => JSON.stringify(v)).join(', ')}] as const` + ) + } + if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`) + return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },` + }) + + return `{\n${lines.join('\n')}\n${indent}}` +} + +function render(operations: Operation[]): string { + const out: string[] = [] + const summaries = loadSummaries() + + out.push('/**') + out.push(' * GENERATED FILE — DO NOT EDIT.') + out.push(' *') + out.push(' * Emitted from the Zod route contracts in') + out.push(' * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`.') + out.push(' * Regenerate with `bun run generate:cli-api`; CI fails when this file is') + out.push(' * stale, so edit the contract rather than this file.') + out.push(' *') + out.push(' * Contains only type declarations and one const table — no imports, so the') + out.push(' * `packages/* must not import apps/*` boundary is preserved.') + out.push(' */') + out.push('') + + for (const op of operations) { + const Name = pascal(op.name) + const { contract } = op + + out.push(`/** \`${contract.method} ${contract.path}\` */`) + + for (const slot of ['params', 'query', 'body', 'headers'] as const) { + const schema = contract[slot] + if (!schema) continue + const slotName = `${Name}${pascal(slot)}` + const generated = schemaToType(schema, 'input', slotName) + out.push(...generated.declarations) + out.push(`export type ${slotName} = ${generated.type}`) + out.push('') + } + + if (contract.response.mode === 'json' && contract.response.schema) { + const generated = schemaToType(contract.response.schema, 'output', `${Name}Response`) + out.push(...generated.declarations) + out.push(`export type ${Name}Response = ${generated.type}`) + } else { + out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`) + out.push(`export type ${Name}Response = never`) + } + out.push('') + } + + out.push('/**') + out.push(' * Every v2 operation, keyed by name.') + out.push(' *') + out.push(' * `query` and `body` describe each field well enough for the CLI to build a') + out.push(' * flag for it and coerce the string argv gives back: its kind, whether it is') + out.push(' * required, its enum values, and its server-side default. A slot the contract') + out.push(' * does not declare — or one whose shape is a union with no flat field list —') + out.push(' * is absent, and the runtime falls back to taking it as JSON.') + out.push(' *') + out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") + out.push(' * specs so `--help` reuses prose that is already written and already checked.') + out.push(' */') + out.push('export const V2_OPERATIONS = {') + for (const op of operations) { + const params = pathParams(op.contract.path) + out.push(` ${op.name}: {`) + out.push(` method: '${op.contract.method}',`) + out.push(` path: '${op.contract.path}',`) + out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`) + out.push(` responseMode: '${op.contract.response.mode}',`) + // OpenAPI writes `{id}` where the contract writes `[id]`. + const summary = summaries.get( + `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + ) + if (summary) out.push(` summary: ${JSON.stringify(summary)},`) + for (const slot of ['query', 'body'] as const) { + const map = renderSlotMap(op.contract[slot], ' ') + if (map) out.push(` ${slot}: ${map},`) + // A declared slot with no flat field list still has to be sendable. + // Absence alone cannot say so: it means both "no body" and "a body the + // generator could not describe", and reading it as the former left + // `tables rows create` unable to send anything at all. + if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) { + out.push(` opaqueBody: true,`) + } + } + out.push(' },') + } + out.push('} as const') + out.push('') + out.push('export type V2OperationName = keyof typeof V2_OPERATIONS') + out.push('') + + return out.join('\n') +} + +/** + * Runs the emitted source through Biome so the generated file is a fixed point + * of the repo's formatter. + * + * Without this the file is rewritten on the way into a commit: lint-staged runs + * `biome check --write` on explicit paths, which bypasses the `files.includes` + * exclusion in biome.json. The result was a generated file that no longer + * matched its generator, so `--check` failed in CI complaining about contract + * drift that had not happened. Formatting here means the hook has nothing left + * to change. + */ +function format(source: string): string { + const result = spawnSync( + path.join(ROOT, 'node_modules/.bin/biome'), + ['format', `--stdin-file-path=${OUTPUT}`], + { input: source, encoding: 'utf8' } + ) + + if (result.status !== 0 || !result.stdout) { + // Fail loudly: silently emitting unformatted output would reintroduce the + // exact hook-rewrites-generated-file loop this exists to close. + throw new Error( + `biome failed to format the generated output (status ${result.status}): ${result.stderr ?? ''}` + ) + } + + return result.stdout +} + +async function main() { + const args = new Set(process.argv.slice(2)) + const operations = await collectOperations() + + const generated = format(render(operations)) + + if (args.has('--check')) { + let current = '' + try { + current = readFileSync(OUTPUT, 'utf8') + } catch { + console.error(`${path.relative(ROOT, OUTPUT)} is missing. Run: bun run generate:cli-api`) + process.exit(1) + } + if (current !== generated) { + console.error( + `${path.relative(ROOT, OUTPUT)} is stale. Run: bun run generate:cli-api\n\n` + + 'The v2 contracts changed without the CLI being regenerated.' + ) + process.exit(1) + } + console.log(`${path.relative(ROOT, OUTPUT)} is up to date (${operations.length} operations).`) + return + } + + writeFileSync(OUTPUT, generated) + console.log( + `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${contractModules().length} contract modules.` + ) +} + +main()