diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml new file mode 100644 index 00000000000..f36e514ec69 --- /dev/null +++ b/.github/workflows/publish-sim-cli.yml @@ -0,0 +1,149 @@ +name: Publish Sim API CLI Package + +on: + push: + branches: [main, staging, dev] + paths: + - 'packages/sim-cli/**' + +permissions: + contents: read + +concurrency: + group: publish-sim-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-npm: + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '20' + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify npm authentication + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm whoami + + - name: Run tests + working-directory: packages/sim-cli + run: bun run test + + - name: Type-check package + working-directory: packages/sim-cli + run: bun run type-check + + - name: Build package + working-directory: packages/sim-cli + run: bun run build + + - name: Resolve release channel + id: release + working-directory: packages/sim-cli + env: + BRANCH: ${{ github.ref_name }} + run: | + BASE_VERSION="$(bun -p "require('./package.json').version")" + if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2 + exit 1 + fi + + case "$BRANCH" in + dev) + VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="dev" + ;; + staging) + VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="staging" + ;; + main) + VERSION="$BASE_VERSION" + TAG="latest" + ;; + *) + echo "Unsupported release branch '$BRANCH'." >&2 + exit 1 + ;; + esac + + bun pm pkg set "version=$VERSION" + RESOLVED_VERSION="$(bun -p "require('./package.json').version")" + if [ "$RESOLVED_VERSION" != "$VERSION" ]; then + echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2 + exit 1 + fi + + { + echo "version=$VERSION" + echo "tag=$TAG" + } >> "$GITHUB_OUTPUT" + + - name: Smoke-test packed Node bundle + working-directory: packages/sim-cli + run: | + set -euo pipefail + SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-cli-smoke.XXXXXX")" + PACKAGE_PATH="$SMOKE_DIR/sim-cli.tgz" + bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet + tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" + "$SMOKE_DIR/package/dist/index.js" --version + + - name: Check if version already exists + id: version_check + working-directory: packages/sim-cli + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to npm + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/sim-cli + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: bun publish --access public --tag "$NPM_TAG" --no-save + + - name: Summarize release + if: steps.version_check.outputs.exists == 'false' + env: + VERSION: ${{ steps.release.outputs.version }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." + + - name: Summarize skipped release + if: steps.version_check.outputs.exists == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..80a0ec62352 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -264,4 +264,4 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim + run: bunx turbo run build --filter=@sim/app diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..b7020ae27f9 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2263 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + }, + { + "name": "Billing", + "description": "Inspect billing status and credit-denominated ledger events" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current usage and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + }, + "/api/v2/billing/status": { + "get": { + "operationId": "getBillingStatus", + "summary": "Get Billing Status", + "description": "Return the current plan, billing standing, period, and credit allowance. This endpoint never embeds ledger rows or per-source analytics; use `GET /api/v2/billing/logs` for billing history.", + "tags": ["Billing"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Resolve the status against this workspace's actual payer. A workspace-scoped API key is pinned to its own workspace; passing a different id returns 403." + } + ], + "responses": { + "200": { + "description": "The current billing status.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["workspaceId", "period", "plan", "status", "credits"], + "properties": { + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace whose payer was resolved, or null for account billing." + }, + "period": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "limit_exceeded", "billing_blocked"] + }, + "credits": { + "type": "object", + "required": ["used", "limit", "remaining"], + "properties": { + "used": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "remaining": { + "type": "number" + } + } + } + } + } + } + }, + "example": { + "data": { + "workspaceId": null, + "period": { + "start": "2026-07-01T00:00:00.000Z", + "end": "2026-08-01T00:00:00.000Z" + }, + "plan": "pro", + "status": "active", + "credits": { + "used": 512, + "limit": 20000, + "remaining": 19488 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/billing/logs": { + "get": { + "operationId": "listBillingLogs", + "summary": "List Billing Logs", + "description": "Cursor-paged, credit-denominated billing ledger. This endpoint returns history only and never embeds the current billing status. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Billing"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "description": "Restrict to one usage source (e.g. `workflow`, `sim-chat`). `sim-chat` includes both the internal Copilot and workspace-chat ledgers." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." + } + ], + "responses": { + "200": { + "description": "A page of usage events.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "createdAt", + "source", + "workspaceId", + "workflow", + "executionId", + "creditCost" + ], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "workspaceId": { + "type": ["string", "null"] + }, + "workflow": { + "oneOf": [ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + } + } + }, + { + "type": "null" + } + ] + }, + "executionId": { + "type": ["string", "null"] + }, + "creditCost": { + "type": "number", + "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event." + } + } + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "example": { + "data": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "sim-chat", + "workspaceId": "ws_1", + "workflow": null, + "executionId": null, + "creditCost": 12 + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed", "cancelled"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current usage and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + }, + "V2Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`." + }, + "message": { + "type": "string" + }, + "details": { + "description": "Optional structured context (e.g. per-field validation issues)." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + }, + "V2BadRequest": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Forbidden": { + "description": "The credential is not authorized for the requested resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2RateLimited": { + "description": "Rate limit exceeded; retry after the window resets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } +} 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/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 3ab31695330..c5ae91e4c30 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -666,23 +666,17 @@ describe('Knowledge Search Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) - // The env object lazily reads process.env, so a developer's local .env - // keys survive the deletion above — stub the direct key empty and fail - // the hosted rotation fallback for hermeticity on any machine. - vi.stubEnv('OPENAI_API_KEY', '') - const apiKeysModule = await import('@/lib/core/config/api-keys') - const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { - throw new Error('No rotation keys configured') + Object.assign(env, { + OPENAI_API_KEY: undefined, + OPENAI_API_KEY_1: undefined, + OPENAI_API_KEY_2: undefined, + OPENAI_API_KEY_3: undefined, + OPENROUTER_API_KEY: undefined, }) - try { - await expect(generateSearchEmbedding('test query')).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) - } finally { - rotationSpy.mockRestore() - vi.unstubAllEnvs() - } + await expect(generateSearchEmbedding('test query')).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) }) it('should handle Azure OpenAI API errors properly', async () => { @@ -713,6 +707,7 @@ describe('Knowledge Search Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) Object.assign(env, { OPENAI_API_KEY: 'test-openai-key', + OPENROUTER_API_KEY: undefined, }) mockNextFetchResponse({ diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts new file mode 100644 index 00000000000..33757c33864 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' + +const { + mockCheckRateLimit, + mockGate, + mockHandler, + mockLoggerError, + mockLoggerInfo, + requestContextState, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGate: vi.fn(), + mockHandler: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + requestContextState: { + current: undefined as { requestId: string; method?: string; path?: string } | undefined, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: (...arguments_: unknown[]) => + mockLoggerInfo(requestContextState.current?.requestId, ...arguments_), + warn: vi.fn(), + error: (...arguments_: unknown[]) => + mockLoggerError(requestContextState.current?.requestId, ...arguments_), + }), + getRequestContext: () => requestContextState.current, + runWithRequestContext: async ( + context: { requestId: string; method?: string; path?: string }, + callback: () => T | Promise + ): Promise => { + requestContextState.current = context + try { + return await callback() + } finally { + requestContextState.current = undefined + } + }, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => requestContextState.current?.requestId ?? 'outer-request-id', +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockGate, +})) + +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' + +const RATE_LIMIT = { + allowed: true, + limit: 400, + remaining: 399, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + userId: 'user-1', + keyType: 'personal' as const, +} + +const queryContract = defineRouteContract({ + method: 'POST', + path: '/api/test/:itemId', + params: z.object({ itemId: z.string().min(1) }), + query: z.object({ limit: z.coerce.number().int().positive() }), + body: z.object({ name: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/test', + query: z.object({ workspaceId: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const POST = withPublicApiRouteHandler({ + contract: queryContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: 32, + payloadTooLargeResponse: () => + NextResponse.json({ error: 'Custom payload limit response' }, { status: 413 }), + }, + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const FAILING_GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async () => { + throw new Error('handler failed') + }, +}) + +function postRequest(body: string): NextRequest { + return new NextRequest('http://localhost:3000/api/test/item-1?limit=10', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) +} + +function listRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost:3000/api/test?${query}`) +} + +describe('withPublicApiRouteHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGate.mockResolvedValue(null) + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + recordRateLimitSnapshot(request, RATE_LIMIT) + return RATE_LIMIT + }) + }) + + it.each([ + ['authentication failure', 401], + ['rate-limit denial', 429], + ])('short-circuits %s before reading or parsing the body', async (_label, status) => { + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + if (status === 401) { + return { + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + error: 'API key required', + } + } + + recordRateLimitSnapshot(request, RATE_LIMIT) + return { ...RATE_LIMIT, allowed: false, remaining: 0, retryAfterMs: 30_000 } + }) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(status) + expect(request.bodyUsed).toBe(false) + expect(mockHandler).not.toHaveBeenCalled() + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'table-rows') + expect(mockGate).not.toHaveBeenCalled() + if (status === 401) { + expect(response.headers.get('X-RateLimit-Limit')).toBe('0') + } else { + expect(response.headers.get('Retry-After')).toBe('30') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + } + }) + + it('checks the v2 rollout gate before reading or parsing the body', async () => { + mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(404) + expect(request.bodyUsed).toBe(false) + expect(mockGate).toHaveBeenCalledWith('user-1') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('fails fast when an allowed rate-limit result has no user ID', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined }) + + const response = await GET(listRequest()) + + expect(response.status).toBe(500) + expect(mockGate).not.toHaveBeenCalled() + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('returns a contract validation response after authentication', async () => { + const response = await POST(postRequest(JSON.stringify({ name: '' })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('forwards the body-size parse option', async () => { + const response = await POST(postRequest(JSON.stringify({ name: 'x'.repeat(40) })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(413) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('399') + await expect(response.json()).resolves.toEqual({ error: 'Custom payload limit response' }) + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('provides parsed params, query, body, and auth to the handler', async () => { + const request = postRequest(JSON.stringify({ name: 'Ada' })) + const response = await POST(request, { params: Promise.resolve({ itemId: 'item-1' }) }) + + expect(response.status).toBe(200) + expect(mockHandler).toHaveBeenCalledWith({ + request, + input: { + params: { itemId: 'item-1' }, + query: { limit: 10 }, + body: { name: 'Ada' }, + headers: undefined, + }, + auth: { + requestId: 'outer-request-id', + userId: 'user-1', + rateLimit: RATE_LIMIT, + }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Reset')).toBe(RATE_LIMIT.resetAt.toISOString()) + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'outer-request-id', + 'OK', + expect.objectContaining({ status: 200 }) + ) + }) + + it('supports direct invocation without a route context', async () => { + const request = listRequest() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockHandler.mock.calls[0][0].input.query).toEqual({ workspaceId: 'workspace-1' }) + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'tables') + }) + + it('keeps rate-limit and request headers on unhandled endpoint errors', async () => { + const response = await FAILING_GET(listRequest()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockLoggerError).toHaveBeenCalledWith( + 'outer-request-id', + 'Unhandled route error', + expect.objectContaining({ error: 'handler failed' }) + ) + }) +}) diff --git a/apps/sim/app/api/public-api-route-handler.ts b/apps/sim/app/api/public-api-route-handler.ts new file mode 100644 index 00000000000..af25a4fe3b4 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.ts @@ -0,0 +1,79 @@ +import type { NextRequest, NextResponse } from 'next/server' +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type ApiEndpoint, type AuthorizedRequest, checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +interface PublicApiRouteContext { + params?: + | Promise> + | Record +} + +interface PublicApiRouteHandlerArguments { + request: NextRequest + input: ParsedRequest + auth: AuthorizedRequest +} + +interface PublicApiRouteHandlerOptions { + contract: C + rateLimitEndpoint: ApiEndpoint + parseOptions?: ParseRequestOptions + handler: ( + arguments_: PublicApiRouteHandlerArguments + ) => Promise | NextResponse | Response +} + +type PublicApiNextRouteHandler = ( + request: NextRequest, + context?: PublicApiRouteContext +) => Promise + +/** + * Wraps an API-key-authenticated public route with request context, rate + * limiting, authentication, and contract parsing before invoking the route's + * authorization and business logic. Unexpected endpoint errors are logged once + * by the shared route handler and rendered as the canonical v2 500 envelope. + */ +export function withPublicApiRouteHandler({ + contract, + rateLimitEndpoint, + parseOptions, + handler, +}: PublicApiRouteHandlerOptions): PublicApiNextRouteHandler { + const wrapped = withRouteHandler( + async (request, context) => { + const requestId = generateRequestId() + const rateLimit = await checkRateLimit(request, rateLimitEndpoint) + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + const userId = rateLimit.userId + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + ...parseOptions, + }) + if (!parsed.success) return parsed.response + + return handler({ + request, + input: parsed.data, + auth: { requestId, userId, rateLimit }, + }) + }, + { + unhandledErrorResponse: () => v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} 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/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 108e9fc534e..12066f2f9cc 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -38,36 +36,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) - - const log = rows[0] + const log = await getPublicWorkflowLog({ column: 'id', value: id }) if (!log) { return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..e186a4f1558 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,22 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} 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..c2f4e9b6005 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,139 @@ +/** + * @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 no default, so an + // early click saved no workspace when the same click a moment later would + // have saved the user's last active 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 default workspace') + }) + + it('does not present a workspace choice as final while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Loading your workspace options') + 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('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') + }) + + it('issues a personal key even when the approver is a workspace admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: false, + }), + 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..f7865af95da 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 "no default workspace" row; an empty string reads as unselected. */ +const NO_DEFAULT_WORKSPACE_VALUE = '__no_default_workspace__' /** * 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 default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,34 @@ 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 default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click save no default when a + * moment later the same click would have saved the user's workspace. Blocking + * is the only way the card can promise what it is about to configure. + */ + 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) + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {loadingWorkspaces + ? 'Loading your workspace options…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : chosen + ? `Issues a personal key tied to your account and makes ${chosen.name} the CLI default.` + : // 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 is only the terminal's default. Login + // always mints a personal key so the profile can switch workspaces. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: false, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } 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/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index 193a29d8329..6d0671d1867 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -41,6 +41,8 @@ export const BrowserUseBlock: BlockConfig = { id: 'variables', title: 'Variables (Secrets)', type: 'table', + password: true, + required: false, columns: ['Key', 'Value'], }, { diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts index bb31bfe5eab..7611c690036 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -289,6 +289,7 @@ export const CodePipelineBlock: BlockConfig< id: 'approvalToken', title: 'Approval Token', type: 'short-input', + password: true, placeholder: 'Token from Get Pipeline State', condition: { field: 'operation', value: 'put_approval_result' }, required: { field: 'operation', value: 'put_approval_result' }, diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 5fb3d01153b..a9d7d9b93fa 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -446,6 +446,7 @@ export const DiscordBlock: BlockConfig = { id: 'webhookToken', title: 'Webhook Token', type: 'short-input', + password: true, placeholder: 'Enter webhook token', required: true, condition: { diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index d3252b25438..1aeaf91c8d5 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -484,6 +484,7 @@ export const PiBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, paramVisibility: 'user-only', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', required: { diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index d3867e13df0..74fad7758a5 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -138,6 +138,7 @@ export const SecretsManagerBlock: BlockConfig = { id: 'secretValue', title: 'Secret Value', type: 'code', + password: true, placeholder: '{"username":"admin","password":"secret123"}', condition: { field: 'operation', value: ['create_secret', 'update_secret'] }, required: { field: 'operation', value: ['create_secret', 'update_secret'] }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 5c88b787ae5..62181bdaabb 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -100,6 +100,7 @@ export const SftpBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 74d0c570aac..a991a0ea6c8 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -150,6 +150,7 @@ export const SSHBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 6cb1a05048f..e18134b9483 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -130,6 +130,7 @@ export const STSBlock: BlockConfig = { id: 'webIdentityToken', title: 'Web Identity Token', type: 'long-input', + password: true, placeholder: 'OIDC/OAuth 2.0 token from the identity provider', condition: { field: 'operation', value: 'assume_role_with_web_identity' }, required: { field: 'operation', value: 'assume_role_with_web_identity' }, @@ -155,6 +156,7 @@ export const STSBlock: BlockConfig = { id: 'samlAssertion', title: 'SAML Assertion', type: 'long-input', + password: true, placeholder: 'Base64-encoded SAML authentication response', condition: { field: 'operation', value: 'assume_role_with_saml' }, required: { field: 'operation', value: 'assume_role_with_saml' }, @@ -240,6 +242,7 @@ export const STSBlock: BlockConfig = { id: 'tokenCode', title: 'MFA Token Code', type: 'short-input', + password: true, placeholder: '123456', condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, required: false, diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index f29968b3061..c971e41eb53 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -271,6 +271,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'password', title: 'Password', type: 'short-input', + password: true, + required: false, placeholder: 'Meeting password', mode: 'advanced', condition: { diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index f285bf0fe3f..fe7d2e40948 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 @@ -23,11 +35,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/api/contracts/v1/tables/index.test.ts b/apps/sim/lib/api/contracts/v1/tables/index.test.ts new file mode 100644 index 00000000000..78eaca5f422 --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/tables/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, +} from '@/lib/api/contracts/v1/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' + +describe('v1 public table row contracts', () => { + it('never expose private secret provenance', () => { + for (const contract of [ + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 4491b8840be..aa4490889f7 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -18,6 +18,7 @@ import { upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import type { Filter, Sort } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' @@ -61,7 +62,7 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({ * new rows at the tail; ordering by index is an in-app affordance only. */ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema - .omit({ position: true }) + .omit({ position: true, [PRIVATE_SECRET_PROVENANCE_FIELD]: true }) .refine(...rowAnchorMutexRefine) /** @@ -83,6 +84,18 @@ export const v1CreateTableRowsBodySchema = z.union([ v1InsertTableRowBodySchema, ]) +export const v1UpdateRowsByFilterBodySchema = updateRowsByFilterBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpdateTableRowBodySchema = updateTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpsertTableRowBodySchema = upsertTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + export type V1ListTablesQuery = z.output export type V1TableRowsQuery = z.output export type V1InsertTableRowBody = z.output @@ -209,7 +222,7 @@ export const v1UpdateRowsByFilterContract = defineRouteContract({ method: 'PUT', path: '/api/v1/tables/[tableId]/rows', params: tableIdParamsSchema, - body: updateRowsByFilterBodySchema, + body: v1UpdateRowsByFilterBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -242,7 +255,7 @@ export const v1UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v1/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -264,7 +277,7 @@ export const v1UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v1/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, 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/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..5ae6a5509f2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -1,5 +1,5 @@ { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "private": true, "license": "Apache-2.0", diff --git a/bun.lock b/bun.lock index efb1d9c2298..78d53a9db71 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -134,7 +135,7 @@ }, }, "apps/sim": { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "dependencies": { "@1password/sdk": "0.3.1", @@ -585,13 +586,35 @@ "vitest": "^4.1.0", }, }, + "packages/sim-cli": { + "name": "sim", + "version": "2.0.0", + "bin": { + "sim": "dist/index.js", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", + "typescript": "^7.0.2", + "vitest": "^4.1.0", + }, + }, "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", + "dependencies": { + "@sim/utils": "workspace:*", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/testing": { @@ -1725,6 +1748,8 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sim/app": ["@sim/app@workspace:apps/sim"], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], "@sim/auth": ["@sim/auth@workspace:packages/auth"], @@ -1857,7 +1882,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2459,7 +2484,7 @@ "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=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -3399,7 +3424,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -3935,7 +3960,7 @@ "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -4117,7 +4142,7 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "sim": ["sim@workspace:apps/sim"], + "sim": ["sim@workspace:packages/sim-cli"], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -4253,7 +4278,7 @@ "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], - "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -4537,6 +4562,8 @@ "@a2a-js/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4569,6 +4596,8 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4627,6 +4656,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@fumadocs/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4781,7 +4812,7 @@ "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], - "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-email/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "@reactflow/background/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -4807,6 +4838,8 @@ "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -4821,6 +4854,10 @@ "@tailwindcss/postcss/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=="], + "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + + "@tiptap/markdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], "@trigger.dev/core/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], @@ -4883,6 +4920,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/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "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=="], @@ -4923,8 +4962,6 @@ "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -4971,6 +5008,8 @@ "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "docs/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4989,6 +5028,8 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5035,12 +5076,18 @@ "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/@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=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "fumadocs-openapi/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], @@ -5133,8 +5180,6 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -5169,13 +5214,11 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "react-email/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -5195,7 +5238,7 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + "sim/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5209,6 +5252,8 @@ "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -5225,6 +5270,14 @@ "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "tailwindcss/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=="], + + "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -5435,8 +5488,6 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -5523,6 +5574,10 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fumadocs-mdx/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "fumadocs-openapi/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -5615,21 +5670,17 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - - "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/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=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -5679,14 +5730,8 @@ "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - - "sim/tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/package.json b/package.json index 5ecfb3ab44f..332dd51c4bc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", "generate:openapi": "bun run scripts/generate-openapi.ts", "check:openapi": "bun run scripts/check-openapi.ts", + "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", + "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", "check:cron-parity": "bun run scripts/check-cron-parity.ts", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", diff --git a/packages/sim-cli/LICENSE b/packages/sim-cli/LICENSE new file mode 100644 index 00000000000..f4e76aaaac1 --- /dev/null +++ b/packages/sim-cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sim Studio, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md new file mode 100644 index 00000000000..979c2b77142 --- /dev/null +++ b/packages/sim-cli/README.md @@ -0,0 +1,331 @@ +# Sim CLI + +Talk to the [Sim](https://sim.ai) API from your terminal. + +```bash +npm install --global sim +sim login +sim workflows list +``` + +Prerelease channels track the corresponding Sim environments: + +```bash +npm install --global sim@staging # staging +npm install --global sim@dev # dev +``` + +## Profiles + +Profiles work like the AWS CLI: one identity and one set of defaults per named +profile, selected with `-P`, `--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`, `--output`) | +| 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 + Personal key, defaulting to ws_local. Override per command with --workspace. +``` + +The approval page is where you pick the workspace — the terminal has no key yet, +so it cannot list them for you. `sim login` issues a personal key, and whichever +workspace you pick becomes only the profile's default `workspace`; it does not +limit the key to that workspace. Use `--workspace` to target another workspace +the key can access. + +`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 + +Plural resource names are canonical, but every plural top-level resource group +also accepts its singular form: for example, `sim table list`, +`sim file get`, and `sim workflow get` are equivalent to their plural +spellings. + +`knowledge` also accepts the shorter `kb` alias. + +```bash +sim workflows ls [path] [--search ] [--limit ] +sim workflows list [--folder ] [--deployed-only] [--limit ] +sim workflows get +sim workflows update [--name ] [--description ] [--folder ] +sim workflows mv +sim workflows deploy|undeploy|rollback +sim workflows run [--input ] [--select-output …] [--async] +sim workflows runs list --workflow [--status ] +sim workflows runs get --workflow [--include-output] +sim workflows runs cancel --workflow +sim workflows runs resume --workflow --context [--input ] + +sim logs list [--level error] [--workflow …] [--trigger …] [--start-date ] +sim logs get + +sim audit-logs list --organization [--all-workspaces] +sim audit-logs get --organization + +sim workspaces get +sim workspaces members + +sim tables ls [path] [--search ] [--limit ] +sim tables list [--folder ] +sim tables get +sim tables update [--name ] [--description ] [--folder ] +sim tables mv +sim tables columns +sim tables rows list [--limit ] +sim tables rows create --data +sim tables rows create --rows +sim tables rows query [--filter ] [--sort ] [--limit ] +sim tables rows query --filter '{"all":[{"field":"status","op":"eq","value":"active"}]}' +sim tables upsert --data +sim tables rows batch-delete (--row … | --filter ) --yes + +sim files ls [path] [--search ] [--limit ] +sim files list [--folder ] +sim files describe +sim files get [-o ] # stdout by default +sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] +sim files share get +sim files share set --is-active [--auth-type public|password|email|sso] +sim files mv --file-ids … [--to ] +sim files batch-delete --file-ids … --yes +sim files delete --yes + +sim knowledge ls [path] [--search ] [--limit ] +sim knowledge list [--folder ] +sim knowledge get +sim knowledge update [--name ] [--description ] [--folder ] +sim knowledge mv +sim knowledge search --query --kb … [--search-mode vector|hybrid] + +sim knowledge documents list [--search ] +sim knowledge documents get +sim knowledge documents upload [--tag ...] +sim knowledge documents delete --yes + +sim billing status [--all-workspaces] +sim billing logs [--period 7d] [--source sim-chat] [--limit ] [--all-workspaces] +``` + +The `sim-chat` billing source combines Copilot and workspace chat usage. +Organization audit logs require a personal API key. Commands with +`--all-workspaces` otherwise default to the workspace in the active profile. + +`workflows runs get` is the lightweight status and polling resource. +`--workflow` names the parent resource, while the run ID remains positional. +For a paused run, its status includes the context ID needed by `resume`. +`logs get` is the full diagnostic resource. It keeps the default human output +concise; add `--trace` for the expanded recursive trace with span inputs, +outputs, errors, timing, and cost. JSON and YAML retain the complete structured +response. + +`sim logs get` keeps the default human output concise. Use JSON or YAML to +inspect its complete `executionData` and recursive `traceSpans` tree: + +```bash +sim logs get --trace +sim logs get --output json | jq '.traceSpans' +sim logs list --include-trace-spans --output json +``` + +Workflow output selectors use `blockName.field` syntax, such as +`--select-output agent_1.content`; fields that are not produced are omitted. + +`ls` is a directory view: it combines the resources at its optional path with +that folder's direct child folders. It never includes deeper descendants. Its +`ref` column is the resource ID or canonical folder path to pass to the next +command. Use `list` when you want resources only, or `folders ls` when you want +folders only. + +Each folder-backed resource has the same path commands: + +```bash +sim tables ls Reports +sim tables folders ls --parent Reports +sim tables mkdir Reports/Quarterly +sim tables folders create Reports/Quarterly +sim tables folders mv Reports/Quarterly Archive/Quarterly +sim tables folders delete Archive/Quarterly --yes +sim tables folders delete Archive --recursive --yes +``` + +`mkdir` is the concise form of `folders create`. Replace `tables` with `files`, +`workflows`, or `knowledge`. The leading `/` is optional on API inputs; the API +returns the canonical leading-slash form. Omit the `ls` path to list root. + +### List inputs + +Primitive lists take space-separated values. Prefix a path with `@` to read +one value per line, or use `@-` to read the list from stdin. + +```bash +sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids @file-ids.txt --to Archive +printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive +``` + +Arrays of objects remain JSON inputs because they cannot be represented as a +flat list without losing structure. + +### 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 query 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 can be selected per command with `--output`, saved as a profile +default with `sim configure --set-output `, or set ambiently with +`SIM_OUTPUT` 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 logs list --level error | jq -r '.[].runId' +sim logs list --level error --output json | jq -r '.[].runId' +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. + +An invalid active `SIM_OUTPUT` or `output =` value fails with the accepted +formats. A valid higher-priority `--output` still overrides a stale lower tier, +so `sim --output table configure --set-output json` can repair a profile. + +## 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/THIRD_PARTY_LICENSES b/packages/sim-cli/THIRD_PARTY_LICENSES new file mode 100644 index 00000000000..f8ff0105dc1 --- /dev/null +++ b/packages/sim-cli/THIRD_PARTY_LICENSES @@ -0,0 +1,30 @@ +The Sim CLI bundle includes the following third-party software. + +chalk +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +commander +Copyright (c) 2011 TJ Holowaychuk + +js-yaml +Copyright (C) 2011-2015 by Vitaly Puzrin + +Each dependency above is licensed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json new file mode 100644 index 00000000000..432ee0f3fb3 --- /dev/null +++ b/packages/sim-cli/package.json @@ -0,0 +1,60 @@ +{ + "name": "sim", + "version": "2.0.0", + "description": "Sim CLI - talk to the Sim API from your terminal", + "type": "module", + "bin": { + "sim": "dist/index.js" + }, + "scripts": { + "prebuild": "bun run clean", + "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js", + "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", + "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", + "THIRD_PARTY_LICENSES" + ], + "keywords": [ + "sim", + "ai", + "agents", + "cli", + "workflow" + ], + "author": "Sim", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/simstudioai/sim.git", + "directory": "packages/sim-cli" + }, + "homepage": "https://github.com/simstudioai/sim/tree/main/packages/sim-cli#readme", + "bugs": { + "url": "https://github.com/simstudioai/sim/issues" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", + "typescript": "^7.0.2", + "vitest": "^4.1.0" + } +} 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..4b2747c53ce --- /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' + +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..198f3610c30 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -0,0 +1,175 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto' +import { sleep } from '../helpers' +import { SimApiError } from '../http/client' + +/** + * 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 sleep(POLL_INTERVAL_MS) + } + + throw new SimApiError('Timed out waiting for browser approval.', 0) +} diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts new file mode 100644 index 00000000000..a75fe6174ee --- /dev/null +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -0,0 +1,260 @@ +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + buildApprovalUrl: vi.fn(() => 'https://sim.ai/cli/auth?code=ABCD'), + createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })), + createInterface: vi.fn(), + listProfiles: vi.fn<() => string[]>(() => []), + readCredentialsProfile: vi.fn<() => Record>(() => ({})), + pollForKey: vi.fn(async () => ({ + apiKey: 'sim-key', + scope: 'platform' as const, + workspaceBound: false, + workspaceId: 'ws_1' as string | undefined, + })), + profileFrom: vi.fn(() => ({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null as string | null, + workspaceId: null as string | null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + })), + writeConfigProfile: vi.fn(), + writeCredentialsProfile: vi.fn(), +})) + +vi.mock('node:readline/promises', () => ({ createInterface: mocks.createInterface })) +vi.mock('../auth/device-flow', () => ({ + buildApprovalUrl: mocks.buildApprovalUrl, + createAuthRequest: mocks.createAuthRequest, + pollForKey: mocks.pollForKey, +})) +vi.mock('../config/index', () => ({ + credentialsPath: () => '/tmp/sim-credentials', + deleteProfile: vi.fn(), + listProfiles: mocks.listProfiles, + readCredentialsProfile: mocks.readCredentialsProfile, + writeConfigProfile: mocks.writeConfigProfile, + writeCredentialsProfile: mocks.writeCredentialsProfile, +})) +vi.mock('../context', () => ({ profileFrom: mocks.profileFrom })) + +import { loginCommand, profilesCommand, whoamiCommand } from './auth' + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + +function setInteractive(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value }) +} + +async function login(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(loginCommand()) + await root.parseAsync(['node', 'sim', 'login', '--no-browser', ...args]) +} + +async function whoami(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(whoamiCommand()) + await root.parseAsync(['node', 'sim', 'whoami', ...args]) +} + +describe('login command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listProfiles.mockReturnValue([]) + mocks.readCredentialsProfile.mockReturnValue({}) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'yes'), + close: vi.fn(), + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY) + else Reflect.deleteProperty(process.stdin, 'isTTY') + }) + + it('does not prompt when the profile is new', async () => { + setInteractive(false) + await login() + + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('requires --yes before overwriting non-interactively', async () => { + setInteractive(false) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + + await expect(login()).rejects.toThrow( + 'Profile "default" already exists. Re-run with --yes to overwrite it.' + ) + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + + await login('--yes') + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('continues only when an interactive overwrite is confirmed', async () => { + setInteractive(true) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + const question = vi.fn(async () => 'yes') + const close = vi.fn() + mocks.createInterface.mockReturnValue({ question, close }) + + await login() + + expect(question).toHaveBeenCalledWith( + 'Profile "default" already exists. Replace its API key and login defaults? (y/N) ' + ) + expect(close).toHaveBeenCalledOnce() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('leaves the profile unchanged when confirmation is declined', async () => { + setInteractive(true) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'no'), + close: vi.fn(), + }) + + await login() + + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('does not prompt for a config-only or logged-out profile', async () => { + setInteractive(false) + mocks.listProfiles.mockReturnValue(['default']) + mocks.readCredentialsProfile.mockReturnValue({}) + + await login() + + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('clears a stale workspace default when none is selected during login', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_old', + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: undefined, + }) + + await login() + + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('default', { + endpoint: 'https://sim.ai', + workspace: null, + }) + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no default workspace')) + }) +}) + +describe('profiles command', () => { + it('accepts the singular profile alias', () => { + expect(profilesCommand().alias()).toBe('profile') + }) +}) + +describe('whoami command', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('reports authentication without exposing any part of the API key', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'text', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('API key\tconfigured (credentials)') + expect(output).not.toContain('sim_super_secret_value') + expect(output).not.toContain('secret') + }) + + it('uses non-secret-shaped authentication metadata in machine output', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = String(vi.mocked(console.log).mock.calls[0][0]) + expect(JSON.parse(output)).toMatchObject({ + authenticated: true, + sources: { authentication: 'credentials' }, + }) + expect(output).not.toContain('apiKey') + expect(output).not.toContain('sim_super_secret_value') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts new file mode 100644 index 00000000000..242683ca644 --- /dev/null +++ b/packages/sim-cli/src/commands/auth.ts @@ -0,0 +1,266 @@ +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline/promises' +import chalk from 'chalk' +import { Command } from 'commander' +import { + buildApprovalUrl, + type CliAuthScope, + createAuthRequest, + pollForKey, +} from '../auth/device-flow' +import { + credentialsPath, + deleteProfile, + listProfiles, + readCredentialsProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' +import { printRecord } from '../output/render' + +/** + * 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 presentAuthentication(source: SettingSource): { + authenticated: boolean + source: SettingSource +} { + switch (source) { + case 'flag': + return { authenticated: true, source: 'flag' } + case 'env': + return { authenticated: true, source: 'env' } + case 'credentials': + return { authenticated: true, source: 'credentials' } + case 'unset': + return { authenticated: false, source: 'unset' } + case 'config': + case 'default': + throw new SimApiError(`Unexpected API key source "${source}".`, 0) + } +} + +async function confirmProfileOverwrite(profileName: string): Promise { + if (!process.stdin.isTTY) { + throw new SimApiError( + `Profile "${profileName}" already exists. Re-run with --yes to overwrite it.`, + 0 + ) + } + + const prompt = createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = await prompt.question( + `Profile "${profileName}" already exists. Replace its API key and login defaults? (y/N) ` + ) + return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes' + } finally { + prompt.close() + } +} + +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') + .option('-y, --yes', 'Overwrite an existing profile without prompting') + .action( + async (options: { scope: string; browser: boolean; yes?: 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 + + if (readCredentialsProfile(profile.name).api_key && !options.yes) { + const confirmed = await confirmProfileOverwrite(profile.name) + if (!confirmed) { + console.log(chalk.dim('Login cancelled; the existing profile was not changed.')) + return + } + } + + 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, + workspace: key.workspaceId ?? null, + } + 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 { + 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 authentication = presentAuthentication(sources.apiKey) + + 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', + authentication.authenticated + ? annotate('configured', authentication.source) + : 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: authentication.authenticated, + sources: { + endpoint: sources.endpoint, + authentication: authentication.source, + workspaceId: sources.workspaceId, + output: sources.output, + }, + } + ) + }) +} + +export function profilesCommand(): Command { + return new Command('profiles') + .alias('profile') + .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..88879206b55 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' + +/** + * 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/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts new file mode 100644 index 00000000000..842e8022f58 --- /dev/null +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -0,0 +1,241 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../runtime/build' +import { attachCredentialCommands } from './credentials' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'table' }, +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => 'ws_local', + }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'key', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachCredentialCommands(root) + return root +} + +function commandAt(...names: string[]): Command { + let current = program() + for (const name of names) { + const next = current.commands.find((command) => command.name() === name) + if (!next) throw new Error(`Missing command ${names.join(' ')}`) + current = next + } + return current +} + +describe('credential connection commands', () => { + beforeEach(() => { + vi.restoreAllMocks() + output.format = 'table' + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('discovers and validates a service-account provider before creating it', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ + data: [ + { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/zoom', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Account ID', + required: true, + secret: false, + multiline: false, + }, + ], + }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + data: { + id: 'cred_123', + type: 'service_account', + displayName: 'Production Zoom', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account"}', + ]) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/v2/credentials', { + method: 'POST', + body: { + workspaceId: 'ws_local', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client', + clientSecret: 'secret', + orgId: 'account', + }, + }) + }) + + it('exposes one provider-shaped credential object instead of every provider secret', () => { + const help = commandAt('credentials', 'create').helpInformation() + + expect(help).toContain('') + expect(help).toContain('--credentials ') + expect(help).not.toContain('--type') + expect(help).not.toContain('--client-secret') + expect(help).not.toContain('--service-account-json') + }) + + it('rejects missing and unsupported provider fields before creation', async () => { + mockRequest.mockReset().mockResolvedValue({ + data: [ + { + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [ + { id: 'clientId', required: true }, + { id: 'clientSecret', required: true }, + { id: 'orgId', required: true }, + ], + }, + ], + nextCursor: null, + }) + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret"}', + ]) + ).rejects.toThrow('missing required fields for zoom-service-account: orgId') + expect(mockRequest).toHaveBeenCalledTimes(1) + + mockRequest.mockClear() + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account","extra":"no"}', + ]) + ).rejects.toThrow('unsupported field "extra" for zoom-service-account') + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + + it('creates and prints a new-provider connection link', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'connect', + 'google-email', + '--name', + 'Work Gmail', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/credentials/connections', { + method: 'POST', + body: { + workspaceId: 'ws_local', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1' + ) + }) + + it('creates a reconnect link for an existing credential', async () => { + output.format = 'json' + await program().parseAsync(['node', 'sim', 'credentials', 'reconnect', 'cred_1']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/credentials/connections', { + method: 'POST', + body: { workspaceId: 'ws_local', credentialId: 'cred_1' }, + }) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain('authorizationUrl') + }) +}) diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts new file mode 100644 index 00000000000..39b7684f978 --- /dev/null +++ b/packages/sim-cli/src/commands/credentials.ts @@ -0,0 +1,195 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import { + type CreateCredentialConnectionResponse, + type CreateServiceAccountCredentialResponse, + type ListCredentialProvidersResponse, + V2_OPERATIONS, +} from '../generated/v2-api' +import { SimApiError } from '../http/client' +import { coerce } from '../runtime/request' +import { renderResult } from '../runtime/result' + +const CONNECTION_RESULT: CommandSpec = { + fields: [ + { header: 'connection link', path: 'authorizationUrl' }, + { header: 'expires', path: 'expiresAt', format: 'timestamp' }, + ], +} + +const SERVICE_ACCOUNT_RESULT: CommandSpec = { + fields: [ + { header: 'id' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, + { header: 'role' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + ], +} + +type ConnectionBody = { providerId: string; displayName: string } | { credentialId: string } +type CredentialProvider = ListCredentialProvidersResponse['data'][number] +type ServiceAccountProvider = Extract + +interface CreateServiceAccountOptions { + credentials: string + description?: string + id?: string + name: string +} + +function serviceAccountProvider( + providers: CredentialProvider[], + providerId: string +): ServiceAccountProvider { + const provider = providers.find( + (candidate): candidate is ServiceAccountProvider => + candidate.type === 'service_account' && candidate.providerId === providerId + ) + if (!provider) { + throw new SimApiError(`Unknown service-account provider "${providerId}".`, 0) + } + if (!provider.available) { + throw new SimApiError(`Service-account provider "${providerId}" is not available.`, 0) + } + return provider +} + +function credentialValues(provider: ServiceAccountProvider, raw: string): Record { + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'credentials') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--credentials must be a JSON object', 0) + } + + const values = parsed as Record + const fields = new Map(provider.fields.map((field) => [field.id, field])) + for (const [id, value] of Object.entries(values)) { + const field = fields.get(id) + if (!field) { + throw new SimApiError( + `--credentials contains unsupported field "${id}" for ${provider.providerId}.`, + 0 + ) + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new SimApiError(`--credentials.${id} must be a non-empty string.`, 0) + } + if (field.options && !field.options.some((option) => option.value === value)) { + throw new SimApiError( + `--credentials.${id} must be one of: ${field.options.map((option) => option.value).join(', ')}.`, + 0 + ) + } + } + + const authMethod = typeof values.authMethod === 'string' ? values.authMethod : undefined + const missing = provider.fields + .filter( + (field) => + field.required || + (authMethod !== undefined && field.requiredForAuthMethods?.includes(authMethod)) + ) + .filter((field) => values[field.id] === undefined) + .map((field) => field.id) + if (missing.length > 0) { + throw new SimApiError( + `--credentials is missing required fields for ${provider.providerId}: ${missing.join(', ')}.`, + 0 + ) + } + + return values as Record +} + +async function createServiceAccount( + command: Command, + providerId: string, + options: CreateServiceAccountOptions +): Promise { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const discovery = V2_OPERATIONS.listCredentialProviders + const catalog = await client.request(discovery.path, { + method: discovery.method, + query: { workspaceId }, + }) + const provider = serviceAccountProvider(catalog.data, providerId) + if (provider.requiresClientGeneratedCredentialId && !options.id) { + throw new SimApiError(`--id is required for ${providerId}.`, 0) + } + + const credentials = credentialValues(provider, options.credentials) + const operation = V2_OPERATIONS.createServiceAccountCredential + const response = await client.request(operation.path, { + method: operation.method, + body: { + workspaceId, + type: 'service_account', + providerId, + displayName: options.name, + ...(options.description ? { description: options.description } : {}), + ...(options.id ? { id: options.id } : {}), + ...credentials, + }, + }) + + renderResult( + 'createServiceAccountCredential', + profile.output, + response.data, + SERVICE_ACCOUNT_RESULT + ) +} + +async function createConnectionLink(command: Command, body: ConnectionBody): Promise { + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.createCredentialConnection + const response = await client.request(operation.path, { + method: operation.method, + body: { + workspaceId: client.requireWorkspace(), + ...body, + }, + }) + + renderResult('createCredentialConnection', profile.output, response.data, CONNECTION_RESULT) +} + +/** Adds the human-facing OAuth connection commands backed by the v2 credentials API. */ +export function attachCredentialCommands(program: Command): void { + const credentials = program.commands.find((command) => command.name() === 'credentials') + if (!credentials) throw new Error('The generated credentials command group is missing') + + credentials + .command('create ') + .description('Create a service-account credential using its discovered provider schema') + .requiredOption('--name ', 'Name shown for the credential in Sim') + .requiredOption( + '--credentials ', + 'Provider credentials as JSON (or @path / @- to read a file or stdin)' + ) + .option('--description ', 'Optional credential description') + .option( + '--id ', + 'Client-generated credential ID when provider discovery requires it' + ) + .action((providerId: string, options: CreateServiceAccountOptions, command: Command) => + createServiceAccount(command, providerId, options) + ) + + credentials + .command('connect ') + .description('Create a short-lived link for connecting an OAuth provider') + .requiredOption('--name ', 'Name shown for the new credential in Sim') + .action(async (providerId: string, options: { name: string }, command: Command) => + createConnectionLink(command, { providerId, displayName: options.name }) + ) + + credentials + .command('reconnect ') + .description('Create a short-lived link for reconnecting an OAuth credential') + .action((credentialId: string, _options: unknown, command: Command) => + createConnectionLink(command, { credentialId }) + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts new file mode 100644 index 00000000000..fe948283df8 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -0,0 +1,274 @@ +import { + createWriteStream, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get' +import { attachProtocolCommands } from './index' + +const { output, requestRaw } = vi.hoisted(() => ({ + output: { format: 'json' }, + requestRaw: vi.fn(), +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { requestRaw, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) + output.format = 'json' + requestRaw.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + 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() + }, + }) +} + +function failingBody(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')) + controller.error(new Error('connection lost')) + }, + }) +} + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +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') + 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 () => { + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) + + it('cancels the response body and waits for the pump when writing fails', async () => { + const cancelled = vi.fn() + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('first chunk')) + }, + cancel: cancelled, + }) + const target = join(dir, 'out.txt') + const destination = Object.assign( + new Writable({ + write(_chunk, _encoding, callback) { + const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }) + callback(error) + }, + }), + { path: target } + ) + + await expect(streamToFile(body, destination)).rejects.toThrow( + `Could not write ${target}: disk full` + ) + expect(cancelled).toHaveBeenCalledOnce() + }) +}) + +describe('saveToFile', () => { + it('preserves the original destination when a forced download fails', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(failingBody(), target, true)).rejects.toThrow(/connection lost/) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + + it('leaves no partial destination when a new download fails', async () => { + const target = join(dir, 'out.txt') + + await expect(saveToFile(failingBody(), target, false)).rejects.toThrow(/connection lost/) + + expect(existsSync(target)).toBe(false) + }) + + it('preserves an existing destination without --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(bodyOf(['new']), target, false)).rejects.toThrow( + /already exists.*--force/s + ) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + + it('publishes a completed forced download over the original', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + + await saveToFile(bodyOf(['new']), target, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + }) + + it('preserves a forced symlink destination and replaces its target', async () => { + const target = join(dir, 'target.txt') + const link = join(dir, 'link.txt') + writeFileSync(target, 'old') + symlinkSync(target, link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) + + it('preserves a dangling forced symlink and creates its target', async () => { + const target = join(dir, 'missing.txt') + const link = join(dir, 'link.txt') + symlinkSync('missing.txt', link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) +}) + +describe('isTerminalSafeContentType', () => { + it('accepts text formats and rejects binary or unknown formats', () => { + expect(isTerminalSafeContentType('text/markdown; charset=utf-8')).toBe(true) + expect(isTerminalSafeContentType('application/problem+json')).toBe(true) + expect(isTerminalSafeContentType('application/pdf')).toBe(false) + expect(isTerminalSafeContentType(null)).toBe(false) + }) +}) + +describe('files get', () => { + it('prints a normalized machine-readable result', async () => { + const target = join(dir, 'download.txt') + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '--output-file', target]) + + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + path: target, + status: 'saved', + }) + expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + }) + + it('streams raw bytes to stdout by default', async () => { + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) + const chunks: Uint8Array[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + return true + }) + const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1']) + + expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') + expect(logged).not.toHaveBeenCalled() + }) + + it.each([ + ['without an output path', ['--force']], + ['with the stdout alias', ['-o', '-', '--force']], + ])('rejects --force %s', async (_label, args) => { + await expect( + program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', ...args]) + ).rejects.toThrow(/--force requires --output-file /) + expect(requestRaw).not.toHaveBeenCalled() + }) + + it('refuses binary content when stdout is an interactive terminal', async () => { + requestRaw.mockResolvedValue( + new Response(new Uint8Array([0, 1, 2]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + ) + const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + + try { + await expect(program().parseAsync(['node', 'sim', 'file', 'get', 'file_1'])).rejects.toThrow( + /Refusing to write application\/octet-stream.*--output-file/s + ) + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalDescriptor) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + } + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts new file mode 100644 index 00000000000..ae325c09816 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -0,0 +1,227 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { Readable, type Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { resolvePath, SimApiError } from '../../http/client' +import { printProtocolResult } from './result' + +function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + return new SimApiError( + `${path} already exists. Pass --force to overwrite it, or choose another output path.`, + 0 + ) + } + return new SimApiError(`Could not write ${path}: ${(error as Error).message}`, 0) +} + +async function forcedPublicationTarget(target: string): Promise { + let candidate = target + const visited = new Set() + + while (true) { + const absoluteCandidate = resolve(candidate) + if (visited.has(absoluteCandidate)) { + throw Object.assign(new Error(`Symbolic link loop at ${target}`), { code: 'ELOOP' }) + } + visited.add(absoluteCandidate) + + let metadata + try { + metadata = await lstat(candidate) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return candidate + throw error + } + + if (!metadata.isSymbolicLink()) return candidate + candidate = resolve(dirname(candidate), await readlink(candidate)) + } +} + +function normalizedWriteFailure(target: string, error: unknown): SimApiError { + return error instanceof SimApiError ? error : writeFailure(target, error) +} + +function combinedCleanupFailure( + failure: SimApiError, + temporaryPath: string, + cleanupError: unknown +): SimApiError { + return new SimApiError( + `${failure.message} Cleanup also failed for ${temporaryPath}: ${(cleanupError as Error).message}`, + 0 + ) +} + +function unsupportedAtomicPublish(target: string, error: unknown): SimApiError | null { + const code = (error as NodeJS.ErrnoException).code + if (!['ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(code ?? '')) return null + return new SimApiError( + `Could not publish ${target} without overwrite protection because this filesystem does not support atomic hard links. Re-run with --force to publish the completed download with an atomic rename.`, + 0 + ) +} + +/** Streams a fetch body to disk while honoring write-stream backpressure. */ +export async function streamToFile( + body: ReadableStream, + file: Writable & Pick, + reportedPath: WriteStream['path'] = file.path +): Promise { + try { + await pipeline(Readable.fromWeb(body as Parameters[0]), file) + } catch (error) { + throw writeFailure(reportedPath, error) + } +} + +async function saveStagedFile( + body: ReadableStream, + target: string, + force: boolean +): Promise { + let temporaryDirectory: string | null = null + let failure: SimApiError | null = null + + try { + const publicationTarget = force ? await forcedPublicationTarget(target) : target + temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) + const temporaryPath = join(temporaryDirectory, 'payload') + await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) + if (force) { + await rename(temporaryPath, publicationTarget) + } else { + try { + await link(temporaryPath, publicationTarget) + } catch (error) { + throw unsupportedAtomicPublish(target, error) ?? error + } + } + } catch (error) { + failure = normalizedWriteFailure(target, error) + } + + if (temporaryDirectory) { + try { + await rm(temporaryDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) + throw new SimApiError( + `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, + 0 + ) + } + } + + if (failure) throw failure +} + +/** Publishes a complete staged body atomically, with overwrite requiring explicit force. */ +export async function saveToFile( + body: ReadableStream, + target: string, + force: boolean +): Promise { + return saveStagedFile(body, target, force) +} + +/** Streams a fetch body to stdout without closing the process-wide stream. */ +export async function streamToStdout( + body: ReadableStream, + output: NodeJS.WriteStream = process.stdout +): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + if (!output.write(value)) await once(output, 'drain') + } + } finally { + reader.releaseLock() + } +} + +/** Returns whether content can be written directly to an interactive terminal. */ +export function isTerminalSafeContentType(contentType: string | null): boolean { + if (!contentType) return false + + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase() + return ( + mediaType.startsWith('text/') || + mediaType.endsWith('+json') || + mediaType.endsWith('+xml') || + [ + 'application/graphql', + 'application/javascript', + 'application/json', + 'application/sql', + 'application/x-javascript', + 'application/x-yaml', + 'application/xml', + 'application/yaml', + 'image/svg+xml', + ].includes(mediaType) + ) +} + +export function attachFileGet(files: Command): void { + files + .command('get ') + .description('Get a file’s content') + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const writesToStdout = options.outputFile === undefined || options.outputFile === '-' + if (writesToStdout && options.force) { + throw new SimApiError('--force requires --output-file ', 0) + } + + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const operation = V2_OPERATIONS.downloadFile + const response = await client.requestRaw(resolvePath(operation.path, { fileId }), { + method: operation.method, + query: { workspaceId }, + }) + if (!response.body) { + throw new SimApiError('File content response was empty.', response.status) + } + + if (options.outputFile === undefined || options.outputFile === '-') { + const contentType = response.headers.get('content-type') + if (process.stdout.isTTY && !isTerminalSafeContentType(contentType)) { + await response.body.cancel() + throw new SimApiError( + `Refusing to write ${contentType ?? 'unknown content'} to an interactive terminal. Use --output-file or pipe stdout.`, + 0 + ) + } + + await streamToStdout(response.body) + return + } + + const target = options.outputFile + + await saveToFile(response.body, target, Boolean(options.force)) + printProtocolResult(profile.output, { + id: fileId, + path: target, + status: 'saved', + }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts new file mode 100644 index 00000000000..c56ef989fd8 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -0,0 +1,142 @@ +import { 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 { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-file-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +describe('files upload', () => { + it('uses a signed PUT transfer and completes without a request body', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + mockRequest + .mockResolvedValueOnce({ + data: { + session: { + id: 'upload_1', + status: 'uploading', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: null, + }, + uploadToken: 'secret-token', + transfer: { + method: 'put', + url: 'https://storage.example/file', + headers: { 'content-type': 'text/plain' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + id: 'upload_1', + status: 'completed', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: { + id: 'file_1', + name: 'notes.txt', + size: 5, + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderPath: '/', + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', 'Reports']) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://storage.example/file', + expect.objectContaining({ + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: expect.any(Blob), + }) + ) + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/files/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + folderPath: 'Reports', + }, + }, + ]) + expect(mockRequest.mock.calls[1]).toEqual([ + '/api/v2/files/uploads/upload_1/complete', + { + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + }, + ]) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + name: 'notes.txt', + size: 5, + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderPath: '/', + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', + }) + expect(logged[0]).not.toContain('secret-token') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts new file mode 100644 index 00000000000..99dfe3c9418 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -0,0 +1,51 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +export function attachFileUpload(files: Command): void { + files + .command('upload ') + .description('Upload a file to the workspace') + .option('--folder ', 'Destination folder path (defaults to /)') + .option('--name ', 'Store it under a different name') + .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + + const created = await client.request( + V2_OPERATIONS.createFileUpload.path, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, + size, + }, + path + ) + + if (!completed.file) { + throw new Error(`File upload ${session.id} completed without a file`) + } + printProtocolResult(profile.output, completed.file) + }) +} diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts new file mode 100644 index 00000000000..8be3088630c --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander' +import { attachFileGet } from './files-get' +import { attachFileUpload } from './files-upload' +import { attachKnowledgeDocumentUpload } from './knowledge-document-upload' +import { attachResourceDirectoryCommands } from './resource-directory' +import { attachTableImport } from './tables-import' + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = new Command(name) + program.addCommand(created) + return created +} + +/** Attaches commands whose multi-request or binary protocols cannot be generated. */ +export function attachProtocolCommands(program: Command): void { + const files = group(program, 'files') + attachFileUpload(files) + attachFileGet(files) + attachResourceDirectoryCommands(files, { + kind: 'file', + resources: 'listFiles', + folders: 'listFileFolders', + createFolder: 'createFileFolder', + }) + + const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) + attachResourceDirectoryCommands(knowledge, { + kind: 'knowledge', + resources: 'listKnowledgeBases', + folders: 'listKnowledgeFolders', + createFolder: 'createKnowledgeFolder', + }) + + const tables = group(program, 'tables') + attachTableImport(tables) + attachResourceDirectoryCommands(tables, { + kind: 'table', + resources: 'listTables', + folders: 'listTableFolders', + createFolder: 'createTableFolder', + }) + + attachResourceDirectoryCommands(group(program, 'workflows'), { + kind: 'workflow', + resources: 'listWorkflows', + folders: 'listWorkflowFolders', + createFolder: 'createWorkflowFolder', + }) +} diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts new file mode 100644 index 00000000000..c7baf48d5bf --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -0,0 +1,225 @@ +import { 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 { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-kb-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +function uploadSession() { + return { + id: 'upload_1', + knowledgeBaseId: 'kb_1', + status: 'uploading', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + document: null, + } +} + +describe('knowledge documents upload', () => { + it('owns the multipart protocol while hiding its low-level operations', () => { + const root = program() + const knowledge = root.commands.find((command) => command.name() === 'knowledge') + expect(root.commands.map((command) => command.name())).not.toContain('documents') + + const documents = knowledge?.commands.find((command) => command.name() === 'documents') + expect(documents?.commands.map((command) => command.name())).toContain('upload') + expect(documents?.commands.map((command) => command.name())).not.toEqual( + expect.arrayContaining(['uploads', 'parts', 'complete']) + ) + expect( + documents?.commands.find((command) => command.name() === 'upload')?.helpInformation() + ).toContain(' ') + }) + + it('uploads a local document and prints the created document without transfer secrets', async () => { + const path = join(dir, 'notes.doc') + writeFileSync(path, 'hello') + const session = uploadSession() + mockRequest + .mockResolvedValueOnce({ + data: { + session, + uploadToken: 'secret-token', + transfer: { method: 'multipart', partSize: 10, partCount: 1 }, + }, + }) + .mockResolvedValueOnce({ + data: { + parts: [ + { + partNumber: 1, + url: 'https://storage.example/part', + headers: { 'content-type': 'application/octet-stream' }, + expiresAt: '2026-08-04T20:00:00.000Z', + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: { + ...session, + status: 'completed', + document: { + id: 'doc_1', + knowledgeBaseId: 'kb_1', + filename: 'notes.doc', + fileSize: 5, + mimeType: 'application/msword', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(null, { + status: 200, + headers: { etag: '"etag-1"' }, + }) + ) + ) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'kb', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + 'customer', + 'priority', + '--recipe', + 'default', + '--lang', + 'en', + ]) + + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/knowledge/kb_1/documents/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + tag1: 'customer', + tag2: 'priority', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + }, + ]) + expect(mockRequest.mock.calls[1][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/parts' + ) + expect(mockRequest.mock.calls[2][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' + ) + expect(mockRequest.mock.calls[2][1]).toEqual({ + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + }) + expect(JSON.parse(logged[0])).toEqual({ + id: 'doc_1', + knowledgeBaseId: 'kb_1', + name: 'notes.doc', + size: 5, + status: 'pending', + }) + expect(logged[0]).not.toContain('secret-token') + }) + + it('rejects more tags than the protocol supports before making a request', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'kb', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]) + ).rejects.toThrow(/at most seven/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('requires the knowledge-base argument before reading the file', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync(['node', 'sim', 'kb', 'documents', 'upload']) + ).rejects.toThrow(/missing required argument 'knowledgeBaseId'/) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts new file mode 100644 index 00000000000..f268a0e5a25 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -0,0 +1,98 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import type { + CompleteKnowledgeDocumentUploadResponse, + CreateKnowledgeDocumentUploadResponse, +} from '../../generated/v2-api' +import { SimApiError } from '../../http/client' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +interface KnowledgeDocumentUploadOptions { + name?: string + tag?: string[] + recipe?: string + lang?: string +} + +function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record { + if (options.tag && options.tag.length > 7) { + throw new SimApiError('--tag accepts at most seven values', 0) + } + + const metadata: Record = {} + options.tag?.forEach((value, index) => { + metadata[`tag${index + 1}`] = value + }) + + if (options.recipe || options.lang) { + metadata.processingOptions = { + ...(options.recipe ? { recipe: options.recipe } : {}), + ...(options.lang ? { lang: options.lang } : {}), + } + } + return metadata +} + +export function attachKnowledgeDocumentUpload(documents: Command): void { + documents + .command('upload ') + .description('Upload a document to a knowledge base') + .option('--name ', 'Store it under a different name') + .option('--tag ', 'Document tags, in tag1 through tag7 order') + .option('--recipe ', 'Document processing recipe') + .option('--lang ', 'Document language code') + .action( + async ( + knowledgeBaseId: string, + path: string, + options: KnowledgeDocumentUploadOptions, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...uploadMetadata(options), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteKnowledgeDocumentUploadResponse['data'] + >( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + knowledgeBaseId + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, + size, + }, + path + ) + + if (!completed.document) { + throw new Error(`Knowledge upload ${session.id} completed without a document`) + } + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts new file mode 100644 index 00000000000..1ef6d4b1d12 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -0,0 +1,153 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +describe('resource directory', () => { + it('makes ls and mkdir available for every folder-backed resource', () => { + for (const resource of ['files', 'knowledge', 'tables', 'workflows']) { + const group = program().commands.find((command) => command.name() === resource) + expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true) + expect(group?.commands.some((command) => command.name() === 'mkdir')).toBe(true) + } + }) + + it('combines child folders and resources in one directory listing', async () => { + mockRequest.mockImplementation(async (path: string) => { + if (path === '/api/v2/tables/folders') { + return { + data: [ + { + name: 'Archive', + path: '/Reports/Archive', + parentPath: '/Reports', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + if (path === '/api/v2/tables') { + return { + data: [ + { + id: 'tbl_1', + name: 'Revenue', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + throw new Error(`Unexpected path: ${path}`) + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync(['node', 'sim', 'table', 'ls', 'Reports', '--search', 'r']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + query: { + workspaceId: 'ws_local', + parentPath: 'Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', { + query: { + workspaceId: 'ws_local', + folderPath: 'Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + cursor: null, + }, + }) + expect(JSON.parse(logged[0])).toEqual([ + { + kind: 'folder', + name: 'Archive', + ref: '/Reports/Archive', + folderPath: '/Reports', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + { + kind: 'table', + name: 'Revenue', + ref: 'tbl_1', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ]) + }) + + it('creates a folder through the generated resource operation', async () => { + mockRequest.mockResolvedValue({ + data: { + folder: { + name: 'Quarterly', + path: '/Reports/Quarterly', + parentPath: '/Reports', + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'table', 'mkdir', 'Reports/Quarterly']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + method: 'POST', + body: { workspaceId: 'ws_local', path: 'Reports/Quarterly' }, + }) + }) + + it('rejects extra directory arguments instead of silently ignoring them', async () => { + await expect( + program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) + ).rejects.toThrow(/too many arguments/i) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts new file mode 100644 index 00000000000..fa0ca8eb07a --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -0,0 +1,196 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context' +import { + type ListFileFoldersResponse, + type ListFilesResponse, + type ListKnowledgeBasesResponse, + type ListKnowledgeFoldersResponse, + type ListTableFoldersResponse, + type ListTablesResponse, + type ListWorkflowFoldersResponse, + type ListWorkflowsResponse, + V2_OPERATIONS, + type V2OperationName, +} from '../../generated/v2-api' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { type Column, printList, text, timestamp } from '../../output/render' +import { DEFAULT_LIMIT } from '../../runtime/options' +import { renderResult } from '../../runtime/result' + +type FolderListOperation = + | 'listFileFolders' + | 'listKnowledgeFolders' + | 'listTableFolders' + | 'listWorkflowFolders' + +type DirectoryResource = + | ListFilesResponse['data'][number] + | ListKnowledgeBasesResponse['data'][number] + | ListTablesResponse['data'][number] + | ListWorkflowsResponse['data'][number] + +type DirectoryFolder = + | ListFileFoldersResponse['data'][number] + | ListKnowledgeFoldersResponse['data'][number] + | ListTableFoldersResponse['data'][number] + | ListWorkflowFoldersResponse['data'][number] + +interface DirectoryEntry { + kind: string + name: string + ref: string + folderPath: string + updatedAt: string +} + +type ResourceDirectoryConfig = + | { + kind: 'file' + resources: 'listFiles' + folders: 'listFileFolders' + createFolder: 'createFileFolder' + } + | { + kind: 'knowledge' + resources: 'listKnowledgeBases' + folders: 'listKnowledgeFolders' + createFolder: 'createKnowledgeFolder' + } + | { + kind: 'table' + resources: 'listTables' + folders: 'listTableFolders' + createFolder: 'createTableFolder' + } + | { + kind: 'workflow' + resources: 'listWorkflows' + folders: 'listWorkflowFolders' + createFolder: 'createWorkflowFolder' + } + +interface ListOptions { + search?: string + limit: string +} + +const COLUMNS: Column[] = [ + { header: 'kind', value: (entry) => text(entry.kind) }, + { header: 'name', value: (entry) => text(entry.name) }, + { header: 'ref', value: (entry) => text(entry.ref) }, + { header: 'folder', value: (entry) => text(entry.folderPath) }, + { header: 'updated', value: (entry) => timestamp(entry.updatedAt) }, +] + +function operationPath(operation: V2OperationName): string { + return V2_OPERATIONS[operation].path +} + +async function listResources( + client: SimClient, + config: ResourceDirectoryConfig, + workspaceId: string, + folderPath: string, + search: string | undefined, + limit: number +): Promise { + const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } + const path = operationPath(config.resources) + const paginated = 'cursor' in V2_OPERATIONS[config.resources].query + + if (!paginated) { + const page = await client.request>(path, { query }) + return page.data.slice(0, limit) + } + + return requestAllPages(client, path, { + query, + pageSize: DEFAULT_LIMIT, + limit, + }) +} + +async function listFolders( + client: SimClient, + operation: FolderListOperation, + workspaceId: string, + parentPath: string, + search: string | undefined +): Promise { + const page = await client.request>(operationPath(operation), { + query: { workspaceId, parentPath, search, sortBy: 'name', sortOrder: 'asc' }, + }) + return page.data +} + +function entriesFor( + config: ResourceDirectoryConfig, + folders: DirectoryFolder[], + resources: DirectoryResource[] +): DirectoryEntry[] { + return [ + ...folders.map((folder) => ({ + kind: 'folder', + name: folder.name, + ref: folder.path, + folderPath: folder.parentPath, + updatedAt: folder.updatedAt, + })), + ...resources.map((resource) => ({ + kind: config.kind, + name: resource.name, + ref: resource.id, + folderPath: resource.folderPath, + updatedAt: resource.updatedAt, + })), + ].sort( + (left, right) => left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind) + ) +} + +export function attachResourceDirectoryCommands( + group: Command, + config: ResourceDirectoryConfig +): void { + group + .command('ls [path]') + .allowExcessArguments(false) + .description(`List ${config.kind} resources and child folders together`) + .option('--search ', 'Filter folders and resources by name') + .addOption( + new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( + String(DEFAULT_LIMIT) + ) + ) + .action(async (path: string | undefined, options: ListOptions, command: Command) => { + const rawLimit = Number(options.limit) + if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative integer', 0) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const folderPath = path ?? '/' + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const [folders, resources] = await Promise.all([ + listFolders(client, config.folders, workspaceId, folderPath, options.search), + listResources(client, config, workspaceId, folderPath, options.search, limit), + ]) + const entries = entriesFor(config, folders, resources) + printList(profile.output, entries.slice(0, limit), COLUMNS) + }) + + group + .command('mkdir ') + .allowExcessArguments(false) + .description(`Create a ${config.kind} directory at a path`) + .action(async (path: string, _options: Record, command: Command) => { + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS[config.createFolder] + const result = await client.request<{ data?: unknown }>(operation.path, { + method: operation.method, + body: { workspaceId: client.requireWorkspace(), path }, + }) + renderResult(config.createFolder, profile.output, result.data ?? result, {}) + }) +} diff --git a/packages/sim-cli/src/commands/protocol/result.ts b/packages/sim-cli/src/commands/protocol/result.ts new file mode 100644 index 00000000000..35e84b03fe6 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/result.ts @@ -0,0 +1,7 @@ +import type { OutputFormat } from '../../config/index' +import { printRecord, text } from '../../output/render' + +export function printProtocolResult(format: OutputFormat, result: Record): void { + const fields = Object.entries(result).map<[string, string]>(([key, value]) => [key, text(value)]) + printRecord(format, fields, result) +} diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts new file mode 100644 index 00000000000..b14cee1a0dd --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -0,0 +1,130 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +async function runImport(argv: string[]) { + await program().parseAsync(['node', 'sim', 'table', 'import', ...argv]) +} + +describe('tables import argument guards', () => { + it('refuses to guess the source', async () => { + await expect(runImport([])).rejects.toThrow(/exactly one of /) + await expect(runImport(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) + }) + + it('rejects existing-table flags when creating one', async () => { + await expect(runImport(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--create-columns', '{}'])).rejects.toThrow( + /applies to --table-id/ + ) + }) + + it('rejects new-table flags when importing into an existing one', async () => { + await expect(runImport(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ + ) + await expect(runImport(['f.csv', '--table-id', 't', '--folder', '/Reports'])).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(runImport(['--file-id', 'w_1'])).rejects.toThrow(/--name /) + }) + + it('checks target options before touching the filesystem', async () => { + await expect(runImport(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) + }) + + it('rejects an invalid import mode before making a request', async () => { + await expect( + runImport(['--file-id', 'w_1', '--name', 'Customers', '--mode', 'merge']) + ).rejects.toThrow(/allowed choices are append, replace/i) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + +describe('tables import output', () => { + it('prints a normalized result without transfer secrets', async () => { + mockRequest.mockResolvedValue({ + data: { + session: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + }, + uploadToken: null, + transfer: null, + }, + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await runImport([ + '--file-id', + 'file_1', + '--name', + 'Customers', + '--folder', + 'Reports', + '--no-wait', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId: 'ws_local', + source: { type: 'workspace_file', fileId: 'file_1' }, + target: { type: 'new', name: 'Customers', folderPath: 'Reports' }, + }, + }) + + expect(JSON.parse(logged[0])).toEqual({ + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + }) + expect(logged[0]).not.toContain('uploadToken') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts new file mode 100644 index 00000000000..d60bbcc6288 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -0,0 +1,209 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import chalk from 'chalk' +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context' +import type { + CompleteTableImportResponse, + CreateTableImportResponse, + GetTableImportResponse, +} from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { SimApiError, type SimClient } from '../../http/client' +import { coerce, type FieldSpec } from '../../runtime/request' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +type TableImport = GetTableImportResponse['data'] + +interface ImportOptions { + name?: string + tableId?: string + mode?: string + folder?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + wait: boolean +} + +const IMPORT_POLL_MS = 1500 +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +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) +} + +function jsonFlag(raw: string, flagName: string, kind: FieldSpec['kind']): unknown { + return coerce(raw, { kind }, { json: true }, flagName) +} + +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await sleep(IMPORT_POLL_MS) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + 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 +} + +function validateTargetOptions(options: ImportOptions): boolean { + const intoExisting = Boolean(options.tableId) + const misplaced = intoExisting + ? ([ + ['--name', options.name], + ['--folder', options.folder], + ] 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 + ) + } + return intoExisting +} + +export function attachTableImport(tables: Command): void { + tables + .command('import [path]') + .description('Import a CSV, into a new table by default') + .option( + '--name ', + 'Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name' + ) + .option('--table-id ', 'Import into this existing table instead of creating one') + .addOption( + new Option( + '--mode ', + 'How to write into --table-id (default: append)' + ).choices(['append', 'replace']) + ) + .option('--folder ', 'Folder path 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, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + + const intoExisting = validateTargetOptions(options) + const local = path ? await localFile(path) : 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 { + 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.folder !== undefined ? { folderPath: options.folder } : {}), + } + } + + const started = await client.request( + V2_OPERATIONS.createTableImport.path, + { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + } + ) + + let job: TableImport = started.data.session + if (path) { + if (!local || !started.data.uploadToken || !started.data.transfer) { + throw new Error('Local table import did not return an upload transfer') + } + job = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + uploadToken: started.data.uploadToken, + transfer: started.data.transfer, + size: local.size, + }, + path + ) + } + + if (!options.wait) { + printProtocolResult(profile.output, { + id: job.id, + status: job.status, + tableId: job.tableId, + rowsProcessed: job.rowsProcessed, + }) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + printProtocolResult(profile.output, { + id: finished.id, + status: finished.status, + tableId: finished.tableId, + rowsProcessed: finished.rowsProcessed, + }) + }) +} diff --git a/packages/sim-cli/src/commands/secrets.test.ts b/packages/sim-cli/src/commands/secrets.test.ts new file mode 100644 index 00000000000..b2b2a5d6cf6 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.test.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../runtime/build' +import { attachSecretCommands } from './secrets' + +const { mockPromptSecret, mockRequest } = vi.hoisted(() => ({ + mockPromptSecret: vi.fn(async () => 'prompted-secret'), + mockRequest: vi.fn(), +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => 'ws_local', + }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'key', + }, + }), +})) +vi.mock('../terminal/secret-input', () => ({ promptSecret: mockPromptSecret })) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachSecretCommands(root) + return root +} + +describe('secrets set', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPromptSecret.mockResolvedValue('prompted-secret') + mockRequest.mockResolvedValue({ + data: { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('prompts when no value flag is supplied', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + ]) + + expect(mockPromptSecret).toHaveBeenCalledOnce() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'workspace', + value: 'prompted-secret', + }, + }) + }) + + it('accepts --value directly without prompting', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'personal', + '--value', + 'direct-secret', + ]) + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'personal', + value: 'direct-secret', + }, + }) + }) + + it('keeps --value optional in help and rejects an empty direct value', async () => { + const secrets = program().commands.find((command) => command.name() === 'secrets') + const set = secrets?.commands.find((command) => command.name() === 'set') + if (!set) throw new Error('Missing secrets set command') + expect(set.helpInformation()).toContain('--value ') + expect(set.helpInformation()).not.toContain('Set value (required)') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + '--value', + '', + ]) + ).rejects.toThrow('Secret value cannot be empty.') + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts new file mode 100644 index 00000000000..922e348c132 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.ts @@ -0,0 +1,67 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import { type SetSecretResponse, V2_OPERATIONS } from '../generated/v2-api' +import { resolvePath, SimApiError } from '../http/client' +import { renderResult } from '../runtime/result' +import { promptSecret } from '../terminal/secret-input' + +const MAX_SECRET_LENGTH = 65_536 +const SECRET_SCOPES = ['workspace', 'personal'] as const + +const SECRET_RESULT: CommandSpec = { + fields: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], +} + +interface SetSecretOptions { + scope: (typeof SECRET_SCOPES)[number] + value?: string +} + +function validateSecretValue(value: string): string { + if (value.length === 0) throw new SimApiError('Secret value cannot be empty.', 0) + if (value.length > MAX_SECRET_LENGTH) { + throw new SimApiError(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`, 0) + } + return value +} + +async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise { + const value = validateSecretValue(options.value ?? (await promptSecret())) + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.setSecret + const response = await client.request(resolvePath(operation.path, { name }), { + method: operation.method, + body: { + workspaceId: client.requireWorkspace(), + scope: options.scope, + value, + }, + }) + + renderResult('setSecret', profile.output, response.data, SECRET_RESULT) +} + +/** Adds interactive secret entry while preserving an explicit value flag for scripts. */ +export function attachSecretCommands(program: Command): void { + const secrets = program.commands.find((command) => command.name() === 'secrets') + if (!secrets) throw new Error('The generated secrets command group is missing') + + secrets + .command('set ') + .description('Create or replace a named secret') + .addOption( + new Option('--scope ', 'Secret ownership scope') + .choices([...SECRET_SCOPES]) + .makeOptionMandatory() + ) + .option('--value ', 'Secret value; visible to shell history when supplied directly') + .action((name: string, options: SetSecretOptions, command: Command) => + setSecret(name, options, command) + ) +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts new file mode 100644 index 00000000000..79b5751a0b0 --- /dev/null +++ b/packages/sim-cli/src/config/index.ts @@ -0,0 +1,18 @@ +export { configDir, configPath, credentialsPath } from './paths' +export { + DEFAULT_ENDPOINT, + DEFAULT_PROFILE, + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + type OutputFormat, + ProfileConfigError, + type ProfileOverrides, + type ResolvedProfile, + readConfigProfile, + readCredentialsProfile, + resolveProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from './profile' 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..d26ba8bd594 --- /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' + +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..225af15842b --- /dev/null +++ b/packages/sim-cli/src/config/profile.test.ts @@ -0,0 +1,167 @@ +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' +import { + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + resolveProfile, + writeConfigProfile, + writeCredentialsProfile, +} from './profile' + +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('fails fast on an unrecognized active output format', () => { + process.env.SIM_OUTPUT = 'xml' + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from env. Use one of: table, json, yaml, text' + ) + + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') + writeConfigProfile('default', { output: 'xml' }) + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from config. Use one of: table, json, yaml, text' + ) + expect(resolveProfile({ output: 'json' }).output).toBe('json') + }) + + it('resolves output from flag, environment, then profile', () => { + 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' } }) + + expect(resolveProfile({ output: 'text' })).toMatchObject({ + output: 'text', + sources: { output: 'flag' }, + }) + }) + + 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..c770cc2aae9 --- /dev/null +++ b/packages/sim-cli/src/config/profile.ts @@ -0,0 +1,219 @@ +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' +import { configPath, credentialsPath } from './paths' + +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] + +/** An invalid active profile setting that the user can correct. */ +export class ProfileConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProfileConfigError' + } +} + +/** 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 + output?: OutputFormat +} + +/** + * 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(/\/+$/, '') +} + +/** + * 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' + ) + + const output = resolve( + [ + ['flag', overrides.output], + ['env', process.env.SIM_OUTPUT], + ['config', config.output], + ], + 'table', + 'default' + ) + if (!(OUTPUT_FORMATS as readonly string[]).includes(output.value as string)) { + throw new ProfileConfigError( + `Unknown output format "${output.value}" from ${output.source}. Use one of: ${OUTPUT_FORMATS.join(', ')}` + ) + } + + 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..61cc307a2b4 --- /dev/null +++ b/packages/sim-cli/src/context.ts @@ -0,0 +1,41 @@ +import type { Command } from 'commander' +import { + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + resolveProfile, +} from './config/index' +import { SimClient } from './http/client' + +/** Global flags, shared by every subcommand. */ +export interface GlobalOptions { + profile?: string + endpoint?: string + workspace?: string + output?: OutputFormat +} + +/** + * 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, + output: globals.output, + ...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..46c7c65e108 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.ts @@ -0,0 +1,832 @@ +import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' + +const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' +const TABLE_FILTER_HELP = + 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' +const TABLE_SORT_HELP = + 'Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc)' +const CUSTOM_TOOL_SCHEMA_HELP = + 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +const FOLDER_PATH_INPUT = { + describe: 'Folder path; the leading / is optional', +} as const +const FOLDER_PATH_FLAG = { + ...FOLDER_PATH_INPUT, + name: 'folder', +} as const +const FOLDER_DELETE_FLAGS = { + path: FOLDER_PATH_INPUT, + recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, +} as const +const KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS = { id: 'knowledgeBaseId' } as const +const WORKFLOW_RUN_SCOPE = { + id: { + name: 'workflow', + placeholder: 'workflowId', + describe: 'Workflow ID', + }, +} as const +const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ + { header: 'path' }, + { header: 'name' }, + { header: 'parent', path: 'parentPath' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, +] + +function moveResource(command: string, resource: string): CommandVariantSpec { + return { + command, + positionals: ['folderPath'], + requestFields: ['folderPath'], + describe: `Move a ${resource} to a folder`, + } +} + +/** + * 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 = { + createCredentialConnection: { hidden: true }, + createServiceAccountCredential: { hidden: true }, + getBillingStatus: { + command: 'billing status', + allWorkspaces: true, + describe: 'Show billing status and current-period credit usage', + fields: [ + { header: 'plan' }, + { header: 'status' }, + { header: 'workspace', path: 'workspaceId' }, + { header: 'period start', path: 'period.start', format: 'timestamp' }, + { header: 'period end', path: 'period.end', format: 'timestamp' }, + { header: 'used credits', path: 'credits.used' }, + { header: 'limit credits', path: 'credits.limit' }, + { header: 'remaining credits', path: 'credits.remaining' }, + ], + }, + listBillingLogs: { + command: 'billing logs', + allWorkspaces: true, + describe: 'List credit usage events', + flags: { + source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, + period: { describe: 'Billing period' }, + startDate: { describe: 'Custom period start (ISO 8601)' }, + endDate: { describe: 'Custom period end (ISO 8601)' }, + }, + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, + { header: 'source' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'credits', path: 'creditCost' }, + { header: 'run', path: 'runId' }, + { header: 'id' }, + ], + }, + + // ─── 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, describe: TABLE_FILTER_HELP }, + }, + 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, describe: TABLE_FILTER_HELP }, + 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', + }, + setSecret: { hidden: true }, + + // ─── 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.', + fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], + }, + deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, + deleteKnowledgeDocument: { + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + confirm: 'This deletes the document and its embeddings.', + }, + deleteFile: { confirm: 'This archives the file.' }, + deleteCredential: { + confirm: 'This disconnects the credential and removes its stored authentication.', + }, + 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.', + }, + deleteSecret: { + confirm: 'This deletes the secret; anything using it may stop 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.', + fields: [ + { header: 'id' }, + { header: 'deleted', format: 'bool' }, + { header: 'remaining columns', path: 'columns', format: 'count' }, + ], + }, + // ─── 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 }, + folderPaths: { ...FOLDER_PATH_FLAG, list: true }, + triggers: { name: 'trigger', list: true }, + details: { describe: 'Response detail level' }, + includeTraceSpans: { + boolean: true, + describe: 'Include trace spans in JSON or YAML output (implies full detail)', + }, + includeFinalOutput: { + boolean: true, + describe: 'Include final output in JSON or YAML output (implies full detail)', + }, + }, + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'run', path: 'runId' }, + ], + }, + getLog: { + describe: 'Show run diagnostics', + expandedTrace: true, + fields: [ + { header: 'run', path: 'runId' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'status' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'files', format: 'count' }, + { header: 'trace', path: 'traceSpans', format: 'trace-count' }, + ], + }, + searchKnowledge: { + // Accepts a string or an array on the wire; the CLI always sends the array. + flags: { + knowledgeBaseIds: { name: 'kb', list: true, describe: 'Knowledge base ID (repeatable)' }, + query: { describe: 'Text to search for' }, + tagFilters: { + json: true, + describe: 'Tag filters as [{"tagName":"...","operator":"...","value":"..."}]', + }, + searchMode: { + choices: ['vector', 'hybrid'], + describe: 'Search algorithm', + }, + }, + itemsPath: 'results', + 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, describe: TABLE_FILTER_HELP }, + sort: { json: true, describe: TABLE_SORT_HELP }, + }, + // 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', + }, + createTableRows: { + bodyVariants: [ + { + name: 'data', + property: 'data', + kind: 'object', + describe: 'One row keyed by column name', + }, + { + name: 'rows', + property: 'rows', + kind: 'array', + describe: 'Several rows keyed by column name', + }, + ], + }, + createTable: { + flags: { + name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, + schema: { + json: true, + describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', + }, + }, + }, + updateTable: { + variants: [moveResource('tables mv', 'table')], + flags: { + name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, + }, + }, + createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { + variants: [moveResource('knowledge mv', 'knowledge base')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + createWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateWorkflow: { + variants: [moveResource('workflows mv', 'workflow')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, + createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, + updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, + + // ─── Output columns for list commands ───────────────────────────────────── + listTables: { + flags: { folderPath: FOLDER_PATH_FLAG }, + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'folder', path: 'folderPath' }, + { header: 'rows', path: 'rowCount' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkflows: { + flags: { folderPath: FOLDER_PATH_FLAG }, + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'folder', path: 'folderPath' }, + { header: 'deployed', path: 'isDeployed', format: 'bool' }, + { header: 'runs', path: 'runCount' }, + { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, + ], + }, + listFiles: { + flags: { folderPath: FOLDER_PATH_FLAG }, + 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 by', path: 'uploadedByEmail' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + listTableRows: { expand: 'data' }, + listKnowledgeBases: { + flags: { folderPath: FOLDER_PATH_FLAG }, + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'folder', path: 'folderPath' }, + { header: 'docs', path: 'docCount' }, + { header: 'tokens', path: 'tokenCount' }, + { header: 'model', path: 'embeddingModel' }, + ], + }, + getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS }, + listKnowledgeDocuments: { + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + 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: 'built-in', path: 'readOnly', format: 'bool' }, + ], + }, + listCustomTools: { + columns: [ + { header: 'id' }, + { header: 'name', path: 'title' }, + { header: 'description', path: 'schema.function.description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCredentials: { + columns: [ + { header: 'id' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listSecrets: { + columns: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + getWorkspace: { + profileWorkspacePath: true, + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'mode' }, + { header: 'members', path: 'memberCount' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkspaceMembers: { + command: 'workspaces members', + describe: 'List workspace members', + profileWorkspacePath: true, + columns: [ + { header: 'email' }, + { header: 'name' }, + { header: 'role' }, + { header: 'external', path: 'isExternal', format: 'bool' }, + { header: 'joined', path: 'joinedAt', format: 'timestamp' }, + ], + }, + + listAuditLogs: { + allWorkspaces: true, + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, + { header: 'actor', path: 'actorEmail' }, + { header: 'action' }, + { header: 'resource', path: 'resourceName' }, + ], + }, + getAuditLog: { + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, + }, + + // ─── The expanded files surface ─────────────────────────────────────────── + // Every one of these derives badly. `/files/move` and `/files/bulk-delete` + // are verbs sitting where the deriver expects a sub-resource, so it made them + // groups holding a lone `create`. + bulkDeleteFiles: { + // `batch-` for the bulk form, matching `tables rows batch-delete`. + command: 'files batch-delete', + describe: 'Delete several files at once', + flags: { + fileIds: { list: true }, + }, + confirm: 'This deletes every listed file.', + }, + getFile: { + command: 'files describe', + describe: 'Show file metadata and sharing status', + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'folder', path: 'folderPath' }, + { header: 'uploaded by', path: 'uploadedByEmail' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + // v2 returns the share under `share` (null when unshared), and its flag + // is `isActive`. + { header: 'shared', path: 'share.isActive', format: 'bool' }, + { header: 'share URL', path: 'share.url' }, + { header: 'share auth', path: 'share.authType' }, + { header: 'allowed emails', path: 'share.allowedEmails', format: 'count' }, + ], + }, + moveFileItems: { + command: 'files move', + aliases: ['mv'], + describe: 'Move files into another folder', + flags: { + fileIds: { list: true }, + targetFolderPath: { + ...FOLDER_PATH_INPUT, + name: 'to', + describe: 'Destination folder path; omit for root', + }, + }, + }, + renameFile: { + // Derived to `files update`, which contradicted its own summary. + command: 'files rename', + describe: 'Rename a file', + }, + updateFileContent: { + command: 'files set-content', + describe: 'Replace a file’s contents', + flags: { + encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, + }, + }, + // Both share commands return the share itself as `data`, which the runtime + // unwraps, so these fields sit at the top level rather than under a wrapper. + getFileShare: { + command: 'files share get', + describe: 'Show a file’s share settings', + fields: [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, + ], + }, + // v2 folds share and unshare into one PATCH; `--is-active false` disables it, + // so there is no separate unshare operation to expose. + upsertFileShare: { + command: 'files share set', + describe: 'Enable or disable sharing for a file', + flags: { + allowedEmails: { list: true }, + }, + fields: [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, + ], + }, + + // ─── Resource-scoped, path-addressed folders ────────────────────────────── + listFileFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listKnowledgeFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listTableFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listWorkflowFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + createFileFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a file folder at a path', + }, + createKnowledgeFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a knowledge folder at a path', + }, + createTableFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a table folder at a path', + }, + createWorkflowFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a workflow folder at a path', + }, + relocateFileFolder: { + command: 'files folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, + describe: 'Rename or move a file folder', + }, + relocateKnowledgeFolder: { + command: 'knowledge folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, + describe: 'Rename or move a knowledge folder', + }, + relocateTableFolder: { + command: 'tables folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, + describe: 'Rename or move a table folder', + }, + relocateWorkflowFolder: { + command: 'workflows folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, + describe: 'Rename or move a workflow folder', + }, + deleteFileFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the file folder and, when recursive, everything inside it.', + }, + deleteKnowledgeFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', + }, + deleteTableFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the table folder and, when recursive, everything inside it.', + }, + deleteWorkflowFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the workflow folder and, when recursive, everything inside it.', + }, + + // ─── The expanded tables surface ────────────────────────────────────────── + // `/cancel-runs`, `/rows/find`, `/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', + flags: { + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, + }, + findTableRows: { + command: 'tables rows find', + describe: 'Find rows matching a predicate', + flags: { + q: { describe: 'Value to find' }, + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + sort: { json: true, describe: TABLE_SORT_HELP }, + }, + itemsPath: 'matches', + columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], + }, + runTableColumn: { + command: 'tables columns run', + describe: 'Run a column’s workflow', + flags: { + groupIds: { list: true }, + rowIds: { list: true }, + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, + }, + 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, + }, + + // ─── Runs ───────────────────────────────────────────────────────────────── + // 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', + flags: { + async: { boolean: true, describe: 'Queue the run and return immediately' }, + input: { json: true, describe: 'Trigger input as JSON' }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: + 'Return blockName.field values (e.g. agent_1.content); missing fields are omitted', + }, + // 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 }, + includeThinking: { omit: true }, + includeToolCalls: { omit: true }, + }, + }, + getWorkflowRun: { + command: 'workflows runs get', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Show run status (requested outputs are included in JSON or YAML output)', + flags: { + includeOutput: { + boolean: true, + describe: 'Include the final output in JSON or YAML output', + }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: 'Include blockName.field values in JSON or YAML output (e.g. agent_1.content)', + }, + }, + fields: [ + { header: 'run', path: 'runId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'context', path: 'paused.contextId' }, + { header: 'pause kind', path: 'paused.pauseKind' }, + { header: 'paused at', path: 'paused.pausedAt', format: 'timestamp' }, + { header: 'resume at', path: 'paused.resumeAt', format: 'timestamp' }, + { header: 'blocked on', path: 'paused.blockedOnBlockId' }, + { header: 'pause points', path: 'paused.pausePointCount' }, + { header: 'error', path: 'error.message' }, + ], + }, + listWorkflowRuns: { + command: 'workflows runs list', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'List runs for a workflow', + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'run', path: 'runId' }, + ], + }, + cancelWorkflowRun: { + command: 'workflows runs cancel', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Cancel a running workflow run', + // Not `confirm`-gated: cancelling is recoverable (re-run it), and the + // whole point is to stop something that is already going wrong. + }, + resumeWorkflow: { + command: 'workflows runs resume', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Resume a paused run (output is included in JSON or YAML output)', + flags: { + contextId: { + name: 'context', + describe: 'Pause context ID returned by run status', + }, + input: { + json: true, + describe: 'Resume input as JSON', + }, + }, + fields: [ + { header: 'run', path: 'runId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'status URL', path: 'statusUrl' }, + { header: 'queue position', path: 'queuePosition' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'error', path: 'error.message' }, + ], + }, + + // ─── Not a terminal-shaped operation ────────────────────────────────────── + // Multipart upload; `sim knowledge documents upload ` needs its + // own file-reading command rather than a generated flag surface. + uploadKnowledgeDocument: { hidden: true }, + createKnowledgeDocumentUpload: { hidden: true }, + createKnowledgeDocumentUploadPartUrls: { hidden: true }, + completeKnowledgeDocumentUpload: { hidden: true }, + abortKnowledgeDocumentUpload: { 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..736c247ef24 --- /dev/null +++ b/packages/sim-cli/src/contract/types.ts @@ -0,0 +1,181 @@ +import type { V2OperationName } from '../generated/v2-api' + +/** + * 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 usually derives from ` `. + * + * 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`). + * - `pathFlags` — when a parent path segment is command context rather than the + * resource being acted on (`workflows runs get --workflow `). + * - `pathArgumentNames` — when a route's generic `[id]` needs a clearer CLI + * placeholder (``). + * - `profileWorkspacePath` — when `[workspaceId]` is the active profile target, + * not a resource argument (`workspaces get`). + * - `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 one or more space-separated values, or `@path` / `@-` with one + * value per line. + * + * 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 + /** Accepted values when the generated descriptor cannot recover an enum. */ + choices?: readonly string[] + /** Expose a string-backed API boolean as a conventional terminal toggle. */ + boolean?: true + /** + * 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 +} + +/** How a route path parameter is exposed as a required named option. */ +export interface PathFlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased path parameter. */ + name?: string + /** Help placeholder without angle brackets. Defaults to `value`. */ + placeholder?: string + /** Short alias, e.g. `k` for `--kb`. */ + short?: string + /** One-line help for the scope selected by this path parameter. */ + describe?: string +} + +/** 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' | 'count' | 'trace-count' +} + +export interface BodyVariantSpec { + /** User-facing flag name, without `--`. */ + name: string + /** Request-body property populated by this variant. */ + property: string + /** JSON shape accepted by this variant. */ + kind: 'object' | 'array' + /** One-line help describing when to use this variant. */ + describe: string +} + +export interface CommandVariantSpec { + /** Full alternate command path, such as `workflows mv`. */ + command: string + /** Request fields exposed as required positional arguments. */ + positionals?: readonly string[] + /** Request fields available on this narrower command surface. */ + requestFields?: readonly string[] + /** One-line help for the alternate command. */ + describe?: string +} + +export interface CommandSpec { + /** + * Command path, space-separated. Omit to accept the derived + * ` [sub-resource] ` name. + */ + command?: string + /** Run this operation when its top-level group is invoked without a subcommand. */ + groupDefault?: boolean + /** Alternate leaf command names, such as `ls` for `list`. */ + aliases?: readonly string[] + /** Route path parameters exposed as required named options instead of positionals. */ + pathFlags?: Record + /** Friendly placeholders for route path parameters that remain positional. */ + pathArgumentNames?: Record + /** Fill a `[workspaceId]` route segment from the active profile instead of an argument. */ + profileWorkspacePath?: boolean + /** Request fields exposed as required positional arguments, in order. */ + positionals?: readonly string[] + /** Restrict this command to these request fields; profile fields remain implicit. */ + requestFields?: readonly string[] + /** Additional command shapes backed by the same API operation. */ + variants?: readonly CommandVariantSpec[] + /** 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 + /** Friendly mutually-exclusive flags for an otherwise opaque union body. */ + bodyVariants?: readonly BodyVariantSpec[] + /** Columns for table output. Omit on non-list commands to print a record. */ + columns?: ColumnSpec[] + /** Fields shown for a single record in human formats. Machine output stays raw. */ + fields?: ColumnSpec[] + /** Add `--trace` to expand recursive trace spans in human-readable output. */ + expandedTrace?: boolean + /** Dot path to a nested result array rendered as the command's human list. */ + itemsPath?: string + /** Allow an optional workspaceId field to omit the configured workspace filter. */ + allWorkspaces?: boolean + /** + * 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..9acec0d2016 --- /dev/null +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -0,0 +1,7633 @@ +/** + * 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 +} + +type AbortFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type AbortFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: AbortFileUploadResponseRef0 | null +} + +export type AbortFileUploadResponse = { + data: AbortFileUploadResponseRef1 +} + +/** `DELETE /api/v2/knowledge/[id]/documents/uploads/[uploadId]` */ +export type AbortKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type AbortKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type AbortKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +type AbortKnowledgeDocumentUploadResponseRef0 = { + 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 +} + +type AbortKnowledgeDocumentUploadResponseRef1 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: AbortKnowledgeDocumentUploadResponseRef0 | null +} + +export type AbortKnowledgeDocumentUploadResponse = { + data: AbortKnowledgeDocumentUploadResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/columns` */ +export type AddTableColumnParams = { + tableId: string +} + +export type AddTableColumnQuery = Record + +export type AddTableColumnBody = { + workspaceId: string + column: { + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + position?: number + } +} + +type AddTableColumnResponseRef0 = { + 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 + }> +} + +export type AddTableColumnResponse = { + data: AddTableColumnResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/groups` */ +export type AddWorkflowGroupParams = { + tableId: string +} + +export type AddWorkflowGroupQuery = Record + +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 +} + +type AddWorkflowGroupResponseRef0 = { + 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 +} + +type AddWorkflowGroupResponseRef1 = { + group: AddWorkflowGroupResponseRef0 + 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 + }> +} + +export type AddWorkflowGroupResponse = { + data: AddWorkflowGroupResponseRef1 +} + +/** `POST /api/v2/files/bulk-delete` */ +export type BulkDeleteFilesQuery = Record + +export type BulkDeleteFilesBody = { + workspaceId: string + fileIds: Array +} + +type BulkDeleteFilesResponseRef0 = { + deletedItems: { + files: number + } +} + +export type BulkDeleteFilesResponse = { + data: BulkDeleteFilesResponseRef0 +} + +/** `PATCH /api/v2/knowledge/[id]/documents` */ +export type BulkUpdateKnowledgeDocumentsParams = { + id: string +} + +export type BulkUpdateKnowledgeDocumentsQuery = Record + +export type BulkUpdateKnowledgeDocumentsBody = { + workspaceId: string + operation: 'enable' | 'disable' + documentIds?: Array + selectAll?: true + enabledFilter?: 'all' | 'enabled' | 'disabled' +} + +type BulkUpdateKnowledgeDocumentsResponseRef0 = { + operation: 'enable' | 'disable' + updatedCount: number + documentIds?: Array +} + +export type BulkUpdateKnowledgeDocumentsResponse = { + data: BulkUpdateKnowledgeDocumentsResponseRef0 +} + +/** `DELETE /api/v2/tables/exports/[exportId]` */ +export type CancelTableExportParams = { + exportId: string +} + +export type CancelTableExportQuery = { + workspaceId: string +} + +type CancelTableExportResponseRef0 = { + 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 +} + +export type CancelTableExportResponse = { + data: CancelTableExportResponseRef0 +} + +/** `DELETE /api/v2/tables/imports/[importId]` */ +export type CancelTableImportParams = { + importId: string +} + +export type CancelTableImportQuery = { + workspaceId: string +} + +export type CancelTableImportHeaders = { + 'upload-token'?: string +} + +type CancelTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CancelTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type CancelTableImportResponseRef2 = string + +type CancelTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CancelTableImportResponseRef0 | CancelTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: CancelTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type CancelTableImportResponse = { + data: CancelTableImportResponseRef3 +} + +/** `POST /api/v2/tables/[tableId]/cancel-runs` */ +export type CancelTableRunsParams = { + tableId: string +} + +export type CancelTableRunsQuery = Record + +type CancelTableRunsBodyRef0 = + | { + all: Array< + | CancelTableRunsBodyRef0 + | { + 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< + | CancelTableRunsBodyRef0 + | { + 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 CancelTableRunsBody = { + workspaceId: string + scope: 'all' | 'row' + rowId?: string + filter?: CancelTableRunsBodyRef0 + excludeRowIds?: Array +} + +type CancelTableRunsResponseRef0 = { + cancelled: number +} + +export type CancelTableRunsResponse = { + data: CancelTableRunsResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/runs/[runId]/cancel` */ +export type CancelWorkflowRunParams = { + id: string + runId: string +} + +export type CancelWorkflowRunQuery = Record + +type CancelWorkflowRunResponseRef0 = { + success: boolean + runId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: + | 'recorded' + | 'already_cancelled' + | 'already_completed' + | 'already_failed' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' +} + +export type CancelWorkflowRunResponse = { + data: CancelWorkflowRunResponseRef0 +} + +/** `POST /api/v2/files/uploads/[uploadId]/complete` */ +export type CompleteFileUploadParams = { + uploadId: string +} + +export type CompleteFileUploadQuery = { + workspaceId: string +} + +export type CompleteFileUploadHeaders = { + 'upload-token': string +} + +type CompleteFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type CompleteFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CompleteFileUploadResponseRef0 | null +} + +export type CompleteFileUploadResponse = { + data: CompleteFileUploadResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete` */ +export type CompleteKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type CompleteKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type CompleteKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +type CompleteKnowledgeDocumentUploadResponseRef0 = { + 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 +} + +type CompleteKnowledgeDocumentUploadResponseRef1 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: CompleteKnowledgeDocumentUploadResponseRef0 | null +} + +export type CompleteKnowledgeDocumentUploadResponse = { + data: CompleteKnowledgeDocumentUploadResponseRef1 +} + +/** `POST /api/v2/tables/imports/[importId]/complete` */ +export type CompleteTableImportParams = { + importId: string +} + +export type CompleteTableImportQuery = { + workspaceId: string +} + +export type CompleteTableImportHeaders = { + 'upload-token': string +} + +type CompleteTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CompleteTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type CompleteTableImportResponseRef2 = string + +type CompleteTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CompleteTableImportResponseRef0 | CompleteTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: CompleteTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type CompleteTableImportResponse = { + data: CompleteTableImportResponseRef3 +} + +/** `POST /api/v2/credentials/connections` */ +export type CreateCredentialConnectionQuery = Record + +export type CreateCredentialConnectionBody = + | { + workspaceId: string + providerId: string + displayName: string + } + | { + workspaceId: string + credentialId: string + } + +type CreateCredentialConnectionResponseRef0 = { + authorizationUrl: string + expiresAt: string +} + +export type CreateCredentialConnectionResponse = { + data: CreateCredentialConnectionResponseRef0 +} + +/** `POST /api/v2/custom-tools` */ +export type CreateCustomToolQuery = Record + +export type CreateCustomToolBody = { + workspaceId: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string +} + +type CreateCustomToolResponseRef0 = { + 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 +} + +export type CreateCustomToolResponse = { + data: CreateCustomToolResponseRef0 +} + +/** `POST /api/v2/files` */ +export type CreateFileQuery = Record + +type CreateFileBodyRef0 = string + +export type CreateFileBody = { + workspaceId: string + name: string + contentType?: string + folderPath?: CreateFileBodyRef0 + content?: string + encoding?: 'utf-8' | 'base64' +} + +type CreateFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type CreateFileResponse = { + data: CreateFileResponseRef0 +} + +/** `POST /api/v2/files/folders` */ +export type CreateFileFolderQuery = Record + +type CreateFileFolderBodyRef0 = string + +export type CreateFileFolderBody = { + workspaceId: string + path: CreateFileFolderBodyRef0 +} + +type CreateFileFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateFileFolderResponse = { + data: CreateFileFolderResponseRef0 +} + +/** `POST /api/v2/files/uploads` */ +export type CreateFileUploadQuery = Record + +type CreateFileUploadBodyRef0 = string + +export type CreateFileUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + folderPath?: CreateFileUploadBodyRef0 +} + +type CreateFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type CreateFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CreateFileUploadResponseRef0 | null +} + +type CreateFileUploadResponseRef2 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateFileUploadResponseRef3 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateFileUploadResponseRef4 = { + session: CreateFileUploadResponseRef1 + uploadToken: string + transfer: CreateFileUploadResponseRef2 | CreateFileUploadResponseRef3 +} + +export type CreateFileUploadResponse = { + data: CreateFileUploadResponseRef4 +} + +/** `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 +} + +type CreateFileUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateFileUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateFileUploadPartUrlsResponse = { + data: CreateFileUploadPartUrlsResponseRef1 +} + +/** `POST /api/v2/knowledge` */ +export type CreateKnowledgeBaseQuery = Record + +type CreateKnowledgeBaseBodyRef0 = { + maxSize?: number + minSize?: number + overlap?: number +} + +type CreateKnowledgeBaseBodyRef1 = string + +export type CreateKnowledgeBaseBody = { + workspaceId: string + name: string + description?: string + chunkingConfig?: CreateKnowledgeBaseBodyRef0 + folderPath?: CreateKnowledgeBaseBodyRef1 +} + +type CreateKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type CreateKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: CreateKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type CreateKnowledgeBaseResponse = { + data: CreateKnowledgeBaseResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads` */ +export type CreateKnowledgeDocumentUploadParams = { + id: string +} + +export type CreateKnowledgeDocumentUploadQuery = Record + +export type CreateKnowledgeDocumentUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + processingOptions?: { + recipe?: string + lang?: string + } +} + +type CreateKnowledgeDocumentUploadResponseRef0 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: CreateKnowledgeDocumentUploadResponseRef1 | null +} + +type CreateKnowledgeDocumentUploadResponseRef1 = { + 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 +} + +type CreateKnowledgeDocumentUploadResponseRef2 = + | CreateKnowledgeDocumentUploadResponseRef3 + | CreateKnowledgeDocumentUploadResponseRef4 + +type CreateKnowledgeDocumentUploadResponseRef3 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateKnowledgeDocumentUploadResponseRef4 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateKnowledgeDocumentUploadResponseRef5 = { + session: CreateKnowledgeDocumentUploadResponseRef0 + uploadToken: string + transfer: CreateKnowledgeDocumentUploadResponseRef2 +} + +export type CreateKnowledgeDocumentUploadResponse = { + data: CreateKnowledgeDocumentUploadResponseRef5 +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts` */ +export type CreateKnowledgeDocumentUploadPartUrlsParams = { + id: string + uploadId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateKnowledgeDocumentUploadPartUrlsHeaders = { + 'upload-token': string +} + +type CreateKnowledgeDocumentUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateKnowledgeDocumentUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateKnowledgeDocumentUploadPartUrlsResponse = { + data: CreateKnowledgeDocumentUploadPartUrlsResponseRef1 +} + +/** `POST /api/v2/knowledge/folders` */ +export type CreateKnowledgeFolderQuery = Record + +type CreateKnowledgeFolderBodyRef0 = string + +export type CreateKnowledgeFolderBody = { + workspaceId: string + path: CreateKnowledgeFolderBodyRef0 +} + +type CreateKnowledgeFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateKnowledgeFolderResponse = { + data: CreateKnowledgeFolderResponseRef0 +} + +/** `POST /api/v2/mcp-servers` */ +export type CreateMcpServerQuery = Record + +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 +} + +type CreateMcpServerResponseRef0 = { + 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 +} + +export type CreateMcpServerResponse = { + data: CreateMcpServerResponseRef0 +} + +/** `POST /api/v2/credentials` */ +export type CreateServiceAccountCredentialQuery = Record + +export type CreateServiceAccountCredentialBody = { + workspaceId: string + type: 'service_account' + providerId: string + displayName?: string + description?: string + id?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + certificateId?: string + orgId?: string + dataCenter?: string + authMethod?: string + privateKey?: string + username?: string +} + +type CreateServiceAccountCredentialResponseRef0 = { + id: string + type: 'oauth' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type CreateServiceAccountCredentialResponse = { + data: CreateServiceAccountCredentialResponseRef0 +} + +/** `POST /api/v2/skills` */ +export type CreateSkillQuery = Record + +export type CreateSkillBody = { + workspaceId: string + name: string + description: string + content: string +} + +type CreateSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type CreateSkillResponse = { + data: CreateSkillResponseRef0 +} + +/** `POST /api/v2/tables` */ +export type CreateTableQuery = Record + +type CreateTableBodyRef0 = string + +export type CreateTableBody = { + name: string + description?: string + workspaceId: string + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + }> + } + folderPath?: CreateTableBodyRef0 +} + +type CreateTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type CreateTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: 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 + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: CreateTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type CreateTableResponse = { + data: CreateTableResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/exports` */ +export type CreateTableExportParams = { + tableId: string +} + +export type CreateTableExportQuery = Record + +export type CreateTableExportBody = { + workspaceId: string + format?: 'csv' | 'json' +} + +type CreateTableExportResponseRef0 = { + 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 +} + +export type CreateTableExportResponse = { + data: CreateTableExportResponseRef0 +} + +/** `POST /api/v2/tables/folders` */ +export type CreateTableFolderQuery = Record + +type CreateTableFolderBodyRef0 = string + +export type CreateTableFolderBody = { + workspaceId: string + path: CreateTableFolderBodyRef0 +} + +type CreateTableFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateTableFolderResponse = { + data: CreateTableFolderResponseRef0 +} + +/** `POST /api/v2/tables/imports` */ +export type CreateTableImportQuery = Record + +type CreateTableImportBodyRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CreateTableImportBodyRef1 = { + type: 'workspace_file' + fileId: string +} + +type CreateTableImportBodyRef2 = string + +export type CreateTableImportBody = { + workspaceId: string + source: CreateTableImportBodyRef0 | CreateTableImportBodyRef1 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportBodyRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + mapping?: Record + createColumns?: Array + timezone?: string +} + +type CreateTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CreateTableImportResponseRef1 = string + +type CreateTableImportResponseRef2 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CreateTableImportResponseRef0 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportResponseRef1 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +type CreateTableImportResponseRef3 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateTableImportResponseRef4 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateTableImportResponseRef5 = { + type: 'workspace_file' + fileId: string +} + +type CreateTableImportResponseRef6 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CreateTableImportResponseRef5 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportResponseRef1 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +type CreateTableImportResponseRef7 = + | { + session: CreateTableImportResponseRef2 + uploadToken: string + transfer: CreateTableImportResponseRef3 | CreateTableImportResponseRef4 + } + | { + session: CreateTableImportResponseRef6 + uploadToken: null + transfer: null + } + +export type CreateTableImportResponse = { + data: CreateTableImportResponseRef7 +} + +/** `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 +} + +type CreateTableImportPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateTableImportPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateTableImportPartUrlsResponse = { + data: CreateTableImportPartUrlsResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/rows` */ +export type CreateTableRowsParams = { + tableId: string +} + +export type CreateTableRowsQuery = Record + +type CreateTableRowsBodyRef0 = Record + +export type CreateTableRowsBody = + | { + workspaceId: string + rows: Array + } + | { + workspaceId: string + data: CreateTableRowsBodyRef0 + afterRowId?: string + beforeRowId?: string + } + +type CreateTableRowsResponseRef0 = { + data: CreateTableRowsResponseRef2 +} + +type CreateTableRowsResponseRef1 = Record + +type CreateTableRowsResponseRef2 = { + id: string + data: CreateTableRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type CreateTableRowsResponseRef3 = { + data: CreateTableRowsResponseRef4 +} + +type CreateTableRowsResponseRef4 = { + rows: Array + insertedCount: number +} + +export type CreateTableRowsResponse = CreateTableRowsResponseRef0 | CreateTableRowsResponseRef3 + +/** `POST /api/v2/tables/[tableId]/views` */ +export type CreateTableViewParams = { + tableId: string +} + +export type CreateTableViewQuery = Record + +type CreateTableViewBodyRef0 = + | { + all: Array< + | CreateTableViewBodyRef0 + | { + 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< + | CreateTableViewBodyRef0 + | { + 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 + } + > + } + | { + 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 CreateTableViewBody = { + workspaceId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: CreateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } +} + +type CreateTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type CreateTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: CreateTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type CreateTableViewResponse = { + data: CreateTableViewResponseRef1 +} + +/** `POST /api/v2/workflows` */ +export type CreateWorkflowQuery = Record + +type CreateWorkflowBodyRef0 = string + +export type CreateWorkflowBody = { + workspaceId: string + name: string + description?: string | null + folderPath?: CreateWorkflowBodyRef0 +} + +type CreateWorkflowResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type CreateWorkflowResponse = { + data: CreateWorkflowResponseRef0 +} + +/** `POST /api/v2/workflows/folders` */ +export type CreateWorkflowFolderQuery = Record + +type CreateWorkflowFolderBodyRef0 = string + +export type CreateWorkflowFolderBody = { + workspaceId: string + path: CreateWorkflowFolderBodyRef0 +} + +type CreateWorkflowFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type CreateWorkflowFolderResponse = { + data: CreateWorkflowFolderResponseRef0 +} + +/** `DELETE /api/v2/credentials/[credentialId]` */ +export type DeleteCredentialParams = { + credentialId: string +} + +export type DeleteCredentialQuery = { + workspaceId: string +} + +type DeleteCredentialResponseRef0 = { + id: string + deleted: true +} + +export type DeleteCredentialResponse = { + data: DeleteCredentialResponseRef0 +} + +/** `DELETE /api/v2/custom-tools/[id]` */ +export type DeleteCustomToolParams = { + id: string +} + +export type DeleteCustomToolQuery = { + workspaceId: string +} + +type DeleteCustomToolResponseRef0 = { + id: string + deleted: true +} + +export type DeleteCustomToolResponse = { + data: DeleteCustomToolResponseRef0 +} + +/** `DELETE /api/v2/files/[fileId]` */ +export type DeleteFileParams = { + fileId: string +} + +export type DeleteFileQuery = { + workspaceId: string +} + +type DeleteFileResponseRef0 = { + id: string + deleted: true +} + +export type DeleteFileResponse = { + data: DeleteFileResponseRef0 +} + +/** `DELETE /api/v2/files/folders` */ +type DeleteFileFolderQueryRef0 = string + +export type DeleteFileFolderQuery = { + workspaceId: string + path: DeleteFileFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteFileFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + files: number + } +} + +export type DeleteFileFolderResponse = { + data: DeleteFileFolderResponseRef0 +} + +/** `DELETE /api/v2/knowledge/[id]` */ +export type DeleteKnowledgeBaseParams = { + id: string +} + +export type DeleteKnowledgeBaseQuery = { + workspaceId: string +} + +type DeleteKnowledgeBaseResponseRef0 = { + id: string + deleted: true +} + +export type DeleteKnowledgeBaseResponse = { + data: DeleteKnowledgeBaseResponseRef0 +} + +/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ +export type DeleteKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type DeleteKnowledgeDocumentQuery = { + workspaceId: string +} + +type DeleteKnowledgeDocumentResponseRef0 = { + id: string + deleted: true +} + +export type DeleteKnowledgeDocumentResponse = { + data: DeleteKnowledgeDocumentResponseRef0 +} + +/** `DELETE /api/v2/knowledge/folders` */ +type DeleteKnowledgeFolderQueryRef0 = string + +export type DeleteKnowledgeFolderQuery = { + workspaceId: string + path: DeleteKnowledgeFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteKnowledgeFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + knowledgeBases: number + } +} + +export type DeleteKnowledgeFolderResponse = { + data: DeleteKnowledgeFolderResponseRef0 +} + +/** `DELETE /api/v2/mcp-servers/[id]` */ +export type DeleteMcpServerParams = { + id: string +} + +export type DeleteMcpServerQuery = { + workspaceId: string +} + +type DeleteMcpServerResponseRef0 = { + id: string + deleted: true +} + +export type DeleteMcpServerResponse = { + data: DeleteMcpServerResponseRef0 +} + +/** `DELETE /api/v2/secrets/[name]` */ +export type DeleteSecretParams = { + name: string +} + +export type DeleteSecretQuery = { + workspaceId: string + scope: 'workspace' | 'personal' +} + +type DeleteSecretResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + deleted: true +} + +export type DeleteSecretResponse = { + data: DeleteSecretResponseRef0 +} + +/** `DELETE /api/v2/skills/[id]` */ +export type DeleteSkillParams = { + id: string +} + +export type DeleteSkillQuery = { + workspaceId: string +} + +type DeleteSkillResponseRef0 = { + id: string + deleted: true +} + +export type DeleteSkillResponse = { + data: DeleteSkillResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]` */ +export type DeleteTableParams = { + tableId: string +} + +export type DeleteTableQuery = { + workspaceId: string +} + +type DeleteTableResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableResponse = { + data: DeleteTableResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/columns` */ +export type DeleteTableColumnParams = { + tableId: string +} + +export type DeleteTableColumnQuery = Record + +export type DeleteTableColumnBody = { + workspaceId: string + columnName: string +} + +type DeleteTableColumnResponseRef0 = { + 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 + }> +} + +export type DeleteTableColumnResponse = { + data: DeleteTableColumnResponseRef0 +} + +/** `DELETE /api/v2/tables/folders` */ +type DeleteTableFolderQueryRef0 = string + +export type DeleteTableFolderQuery = { + workspaceId: string + path: DeleteTableFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteTableFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + tables: number + } +} + +export type DeleteTableFolderResponse = { + data: DeleteTableFolderResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ +export type DeleteTableRowParams = { + tableId: string + rowId: string +} + +export type DeleteTableRowQuery = { + workspaceId: string +} + +type DeleteTableRowResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableRowResponse = { + data: DeleteTableRowResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/rows` */ +export type DeleteTableRowsParams = { + tableId: string +} + +export type DeleteTableRowsQuery = Record + +type DeleteTableRowsBodyRef0 = + | { + all: Array< + | DeleteTableRowsBodyRef0 + | { + 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< + | DeleteTableRowsBodyRef0 + | { + 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 DeleteTableRowsBody = { + workspaceId: string + filter?: DeleteTableRowsBodyRef0 + limit?: number + rowIds?: Array +} + +type DeleteTableRowsResponseRef0 = { + deletedCount: number + deletedRowIds: Array + requestedCount?: number + missingRowIds?: Array +} + +export type DeleteTableRowsResponse = { + data: DeleteTableRowsResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/views/[viewId]` */ +export type DeleteTableViewParams = { + tableId: string + viewId: string +} + +export type DeleteTableViewQuery = { + workspaceId: string +} + +type DeleteTableViewResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableViewResponse = { + data: DeleteTableViewResponseRef0 +} + +/** `DELETE /api/v2/workflows/[id]` */ +export type DeleteWorkflowParams = { + id: string +} + +export type DeleteWorkflowQuery = Record + +type DeleteWorkflowResponseRef0 = { + id: string + deleted: true +} + +export type DeleteWorkflowResponse = { + data: DeleteWorkflowResponseRef0 +} + +/** `DELETE /api/v2/workflows/folders` */ +type DeleteWorkflowFolderQueryRef0 = string + +export type DeleteWorkflowFolderQuery = { + workspaceId: string + path: DeleteWorkflowFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteWorkflowFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + workflows: number + } +} + +export type DeleteWorkflowFolderResponse = { + data: DeleteWorkflowFolderResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/groups` */ +export type DeleteWorkflowGroupParams = { + tableId: string +} + +export type DeleteWorkflowGroupQuery = Record + +export type DeleteWorkflowGroupBody = { + workspaceId: string + groupId: string +} + +type DeleteWorkflowGroupResponseRef0 = { + 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?: string + }> +} + +export type DeleteWorkflowGroupResponse = { + data: DeleteWorkflowGroupResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/deploy` */ +export type DeployWorkflowParams = { + id: string +} + +export type DeployWorkflowQuery = Record + +export type DeployWorkflowBody = { + name?: string + description?: string | null +} + +type DeployWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type DeployWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: DeployWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: DeployWorkflowResponseRef3 | null +} + +type DeployWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type DeployWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type DeployWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: DeployWorkflowResponseRef0 | null + latestDeploymentAttempt: DeployWorkflowResponseRef1 | null + version?: number +} + +export type DeployWorkflowResponse = { + data: DeployWorkflowResponseRef4 +} + +/** `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 ExecuteWorkflowQuery = Record + +export type ExecuteWorkflowBody = { + input?: Record + async?: boolean + executionTimeoutSeconds?: number + stream?: boolean + selectedOutputs?: Array + includeThinking?: boolean + includeToolCalls?: boolean + includeFileBase64?: boolean + base64MaxBytes?: number +} + +export type ExecuteWorkflowHeaders = { + 'x-run-id'?: string + 'x-sim-via'?: string +} + +type ExecuteWorkflowResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type ExecuteWorkflowResponseRef1 = { + runId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: ExecuteWorkflowResponseRef0 | null + startedAt?: string + endedAt?: string + durationMs?: number +} + +type ExecuteWorkflowResponseRef2 = { + runId: string + statusUrl: string +} + +export type ExecuteWorkflowResponse = + | { + data: ExecuteWorkflowResponseRef1 + } + | { + data: ExecuteWorkflowResponseRef2 + } + +/** `GET /api/v2/workflows/[id]/export` */ +export type ExportWorkflowParams = { + id: string +} + +export type ExportWorkflowQuery = Record + +type ExportWorkflowResponseRef0 = { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderPath: string + } + state: Record +} + +export type ExportWorkflowResponse = { + data: ExportWorkflowResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/rows/find` */ +export type FindTableRowsParams = { + tableId: string +} + +export type FindTableRowsQuery = Record + +type FindTableRowsBodyRef0 = + | { + all: Array< + | FindTableRowsBodyRef0 + | { + 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< + | FindTableRowsBodyRef0 + | { + 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 FindTableRowsBody = { + workspaceId: string + q: string + predicate?: FindTableRowsBodyRef0 + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> +} + +type FindTableRowsResponseRef0 = { + ordinal: number + rowId: string + column: string +} + +type FindTableRowsResponseRef1 = { + matches: Array + truncated: boolean +} + +export type FindTableRowsResponse = { + data: FindTableRowsResponseRef1 +} + +/** `GET /api/v2/audit-logs/[id]` */ +export type GetAuditLogParams = { + id: string +} + +export type GetAuditLogQuery = { + organizationId: string +} + +type GetAuditLogResponseRef0 = { + id: string + workspaceId: 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 +} + +export type GetAuditLogResponse = { + data: GetAuditLogResponseRef0 +} + +/** `GET /api/v2/billing/status` */ +export type GetBillingStatusQuery = { + workspaceId?: string +} + +type GetBillingStatusResponseRef0 = { + workspaceId: string | null + period: { + start: string + end: string + } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { + used: number + limit: number + remaining: number + } | null + storage: { + usedBytes: number + limitBytes: number + percentUsed: number + } | null +} + +export type GetBillingStatusResponse = { + data: GetBillingStatusResponseRef0 +} + +/** `GET /api/v2/custom-tools/[id]` */ +export type GetCustomToolParams = { + id: string +} + +export type GetCustomToolQuery = { + workspaceId: string +} + +type GetCustomToolResponseRef0 = { + 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 +} + +export type GetCustomToolResponse = { + data: GetCustomToolResponseRef0 +} + +/** `GET /api/v2/files/[fileId]/metadata` */ +export type GetFileParams = { + fileId: string +} + +export type GetFileQuery = { + workspaceId: string + scope?: 'active' | 'archived' +} + +type GetFileResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +type GetFileResponseRef1 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + share: GetFileResponseRef0 | null +} + +export type GetFileResponse = { + data: GetFileResponseRef1 +} + +/** `GET /api/v2/files/[fileId]/share` */ +export type GetFileShareParams = { + fileId: string +} + +export type GetFileShareQuery = { + workspaceId: string +} + +type GetFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type GetFileShareResponse = { + data: GetFileShareResponseRef0 | null +} + +/** `GET /api/v2/knowledge/[id]` */ +export type GetKnowledgeBaseParams = { + id: string +} + +export type GetKnowledgeBaseQuery = { + workspaceId: string +} + +type GetKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type GetKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: GetKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type GetKnowledgeBaseResponse = { + data: GetKnowledgeBaseResponseRef1 +} + +/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ +export type GetKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type GetKnowledgeDocumentQuery = { + workspaceId: string +} + +type GetKnowledgeDocumentResponseRef0 = { + 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 + tags: Record + processingError: string | null + processingStartedAt: string | null + processingCompletedAt: string | null + connectorId: string | null + connectorType: string | null + sourceUrl: string | null +} + +export type GetKnowledgeDocumentResponse = { + data: GetKnowledgeDocumentResponseRef0 +} + +/** `GET /api/v2/logs/[runId]` */ +export type GetLogParams = { + runId: string +} + +export type GetLogQuery = Record + +type GetLogResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + +type GetLogResponseRef1 = { + runId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + files: Array | null + workflow: { + id: string | null + name: string + description: string | null + folderPath: string | null + ownerEmail: string | null + workspaceId: string | null + createdAt: string | null + updatedAt: string | null + deleted: boolean + } + workflowState: Record | null + traceSpans: Array + finalOutput: unknown | null + cost: { + total: number + } | null + createdAt: string +} + +export type GetLogResponse = { + data: GetLogResponseRef1 +} + +/** `GET /api/v2/mcp-servers/[id]` */ +export type GetMcpServerParams = { + id: string +} + +export type GetMcpServerQuery = { + workspaceId: string +} + +type GetMcpServerResponseRef0 = { + 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 +} + +export type GetMcpServerResponse = { + data: GetMcpServerResponseRef0 +} + +/** `GET /api/v2/skills/[id]` */ +export type GetSkillParams = { + id: string +} + +export type GetSkillQuery = { + workspaceId: string +} + +type GetSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type GetSkillResponse = { + data: GetSkillResponseRef0 +} + +/** `GET /api/v2/tables/[tableId]` */ +export type GetTableParams = { + tableId: string +} + +export type GetTableQuery = { + workspaceId: string +} + +type GetTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type GetTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: 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 + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: GetTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type GetTableResponse = { + data: GetTableResponseRef1 +} + +/** `GET /api/v2/tables/exports/[exportId]` */ +export type GetTableExportParams = { + exportId: string +} + +export type GetTableExportQuery = { + workspaceId: string +} + +type GetTableExportResponseRef0 = { + 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 +} + +export type GetTableExportResponse = { + data: GetTableExportResponseRef0 +} + +/** `GET /api/v2/tables/imports/[importId]` */ +export type GetTableImportParams = { + importId: string +} + +export type GetTableImportQuery = { + workspaceId: string +} + +export type GetTableImportHeaders = { + 'upload-token'?: string +} + +type GetTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type GetTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type GetTableImportResponseRef2 = string + +type GetTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: GetTableImportResponseRef0 | GetTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: GetTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type GetTableImportResponse = { + data: GetTableImportResponseRef3 +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ +export type GetTableRowParams = { + tableId: string + rowId: string +} + +export type GetTableRowQuery = { + workspaceId: string +} + +type GetTableRowResponseRef0 = Record + +type GetTableRowResponseRef1 = { + id: string + data: GetTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +export type GetTableRowResponse = { + data: GetTableRowResponseRef1 +} + +/** `GET /api/v2/tables/[tableId]/views/[viewId]` */ +export type GetTableViewParams = { + tableId: string + viewId: string +} + +export type GetTableViewQuery = { + workspaceId: string +} + +type GetTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type GetTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: GetTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type GetTableViewResponse = { + data: GetTableViewResponseRef1 +} + +/** `GET /api/v2/workflows/[id]` */ +export type GetWorkflowParams = { + id: string +} + +export type GetWorkflowQuery = Record + +type GetWorkflowResponseRef0 = { + name: string + type: string + description?: string +} + +type GetWorkflowResponseRef1 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + variables: Record + inputs: Array +} + +export type GetWorkflowResponse = { + data: GetWorkflowResponseRef1 +} + +/** `GET /api/v2/workflows/[id]/deployment` */ +export type GetWorkflowDeploymentParams = { + id: string +} + +export type GetWorkflowDeploymentQuery = Record + +type GetWorkflowDeploymentResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type GetWorkflowDeploymentResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: GetWorkflowDeploymentResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: GetWorkflowDeploymentResponseRef3 | null +} + +type GetWorkflowDeploymentResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type GetWorkflowDeploymentResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type GetWorkflowDeploymentResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: GetWorkflowDeploymentResponseRef0 | null + latestDeploymentAttempt: GetWorkflowDeploymentResponseRef1 | null + needsRedeployment: boolean +} + +export type GetWorkflowDeploymentResponse = { + data: GetWorkflowDeploymentResponseRef4 +} + +/** `GET /api/v2/workflows/[id]/runs/[runId]` */ +export type GetWorkflowRunParams = { + id: string + runId: string +} + +export type GetWorkflowRunQuery = { + includeOutput?: boolean + selectedOutputs?: string +} + +type GetWorkflowRunResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type GetWorkflowRunResponseRef1 = { + runId: string + workflowId: string + status: + | 'pending' + | 'running' + | 'paused' + | 'redacting' + | 'completed' + | 'failed' + | 'cancelled' + | 'queued' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: { + contextId: string | null + pausedAt: string + resumeAt: string | null + pauseKind: 'time' | 'human' | null + blockedOnBlockId: string | null + automaticResumeWaitingReason: string | null + pausePointCount: number + resumedCount: number + } | null + cost: { + total: number + } | null + error: GetWorkflowRunResponseRef0 | null + output: unknown | null + blockOutputs: Record | null +} + +export type GetWorkflowRunResponse = { + data: GetWorkflowRunResponseRef1 +} + +/** `GET /api/v2/workflows/[id]/versions/[version]` */ +export type GetWorkflowVersionParams = { + id: string + version: number +} + +export type GetWorkflowVersionQuery = Record + +type GetWorkflowVersionResponseRef0 = Record + +type GetWorkflowVersionResponseRef1 = { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + state: GetWorkflowVersionResponseRef0 +} + +export type GetWorkflowVersionResponse = { + data: GetWorkflowVersionResponseRef1 +} + +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceQuery = Record + +type GetWorkspaceResponseRef0 = { + id: string + name: string + color: string + logoUrl: string | null + memberCount: number + createdAt: string + updatedAt: string +} + +export type GetWorkspaceResponse = { + data: GetWorkspaceResponseRef0 +} + +/** `POST /api/v2/workflows/import` */ +export type ImportWorkflowQuery = Record + +type ImportWorkflowBodyRef0 = string + +export type ImportWorkflowBody = { + workspaceId: string + workflow: string | Record + folderPath?: ImportWorkflowBodyRef0 + name?: string + description?: string +} + +type ImportWorkflowResponseRef0 = { + id: string + name: string + description: string | null + workspaceId: string + folderPath: string + createdAt: string + updatedAt: string +} + +export type ImportWorkflowResponse = { + data: ImportWorkflowResponseRef0 +} + +/** `GET /api/v2/audit-logs` */ +export type ListAuditLogsQuery = { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + startDate?: string + endDate?: string + includeDeparted?: boolean + limit?: number + cursor?: string + organizationId: string + actorEmail?: string +} + +type ListAuditLogsResponseRef0 = { + id: string + workspaceId: 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 +} + +export type ListAuditLogsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/billing/logs` */ +export type ListBillingLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'sim-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 +} + +type ListBillingLogsResponseRef0 = { + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId: string | null + workflow: { + id: string + name: string | null + } | null + runId: string | null + creditCost: number +} + +export type ListBillingLogsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/credentials/providers` */ +export type ListCredentialProvidersQuery = { + workspaceId: string + search?: string +} + +type ListCredentialProvidersResponseRef0 = + | { + type: 'oauth' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + supportsReconnect: boolean + authorizationOptions: Array<{ + providerId: string + label: string + }> + } + | { + type: 'service_account' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: Array<{ + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: Array + options?: Array<{ + value: string + label: string + }> + hint?: string + }> + } + +export type ListCredentialProvidersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/credentials` */ +export type ListCredentialsQuery = { + workspaceId: string + type?: 'oauth' | 'service_account' + providerId?: string + search?: string + sortBy?: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListCredentialsResponseRef0 = { + id: string + type: 'oauth' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type ListCredentialsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/custom-tools` */ +export type ListCustomToolsQuery = { + workspaceId: string + search?: string + sortBy?: 'title' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListCustomToolsResponseRef0 = { + 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 +} + +export type ListCustomToolsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/files/folders` */ +type ListFileFoldersQueryRef0 = string + +export type ListFileFoldersQuery = { + workspaceId: string + parentPath?: ListFileFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListFileFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListFileFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/files` */ +type ListFilesQueryRef0 = string + +export type ListFilesQuery = { + workspaceId: string + folderPath?: ListFilesQueryRef0 + scope?: 'active' | 'archived' + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListFilesResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type ListFilesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +type ListKnowledgeBasesQueryRef0 = string + +export type ListKnowledgeBasesQuery = { + workspaceId: string + folderPath?: ListKnowledgeBasesQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListKnowledgeBasesResponseRef0 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: ListKnowledgeBasesResponseRef1 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +type ListKnowledgeBasesResponseRef1 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +export type ListKnowledgeBasesResponse = { + data: Array + 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 + tagFilters?: string +} + +type ListKnowledgeDocumentsResponseRef0 = { + 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 + tags: Record +} + +export type ListKnowledgeDocumentsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/folders` */ +type ListKnowledgeFoldersQueryRef0 = string + +export type ListKnowledgeFoldersQuery = { + workspaceId: string + parentPath?: ListKnowledgeFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListKnowledgeFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListKnowledgeFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/tags` */ +export type ListKnowledgeTagsParams = { + id: string +} + +export type ListKnowledgeTagsQuery = { + workspaceId: string +} + +type ListKnowledgeTagsResponseRef0 = { + displayName: string + tagSlot: string + fieldType: string +} + +export type ListKnowledgeTagsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/logs` */ +export type ListLogsQuery = { + workspaceId: string + workflowIds?: string + triggers?: string + level?: 'info' | 'error' + startDate?: string + endDate?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + details?: 'basic' | 'full' + includeTraceSpans?: boolean + includeFinalOutput?: boolean + limit?: number + cursor?: string + order?: 'asc' | 'desc' + runId?: string + folderPaths?: string +} + +type ListLogsResponseRef0 = { + runId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + 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?: Array +} + +type ListLogsResponseRef1 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + +export type ListLogsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/mcp-servers` */ +export type ListMcpServersQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListMcpServersResponseRef0 = { + 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 +} + +export type ListMcpServersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/mcp-servers/[id]/tools` */ +export type ListMcpServerToolsParams = { + id: string +} + +export type ListMcpServerToolsQuery = { + workspaceId: string + refresh?: boolean +} + +type ListMcpServerToolsResponseRef0 = { + name: string + description?: string + inputSchema: { + type: 'object' + properties?: Record + required?: Array + } + serverId: string + serverName: string +} + +export type ListMcpServerToolsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/secrets` */ +export type ListSecretsQuery = { + workspaceId: string + scope?: 'workspace' | 'personal' + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSecretsResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type ListSecretsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/skills` */ +export type ListSkillsQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSkillsResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string +} + +export type ListSkillsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/folders` */ +type ListTableFoldersQueryRef0 = string + +export type ListTableFoldersQuery = { + workspaceId: string + parentPath?: ListTableFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListTableFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListTableFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/rows` */ +export type ListTableRowsParams = { + tableId: string +} + +export type ListTableRowsQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +type ListTableRowsResponseRef0 = { + id: string + data: ListTableRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type ListTableRowsResponseRef1 = Record + +export type ListTableRowsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables` */ +type ListTablesQueryRef0 = string + +export type ListTablesQuery = { + workspaceId: string + folderPath?: ListTablesQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListTablesResponseRef0 = { + id: string + name: string + description: string | null + ownerEmail: 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 + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: ListTablesResponseRef1 | null + createdAt: string + updatedAt: string +} + +type ListTablesResponseRef1 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +export type ListTablesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/views` */ +export type ListTableViewsParams = { + tableId: string +} + +export type ListTableViewsQuery = { + workspaceId: string +} + +type ListTableViewsResponseRef0 = { + id: string + tableId: string + name: string + config: ListTableViewsResponseRef1 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +type ListTableViewsResponseRef1 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +export type ListTableViewsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows/folders` */ +type ListWorkflowFoldersQueryRef0 = string + +export type ListWorkflowFoldersQuery = { + workspaceId: string + parentPath?: ListWorkflowFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListWorkflowFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type ListWorkflowFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/groups` */ +export type ListWorkflowGroupsParams = { + tableId: string +} + +export type ListWorkflowGroupsQuery = { + workspaceId: string +} + +type ListWorkflowGroupsResponseRef0 = { + 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 +} + +export type ListWorkflowGroupsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows/[id]/runs` */ +export type ListWorkflowRunsParams = { + id: string +} + +export type ListWorkflowRunsQuery = { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger?: string + startDate?: string + endDate?: string + limit?: number + cursor?: string + order?: 'asc' | 'desc' +} + +type ListWorkflowRunsResponseRef0 = { + runId: string + workflowId: string + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null +} + +export type ListWorkflowRunsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows` */ +type ListWorkflowsQueryRef0 = string + +export type ListWorkflowsQuery = { + workspaceId: string + folderPath?: ListWorkflowsQueryRef0 + deployedOnly?: boolean + limit?: number + cursor?: string + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' + sortOrder?: 'asc' | 'desc' +} + +type ListWorkflowsResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type ListWorkflowsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows/[id]/versions` */ +export type ListWorkflowVersionsParams = { + id: string +} + +export type ListWorkflowVersionsQuery = { + limit?: number + cursor?: string +} + +type ListWorkflowVersionsResponseRef0 = { + id: string + version: number + name?: string | null + description?: string | null + isActive: boolean + createdAt: string + deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null +} + +export type ListWorkflowVersionsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/members` */ +export type ListWorkspaceMembersParams = { + workspaceId: string +} + +export type ListWorkspaceMembersQuery = { + limit?: number + cursor?: string +} + +type ListWorkspaceMembersResponseRef0 = { + email: string + name: string + image: string | null + role: 'admin' | 'write' | 'read' + isExternal: boolean + joinedAt: string +} + +export type ListWorkspaceMembersResponse = { + data: Array + nextCursor: string | null +} + +/** `POST /api/v2/files/move` */ +export type MoveFileItemsQuery = Record + +type MoveFileItemsBodyRef0 = string + +export type MoveFileItemsBody = { + workspaceId: string + fileIds: Array + targetFolderPath?: MoveFileItemsBodyRef0 +} + +type MoveFileItemsResponseRef0 = { + movedItems: { + files: number + } +} + +export type MoveFileItemsResponse = { + data: MoveFileItemsResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsQuery = Record + +type QueryRowsBodyRef0 = + | { + all: Array< + | QueryRowsBodyRef0 + | { + 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< + | QueryRowsBodyRef0 + | { + 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 QueryRowsBody = { + workspaceId: string + predicate?: QueryRowsBodyRef0 + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> + limit?: number + cursor?: string +} + +type QueryRowsResponseRef0 = { + id: string + data: QueryRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type QueryRowsResponseRef1 = Record + +export type QueryRowsResponse = { + data: Array + nextCursor: string | null +} + +/** `POST /api/v2/tables/[tableId]/query/count` */ +export type QueryRowsCountParams = { + tableId: string +} + +export type QueryRowsCountQuery = Record + +type QueryRowsCountBodyRef0 = + | { + all: Array< + | QueryRowsCountBodyRef0 + | { + 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< + | QueryRowsCountBodyRef0 + | { + 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 QueryRowsCountBody = { + workspaceId: string + predicate?: QueryRowsCountBodyRef0 +} + +type QueryRowsCountResponseRef0 = { + totalCount: number +} + +export type QueryRowsCountResponse = { + data: QueryRowsCountResponseRef0 +} + +/** `PATCH /api/v2/files/folders` */ +export type RelocateFileFolderQuery = Record + +type RelocateFileFolderBodyRef0 = string + +export type RelocateFileFolderBody = { + workspaceId: string + path: RelocateFileFolderBodyRef0 + destinationPath: RelocateFileFolderBodyRef0 +} + +type RelocateFileFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateFileFolderResponse = { + data: RelocateFileFolderResponseRef0 +} + +/** `PATCH /api/v2/knowledge/folders` */ +export type RelocateKnowledgeFolderQuery = Record + +type RelocateKnowledgeFolderBodyRef0 = string + +export type RelocateKnowledgeFolderBody = { + workspaceId: string + path: RelocateKnowledgeFolderBodyRef0 + destinationPath: RelocateKnowledgeFolderBodyRef0 +} + +type RelocateKnowledgeFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateKnowledgeFolderResponse = { + data: RelocateKnowledgeFolderResponseRef0 +} + +/** `PATCH /api/v2/tables/folders` */ +export type RelocateTableFolderQuery = Record + +type RelocateTableFolderBodyRef0 = string + +export type RelocateTableFolderBody = { + workspaceId: string + path: RelocateTableFolderBodyRef0 + destinationPath: RelocateTableFolderBodyRef0 +} + +type RelocateTableFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateTableFolderResponse = { + data: RelocateTableFolderResponseRef0 +} + +/** `PATCH /api/v2/workflows/folders` */ +export type RelocateWorkflowFolderQuery = Record + +type RelocateWorkflowFolderBodyRef0 = string + +export type RelocateWorkflowFolderBody = { + workspaceId: string + path: RelocateWorkflowFolderBodyRef0 + destinationPath: RelocateWorkflowFolderBodyRef0 +} + +type RelocateWorkflowFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type RelocateWorkflowFolderResponse = { + data: RelocateWorkflowFolderResponseRef0 +} + +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileQuery = Record + +export type RenameFileBody = { + workspaceId: string + name: string +} + +type RenameFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type RenameFileResponse = { + data: RenameFileResponseRef0 +} + +/** `POST /api/v2/files/[fileId]/restore` */ +export type RestoreFileParams = { + fileId: string +} + +export type RestoreFileQuery = Record + +export type RestoreFileBody = { + workspaceId: string +} + +type RestoreFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type RestoreFileResponse = { + data: RestoreFileResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/runs/[runId]/resume` */ +export type ResumeWorkflowParams = { + id: string + runId: string +} + +export type ResumeWorkflowQuery = Record + +export type ResumeWorkflowBody = { + contextId: string + input?: unknown +} + +type ResumeWorkflowResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type ResumeWorkflowResponseRef1 = { + runId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: ResumeWorkflowResponseRef0 | null + startedAt?: string + endedAt?: string + durationMs?: number +} + +type ResumeWorkflowResponseRef2 = { + runId: string + statusUrl: string + queuePosition?: number +} + +export type ResumeWorkflowResponse = + | { + data: ResumeWorkflowResponseRef1 + } + | { + data: ResumeWorkflowResponseRef2 + } + +/** `POST /api/v2/workflows/[id]/rollback` */ +export type RollbackWorkflowParams = { + id: string +} + +export type RollbackWorkflowQuery = Record + +export type RollbackWorkflowBody = { + version?: number +} + +type RollbackWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type RollbackWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: RollbackWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: RollbackWorkflowResponseRef3 | null +} + +type RollbackWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type RollbackWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type RollbackWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: RollbackWorkflowResponseRef0 | null + latestDeploymentAttempt: RollbackWorkflowResponseRef1 | null + version: number +} + +export type RollbackWorkflowResponse = { + data: RollbackWorkflowResponseRef4 +} + +/** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type RunRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type RunRowEnrichmentQuery = Record + +export type RunRowEnrichmentBody = { + workspaceId: string +} + +type RunRowEnrichmentResponseRef0 = { + dispatchId: string | null +} + +export type RunRowEnrichmentResponse = { + data: RunRowEnrichmentResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/columns/run` */ +export type RunTableColumnParams = { + tableId: string +} + +export type RunTableColumnQuery = Record + +type RunTableColumnBodyRef0 = + | { + all: Array< + | RunTableColumnBodyRef0 + | { + 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< + | RunTableColumnBodyRef0 + | { + 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 RunTableColumnBody = { + workspaceId: string + groupIds: Array + runMode?: 'all' | 'incomplete' + rowIds?: Array + filter?: RunTableColumnBodyRef0 + excludeRowIds?: Array + limit?: { + type: 'rows' + max: number + } +} + +type RunTableColumnResponseRef0 = { + dispatchId: string | null +} + +export type RunTableColumnResponse = { + data: RunTableColumnResponseRef0 +} + +/** `POST /api/v2/knowledge/search` */ +export type SearchKnowledgeQuery = Record + +type SearchKnowledgeBodyRef0 = { + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator?: + | 'eq' + | 'neq' + | 'contains' + | 'not_contains' + | 'starts_with' + | 'ends_with' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + value: string | number | boolean + valueTo?: string | number +} + +export type SearchKnowledgeBody = { + workspaceId: string + knowledgeBaseIds: string | Array + query?: string + topK?: number + tagFilters?: Array + searchMode?: 'vector' | 'hybrid' | null + rerankerEnabled?: boolean + rerankerModel?: 'rerank-v4.0-pro' | 'rerank-v4.0-fast' | 'rerank-v3.5' + rerankerInputCount?: number +} + +type SearchKnowledgeResponseRef0 = { + knowledgeBaseId: string + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record + similarity: number + rerankerScore?: number +} + +type SearchKnowledgeResponseRef1 = { + results: Array + query: string + knowledgeBaseIds: Array + topK: number + totalResults: number + rerankerStatus: 'not_requested' | 'skipped' | 'unavailable' | 'applied' +} + +export type SearchKnowledgeResponse = { + data: SearchKnowledgeResponseRef1 +} + +/** `PUT /api/v2/secrets/[name]` */ +export type SetSecretParams = { + name: string +} + +export type SetSecretQuery = Record + +export type SetSecretBody = { + workspaceId: string + scope: 'workspace' | 'personal' + value: string +} + +type SetSecretResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type SetSecretResponse = { + data: SetSecretResponseRef0 +} + +/** `GET /api/v2/tables/exports/[exportId]/download` */ +export type TableExportDownloadParams = { + exportId: string +} + +export type TableExportDownloadQuery = { + workspaceId: string +} + +type TableExportDownloadResponseRef0 = { + url: string + fileName: string + expiresAt: string +} + +export type TableExportDownloadResponse = { + data: TableExportDownloadResponseRef0 +} + +/** `DELETE /api/v2/workflows/[id]/deploy` */ +export type UndeployWorkflowParams = { + id: string +} + +export type UndeployWorkflowQuery = Record + +type UndeployWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type UndeployWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: UndeployWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: UndeployWorkflowResponseRef3 | null +} + +type UndeployWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type UndeployWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type UndeployWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: UndeployWorkflowResponseRef0 | null + latestDeploymentAttempt: UndeployWorkflowResponseRef1 | null +} + +export type UndeployWorkflowResponse = { + data: UndeployWorkflowResponseRef4 +} + +/** `PATCH /api/v2/custom-tools/[id]` */ +export type UpdateCustomToolParams = { + id: string +} + +export type UpdateCustomToolQuery = Record + +export type UpdateCustomToolBody = { + workspaceId: string + title?: string + schema?: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code?: string +} + +type UpdateCustomToolResponseRef0 = { + 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 +} + +export type UpdateCustomToolResponse = { + data: UpdateCustomToolResponseRef0 +} + +/** `PUT /api/v2/files/[fileId]/content` */ +export type UpdateFileContentParams = { + fileId: string +} + +export type UpdateFileContentQuery = Record + +export type UpdateFileContentBody = { + workspaceId: string + content: string + encoding?: 'utf-8' | 'base64' +} + +type UpdateFileContentResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type UpdateFileContentResponse = { + data: UpdateFileContentResponseRef0 +} + +/** `PATCH /api/v2/knowledge/[id]` */ +export type UpdateKnowledgeBaseParams = { + id: string +} + +export type UpdateKnowledgeBaseQuery = Record + +type UpdateKnowledgeBaseBodyRef0 = { + maxSize?: number + minSize?: number + overlap?: number +} + +type UpdateKnowledgeBaseBodyRef1 = string + +export type UpdateKnowledgeBaseBody = { + workspaceId: string + name?: string + description?: string + chunkingConfig?: UpdateKnowledgeBaseBodyRef0 + folderPath?: UpdateKnowledgeBaseBodyRef1 +} + +type UpdateKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type UpdateKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: UpdateKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type UpdateKnowledgeBaseResponse = { + data: UpdateKnowledgeBaseResponseRef1 +} + +/** `PATCH /api/v2/knowledge/[id]/documents/[documentId]` */ +export type UpdateKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type UpdateKnowledgeDocumentQuery = Record + +export type UpdateKnowledgeDocumentBody = { + workspaceId: string + filename?: string + enabled?: boolean + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + number1?: number + number2?: number + number3?: number + number4?: number + number5?: number + date1?: string + date2?: string + boolean1?: boolean + boolean2?: boolean + boolean3?: boolean + retryProcessing?: true +} + +type UpdateKnowledgeDocumentResponseRef0 = { + 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 + tags: Record +} + +type UpdateKnowledgeDocumentResponseRef1 = { + id: string + queued: true + processingStatus: string + message: string +} + +export type UpdateKnowledgeDocumentResponse = { + data: UpdateKnowledgeDocumentResponseRef0 | UpdateKnowledgeDocumentResponseRef1 +} + +/** `PATCH /api/v2/mcp-servers/[id]` */ +export type UpdateMcpServerParams = { + id: string +} + +export type UpdateMcpServerQuery = Record + +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 +} + +type UpdateMcpServerResponseRef0 = { + 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 +} + +export type UpdateMcpServerResponse = { + data: UpdateMcpServerResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/rows` */ +export type UpdateRowsByFilterParams = { + tableId: string +} + +export type UpdateRowsByFilterQuery = Record + +type UpdateRowsByFilterBodyRef0 = + | { + all: Array< + | UpdateRowsByFilterBodyRef0 + | { + 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< + | UpdateRowsByFilterBodyRef0 + | { + 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 + } + > + } + +type UpdateRowsByFilterBodyRef1 = Record + +export type UpdateRowsByFilterBody = { + workspaceId: string + filter: UpdateRowsByFilterBodyRef0 + data: UpdateRowsByFilterBodyRef1 + limit?: number +} + +type UpdateRowsByFilterResponseRef0 = { + updatedCount: number + updatedRowIds: Array +} + +export type UpdateRowsByFilterResponse = { + data: UpdateRowsByFilterResponseRef0 +} + +/** `PATCH /api/v2/skills/[id]` */ +export type UpdateSkillParams = { + id: string +} + +export type UpdateSkillQuery = Record + +export type UpdateSkillBody = { + workspaceId: string + name?: string + description?: string + content?: string +} + +type UpdateSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type UpdateSkillResponse = { + data: UpdateSkillResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]` */ +export type UpdateTableParams = { + tableId: string +} + +export type UpdateTableQuery = Record + +type UpdateTableBodyRef0 = string + +export type UpdateTableBody = { + workspaceId: string + name?: string + description?: string | null + folderPath?: UpdateTableBodyRef0 +} + +type UpdateTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type UpdateTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: 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 + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: UpdateTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type UpdateTableResponse = { + data: UpdateTableResponseRef1 +} + +/** `PATCH /api/v2/tables/[tableId]/columns` */ +export type UpdateTableColumnParams = { + tableId: string +} + +export type UpdateTableColumnQuery = Record + +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 + } +} + +type UpdateTableColumnResponseRef0 = { + 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 + }> +} + +export type UpdateTableColumnResponse = { + data: UpdateTableColumnResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ +export type UpdateTableRowParams = { + tableId: string + rowId: string +} + +export type UpdateTableRowQuery = Record + +type UpdateTableRowBodyRef0 = Record + +export type UpdateTableRowBody = { + workspaceId: string + data: UpdateTableRowBodyRef0 +} + +type UpdateTableRowResponseRef0 = Record + +type UpdateTableRowResponseRef1 = { + id: string + data: UpdateTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +export type UpdateTableRowResponse = { + data: UpdateTableRowResponseRef1 +} + +/** `PATCH /api/v2/tables/[tableId]/views/[viewId]` */ +export type UpdateTableViewParams = { + tableId: string + viewId: string +} + +export type UpdateTableViewQuery = Record + +type UpdateTableViewBodyRef0 = + | { + all: Array< + | UpdateTableViewBodyRef0 + | { + 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< + | UpdateTableViewBodyRef0 + | { + 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 + } + > + } + | { + 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 UpdateTableViewBody = { + workspaceId: string + name?: string + config?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + configPatch?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault?: boolean +} + +type UpdateTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type UpdateTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: UpdateTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type UpdateTableViewResponse = { + data: UpdateTableViewResponseRef1 +} + +/** `PATCH /api/v2/workflows/[id]` */ +export type UpdateWorkflowParams = { + id: string +} + +export type UpdateWorkflowQuery = Record + +type UpdateWorkflowBodyRef0 = string + +export type UpdateWorkflowBody = { + name?: string + description?: string | null + folderPath?: UpdateWorkflowBodyRef0 +} + +type UpdateWorkflowResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type UpdateWorkflowResponse = { + data: UpdateWorkflowResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/groups` */ +export type UpdateWorkflowGroupParams = { + tableId: string +} + +export type UpdateWorkflowGroupQuery = Record + +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 +} + +type UpdateWorkflowGroupResponseRef0 = { + 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 +} + +type UpdateWorkflowGroupResponseRef1 = { + group: UpdateWorkflowGroupResponseRef0 + 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 + }> +} + +export type UpdateWorkflowGroupResponse = { + data: UpdateWorkflowGroupResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents` */ +export type UploadKnowledgeDocumentParams = { + id: string +} + +export type UploadKnowledgeDocumentQuery = { + workspaceId: string +} + +type UploadKnowledgeDocumentResponseRef0 = { + 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 +} + +export type UploadKnowledgeDocumentResponse = { + data: UploadKnowledgeDocumentResponseRef0 +} + +/** `PATCH /api/v2/files/[fileId]/share` */ +export type UpsertFileShareParams = { + fileId: string +} + +export type UpsertFileShareQuery = Record + +export type UpsertFileShareBody = { + workspaceId: string + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +type UpsertFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type UpsertFileShareResponse = { + data: UpsertFileShareResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/rows/upsert` */ +export type UpsertTableRowParams = { + tableId: string +} + +export type UpsertTableRowQuery = Record + +type UpsertTableRowBodyRef0 = Record + +export type UpsertTableRowBody = { + workspaceId: string + data: UpsertTableRowBodyRef0 + conflictTarget?: string +} + +type UpsertTableRowResponseRef0 = Record + +type UpsertTableRowResponseRef1 = { + id: string + data: UpsertTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +type UpsertTableRowResponseRef2 = { + row: UpsertTableRowResponseRef1 + operation: 'insert' | 'update' +} + +export type UpsertTableRowResponse = { + data: UpsertTableRowResponseRef2 +} + +/** + * 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 }, + }, + }, + abortKnowledgeDocumentUpload: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Abort Document 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 }, + }, + }, + bulkDeleteFiles: { + method: 'POST', + path: '/api/v2/files/bulk-delete', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Files', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', required: true }, + }, + }, + bulkUpdateKnowledgeDocuments: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Bulk Enable or Disable Documents', + body: { + workspaceId: { kind: 'string', required: true }, + operation: { kind: 'enum', required: true, values: ['enable', 'disable'] as const }, + documentIds: { kind: 'array' }, + selectAll: { kind: 'boolean' }, + enabledFilter: { kind: 'enum', values: ['all', 'enabled', 'disabled'] as const }, + }, + }, + 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' }, + }, + }, + cancelWorkflowRun: { + method: 'POST', + path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Cancel Workflow Run', + }, + 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 }, + }, + }, + completeKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Complete Document Upload', + query: { + workspaceId: { kind: 'string', 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 }, + }, + }, + createCredentialConnection: { + method: 'POST', + path: '/api/v2/credentials/connections', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Credential Connection', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, + }, + 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 }, + }, + }, + createFile: { + method: 'POST', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string' }, + folderPath: { kind: 'string' }, + content: { kind: 'string', default: '' }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, + createFileFolder: { + method: 'POST', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { 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 }, + folderPath: { 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 }, + }, + }, + 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' }, + folderPath: { kind: 'string' }, + }, + }, + createKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Create Document Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + tag1: { kind: 'string' }, + tag2: { kind: 'string' }, + tag3: { kind: 'string' }, + tag4: { kind: 'string' }, + tag5: { kind: 'string' }, + tag6: { kind: 'string' }, + tag7: { kind: 'string' }, + processingOptions: { kind: 'object' }, + }, + }, + createKnowledgeDocumentUploadPartUrls: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Create Document Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, + createKnowledgeFolder: { + method: 'POST', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, + 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, default: 'streamable-http' }, + url: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer', default: 30000 }, + retries: { kind: 'integer', default: 3 }, + enabled: { kind: 'boolean', default: true }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + createServiceAccountCredential: { + method: 'POST', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Service-Account Credential', + body: { + workspaceId: { kind: 'string', required: true }, + type: { kind: 'string', required: true }, + providerId: { kind: 'string', required: true }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + id: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + certificateId: { kind: 'string' }, + orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, + authMethod: { kind: 'string' }, + privateKey: { kind: 'string' }, + username: { 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' }, + workspaceId: { kind: 'string', required: true }, + schema: { kind: 'object', required: true }, + folderPath: { 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' }, + }, + }, + createTableFolder: { + method: 'POST', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, + 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: 'object' }, + createColumns: { kind: 'array' }, + 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' }, + folderPath: { kind: 'string' }, + }, + }, + createWorkflowFolder: { + method: 'POST', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Workflow Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, + deleteCredential: { + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + pathParams: ['credentialId'] as const, + responseMode: 'json', + summary: 'Disconnect 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 }, + }, + }, + deleteFileFolder: { + method: 'DELETE', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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 }, + }, + }, + deleteKnowledgeFolder: { + method: 'DELETE', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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 }, + }, + }, + deleteSecret: { + method: 'DELETE', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Delete Secret', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + }, + }, + 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 }, + }, + }, + deleteTableFolder: { + method: 'DELETE', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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', + }, + deleteWorkflowFolder: { + method: 'DELETE', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Workflow Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, + }, + }, + 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 Workflow', + body: { + input: { kind: 'object' }, + async: { kind: 'boolean', default: false }, + executionTimeoutSeconds: { kind: 'integer' }, + 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 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', + query: { + organizationId: { kind: 'string', required: true }, + }, + }, + getBillingStatus: { + method: 'GET', + path: '/api/v2/billing/status', + pathParams: [] as const, + responseMode: 'json', + summary: 'Get Billing Status', + query: { + workspaceId: { kind: 'string' }, + }, + }, + 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 }, + }, + }, + getFile: { + method: 'GET', + path: '/api/v2/files/[fileId]/metadata', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Get File Metadata', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + }, + }, + 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 }, + }, + }, + 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/[runId]', + pathParams: ['runId'] 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 }, + }, + }, + getWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Workflow', + }, + getWorkflowDeployment: { + method: 'GET', + path: '/api/v2/workflows/[id]/deployment', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Workflow Deployment', + }, + getWorkflowRun: { + method: 'GET', + path: '/api/v2/workflows/[id]/runs/[runId]', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Get Workflow Run', + query: { + includeOutput: { kind: 'boolean' }, + selectedOutputs: { kind: 'string' }, + }, + }, + getWorkflowVersion: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + pathParams: ['id', 'version'] as const, + responseMode: 'json', + summary: 'Get Workflow Version', + }, + getWorkspace: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'Get Workspace', + }, + importWorkflow: { + method: 'POST', + path: '/api/v2/workflows/import', + pathParams: [] as const, + responseMode: 'json', + summary: 'Import Workflow', + body: { + workspaceId: { kind: 'string', required: true }, + workflow: { kind: 'unknown', required: true }, + folderPath: { kind: 'string' }, + name: { kind: 'string' }, + description: { kind: 'string' }, + }, + }, + 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' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + includeDeparted: { kind: 'boolean' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + organizationId: { kind: 'string', required: true }, + actorEmail: { kind: 'string' }, + }, + }, + listBillingLogs: { + method: 'GET', + path: '/api/v2/billing/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Billing Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'sim-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' }, + }, + }, + listCredentialProviders: { + method: 'GET', + path: '/api/v2/credentials/providers', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Credential Providers', + query: { + workspaceId: { kind: 'string', required: true }, + search: { 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', '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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listFileFolders: { + method: 'GET', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listFiles: { + method: 'GET', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Files', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { kind: 'string' }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + 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: 'integer', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listKnowledgeBases: { + method: 'GET', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Knowledge Bases', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { 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: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + 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' }, + tagFilters: { kind: 'string' }, + }, + }, + listKnowledgeFolders: { + method: 'GET', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listKnowledgeTags: { + method: 'GET', + path: '/api/v2/knowledge/[id]/tags', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Tags', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listLogs: { + method: 'GET', + path: '/api/v2/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Logs', + query: { + workspaceId: { kind: 'string', required: true }, + workflowIds: { kind: 'string' }, + triggers: { kind: 'string' }, + level: { kind: 'enum', values: ['info', 'error'] as const }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + minDurationMs: { kind: 'integer' }, + maxDurationMs: { kind: 'integer' }, + 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: 'integer', default: 100 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + runId: { kind: 'string' }, + folderPaths: { kind: 'string' }, + }, + }, + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listMcpServerTools: { + method: 'GET', + path: '/api/v2/mcp-servers/[id]/tools', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List MCP Server Tools', + query: { + workspaceId: { kind: 'string', required: true }, + refresh: { kind: 'boolean' }, + }, + }, + listSecrets: { + method: 'GET', + path: '/api/v2/secrets', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Secrets', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['workspace', 'personal'] as const }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listTableFolders: { + method: 'GET', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + 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', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listTables: { + method: 'GET', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { 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: 'integer', 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 }, + }, + }, + listWorkflowFolders: { + method: 'GET', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workflow Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + 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 }, + }, + }, + listWorkflowRuns: { + method: 'GET', + path: '/api/v2/workflows/[id]/runs', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Workflow Runs', + query: { + status: { + kind: 'enum', + values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, + }, + trigger: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, + listWorkflows: { + method: 'GET', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workflows', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { kind: 'string' }, + deployedOnly: { kind: 'boolean' }, + limit: { kind: 'integer', 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' }, + }, + }, + listWorkspaceMembers: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/members', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'List Workspace Members', + 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', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', required: true }, + targetFolderPath: { 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' }, + }, + }, + queryRowsCount: { + method: 'POST', + path: '/api/v2/tables/[tableId]/query/count', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Count Rows', + body: { + workspaceId: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + }, + }, + relocateFileFolder: { + method: 'PATCH', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + relocateKnowledgeFolder: { + method: 'PATCH', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + relocateTableFolder: { + method: 'PATCH', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + relocateWorkflowFolder: { + method: 'PATCH', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Workflow Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + 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 }, + }, + }, + resumeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/runs/[runId]/resume', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Resume Workflow Run', + body: { + contextId: { kind: 'string', required: true }, + input: { kind: 'unknown' }, + }, + }, + rollbackWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Rollback Workflow', + body: { + version: { kind: 'integer' }, + }, + }, + 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' }, + rerankerEnabled: { kind: 'boolean' }, + rerankerModel: { + kind: 'enum', + values: ['rerank-v4.0-pro', 'rerank-v4.0-fast', 'rerank-v3.5'] as const, + default: 'rerank-v4.0-fast', + }, + rerankerInputCount: { kind: 'integer' }, + }, + }, + setSecret: { + method: 'PUT', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Set Secret', + body: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + value: { kind: 'string', required: true }, + }, + }, + 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', + }, + 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' }, + }, + }, + updateKnowledgeBase: { + method: 'PATCH', + 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' }, + folderPath: { kind: 'string' }, + }, + }, + updateKnowledgeDocument: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + summary: 'Update Document', + body: { + workspaceId: { kind: 'string', required: true }, + filename: { kind: 'string' }, + enabled: { kind: 'boolean' }, + tag1: { kind: 'string' }, + tag2: { kind: 'string' }, + tag3: { kind: 'string' }, + tag4: { kind: 'string' }, + tag5: { kind: 'string' }, + tag6: { kind: 'string' }, + tag7: { kind: 'string' }, + number1: { kind: 'number' }, + number2: { kind: 'number' }, + number3: { kind: 'number' }, + number4: { kind: 'number' }, + number5: { kind: 'number' }, + date1: { kind: 'string' }, + date2: { kind: 'string' }, + boolean1: { kind: 'boolean' }, + boolean2: { kind: 'boolean' }, + boolean3: { kind: 'boolean' }, + retryProcessing: { kind: 'boolean' }, + }, + }, + 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, default: 'streamable-http' }, + url: { kind: 'string' }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer', default: 30000 }, + retries: { kind: 'integer', default: 3 }, + enabled: { kind: 'boolean', default: true }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + updateRowsByFilter: { + method: 'PATCH', + 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: 'object', 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' }, + description: { kind: 'string' }, + folderPath: { 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: 'object', 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' }, + folderPath: { 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: 'PATCH', + 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: 'object', required: true }, + conflictTarget: { kind: 'string' }, + }, + }, +} as const + +export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/helpers.ts b/packages/sim-cli/src/helpers.ts new file mode 100644 index 00000000000..6b5f77fe373 --- /dev/null +++ b/packages/sim-cli/src/helpers.ts @@ -0,0 +1,12 @@ +/** + * Local copies of the shared helpers. + * + * `@sim/utils` is a private workspace package, so the published `sim` package + * cannot depend on it — importing it would resolve in the monorepo and fail for + * anyone installing from npm. + */ + +/** Resolves after `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} 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..39b5177e58e --- /dev/null +++ b/packages/sim-cli/src/http/client.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { + formatApiErrorDetails, + requestAllPages, + resolvePath, + SimApiError, + SimClient, +} from './client' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('cursor pagination', () => { + it('follows v2 cursors through the requested item limit', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ data: ['a', 'b'], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: ['c'], nextCursor: null }) + + await expect( + requestAllPages({ request } as Pick, '/api/v2/items', { + query: { workspaceId: 'workspace-1' }, + pageSize: 2, + limit: 3, + auth: 'optional', + }) + ).resolves.toEqual(['a', 'b', 'c']) + expect(request).toHaveBeenNthCalledWith(1, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 2, cursor: null }, + auth: 'optional', + }) + expect(request).toHaveBeenNthCalledWith(2, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 1, cursor: 'next' }, + auth: 'optional', + }) + }) +}) + +describe('API errors', () => { + it('keeps structured details and does not misdiagnose an ordinary 404', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'NOT_FOUND', + message: 'Workflow not found', + details: { id: 'missing' }, + }, + }), + { status: 404 } + ) + ) + ) + const client = new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: 'key', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + + const request = client.request('/api/v2/workflows/missing') + await expect(request).rejects.toMatchObject({ + message: 'Workflow not found', + code: 'NOT_FOUND', + details: { id: 'missing' }, + }) + await expect(request).rejects.not.toThrow(/v2 API may not be enabled/) + }) + + it('turns nested validation details into concise path-aware lines', () => { + const lines = formatApiErrorDetails([ + { + code: 'invalid_union', + path: ['predicate'], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_union', + path: ['all', 0], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_value', + path: ['op'], + message: 'Expected one of eq, ne', + }, + ], + ], + }, + ], + ], + }, + ]) + + expect(lines).toEqual([' details:', ' predicate.all.0.op: Expected one of eq, ne']) + }) + + it('keeps non-validation details as JSON', () => { + expect(formatApiErrorDetails({ id: 'missing' })).toEqual([' details: {"id":"missing"}']) + }) +}) + +describe('raw requests', () => { + function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { + return new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: options.apiKey ?? null, + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + } + + it('returns an unconsumed response and forwards an abort signal', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + + const response = await client().requestRaw('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { workspaceId: 'ws_1', prompt: 'hello' }, + signal: controller.signal, + }) + + expect(response.bodyUsed).toBe(false) + expect(await response.text()).toBe('stream body') + expect(fetch).toHaveBeenCalledWith( + 'https://sim.example/api/v2/chat', + expect.objectContaining({ + method: 'POST', + signal: controller.signal, + headers: expect.objectContaining({ + accept: 'text/event-stream', + 'content-type': 'application/json', + 'x-api-key': 'key', + }), + }) + ) + }) + + it('turns an aborted fetch into a clean CLI error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) + const controller = new AbortController() + controller.abort() + + await expect( + client().requestRaw('/api/v2/chat', { signal: controller.signal }) + ).rejects.toMatchObject({ + message: 'Request cancelled.', + status: 0, + }) + }) + + it('allows auth-disabled self-hosted chat without sending an API key', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + const workspaceId = unauthenticated.requireWorkspace(undefined, { auth: 'optional' }) + + await unauthenticated.requestRaw('/api/v2/chat', { + method: 'POST', + body: { workspaceId, prompt: 'hello' }, + auth: 'optional', + }) + + expect(workspaceId).toBe('ws_1') + expect(fetch).toHaveBeenCalledOnce() + const headers = fetch.mock.calls[0][1].headers as Record + expect(headers).not.toHaveProperty('x-api-key') + }) + + it('keeps authentication required by default for every other command', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + + expect(() => unauthenticated.requireWorkspace()).toThrow(/Not logged in/) + await expect(unauthenticated.requestRaw('/api/v2/workflows')).rejects.toThrow(/Not logged in/) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +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', + 'getBillingStatus', + 'listBillingLogs', + 'listWorkflowRuns', + 'getWorkflowRun', + 'resumeWorkflow', + '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', + 'abortKnowledgeDocumentUpload', + '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..dbebfecc7d5 --- /dev/null +++ b/packages/sim-cli/src/http/client.ts @@ -0,0 +1,274 @@ +import type { ResolvedProfile } from '../config/index' + +/** + * 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, nextCursor }` — one page of a list. */ +export interface V2Page { + data: T[] + nextCursor: string | null +} + +export interface RequestAllPagesOptions extends Omit { + query?: Record + /** Server page size; callers choose one accepted by the endpoint contract. */ + pageSize: number + /** Maximum items to return. Omit to follow the cursor through the full list. */ + limit?: number +} + +export type QueryValue = string | number | boolean | null | undefined +export type AuthRequirement = 'required' | 'optional' + +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 + /** Cancels both the initial request and any subsequent streaming body read. */ + signal?: AbortSignal + /** Self-hosted, auth-disabled routes may deliberately omit a local API key. */ + auth?: AuthRequirement +} + +export interface WorkspaceOptions { + auth?: AuthRequirement +} + +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)}…` +} + +/** Formats nested validation issues as readable, path-aware lines. */ +export function formatApiErrorDetails(details: unknown): string[] { + const issues = new Set() + + const visit = (value: unknown, parentPath: string[] = []): void => { + if (Array.isArray(value)) { + value.forEach((item) => visit(item, parentPath)) + return + } + if (!value || typeof value !== 'object') return + + const issue = value as Record + const ownPath = Array.isArray(issue.path) ? issue.path.map(String) : [] + const path = [...parentPath, ...ownPath] + const nested = Array.isArray(issue.errors) ? issue.errors : [] + + if (nested.length > 0) { + visit(nested, path) + return + } + if (typeof issue.message !== 'string' || issue.message === 'Invalid input') return + + issues.add(`${path.length > 0 ? path.join('.') : 'request'}: ${issue.message}`) + } + + visit(details) + if (issues.size === 0) return [` details: ${truncate(JSON.stringify(details), 1000)}`] + + const visible = [...issues].slice(0, 8) + const lines = [' details:', ...visible.map((issue) => ` ${issue}`)] + if (issues.size > visible.length) lines.push(` … ${issues.size - visible.length} more issues`) + return lines +} + +export class SimClient { + constructor(private readonly profile: ResolvedProfile) {} + + private resolveApiKey(auth: AuthRequirement = 'required'): string | undefined { + if (!this.profile.apiKey) { + if (auth === 'optional') return undefined + 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. + * + * By default this 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. Auth-disabled self-hosted protocols opt out explicitly. + */ + requireWorkspace(explicit?: string, options: WorkspaceOptions = {}): string { + this.resolveApiKey(options.auth) + 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 + } + + /** + * Makes a request without consuming its body. Authentication is required + * unless a self-hosted protocol explicitly opts out. + * + * JSON commands use {@link request}; streaming and binary protocols keep the + * raw response so they can process bytes incrementally. HTTP failures still + * become the same structured `SimApiError` either way. + */ + async requestRaw(path: string, options: RequestOptions = {}): Promise { + const apiKey = this.resolveApiKey(options.auth) + + 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: { + ...(apiKey ? { 'x-api-key': apiKey } : {}), + accept: 'application/json', + ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...options.headers, + }, + body: hasBody ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }) + } catch (cause) { + if (options.signal?.aborted) { + throw new SimApiError('Request cancelled.', 0) + } + throw new SimApiError( + `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, + 0 + ) + } + + if (!response.ok) { + const raw = await response.text() + const error = toApiError(response.status, raw) + if (response.status === 401) { + error.message = `${error.message} — run: sim login --profile ${this.profile.name}` + } + throw error + } + + return response + } + + async request(path: string, options: RequestOptions = {}): Promise { + const response = await this.requestRaw(path, options) + const raw = await response.text() + + if (!raw) return undefined as T + return JSON.parse(raw) as T + } +} + +/** Follows a standard v2 cursor envelope without duplicating pagination loops. */ +export async function requestAllPages( + client: Pick, + path: string, + options: RequestAllPagesOptions +): Promise { + const { query, pageSize, limit: requestedLimit, ...requestOptions } = options + const limit = requestedLimit ?? Number.POSITIVE_INFINITY + if (limit <= 0) return [] + + const items: T[] = [] + let cursor: string | null = null + do { + const page: V2Page = await client.request>(path, { + ...requestOptions, + query: { + ...query, + limit: Math.min(pageSize, limit - items.length), + cursor, + }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < limit) + + return items.slice(0, limit) +} + +/** + * 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..4e26c52ed1c --- /dev/null +++ b/packages/sim-cli/src/index.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import chalk from 'chalk' +import { Command, Option } from 'commander' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth' +import { configureCommand } from './commands/configure' +import { attachCredentialCommands } from './commands/credentials' +import { attachProtocolCommands } from './commands/protocol/index' +import { attachSecretCommands } from './commands/secrets' +import { OUTPUT_FORMATS, ProfileConfigError } from './config/index' +import { formatApiErrorDetails, SimApiError } from './http/client' +import { sanitize } from './output/render' +import { buildGeneratedCommands } from './runtime/build' + +const program = new Command() + +function readPackageVersion(): string { + const metadata: unknown = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) + if ( + typeof metadata !== 'object' || + metadata === null || + !('version' in metadata) || + typeof metadata.version !== 'string' + ) { + throw new Error('CLI package metadata is missing a valid version') + } + return metadata.version +} + +program + .name('sim') + .description('Talk to the Sim API from your terminal') + .version(readPackageVersion()) + .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)') + .addOption( + new Option('--output ', 'Output format for this command').choices([...OUTPUT_FORMATS]) + ) + +program.addCommand(loginCommand()) +program.addCommand(logoutCommand()) +program.addCommand(whoamiCommand()) +program.addCommand(profilesCommand()) +program.addCommand(configureCommand()) + +for (const command of buildGeneratedCommands()) { + program.addCommand(command) +} + +attachCredentialCommands(program) +attachProtocolCommands(program) +attachSecretCommands(program) + +program.addHelpText( + 'after', + ` +Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in +~/.sim/credentials (0600). Select one with -P, --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 --output json tables get tbl_123 Override output for one command + $ sim configure --set-output json Save a profile output default + $ sim knowledge search --query "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 ProfileConfigError) { + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + process.exit(1) + } + if (error instanceof SimApiError) { + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) { + console.error(chalk.dim(sanitize(line))) + } + } + 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..a72caa2946b --- /dev/null +++ b/packages/sim-cli/src/output/render.test.ts @@ -0,0 +1,343 @@ +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' + +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('can preserve a containing response for machine output', () => { + const rows = [{ name: 'alpha', status: 'error' }] + const response = { results: rows, totalResults: 1 } + printList('json', rows, COLUMNS, response) + expect(JSON.parse(logged[0])).toEqual(response) + }) + + 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') + }) + + it.each(['text', 'table'] as const)('sanitizes API-controlled labels in %s output', (format) => { + printRecord(format, [[`${ESC}]0;pwned${BEL}safe\nlabel`, 'value']], {}) + + expect(logged.join('\n')).not.toContain(ESC) + expect(logged.join('\n')).not.toContain(BEL) + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('safe label') + }) +}) + +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('removes bidi formatting controls while preserving ordinary RTL text', () => { + expect(sanitize('safe\u202eevil\u202c \u2066host\u2069 مرحبا')).toBe('safeevil host مرحبا') + }) + + 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('normalizes CRLF and removes a lone carriage return that could overwrite a line', () => { + expect(sanitize('first\r\nsecond\roverwrite')).toBe('first\nsecondoverwrite') + }) + + 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..3c748b79bca --- /dev/null +++ b/packages/sim-cli/src/output/render.ts @@ -0,0 +1,307 @@ +import chalk from 'chalk' +import { dump } from 'js-yaml' +import type { OutputFormat } from '../config/index' +import { displayWidth } from './terminal-text' + +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; CR is normalized below + ].join('|'), + 'g' +) + +// Directional formatting marks can visually reorder an otherwise safe label +// or URL without changing its underlying bytes. Remove only the explicit +// controls; ordinary Hebrew, Arabic, and other right-to-left text is preserved. +const BIDI_CONTROL_PATTERN = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu + +/** + * 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 { + // Preserve normal Windows line endings without leaving a lone carriage + // return capable of moving the cursor back over already-rendered text. + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '') + .replace(CONTROL_PATTERN, '') + .replace(BIDI_CONTROL_PATTERN, '') +} + +/** Flattens untrusted terminal text into one compact, display-safe line. */ +export function safeOneLine(value: string): string { + return sanitize(value) + .replace(/[\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +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. + */ +/** + * Visible width of a cell. + * + * Delegates to the grapheme-aware measurement: the previous implementation + * counted stripped string length, so emoji and East Asian characters measured + * as one column and mis-aligned every table containing them. + */ +export function visibleWidth(value: string): number { + return displayWidth(value) +} + +/** + * 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[], + raw: unknown = rows +): void { + const machine = renderMachine(format, raw) + 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 + } + + const safeFields = fields.map<[string, string]>(([label, value]) => [safeOneLine(label), value]) + + if (format === 'text') { + for (const [label, value] of safeFields) { + console.log(`${label}\t${oneLine(stripAnsi(value))}`) + } + return + } + + const width = Math.max(...safeFields.map(([label]) => visibleWidth(label))) + for (const [label, value] of safeFields) { + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) + } +} diff --git a/packages/sim-cli/src/output/terminal-text.ts b/packages/sim-cli/src/output/terminal-text.ts new file mode 100644 index 00000000000..734f18b61d7 --- /dev/null +++ b/packages/sim-cli/src/output/terminal-text.ts @@ -0,0 +1,106 @@ +/** + * Grapheme-aware terminal text primitives. + * + * Extracted from the chat terminal because they are pure and have no dependency + * on it: width, truncation, padding and cursor-index arithmetic that correctly + * handle combining marks, emoji and East Asian wide characters. `output/render` + * previously carried weaker copies that measured by string length. + */ +const RESET = `${String.fromCharCode(27)}[0m` + +const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +export function graphemes(value: string): Array<{ segment: string; index: number }> { + return [...GRAPHEME_SEGMENTER.segment(value)].map(({ segment, index }) => ({ segment, index })) +} +/** First grapheme cluster of a string, or null when it is empty. */ +export function firstGrapheme(value: string): string | null { + return GRAPHEME_SEGMENTER.segment(value)[Symbol.iterator]().next().value?.segment ?? null +} + +export function previousGraphemeIndex(value: string, cursor: number): number { + let previous = 0 + for (const part of graphemes(value)) { + if (part.index >= cursor) break + previous = part.index + } + return previous +} +export function nextGraphemeIndex(value: string, cursor: number): number { + for (const part of graphemes(value)) { + if (part.index > cursor) return part.index + if (part.index === cursor) return part.index + part.segment.length + } + return value.length +} +export function lineStart(value: string, cursor: number): number { + const newline = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + return newline < 0 ? 0 : newline + 1 +} +export function lineEnd(value: string, cursor: number): number { + const newline = value.indexOf('\n', cursor) + return newline < 0 ? value.length : newline +} +export function displayWidth(value: string): number { + let width = 0 + for (const part of graphemes(value.replace(/\u001b\[[0-9;:]*m/gu, ''))) { + width += graphemeWidth(part.segment) + } + return width +} +export function graphemeWidth(value: string): number { + if (!value || value === '\n') return 0 + if (/^\p{Mark}+$/u.test(value)) return 0 + if (value.includes('\u200d') || /\p{Extended_Pictographic}/u.test(value)) return 2 + const codePoint = value.codePointAt(0) ?? 0 + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0 + return isWideCodePoint(codePoint) ? 2 : 1 +} +export function isWideCodePoint(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) + ) +} +export function truncateDisplay(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + let result = '' + let used = 0 + for (const part of graphemes(value)) { + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result += part.segment + used += partWidth + } + return `${result}…${RESET}` +} +export function tailToWidth(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + const parts = graphemes(value) + let result = '' + let used = 0 + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index] + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result = `${part.segment}${result}` + used += partWidth + } + return `…${result}` +} +/** Squares off a ragged art line so every box border starts at the same column. */ +export function artPad(line: string, width: number): string { + return ' '.repeat(Math.max(0, width - displayWidth(line))) +} diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts new file mode 100644 index 00000000000..553da8a863a --- /dev/null +++ b/packages/sim-cli/src/output/trace.ts @@ -0,0 +1,115 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index' +import { duration, sanitize } from './render' + +type TraceSpan = Record + +function traceSpan(value: unknown): TraceSpan { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Trace contains a malformed span') + } + return value as TraceSpan +} + +function requiredText(span: TraceSpan, field: string): string { + const value = span[field] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Trace span is missing ${field}`) + } + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalText(span: TraceSpan, field: string): string | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'string') throw new Error(`Trace span ${field} must be a string`) + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalNumber(span: TraceSpan, field: string): number | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Trace span ${field} must be a finite number`) + } + return value +} + +function costTotal(span: TraceSpan): number | undefined { + const value = span.cost + if (value === undefined) return undefined + const cost = traceSpan(value) + return optionalNumber(cost, 'total') +} + +function appendValue(lines: string[], indent: string, label: string, value: unknown): void { + if (value === undefined) return + const encoded = JSON.stringify(value, null, 2) + if (encoded === undefined) throw new Error(`Trace span ${label} cannot be rendered`) + const valueLines = sanitize(encoded).split('\n') + if (valueLines.length === 1) { + lines.push(`${indent}${label}: ${valueLines[0]}`) + return + } + lines.push(`${indent}${label}:`) + lines.push(...valueLines.map((line) => `${indent} ${line}`)) +} + +function renderSpan(value: unknown, depth: number): string[] { + const span = traceSpan(value) + const indent = ' '.repeat(depth) + const detailIndent = `${indent} ` + const name = requiredText(span, 'name') + const type = requiredText(span, 'type') + const status = optionalText(span, 'status') + const elapsed = optionalNumber(span, 'durationMs') ?? optionalNumber(span, 'duration') + const totalCost = costTotal(span) + const summary = [ + `${indent}- ${name}`, + `[${type}]`, + status, + elapsed === undefined ? undefined : duration(Math.round(elapsed)), + totalCost === undefined ? undefined : `$${totalCost.toFixed(4)}`, + ] + .filter((part): part is string => Boolean(part)) + .join(' ') + const lines = [summary, `${detailIndent}id: ${requiredText(span, 'id')}`] + const blockId = optionalText(span, 'blockId') + if (blockId) lines.push(`${detailIndent}block: ${blockId}`) + const startTime = optionalText(span, 'startTime') + const endTime = optionalText(span, 'endTime') + if (startTime || endTime) { + lines.push(`${detailIndent}time: ${startTime ?? '—'} → ${endTime ?? '—'}`) + } + const relativeStartMs = optionalNumber(span, 'relativeStartMs') + if (relativeStartMs !== undefined) { + lines.push(`${detailIndent}relative start: ${duration(Math.round(relativeStartMs))}`) + } + const errorType = optionalText(span, 'errorType') + const errorMessage = optionalText(span, 'errorMessage') + if (errorType || errorMessage) { + lines.push(`${detailIndent}error: ${[errorType, errorMessage].filter(Boolean).join(': ')}`) + } + appendValue(lines, detailIndent, 'tokens', span.tokens) + appendValue(lines, detailIndent, 'input', span.input) + appendValue(lines, detailIndent, 'output', span.output) + appendValue(lines, detailIndent, 'tool calls', span.toolCalls) + + if (span.children !== undefined) { + if (!Array.isArray(span.children)) throw new Error('Trace span children must be an array') + for (const child of span.children) lines.push(...renderSpan(child, depth + 1)) + } + return lines +} + +/** Prints the complete recursive run trace for an explicitly expanded log. */ +export function printTraceSpans(format: OutputFormat, traceSpans: unknown[]): void { + if (format === 'json' || format === 'yaml') return + console.log('') + console.log(format === 'table' ? chalk.dim('trace:') : 'trace:') + if (traceSpans.length === 0) { + console.log(chalk.dim(' No trace spans.')) + return + } + console.log(traceSpans.flatMap((span) => renderSpan(span, 0)).join('\n')) +} 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..de9d2a939bb --- /dev/null +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -0,0 +1,1166 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from './build' + +/** + * 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, profileState } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, + profileState: { workspaceId: 'ws_local' as string | null }, +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => { + if (!profileState.workspaceId) throw new Error('workspace required') + return profileState.workspaceId + }, + }, + profile: { + workspaceId: profileState.workspaceId, + output: output.format, + name: 'default', + apiKey: 'k', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride().option('--workspace ') + for (const group of buildGeneratedCommands()) 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 +} + +function commandAt(...names: string[]): Command { + let current = program() + for (const name of names) { + const next = current.commands.find((command) => command.name() === name) + if (!next) throw new Error(`Missing command ${names.join(' ')}`) + current = next + } + return current +} + +async function run(argv: string[], response: unknown = { data: [], nextCursor: null }) { + mockRequest.mockReset() + mockRequest.mockResolvedValue(response) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await program().parseAsync(['node', 'sim', ...argv]) + return mockRequest.mock.calls[0] +} + +describe('commands parsed through commander', () => { + beforeEach(() => { + vi.restoreAllMocks() + profileState.workspaceId = 'ws_local' + }) + + 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('registers singular aliases for every plural resource group', () => { + const aliases = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + knowledge: 'kb', + logs: 'log', + 'mcp-servers': 'mcp-server', + secrets: 'secret', + skills: 'skill', + tables: 'table', + workflows: 'workflow', + workspaces: 'workspace', + } + + for (const [name, alias] of Object.entries(aliases)) { + expect( + program() + .commands.find((command) => command.name() === name) + ?.alias() + ).toBe(alias) + } + expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) + }) + + it('describes generated resource and sub-resource groups', () => { + expect(commandAt('tables').description()).toBe('Manage tables') + expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') + }) + + it('shows the command syntax when a required positional argument is missing', async () => { + const root = program() + const skills = root.commands.find((command) => command.name() === 'skills') + const update = skills?.commands.find((command) => command.name() === 'update') + if (!update) throw new Error('Missing command skills update') + + let errorOutput = '' + update.configureOutput({ + writeErr: (message) => { + errorOutput += message + }, + }) + + await expect(root.parseAsync(['node', 'sim', 'skills', 'update'])).rejects.toMatchObject({ + code: 'commander.missingArgument', + }) + expect(errorOutput).toContain("error: missing required argument 'id'") + expect(errorOutput).toContain('Example: sim skills update ') + expect(errorOutput).not.toContain('--id') + }) + + it('dispatches generated commands through their singular resource alias', async () => { + const [tablePath] = await run(['table', 'list']) + expect(tablePath).toBe('/api/v2/tables') + + const [filePath] = await run(['file', 'list']) + expect(filePath).toBe('/api/v2/files') + + const [knowledgePath] = await run(['kb', 'list']) + expect(knowledgePath).toBe('/api/v2/knowledge') + }) + + it('nests document commands under their knowledge base', async () => { + expect(program().commands.map((command) => command.name())).not.toContain('documents') + + const help = commandAt('knowledge', 'documents', 'get').helpInformation() + expect(help).toContain(' ') + expect(help).not.toContain('--kb') + + const [listPath, listOptions] = await run(['kb', 'documents', 'list', 'kb_1']) + expect(listPath).toBe('/api/v2/knowledge/kb_1/documents') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local' }) + + const [getPath, getOptions] = await run(['kb', 'documents', 'get', 'kb_1', 'doc_1']) + expect(getPath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) + + await expect(run(['kb', 'documents', 'delete', 'kb_1', 'doc_1'])).rejects.toThrow( + /document and its embeddings/ + ) + expect(mockRequest).not.toHaveBeenCalled() + + const [deletePath, deleteOptions] = await run([ + 'kb', + 'documents', + 'delete', + 'kb_1', + 'doc_1', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local' }) + + await expect(run(['kb', 'documents', 'get', 'kb_1'])).rejects.toThrow(/documentId/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('keeps billing status and logs as explicit subcommands', async () => { + expect( + commandAt('billing') + .commands.map((command) => command.name()) + .sort() + ).toEqual(['logs', 'status']) + + const help = commandAt('billing', 'logs').helpInformation() + expect(help).toContain('--source ') + expect(help).toMatch(/sim-chat combines Copilot and\s+workspace chat/) + expect(help).toContain('"sim-chat"') + expect(help).not.toContain('"workspace-chat"') + expect(help).not.toContain('"copilot"') + expect(help).not.toContain('One of: workflow') + + const [summaryPath, summaryOptions] = await run(['billing', 'status'], { + data: { + plan: 'pro', + status: 'active', + credits: { used: 10, limit: 100, remaining: 90 }, + }, + }) + expect(summaryPath).toBe('/api/v2/billing/status') + expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) + + const [, accountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(accountOptions.query).toEqual({}) + + profileState.workspaceId = null + const [, unconfiguredAccountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(unconfiguredAccountOptions.query).toEqual({}) + await expect(run(['billing', 'status'])).rejects.toThrow('workspace required') + profileState.workspaceId = 'ws_local' + await expect( + run(['--workspace', 'ws_other', 'billing', 'status', '--all-workspaces']) + ).rejects.toThrow('--all-workspaces cannot be combined with --workspace') + + const [logsPath, logsOptions] = await run([ + 'billing', + 'logs', + '--period', + '7d', + '--source', + 'sim-chat', + ]) + expect(logsPath).toBe('/api/v2/billing/logs') + expect(logsOptions.query).toMatchObject({ + workspaceId: 'ws_local', + period: '7d', + source: 'sim-chat', + }) + + const [, accountLogsOptions] = await run(['billing', 'logs', '--all-workspaces']) + expect(accountLogsOptions.query).not.toHaveProperty('workspaceId') + + for (const deprecated of ['copilot', 'workspace-chat']) { + await expect(run(['billing', 'logs', '--source', deprecated])).rejects.toThrow( + /allowed choices.*sim-chat/i + ) + expect(mockRequest).not.toHaveBeenCalled() + } + }) + + 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', + '--run-id', + 'run_1', + ]) + expect(options.query).toMatchObject({ + minDurationMs: 10, + maxDurationMs: 20, + minCost: 1, + runId: 'run_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('exposes inline file creation added by the v2 files contract', async () => { + const [path, options] = await run([ + 'file', + 'create', + '--name', + 'notes.txt', + '--content', + 'hello', + '--encoding', + 'utf-8', + ]) + expect(path).toBe('/api/v2/files') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + name: 'notes.txt', + content: 'hello', + encoding: 'utf-8', + }) + }) + + it('describes file metadata and sharing without fetching content', async () => { + const [path, options] = await run(['file', 'describe', 'file_1'], { + data: { id: 'file_1', sharing: { enabled: false } }, + }) + expect(path).toBe('/api/v2/files/file_1/metadata') + expect(options.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('reads and writes sharing through one upsert', async () => { + const [sharePath, shareOptions] = await run([ + 'file', + 'share', + 'set', + 'file_1', + '--is-active', + 'true', + '--auth-type', + 'email', + '--allowed-emails', + 'ada@example.com', + ]) + expect(sharePath).toBe('/api/v2/files/file_1/share') + expect(shareOptions.method).toBe('PATCH') + expect(shareOptions.body).toEqual({ + workspaceId: 'ws_local', + isActive: true, + authType: 'email', + allowedEmails: ['ada@example.com'], + }) + + // v2 has no unshare operation; disabling is the same upsert. + const [offPath, offOptions] = await run([ + 'file', + 'share', + 'set', + 'file_1', + '--is-active', + 'false', + ]) + expect(offPath).toBe('/api/v2/files/file_1/share') + expect(offOptions.body).toMatchObject({ isActive: false }) + + const [getPath, getOptions] = await run(['file', 'share', 'get', 'file_1'], { + data: { sharing: { enabled: false } }, + }) + expect(getPath).toBe('/api/v2/files/file_1/share') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('moves space-separated file ids to a folder path', async () => { + const [path, options] = await run([ + 'file', + 'mv', + '--file-ids', + 'file_1', + 'file_2', + '--to', + 'Archive', + ]) + expect(path).toBe('/api/v2/files/move') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + fileIds: ['file_1', 'file_2'], + targetFolderPath: 'Archive', + }) + }) + + it('uses Linux-style resource move commands without changing update syntax', async () => { + const [tablePath, tableOptions] = await run(['table', 'mv', 'tbl_1', 'Archive']) + expect(tablePath).toBe('/api/v2/tables/tbl_1') + expect(tableOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [workflowPath, workflowOptions] = await run(['workflow', 'mv', 'wf_1', 'Archive']) + expect(workflowPath).toBe('/api/v2/workflows/wf_1') + expect(workflowOptions.body).toEqual({ folderPath: 'Archive' }) + + const [knowledgePath, knowledgeOptions] = await run(['kb', 'mv', 'kb_1', 'Archive']) + expect(knowledgePath).toBe('/api/v2/knowledge/kb_1') + expect(knowledgeOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [, updateOptions] = await run(['workflow', 'update', 'wf_1', '--description', 'Updated']) + expect(updateOptions.body).toEqual({ description: 'Updated' }) + + const moveHelp = commandAt('workflows', 'mv').helpInformation() + expect(moveHelp).toContain(' ') + expect(moveHelp).not.toContain('--folder') + expect(commandAt('workflows', 'update').helpInformation()).not.toContain('update|mv') + }) + + it('exposes path-addressed folder commands under each resource', async () => { + const [createPath, createOptions] = await run(['table', 'folders', 'create', 'Reports']) + expect(createPath).toBe('/api/v2/tables/folders') + expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: 'Reports' }) + + const [movePath, moveOptions] = await run([ + 'table', + 'folders', + 'mv', + 'Reports', + 'Archive/Reports', + ]) + expect(movePath).toBe('/api/v2/tables/folders') + expect(moveOptions.body).toEqual({ + workspaceId: 'ws_local', + path: 'Reports', + destinationPath: 'Archive/Reports', + }) + + const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', 'Reports']) + expect(listPath).toBe('/api/v2/tables/folders') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: 'Reports' }) + + const [deletePath, deleteOptions] = await run([ + 'table', + 'folders', + 'delete', + 'Archive/Reports', + '--recursive', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/tables/folders') + expect(deleteOptions.query).toEqual({ + workspaceId: 'ws_local', + path: 'Archive/Reports', + recursive: true, + }) + + const [, nonRecursiveOptions] = await run([ + 'table', + 'folders', + 'delete', + 'Archive/Empty', + '--yes', + ]) + expect(nonRecursiveOptions.query).toEqual({ + workspaceId: 'ws_local', + path: 'Archive/Empty', + }) + + const help = commandAt('tables', 'folders', 'delete').helpInformation() + expect(help).toContain('--recursive') + expect(help).not.toContain('--recursive ') + expect(help).not.toContain('--no-recursive') + }) + + it('exposes named secrets separately from connected credentials', () => { + expect(commandAt('secrets', 'list').name()).toBe('list') + expect(commandAt('credentials', 'list').name()).toBe('list') + }) + + it('exposes workspace metadata and email-attributed members', async () => { + const getHelp = commandAt('workspaces', 'get').helpInformation() + expect(getHelp).not.toContain('') + + const [workspacePath] = await run(['workspace', 'get'], { + data: { id: 'ws_local' }, + }) + expect(workspacePath).toBe('/api/v2/workspaces/ws_local') + + profileState.workspaceId = null + await expect(run(['workspace', 'get'])).rejects.toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + profileState.workspaceId = 'ws_local' + + const membersHelp = commandAt('workspaces', 'members').helpInformation() + expect(membersHelp).not.toContain('') + + const [membersPath, membersOptions] = await run(['workspace', 'members']) + expect(membersPath).toBe('/api/v2/workspaces/ws_local/members') + expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) + }) + + 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('runs a workflow without input and keeps output selection distinct from rendering', async () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(help).toContain('--select-output ') + expect(help).toContain('blockName.field') + expect(help).toContain('agent_1.content') + expect(help).not.toContain('--output ') + + const [, withoutInput] = await run(['workflows', 'run', 'wf_1'], { data: { success: true } }) + expect(withoutInput.body).toEqual({}) + + const [, selected] = await run( + ['workflows', 'run', 'wf_1', '--select-output', 'agent.answer', 'save.result'], + { data: { success: true } } + ) + expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) + }) + + it('documents the table predicate and sort wire shapes in help', () => { + const help = commandAt('tables', 'rows', 'query').helpInformation() + expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('[{"field":"createdAt","direction":"desc"}]') + }) + + 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() + }) + + it('marks required flags in help and rejects omissions before a request', async () => { + const help = commandAt('tables', 'create').helpInformation() + expect(help).toMatch(/--name.*required/s) + expect(help).toMatch(/--schema.*required/s) + + await expect(run(['tables', 'create', '--name', 'Customers'])).rejects.toThrow( + /required option '--schema/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('shows repeated values and recovered enum choices accurately', async () => { + const help = commandAt('knowledge', 'search').helpInformation() + expect(help).toContain('--kb ') + expect(help).not.toMatch(/--kb[^\n]*JSON/) + expect(help).toMatch(/--search-mode.*vector.*hybrid/s) + + await expect( + run(['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'semantic']) + ).rejects.toThrow(/allowed choices are vector, hybrid/i) + + const [, options] = await run( + ['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'hybrid'], + { data: { results: [] } } + ) + expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' }) + }) + + it('documents space-separated and file-backed lists', () => { + const help = commandAt('files', 'move').helpInformation() + expect(help).toContain('--file-ids ') + expect(help).toMatch(/space-separated.*@path.*one\s+value\s+per\s+line/s) + }) + + it('advertises the file-content encoding choices', () => { + expect(commandAt('files', 'set-content').helpInformation()).toMatch( + /--encoding.*utf-8.*base64/s + ) + }) + + it('offers expanded trace output without changing the default summary', () => { + expect(commandAt('logs', 'get').description()).toBe('Show run diagnostics') + expect(commandAt('logs', 'get').helpInformation()).toMatch( + /--trace.*inputs, outputs, errors, timing,\s+and cost/s + ) + const listHelp = commandAt('logs', 'list').helpInformation() + expect(listHelp).toMatch(/--include-trace-spans.*implies full detail/s) + expect(listHelp).toMatch(/--include-final-output.*implies full detail/s) + }) + + it('uses a named workflow scope for run subresources', async () => { + expect(commandAt('workflows').commands.map((command) => command.name())).not.toContain( + 'executions' + ) + const runs = commandAt('workflows', 'runs') + expect(runs.commands.map((command) => command.name()).sort()).toEqual([ + 'cancel', + 'get', + 'list', + 'resume', + ]) + + const help = commandAt('workflows', 'runs', 'get').helpInformation() + expect(help).toContain('') + expect(help).toMatch(/--workflow .*required/s) + expect(help).toContain('--include-output') + expect(help).toContain('--select-output ') + + const [path, options] = await run([ + 'workflows', + 'runs', + 'get', + 'run_1', + '--workflow', + 'wf_1', + '--include-output', + '--select-output', + 'agent.content', + 'writer.text', + ]) + expect(path).toBe('/api/v2/workflows/wf_1/runs/run_1') + expect(options.query).toEqual({ + includeOutput: true, + selectedOutputs: 'agent.content,writer.text', + }) + + const [listPath] = await run(['workflows', 'runs', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/runs') + + const [cancelPath] = await run(['workflows', 'runs', 'cancel', 'run_1', '--workflow', 'wf_1']) + expect(cancelPath).toBe('/api/v2/workflows/wf_1/runs/run_1/cancel') + + const resumeHelp = commandAt('workflows', 'runs', 'resume').helpInformation() + expect(resumeHelp).toContain('') + expect(resumeHelp).toMatch(/--workflow .*required/s) + expect(resumeHelp).toMatch(/--context .*required/s) + + const [resumePath, resumeOptions] = await run([ + 'workflows', + 'runs', + 'resume', + 'run_1', + '--workflow', + 'wf_1', + '--context', + 'ctx_1', + '--input', + '{"approved":true}', + ]) + expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_1/resume') + expect(resumeOptions.body).toEqual({ + contextId: 'ctx_1', + input: { approved: true }, + }) + }) + + it('supports organization-wide audit listing explicitly', async () => { + const help = commandAt('audit-logs', 'list').helpInformation() + expect(help).toMatch(/--organization .*personal API key required.*required/s) + expect(help).toContain('--all-workspaces') + expect(help).toContain('--actor-email') + expect(help).not.toContain('--actor-id') + + const [, scopedOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--actor-email', + 'owner@example.com', + ]) + expect(scopedOptions.query).toMatchObject({ + organizationId: 'org_1', + workspaceId: 'ws_local', + actorEmail: 'owner@example.com', + }) + + const [, organizationOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--all-workspaces', + ]) + expect(organizationOptions.query).toMatchObject({ + organizationId: 'org_1', + limit: 100, + }) + expect(organizationOptions.query).not.toHaveProperty('workspaceId') + + const [detailPath, detailOptions] = await run([ + 'audit-logs', + 'get', + 'audit_1', + '--organization', + 'org_1', + ]) + expect(detailPath).toBe('/api/v2/audit-logs/audit_1') + expect(detailOptions.query).toEqual({ organizationId: 'org_1' }) + }) + + it('describes asynchronous workflow runs without a contradictory negative flag', () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(commandAt('workflows', 'run').description()).toBe('Run a deployed workflow') + expect(help).toContain('--async') + expect(help).not.toContain('--no-async') + }) +}) + +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' }) + }) + + it('keeps sensitive run detail opt-in for human log output', async () => { + const log = { + runId: 'run_1', + status: 'completed', + workflow: { name: 'Billing' }, + level: 'info', + trigger: 'api', + startedAt: '2026-08-04T00:00:00.000Z', + endedAt: null, + totalDurationMs: 50, + cost: { total: 0.001 }, + files: [], + workflowState: { env: { SECRET_TOKEN: 'encrypted-value' } }, + finalOutput: { recipient: 'private@example.com' }, + traceSpans: [ + { + id: 'span_1', + name: 'Workflow Execution', + type: 'workflow', + children: [ + { + id: 'span_2', + name: 'Send email', + type: 'block', + status: 'completed', + durationMs: 25, + cost: { total: 0.0005 }, + input: { recipient: 'trace-secret@example.com' }, + output: { delivered: true }, + }, + ], + }, + ], + } + + const human = await lines(['logs', 'get', 'run_1'], log, 'text') + expect(human.join('\n')).not.toContain('workflowState') + expect(human.join('\n')).not.toContain('SECRET_TOKEN') + expect(human.join('\n')).not.toContain('traceSpans') + expect(human.join('\n')).not.toContain('private@example.com') + expect(human.join('\n')).not.toContain('trace-secret@example.com') + expect(human.join('\n')).toContain('trace\t2 spans (use --trace)') + + const expanded = await lines(['logs', 'get', 'run_1', '--trace'], log, 'text') + expect(expanded.join('\n')).toContain('trace\t2 spans') + expect(expanded.join('\n')).not.toContain('(use --trace)') + expect(expanded.join('\n')).toContain('Workflow Execution [workflow]') + expect(expanded.join('\n')).toContain('Send email [block] completed 25ms $0.0005') + expect(expanded.join('\n')).toContain('trace-secret@example.com') + expect(expanded.join('\n')).toContain('"delivered": true') + + const machine = await lines(['logs', 'get', 'run_1'], log, 'json') + expect(JSON.parse(machine[0])).toMatchObject({ + workflowState: log.workflowState, + traceSpans: log.traceSpans, + finalOutput: log.finalOutput, + }) + + const yaml = await lines(['logs', 'get', 'run_1'], log, 'yaml') + expect(yaml.join('\n')).toContain('traceSpans:') + expect(yaml.join('\n')).toContain('span_2') + }) +}) + +describe('contract-selected list rendering', () => { + async function lines(argv: string[], data: unknown): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + output.format = 'text' + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => captured.push(line)) + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('renders knowledge results as rows instead of a truncated JSON blob', async () => { + const printed = await lines(['knowledge', 'search', '--kb', 'kb_1', '--query', 'refund'], { + results: [ + { + similarity: 0.91, + documentName: 'policy.md', + chunkIndex: 2, + content: 'Refunds are available for 30 days.', + }, + ], + query: 'refund', + totalResults: 1, + }) + + expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.']) + }) + + it('renders row matches as rows', async () => { + const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { + matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], + truncated: false, + }) + + expect(printed).toEqual(['3\trow_1\temail']) + }) + + it('maps custom-tool, credential, and secret fields to their actual response paths', async () => { + const tools = await lines( + ['custom-tools', 'list'], + [ + { + id: 'tool_1', + title: 'Lookup', + schema: { function: { description: 'Find a customer' } }, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(tools[0]).toContain('Lookup') + expect(tools[0]).toContain('Find a customer') + + const credentials = await lines( + ['credentials', 'list'], + [ + { + id: 'cred_1', + displayName: 'Production Stripe', + providerId: 'stripe', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(credentials[0]).toContain('Production Stripe') + expect(credentials[0]).toContain('stripe') + + const secrets = await lines( + ['secrets', 'list'], + [ + { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(secrets[0]).toContain('STRIPE_API_KEY') + expect(secrets[0]).toContain('workspace') + }) + + /** + * A field path that misses renders as an em-dash rather than failing, so a + * renamed response key is invisible until someone reads the output. v2 nests + * the share under `share` and calls the flag `isActive`; the CLI briefly read + * a `sharing` wrapper and silently showed nothing for all four columns. + */ + it('reads share fields from the v2 share object, not a sharing wrapper', async () => { + const described = ( + await lines(['files', 'describe', 'file_1'], { + id: 'file_1', + name: 'notes.txt', + uploadedByEmail: 'ada@example.com', + share: { + isActive: true, + url: 'https://sim.ai/s/tok_1', + authType: 'email', + hasPassword: false, + allowedEmails: ['ada@example.com'], + }, + }) + ).join('\n') + expect(described).toContain('https://sim.ai/s/tok_1') + expect(described).toContain('email') + expect(described).toContain('ada@example.com') + + const share = ( + await lines(['files', 'share', 'get', 'file_1'], { + isActive: true, + url: 'https://sim.ai/s/tok_2', + authType: 'sso', + hasPassword: true, + allowedEmails: ['ada@example.com', 'grace@example.com'], + }) + ).join('\n') + expect(share).toContain('https://sim.ai/s/tok_2') + expect(share).toContain('sso') + }) +}) + +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' }) + }) + + it('uses a valid per-page size for unlimited and large totals', async () => { + for (const requested of ['0', '250']) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', requested]) + + expect(mockRequest.mock.calls[0][1].query.limit).toBe(100) + } + }) +}) + +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') + }) + + it('uses the generated list command for table rows', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [{ id: 'r1', data: { email: 'a@example.com' } }], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => lines.push(line)) + try { + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'list', 'tbl_1']) + } finally { + output.format = 'json' + } + + expect(lines[0]).toContain('a@example.com') + expect(mockRequest.mock.calls[0][0]).toBe('/api/v2/tables/tbl_1/rows') + }) +}) + +describe('boolean flags', () => { + 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', + '--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('offers a direct single-row flag', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--data', + '{"city":"Paris"}', + ]) + expect(options.body).toEqual({ workspaceId: 'ws_local', data: { city: 'Paris' } }) + }) + + it('requires exactly one row-body form', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1'])).rejects.toThrow( + /exactly one of --data or --rows/ + ) + await expect( + run(['tables', 'rows', 'create', 'tbl_1', '--data', '{}', '--rows', '[]']) + ).rejects.toThrow(/exactly one of --data or --rows/) + }) + + it('rejects the wrong JSON shape for a row-body flag', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--data', '[1,2]'])).rejects.toThrow( + /--data must be a JSON object/ + ) + }) + + it('explains the single and batch row forms in help', () => { + const help = commandAt('tables', 'rows', 'create').helpInformation() + expect(help).toMatch(/--data.*One row keyed by column name/s) + expect(help).toMatch(/--rows.*Several rows keyed by column name/s) + expect(help).not.toContain('--body') + }) + + 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..5753fb09cd7 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.ts @@ -0,0 +1,245 @@ +import { Command } from 'commander' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, CommandVariantSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { deriveCommandPath } from './derive' +import { executeOperation } from './execute' +import { addOperationOptions } from './options' +import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request' +import type { OperationSpec } from './types' + +const GROUP_ALIASES: Readonly> = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + knowledge: 'kb', + logs: 'log', + 'mcp-servers': 'mcp-server', + secrets: 'secret', + skills: 'skill', + tables: 'table', + workflows: 'workflow', + workspaces: 'workspace', +} + +function argumentSyntax(command: Command): string { + return command.registeredArguments + .map((argument) => { + const name = `${argument.name()}${argument.variadic ? '...' : ''}` + return argument.required ? `<${name}>` : `[${name}]` + }) + .join(' ') +} + +function commandPath(command: Command): string { + const names: string[] = [] + let current: Command | null = command + while (current) { + names.unshift(current.name()) + current = current.parent + } + return names.join(' ') +} + +function addMissingArgumentExample(command: Command): Command { + const outputError = command.configureOutput().outputError + if (!outputError) throw new Error('Commander output formatter is not configured') + + command.configureOutput({ + outputError: (message, write) => { + outputError(message, write) + if (!message.startsWith('error: missing required argument ')) return + + const syntax = argumentSyntax(command) + const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command) + write(`Example: ${example}\n`) + }, + }) + return command +} + +function configureOperation( + command: Command, + operation: V2OperationName, + spec: CommandSpec +): Command { + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + command.allowExcessArguments(false) + + for (const alias of spec.aliases ?? []) command.alias(alias) + + for (const param of Object.keys(spec.pathFlags ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + } + + for (const param of Object.keys(spec.pathArgumentNames ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + if (spec.pathFlags?.[param]) { + throw new Error(`${operation}.${param} cannot be both a path argument and a path flag`) + } + } + + if (spec.profileWorkspacePath) { + if (!operationSpec.pathParams.includes(PROFILE_INJECTED_FIELD)) { + throw new Error(`${operation}.profileWorkspacePath requires a workspaceId path parameter`) + } + if (spec.pathFlags?.[PROFILE_INJECTED_FIELD]) { + throw new Error(`${operation}.workspaceId cannot be both profile-injected and a path flag`) + } + } + + for (const param of operationSpec.pathParams) { + if (spec.pathFlags?.[param] || isProfileWorkspacePath(spec, param)) continue + command.argument(`<${spec.pathArgumentNames?.[param] ?? param}>`) + } + + if (spec.allWorkspaces) { + const workspace = operationSpec.query?.workspaceId ?? operationSpec.body?.workspaceId + if (!workspace || workspace.required) { + throw new Error(`${operation}.allWorkspaces requires an optional workspaceId field`) + } + } + + for (const field of spec.positionals ?? []) { + const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] + if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) + if (spec.requestFields && !spec.requestFields.includes(field)) { + throw new Error(`${operation}.${field} is positional but not exposed`) + } + command.argument(`<${flagNameFor(operation, field)}>`) + } + + if (spec.requestFields) { + for (const field of spec.requestFields) { + if (!operationSpec.query?.[field] && !operationSpec.body?.[field]) { + throw new Error(`${operation}.${field} is not a request field`) + } + } + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if ( + descriptor.required && + field !== PROFILE_INJECTED_FIELD && + !spec.requestFields.includes(field) + ) { + throw new Error(`${operation}.${field} is required but not exposed`) + } + } + } + } + + command.description( + spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` + ) + addOperationOptions(command, operation, spec, operationSpec) + command.action((...invocation: unknown[]) => + executeOperation(operation, spec, operationSpec, invocation) + ) + return command +} + +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + return addMissingArgumentExample(configureOperation(new Command(leafName), operation, spec)) +} + +function groupFor(groups: Map, name: string): Command { + const existing = groups.get(name) + if (existing) return existing + + const group = new Command(name).description(`Manage ${name.replaceAll('-', ' ')}`) + const alias = GROUP_ALIASES[name] + if (alias) group.alias(alias) + groups.set(name, group) + return group +} + +function resourceLabel(name: string): string { + const label = name.endsWith('s') ? name.slice(0, -1) : name + return label.replaceAll('-', ' ') +} + +function nestedGroup(parent: Command, name: string): Command { + const existing = parent.commands.find((candidate) => candidate.name() === name) + if (existing) return existing + + const created = new Command(name).description( + `Manage ${resourceLabel(parent.name())} ${name.replaceAll('-', ' ')}` + ) + parent.addCommand(created) + return created +} + +function addLeafCommand( + groups: Map, + operation: V2OperationName, + spec: CommandSpec, + segments: string[] +): void { + const [groupName, ...rest] = segments + if (rest.length === 0) throw new Error(`${operation} leaf command must include a verb`) + const group = groupFor(groups, groupName) + + if (rest.length > 1) { + const [subName, ...tail] = rest + nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) + return + } + + group.addCommand(buildLeaf(operation, spec, rest[0])) +} + +function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): CommandSpec { + return { + ...spec, + command: variant.command, + groupDefault: false, + aliases: [], + positionals: variant.positionals, + requestFields: variant.requestFields, + variants: [], + describe: variant.describe ?? spec.describe, + } +} + +/** Builds every JSON command described by the generated operation table. */ +export function buildGeneratedCommands(): Command[] { + const groups = new Map() + + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = CLI_CONTRACT[operation] ?? {} + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + if (spec.hidden || operationSpec.responseMode !== 'json') continue + + const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) + if (spec.groupDefault) { + const [groupName, ...rest] = segments + const group = groupFor(groups, groupName) + if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) + const pathPositionals = operationSpec.pathParams.filter( + (param) => !spec.pathFlags?.[param] && !isProfileWorkspacePath(spec, param) + ) + if (pathPositionals.length > 0 || spec.positionals?.length) { + throw new Error(`${operation} groupDefault cannot require positional arguments`) + } + configureOperation(group, operation, spec) + } else { + addLeafCommand(groups, operation, spec, segments) + } + + for (const variant of spec.variants ?? []) { + addLeafCommand( + groups, + operation, + variantCommandSpec(spec, variant), + variant.command.split(' ') + ) + } + } + + 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..eb0d17a94e8 --- /dev/null +++ b/packages/sim-cli/src/runtime/derive.ts @@ -0,0 +1,70 @@ +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' + +/** + * 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/execute.ts b/packages/sim-cli/src/runtime/execute.ts new file mode 100644 index 00000000000..5b3ebd83e70 --- /dev/null +++ b/packages/sim-cli/src/runtime/execute.ts @@ -0,0 +1,107 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { SimApiError, type V2Page } from '../http/client' +import { camel } from './derive' +import { DEFAULT_LIMIT } from './options' +import { + buildRequest, + flagNameFor, + isProfileWorkspacePath, + PROFILE_INJECTED_FIELD, +} from './request' +import { renderPage, renderResult } from './result' +import type { OperationSpec } from './types' + +function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { + if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' + if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' + return null +} + +/** Executes a parsed generated command, including cursor pagination. */ +export async function executeOperation( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec, + invocation: unknown[] +): Promise { + const host = invocation[invocation.length - 1] as Command + const inheritedFlags = host.optsWithGlobals() as Record + const flags: Record = { + ...(inheritedFlags.workspace === undefined ? {} : { workspace: inheritedFlags.workspace }), + ...(inheritedFlags.allWorkspaces === undefined + ? {} + : { allWorkspaces: inheritedFlags.allWorkspaces }), + ...(invocation[invocation.length - 2] as Record), + } + const pathPositionalCount = operationSpec.pathParams.filter( + (param) => !commandSpec.pathFlags?.[param] && !isProfileWorkspacePath(commandSpec, param) + ).length + const positional = invocation.slice(0, pathPositionalCount) as string[] + const requestFlags: Record = { ...flags } + for (const [index, field] of (commandSpec.positionals ?? []).entries()) { + requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] + } + + if (commandSpec.confirm && !requestFlags.yes) { + throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) + } + + if (commandSpec.allWorkspaces && requestFlags.allWorkspaces && requestFlags.workspace) { + throw new SimApiError('--all-workspaces cannot be combined with --workspace', 0) + } + + const { client, profile } = clientFrom(host) + const hasWorkspaceField = Boolean( + (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || + (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) + ) + const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true + const request = buildRequest( + operation, + positional, + requestFlags, + hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) + const paging = cursorSlot(operationSpec) + + if (paging) { + const rawLimit = Number.parseInt(String(requestFlags.limit ?? DEFAULT_LIMIT), 10) + if (Number.isNaN(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) + const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} + const rows: unknown[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method, + query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } + : request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + renderPage(profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method, + query: request.query, + body: request.body, + }) + renderResult(operation, profile.output, result?.data ?? result, commandSpec, { + expandedTrace: requestFlags.trace === true, + }) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts new file mode 100644 index 00000000000..9c2a1576ffc --- /dev/null +++ b/packages/sim-cli/src/runtime/options.ts @@ -0,0 +1,140 @@ +import { type Command, Option } from 'commander' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + pathFlagNameFor, + takesJson, +} from './request' +import type { OperationSpec } from './types' + +export const DEFAULT_LIMIT = 100 + +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + 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}, ` : '' + + 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' || flag.boolean) { + if (descriptor.required) { + command.addOption( + new Option( + `${short}--${name} `, + `${flag.describe ?? `Set ${field}`} (required)` + ) + .choices(['true', 'false']) + .makeOptionMandatory() + ) + return + } + + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + if (!flag.boolean) command.option(`--no-${name}`, `Set ${field} to false`) + return + } + + const takesList = flag.list === true + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? '' : wantsJson ? '' : '' + const choices = flag.choices ?? descriptor.values + const describe = `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`}${ + takesList + ? ' (space-separated, or @path / @- with one value per line)' + : wantsJson + ? ' (JSON, or @path / @- to read a file or stdin)' + : '' + }${descriptor.required ? ' (required)' : ''}` + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (choices && !takesList) option.choices([...choices]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + if (descriptor.required) option.makeOptionMandatory() + command.addOption(option) +} + +/** Adds request-field and safety options for one generated operation. */ +export function addOperationOptions( + command: Command, + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): void { + for (const param of operationSpec.pathParams) { + const flag = commandSpec.pathFlags?.[param] + if (!flag) continue + + const name = pathFlagNameFor(commandSpec, param) + const short = flag.short ? `-${flag.short}, ` : '' + command.addOption( + new Option( + `${short}--${name} <${flag.placeholder ?? 'value'}>`, + `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`} (required)` + ).makeOptionMandatory() + ) + } + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue + if (commandSpec.positionals?.includes(field)) continue + addFieldOption(command, operation, field, descriptor) + } + } + + if (commandSpec.allWorkspaces) { + command.option( + '--all-workspaces', + 'Do not filter to the configured workspace (personal API key required for account-wide access)' + ) + } + + if (commandSpec.expandedTrace) { + command.option( + '--trace', + 'Show expanded trace spans with inputs, outputs, errors, timing, and cost' + ) + } + + if (operationSpec.opaqueBody) { + if (commandSpec.bodyVariants) { + for (const variant of commandSpec.bodyVariants) { + command.option( + `--${variant.name} `, + `${variant.describe} (JSON, or @path / @-; choose exactly one body flag)` + ) + } + } else { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin) (required)' + ) + } + } + + if (commandSpec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } +} 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..dc8295e70d6 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -0,0 +1,255 @@ +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' +import { deriveCommandPath } from './derive' +import { buildRequest, coerce, type FieldSpec } from './request' + +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('omits an optional profile workspace when all workspaces are requested', () => { + const built = buildRequest('listBillingLogs', [], { allWorkspaces: true }, WORKSPACE) + expect(built.query).not.toHaveProperty('workspaceId') + }) + + 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('sends an empty object when a declared body has no provided fields', () => { + expect(buildRequest('executeWorkflow', ['wf_1'], {}, WORKSPACE).body).toEqual({}) + }) + + 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') + }) + + it('fills a configured workspace path segment from the profile', () => { + expect(buildRequest('getWorkspace', [], {}, WORKSPACE).path).toBe( + `/api/v2/workspaces/${WORKSPACE}` + ) + }) + + it('combines nested resource path arguments in route order', () => { + expect(buildRequest('getKnowledgeDocument', ['kb_1', 'doc_1'], {}, WORKSPACE)).toEqual({ + path: '/api/v2/knowledge/kb_1/documents/doc_1', + query: { workspaceId: WORKSPACE }, + body: undefined, + }) + }) + + describe('failures, all before any network call', () => { + it('rejects a missing path arg', () => { + expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') + }) + + it('rejects a profile-backed workspace path when no workspace is configured', () => { + expect(() => buildRequest('getWorkspace', [], {}, null)).toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + }) + + it('rejects a missing required flag', () => { + expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( + '--data is required' + ) + }) + + it('names a missing nested parent path argument clearly', () => { + expect(() => buildRequest('getKnowledgeDocument', [], {}, WORKSPACE)).toThrow( + 'Missing ' + ) + }) + + 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']) + }) + + it('reads one list value per line from @path', () => { + const path = join(tmpdir(), 'sim-cli-list-values.txt') + writeFileSync(path, 'file_1\nfile_2\n') + expect(coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toEqual([ + 'file_1', + 'file_2', + ]) + rmSync(path) + }) + + it('rejects empty lines in a list file', () => { + const path = join(tmpdir(), 'sim-cli-list-empty-line.txt') + writeFileSync(path, 'file_1\n\nfile_2') + expect(() => coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toThrow( + /empty value on line 2/ + ) + rmSync(path) + }) +}) + +describe('contract-provided choices', () => { + it('validates an enum the generator could not recover', () => { + const field: FieldSpec = { kind: 'enum' } + const flag = { choices: ['vector', 'hybrid'] } as const + expect(coerce('hybrid', field, flag, 'search-mode')).toBe('hybrid') + expect(() => coerce('semantic', field, flag, 'search-mode')).toThrow( + '--search-mode must be one of: vector, hybrid' + ) + }) +}) + +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..848acdadb1e --- /dev/null +++ b/packages/sim-cli/src/runtime/request.ts @@ -0,0 +1,385 @@ +import { existsSync, readFileSync, readSync } from 'node:fs' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, FlagSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { type QueryValue, SimApiError } from '../http/client' +import { camel, kebab } from './derive' + +/** 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' + +/** Whether this path segment comes from the active profile's workspace. */ +export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): boolean { + return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD +} + +/** 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) +} + +/** The named option used for a path parameter that is contextual rather than primary. */ +export function pathFlagNameFor(commandSpec: CommandSpec, param: string): string { + return commandSpec.pathFlags?.[param]?.name ?? kebab(param) +} + +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 flag argument that may name a file instead of carrying its value + * 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. JSON never starts with `@`; primitive list + * flags reserve it for this explicit file-input form. + */ +function readArgumentSource(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) + } +} + +/** Reads a primitive list from argv or a newline-delimited file. */ +function readListValues(raw: unknown, flagName: string): string[] { + const arguments_ = Array.isArray(raw) ? raw : [raw] + const values = arguments_.flatMap((argument) => { + if (typeof argument !== 'string') { + throw new SimApiError(`--${flagName} values must be strings`, 0) + } + + if (!argument.startsWith('@')) return [argument] + + const source = readArgumentSource(argument, flagName) + const lines = source.text.split(/\r?\n/) + if (lines.at(-1) === '') lines.pop() + if (lines.length === 0) { + throw new SimApiError(`--${flagName}${source.from} contains no values`, 0) + } + + return lines.map((line, index) => { + const value = line.trim() + if (!value) { + throw new SimApiError( + `--${flagName}${source.from} has an empty value on line ${index + 1}`, + 0 + ) + } + return value + }) + }) + + return values.map((value) => { + const trimmed = value.trim() + if (!trimmed) throw new SimApiError(`--${flagName} values cannot be empty`, 0) + return trimmed + }) +} + +/** + * 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`, `folderPaths`, + * `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 = readListValues(raw, flagName) + return field.kind === 'string' ? values.join(',') : values + } + + if (takesJson(field, flag)) { + if (typeof raw !== 'string') return raw + const source = readArgumentSource(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' || flag.boolean) return raw === true || raw === 'true' + + const choices = flag.choices ?? field.values + if (choices && !choices.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${choices.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. + * + * Primary path params come from positional arguments in declared order. A + * contextual path param can instead come from a named option declared by the + * CLI contract. Every other field is looked up by its flag name in the slot the + * API 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 commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} + const spec = V2_OPERATIONS[operation] as { + method: string + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + } + + let path = spec.path + let positionalIndex = 0 + for (const param of spec.pathParams) { + const pathFlag = commandSpec.pathFlags?.[param] + const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param) + const flagName = pathFlagNameFor(commandSpec, param) + const argumentName = commandSpec.pathArgumentNames?.[param] ?? param + const value = profileWorkspacePath + ? workspaceId + : pathFlag + ? flags[camel(flagName)] + : positional[positionalIndex++] + if (value === undefined || value === null) { + if (profileWorkspacePath) { + throw new SimApiError( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ', + 0 + ) + } + throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0) + } + if (typeof value !== 'string' || value.length === 0) { + throw new SimApiError( + pathFlag ? `--${flagName} cannot be empty` : `<${argumentName}> cannot be empty`, + 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 omitProfileWorkspace = commandSpec.allWorkspaces && flags.allWorkspaces === true + const raw = + field === PROFILE_INJECTED_FIELD + ? omitProfileWorkspace + ? undefined + : 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) { + if (commandSpec.bodyVariants) { + const provided = commandSpec.bodyVariants.filter( + (variant) => flags[camel(variant.name)] !== undefined + ) + const names = commandSpec.bodyVariants.map((variant) => `--${variant.name}`).join(' or ') + if (provided.length !== 1) { + throw new SimApiError(`Pass exactly one of ${names}`, 0) + } + + const variant = provided[0] + const raw = flags[camel(variant.name)] + if (typeof raw !== 'string') throw new SimApiError(`--${variant.name} is required`, 0) + const parsed = coerce(raw, { kind: variant.kind }, { json: true }, variant.name) + if ( + (variant.kind === 'object' && + (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))) || + (variant.kind === 'array' && !Array.isArray(parsed)) + ) { + throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0) + } + return { path, query, body: { ...body, [variant.property]: parsed } } + } + + 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, + /** + * A declared JSON body is still an object when all of its fields are optional. + * Sending no bytes makes the server reject before field defaults can apply. + */ + body: spec.body ? body : undefined, + } +} diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts new file mode 100644 index 00000000000..e803f45934d --- /dev/null +++ b/packages/sim-cli/src/runtime/result.ts @@ -0,0 +1,196 @@ +import type { OutputFormat } from '../config/index' +import type { ColumnSpec, CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { + bool, + bytes, + type Column, + duration, + printDocument, + printList, + printRecord, + sanitize, + text, + timestamp, +} from '../output/render' +import { printTraceSpans } from '../output/trace' + +interface RenderResultOptions { + expandedTrace?: boolean +} + +function countTraceSpans(value: unknown): number { + if (!Array.isArray(value)) return 0 + return value.reduce((count, span) => { + if (!span || typeof span !== 'object' || Array.isArray(span)) { + throw new Error('Trace contains a malformed span') + } + return count + 1 + countTraceSpans((span as Record).children) + }, 0) +} + +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'], + options: RenderResultOptions = {} +): 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 bool(value as boolean | null) + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + case 'count': + return Array.isArray(value) ? String(value.length) : text(null) + case 'trace-count': { + const count = countTraceSpans(value) + return `${count} ${count === 1 ? 'span' : 'spans'}${ + options.expandedTrace ? '' : ' (use --trace)' + }` + } + default: + if (value === null || value === undefined || value === '') return text(null) + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) + } +} + +const NESTED_CELL_WIDTH = 160 + +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), + })) +} + +function fieldsFrom( + data: unknown, + specs: ColumnSpec[], + options: RenderResultOptions = {} +): Array<[string, string]> { + return specs.flatMap((spec) => { + const value = at(data, spec.path ?? spec.header) + return value === undefined ? [] : [[spec.header, renderCell(value, spec.format, options)]] + }) +} + +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 }) + } + } + + 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) + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), + })) +} + +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 +} + +export function renderPage(format: OutputFormat, rows: unknown[], spec: CommandSpec): void { + printList( + format, + rows, + spec.columns ? columnsFrom(spec.columns) : inferColumns(rows, spec.expand) + ) +} + +/** Renders one non-paginated operation result according to its CLI contract. */ +export function renderResult( + operation: V2OperationName, + format: OutputFormat, + raw: unknown, + spec: CommandSpec, + options: RenderResultOptions = {} +): void { + if (spec.document) { + printDocument(format, raw) + return + } + + const data = unwrapResource(raw) + if (spec.itemsPath) { + const items = at(data, spec.itemsPath) + if (!Array.isArray(items)) { + throw new Error(`${operation} expected an array at response path ${spec.itemsPath}`) + } + printList( + format, + items, + spec.columns ? columnsFrom(spec.columns) : inferColumns(items, spec.expand), + data + ) + return + } + + if (Array.isArray(data)) { + printList( + format, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) + return + } + + const fields = spec.fields + ? fieldsFrom(data, spec.fields, options) + : data && typeof data === 'object' + ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) + : [] + + printRecord(format, fields, data) + if (spec.expandedTrace && options.expandedTrace) { + const traceSpans = at(data, 'traceSpans') + if (!Array.isArray(traceSpans)) { + throw new Error(`${operation} expected a traceSpans array`) + } + printTraceSpans(format, traceSpans) + } +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts new file mode 100644 index 00000000000..ab9892aec8c --- /dev/null +++ b/packages/sim-cli/src/runtime/types.ts @@ -0,0 +1,13 @@ +import type { RequestOptions } from '../http/client' +import type { FieldSpec } from './request' + +export interface OperationSpec { + method: NonNullable + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + summary?: string + responseMode?: 'json' | 'binary' | 'stream' +} diff --git a/packages/sim-cli/src/terminal/secret-input.test.ts b/packages/sim-cli/src/terminal/secret-input.test.ts new file mode 100644 index 00000000000..c7068dd6745 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.test.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from 'node:events' +import type { ReadStream } from 'node:tty' +import { describe, expect, it } from 'vitest' +import { promptSecret } from './secret-input' + +class FakeInput extends EventEmitter { + isTTY = true + isRaw = false + paused = true + readonly rawStates: boolean[] = [] + + isPaused(): boolean { + return this.paused + } + + setRawMode(value: boolean): this { + this.isRaw = value + this.rawStates.push(value) + return this + } + + resume(): this { + this.paused = false + return this + } + + pause(): this { + this.paused = true + return this + } +} + +class FakeOutput { + value = '' + + write(value: string): boolean { + this.value += value + return true + } +} + +describe('promptSecret', () => { + it('masks input and restores the terminal before returning it', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'hunter2', { name: 'h' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('hunter2') + expect(output.value).toBe('Secret value: *******\n') + expect(input.rawStates).toEqual([true, false]) + expect(input.paused).toBe(true) + }) + + it('handles backspace without revealing the value', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'ab', { name: 'a' }) + input.emit('keypress', '', { name: 'backspace' }) + input.emit('keypress', 'c', { name: 'c' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('ac') + expect(output.value).toBe('Secret value: **\b \b*\n') + }) + + it('requires --value when no interactive terminal is available', () => { + const input = new FakeInput() + input.isTTY = false + + expect(() => promptSecret(input as unknown as ReadStream, new FakeOutput())).toThrow( + 'Interactive secret input requires a terminal. Pass --value instead.' + ) + }) + + it('restores the terminal when input is cancelled', async () => { + const input = new FakeInput() + const result = promptSecret(input as unknown as ReadStream, new FakeOutput()) + + input.emit('keypress', '\u0003', { ctrl: true, name: 'c' }) + + await expect(result).rejects.toThrow('Secret input cancelled.') + expect(input.rawStates).toEqual([true, false]) + }) +}) diff --git a/packages/sim-cli/src/terminal/secret-input.ts b/packages/sim-cli/src/terminal/secret-input.ts new file mode 100644 index 00000000000..e8b365767d7 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.ts @@ -0,0 +1,81 @@ +import { emitKeypressEvents, type Key } from 'node:readline' +import type { ReadStream } from 'node:tty' +import { SimApiError } from '../http/client' + +const MAX_SECRET_LENGTH = 65_536 + +interface SecretOutput { + write(value: string): unknown +} + +/** Reads a secret from a TTY while rendering one mask character per entered character. */ +export function promptSecret( + input: ReadStream = process.stdin, + output: SecretOutput = process.stderr +): Promise { + if (!input.isTTY) { + throw new SimApiError('Interactive secret input requires a terminal. Pass --value instead.', 0) + } + + const wasPaused = input.isPaused() + const wasRaw = input.isRaw + let value = '' + let settled = false + + output.write('Secret value: ') + emitKeypressEvents(input) + input.setRawMode(true) + input.resume() + + return new Promise((resolve, reject) => { + const cleanup = () => { + input.removeListener('keypress', onKeypress) + input.setRawMode(wasRaw) + if (wasPaused) input.pause() + } + + const finish = (complete: () => void) => { + if (settled) return + settled = true + output.write('\n') + try { + cleanup() + complete() + } catch (error) { + reject(error) + } + } + + const fail = (message: string) => finish(() => reject(new SimApiError(message, 0))) + + function onKeypress(text: string, key: Key): void { + if (key.ctrl && (key.name === 'c' || key.name === 'd')) { + fail('Secret input cancelled.') + return + } + if (key.name === 'return' || key.name === 'enter') { + if (value.length === 0) fail('Secret value cannot be empty.') + else finish(() => resolve(value)) + return + } + if (key.name === 'backspace') { + const characters = Array.from(value) + if (characters.length > 0) { + characters.pop() + value = characters.join('') + output.write('\b \b') + } + return + } + if (!text || key.ctrl || key.meta || key.name === 'escape') return + if (value.length + text.length > MAX_SECRET_LENGTH) { + fail(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`) + return + } + value += text + output.write('*'.repeat(Array.from(text).length)) + } + + input.on('keypress', onKeypress) + }) +} diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts new file mode 100644 index 00000000000..dfbd1d35e46 --- /dev/null +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -0,0 +1,57 @@ +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { SimApiError } from '../http/client' + +const CONTENT_TYPES: Record = { + css: 'text/css', + csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + gif: 'image/gif', + html: 'text/html', + htm: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + jsonl: 'application/jsonl', + md: 'text/markdown', + pdf: 'application/pdf', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + webp: 'image/webp', + yaml: 'application/yaml', + yml: 'application/yaml', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + zip: 'application/zip', +} + +export 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' +} + +export interface LocalFile { + name: string + size: number +} + +/** Validates the size and name shared by every local-file transfer. */ +export async function localFile(path: string, override?: string): Promise { + let size: number + try { + const stats = await stat(path) + if (!stats.isFile()) throw new SimApiError(`${path} is not a regular file`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts new file mode 100644 index 00000000000..cc8e7d0566d --- /dev/null +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -0,0 +1,124 @@ +import { openAsBlob } from 'node:fs' +import { SimApiError, type SimClient } from '../http/client' + +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +export type UploadTransfer = + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + +export interface UploadSession { + basePath: string + uploadToken: string + transfer: UploadTransfer + size: number +} + +const PART_URL_BATCH = 100 + +async function uploadPut(transfer: Extract, blob: Blob) { + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim + const response = await fetch(transfer.url, { + method: 'PUT', + headers: transfer.headers, + body: blob, + }) + if (!response.ok) { + throw new SimApiError(`Upload failed with status ${response.status}`, response.status) + } +} + +async function uploadParts( + client: SimClient, + workspaceId: string, + session: UploadSession, + transfer: Extract, + blob: Blob +): Promise { + const expectedPartCount = Math.ceil(session.size / transfer.partSize) + if (expectedPartCount !== transfer.partCount) { + throw new Error( + `Upload session expected ${transfer.partCount} parts, but file requires ${expectedPartCount}` + ) + } + + 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[] } }>( + `${session.basePath}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': session.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * transfer.partSize + const chunk = blob.slice(start, Math.min(start + transfer.partSize, session.size)) + + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim + 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 + ) + } + } + } +} + +/** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ +export async function finishUploadSession( + client: SimClient, + workspaceId: string, + session: UploadSession, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + if (session.transfer.method === 'put') { + await uploadPut(session.transfer, blob) + } else { + await uploadParts(client, workspaceId, session, session.transfer, blob) + } + + const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': session.uploadToken }, + }) + return completed.data + } catch (error) { + await client + .request(session.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': session.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json new file mode 100644 index 00000000000..98522576add --- /dev/null +++ b/packages/sim-cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/base.json", + "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/check-source-text.ts b/scripts/check-source-text.ts index 1052604eb32..c406242cc8d 100644 --- a/scripts/check-source-text.ts +++ b/scripts/check-source-text.ts @@ -56,7 +56,9 @@ const files = listed.stdout const offenders: string[] = [] for (const file of files) { - const bytes = await Bun.file(path.join(ROOT, file)).bytes() + const source = Bun.file(path.join(ROOT, file)) + if (!(await source.exists())) continue + const bytes = await source.bytes() if (bytes.includes(0)) offenders.push(file) } diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 22565e4c781..857bab94901 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -29,6 +29,9 @@ const ALLOWLISTED_FILES = new Set([ 'packages/utils/src/id.test.ts', 'packages/utils/src/object.test.ts', 'packages/utils/src/retry.test.ts', + // Published standalone CLIs: `@sim/utils` is private, so they carry local + // copies rather than a dependency that only resolves inside the monorepo. + 'packages/sim-cli/src/helpers.ts', 'packages/cli/src/index.ts', 'packages/ts-sdk/src/index.ts', // CJS bundle — cannot use ES module imports diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts new file mode 100644 index 00000000000..a5ee67f9cd8 --- /dev/null +++ b/scripts/generate-v2-cli-api.ts @@ -0,0 +1,570 @@ +#!/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 a schema carrying no constraints. A + // `.describe()` on one adds annotation keys without narrowing the type, so + // those are not constraints either. + const ANNOTATION_KEYS = new Set(['$schema', 'description', 'title', 'default', 'examples']) + if (Object.keys(schema).every((k) => ANNOTATION_KEYS.has(k))) 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 + + // A schema carrying `.meta({ id })` is lifted into `$defs` and referenced, so + // the property here is a bare `$ref` with no type to classify. Left + // unresolved every such field reads as `unknown` and the CLI demands JSON for + // what is really a plain string flag. + const defs = (json.$defs ?? {}) as Record + const deref = (schema: JsonSchema): JsonSchema => { + let current = schema + for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) { + const resolved = defs[current.$ref.replace('#/$defs/', '')] + if (!resolved) break + current = resolved + } + return current + } + + const lines = keys.map((key) => { + const property = deref(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()