From c2c09375ecdbedcd17286227642ffa0f49ed6b22 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 14 Oct 2026 22:06:11 +0200 Subject: [PATCH 1/4] split --- .changeset/remote-api-separation.md | 11 + .claude-plugin/plugin.json | 12 +- .github/workflows/release.yml | 23 - .github/workflows/test.yml | 32 - apps/scripts/validate-snippets.ts | 333 + package-lock.json | 178 +- package.json | 3 +- packages/cli/eslint.config.js | 34 +- packages/cli/examples/flow-complete.md | 14 +- packages/cli/jest.config.mjs | 5 +- packages/cli/openapi/client-operations.json | 250 - packages/cli/openapi/spec.json | 7915 ----------------- packages/cli/package.json | 14 +- .../cli/scripts/check-client-contract.mjs | 179 - packages/cli/scripts/generate-client-spec.mjs | 60 - .../src/__tests__/helpers/mock-api-client.ts | 26 - .../cli/src/__tests__/helpers/msw-handlers.ts | 148 - .../cli/src/__tests__/helpers/msw-server.ts | 4 +- .../api/api-projects.integration.test.ts | 71 - .../cli/removed-commands.integration.test.ts | 50 + .../unit/commands/auth-token.test.ts | 111 + .../unit/commands/auth-whoami.test.ts | 69 +- .../__tests__/unit/commands/deploy.test.ts | 507 -- .../unit/commands/deployments.test.ts | 546 -- .../unit/commands/diagnostics.test.ts | 196 +- .../__tests__/unit/commands/feedback.test.ts | 144 - .../src/__tests__/unit/commands/flows.test.ts | 502 -- .../__tests__/unit/commands/frames.test.ts | 121 - .../src/__tests__/unit/commands/hub.test.ts | 233 - .../commands/list-pagination-handlers.test.ts | 79 - .../__tests__/unit/commands/projects.test.ts | 174 - .../unit/config/fetch-content.test.ts | 65 +- .../__tests__/unit/core/api-client.test.ts | 108 - .../cli/src/__tests__/unit/core/auth.test.ts | 25 - .../cli/src/__tests__/unit/core/http.test.ts | 193 - .../__tests__/unit/core/oauth-client.test.ts | 39 + .../cli/src/__tests__/unit/core/sse.test.ts | 118 - .../__tests__/unit/entries-cli-free.test.ts | 44 + .../unit/examples/flow-complete.test.ts | 14 +- .../__tests__/unit/lib/config-file.test.ts | 244 +- packages/cli/src/cli.ts | 422 +- .../src/commands/api/__tests__/api-ls.test.ts | 166 + .../src/commands/api/__tests__/api.test.ts | 408 + packages/cli/src/commands/api/index.ts | 515 ++ packages/cli/src/commands/auth/index.ts | 77 +- packages/cli/src/commands/deploy/index.ts | 443 - .../cli/src/commands/deployments/index.ts | 438 - .../cli/src/commands/diagnostics/index.ts | 73 +- packages/cli/src/commands/feedback/index.ts | 102 - packages/cli/src/commands/flows/index.ts | 282 - packages/cli/src/commands/frames/index.ts | 84 - packages/cli/src/commands/hub/index.ts | 280 - packages/cli/src/commands/journeys/index.ts | 36 - packages/cli/src/commands/login/index.ts | 11 +- .../observe/__tests__/observe.test.ts | 722 -- packages/cli/src/commands/observe/index.ts | 427 - .../previews/__tests__/output.test.ts | 62 - .../previews/__tests__/previews.test.ts | 504 -- packages/cli/src/commands/previews/index.ts | 242 - packages/cli/src/commands/previews/output.ts | 66 - packages/cli/src/commands/projects/index.ts | 149 - packages/cli/src/commands/secrets/index.ts | 117 - packages/cli/src/config/utils.ts | 66 +- .../src/core/__tests__/api-error-426.test.ts | 93 - .../cli/src/core/__tests__/api-error.test.ts | 123 +- .../src/core/__tests__/api-request.test.ts | 277 - .../cli/src/core/__tests__/app-fetch.test.ts | 75 + .../cli/src/core/__tests__/contract.test.ts | 668 -- .../src/core/__tests__/openapi-subset.test.ts | 638 -- packages/cli/src/core/api-client.ts | 37 - packages/cli/src/core/api-error.ts | 100 - packages/cli/src/core/api-request.ts | 324 - packages/cli/src/core/app-fetch.ts | 52 + packages/cli/src/core/app-paths.ts | 15 + packages/cli/src/core/auth.ts | 10 - packages/cli/src/core/contract.ts | 453 - packages/cli/src/core/fetch-failure.ts | 22 + packages/cli/src/core/http.ts | 103 - packages/cli/src/core/index.ts | 8 - packages/cli/src/core/oauth-client.ts | 88 +- packages/cli/src/core/openapi-subset.ts | 539 -- packages/cli/src/core/sse.ts | 37 - packages/cli/src/core/stdin.ts | 9 +- packages/cli/src/dev.ts | 4 + .../src/examples/flow-complete.manifest.ts | 16 +- packages/cli/src/index.ts | 189 +- packages/cli/src/lib/config-file.ts | 81 +- packages/cli/src/telemetry/emitter.ts | 6 +- .../cli/src/telemetry/first-run-notice.ts | 9 +- packages/cli/src/types/api.gen.d.ts | 4580 ---------- packages/cli/src/validate.ts | 19 + packages/cli/tsup.config.ts | 79 +- packages/mcps/SKILL.md | 53 +- packages/mcps/mcp/README.md | 190 +- packages/mcps/mcp/eslint.config.js | 28 +- packages/mcps/mcp/jest.config.mjs | 1 + packages/mcps/mcp/package.json | 8 +- packages/mcps/mcp/server.json | 34 +- .../src/__tests__/action-requirements.test.ts | 153 - .../src/__tests__/action-schema-sync.test.ts | 247 - .../mcps/mcp/src/__tests__/base-url.test.ts | 71 - .../mcps/mcp/src/__tests__/catalog.test.ts | 142 +- .../__tests__/create-tool-handlers.test.ts | 53 +- .../mcp/src/__tests__/diagnostics.test.ts | 387 - .../src/__tests__/http-tool-client.test.ts | 707 -- packages/mcps/mcp/src/__tests__/http.test.ts | 119 - .../mcp/src/__tests__/instructions.test.ts | 26 + packages/mcps/mcp/src/__tests__/links.test.ts | 246 - .../src/__tests__/mcp-surface-parity.test.ts | 910 -- .../mcps/mcp/src/__tests__/public-api.test.ts | 144 +- .../resources/package-schemas.test.ts | 9 +- .../resources/references-catalog.test.ts | 56 +- .../resources/references-openapi.test.ts | 79 - .../src/__tests__/server-registration.test.ts | 63 +- .../src/__tests__/server-telemetry.test.ts | 339 +- .../mcps/mcp/src/__tests__/server.test.ts | 123 +- .../mcp/src/__tests__/specs-entry.test.ts | 92 + packages/mcps/mcp/src/__tests__/stdio.test.ts | 13 +- .../mcp/src/__tests__/support/coded-error.ts | 15 - .../src/__tests__/support/connected-tool.ts | 30 - .../mcp/src/__tests__/support/stub-client.ts | 58 - .../src/__tests__/tool-definitions.test.ts | 39 - .../src/__tests__/tools/_resolvers.test.ts | 128 - .../mcps/mcp/src/__tests__/tools/auth.test.ts | 365 - .../mcp/src/__tests__/tools/bundle.test.ts | 77 +- .../src/__tests__/tools/deploy-manage.test.ts | 695 -- .../mcp/src/__tests__/tools/examples.test.ts | 16 +- .../src/__tests__/tools/feature-gate.test.ts | 76 - .../mcp/src/__tests__/tools/feedback.test.ts | 216 - .../mcp/src/__tests__/tools/flow-load.test.ts | 157 +- .../tools/flow-manage-preview.test.ts | 753 -- .../tools/flow-manage-user-data.test.ts | 225 - .../src/__tests__/tools/flow-manage.test.ts | 521 -- .../src/__tests__/tools/flow-schema.test.ts | 11 +- .../src/__tests__/tools/frame-manage.test.ts | 929 -- .../src/__tests__/tools/hub-manage.test.ts | 825 -- .../__tests__/tools/observe-journeys.test.ts | 327 - .../__tests__/tools/observe-session.test.ts | 954 -- .../mcp/src/__tests__/tools/package.test.ts | 29 +- .../tools/project-manage-user-data.test.ts | 83 - .../__tests__/tools/project-manage.test.ts | 307 - .../mcps/mcp/src/__tests__/tools/push.test.ts | 12 +- .../src/__tests__/tools/secret-manage.test.ts | 281 - .../tools/simulate-bundle-cache.test.ts | 29 +- .../mcp/src/__tests__/tools/simulate.test.ts | 87 +- .../src/__tests__/tools/url-inputs.test.ts | 78 + .../tools/validate-user-data.test.ts | 9 +- .../mcp/src/__tests__/tools/validate.test.ts | 30 +- packages/mcps/mcp/src/__tests__/types.test.ts | 120 - .../mcps/mcp/src/__tests__/ui-parts.test.ts | 47 - packages/mcps/mcp/src/action-requirements.ts | 173 - packages/mcps/mcp/src/base-url.ts | 41 - packages/mcps/mcp/src/catalog.ts | 150 +- packages/mcps/mcp/src/cloud-flow.ts | 23 - packages/mcps/mcp/src/http-tool-client.ts | 461 - packages/mcps/mcp/src/http.ts | 45 - packages/mcps/mcp/src/index.ts | 228 +- packages/mcps/mcp/src/instructions.ts | 36 +- packages/mcps/mcp/src/knowledge-spec.ts | 61 + packages/mcps/mcp/src/links.ts | 187 - packages/mcps/mcp/src/prompts/add-step.ts | 126 +- .../mcps/mcp/src/prompts/manage-contract.ts | 98 +- .../mcps/mcp/src/prompts/setup-mapping.ts | 88 +- .../mcps/mcp/src/resources/package-schemas.ts | 70 +- packages/mcps/mcp/src/resources/references.ts | 388 +- .../mcp/src/runtime/__tests__/hosted.test.ts | 119 - .../runtime/__tests__/runtime-tools.test.ts | 98 +- packages/mcps/mcp/src/runtime/bundle-cache.ts | 5 +- packages/mcps/mcp/src/runtime/hosted.ts | 114 - packages/mcps/mcp/src/runtime/types.ts | 29 +- packages/mcps/mcp/src/server.ts | 213 +- packages/mcps/mcp/src/specs.ts | 89 + packages/mcps/mcp/src/stdio.ts | 11 +- packages/mcps/mcp/src/surface.json | 375 - packages/mcps/mcp/src/tool-client.ts | 654 -- packages/mcps/mcp/src/tool-definitions.ts | 421 - packages/mcps/mcp/src/tool-handlers.ts | 26 + packages/mcps/mcp/src/tool-spec.ts | 22 +- .../tools/__tests__/package-app-url.test.ts | 45 - packages/mcps/mcp/src/tools/_resolvers.ts | 111 - packages/mcps/mcp/src/tools/auth.ts | 213 - packages/mcps/mcp/src/tools/bundle.ts | 43 +- packages/mcps/mcp/src/tools/deploy-manage.ts | 353 - packages/mcps/mcp/src/tools/diagnostics.ts | 186 - packages/mcps/mcp/src/tools/examples.ts | 37 +- packages/mcps/mcp/src/tools/feature-gate.ts | 48 - packages/mcps/mcp/src/tools/feedback.ts | 91 - packages/mcps/mcp/src/tools/flow-load.ts | 83 +- packages/mcps/mcp/src/tools/flow-manage.ts | 667 -- packages/mcps/mcp/src/tools/frame-manage.ts | 506 -- packages/mcps/mcp/src/tools/hub-manage.ts | 1109 --- packages/mcps/mcp/src/tools/narrow.ts | 13 +- .../mcps/mcp/src/tools/observe-journeys.ts | 197 - .../mcps/mcp/src/tools/observe-session.ts | 465 - packages/mcps/mcp/src/tools/package.ts | 56 +- .../mcps/mcp/src/tools/project-context.ts | 26 - packages/mcps/mcp/src/tools/project-manage.ts | 175 - packages/mcps/mcp/src/tools/push.ts | 20 +- .../mcps/mcp/src/tools/resolve-config-path.ts | 19 - packages/mcps/mcp/src/tools/secret-manage.ts | 189 - .../mcp/src/tools/simulate-description.ts | 5 +- packages/mcps/mcp/src/tools/simulate.ts | 72 +- packages/mcps/mcp/src/tools/validate.ts | 51 +- packages/mcps/mcp/src/types.ts | 52 - packages/mcps/mcp/src/ui-parts.ts | 61 - packages/mcps/mcp/tsup.config.ts | 5 +- packages/runner/eslint.config.js | 4 +- .../runner/src/__tests__/api-paths.test.ts | 27 - packages/runner/src/api-paths.ts | 5 +- skills/walkeros-debugging/SKILL.md | 11 +- skills/walkeros-mcp-actions/SKILL.md | 68 +- skills/walkeros-using-cli/SKILL.md | 69 +- .../walkeros-using-cli/commands-reference.md | 310 +- .../walkeros-using-cli/server-deployment.md | 38 +- .../walkeros-writing-documentation/SKILL.md | 9 +- website/docs/apps/cli.mdx | 690 +- website/docs/apps/mcp.mdx | 626 +- website/docs/apps/runtime.mdx | 6 +- website/docs/apps/storybook.mdx | 4 +- website/docs/collector/commands.mdx | 88 +- website/docs/collector/index.mdx | 107 +- website/docs/collector/logger.mdx | 4 +- website/docs/core/index.mdx | 12 +- website/docs/core/server.mdx | 180 +- website/docs/core/web.mdx | 46 +- website/docs/destinations/api/server.mdx | 2 +- website/docs/destinations/create-your-own.mdx | 11 +- website/docs/destinations/web/snowplow.mdx | 18 +- website/docs/getting-started/event-model.mdx | 50 +- .../examples/mapping-comparison.mdx | 12 +- website/docs/getting-started/flow/index.mdx | 18 +- website/docs/getting-started/flow/routing.mdx | 6 +- .../getting-started/flow/step-examples.mdx | 186 +- .../docs/getting-started/flow/validate.mdx | 28 +- website/docs/getting-started/observe.mdx | 4 +- .../docs/getting-started/quickstart/react.mdx | 8 +- .../getting-started/quickstart/walkerjs.mdx | 5 +- website/docs/guides/consent/index.mdx | 6 +- website/docs/guides/debugging.mdx | 2 +- website/docs/guides/migration.mdx | 70 +- website/docs/migrating/v3-to-v4.mdx | 5 +- website/docs/sources/create-your-own.mdx | 64 +- website/docs/sources/index.mdx | 52 +- website/docs/sources/server/aws.mdx | 6 +- website/docs/sources/web/browser/commands.mdx | 24 +- website/docs/sources/web/browser/tagger.mdx | 183 +- .../web/browser/tagging/html-attributes.mdx | 160 +- website/docs/sources/web/session/index.mdx | 6 +- website/docs/stores/cache.mdx | 8 +- website/docs/stores/server/fs.mdx | 13 +- website/docs/stores/server/gcs.mdx | 13 +- website/docs/stores/server/s3.mdx | 13 +- website/docs/stores/server/sheets.mdx | 6 +- website/docs/transformers/bot.mdx | 13 +- website/docs/transformers/create-your-own.mdx | 16 +- website/docs/transformers/index.mdx | 10 +- website/docs/upgrading.mdx | 66 +- 257 files changed, 4987 insertions(+), 46712 deletions(-) create mode 100644 .changeset/remote-api-separation.md create mode 100644 apps/scripts/validate-snippets.ts delete mode 100644 packages/cli/openapi/client-operations.json delete mode 100644 packages/cli/openapi/spec.json delete mode 100644 packages/cli/scripts/check-client-contract.mjs delete mode 100644 packages/cli/scripts/generate-client-spec.mjs delete mode 100644 packages/cli/src/__tests__/helpers/mock-api-client.ts delete mode 100644 packages/cli/src/__tests__/helpers/msw-handlers.ts delete mode 100644 packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts create mode 100644 packages/cli/src/__tests__/integration/cli/removed-commands.integration.test.ts create mode 100644 packages/cli/src/__tests__/unit/commands/auth-token.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/deploy.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/deployments.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/feedback.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/flows.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/frames.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/hub.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/list-pagination-handlers.test.ts delete mode 100644 packages/cli/src/__tests__/unit/commands/projects.test.ts delete mode 100644 packages/cli/src/__tests__/unit/core/api-client.test.ts delete mode 100644 packages/cli/src/__tests__/unit/core/http.test.ts delete mode 100644 packages/cli/src/__tests__/unit/core/sse.test.ts create mode 100644 packages/cli/src/__tests__/unit/entries-cli-free.test.ts create mode 100644 packages/cli/src/commands/api/__tests__/api-ls.test.ts create mode 100644 packages/cli/src/commands/api/__tests__/api.test.ts create mode 100644 packages/cli/src/commands/api/index.ts delete mode 100644 packages/cli/src/commands/deploy/index.ts delete mode 100644 packages/cli/src/commands/deployments/index.ts delete mode 100644 packages/cli/src/commands/feedback/index.ts delete mode 100644 packages/cli/src/commands/flows/index.ts delete mode 100644 packages/cli/src/commands/frames/index.ts delete mode 100644 packages/cli/src/commands/hub/index.ts delete mode 100644 packages/cli/src/commands/journeys/index.ts delete mode 100644 packages/cli/src/commands/observe/__tests__/observe.test.ts delete mode 100644 packages/cli/src/commands/observe/index.ts delete mode 100644 packages/cli/src/commands/previews/__tests__/output.test.ts delete mode 100644 packages/cli/src/commands/previews/__tests__/previews.test.ts delete mode 100644 packages/cli/src/commands/previews/index.ts delete mode 100644 packages/cli/src/commands/previews/output.ts delete mode 100644 packages/cli/src/commands/projects/index.ts delete mode 100644 packages/cli/src/commands/secrets/index.ts delete mode 100644 packages/cli/src/core/__tests__/api-error-426.test.ts delete mode 100644 packages/cli/src/core/__tests__/api-request.test.ts create mode 100644 packages/cli/src/core/__tests__/app-fetch.test.ts delete mode 100644 packages/cli/src/core/__tests__/contract.test.ts delete mode 100644 packages/cli/src/core/__tests__/openapi-subset.test.ts delete mode 100644 packages/cli/src/core/api-client.ts delete mode 100644 packages/cli/src/core/api-request.ts create mode 100644 packages/cli/src/core/app-fetch.ts create mode 100644 packages/cli/src/core/app-paths.ts delete mode 100644 packages/cli/src/core/contract.ts create mode 100644 packages/cli/src/core/fetch-failure.ts delete mode 100644 packages/cli/src/core/http.ts delete mode 100644 packages/cli/src/core/openapi-subset.ts delete mode 100644 packages/cli/src/core/sse.ts delete mode 100644 packages/cli/src/types/api.gen.d.ts create mode 100644 packages/cli/src/validate.ts delete mode 100644 packages/mcps/mcp/src/__tests__/action-requirements.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/action-schema-sync.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/base-url.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/diagnostics.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/http-tool-client.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/http.test.ts create mode 100644 packages/mcps/mcp/src/__tests__/instructions.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/links.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/mcp-surface-parity.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/resources/references-openapi.test.ts create mode 100644 packages/mcps/mcp/src/__tests__/specs-entry.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/support/coded-error.ts delete mode 100644 packages/mcps/mcp/src/__tests__/support/connected-tool.ts delete mode 100644 packages/mcps/mcp/src/__tests__/support/stub-client.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tool-definitions.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/_resolvers.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/auth.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/deploy-manage.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/feature-gate.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/feedback.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/flow-manage-preview.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/flow-manage-user-data.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/flow-manage.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/frame-manage.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/hub-manage.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/observe-journeys.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/observe-session.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/project-manage-user-data.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/project-manage.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/tools/secret-manage.test.ts create mode 100644 packages/mcps/mcp/src/__tests__/tools/url-inputs.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/types.test.ts delete mode 100644 packages/mcps/mcp/src/__tests__/ui-parts.test.ts delete mode 100644 packages/mcps/mcp/src/action-requirements.ts delete mode 100644 packages/mcps/mcp/src/base-url.ts delete mode 100644 packages/mcps/mcp/src/cloud-flow.ts delete mode 100644 packages/mcps/mcp/src/http-tool-client.ts delete mode 100644 packages/mcps/mcp/src/http.ts create mode 100644 packages/mcps/mcp/src/knowledge-spec.ts delete mode 100644 packages/mcps/mcp/src/links.ts delete mode 100644 packages/mcps/mcp/src/runtime/__tests__/hosted.test.ts delete mode 100644 packages/mcps/mcp/src/runtime/hosted.ts create mode 100644 packages/mcps/mcp/src/specs.ts delete mode 100644 packages/mcps/mcp/src/surface.json delete mode 100644 packages/mcps/mcp/src/tool-client.ts delete mode 100644 packages/mcps/mcp/src/tool-definitions.ts create mode 100644 packages/mcps/mcp/src/tool-handlers.ts delete mode 100644 packages/mcps/mcp/src/tools/__tests__/package-app-url.test.ts delete mode 100644 packages/mcps/mcp/src/tools/_resolvers.ts delete mode 100644 packages/mcps/mcp/src/tools/auth.ts delete mode 100644 packages/mcps/mcp/src/tools/deploy-manage.ts delete mode 100644 packages/mcps/mcp/src/tools/diagnostics.ts delete mode 100644 packages/mcps/mcp/src/tools/feature-gate.ts delete mode 100644 packages/mcps/mcp/src/tools/feedback.ts delete mode 100644 packages/mcps/mcp/src/tools/flow-manage.ts delete mode 100644 packages/mcps/mcp/src/tools/frame-manage.ts delete mode 100644 packages/mcps/mcp/src/tools/hub-manage.ts delete mode 100644 packages/mcps/mcp/src/tools/observe-journeys.ts delete mode 100644 packages/mcps/mcp/src/tools/observe-session.ts delete mode 100644 packages/mcps/mcp/src/tools/project-context.ts delete mode 100644 packages/mcps/mcp/src/tools/project-manage.ts delete mode 100644 packages/mcps/mcp/src/tools/resolve-config-path.ts delete mode 100644 packages/mcps/mcp/src/tools/secret-manage.ts delete mode 100644 packages/mcps/mcp/src/types.ts delete mode 100644 packages/mcps/mcp/src/ui-parts.ts diff --git a/.changeset/remote-api-separation.md b/.changeset/remote-api-separation.md new file mode 100644 index 000000000..bc5badd99 --- /dev/null +++ b/.changeset/remote-api-separation.md @@ -0,0 +1,11 @@ +--- +'@walkeros/cli': minor +'@walkeros/mcp': minor +--- + +New `walkeros api` sends any request to the walkerOS app API, `walkeros api ls` +lists its operations, `walkeros auth token` prints your token. The typed remote +commands (projects, flows, deploy, previews, observe, feedback) and their +programmatic functions are gone. The local MCP server keeps the flow tools; +saved flows and deployments move to the hosted server at +app.walkeros.io/api/mcp. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 509eb348f..ec038275f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "walkeros", "version": "0.2.0", - "description": "Skills and MCP tools for walkerOS - the open-source event data collection platform. Learn architecture, create sources/destinations/transformers, validate events, bundle flows, and manage projects via API.", + "description": "Skills and MCP tools for walkerOS - the open-source event data collection platform. Learn architecture, create sources/destinations/transformers, validate events, bundle flows locally, and manage projects and deployments through the hosted walkerOS MCP server.", "author": { "name": "elbwalker", "url": "https://github.com/elbwalker" @@ -50,11 +50,11 @@ "mcpServers": { "walkeros-flow": { "command": "npx", - "args": ["@walkeros/mcp"], - "env": { - "WALKEROS_TOKEN": "", - "WALKEROS_PROJECT_ID": "" - } + "args": ["@walkeros/mcp"] + }, + "walkeros": { + "type": "http", + "url": "https://app.walkeros.io/api/mcp" } } } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b563bdd53..825e9e371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,29 +78,6 @@ jobs: echo "No completed green push or dispatch \"Build and test\" run on $SHA; the release runs the full suite on the bumped build." echo "tested=false" >> "$GITHUB_OUTPUT" - # Client contract preflight: the operations the released clients call, - # compared per operation with what stage serves. Public request, no - # credential, no install: the script runs on plain node from a sparse - # checkout. An unreachable stage warns and never blocks. A stable - # release blocks on a mismatch; a pre-release only warns. - - name: Checkout CLI contract files - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - sparse-checkout: packages/cli - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24.15.0' - package-manager-cache: false - - name: Client contract against stage - env: - PRE_RELEASE_WARN_ONLY: ${{ inputs.release_type != 'stable release' && '--warn-only' || '' }} - run: | - node packages/cli/scripts/check-client-contract.mjs \ - --live https://stage.app.walkeros.io/api/openapi.json \ - $PRE_RELEASE_WARN_ONLY - # Needs only the gate, so the npm-publish approval is requested once, at the # start. The reviewer rule stays on npm-publish; no other job carries it. # diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd12a61ad..4a27c0ec9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,38 +67,6 @@ jobs: key: turbo-${{ runner.os }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}- - # Drift gate: the committed CLI api.gen.d.ts (and spec.json) must equal a - # fresh, prettier-formatted generate:types run. Prevents stale/unformatted - # generated API types from being committed (the recurring eternal-diff). - - name: Verify CLI API types are in sync - run: | - npm --prefix packages/cli run generate:types - git diff --exit-code packages/cli/openapi/spec.json packages/cli/src/types/api.gen.d.ts \ - || (echo "::error ::CLI generated API types are stale. Run 'cd packages/cli && npm run generate:types' and commit the result." && exit 1) - # The contract label's floor never goes backwards against the base: the - # target branch on a pull request, the previous tip on a push. Skipped - # without a base (a dispatch, a new branch) or when the base has no - # client spec. - - name: Verify the contract label floor - env: - EVENT_NAME: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - BEFORE: ${{ github.event.before }} - run: | - if [ "$EVENT_NAME" = "pull_request" ]; then - BASE="origin/${BASE_REF}" - else - BASE="${BEFORE}" - fi - if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then - echo "No base commit; contract label check skipped." - exit 0 - fi - if ! git show "$BASE:packages/cli/openapi/spec.json" > "$RUNNER_TEMP/base-spec.json" 2>/dev/null; then - echo "No packages/cli/openapi/spec.json at $BASE; contract label check skipped." - exit 0 - fi - node packages/cli/scripts/check-client-contract.mjs --label-base "$RUNNER_TEMP/base-spec.json" - name: Validate documentation run: npm run validate:links - name: Verify (PR, affected only) diff --git a/apps/scripts/validate-snippets.ts b/apps/scripts/validate-snippets.ts new file mode 100644 index 000000000..b24956df8 --- /dev/null +++ b/apps/scripts/validate-snippets.ts @@ -0,0 +1,333 @@ +#!/usr/bin/env npx tsx +/** + * Validates that every `` in the website docs + * renders Prettier-formatted code. + * Usage: npx tsx apps/scripts/validate-snippets.ts [--write] + * + * `CodeSnippet` shows its code exactly as authored, so an unindented snippet + * reaches the reader unindented. The check reads the string the component + * actually receives: the page is parsed the way Docusaurus parses it, and the + * site's own indentation-restoring remark plugin runs first. + * + * A snippet Prettier can parse must match Prettier's output; `--write` replaces + * each unformatted template literal in the source with the formatted code. + * Excerpts Prettier cannot parse or would misread (`settings: {...}`, `{ ... }` + * placeholders, a lone opening tag) and languages it has no parser for (bash, + * sql) are checked for indentation only, and fixed by hand. + */ + +import { escapeMarkdownHeadingIds } from '@docusaurus/utils'; +import type { TemplateLiteral } from 'estree'; +import { readFileSync, writeFileSync } from 'fs'; +import { glob } from 'glob'; +import type { Nodes } from 'mdast'; +import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'; +import { join, relative } from 'path'; +import * as prettier from 'prettier'; +import remarkMdx from 'remark-mdx'; +import remarkParse from 'remark-parse'; +import ts from 'typescript'; +import { unified } from 'unified'; +import restoreExpressionIndent from '../../website/src/remark/restore-expression-indent'; + +interface Issue { + file: string; + line: number; + message: string; +} + +interface Snippet { + // Source line of the `code={`` attribute. + line: number; + language: string; + literal: TemplateLiteral; +} + +const ROOT = join(__dirname, '..', '..'); +const DOCS_GLOB = 'website/docs/**/*.{md,mdx}'; +const WRITE = process.argv.includes('--write'); + +// `CodeSnippet` defaults to javascript when no language is given. +const DEFAULT_LANGUAGE = 'javascript'; + +// The file extension tells Prettier which parser and dialect to use (tsx +// enables JSX for the typescript parser). +const EXTENSIONS: Record = { + javascript: 'js', + typescript: 'ts', + tsx: 'tsx', + json: 'json', + html: 'html', + yaml: 'yaml', +}; + +function isJsxElement( + node: Nodes, +): node is MdxJsxFlowElement | MdxJsxTextElement { + return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement'; +} + +function stringAttribute( + element: MdxJsxFlowElement | MdxJsxTextElement, + name: string, +): string | undefined { + for (const attribute of element.attributes) { + if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) { + continue; + } + return typeof attribute.value === 'string' ? attribute.value : undefined; + } + return undefined; +} + +function codeLiteral( + element: MdxJsxFlowElement | MdxJsxTextElement, +): TemplateLiteral | undefined { + for (const attribute of element.attributes) { + if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== 'code') { + continue; + } + const value = attribute.value; + if (!value || typeof value === 'string') return undefined; + const statement = value.data?.estree?.body[0]; + if (statement?.type !== 'ExpressionStatement') return undefined; + const expression = statement.expression; + if (expression.type !== 'TemplateLiteral') return undefined; + // An interpolated template is assembled at runtime, not authored as text. + if (expression.expressions.length > 0) return undefined; + return expression; + } + return undefined; +} + +function collectSnippets(node: Nodes, snippets: Snippet[]): void { + if (isJsxElement(node) && node.name === 'CodeSnippet') { + const literal = codeLiteral(node); + if (literal?.loc) { + snippets.push({ + line: literal.loc.start.line, + language: stringAttribute(node, 'language') ?? DEFAULT_LANGUAGE, + literal, + }); + } + } + if ('children' in node) { + for (const child of node.children) collectSnippets(child, snippets); + } +} + +function parseSnippets(source: string): Snippet[] { + // Docusaurus escapes `{#id}` heading anchors before MDX parses the page. The + // escape only adds characters on heading lines, so line numbers and the + // columns on snippet lines stay valid for the original source. + const content = escapeMarkdownHeadingIds(source); + const tree = unified().use(remarkParse).use(remarkMdx).parse(content); + restoreExpressionIndent()(tree, { value: content }); + const snippets: Snippet[] = []; + collectSnippets(tree, snippets); + return snippets; +} + +// Offsets of the literal's backticks in the original source, or undefined when +// the source text there is not the code the page renders. +function sourceRange( + lineOffsets: number[], + source: string, + literal: TemplateLiteral, +): { start: number; end: number } | undefined { + const loc = literal.loc; + if (!loc) return undefined; + const start = lineOffsets[loc.start.line - 1] + loc.start.column; + const end = lineOffsets[loc.end.line - 1] + loc.end.column; + if (source[start] !== '`' || source[end - 1] !== '`') return undefined; + if (source.slice(start + 1, end - 1) !== literal.quasis[0].value.raw) { + return undefined; + } + return { start, end }; +} + +function lineOffsetsOf(source: string): number[] { + const offsets = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') offsets.push(index + 1); + } + return offsets; +} + +function escapeTemplate(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +const SCRIPT_KINDS: Record = { + js: ts.ScriptKind.JSX, + ts: ts.ScriptKind.TS, + tsx: ts.ScriptKind.TSX, +}; + +function hasLabel(code: string, scriptKind: ts.ScriptKind): boolean { + const file = ts.createSourceFile( + 'snippet', + code, + ts.ScriptTarget.Latest, + false, + scriptKind, + ); + const visit = (node: ts.Node): boolean => + ts.isLabeledStatement(node) || (ts.forEachChild(node, visit) ?? false); + return visit(file); +} + +function count(text: string, pattern: RegExp): number { + return text.match(pattern)?.length ?? 0; +} + +/** + * Whether Prettier read an excerpt as different code. Object excerpts such as + * `data: { map: { platform: 'source.platform' } }` parse as labeled blocks, and + * formatting them as statements adds semicolons. An HTML excerpt showing an + * opening tag gets the closing tag added. + */ +function misread(code: string, formatted: string, extension: string): boolean { + const scriptKind = SCRIPT_KINDS[extension]; + if (scriptKind !== undefined) return hasLabel(code, scriptKind); + if (extension === 'html') + return count(formatted, /<\//g) > count(code, /<\//g); + return false; +} + +// The formatted code, or undefined when Prettier has no parser for the language +// or the snippet is an excerpt it cannot parse, or would read as other code. +async function formatSnippet( + code: string, + language: string, +): Promise { + const extension = EXTENSIONS[language]; + if (!extension) return undefined; + const filepath = join(ROOT, 'website', 'docs', `snippet.${extension}`); + const config = await prettier.resolveConfig(filepath); + let formatted: string; + try { + // Prettier ends a file with a newline; a snippet ends with its last line. + formatted = (await prettier.format(code, { ...config, filepath })).replace( + /\n$/, + '', + ); + } catch { + return undefined; + } + return misread(code, formatted, extension) ? undefined : formatted; +} + +function indentOf(line: string): number { + return line.length - line.trimStart().length; +} + +/** + * The first snippet line (1-based) that sits at or left of the line opening a + * bracket above it, the shape a flattened snippet has. A closer directly after + * the opener (`{}` split over two lines) is fine. + */ +function flattenedLine(code: string): number | undefined { + const lines = code.split('\n'); + for (let index = 0; index < lines.length - 1; index += 1) { + if (!/[{([]$/.test(lines[index].trimEnd())) continue; + const offset = lines + .slice(index + 1) + .findIndex((line) => line.trim() !== ''); + if (offset === -1) return undefined; + const next = index + 1 + offset; + if (/^[})\]]/.test(lines[next].trimStart())) continue; + if (indentOf(lines[next]) <= indentOf(lines[index])) return next + 1; + } + return undefined; +} + +async function main(): Promise { + const files = await glob(DOCS_GLOB, { + cwd: ROOT, + ignore: ['**/node_modules/**'], + absolute: true, + }); + + const issues: Issue[] = []; + let formattedCount = 0; + let indentOnlyCount = 0; + let rewritten = 0; + + for (const file of files.sort()) { + const rel = relative(ROOT, file); + const source = readFileSync(file, 'utf-8'); + const lineOffsets = lineOffsetsOf(source); + const edits: { start: number; end: number; text: string }[] = []; + + for (const snippet of parseSnippets(source)) { + const code = snippet.literal.quasis[0].value.cooked ?? ''; + const formatted = await formatSnippet(code, snippet.language); + + if (formatted === undefined) { + indentOnlyCount += 1; + const line = flattenedLine(code); + if (line !== undefined) { + issues.push({ + file: rel, + line: snippet.line + line - 1, + message: + 'not indented under the line that opens it (Prettier cannot parse this snippet; fix it by hand)', + }); + } + continue; + } + + formattedCount += 1; + if (formatted === code) continue; + + const range = WRITE + ? sourceRange(lineOffsets, source, snippet.literal) + : undefined; + if (range) { + edits.push({ ...range, text: `\`${escapeTemplate(formatted)}\`` }); + continue; + } + issues.push({ + file: rel, + line: snippet.line, + message: WRITE + ? 'not formatted, and the source does not match the rendered code; fix it by hand' + : 'not formatted (npm run validate:snippets -- --write)', + }); + } + + if (edits.length === 0) continue; + let output = source; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end); + } + writeFileSync(file, output); + rewritten += edits.length; + } + + if (WRITE) console.log(`Formatted ${rewritten} snippets.\n`); + + const summary = `${formattedCount} checked against Prettier, ${indentOnlyCount} for indentation only`; + if (issues.length === 0) { + console.log(`✅ All code snippets are formatted (${summary}).\n`); + process.exit(0); + } + + console.log(`Code snippets: ${summary}.\n`); + + console.log(`❌ Found ${issues.length} snippet issues:\n`); + for (const issue of issues) { + console.log(` ${issue.file}:${issue.line}`); + console.log(` ${issue.message}\n`); + } + process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index b8acf113f..32ff29f03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14689,89 +14689,6 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/config": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", - "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core": { - "version": "1.34.15", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", - "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "8.11.2", - "@redocly/config": "0.22.0", - "colorette": "1.4.0", - "https-proxy-agent": "7.0.6", - "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", - "minimatch": "5.1.9", - "pluralize": "8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -22239,13 +22156,6 @@ "node": ">=8" } }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, - "license": "MIT" - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -29347,6 +29257,7 @@ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -32166,16 +32077,6 @@ "node": ">=12" } }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -37263,42 +37164,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openapi-fetch": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", - "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", - "license": "MIT", - "dependencies": { - "openapi-typescript-helpers": "^0.1.0" - } - }, - "node_modules/openapi-typescript": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", - "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/openapi-core": "^1.34.6", - "ansi-colors": "^4.1.3", - "change-case": "^5.4.4", - "parse-json": "^8.3.0", - "supports-color": "^10.2.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "openapi-typescript": "bin/cli.js" - }, - "peerDependencies": { - "typescript": "^5.x" - } - }, - "node_modules/openapi-typescript-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", - "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", - "license": "MIT" - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -37725,6 +37590,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", @@ -37742,6 +37608,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=16" }, @@ -38196,16 +38063,6 @@ "node": ">=16.0.0" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -43796,19 +43653,6 @@ "node": ">= 6" } }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-hyperlinks": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", @@ -46088,13 +45932,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-js-replace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", - "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "dev": true, - "license": "MIT" - }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -47536,13 +47373,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -47694,7 +47524,6 @@ "jsdom": "^29.1.1", "npm-package-arg": "^13.0.2", "open": "^11.0.0", - "openapi-fetch": "^0.17.0", "pacote": "^21.0.4", "picomatch": "^4.0.4", "semver": "^7.7.4", @@ -47735,7 +47564,6 @@ "@walkeros/web-source-datalayer": "4.8.0", "@walkeros/web-source-session": "4.8.0", "msw": "^2.12.10", - "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" } }, diff --git a/package.json b/package.json index b2b69751c..a66c19c92 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,11 @@ "generate:skills-index": "npx tsx apps/scripts/generate-skills-index.ts", "validate:links": "npx tsx apps/scripts/validate-links.ts", "validate:docs": "npx tsx apps/scripts/validate-docs.ts", + "validate:snippets": "npx tsx apps/scripts/validate-snippets.ts", "validate:skills": "npx tsx apps/scripts/validate-skills-index.ts", "validate:llms": "npx tsx apps/scripts/validate-llms.ts", "validate:coverage": "npx tsx apps/scripts/validate-docs-coverage.ts", - "validate:sources": "npm run validate:links && npm run validate:docs && npm run validate:skills && npm run validate:coverage", + "validate:sources": "npm run validate:links && npm run validate:docs && npm run validate:snippets && npm run validate:skills && npm run validate:coverage", "validate": "npm run validate:sources && npm run validate:llms" }, "devDependencies": { diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js index 8488b56a6..130636a4e 100644 --- a/packages/cli/eslint.config.js +++ b/packages/cli/eslint.config.js @@ -8,15 +8,14 @@ const baseSyntax = baseConfig.flatMap((block) => { return Array.isArray(rule) ? rule.slice(1) : []; }); -// App operations go through the typed client (`apiRequest`, or the -// openapi-fetch client), so the compiler checks each against the contract. +// The CLI calls a fixed set of app operations, named in one file, so the app +// can keep each one in its public API set. const apiPathMessage = - "Call the app through apiRequest(' ') or the typed openapi-fetch client, not a string path."; -const openapiFetchArgument = - 'CallExpression[callee.type="MemberExpression"][callee.property.name=/^(GET|POST|PUT|PATCH|DELETE)$/] > Literal.arguments:first-child'; + 'The CLI names app paths only in src/core/app-paths.ts (its fixed operations); walkeros api takes its path from the person.'; const apiPathSyntax = [ { - selector: `Literal[value=/^\\/api\\//]:not(${openapiFetchArgument}):not(TSLiteralType > Literal):not(TSPropertySignature > Literal.key)`, + selector: + 'Literal[value=/^\\/api\\//]:not(TSLiteralType > Literal):not(TSPropertySignature > Literal.key)', message: apiPathMessage, }, { @@ -32,7 +31,7 @@ export default [ ...baseConfig, { files: ['src/**/*.ts'], - ignores: [...tests, ...devZones], + ignores: [...tests, ...devZones, 'src/core/app-paths.ts'], rules: { 'no-restricted-syntax': ['error', ...baseSyntax, ...apiPathSyntax], }, @@ -71,25 +70,4 @@ export default [ ], }, }, - { - // The raw fetch helpers stay public API (re-exported by src/index.ts), - // but app calls outside src/core go through apiRequest. - files: ['src/**/*.ts'], - ignores: ['src/core/**', 'src/index.ts', ...tests], - rules: { - 'no-restricted-imports': [ - 'error', - { - patterns: [ - { - regex: '(^|/)core/http(\\.js)?$', - importNames: ['apiFetch', 'publicFetch', 'deployFetch'], - message: - 'Call the app through apiRequest from core/api-request instead.', - }, - ], - }, - ], - }, - }, ]; diff --git a/packages/cli/examples/flow-complete.md b/packages/cli/examples/flow-complete.md index 72cda5bf6..4a4016b2a 100644 --- a/packages/cli/examples/flow-complete.md +++ b/packages/cli/examples/flow-complete.md @@ -907,16 +907,16 @@ walkeros cache info from the flow variable, so LOG_LEVEL changes it per environment. - **CI:** `walkeros validate packages/cli/examples/flow-complete.json --strict` plus the step examples as fixtures. -- **CLI (app-bound, need a login; listed, not run for this guide):** +- **CLI (app-bound, need a login or WALKEROS_TOKEN; listed, not run for this + guide):** ```bash -walkeros flows create flow-complete -c packages/cli/examples/flow-complete.json -walkeros deploy create packages/cli/examples/flow-complete.json -walkeros deploy start -walkeros observe start +walkeros api GET /api/projects/{projectId}/flows +walkeros api POST /api/projects/{projectId}/flows//deploy --header 'Idempotency-Key: ' ``` -- **MCP:** `flow_manage`, `deploy_manage`, `observe_session`, `flow_validate` - then `flow_simulate` then `flow_push` as the agent loop. +- **MCP:** on your machine `flow_validate`, then `flow_simulate`, then + `flow_push` as the agent loop; saved flows, deployments and Observe through + the hosted walkerOS MCP server. - **In GTM:** preview mode and container versions. - **Docs:** `collector/logger`. diff --git a/packages/cli/jest.config.mjs b/packages/cli/jest.config.mjs index 8aed1d4fa..02b02e18d 100644 --- a/packages/cli/jest.config.mjs +++ b/packages/cli/jest.config.mjs @@ -9,9 +9,10 @@ const config = { // forceExit, and keep --detectOpenHandles in CI as a guard. maxWorkers: 3, testTimeout: 30000, - // Transform ESM packages: jsdom and its dependency closure are pure ESM. + // Transform ESM packages: jsdom and its dependency closure are pure ESM, and + // so is commander, which the command registration tests load. transformIgnorePatterns: [ - 'node_modules/(?!(@walkeros|chalk|jsdom|parse5|nwsapi|entities|@exodus/bytes|@asamuzakjp|@bramus|@csstools|css-tree|tough-cookie|msw|until-async|rettime|@bundled-es-modules|@mswjs|@open-draft)/)', + 'node_modules/(?!(@walkeros|chalk|commander|jsdom|parse5|nwsapi|entities|@exodus/bytes|@asamuzakjp|@bramus|@csstools|css-tree|tough-cookie|msw|until-async|rettime|@bundled-es-modules|@mswjs|@open-draft)/)', ], testPathIgnorePatterns: [ ...(baseConfig.testPathIgnorePatterns || []), diff --git a/packages/cli/openapi/client-operations.json b/packages/cli/openapi/client-operations.json deleted file mode 100644 index 01472f118..000000000 --- a/packages/cli/openapi/client-operations.json +++ /dev/null @@ -1,250 +0,0 @@ -[ - { - "op": "GET /api/auth/whoami", - "usedBy": ["mcp auth", "walkeros auth login", "walkeros auth whoami"] - }, - { - "op": "POST /api/feedback", - "usedBy": ["mcp feedback", "walkeros feedback"] - }, - { - "op": "GET /api/health", - "usedBy": ["mcp diagnostics", "walkeros diagnostics"] - }, - { - "op": "POST /api/oauth/device_authorization", - "usedBy": ["mcp auth", "walkeros auth login"] - }, - { - "op": "POST /api/oauth/revoke", - "usedBy": ["mcp auth", "walkeros auth logout"] - }, - { - "op": "POST /api/oauth/token", - "usedBy": [ - "@walkeros/cli resolveAccessToken", - "mcp auth", - "walkeros auth login" - ] - }, - { - "op": "GET /api/openapi.json", - "usedBy": [ - "mcp diagnostics", - "mcp resource walkeros://reference/openapi", - "walkeros diagnostics" - ] - }, - { - "op": "GET /api/packages", - "usedBy": [ - "mcp package_search", - "mcp resource walkeros://reference/packages", - "mcp resource walkeros://schema/{packageName}" - ] - }, - { - "op": "GET /api/projects", - "usedBy": [ - "mcp flow_manage", - "mcp project_manage", - "walkeros projects list" - ] - }, - { - "op": "POST /api/projects", - "usedBy": ["mcp project_manage", "walkeros projects create"] - }, - { - "op": "DELETE /api/projects/{projectId}", - "usedBy": ["mcp project_manage", "walkeros projects delete"] - }, - { - "op": "GET /api/projects/{projectId}", - "usedBy": ["mcp project_manage", "walkeros projects get"] - }, - { - "op": "PATCH /api/projects/{projectId}", - "usedBy": ["mcp project_manage", "walkeros projects update"] - }, - { - "op": "GET /api/projects/{projectId}/deployments", - "usedBy": ["mcp deploy_manage", "walkeros deploy list"] - }, - { - "op": "POST /api/projects/{projectId}/deployments", - "usedBy": ["walkeros deploy create"] - }, - { - "op": "DELETE /api/projects/{projectId}/deployments/{deploymentId}", - "usedBy": ["mcp deploy_manage", "walkeros deploy delete"] - }, - { - "op": "GET /api/projects/{projectId}/deployments/{deploymentId}", - "usedBy": ["mcp deploy_manage", "walkeros deploy status"] - }, - { - "op": "GET /api/projects/{projectId}/deployments/{deploymentId}/stream", - "usedBy": ["mcp deploy_manage", "walkeros deploy start"] - }, - { - "op": "GET /api/projects/{projectId}/flows", - "usedBy": ["mcp flow_manage", "walkeros flows list"] - }, - { - "op": "POST /api/projects/{projectId}/flows", - "usedBy": ["mcp flow_manage", "walkeros flows create"] - }, - { - "op": "DELETE /api/projects/{projectId}/flows/{flowId}", - "usedBy": ["mcp flow_manage", "walkeros flows delete"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}", - "usedBy": [ - "mcp deploy_manage", - "mcp flow_bundle", - "mcp flow_load", - "mcp flow_manage", - "mcp flow_simulate", - "mcp observe_session", - "walkeros deploy create", - "walkeros deploy start", - "walkeros flows get", - "walkeros observe start", - "walkeros previews create" - ] - }, - { - "op": "PATCH /api/projects/{projectId}/flows/{flowId}", - "usedBy": ["mcp flow_manage", "walkeros flows update"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/deploy", - "usedBy": ["@walkeros/cli getDeployment"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/deploy", - "usedBy": ["mcp deploy_manage", "walkeros deploy start"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/duplicate", - "usedBy": ["mcp flow_manage", "walkeros flows duplicate"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/frames", - "usedBy": ["mcp frame_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/frames/{frameId}", - "usedBy": ["mcp frame_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/journeys", - "usedBy": ["mcp flow_manage", "mcp observe_journeys", "mcp observe_session"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/observe-sessions", - "usedBy": ["mcp observe_session", "walkeros observe start"] - }, - { - "op": "DELETE /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}", - "usedBy": ["mcp observe_session"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}", - "usedBy": ["mcp observe_session", "walkeros observe start"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/heartbeat", - "usedBy": ["walkeros observe start"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/previews", - "usedBy": ["mcp flow_manage", "walkeros previews list"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/previews", - "usedBy": ["mcp flow_manage", "walkeros previews create"] - }, - { - "op": "DELETE /api/projects/{projectId}/flows/{flowId}/previews/{previewId}", - "usedBy": ["mcp flow_manage", "walkeros previews delete"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/previews/{previewId}", - "usedBy": ["mcp flow_manage", "walkeros previews get"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant", - "usedBy": ["mcp flow_manage", "walkeros previews create"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/releases", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "PUT /api/projects/{projectId}/flows/{flowId}/releases/annotations", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/releases/step-history", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/releases/{versionId}", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/secrets", - "usedBy": ["mcp secret_manage"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/secrets", - "usedBy": ["mcp secret_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/secrets/values", - "usedBy": ["runner"] - }, - { - "op": "DELETE /api/projects/{projectId}/flows/{flowId}/secrets/{secretId}", - "usedBy": ["mcp secret_manage"] - }, - { - "op": "PUT /api/projects/{projectId}/flows/{flowId}/secrets/{secretId}", - "usedBy": ["mcp secret_manage"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy", - "usedBy": ["@walkeros/cli getDeployment"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy", - "usedBy": ["mcp deploy_manage", "walkeros deploy start"] - }, - { - "op": "GET /api/projects/{projectId}/flows/{flowId}/threads", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/threads", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "POST /api/projects/{projectId}/flows/{flowId}/threads/{threadId}/messages", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "GET /api/projects/{projectId}/knowledge", - "usedBy": ["mcp hub_manage"] - }, - { - "op": "POST /api/projects/{projectId}/runners/heartbeat", - "usedBy": ["runner"] - }, - { - "op": "POST /api/telemetry", - "usedBy": ["@walkeros/cli telemetry"] - } -] diff --git a/packages/cli/openapi/spec.json b/packages/cli/openapi/spec.json deleted file mode 100644 index c10433d39..000000000 --- a/packages/cli/openapi/spec.json +++ /dev/null @@ -1,7915 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "walkerOS Tag Manager API", - "version": "4.7.0+9c4b69f0", - "contact": { - "name": "elbwalker", - "url": "https://www.elbwalker.com" - } - }, - "servers": [ - { - "url": "/" - } - ], - "paths": { - "/api/auth/whoami": { - "get": { - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhoamiResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListProjectsResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProjectRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProjectResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectDetailResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "patch": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProjectRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProjectResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "enum": ["name", "updated_at", "created_at"] - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["asc", "desc"] - }, - "required": false, - "name": "order", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["true", "false"] - }, - "required": false, - "name": "include_deleted", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListFlowsResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFlowRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Flow" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "fields", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FlowDetailResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "patch": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "if-match", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFlowRequest" - } - }, - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFlowRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FlowUpdateResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "412": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FlowConflictResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "if-match", - "in": "header" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "412": { - "description": "" - }, - "502": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/duplicate": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DuplicateFlowRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Flow" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/secrets": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "secrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "flowId", - "createdAt", - "updatedAt" - ] - } - } - }, - "required": ["secrets"] - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Z_][A-Z0-9_]*$" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 - } - }, - "required": ["name", "value"] - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/secrets/{secretId}": { - "put": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "secretId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 - } - }, - "required": ["value"] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "secretId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/secrets/values": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "values": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["values"] - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/deploy": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploymentResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartDeploymentResponse" - } - } - } - }, - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartDeploymentResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "settingsId", - "in": "path" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploySettingsRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploySettingsResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "settingsId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettingsDeploymentResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/previews": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePreviewRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePreviewResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListPreviewsResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$" - }, - "required": true, - "name": "previewId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreviewResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$" - }, - "required": true, - "name": "previewId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$" - }, - "required": true, - "name": "previewId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MintGrantRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MintGrantResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateObserveSessionRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveSessionResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "sessionId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveSessionResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "sessionId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/journeys": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": false, - "name": "traceId", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FlowJourneysResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/heartbeat": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "sessionId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveSessionHeartbeatResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/deployments": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed" - ] - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["web", "server"] - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["cloud", "self-hosted"] - }, - "required": false, - "name": "origin", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": false, - "name": "flowId", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["created_at", "updated_at"] - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["asc", "desc"] - }, - "required": false, - "name": "order", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDeploymentsResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "idempotency-key", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "label": { - "type": "string", - "maxLength": 255 - }, - "flowId": { - "type": "string" - }, - "flowSettingsId": { - "type": "string" - } - }, - "required": ["type"] - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDeploymentResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/health": { - "get": { - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - } - } - } - }, - "/api/openapi.json": { - "get": { - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {}, - "additionalProperties": {} - } - } - } - } - } - } - }, - "/api/feedback": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackRequest" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/telemetry": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelemetryEvent" - } - } - } - }, - "responses": { - "204": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "deploymentId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploymentDetailResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "deploymentId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "" - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}/stream": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "deploymentId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/DeploymentStreamStatusEvent" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["true", "false"] - }, - "required": false, - "name": "rationale", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListFlowReleasesResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases/{versionId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^(ver_[a-zA-Z0-9_-]+|[1-9][0-9]*)$" - }, - "required": true, - "name": "versionId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReleaseDetailResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases/annotations": { - "put": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "humanText": { - "type": ["string", "null"], - "maxLength": 4000 - } - }, - "required": ["versionId", "humanText"] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertVersionAnnotationResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/threads": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "enum": ["step", "entity_action", "release", "contract", "tag"] - }, - "required": false, - "name": "anchorType", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "anchorKey", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["open", "resolved"] - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["true", "false"] - }, - "required": false, - "name": "includeMessages", - "in": "query" - }, - { - "schema": { - "type": "integer" - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListHubThreadsResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "anchorType": { - "type": "string", - "enum": [ - "step", - "entity_action", - "release", - "contract", - "tag" - ] - }, - "anchorKey": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "anchorLabel": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "text": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - } - }, - "required": ["anchorType", "anchorKey", "text"] - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HubThreadResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/threads/{threadId}/messages": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^thr_[a-z0-9]+$" - }, - "required": true, - "name": "threadId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - } - }, - "required": ["text"] - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HubThreadResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/knowledge": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": false, - "name": "flowId", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "pageKey", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^frm_[A-Za-z0-9_-]{21}$" - }, - "required": false, - "name": "frameId", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "markId", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["true", "false"] - }, - "required": false, - "name": "includeMessages", - "in": "query" - }, - { - "schema": { - "type": "integer" - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListKnowledgeResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/frames": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 1024 - }, - "required": false, - "name": "pageKey", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["marks"] - }, - "required": false, - "name": "include", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/FrameListResponse" - }, - { - "$ref": "#/components/schemas/FrameLeanListResponse" - } - ] - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/frames/{frameId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^frm_[A-Za-z0-9_-]{21}$" - }, - "required": true, - "name": "frameId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Frame" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases/step-history": { - "get": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "step", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "flow", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"] - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StepHistoryResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/runners/heartbeat": { - "post": { - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HeartbeatRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RunnerHeartbeatResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/packages": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "platform", - "in": "query" - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageCatalogResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/oauth/device_authorization": { - "post": { - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/DeviceAuthorizationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceAuthorizationResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - } - } - } - }, - "/api/oauth/token": { - "post": { - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/TokenRequest" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - }, - "429": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/oauth/revoke": { - "post": { - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/RevocationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - }, - "401": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuthError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "properties": { - "field": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["path", "message"] - } - } - }, - "additionalProperties": {} - } - }, - "required": ["code", "message"] - } - }, - "required": ["error"] - }, - "FlowConfig": { - "type": "object", - "properties": { - "version": { - "type": "number", - "enum": [4] - }, - "$schema": { - "type": "string" - }, - "include": { - "type": "array", - "items": { - "type": "string" - } - }, - "variables": { - "type": "object", - "additionalProperties": {} - }, - "definitions": { - "type": "object", - "additionalProperties": {} - }, - "flows": { - "type": "object", - "additionalProperties": {} - }, - "contract": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["version"], - "additionalProperties": {} - }, - "Flow": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - }, - "settings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSettingsSummary" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "config", "createdAt", "updatedAt"] - }, - "FlowSettingsSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "platform": { - "type": "string", - "enum": ["web", "server"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["id", "name", "platform", "createdAt", "updatedAt"] - }, - "FlowSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "placeholder": { - "type": "boolean" - }, - "summary": { - "type": "string" - }, - "settings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSettingsListItem" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "placeholder", - "createdAt", - "updatedAt", - "deletedAt" - ] - }, - "FlowSettingsListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "platform": { - "type": "string", - "enum": ["web", "server"] - }, - "serving": { - "$ref": "#/components/schemas/ServingStatus" - }, - "latestAttempt": { - "$ref": "#/components/schemas/LatestAttemptStatus" - }, - "deploymentUrl": { - "type": ["string", "null"] - }, - "deployedAt": { - "type": ["string", "null"] - } - }, - "required": [ - "id", - "name", - "platform", - "serving", - "latestAttempt", - "deploymentUrl", - "deployedAt" - ] - }, - "ServingStatus": { - "type": "string", - "enum": ["live", "none"] - }, - "LatestAttemptStatus": { - "type": ["string", "null"], - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed", - null - ] - }, - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member", "deployer", "viewer"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "memberCount": { - "type": "integer" - }, - "flowCount": { - "type": "integer" - }, - "deploymentCount": { - "type": "integer" - }, - "isDemo": { - "type": "boolean" - } - }, - "required": [ - "id", - "name", - "role", - "createdAt", - "updatedAt", - "memberCount", - "flowCount", - "deploymentCount", - "isDemo" - ] - }, - "DeploySettingsRequest": { - "type": "object", - "properties": { - "flow": { - "type": "string" - }, - "humanText": { - "type": "string", - "maxLength": 4000 - } - } - }, - "DeploySettingsResponse": { - "type": "object", - "properties": { - "deploymentId": { - "type": "string" - }, - "settingsId": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$" - }, - "status": { - "type": "string" - } - }, - "required": ["deploymentId", "settingsId", "status"] - }, - "FlowDetailResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - }, - "placeholder": { - "type": "boolean" - }, - "settings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSettingsEnriched" - } - }, - "bundleId": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "config", - "placeholder", - "createdAt", - "updatedAt", - "deletedAt" - ] - }, - "FlowSettingsEnriched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "platform": { - "type": "string", - "enum": ["web", "server"] - }, - "deployment": { - "type": ["object", "null"], - "properties": { - "id": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "type": "string" - }, - "target": { - "type": ["string", "null"] - }, - "containerUrl": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"] - } - }, - "required": [ - "id", - "slug", - "status", - "type", - "target", - "containerUrl", - "createdAt", - "updatedAt" - ] - }, - "serving": { - "$ref": "#/components/schemas/ServingStatus" - }, - "latestAttempt": { - "$ref": "#/components/schemas/LatestAttemptStatus" - }, - "deployedAt": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "platform", - "deployment", - "serving", - "latestAttempt", - "deployedAt", - "createdAt", - "updatedAt" - ] - }, - "FlowUpdateResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["id", "name", "config", "createdAt", "updatedAt"] - }, - "FlowConflictResponse": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": ["PRECONDITION_FAILED"] - }, - "message": { - "type": "string" - } - }, - "required": ["code", "message"] - }, - "head": { - "type": "object", - "properties": { - "config": { - "$ref": "#/components/schemas/FlowConfig" - }, - "etag": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["config", "etag", "updatedAt"] - } - }, - "required": ["error", "head"] - }, - "CreateProjectResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["id", "name", "createdAt"] - }, - "UpdateProjectResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["id", "name", "updatedAt"] - }, - "DeploymentSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$" - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "slug": { - "type": "string", - "pattern": "^[a-z0-9]{12}$" - }, - "target": { - "type": ["string", "null"] - }, - "label": { - "type": ["string", "null"] - }, - "origin": { - "type": "string", - "enum": ["cloud", "self-hosted"] - }, - "status": { - "type": "string", - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed" - ] - }, - "serving": { - "$ref": "#/components/schemas/ServingStatus" - }, - "currentVersionNumber": { - "type": ["integer", "null"], - "exclusiveMinimum": 0 - }, - "url": { - "type": ["string", "null"] - }, - "flowId": { - "type": ["string", "null"], - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "flowName": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "usageSummary": { - "type": "object", - "properties": { - "eventsIn24h": { - "type": "integer", - "minimum": 0 - }, - "healthy": { - "type": "boolean" - } - }, - "required": ["eventsIn24h", "healthy"] - } - }, - "required": [ - "id", - "type", - "slug", - "target", - "label", - "origin", - "status", - "serving", - "currentVersionNumber", - "url", - "flowId", - "flowName", - "createdAt", - "updatedAt" - ] - }, - "DeploymentDetailResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$" - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "slug": { - "type": "string", - "pattern": "^[a-z0-9]{12}$" - }, - "target": { - "type": ["string", "null"] - }, - "label": { - "type": ["string", "null"] - }, - "flowId": { - "type": ["string", "null"], - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "origin": { - "type": "string", - "enum": ["cloud", "self-hosted"] - }, - "status": { - "type": "string", - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed" - ] - }, - "currentVersion": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentVersionDetail" - }, - { - "type": "null" - } - ] - }, - "versions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeploymentVersionHistoryEntry" - } - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentError" - }, - { - "type": "null" - } - ] - }, - "recentErrors": { - "type": ["array", "null"], - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "firstSeen": { - "type": "string" - }, - "lastSeen": { - "type": "string" - } - }, - "required": ["message", "count", "firstSeen", "lastSeen"] - } - }, - "recentLogs": { - "type": ["array", "null"], - "items": { - "type": "object", - "properties": { - "time": { - "type": "string" - }, - "level": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["time", "level", "message"] - } - }, - "url": { - "type": ["string", "null"] - }, - "selfHosted": { - "type": ["object", "null"], - "properties": { - "lastHeartbeatAt": { - "type": "string", - "format": "date-time" - }, - "instanceId": { - "type": ["string", "null"] - }, - "cliVersion": { - "type": ["string", "null"] - }, - "healthy": { - "type": "boolean" - } - }, - "required": [ - "lastHeartbeatAt", - "instanceId", - "cliVersion", - "healthy" - ] - }, - "lastHeartbeatAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "type", - "slug", - "target", - "label", - "flowId", - "origin", - "status", - "currentVersion", - "versions", - "error", - "url", - "selfHosted", - "createdAt", - "updatedAt" - ] - }, - "DeploymentVersionDetail": { - "type": "object", - "properties": { - "number": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "status": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "flowSettingsId": { - "type": "string" - }, - "configHash": { - "type": "string" - } - }, - "required": ["type"] - }, - "errorMessage": { - "type": ["string", "null"] - }, - "errorCode": { - "type": ["string", "null"] - }, - "publishedAt": { - "type": "string", - "format": "date-time" - }, - "publishedBy": { - "type": ["string", "null"] - } - }, - "required": [ - "number", - "status", - "source", - "errorMessage", - "errorCode", - "publishedAt", - "publishedBy" - ] - }, - "DeploymentVersionHistoryEntry": { - "type": "object", - "properties": { - "versionNumber": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "status": { - "type": "string" - }, - "source": { - "type": "string" - }, - "errorCode": { - "type": ["string", "null"] - }, - "errorMessage": { - "type": ["string", "null"] - }, - "errorPhase": { - "type": ["string", "null"] - }, - "errorDetail": { - "type": ["string", "null"] - }, - "publishedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "versionNumber", - "status", - "source", - "errorCode", - "errorMessage", - "errorPhase", - "publishedAt" - ] - }, - "DeploymentError": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "phase": { - "type": "string", - "enum": ["preflight", "deploy", "bundle", "publish", "provision"] - }, - "detail": { - "type": "string" - } - }, - "required": ["code", "message", "phase"] - }, - "CreateDeploymentResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$" - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "slug": { - "type": "string", - "pattern": "^[a-z0-9]{12}$" - }, - "target": { - "type": ["string", "null"] - }, - "label": { - "type": ["string", "null"] - }, - "origin": { - "type": "string", - "enum": ["cloud", "self-hosted"] - }, - "status": { - "type": "string", - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed" - ] - }, - "serving": { - "$ref": "#/components/schemas/ServingStatus" - }, - "currentVersionNumber": { - "type": ["integer", "null"], - "exclusiveMinimum": 0 - }, - "url": { - "type": ["string", "null"] - }, - "flowId": { - "type": ["string", "null"], - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "flowName": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "usageSummary": { - "type": "object", - "properties": { - "eventsIn24h": { - "type": "integer", - "minimum": 0 - }, - "healthy": { - "type": "boolean" - } - }, - "required": ["eventsIn24h", "healthy"] - } - }, - "required": [ - "id", - "type", - "slug", - "target", - "label", - "origin", - "status", - "serving", - "currentVersionNumber", - "url", - "flowId", - "flowName", - "createdAt", - "updatedAt" - ] - }, - "StartDeploymentResponse": { - "anyOf": [ - { - "type": "object", - "properties": { - "deploymentId": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$" - }, - "slug": { - "type": "string", - "pattern": "^[a-z0-9]{12}$" - }, - "target": { - "type": ["string", "null"] - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "status": { - "type": "string", - "enum": ["deploying"] - }, - "settingsId": { - "type": "string" - }, - "versionId": { - "type": "string" - }, - "versionNumber": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": [ - "deploymentId", - "slug", - "target", - "type", - "status", - "versionId", - "versionNumber" - ] - }, - { - "type": "object", - "properties": { - "deploymentId": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["already_created"] - } - }, - "required": ["deploymentId", "status"] - } - ] - }, - "DeploymentStreamStatusEvent": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "substatus": { - "type": ["string", "null"] - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "target": { - "type": ["string", "null"] - }, - "containerUrl": { - "type": ["string", "null"] - }, - "errorCode": { - "type": ["string", "null"] - }, - "errorMessage": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "status", - "substatus", - "type", - "target", - "containerUrl", - "errorCode", - "errorMessage", - "createdAt", - "updatedAt" - ] - }, - "ListDeploymentsResponse": { - "type": "object", - "properties": { - "deployments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeploymentSummary" - } - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "limit": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "offset": { - "type": "integer", - "minimum": 0 - }, - "nextCursor": { - "type": ["string", "null"] - } - }, - "required": ["deployments", "total", "limit", "offset", "nextCursor"] - }, - "ListFlowReleasesResponse": { - "type": "object", - "properties": { - "releases": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowRelease" - } - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "limit": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "offset": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["releases", "total", "limit", "offset"] - }, - "FlowRelease": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "deploymentId": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$" - }, - "deploymentSlug": { - "type": ["string", "null"], - "pattern": "^[a-z0-9]{12}$" - }, - "deploymentType": { - "type": ["string", "null"], - "enum": ["web", "server", null] - }, - "versionNumber": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "flowVersionId": { - "type": ["string", "null"], - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "flowVersionNumber": { - "type": ["integer", "null"], - "exclusiveMinimum": 0 - }, - "status": { - "type": "string" - }, - "source": { - "type": "string" - }, - "errorCode": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdBy": { - "type": ["string", "null"] - }, - "createdByLabel": { - "type": ["string", "null"] - }, - "rationale": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReleaseRationaleSummary" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "deploymentId", - "deploymentSlug", - "deploymentType", - "versionNumber", - "flowVersionId", - "flowVersionNumber", - "status", - "source", - "errorCode", - "createdAt", - "createdBy", - "createdByLabel" - ] - }, - "ReleaseRationaleSummary": { - "type": "object", - "properties": { - "hasHumanText": { - "type": "boolean" - }, - "hasGeneratedSummary": { - "type": "boolean" - }, - "firstLine": { - "type": ["string", "null"] - } - }, - "required": ["hasHumanText", "hasGeneratedSummary", "firstLine"] - }, - "ReleaseDiff": { - "type": "object", - "properties": { - "prevVersionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "prevVersionNumber": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "text": { - "type": "string" - }, - "contentIdentical": { - "type": "boolean" - } - }, - "required": [ - "prevVersionId", - "prevVersionNumber", - "text", - "contentIdentical" - ] - }, - "ReleaseDetailResponse": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "versionNumber": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "contentHash": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdBy": { - "type": "string" - }, - "rationale": { - "anyOf": [ - { - "$ref": "#/components/schemas/VersionAnnotation" - }, - { - "type": "null" - } - ] - }, - "diff": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReleaseDiff" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "versionId", - "versionNumber", - "contentHash", - "createdAt", - "createdBy", - "rationale", - "diff" - ] - }, - "VersionAnnotation": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "humanText": { - "type": ["string", "null"] - }, - "generatedSummary": { - "type": ["string", "null"] - }, - "author": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "versionId", - "humanText", - "generatedSummary", - "author", - "createdAt", - "updatedAt" - ] - }, - "UpsertVersionAnnotationResponse": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "humanText": { - "type": ["string", "null"] - }, - "generatedSummary": { - "type": ["string", "null"] - }, - "author": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "versionId", - "humanText", - "generatedSummary", - "author", - "createdAt", - "updatedAt" - ] - }, - "ListHubThreadsResponse": { - "type": "object", - "properties": { - "threads": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HubThread" - } - }, - "hasMoreThreads": { - "type": "boolean" - } - }, - "required": ["threads", "hasMoreThreads"] - }, - "HubThread": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^thr_[a-z0-9]+$" - }, - "anchorType": { - "type": "string", - "enum": ["step", "entity_action", "release", "contract", "tag"] - }, - "anchorKey": { - "type": "string" - }, - "anchorLabel": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["open", "resolved"] - }, - "resolvedByVersionId": { - "type": ["string", "null"] - }, - "resolvedByVersionNumber": { - "type": ["integer", "null"] - }, - "resolvedAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "resolvedBy": { - "type": ["string", "null"] - }, - "createdBy": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "messageCount": { - "type": "integer" - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HubMessage" - } - }, - "hasMoreMessages": { - "type": "boolean" - } - }, - "required": [ - "id", - "anchorType", - "anchorKey", - "anchorLabel", - "status", - "resolvedByVersionId", - "resolvedByVersionNumber", - "resolvedAt", - "resolvedBy", - "createdBy", - "createdAt", - "updatedAt", - "messageCount" - ] - }, - "HubMessage": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "author": { - "type": "string" - }, - "text": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - }, - "required": ["id", "author", "text", "createdAt"] - }, - "HubThreadResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^thr_[a-z0-9]+$" - }, - "anchorType": { - "type": "string", - "enum": ["step", "entity_action", "release", "contract", "tag"] - }, - "anchorKey": { - "type": "string" - }, - "anchorLabel": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["open", "resolved"] - }, - "resolvedByVersionId": { - "type": ["string", "null"] - }, - "resolvedByVersionNumber": { - "type": ["integer", "null"] - }, - "resolvedAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "resolvedBy": { - "type": ["string", "null"] - }, - "createdBy": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "messageCount": { - "type": "integer" - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HubMessage" - } - }, - "hasMoreMessages": { - "type": "boolean" - } - }, - "required": [ - "id", - "anchorType", - "anchorKey", - "anchorLabel", - "status", - "resolvedByVersionId", - "resolvedByVersionNumber", - "resolvedAt", - "resolvedBy", - "createdBy", - "createdAt", - "updatedAt", - "messageCount" - ] - }, - "ListKnowledgeResponse": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KnowledgeEntry" - } - }, - "hasMoreEntries": { - "type": "boolean" - } - }, - "required": ["entries", "hasMoreEntries"] - }, - "KnowledgeEntry": { - "oneOf": [ - { - "$ref": "#/components/schemas/KnowledgeThread" - }, - { - "$ref": "#/components/schemas/KnowledgeDescription" - } - ], - "discriminator": { - "propertyName": "kind", - "mapping": { - "thread": "#/components/schemas/KnowledgeThread", - "description": "#/components/schemas/KnowledgeDescription" - } - } - }, - "KnowledgeThread": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "anchorKey": { - "type": "string" - }, - "anchorLabel": { - "type": "string" - }, - "frameId": { - "type": ["string", "null"] - }, - "frameName": { - "type": ["string", "null"] - }, - "flowId": { - "type": ["string", "null"] - }, - "subjectKey": { - "type": ["string", "null"] - }, - "spatial": { - "anyOf": [ - { - "$ref": "#/components/schemas/KnowledgeSpatial" - }, - { - "type": "null" - } - ] - }, - "validity": { - "$ref": "#/components/schemas/KnowledgeValidity" - }, - "freshness": { - "type": "string", - "enum": ["current", "subject_changed", "unknown"] - }, - "author": { - "$ref": "#/components/schemas/KnowledgeAuthor" - }, - "source": { - "type": "string", - "enum": ["tag_mode", "hub", "mcp"] - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "kind": { - "type": "string", - "enum": ["thread"] - }, - "anchorType": { - "type": "string", - "enum": [ - "step", - "entity_action", - "release", - "contract", - "tag", - "page" - ] - }, - "status": { - "type": "string", - "enum": ["open", "resolved"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "messageCount": { - "type": "integer" - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/KnowledgeMessage" - } - }, - "hasMoreMessages": { - "type": "boolean" - } - }, - "required": [ - "id", - "anchorKey", - "anchorLabel", - "frameId", - "frameName", - "flowId", - "subjectKey", - "spatial", - "validity", - "freshness", - "author", - "source", - "updatedAt", - "kind", - "anchorType", - "status", - "createdAt", - "messageCount" - ] - }, - "KnowledgeSpatial": { - "type": "object", - "properties": { - "at": { - "type": "object", - "properties": { - "x": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "y": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - }, - "required": ["x", "y"] - }, - "element": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["at"] - }, - "KnowledgeValidity": { - "oneOf": [ - { - "type": "object", - "properties": { - "tier": { - "type": "string", - "enum": ["release"] - }, - "versionId": { - "type": "string" - }, - "versionNumber": { - "type": "integer" - }, - "promoted": { - "type": "boolean" - } - }, - "required": ["tier", "versionId", "versionNumber", "promoted"] - }, - { - "type": "object", - "properties": { - "tier": { - "type": "string", - "enum": ["draft"] - }, - "versionId": { - "type": "string" - } - }, - "required": ["tier"] - }, - { - "type": "object", - "properties": { - "tier": { - "type": "string", - "enum": ["none"] - } - }, - "required": ["tier"] - } - ] - }, - "KnowledgeAuthor": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["user", "preview", "agent"] - }, - "id": { - "type": ["string", "null"] - }, - "label": { - "type": "string" - } - }, - "required": ["kind", "id", "label"] - }, - "KnowledgeMessage": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "author": { - "type": "string" - }, - "authorLabel": { - "type": "string" - }, - "text": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "clientMessageId": { - "type": ["string", "null"] - } - }, - "required": [ - "id", - "author", - "authorLabel", - "text", - "createdAt", - "clientMessageId" - ] - }, - "KnowledgeDescription": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "anchorKey": { - "type": "string" - }, - "anchorLabel": { - "type": "string" - }, - "frameId": { - "type": ["string", "null"] - }, - "frameName": { - "type": ["string", "null"] - }, - "flowId": { - "type": ["string", "null"] - }, - "subjectKey": { - "type": ["string", "null"] - }, - "spatial": { - "anyOf": [ - { - "$ref": "#/components/schemas/KnowledgeSpatial" - }, - { - "type": "null" - } - ] - }, - "validity": { - "$ref": "#/components/schemas/KnowledgeValidity" - }, - "freshness": { - "type": "string", - "enum": ["current", "subject_changed", "unknown"] - }, - "author": { - "$ref": "#/components/schemas/KnowledgeAuthor" - }, - "source": { - "type": "string", - "enum": ["tag_mode", "hub", "mcp"] - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "kind": { - "type": "string", - "enum": ["description"] - }, - "anchorType": { - "type": "string", - "enum": ["tag", "page"] - }, - "body": { - "type": "string" - } - }, - "required": [ - "id", - "anchorKey", - "anchorLabel", - "frameId", - "frameName", - "flowId", - "subjectKey", - "spatial", - "validity", - "freshness", - "author", - "source", - "updatedAt", - "kind", - "anchorType", - "body" - ] - }, - "FrameLink": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^lnk_[A-Za-z0-9_-]{21}$" - }, - "frameId": { - "type": "string", - "pattern": "^frm_[A-Za-z0-9_-]{21}$" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "w": { - "type": "number", - "exclusiveMinimum": 0 - }, - "h": { - "type": "number", - "exclusiveMinimum": 0 - } - }, - "required": ["id", "frameId", "x", "y", "w", "h"], - "additionalProperties": false - }, - "PlanSize": { - "type": "object", - "properties": { - "width": { - "type": "number", - "exclusiveMinimum": 0 - }, - "height": { - "type": "number", - "exclusiveMinimum": 0 - } - }, - "required": ["width", "height"] - }, - "FrameSource": { - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["page"] - }, - "key": { - "type": "string", - "minLength": 1, - "maxLength": 1024 - }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048 - } - }, - "required": ["kind", "key", "url"] - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["figma"] - }, - "fileKey": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "nodeId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - } - }, - "required": ["kind", "fileKey", "nodeId"] - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["image"] - } - }, - "required": ["kind"] - } - ] - }, - "Frame": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^frm_[A-Za-z0-9_-]{21}$" - }, - "projectId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "size": { - "$ref": "#/components/schemas/PlanSize" - }, - "marks": { - "type": "object", - "additionalProperties": {} - }, - "source": { - "$ref": "#/components/schemas/FrameSource" - }, - "origin": { - "type": "string", - "enum": ["drawn", "imported", "observed"] - }, - "links": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FrameLink" - } - }, - "variantSetId": { - "type": ["string", "null"] - }, - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "screenshot": { - "anyOf": [ - { - "$ref": "#/components/schemas/FrameScreenshot" - }, - { - "type": "null" - } - ] - }, - "markupAssetId": { - "type": ["string", "null"], - "pattern": "^fas_[A-Za-z0-9_-]{21}$" - }, - "version": { - "type": "integer" - }, - "lastSavedVersion": { - "type": ["integer", "null"] - }, - "lastSavedNumber": { - "type": ["integer", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "createdBy": { - "type": "string" - }, - "updatedBy": { - "type": "string" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "projectId", - "name", - "size", - "marks", - "source", - "origin", - "links", - "variantSetId", - "labels", - "flowId", - "screenshot", - "markupAssetId", - "version", - "lastSavedVersion", - "lastSavedNumber", - "createdAt", - "updatedAt", - "createdBy", - "updatedBy", - "deletedAt" - ] - }, - "FrameScreenshot": { - "type": "object", - "properties": { - "assetId": { - "type": "string" - }, - "capturedAt": { - "type": "string", - "format": "date-time" - }, - "size": { - "$ref": "#/components/schemas/PlanSize" - }, - "dpr": { - "type": "number", - "exclusiveMinimum": 0 - }, - "capturedRect": { - "$ref": "#/components/schemas/PlanRect" - } - }, - "required": ["assetId", "capturedAt", "size", "dpr", "capturedRect"] - }, - "PlanRect": { - "type": "object", - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "w": { - "type": "number" - }, - "h": { - "type": "number" - } - }, - "required": ["x", "y", "w", "h"] - }, - "FrameLean": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^frm_[A-Za-z0-9_-]{21}$" - }, - "projectId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "size": { - "$ref": "#/components/schemas/PlanSize" - }, - "source": { - "$ref": "#/components/schemas/FrameSource" - }, - "origin": { - "type": "string", - "enum": ["drawn", "imported", "observed"] - }, - "links": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FrameLink" - } - }, - "variantSetId": { - "type": ["string", "null"] - }, - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "screenshot": { - "anyOf": [ - { - "$ref": "#/components/schemas/FrameScreenshot" - }, - { - "type": "null" - } - ] - }, - "markupAssetId": { - "type": ["string", "null"], - "pattern": "^fas_[A-Za-z0-9_-]{21}$" - }, - "version": { - "type": "integer" - }, - "lastSavedVersion": { - "type": ["integer", "null"] - }, - "lastSavedNumber": { - "type": ["integer", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "createdBy": { - "type": "string" - }, - "updatedBy": { - "type": "string" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "projectId", - "name", - "size", - "source", - "origin", - "links", - "variantSetId", - "labels", - "flowId", - "screenshot", - "markupAssetId", - "version", - "lastSavedVersion", - "lastSavedNumber", - "createdAt", - "updatedAt", - "createdBy", - "updatedBy", - "deletedAt" - ] - }, - "FrameListResponse": { - "type": "object", - "properties": { - "frames": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Frame" - } - } - }, - "required": ["frames"] - }, - "FrameLeanListResponse": { - "type": "object", - "properties": { - "frames": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FrameLean" - } - } - }, - "required": ["frames"] - }, - "StepHistoryResponse": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "flow": { - "type": ["string", "null"] - }, - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StepHistoryEntry" - } - }, - "scanned": { - "type": "integer" - }, - "truncated": { - "type": "boolean" - }, - "entriesTruncated": { - "type": "boolean" - }, - "knownSteps": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "step", - "flow", - "entries", - "scanned", - "truncated", - "entriesTruncated" - ] - }, - "StepHistoryEntry": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$" - }, - "versionNumber": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "flow": { - "type": ["string", "null"] - }, - "change": { - "type": "string", - "enum": ["added", "removed", "changed"] - }, - "humanText": { - "type": ["string", "null"] - }, - "generatedSummary": { - "type": ["string", "null"] - } - }, - "required": [ - "versionId", - "versionNumber", - "createdAt", - "flow", - "change", - "humanText", - "generatedSummary" - ] - }, - "PreviewResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$" - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "flowSettingsId": { - "type": "string" - }, - "projectId": { - "type": "string" - }, - "bundleUrl": { - "type": "string", - "format": "uri" - }, - "activationUrl": { - "type": ["string", "null"] - }, - "tagMode": { - "type": "boolean" - }, - "createdBy": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "flowId", - "flowSettingsId", - "projectId", - "bundleUrl", - "activationUrl", - "tagMode", - "createdBy", - "createdAt" - ] - }, - "CreatePreviewResponse": { - "allOf": [ - { - "$ref": "#/components/schemas/PreviewResponse" - }, - { - "type": "object", - "properties": { - "grant": { - "type": ["string", "null"] - } - }, - "required": ["grant"] - } - ] - }, - "ListPreviewsResponse": { - "type": "object", - "properties": { - "previews": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PreviewResponse" - } - }, - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["previews", "total"] - }, - "CreatePreviewRequest": { - "type": "object", - "properties": { - "flowSettingsId": { - "type": "string" - }, - "source": { - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["draft"] - } - }, - "required": ["kind"] - }, - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["deployment-version"] - }, - "deploymentVersionId": { - "type": "string" - } - }, - "required": ["kind", "deploymentVersionId"] - } - ] - }, - "tagMode": { - "type": "boolean", - "default": true - } - }, - "required": ["flowSettingsId"] - }, - "MintGrantRequest": { - "type": "object", - "properties": { - "origins": { - "type": "array", - "items": { - "type": "string", - "maxLength": 253 - }, - "minItems": 1, - "maxItems": 5 - }, - "sessionId": { - "type": "string" - } - }, - "required": ["origins"] - }, - "MintGrantResponse": { - "type": "object", - "properties": { - "grant": { - "type": "string" - }, - "activationUrl": { - "type": "string", - "format": "uri" - }, - "sessionExpiresAt": { - "type": "string", - "format": "date-time" - }, - "sessionGrant": { - "type": "string" - }, - "sessionId": { - "type": "string" - } - }, - "required": ["grant", "activationUrl", "sessionExpiresAt"] - }, - "ObserveSessionResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$" - }, - "projectId": { - "type": "string" - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "status": { - "type": "string" - }, - "errorMessage": { - "type": ["string", "null"] - }, - "configSnapshot": { - "type": "object", - "additionalProperties": {} - }, - "observedFlowName": { - "type": ["string", "null"] - }, - "serverFlowName": { - "type": ["string", "null"] - }, - "serverEndpoint": { - "type": ["string", "null"] - }, - "web": { - "anyOf": [ - { - "$ref": "#/components/schemas/ObserveSessionWeb" - }, - { - "type": "null" - } - ] - }, - "server": { - "anyOf": [ - { - "$ref": "#/components/schemas/ObserveSessionServer" - }, - { - "type": "null" - } - ] - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "recordsReceived": { - "type": "integer", - "minimum": 0 - }, - "createdBy": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "projectId", - "flowId", - "status", - "errorMessage", - "configSnapshot", - "observedFlowName", - "serverFlowName", - "serverEndpoint", - "web", - "server", - "expiresAt", - "recordsReceived", - "createdBy", - "createdAt" - ] - }, - "ObserveSessionWeb": { - "type": "object", - "properties": { - "activationUrl": { - "type": ["string", "null"], - "format": "uri" - }, - "credential": { - "type": "string" - }, - "previewEnabled": { - "type": "boolean" - }, - "bundleUrl": { - "type": "string", - "format": "uri" - }, - "url": { - "type": "string", - "format": "uri" - }, - "binding": { - "type": "string" - } - }, - "required": [ - "activationUrl", - "credential", - "previewEnabled", - "bundleUrl" - ] - }, - "ObserveSessionServer": { - "type": "object", - "properties": { - "endpoint": { - "type": ["string", "null"], - "format": "uri" - }, - "env": { - "$ref": "#/components/schemas/ObserveSessionServerEnv" - } - }, - "required": ["endpoint", "env"] - }, - "ObserveSessionServerEnv": { - "type": "object", - "properties": { - "WALKEROS_OBSERVER_URL": { - "type": "string", - "format": "uri" - }, - "WALKEROS_DEPLOYMENT_ID": { - "type": "string" - }, - "WALKEROS_INGEST_TOKEN": { - "type": "string" - } - }, - "required": [ - "WALKEROS_OBSERVER_URL", - "WALKEROS_DEPLOYMENT_ID", - "WALKEROS_INGEST_TOKEN" - ] - }, - "CreateObserveSessionRequest": { - "type": "object", - "properties": { - "settingsName": { - "type": "string", - "minLength": 1 - }, - "force": { - "type": "boolean" - }, - "replace": { - "type": "boolean" - }, - "level": { - "$ref": "#/components/schemas/ObserveLevel" - }, - "origins": { - "type": "array", - "items": { - "type": "string", - "maxLength": 253 - }, - "maxItems": 20 - }, - "tagMode": { - "type": "boolean" - } - }, - "required": ["settingsName"] - }, - "ObserveLevel": { - "type": "string", - "enum": ["off", "standard", "trace"] - }, - "ObserveSessionHeartbeatResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["ok"] - }, - "FeedbackRequest": { - "type": "object", - "properties": { - "text": { - "type": "string", - "minLength": 1, - "maxLength": 5000 - }, - "userId": { - "type": "string", - "format": "email" - }, - "projectId": { - "type": "string", - "maxLength": 255 - }, - "version": { - "type": "string", - "maxLength": 100 - } - }, - "required": ["text"] - }, - "FeedbackResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] - }, - "id": { - "type": "string" - } - }, - "required": ["ok", "id"] - }, - "TelemetryEvent": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "entity": { - "type": "string" - }, - "action": { - "type": "string" - }, - "data": { - "type": "object", - "additionalProperties": {} - }, - "context": { - "type": "object", - "additionalProperties": {} - }, - "globals": { - "type": "object", - "additionalProperties": {} - }, - "custom": { - "type": "object", - "additionalProperties": {} - }, - "user": { - "type": "object", - "properties": { - "device": { - "type": "string" - }, - "session": { - "type": "string" - }, - "os": { - "type": "string" - }, - "osVersion": { - "type": "string" - }, - "node": { - "type": "string" - }, - "language": { - "type": "string" - }, - "timezone": { - "type": "string" - } - }, - "required": [ - "device", - "os", - "osVersion", - "node", - "language", - "timezone" - ], - "additionalProperties": {} - }, - "nested": { - "type": "array", - "items": {} - }, - "consent": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - "trigger": { - "type": "string" - }, - "timestamp": { - "type": "number" - }, - "timing": { - "type": "number" - }, - "source": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["cli", "mcp"] - }, - "platform": { - "type": "string" - }, - "release": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "version": { - "type": "string" - }, - "schema": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": ["type"], - "additionalProperties": {} - } - }, - "required": [ - "id", - "name", - "entity", - "action", - "data", - "context", - "globals", - "custom", - "user", - "nested", - "consent", - "trigger", - "timestamp", - "timing", - "source" - ] - }, - "PackageCatalogResponse": { - "type": "object", - "properties": { - "catalog": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackageCatalogEntry" - } - }, - "count": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["catalog", "count"] - }, - "PackageCatalogEntry": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "description": { - "type": "string" - }, - "type": { - "type": "string" - }, - "platform": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["name", "version", "type", "platform"] - }, - "RunnerHeartbeatResponse": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "instanceId": { - "type": "string" - }, - "deploymentId": { - "type": "string" - } - }, - "required": ["id", "instanceId", "deploymentId"] - }, - "WhoamiResponse": { - "type": "object", - "properties": { - "userId": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - }, - "projectId": { - "type": ["string", "null"] - } - }, - "required": ["userId", "email", "projectId"] - }, - "ListProjectsResponse": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - }, - "total": { - "type": "number" - }, - "nextCursor": { - "type": ["string", "null"] - } - }, - "required": ["projects", "total", "nextCursor"] - }, - "CreateProjectRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - } - }, - "required": ["name"] - }, - "ProjectDetailResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string" - }, - "siteUrl": { - "type": ["string", "null"], - "format": "uri" - }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member", "deployer", "viewer"] - } - }, - "required": ["id", "name", "role"] - }, - "UpdateProjectRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "siteUrl": { - "type": ["string", "null"], - "maxLength": 2048, - "format": "uri" - } - } - }, - "ListFlowsResponse": { - "type": "object", - "properties": { - "flows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSummary" - } - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "nextCursor": { - "type": ["string", "null"] - } - }, - "required": ["flows", "total", "nextCursor"] - }, - "CreateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - } - }, - "required": ["name"] - }, - "UpdateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - } - } - }, - "DuplicateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - } - } - }, - "DeploymentResponse": { - "type": ["object", "null"], - "properties": { - "id": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "status": { - "type": "string" - }, - "containerUrl": { - "type": ["string", "null"] - }, - "publicUrl": { - "type": ["string", "null"] - }, - "errorMessage": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "flowId", - "type", - "status", - "containerUrl", - "errorMessage", - "createdAt", - "updatedAt" - ] - }, - "SettingsDeploymentResponse": { - "type": ["object", "null"], - "properties": { - "id": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "settingsId": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "status": { - "type": "string" - }, - "containerUrl": { - "type": ["string", "null"] - }, - "publicUrl": { - "type": ["string", "null"] - }, - "errorMessage": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "flowId", - "settingsId", - "type", - "status", - "containerUrl", - "publicUrl", - "errorMessage", - "createdAt", - "updatedAt" - ] - }, - "FlowJourneysResponse": { - "type": "object", - "properties": { - "sessionId": { - "type": ["string", "null"], - "pattern": "^ses_[a-zA-Z0-9_-]+$" - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "assembledAt": { - "type": "string", - "format": "date-time" - }, - "journeys": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } - }, - "gaps": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } - }, - "unattributed": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } - } - }, - "required": ["sessionId", "flowId", "assembledAt", "journeys", "gaps"] - }, - "HealthResponse": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "appVersion": { - "type": "string" - }, - "contractVersion": { - "type": "string" - }, - "contractHash": { - "type": "string" - }, - "minSupportedClient": { - "type": "string" - } - }, - "required": [ - "status", - "appVersion", - "contractVersion", - "contractHash", - "minSupportedClient" - ] - }, - "HeartbeatRequest": { - "type": "object", - "properties": { - "instanceId": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "flowId": { - "type": "string" - }, - "configVersion": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": ["local", "collect", "serve"] - }, - "cliVersion": { - "type": "string" - }, - "uptime": { - "type": "integer", - "minimum": 0 - }, - "metadata": { - "type": "object", - "additionalProperties": {} - }, - "deploymentId": { - "type": "string" - }, - "counters": { - "type": "object", - "properties": { - "eventsIn": { - "type": "integer", - "minimum": 0 - }, - "eventsOut": { - "type": "integer", - "minimum": 0 - }, - "eventsFailed": { - "type": "integer", - "minimum": 0 - }, - "destinations": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "minimum": 0 - }, - "failed": { - "type": "integer", - "minimum": 0 - }, - "duration": { - "type": "number", - "minimum": 0 - }, - "dlqSize": { - "type": "integer", - "minimum": 0 - }, - "dropped": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["count", "failed", "duration"] - } - } - }, - "required": [ - "eventsIn", - "eventsOut", - "eventsFailed", - "destinations" - ] - }, - "recentErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string", - "maxLength": 256 - }, - "count": { - "type": "integer", - "minimum": 1 - }, - "firstSeen": { - "type": "string", - "format": "date-time" - }, - "lastSeen": { - "type": "string", - "format": "date-time" - } - }, - "required": ["message", "count", "firstSeen", "lastSeen"] - }, - "maxItems": 50 - }, - "recentLogs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "time": { - "type": "string", - "format": "date-time" - }, - "level": { - "type": "string", - "enum": ["error", "warn", "info", "debug"] - }, - "message": { - "type": "string", - "maxLength": 256 - } - }, - "required": ["time", "level", "message"] - }, - "maxItems": 100 - } - }, - "required": ["instanceId", "flowId"] - }, - "DeviceAuthorizationResponse": { - "type": "object", - "properties": { - "device_code": { - "type": "string" - }, - "user_code": { - "type": "string" - }, - "verification_uri": { - "type": "string" - }, - "verification_uri_complete": { - "type": "string" - }, - "expires_in": { - "type": "integer" - }, - "interval": { - "type": "integer" - } - }, - "required": [ - "device_code", - "user_code", - "verification_uri", - "verification_uri_complete", - "expires_in", - "interval" - ] - }, - "OAuthError": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "error_description": { - "type": "string" - } - }, - "required": ["error", "error_description"] - }, - "DeviceAuthorizationRequest": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "scope": { - "type": "string" - }, - "resource": { - "type": "string" - } - }, - "required": ["client_id"] - }, - "TokenResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "token_type": { - "type": "string", - "enum": ["Bearer"] - }, - "expires_in": { - "type": "integer" - }, - "refresh_token": { - "type": "string" - }, - "scope": { - "type": "string" - } - }, - "required": ["access_token", "token_type", "expires_in", "scope"] - }, - "TokenRequest": { - "type": "object", - "properties": { - "grant_type": { - "type": "string", - "enum": [ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:device_code" - ] - }, - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "code": { - "type": "string" - }, - "redirect_uri": { - "type": "string" - }, - "code_verifier": { - "type": "string" - }, - "refresh_token": { - "type": "string" - }, - "device_code": { - "type": "string" - }, - "scope": { - "type": "string" - }, - "resource": { - "type": "string" - } - }, - "required": ["grant_type"] - }, - "RevocationRequest": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "token_type_hint": { - "type": "string", - "enum": ["access_token", "refresh_token"] - }, - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - } - }, - "required": ["token"] - } - } - } -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8bb784e03..42edafff4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,13 +15,15 @@ "types": "./dist/dev.d.ts", "default": "./dist/dev.js" }, + "./validate": { + "types": "./dist/validate.d.ts", + "default": "./dist/validate.js" + }, "./examples": { "types": "./dist/examples/index.d.ts", "default": "./dist/examples/index.js" }, - "./examples/*": "./examples/*", - "./openapi/spec.json": "./openapi/spec.json", - "./openapi/client-operations.json": "./openapi/client-operations.json" + "./examples/*": "./examples/*" }, "bin": { "walkeros": "./dist/cli.js" @@ -31,8 +33,6 @@ "examples/**", "!examples/shared/walker.js", "!dist/examples/shared/walker.js", - "openapi/spec.json", - "openapi/client-operations.json", "src/telemetry/flow.json", "README.md", "CHANGELOG.md" @@ -45,8 +45,6 @@ "test:debug": "jest --colors --detectOpenHandles --testPathIgnorePatterns '__tests__/e2e/' '__tests__/integration/'", "test:integration": "jest --colors --testPathPatterns='__tests__/integration/'", "test:e2e": "jest --colors --testPathPatterns='__tests__/e2e/'", - "generate:types": "node scripts/generate-client-spec.mjs && openapi-typescript ./openapi/spec.json --default-non-nullable false -o src/types/api.gen.d.ts && prettier --write src/types/api.gen.d.ts ./openapi/spec.json", - "generate:types:local": "OPENAPI_SPEC=${WALKEROS_APP_URL:-http://localhost:3000}/api/openapi.json npm run generate:types", "typecheck": "tsc --noEmit", "lint": "eslint \"src/**/*.ts*\"", "clean": "rm -rf .turbo && rm -rf dist && rm -rf coverage && rm -rf .temp && rm -rf .tmp", @@ -69,7 +67,6 @@ "jsdom": "^29.1.1", "npm-package-arg": "^13.0.2", "open": "^11.0.0", - "openapi-fetch": "^0.17.0", "pacote": "^21.0.4", "picomatch": "^4.0.4", "semver": "^7.7.4", @@ -107,7 +104,6 @@ "@walkeros/web-source-datalayer": "4.8.0", "@walkeros/web-source-session": "4.8.0", "msw": "^2.12.10", - "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" }, "repository": { diff --git a/packages/cli/scripts/check-client-contract.mjs b/packages/cli/scripts/check-client-contract.mjs deleted file mode 100644 index 2605c2931..000000000 --- a/packages/cli/scripts/check-client-contract.mjs +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env node -/** - * Release checks for the client contract. Plain node (22.18 or later), no - * install and no credential: it imports the subset module directly. - * - * --live [--warn-only] - * Prunes the live OpenAPI document to the operations in - * openapi/client-operations.json and compares each operation's wire - * digest with the committed openapi/spec.json. A changed or missing - * operation prints an ::error:: line and exits 1; with --warn-only it - * prints ::warning:: and exits 0. A server that cannot be reached, or - * does not answer 200 with an OpenAPI document, prints a ::warning:: and - * exits 0. - * - * --label-base - * Exits 1 when the contract label's floor in openapi/spec.json is below - * the one in the given base spec, or when a label is not a version. A - * base label without build metadata names no floor and is skipped. - * - * Usage: - * node packages/cli/scripts/check-client-contract.mjs --live https://stage.app.walkeros.io/api/openapi.json [--warn-only] - * node packages/cli/scripts/check-client-contract.mjs --label-base - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - labelFloorRegressed, - manifestOperations, - operationDigests, -} from '../src/core/openapi-subset.ts'; - -const LIVE_TIMEOUT_MS = 20000; - -const cliDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const specPath = path.join(cliDir, 'openapi', 'spec.json'); -const manifestPath = path.join(cliDir, 'openapi', 'client-operations.json'); - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, 'utf8')); -} - -function labelOf(doc) { - const version = doc && doc.info && doc.info.version; - return typeof version === 'string' ? version : undefined; -} - -function failureReason(error) { - if (error && error.name === 'TimeoutError') { - return `timeout after ${LIVE_TIMEOUT_MS} ms`; - } - const code = error && error.cause && error.cause.code; - const message = error && error.message ? error.message : String(error); - return code ? `${message} (${code})` : message; -} - -function parseArgs(argv) { - const args = { warnOnly: false }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--warn-only') args.warnOnly = true; - else if (arg === '--live' || arg === '--label-base') { - const value = argv[i + 1]; - if (value === undefined || value.startsWith('--')) { - throw new Error(`${arg} needs a value`); - } - args[arg === '--live' ? 'live' : 'labelBase'] = value; - i += 1; - } else throw new Error(`unknown argument: ${arg}`); - } - if (args.live === undefined && args.labelBase === undefined) { - throw new Error('--live or --label-base is required'); - } - return args; -} - -/** Exit code of the live check: 1 on a mismatch unless warnOnly. */ -async function checkLive(url, warnOnly) { - let document; - try { - const response = await fetch(url, { - signal: AbortSignal.timeout(LIVE_TIMEOUT_MS), - }); - if (response.status !== 200) { - console.log(`::warning::${url} answered HTTP ${response.status}`); - return 0; - } - document = await response.json(); - } catch (error) { - console.log(`::warning::${url} unreachable: ${failureReason(error)}`); - return 0; - } - if (!document || typeof document !== 'object' || !document.paths) { - console.log(`::warning::${url} served no OpenAPI paths`); - return 0; - } - - const ops = manifestOperations(readJson(manifestPath)); - const committed = operationDigests(readJson(specPath), ops); - const level = warnOnly ? 'warning' : 'error'; - const host = new URL(url).host; - let live; - try { - live = operationDigests(document, ops); - } catch (error) { - console.log(`::${level}::${url}: ${error.message}`); - return warnOnly ? 0 : 1; - } - - const findings = []; - for (const op of ops) { - if (!Object.hasOwn(live, op)) findings.push(`missing on ${host}: ${op}`); - else if (live[op] !== committed[op]) findings.push(`changed: ${op}`); - } - for (const finding of findings) console.log(`::${level}::${finding}`); - const label = labelOf(document) ?? 'no label'; - if (findings.length === 0) { - console.log( - `${host} (${label}): ${ops.length} client operations match openapi/spec.json`, - ); - return 0; - } - console.log( - `${host} (${label}): ${findings.length} of ${ops.length} client operations differ from openapi/spec.json`, - ); - return warnOnly ? 0 : 1; -} - -/** Exit code of the label check: 1 when the floor regressed. */ -function checkLabelBase(baseFile) { - const base = labelOf(readJson(baseFile)); - const head = labelOf(readJson(specPath)); - if (head === undefined) { - console.log('::error::openapi/spec.json has no info.version'); - return 1; - } - if (base === undefined) { - console.log(`${baseFile} has no info.version; label check skipped`); - return 0; - } - if (!base.includes('+')) { - console.log( - `contract label ${base} (base) has no build metadata; label check skipped`, - ); - return 0; - } - let regressed; - try { - regressed = labelFloorRegressed(base, head); - } catch (error) { - console.log(`::error::${error.message}`); - return 1; - } - if (regressed) { - console.log(`::error::contract label went from ${base} to ${head}`); - return 1; - } - console.log(`contract label ${base} to ${head}: floor not lower`); - return 0; -} - -let args; -try { - args = parseArgs(process.argv.slice(2)); -} catch (error) { - console.error(`check-client-contract: ${error.message}`); - process.exitCode = 2; -} - -if (args) { - let code = 0; - if (args.live !== undefined) { - code = Math.max(code, await checkLive(args.live, args.warnOnly)); - } - if (args.labelBase !== undefined) { - code = Math.max(code, checkLabelBase(args.labelBase)); - } - process.exitCode = code; -} diff --git a/packages/cli/scripts/generate-client-spec.mjs b/packages/cli/scripts/generate-client-spec.mjs deleted file mode 100644 index 227549049..000000000 --- a/packages/cli/scripts/generate-client-spec.mjs +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -/** - * Writes openapi/spec.json as the client subset of an app OpenAPI document: - * the operations listed in openapi/client-operations.json, the components - * they reference, without documentation keywords. - * - * The input is $OPENAPI_SPEC, a file path or an http(s) URL, and defaults to - * openapi/spec.json itself, so a second run over its own output changes - * nothing. A subset equal to the committed one apart from info.version, with - * a label of the same floor, leaves openapi/spec.json byte for byte: an app - * change no client operation sees changes nothing here. Runs on plain node - * (22.18 or later), which strips the types of the imported subset module. - * - * Usage: OPENAPI_SPEC= node packages/cli/scripts/generate-client-spec.mjs - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - manifestOperations, - pruneToOperations, - subsetUnchanged, -} from '../src/core/openapi-subset.ts'; - -const cliDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const specPath = path.join(cliDir, 'openapi', 'spec.json'); -const manifestPath = path.join(cliDir, 'openapi', 'client-operations.json'); - -const source = process.env.OPENAPI_SPEC || specPath; - -async function readInput(location) { - if (/^https?:\/\//.test(location)) { - const response = await fetch(location); - if (!response.ok) { - throw new Error(`GET ${location}: HTTP ${response.status}`); - } - return response.json(); - } - return JSON.parse(fs.readFileSync(location, 'utf8')); -} - -const operations = manifestOperations( - JSON.parse(fs.readFileSync(manifestPath, 'utf8')), -); -function readPrevious() { - try { - return JSON.parse(fs.readFileSync(specPath, 'utf8')); - } catch { - return undefined; - } -} - -const subset = pruneToOperations(await readInput(source), operations); -const unchanged = subsetUnchanged(readPrevious(), subset); -if (!unchanged) { - fs.writeFileSync(specPath, `${JSON.stringify(subset, null, 2)}\n`); -} -console.log( - `generate-client-spec: ${operations.length} operations from ${source}; subset ${unchanged ? 'unchanged' : 'written'}`, -); diff --git a/packages/cli/src/__tests__/helpers/mock-api-client.ts b/packages/cli/src/__tests__/helpers/mock-api-client.ts deleted file mode 100644 index 7e842f93d..000000000 --- a/packages/cli/src/__tests__/helpers/mock-api-client.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createApiClient } from '../../core/api-client.js'; - -export interface MockApiClient { - GET: jest.Mock; - POST: jest.Mock; - PATCH: jest.Mock; - DELETE: jest.Mock; -} - -/** - * Sets up mock return value for createApiClient. - * IMPORTANT: The test file must still declare jest.mock('../../../core/api-client.js') - * at the top level (Jest only hoists jest.mock calls within the test file itself). - */ -export function setupMockApiClient(): MockApiClient { - const client: MockApiClient = { - GET: jest.fn(), - POST: jest.fn(), - PATCH: jest.fn(), - DELETE: jest.fn(), - }; - - (createApiClient as jest.Mock).mockReturnValue(client); - - return client; -} diff --git a/packages/cli/src/__tests__/helpers/msw-handlers.ts b/packages/cli/src/__tests__/helpers/msw-handlers.ts deleted file mode 100644 index dbf21cce1..000000000 --- a/packages/cli/src/__tests__/helpers/msw-handlers.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import type { paths } from '../../types/api.gen.js'; - -// Extract response types from the spec. The project endpoints return three -// distinct shapes: the list returns the full record, get-by-id returns a -// narrower detail view, and create returns only the freshly written fields. -type ProjectsResponse = - paths['/api/projects']['get']['responses']['200']['content']['application/json']; -type CreateProjectResponse = - paths['/api/projects']['post']['responses']['201']['content']['application/json']; -type ProjectDetailResponse = - paths['/api/projects/{projectId}']['get']['responses']['200']['content']['application/json']; -type UpdateProjectResponse = - paths['/api/projects/{projectId}']['patch']['responses']['200']['content']['application/json']; -// The full project record, as carried by the list endpoint. -type Project = ProjectsResponse['projects'][number]; -type FlowsResponse = - paths['/api/projects/{projectId}/flows']['get']['responses']['200']['content']['application/json']; -type FlowResponse = - paths['/api/projects/{projectId}/flows/{flowId}']['get']['responses']['200']['content']['application/json']; -type WhoamiResponse = - paths['/api/auth/whoami']['get']['responses']['200']['content']['application/json']; - -// If the API spec changes these shapes, TypeScript will error HERE. -// Full record returned by the list endpoint. -export const mockProject = { - id: 'proj_test123', - name: 'Test Project', - role: 'owner' as const, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - memberCount: 1, - flowCount: 0, - deploymentCount: 0, - isDemo: false, -} satisfies Project; - -// Narrower view returned by GET /api/projects/:projectId. -export const mockProjectDetail = { - id: mockProject.id, - name: mockProject.name, - siteUrl: null, - role: mockProject.role, -} satisfies ProjectDetailResponse; - -// POST /api/projects returns only the freshly written fields. -export const mockCreatedProject = { - id: mockProject.id, - name: mockProject.name, - createdAt: mockProject.createdAt, -} satisfies CreateProjectResponse; - -export const mockFlow = { - id: 'flow_test456', - name: 'Test Flow', - config: { - version: 4 as const, - }, - placeholder: false, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - deletedAt: null, -} satisfies FlowResponse; - -export const mockWhoami = { - userId: 'user_test789', - email: 'test@example.com', - projectId: null, -} satisfies WhoamiResponse; - -export const handlers = [ - // Auth - http.get('*/api/auth/whoami', () => { - return HttpResponse.json(mockWhoami satisfies WhoamiResponse); - }), - - // Projects - http.get('*/api/projects', () => { - const body: ProjectsResponse = { - projects: [mockProject], - total: 1, - nextCursor: null, - }; - return HttpResponse.json(body); - }), - http.post('*/api/projects', async ({ request }) => { - const body = (await request.json()) as { name: string }; - return HttpResponse.json( - { - ...mockCreatedProject, - name: body.name, - } satisfies CreateProjectResponse, - { status: 201 }, - ); - }), - http.get('*/api/projects/:projectId', () => { - return HttpResponse.json(mockProjectDetail satisfies ProjectDetailResponse); - }), - http.patch('*/api/projects/:projectId', async ({ request }) => { - const body = (await request.json()) as { name?: string }; - return HttpResponse.json({ - id: mockProject.id, - name: body.name ?? mockProject.name, - updatedAt: mockProject.updatedAt, - } satisfies UpdateProjectResponse); - }), - http.delete('*/api/projects/:projectId', () => { - return new HttpResponse(null, { status: 204 }); - }), - - // Flows - http.get('*/api/projects/:projectId/flows', () => { - const body: FlowsResponse = { - flows: [ - { - id: mockFlow.id, - name: mockFlow.name, - placeholder: mockFlow.placeholder, - createdAt: mockFlow.createdAt, - updatedAt: mockFlow.updatedAt, - deletedAt: null, - }, - ], - total: 1, - nextCursor: null, - }; - return HttpResponse.json(body); - }), - http.get('*/api/projects/:projectId/flows/:flowId', () => { - return HttpResponse.json(mockFlow); - }), - http.post('*/api/projects/:projectId/flows', async ({ request }) => { - const body = (await request.json()) as { name: string }; - return HttpResponse.json({ ...mockFlow, name: body.name }, { status: 201 }); - }), - http.patch('*/api/projects/:projectId/flows/:flowId', () => { - return HttpResponse.json(mockFlow); - }), - http.delete('*/api/projects/:projectId/flows/:flowId', () => { - return new HttpResponse(null, { status: 204 }); - }), - http.post('*/api/projects/:projectId/flows/:flowId/duplicate', () => { - return HttpResponse.json( - { ...mockFlow, id: 'flow_copy789' }, - { status: 201 }, - ); - }), -]; diff --git a/packages/cli/src/__tests__/helpers/msw-server.ts b/packages/cli/src/__tests__/helpers/msw-server.ts index c8f3d73e3..29c0686f9 100644 --- a/packages/cli/src/__tests__/helpers/msw-server.ts +++ b/packages/cli/src/__tests__/helpers/msw-server.ts @@ -1,4 +1,4 @@ import { setupServer } from 'msw/node'; -import { handlers } from './msw-handlers.js'; -export const server = setupServer(...handlers); +/** Shared msw server with no default handlers: each test adds its own. */ +export const server = setupServer(); diff --git a/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts b/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts deleted file mode 100644 index cc95b0692..000000000 --- a/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Integration test: real API client → MSW → typed mock responses. - * If the OpenAPI spec changes, the mock data in msw-handlers.ts will - * fail to compile (satisfies), catching contract drift at build time. - */ - -import '../../helpers/setup-msw.js'; - -jest.mock('../../../core/auth.js', () => ({ - resolveAccessToken: jest.fn().mockResolvedValue('test-token'), - requireProjectId: jest.fn().mockReturnValue('proj_test123'), -})); -jest.mock('../../../lib/config-file.js', () => ({ - resolveAppUrl: jest.fn().mockReturnValue('https://api.test.local'), - resolveToken: jest - .fn() - .mockReturnValue({ token: 'test-token', source: 'env' }), - getDefaultProject: jest.fn().mockReturnValue(null), - clearDefaultProject: jest.fn(), -})); - -import { - listProjects, - getProject, - createProject, - updateProject, - deleteProject, -} from '../../../commands/projects/index.js'; -import { - mockProject, - mockProjectDetail, - mockCreatedProject, -} from '../../helpers/msw-handlers.js'; - -describe('projects (integration via MSW)', () => { - it('listProjects returns typed project list', async () => { - const result = await listProjects(); - expect(result).toEqual({ - projects: [mockProject], - total: 1, - nextCursor: null, - }); - }); - - it('getProject returns a single project', async () => { - const result = await getProject({ projectId: 'proj_test123' }); - expect(result).toEqual(mockProjectDetail); - }); - - it('createProject sends name and returns project', async () => { - const result = await createProject({ name: 'New Project' }); - expect(result).toEqual({ ...mockCreatedProject, name: 'New Project' }); - }); - - it('updateProject patches and returns updated project', async () => { - const result = await updateProject({ - projectId: 'proj_test123', - name: 'Renamed', - }); - expect(result).toEqual({ - id: mockProject.id, - name: 'Renamed', - updatedAt: mockProject.updatedAt, - }); - }); - - it('deleteProject returns success', async () => { - const result = await deleteProject({ projectId: 'proj_test123' }); - expect(result).toEqual({ success: true }); - }); -}); diff --git a/packages/cli/src/__tests__/integration/cli/removed-commands.integration.test.ts b/packages/cli/src/__tests__/integration/cli/removed-commands.integration.test.ts new file mode 100644 index 000000000..30a67fea4 --- /dev/null +++ b/packages/cli/src/__tests__/integration/cli/removed-commands.integration.test.ts @@ -0,0 +1,50 @@ +/** + * A removed remote command fails as an unknown command with a non-zero exit, + * and the help lists none of them. Runs the built binary; the integration + * task builds the package first. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const CLI = resolve(__dirname, '..', '..', '..', '..', 'dist', 'cli.js'); +const REMOVED = [ + 'projects', + 'flows', + 'deploy', + 'deployments', + 'previews', + 'secrets', + 'observe', + 'journeys', + 'hub', + 'frames', + 'feedback', +]; + +function run(args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf-8', + env: { + ...process.env, + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-removed-')), + }, + timeout: 30000, + }); +} + +describe('removed remote commands', () => { + it('fail as unknown commands and are not listed in the help', () => { + expect(existsSync(CLI)).toBe(true); + const deploy = run(['deploy', 'list']); + expect(deploy.status).not.toBe(0); + expect(deploy.stderr).toMatch(/unknown command 'deploy'/); + const help = run(['--help']); + expect(help.status).toBe(0); + const listed = help.stdout + .split('\n') + .map((text) => text.trim().split(/\s+/)[0]); + expect(REMOVED.filter((name) => listed.includes(name))).toEqual([]); + }); +}); diff --git a/packages/cli/src/__tests__/unit/commands/auth-token.test.ts b/packages/cli/src/__tests__/unit/commands/auth-token.test.ts new file mode 100644 index 000000000..bc43ea323 --- /dev/null +++ b/packages/cli/src/__tests__/unit/commands/auth-token.test.ts @@ -0,0 +1,111 @@ +import '../../helpers/setup-msw.js'; +import { server } from '../../helpers/msw-server.js'; +import { http, HttpResponse } from 'msw'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; +import { authTokenCommand } from '../../../commands/auth/index.js'; +import { EXIT } from '../../../commands/api/index.js'; + +const APP = 'https://app.example.test'; + +function sink() { + const chunks: string[] = []; + return { + stream: new Writable({ + write(chunk: Buffer, _e, done) { + chunks.push(chunk.toString('utf-8')); + done(); + }, + }), + text: () => chunks.join(''), + }; +} + +/** A config dir holding an expired session that must be refreshed. */ +function expiredSession(): string { + const home = mkdtempSync(join(tmpdir(), 'walkeros-token-')); + mkdirSync(join(home, 'walkeros')); + writeFileSync( + join(home, 'walkeros', 'config.json'), + JSON.stringify({ + accessToken: 'old', + accessTokenExpiresAt: '2000-01-01T00:00:00.000Z', + refreshToken: 'rt_1', + appUrl: APP, + }), + ); + return home; +} + +const env = process.env; +afterEach(() => { + process.env = env; +}); + +describe('walkeros auth token', () => { + it('prints WALKEROS_TOKEN', async () => { + process.env = { + ...env, + WALKEROS_TOKEN: 'wos_pat_env', + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-token-')), + }; + const out = sink(); + expect( + await authTokenCommand({ stdout: out.stream, stderr: sink().stream }), + ).toBe(EXIT.ok); + expect(out.text()).toBe('wos_pat_env\n'); + }); + + it('prints the refreshed session token', async () => { + process.env = { + ...env, + WALKEROS_APP_URL: APP, + XDG_CONFIG_HOME: expiredSession(), + }; + delete process.env.WALKEROS_TOKEN; + server.use( + http.post(`${APP}/api/oauth/token`, () => + HttpResponse.json({ + access_token: 'fresh', + expires_in: 3600, + refresh_token: 'rt_2', + }), + ), + ); + const out = sink(); + expect( + await authTokenCommand({ stdout: out.stream, stderr: sink().stream }), + ).toBe(EXIT.ok); + expect(out.text()).toBe('fresh\n'); + }); + + it('exits 3 without a credential', async () => { + process.env = { + ...env, + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-token-')), + }; + delete process.env.WALKEROS_TOKEN; + const err = sink(); + expect( + await authTokenCommand({ stdout: sink().stream, stderr: err.stream }), + ).toBe(EXIT.auth); + expect(err.text()).toMatch(/walkeros auth login/); + }); + + it('exits 4 when the refresh cannot reach the app', async () => { + process.env = { + ...env, + WALKEROS_APP_URL: APP, + XDG_CONFIG_HOME: expiredSession(), + }; + delete process.env.WALKEROS_TOKEN; + server.use(http.post(`${APP}/api/oauth/token`, () => HttpResponse.error())); + const err = sink(); + expect( + await authTokenCommand({ stdout: sink().stream, stderr: err.stream }), + ).toBe(EXIT.unreachable); + expect(err.text()).toMatch(/Could not reach/); + }); +}); diff --git a/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts b/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts index 499f53941..fb14dad03 100644 --- a/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts +++ b/packages/cli/src/__tests__/unit/commands/auth-whoami.test.ts @@ -1,37 +1,56 @@ import { whoami } from '../../../commands/auth/index.js'; -import { setupMockApiClient } from '../../helpers/mock-api-client.js'; - -jest.mock('../../../core/api-client.js'); - -const { GET: mockGet } = setupMockApiClient(); describe('whoami', () => { - afterEach(() => jest.clearAllMocks()); - - it('calls GET /api/auth/whoami', async () => { - mockGet.mockResolvedValue({ - data: { - userId: 'usr_1', - email: 'test@example.com', - projectId: null, - }, - }); - - const result = await whoami(); + const originalEnv = process.env; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + process.env = { + ...originalEnv, + WALKEROS_TOKEN: 'wos_pat_test', + WALKEROS_APP_URL: 'https://app.example.test', + }; + }); + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + }); - expect(mockGet).toHaveBeenCalledWith('/api/auth/whoami'); - expect(result).toEqual({ + it('reads GET /api/auth/whoami with the token', async () => { + let seen: { url: string; auth: string | null } | undefined; + globalThis.fetch = async (input, init) => { + seen = { + url: String(input), + auth: new Headers(init?.headers).get('Authorization'), + }; + return new Response( + JSON.stringify({ + userId: 'usr_1', + email: 'test@example.com', + projectId: null, + }), + { status: 200 }, + ); + }; + await expect(whoami()).resolves.toEqual({ userId: 'usr_1', email: 'test@example.com', projectId: null, }); - }); - - it('throws on error response', async () => { - mockGet.mockResolvedValue({ - error: { error: { message: 'Not authenticated' } }, + expect(seen).toEqual({ + url: 'https://app.example.test/api/auth/whoami', + auth: 'Bearer wos_pat_test', }); + }); - await expect(whoami()).rejects.toThrow('Not authenticated'); + it('throws the app error message', async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: { code: 'UNAUTHORIZED', message: 'Token expired' }, + }), + { status: 401 }, + ); + await expect(whoami()).rejects.toThrow('Token expired'); }); }); diff --git a/packages/cli/src/__tests__/unit/commands/deploy.test.ts b/packages/cli/src/__tests__/unit/commands/deploy.test.ts deleted file mode 100644 index e40f99d58..000000000 --- a/packages/cli/src/__tests__/unit/commands/deploy.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { requireProjectId } from '../../../core/auth.js'; -import { apiFetch } from '../../../core/http.js'; -import { getFlow } from '../../../commands/flows/index.js'; -import { - deploy, - getDeployment, - streamDeploymentStatus, - renderStatusLabel, - DEFAULT_DEPLOY_WAIT_MS, -} from '../../../commands/deploy/index.js'; -import { setupMockApiClient } from '../../helpers/mock-api-client.js'; - -jest.mock('../../../core/api-client.js'); -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ - apiFetch: jest.fn(), -})); -jest.mock('../../../commands/flows/index.js', () => ({ - getFlow: jest.fn(), -})); - -const { GET: mockGet, POST: mockPost } = setupMockApiClient(); - -const mockGetFlow = jest.mocked(getFlow); -const mockApiFetch = jest.mocked(apiFetch); - -const multiFlowContent = { - config: { - flows: { - web: { web: {} }, - server: { server: {} }, - }, - }, - settings: [ - { id: 'cfg_web', name: 'web', platform: 'web' }, - { id: 'cfg_server', name: 'server', platform: 'server' }, - ], -}; - -/** Read the Idempotency-Key from a recorded request-init argument. */ -function idempotencyKeyFromHeaders(init: unknown): string { - if (init && typeof init === 'object' && 'headers' in init) { - const headers = (init as { headers?: Record }).headers; - return headers?.['Idempotency-Key'] ?? ''; - } - return ''; -} - -/** Read the AbortSignal from a recorded request-init argument. */ -function signalFromInit(init: unknown): AbortSignal | undefined { - if (init && typeof init === 'object' && 'signal' in init) { - return (init as { signal?: AbortSignal }).signal; - } - return undefined; -} - -/** Helper to create an SSE stream response body */ -function createSSEStreamBody(events: Array<{ event: string; data: object }>) { - const text = events - .map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`) - .join(''); - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(text)); - controller.close(); - }, - }); -} - -describe('deploy', () => { - afterEach(() => jest.clearAllMocks()); - - describe('deploy() without flowName', () => { - it('calls legacy POST route', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - await deploy({ flowId: 'cfg_1', wait: false }); - expect(mockPost).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}/deploy', - expect.objectContaining({ - params: { path: { projectId: 'proj_default', flowId: 'cfg_1' } }, - headers: { 'Idempotency-Key': expect.any(String) }, - }), - ); - }); - - it('handles AMBIGUOUS_CONFIG with helpful error', async () => { - mockPost.mockResolvedValue({ - error: { - error: { message: 'Ambiguous config', code: 'AMBIGUOUS_CONFIG' }, - }, - }); - mockGetFlow.mockResolvedValue(multiFlowContent as any); - - await expect(deploy({ flowId: 'cfg_1' })).rejects.toThrow( - /Use --flow to specify one/, - ); - }); - - it('streams deployment status via SSE when wait=true', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - - // SSE stream response - mockApiFetch.mockResolvedValueOnce( - new Response( - createSSEStreamBody([ - { - event: 'status', - data: { status: 'deploying', substatus: 'building' }, - }, - { - event: 'status', - data: { - status: 'published', - substatus: null, - publicUrl: 'https://cdn.example.com/walker.js', - }, - }, - { event: 'done', data: {} }, - ]), - { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }, - ), - ); - - const statuses: string[] = []; - const result = await deploy({ - flowId: 'cfg_1', - wait: true, - onStatus: (status) => statuses.push(status), - }); - - expect(result).toMatchObject({ status: 'published' }); - expect(statuses).toEqual(['deploying', 'published']); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/deployments/dep_1/stream', - expect.objectContaining({ - headers: { Accept: 'text/event-stream' }, - }), - ); - }); - }); - - describe('deploy() with flowName', () => { - it('resolves settingsId and calls per-settings route', async () => { - mockGetFlow.mockResolvedValue(multiFlowContent as any); - mockApiFetch.mockResolvedValue( - new Response( - JSON.stringify({ status: 'deploying', deploymentId: 'dep_1' }), - { - status: 200, - }, - ), - ); - - await deploy({ flowId: 'cfg_1', flowName: 'web', wait: false }); - - expect(mockGetFlow).toHaveBeenCalledWith({ - flowId: 'cfg_1', - projectId: 'proj_default', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/flows/cfg_1/settings/cfg_web/deploy', - expect.objectContaining({ - method: 'POST', - headers: { 'Idempotency-Key': expect.any(String) }, - }), - ); - }); - - it('throws with available names when flowName not found', async () => { - mockGetFlow.mockResolvedValue(multiFlowContent as any); - - await expect( - deploy({ flowId: 'cfg_1', flowName: 'nonexistent' }), - ).rejects.toThrow(/Flow "nonexistent" not found. Available: web, server/); - }); - - it('streams SSE when wait=true with flowName', async () => { - mockGetFlow.mockResolvedValue(multiFlowContent as any); - - // First call: trigger deploy - mockApiFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ status: 'deploying', deploymentId: 'dep_1' }), - { status: 200 }, - ), - ); - // Second call: SSE stream - mockApiFetch.mockResolvedValueOnce( - new Response( - createSSEStreamBody([ - { - event: 'status', - data: { status: 'deploying', substatus: 'publishing' }, - }, - { - event: 'status', - data: { - status: 'published', - substatus: null, - publicUrl: 'https://cdn.example.com/walker.js', - }, - }, - { event: 'done', data: {} }, - ]), - { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }, - ), - ); - - const result = await deploy({ - flowId: 'cfg_1', - flowName: 'web', - wait: true, - }); - - expect(result).toMatchObject({ status: 'published' }); - expect(mockApiFetch).toHaveBeenCalledTimes(2); - expect(mockApiFetch).toHaveBeenLastCalledWith( - '/api/projects/proj_default/deployments/dep_1/stream', - expect.objectContaining({ - headers: { Accept: 'text/event-stream' }, - }), - ); - }); - }); - - describe('deploy() SSE error handling', () => { - it('throws on non-ok SSE stream response', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - mockApiFetch.mockResolvedValueOnce( - new Response('Not found', { status: 404 }), - ); - - await expect(deploy({ flowId: 'cfg_1', wait: true })).rejects.toThrow( - 'Stream failed: 404', - ); - }); - - it('throws when stream ends without terminal status', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - - // Stream that closes without done event and no status events - const body = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - mockApiFetch.mockResolvedValueOnce( - new Response(body, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }), - ); - - await expect(deploy({ flowId: 'cfg_1', wait: true })).rejects.toThrow( - 'Stream ended without terminal status', - ); - }); - - it('calls onStatus with substatus', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - mockApiFetch.mockResolvedValueOnce( - new Response( - createSSEStreamBody([ - { - event: 'status', - data: { status: 'deploying', substatus: 'provisioning' }, - }, - { - event: 'status', - data: { status: 'deploying', substatus: 'starting' }, - }, - { - event: 'status', - data: { - status: 'active', - substatus: null, - containerUrl: 'https://flow.scw.cloud', - }, - }, - { event: 'done', data: {} }, - ]), - { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }, - ), - ); - - const calls: Array<[string, string | null]> = []; - await deploy({ - flowId: 'cfg_1', - wait: true, - onStatus: (status, substatus) => calls.push([status, substatus]), - }); - - expect(calls).toEqual([ - ['deploying', 'provisioning'], - ['deploying', 'starting'], - ['active', null], - ]); - }); - }); - - describe('idempotency key per invocation', () => { - it('legacy POST sends a fresh unique Idempotency-Key per call', async () => { - mockPost.mockResolvedValue({ - data: { status: 'deploying', deploymentId: 'dep_1' }, - }); - - await deploy({ flowId: 'cfg_1', wait: false }); - await deploy({ flowId: 'cfg_1', wait: false }); - - const firstKey = idempotencyKeyFromHeaders(mockPost.mock.calls[0]?.[1]); - const secondKey = idempotencyKeyFromHeaders(mockPost.mock.calls[1]?.[1]); - - expect(firstKey).toEqual(expect.any(String)); - expect(firstKey.length).toBeGreaterThan(0); - expect(secondKey).not.toBe(firstKey); - }); - - it('per-settings POST sends a fresh unique Idempotency-Key per call', async () => { - mockGetFlow.mockResolvedValue(multiFlowContent as any); - // Fresh Response per call: a Response body can only be read once. - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ status: 'deploying', deploymentId: 'dep_1' }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ status: 'deploying', deploymentId: 'dep_2' }), - { status: 200 }, - ), - ); - - await deploy({ flowId: 'cfg_1', flowName: 'web', wait: false }); - await deploy({ flowId: 'cfg_1', flowName: 'web', wait: false }); - - const firstKey = idempotencyKeyFromHeaders( - mockApiFetch.mock.calls[0]?.[1], - ); - const secondKey = idempotencyKeyFromHeaders( - mockApiFetch.mock.calls[1]?.[1], - ); - - expect(firstKey).toEqual(expect.any(String)); - expect(firstKey.length).toBeGreaterThan(0); - expect(secondKey).not.toBe(firstKey); - }); - }); - - describe('default wait budget', () => { - it('defaults to 12 minutes so a healthy long server deploy is not aborted', () => { - // Server deploy budget is 10 minutes; the client default must exceed it - // so a slow-but-healthy deploy is never aborted client-side and read as - // a CI failure. - expect(DEFAULT_DEPLOY_WAIT_MS).toBe(12 * 60 * 1000); - }); - - it('defaults the stream timeout to cover the full server deploy budget', async () => { - const body = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - mockApiFetch.mockResolvedValueOnce( - new Response(body, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }), - ); - - // Stream ends without terminal status, so it rejects, but the signal - // passed to apiFetch must carry the 12-minute default budget. - await expect( - streamDeploymentStatus('proj_default', 'dep_1', {}), - ).rejects.toThrow('Stream ended without terminal status'); - - const signal = signalFromInit(mockApiFetch.mock.calls[0]?.[1]); - expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); - }); - - it('honors an explicit timeout override (the --wait flag)', async () => { - const body = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - mockApiFetch.mockResolvedValueOnce( - new Response(body, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }), - ); - - const explicitSignal = AbortSignal.timeout(30_000); - await expect( - streamDeploymentStatus('proj_default', 'dep_1', { - signal: explicitSignal, - }), - ).rejects.toThrow('Stream ended without terminal status'); - - const signal = signalFromInit(mockApiFetch.mock.calls[0]?.[1]); - expect(signal).toBe(explicitSignal); - }); - }); - - describe('renderStatusLabel()', () => { - // The server's deployment status enum is - // ['idle', 'deploying', 'published', 'active', 'stopped', 'failed'] and the - // stream emits these (status, substatus) pairs (see the app's - // emitDeploymentUpdate calls and the snapshot route). `bundling` is never a - // status the stream emits; the build phase is `deploying`/`building`. - const emitted: Array<[string, string | null]> = [ - ['deploying', 'building'], - ['deploying', 'publishing'], - ['deploying', 'provisioning'], - ['deploying', 'starting'], - ['deploying', null], - ['published', null], - ['active', null], - ['failed', null], - ['stopped', null], - ]; - - it.each(emitted)( - 'maps emitted status %s/%s to a non-empty human label', - (status, substatus) => { - const label = renderStatusLabel(status, substatus); - expect(label.length).toBeGreaterThan(0); - // Must never echo the raw enum token as the whole label for a status we - // claim to recognize; it should be a readable phrase. - expect(label).not.toBe(status); - }, - ); - - it('falls back to the substatus-less label when only the substatus is unknown', () => { - expect(renderStatusLabel('deploying', 'unknown-substatus')).toBe( - renderStatusLabel('deploying', null), - ); - }); - - it('renders a safe fallback for an unknown status instead of nothing', () => { - const label = renderStatusLabel('totally-unknown', null); - expect(label.length).toBeGreaterThan(0); - expect(label).toContain('totally-unknown'); - }); - - it('renders a safe fallback for an unknown status with substatus', () => { - const label = renderStatusLabel('totally-unknown', 'weird'); - expect(label.length).toBeGreaterThan(0); - expect(label).toContain('totally-unknown'); - }); - }); - - describe('getDeployment()', () => { - it('without flowName calls typed GET route', async () => { - mockGet.mockResolvedValue({ - data: { id: 'dep_1', type: 'web', status: 'published' }, - }); - await getDeployment({ flowId: 'cfg_1' }); - expect(mockGet).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}/deploy', - { params: { path: { projectId: 'proj_default', flowId: 'cfg_1' } } }, - ); - }); - - it('with flowName calls per-config GET route', async () => { - mockGetFlow.mockResolvedValue(multiFlowContent as any); - mockApiFetch.mockResolvedValue( - new Response( - JSON.stringify({ id: 'dep_1', type: 'web', status: 'published' }), - { status: 200 }, - ), - ); - - await getDeployment({ flowId: 'cfg_1', flowName: 'web' }); - - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/flows/cfg_1/settings/cfg_web/deploy', - ); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/deployments.test.ts b/packages/cli/src/__tests__/unit/commands/deployments.test.ts deleted file mode 100644 index 66c536223..000000000 --- a/packages/cli/src/__tests__/unit/commands/deployments.test.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { requireProjectId } from '../../../core/auth.js'; -import { apiFetch } from '../../../core/http.js'; -import { - listDeployments, - getDeploymentBySlug, - createDeployment, - deleteDeployment, - deleteDeploymentByFlowId, - createDeployCommand, - listDeploymentsCommand, - selfHostServerHint, - DeploymentAmbiguityError, -} from '../../../commands/deployments/index.js'; -import { VERSION } from '../../../version.js'; - -// Release builds carry a snapshot version (`changeset version --snapshot`), -// which is long enough to look like a token to the log redactor. -jest.mock('../../../version.js', () => ({ - VERSION: '4.7.0-next-1790408404259', -})); -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ - apiFetch: jest.fn(), -})); - -const mockApiFetch = jest.mocked(apiFetch); -const mockRequireProjectId = jest.mocked(requireProjectId); - -describe('deployments', () => { - afterEach(() => jest.clearAllMocks()); - - describe('listDeployments', () => { - it('calls GET with correct URL', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [], total: 0 }), { - status: 200, - }), - ); - const result = await listDeployments({ projectId: 'proj_123' }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments', - ); - expect(result).toEqual({ deployments: [], total: 0 }); - }); - - it('includes query params for type and status', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await listDeployments({ - projectId: 'proj_123', - type: 'web', - status: 'active', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments?type=web&status=active', - ); - }); - - it('appends flowId when provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await listDeployments({ - projectId: 'proj_123', - flowId: 'flow_abc', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments?flowId=flow_abc', - ); - }); - - it('appends cursor and limit when provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await listDeployments({ - projectId: 'proj_123', - cursor: 'abc', - limit: 10, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments?cursor=abc&limit=10', - ); - }); - - it('omits flowId when not provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await listDeployments({ projectId: 'proj_123' }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments', - ); - }); - - it('throws on non-ok response', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Unauthorized' } }), { - status: 401, - }), - ); - await expect(listDeployments({ projectId: 'proj_123' })).rejects.toThrow( - 'Unauthorized', - ); - }); - - it('falls back to requireProjectId()', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await listDeployments(); - expect(mockRequireProjectId).toHaveBeenCalled(); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/deployments', - ); - }); - }); - - describe('listDeploymentsCommand', () => { - it('refuses a --status the contract does not declare, before any request', async () => { - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${String(code)})`); - }); - try { - await expect( - listDeploymentsCommand({ project: 'proj_123', status: 'running' }), - ).rejects.toThrow('process.exit(1)'); - expect(errorSpy).toHaveBeenCalledWith( - '--status must be one of: idle, deploying, published, active, stopped, failed', - ); - expect(mockApiFetch).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - exitSpy.mockRestore(); - } - }); - }); - - describe('getDeploymentBySlug', () => { - it('calls GET with slug in URL', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ slug: 'my-deploy' }), { status: 200 }), - ); - const result = await getDeploymentBySlug({ slug: 'my-deploy' }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/deployments/my-deploy', - ); - expect(result).toEqual({ slug: 'my-deploy' }); - }); - - it('uses provided projectId', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ slug: 'my-deploy' }), { status: 200 }), - ); - await getDeploymentBySlug({ slug: 'my-deploy', projectId: 'proj_123' }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments/my-deploy', - ); - }); - - it('throws on error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Not found' } }), { - status: 404, - }), - ); - await expect(getDeploymentBySlug({ slug: 'missing' })).rejects.toThrow( - 'Not found', - ); - }); - }); - - describe('createDeployment', () => { - it('POSTs with type and label', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ slug: 'new-deploy', type: 'web' }), { - status: 201, - }), - ); - const result = await createDeployment({ - type: 'web', - label: 'My Deploy', - projectId: 'proj_123', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'web', label: 'My Deploy' }), - }, - ); - expect(result).toEqual({ slug: 'new-deploy', type: 'web' }); - }); - - it('throws on error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Bad request' } }), { - status: 400, - }), - ); - await expect(createDeployment({ type: 'server' })).rejects.toThrow( - 'Bad request', - ); - }); - }); - - describe('deleteDeployment', () => { - it('sends DELETE', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }), - ); - const result = await deleteDeployment({ - slug: 'my-deploy', - projectId: 'proj_123', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_123/deployments/my-deploy', - { method: 'DELETE' }, - ); - expect(result).toEqual({ success: true }); - }); - - it('returns success on empty response', async () => { - mockApiFetch.mockResolvedValue(new Response('', { status: 200 })); - const result = await deleteDeployment({ slug: 'my-deploy' }); - expect(result).toEqual({ success: true }); - }); - - it('throws on error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Not found' } }), { - status: 404, - }), - ); - await expect(deleteDeployment({ slug: 'missing' })).rejects.toThrow( - 'Not found', - ); - }); - }); - - describe('deleteDeploymentByFlowId', () => { - it('verifies slug belongs to flow before deleting', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - deployments: [ - { - slug: 'my-deploy', - type: 'web', - status: 'active', - updatedAt: '2026-04-22T00:00:00.000Z', - }, - { - slug: 'other-deploy', - type: 'web', - status: 'active', - updatedAt: '2026-04-21T00:00:00.000Z', - }, - ], - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { status: 200 }), - ); - await deleteDeploymentByFlowId({ - projectId: 'proj_123', - flowId: 'flow_abc', - slug: 'my-deploy', - }); - expect(mockApiFetch).toHaveBeenCalledTimes(2); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 1, - '/api/projects/proj_123/deployments?flowId=flow_abc', - ); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 2, - '/api/projects/proj_123/deployments/my-deploy', - { method: 'DELETE' }, - ); - }); - - it('throws when slug does not belong to the flow', async () => { - mockApiFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - deployments: [ - { - slug: 'other-deploy', - type: 'web', - status: 'active', - updatedAt: '2026-04-22T00:00:00.000Z', - }, - ], - }), - { status: 200 }, - ), - ); - await expect( - deleteDeploymentByFlowId({ - projectId: 'proj_123', - flowId: 'flow_abc', - slug: 'wrong-slug', - }), - ).rejects.toThrow('No deployment with slug wrong-slug in flow flow_abc'); - }); - - it('lists then deletes the single match when no slug provided', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - deployments: [ - { - slug: 'only-one', - type: 'web', - status: 'active', - updatedAt: '2026-04-22T00:00:00.000Z', - }, - ], - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { status: 200 }), - ); - - await deleteDeploymentByFlowId({ - projectId: 'proj_123', - flowId: 'flow_abc', - }); - - expect(mockApiFetch).toHaveBeenCalledTimes(2); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 1, - '/api/projects/proj_123/deployments?flowId=flow_abc', - ); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 2, - '/api/projects/proj_123/deployments/only-one', - { method: 'DELETE' }, - ); - }); - - it('throws DeploymentAmbiguityError when multiple active matches and no slug', async () => { - mockApiFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - deployments: [ - { - slug: 'one', - type: 'web', - status: 'active', - updatedAt: '2026-04-20T00:00:00.000Z', - }, - { - slug: 'two', - type: 'web', - status: 'active', - updatedAt: '2026-04-21T00:00:00.000Z', - }, - ], - }), - { status: 200 }, - ), - ); - - await expect( - deleteDeploymentByFlowId({ - projectId: 'proj_123', - flowId: 'flow_abc', - }), - ).rejects.toBeInstanceOf(DeploymentAmbiguityError); - }); - - it('throws a plain error when no matches found', async () => { - mockApiFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ deployments: [] }), { status: 200 }), - ); - await expect( - deleteDeploymentByFlowId({ - projectId: 'proj_123', - flowId: 'flow_abc', - }), - ).rejects.toThrow('No deployments found for flow flow_abc'); - }); - }); - - describe('createDeployCommand human-readable output', () => { - let logSpy: jest.SpyInstance; - - beforeEach(() => { - logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - logSpy.mockRestore(); - }); - - const renderedLines = (): string => - logSpy.mock.calls.map(String).join('\n'); - - it('never prints undefined or a raw placeholder, and points at minting a real deploy token', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - config: { - flows: { default: { config: { platform: 'server' } } }, - }, - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - id: 'dep_a1b2c3d4', - slug: 'k7m2x9p4q1w8', - type: 'server', - }), - { status: 201 }, - ), - ); - - await createDeployCommand('cfg_remoteflow', { - project: 'proj_123', - flow: 'default', - }); - - const output = renderedLines(); - // No undefined interpolation, and no bare `=` in the docker run. - expect(output).not.toContain('undefined'); - expect(output).not.toContain('WALKEROS_DEPLOY_TOKEN=<'); - // The instruction must point the user at minting a real deploy token. - expect(output.toLowerCase()).toContain('deploy token'); - expect(output).toContain('WALKEROS_DEPLOY_TOKEN'); - }); - - it('prints a runnable bundle and docker command pinned to this CLI version', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - config: { - flows: { default: { config: { platform: 'server' } } }, - }, - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ id: 'dep_a1b2', slug: 's', type: 'server' }), - { status: 201 }, - ), - ); - - await createDeployCommand('cfg_remoteflow', { - project: 'proj_123', - flow: 'default', - }); - - const output = renderedLines(); - expect(output).toContain('walkeros bundle flow.json -o dist/'); - expect(output).toContain('-v "$PWD/dist:/app/flow:ro"'); - expect(output).toContain('-e WALKEROS_PROJECT_ID="proj_123"'); - expect(output).toContain('-e WALKEROS_FLOW_ID="cfg_remoteflow"'); - expect(output).toContain('-e WALKEROS_DEPLOYMENT_ID="dep_a1b2"'); - expect(output).toContain(`walkeros/flow:${VERSION}`); - expect(output).not.toContain(':latest'); - }); - - it('prints no self-host command for a web deployment', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - config: { flows: { default: { config: { platform: 'web' } } } }, - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ id: 'dep_w', slug: 's', type: 'web' }), - { status: 201 }, - ), - ); - - await createDeployCommand('cfg_webflow', { project: 'proj_123' }); - - const output = renderedLines(); - expect(output).toContain('Deployment created: dep_w'); - expect(output).not.toContain('docker run'); - }); - }); - - describe('selfHostServerHint', () => { - it('matches the app self-host command shape, one continued docker command', () => { - const lines = selfHostServerHint({ - config: 'flow.json', - flowId: 'cfg_1', - projectId: 'proj_1', - deploymentId: 'dep_1', - appUrl: 'https://app.example.com', - version: '9.9.9', - }); - const start = lines.indexOf(' docker run --rm -p 8080:8080 \\'); - const end = lines.indexOf(' walkeros/flow:9.9.9'); - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - for (const line of lines.slice(start, end)) - expect(line.endsWith(' \\')).toBe(true); - expect(lines).toContain( - ' -e WALKEROS_APP_URL="https://app.example.com" \\', - ); - expect(lines).toContain( - ' npx --yes --package=@walkeros/runner@9.9.9 runneros start dist/flow.mjs', - ); - }); - - it('prints a flow id placeholder for a local config', () => { - const lines = selfHostServerHint({ - config: './flow.json', - projectId: 'proj_1', - deploymentId: 'dep_1', - appUrl: 'https://app.walkeros.io', - version: '1.0.0', - }); - expect(lines).toContain(' walkeros bundle ./flow.json -o dist/'); - expect(lines).toContain(' -e WALKEROS_FLOW_ID="" \\'); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/diagnostics.test.ts b/packages/cli/src/__tests__/unit/commands/diagnostics.test.ts index f71377ff8..a4d25de45 100644 --- a/packages/cli/src/__tests__/unit/commands/diagnostics.test.ts +++ b/packages/cli/src/__tests__/unit/commands/diagnostics.test.ts @@ -1,185 +1,45 @@ +import '../../helpers/setup-msw.js'; +import { server } from '../../helpers/msw-server.js'; +import { http, HttpResponse } from 'msw'; import { diagnostics, - diagnosticsCommand, + formatDiagnostics, } from '../../../commands/diagnostics/index.js'; -import type { ContractComparison } from '../../../core/contract.js'; -import { VERSION } from '../../../version.js'; - -const mockResolveAppUrl = jest.fn(); -const mockReadConfig = jest.fn<{ appUrl?: string } | null, []>(); -jest.mock('../../../lib/config-file.js', () => ({ - resolveAppUrl: () => mockResolveAppUrl(), - readConfig: () => mockReadConfig(), -})); - -const mockCompareContract = jest.fn, [unknown]>(); -jest.mock('../../../core/contract.js', () => ({ - ...jest.requireActual( - '../../../core/contract.js', - ), - compareContract: (input: unknown) => mockCompareContract(input), -})); - -const CLIENT = { - package: '@walkeros/cli', - version: VERSION, - contract: '4.7.0+1a2b3c4d', -}; - -const IN_SYNC: ContractComparison = { - verdict: 'in-sync', - appUrl: 'https://app.test', - client: CLIENT, - server: '4.7.0+5e6f7a8b', - operations: 55, - missing: [], - changed: [], -}; - -const HEALTH = { - status: 'ok', - appVersion: 'abc1234', - contractVersion: '4.7.0+1a2b3c4d', - contractHash: 'f'.repeat(64), - minSupportedClient: '4.7.0', -}; - -const realFetch = global.fetch; -let requests: Array<{ url: string; headers: Headers }>; -let stdout: string[]; -let stdoutSpy: jest.SpyInstance; -let exitSpy: jest.SpyInstance; - -function answerHealth(answer: unknown | Error): void { - const mock: typeof fetch = async (input, init) => { - requests.push({ url: String(input), headers: new Headers(init?.headers) }); - if (answer instanceof Error) throw answer; - return new Response(JSON.stringify(answer), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }; - global.fetch = mock; -} +const APP = 'https://app.example.test'; +const env = process.env; beforeEach(() => { - requests = []; - stdout = []; - delete process.env.WALKEROS_APP_URL; - delete process.env.WALKEROS_TOKEN; - mockResolveAppUrl.mockReturnValue('https://app.test/'); - mockReadConfig.mockReturnValue(null); - mockCompareContract.mockResolvedValue(IN_SYNC); - answerHealth(HEALTH); - stdoutSpy = jest - .spyOn(process.stdout, 'write') - .mockImplementation((chunk: string | Uint8Array): boolean => { - stdout.push(typeof chunk === 'string' ? chunk : chunk.toString()); - return true; - }); - exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${code})`); - }); + process.env = { ...env, WALKEROS_APP_URL: APP }; }); - afterEach(() => { - global.fetch = realFetch; - stdoutSpy.mockRestore(); - exitSpy.mockRestore(); - jest.clearAllMocks(); + process.env = env; }); describe('walkeros diagnostics', () => { - it('prints the cli, the app, its health and the contract line', async () => { - await diagnosticsCommand({}); - expect(stdout.join('')).toBe( - [ - `cli ${VERSION}`, - 'app https://app.test (default)', - 'health reachable, status ok, app abc1234', - 'contract: in-sync (server 4.7.0+5e6f7a8b, client 4.7.0+1a2b3c4d, 55 operations)', - '', - ].join('\n'), - ); - }); - - it('probes health and compares the contract on the same app', async () => { - await diagnostics(); - expect(requests.map((r) => r.url)).toEqual(['https://app.test/api/health']); - expect(mockCompareContract).toHaveBeenCalledWith({ - baseUrl: 'https://app.test', - }); - }); - - it('prints an unreachable app with the reason', async () => { - answerHealth( - new TypeError('fetch failed', { - cause: Object.assign(new Error('connect'), { code: 'ECONNREFUSED' }), + it('reports the CLI version, the app URL with its source and a health ping, nothing else', async () => { + let authorization: string | null = 'unset'; + server.use( + http.get(`${APP}/api/health`, ({ request }) => { + authorization = request.headers.get('authorization'); + return HttpResponse.json({ status: 'ok', appVersion: 'abc123' }); }), ); - mockCompareContract.mockResolvedValue({ - verdict: 'unknown', - appUrl: 'https://app.test', - client: CLIENT, - reason: 'GET https://app.test/api/health: fetch failed (ECONNREFUSED)', + const report = await diagnostics(); + expect(Object.keys(report).sort()).toEqual(['app', 'appUrl', 'cli']); + expect(report.appUrl).toEqual({ resolved: APP, source: 'env' }); + expect(report.app).toEqual({ + reachable: true, + status: 'ok', + appVersion: 'abc123', }); - await diagnosticsCommand({}); - const lines = stdout.join('').split('\n'); - expect(lines[2]).toBe('health unreachable (fetch failed (ECONNREFUSED))'); - expect(lines[3]).toBe( - 'contract: unknown (GET https://app.test/api/health: fetch failed (ECONNREFUSED))', - ); - }); - - it('prints the HTTP status of a non-2xx health answer', async () => { - const mock: typeof fetch = async () => - new Response('unavailable', { status: 503 }); - global.fetch = mock; - await diagnosticsCommand({}); - expect(stdout.join('').split('\n')[2]).toBe('health reachable, HTTP 503'); - }); - - it('prints { cli, appUrl, app, contract } with --json', async () => { - await diagnosticsCommand({ json: true }); - expect(JSON.parse(stdout.join(''))).toEqual({ - cli: { version: VERSION }, - appUrl: { resolved: 'https://app.test', source: 'default' }, - app: { reachable: true, ...HEALTH }, - contract: IN_SYNC, - }); - }); - - it.each([ - ['env', { env: 'https://env.test' }], - ['config', { config: 'https://config.test' }], - ['default', {}], - ])( - 'names the app URL source %s', - async (source, given: { env?: string; config?: string }) => { - if (given.env) process.env.WALKEROS_APP_URL = given.env; - if (given.config) - mockReadConfig.mockReturnValue({ appUrl: given.config }); - const out = await diagnostics(); - expect(out.appUrl.source).toBe(source); - }, - ); - - it('works logged out and sends no credential', async () => { - process.env.WALKEROS_TOKEN = 'secret-token'; - await diagnosticsCommand({}); - expect(requests).toHaveLength(1); - expect(requests[0].headers.has('authorization')).toBe(false); + expect(authorization).toBeNull(); + expect(formatDiagnostics(report)).toHaveLength(3); }); - it.each([ - 'client-outdated', - 'server-older', - 'changed', - 'unknown', - ])('exits 0 on %s', async (verdict) => { - mockCompareContract.mockResolvedValue({ ...IN_SYNC, verdict }); - await expect(diagnosticsCommand({})).resolves.toBeUndefined(); - expect(exitSpy).not.toHaveBeenCalled(); - expect(process.exitCode ?? 0).toBe(0); + it('names an unreachable app', async () => { + server.use(http.get(`${APP}/api/health`, () => HttpResponse.error())); + const report = await diagnostics(); + expect(report.app.reachable).toBe(false); + expect(formatDiagnostics(report)[2]).toMatch(/^health unreachable \(/); }); }); diff --git a/packages/cli/src/__tests__/unit/commands/feedback.test.ts b/packages/cli/src/__tests__/unit/commands/feedback.test.ts deleted file mode 100644 index 6acd8aed6..000000000 --- a/packages/cli/src/__tests__/unit/commands/feedback.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { feedback } from '../../../commands/feedback/index.js'; - -jest.mock('../../../lib/config-file.js', () => ({ - readConfig: jest.fn(), -})); - -jest.mock('../../../core/http.js', () => ({ - publicFetch: jest.fn().mockResolvedValue({ ok: true }), -})); - -import { readConfig } from '../../../lib/config-file.js'; -import { publicFetch } from '../../../core/http.js'; - -const mockReadConfig = readConfig as jest.Mock; -const mockPublicFetch = publicFetch as jest.Mock; - -describe('feedback', () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - mockPublicFetch.mockResolvedValue({ ok: true }); - mockReadConfig.mockReturnValue(null); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - jest.clearAllMocks(); - }); - - it('sends anonymous feedback when no config exists', async () => { - await feedback('Great tool!'); - - expect(mockPublicFetch).toHaveBeenCalledWith('/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: 'Great tool!' }), - }); - }); - - it('sends anonymous feedback when config has anonymousFeedback=true', async () => { - mockReadConfig.mockReturnValue({ - token: 't', - email: 'user@example.com', - appUrl: '', - anonymousFeedback: true, - }); - - await feedback('Nice!'); - - const body = JSON.parse(mockPublicFetch.mock.calls[0][1].body); - expect(body).toEqual({ text: 'Nice!' }); - expect(body.userId).toBeUndefined(); - expect(body.projectId).toBeUndefined(); - }); - - it('includes userId and projectId when not anonymous', async () => { - mockReadConfig.mockReturnValue({ - token: 't', - email: 'user@example.com', - appUrl: '', - anonymousFeedback: false, - }); - process.env.WALKEROS_PROJECT_ID = 'proj_123'; - - await feedback('Bug report'); - - const body = JSON.parse(mockPublicFetch.mock.calls[0][1].body); - expect(body).toEqual({ - text: 'Bug report', - userId: 'user@example.com', - projectId: 'proj_123', - }); - }); - - it('options.anonymous overrides config', async () => { - mockReadConfig.mockReturnValue({ - token: 't', - email: 'user@example.com', - appUrl: '', - anonymousFeedback: false, // config says non-anonymous - }); - - await feedback('Force anon', { anonymous: true }); - - const body = JSON.parse(mockPublicFetch.mock.calls[0][1].body); - expect(body).toEqual({ text: 'Force anon' }); - expect(body.userId).toBeUndefined(); - }); - - it('defaults to anonymous when config has no anonymousFeedback field', async () => { - mockReadConfig.mockReturnValue({ - token: 't', - email: 'user@example.com', - appUrl: '', - }); - - await feedback('Default anon'); - - const body = JSON.parse(mockPublicFetch.mock.calls[0][1].body); - expect(body).toEqual({ text: 'Default anon' }); - expect(body.userId).toBeUndefined(); - }); - - it('sends to the correct path', async () => { - await feedback('Test'); - - expect(mockPublicFetch).toHaveBeenCalledWith( - '/api/feedback', - expect.any(Object), - ); - }); - - it('throws on non-ok response', async () => { - mockPublicFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - await expect(feedback('fail')).rejects.toThrow( - 'Feedback submission failed: 500 Internal Server Error', - ); - }); - - it('omits projectId when env var is not set and not anonymous', async () => { - mockReadConfig.mockReturnValue({ - token: 't', - email: 'user@example.com', - appUrl: '', - anonymousFeedback: false, - }); - delete process.env.WALKEROS_PROJECT_ID; - - await feedback('No project'); - - const body = JSON.parse(mockPublicFetch.mock.calls[0][1].body); - expect(body).toEqual({ - text: 'No project', - userId: 'user@example.com', - }); - expect(body.projectId).toBeUndefined(); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/flows.test.ts b/packages/cli/src/__tests__/unit/commands/flows.test.ts deleted file mode 100644 index 9bb3a1191..000000000 --- a/packages/cli/src/__tests__/unit/commands/flows.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { requireProjectId } from '../../../core/auth.js'; -import { - listFlows, - listAllFlows, - getFlow, - createFlow, - updateFlow, - deleteFlow, - duplicateFlow, - createFlowCommand, - updateFlowCommand, -} from '../../../commands/flows/index.js'; -import { listProjects } from '../../../commands/projects/index.js'; -import { setupMockApiClient } from '../../helpers/mock-api-client.js'; - -jest.mock('../../../core/api-client.js'); -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/output.js', () => ({ - writeResult: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('../../../commands/projects/index.js', () => ({ - listProjects: jest.fn(), -})); - -const mockListProjects = jest.mocked(listProjects); - -const { - GET: mockGet, - POST: mockPost, - PATCH: mockPatch, - DELETE: mockDelete, -} = setupMockApiClient(); - -const mockRequireProjectId = jest.mocked(requireProjectId); - -describe('flows', () => { - afterEach(() => jest.clearAllMocks()); - - describe('listFlows', () => { - it('calls GET /api/projects/{projectId}/flows', async () => { - mockGet.mockResolvedValue({ data: { flows: [], total: 0 } }); - await listFlows({ projectId: 'proj_1' }); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_1' }, - query: { - sort: undefined, - order: undefined, - include_deleted: undefined, - cursor: undefined, - limit: undefined, - }, - }, - }); - }); - - it('falls back to requireProjectId()', async () => { - mockGet.mockResolvedValue({ data: { flows: [] } }); - await listFlows(); - expect(mockRequireProjectId).toHaveBeenCalled(); - }); - - it('passes query params', async () => { - mockGet.mockResolvedValue({ data: { flows: [] } }); - await listFlows({ - projectId: 'proj_1', - sort: 'name', - order: 'asc', - includeDeleted: true, - }); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_1' }, - query: { - sort: 'name', - order: 'asc', - include_deleted: 'true', - cursor: undefined, - limit: undefined, - }, - }, - }); - }); - - it('forwards cursor and limit', async () => { - mockGet.mockResolvedValue({ data: { flows: [] } }); - await listFlows({ projectId: 'proj_1', cursor: 'abc', limit: 10 }); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_1' }, - query: { - sort: undefined, - order: undefined, - include_deleted: undefined, - cursor: 'abc', - limit: 10, - }, - }, - }); - }); - }); - - describe('getFlow', () => { - it('calls GET with flowId', async () => { - mockGet.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await getFlow({ flowId: 'cfg_abc', projectId: 'proj_1' }); - expect(mockGet).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - query: {}, - }, - }, - ); - }); - - it('falls back to requireProjectId()', async () => { - mockGet.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await getFlow({ flowId: 'cfg_abc' }); - expect(mockGet).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_default', flowId: 'cfg_abc' }, - query: {}, - }, - }, - ); - }); - - it('passes fields query param', async () => { - mockGet.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await getFlow({ - flowId: 'cfg_abc', - projectId: 'proj_1', - fields: ['content.variables', 'content.flows.web'], - }); - expect(mockGet).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - query: { fields: 'content.variables,content.flows.web' }, - }, - }, - ); - }); - }); - - describe('createFlow', () => { - it('POSTs with name and content', async () => { - const content = { version: 3 }; - mockPost.mockResolvedValue({ data: { id: 'cfg_new' } }); - await createFlow({ name: 'My Flow', content, projectId: 'proj_1' }); - expect(mockPost).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { path: { projectId: 'proj_1' } }, - body: { name: 'My Flow', config: content }, - }); - }); - }); - - describe('updateFlow', () => { - it('PATCHes with name and content', async () => { - const content = { version: 3, sources: [] }; - mockPatch.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await updateFlow({ - flowId: 'cfg_abc', - name: 'Updated', - content, - projectId: 'proj_1', - }); - expect(mockPatch).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - }, - body: { name: 'Updated', config: content }, - }, - ); - }); - - it('only includes provided fields in body', async () => { - mockPatch.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await updateFlow({ - flowId: 'cfg_abc', - name: 'Updated', - projectId: 'proj_1', - }); - expect(mockPatch).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - }, - body: { name: 'Updated' }, - }, - ); - }); - - it('sends merge-patch content type when mergePatch is true', async () => { - const content = { variables: { trackingId: 'G-NEW' } }; - mockPatch.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await updateFlow({ - flowId: 'cfg_abc', - content, - projectId: 'proj_1', - mergePatch: true, - }); - expect(mockPatch).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - }, - body: { config: content }, - headers: { 'Content-Type': 'application/merge-patch+json' }, - }, - ); - }); - - it('sends application/json by default (no mergePatch)', async () => { - const content = { version: 3, flows: {} }; - mockPatch.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await updateFlow({ - flowId: 'cfg_abc', - content, - projectId: 'proj_1', - }); - expect(mockPatch).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: 'proj_1', flowId: 'cfg_abc' }, - }, - body: { config: content }, - }, - ); - }); - }); - - describe('deleteFlow', () => { - it('sends DELETE and returns success', async () => { - mockDelete.mockResolvedValue({ data: undefined }); - const result = await deleteFlow({ - flowId: 'cfg_abc', - projectId: 'proj_1', - }); - expect(mockDelete).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { path: { projectId: 'proj_1', flowId: 'cfg_abc' } }, - }, - ); - expect(result).toEqual({ success: true }); - }); - }); - - describe('duplicateFlow', () => { - it('POSTs to /duplicate with optional name', async () => { - mockPost.mockResolvedValue({ data: { id: 'cfg_copy' } }); - await duplicateFlow({ - flowId: 'cfg_abc', - name: 'My Copy', - projectId: 'proj_1', - }); - expect(mockPost).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}/duplicate', - { - params: { path: { projectId: 'proj_1', flowId: 'cfg_abc' } }, - body: { name: 'My Copy' }, - }, - ); - }); - - it('POSTs without name when not provided', async () => { - mockPost.mockResolvedValue({ data: { id: 'cfg_copy' } }); - await duplicateFlow({ flowId: 'cfg_abc', projectId: 'proj_1' }); - expect(mockPost).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}/duplicate', - { - params: { path: { projectId: 'proj_1', flowId: 'cfg_abc' } }, - body: { name: undefined }, - }, - ); - }); - }); - - describe('listAllFlows', () => { - const mockFlowSummary = { - id: 'flow_1', - name: 'Test Flow', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - deletedAt: null, - }; - - it('returns flows grouped by project', async () => { - mockListProjects.mockResolvedValue({ - projects: [ - { - id: 'proj_a', - name: 'Project A', - role: 'owner', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - memberCount: 1, - flowCount: 0, - deploymentCount: 0, - isDemo: false, - }, - { - id: 'proj_b', - name: 'Project B', - role: 'member', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - memberCount: 1, - flowCount: 0, - deploymentCount: 0, - isDemo: false, - }, - ], - total: 2, - nextCursor: null, - }); - mockGet - .mockResolvedValueOnce({ - data: { flows: [mockFlowSummary], total: 1 }, - }) - .mockResolvedValueOnce({ - data: { - flows: [{ ...mockFlowSummary, id: 'flow_2', name: 'Other Flow' }], - total: 1, - }, - }); - - const result = await listAllFlows(); - - expect(result).toEqual([ - { - project: { id: 'proj_a', name: 'Project A' }, - flows: [mockFlowSummary], - }, - { - project: { id: 'proj_b', name: 'Project B' }, - flows: [{ ...mockFlowSummary, id: 'flow_2', name: 'Other Flow' }], - }, - ]); - }); - - it('returns empty array when no projects', async () => { - mockListProjects.mockResolvedValue({ - projects: [], - total: 0, - nextCursor: null, - }); - - const result = await listAllFlows(); - - expect(result).toEqual([]); - expect(mockGet).not.toHaveBeenCalled(); - }); - - it('passes sort/order/includeDeleted to each listFlows call', async () => { - mockListProjects.mockResolvedValue({ - projects: [ - { - id: 'proj_a', - name: 'Project A', - role: 'owner', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - memberCount: 1, - flowCount: 0, - deploymentCount: 0, - isDemo: false, - }, - { - id: 'proj_b', - name: 'Project B', - role: 'member', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - memberCount: 1, - flowCount: 0, - deploymentCount: 0, - isDemo: false, - }, - ], - total: 2, - nextCursor: null, - }); - mockGet.mockResolvedValue({ data: { flows: [], total: 0 } }); - - await listAllFlows({ sort: 'name', order: 'asc', includeDeleted: true }); - - expect(mockGet).toHaveBeenCalledTimes(2); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_a' }, - query: { - sort: 'name', - order: 'asc', - include_deleted: 'true', - cursor: undefined, - limit: undefined, - }, - }, - }); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_b' }, - query: { - sort: 'name', - order: 'asc', - include_deleted: 'true', - cursor: undefined, - limit: undefined, - }, - }, - }); - }); - }); - - describe('--content', () => { - const content = { version: 3, flows: { default: { web: {} } } }; - let dir: string; - - beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'walkeros-flows-content-')); - }); - afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); - - it.each([ - ['inline JSON', () => JSON.stringify(content)], - [ - 'a file path', - () => { - const file = path.join(dir, 'flow.json'); - fs.writeFileSync(file, JSON.stringify(content, null, 2)); - return file; - }, - ], - ])('create accepts %s', async (_label, input) => { - mockPost.mockResolvedValue({ data: { id: 'cfg_new' } }); - await createFlowCommand('My Flow', { - content: input(), - project: 'proj_1', - }); - expect(mockPost).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { path: { projectId: 'proj_1' } }, - body: { name: 'My Flow', config: content }, - }); - }); - - it('exits 1 with the not-found message for a missing file', async () => { - // process.exit throws so control flow stops like the real exit. - const exitSpy = jest - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null | undefined) => { - throw new Error(`__exit__:${code}`); - }); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - const missing = path.join(dir, 'missing.json'); - - try { - await expect( - createFlowCommand('My Flow', { content: missing, project: 'proj_1' }), - ).rejects.toThrow('__exit__:1'); - expect(errorSpy).toHaveBeenCalledWith( - `Configuration file not found: ${missing}`, - ); - expect(mockPost).not.toHaveBeenCalled(); - } finally { - exitSpy.mockRestore(); - errorSpy.mockRestore(); - } - }); - - it('update accepts a file path', async () => { - const file = path.join(dir, 'flow.json'); - fs.writeFileSync(file, JSON.stringify(content, null, 2)); - mockPatch.mockResolvedValue({ data: { id: 'cfg_abc' } }); - await updateFlowCommand('cfg_abc', { content: file, project: 'proj_1' }); - expect(mockPatch).toHaveBeenCalledWith( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { path: { projectId: 'proj_1', flowId: 'cfg_abc' } }, - body: { config: content }, - }, - ); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/frames.test.ts b/packages/cli/src/__tests__/unit/commands/frames.test.ts deleted file mode 100644 index bf403688f..000000000 --- a/packages/cli/src/__tests__/unit/commands/frames.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { apiFetch } from '../../../core/http.js'; -import { - listFrames, - listPageFrames, - getFrame, -} from '../../../commands/frames/index.js'; - -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ apiFetch: jest.fn() })); - -const mockApiFetch = jest.mocked(apiFetch); -const ok = (body: unknown, status = 200): Response => - new Response(JSON.stringify(body), { status }); - -describe('frames programmatic API', () => { - afterEach(() => jest.clearAllMocks()); - - it('listFrames reads the lean listing of the flow', async () => { - mockApiFetch.mockResolvedValue(ok({ frames: [] })); - await listFrames({ projectId: 'proj_1', flowId: 'flow_1' }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/frames', - ); - }); - - it('listPageFrames encodes the page key', async () => { - mockApiFetch.mockResolvedValue(ok({ frames: [] })); - await listPageFrames({ - projectId: 'proj_1', - flowId: 'flow_1', - pageKey: 'https://shop.example/cart?x=1', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/frames?pageKey=https%3A%2F%2Fshop.example%2Fcart%3Fx%3D1', - ); - }); - - it('getFrame reads one frame by id', async () => { - mockApiFetch.mockResolvedValue(ok({ id: 'frm_V1StGXR8Z5jdHi6BmyT7K' })); - await getFrame({ - projectId: 'proj_1', - flowId: 'flow_1', - frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/frames/frm_V1StGXR8Z5jdHi6BmyT7K', - ); - }); - - it.each([ - ['FRAME_NOT_FOUND', 'Frame not found'], - ['FLOW_NOT_FOUND', 'Flow not found'], - ])('getFrame throws %s with the wire message', async (code, message) => { - mockApiFetch.mockResolvedValue(ok({ error: { code, message } }, 404)); - await expect( - getFrame({ - projectId: 'proj_1', - flowId: 'flow_1', - frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', - }), - ).rejects.toMatchObject({ code, message }); - }); - - it.each([ - ['listFrames', () => listFrames({ projectId: 'proj_1', flowId: 'flow_1' })], - [ - 'listPageFrames', - () => - listPageFrames({ - projectId: 'proj_1', - flowId: 'flow_1', - pageKey: 'https://shop.example/cart', - }), - ], - [ - 'getFrame', - () => - getFrame({ - projectId: 'proj_1', - flowId: 'flow_1', - frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', - }), - ], - ])( - '%s throws CLIENT_OUTDATED with the wire message, never an empty read', - async (_name, read) => { - const message = 'This client is too old for this endpoint. Upgrade it.'; - mockApiFetch.mockResolvedValue( - ok({ error: { code: 'CLIENT_OUTDATED', message } }, 426), - ); - await expect(read()).rejects.toMatchObject({ - code: 'CLIENT_OUTDATED', - message, - status: 426, - }); - }, - ); - - it('surfaces FEATURE_NOT_AVAILABLE naming frames', async () => { - mockApiFetch.mockResolvedValue( - ok( - { - error: { - code: 'FEATURE_NOT_AVAILABLE', - message: 'frames is not available on your current plan', - }, - }, - 403, - ), - ); - await expect( - listFrames({ projectId: 'proj_1', flowId: 'flow_1' }), - ).rejects.toMatchObject({ - code: 'FEATURE_NOT_AVAILABLE', - message: 'frames is not available on your current plan', - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/hub.test.ts b/packages/cli/src/__tests__/unit/commands/hub.test.ts deleted file mode 100644 index b7521e00c..000000000 --- a/packages/cli/src/__tests__/unit/commands/hub.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { requireProjectId } from '../../../core/auth.js'; -import { apiFetch } from '../../../core/http.js'; -import { - listReleases, - getRelease, - listStepHistory, - setReleaseRationale, - listThreads, - createThread, - addThreadMessage, - listKnowledge, -} from '../../../commands/hub/index.js'; -import type { ReleaseRef } from '../../../commands/hub/index.js'; - -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ apiFetch: jest.fn() })); - -const mockApiFetch = jest.mocked(apiFetch); -const mockRequireProjectId = jest.mocked(requireProjectId); - -function ok(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { status }); -} - -describe('hub programmatic API', () => { - afterEach(() => jest.clearAllMocks()); - - it('listReleases asks for the rationale-carrying index', async () => { - mockApiFetch.mockResolvedValue( - ok({ releases: [], total: 0, limit: 20, offset: 0 }), - ); - await listReleases({ - projectId: 'proj_1', - flowId: 'flow_1', - limit: 5, - offset: 10, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/releases?rationale=true&limit=5&offset=10', - ); - }); - - it('listReleases falls back to the default project', async () => { - mockApiFetch.mockResolvedValue( - ok({ releases: [], total: 0, limit: 20, offset: 0 }), - ); - await listReleases({ flowId: 'flow_1' }); - expect(mockRequireProjectId).toHaveBeenCalled(); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/flows/flow_1/releases?rationale=true', - ); - }); - - it.each<[ReleaseRef, string]>([ - [ - { versionId: 'ver_abc' }, - '/api/projects/proj_1/flows/flow_1/releases/ver_abc', - ], - [{ versionNumber: 14 }, '/api/projects/proj_1/flows/flow_1/releases/14'], - ])('getRelease addresses %j on the path', async (ref, path) => { - mockApiFetch.mockResolvedValue( - ok({ versionId: 'ver_abc', versionNumber: 14 }), - ); - await getRelease({ projectId: 'proj_1', flowId: 'flow_1', ref }); - expect(mockApiFetch).toHaveBeenCalledWith(path); - }); - - it('getRelease throws the API error code on a 404', async () => { - mockApiFetch.mockResolvedValue( - ok({ error: { code: 'NOT_FOUND', message: 'Release not found' } }, 404), - ); - await expect( - getRelease({ - projectId: 'proj_1', - flowId: 'flow_1', - ref: { versionId: 'ver_x' }, - }), - ).rejects.toMatchObject({ - code: 'NOT_FOUND', - message: 'Release not found', - status: 404, - }); - }); - - it('listStepHistory encodes step, flow and limit', async () => { - mockApiFetch.mockResolvedValue( - ok({ step: 'destination.ga4', entries: [] }), - ); - await listStepHistory({ - projectId: 'proj_1', - flowId: 'flow_1', - step: 'destination.ga4', - flow: 'web', - limit: 30, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/releases/step-history?step=destination.ga4&flow=web&limit=30', - ); - }); - - it('setReleaseRationale PUTs humanText for one versionId', async () => { - mockApiFetch.mockResolvedValue( - ok({ versionId: 'ver_abc', humanText: 'why' }), - ); - await setReleaseRationale({ - projectId: 'proj_1', - flowId: 'flow_1', - versionId: 'ver_abc', - text: 'why', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/releases/annotations', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ versionId: 'ver_abc', humanText: 'why' }), - }), - ); - }); - - it('setReleaseRationale clears a rationale with a null humanText', async () => { - mockApiFetch.mockResolvedValue( - ok({ versionId: 'ver_abc', humanText: null }), - ); - await setReleaseRationale({ - projectId: 'proj_1', - flowId: 'flow_1', - versionId: 'ver_abc', - text: null, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/releases/annotations', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ versionId: 'ver_abc', humanText: null }), - }), - ); - }); - - it('listThreads sends includeMessages as the string the route parses', async () => { - mockApiFetch.mockResolvedValue(ok({ threads: [], hasMoreThreads: false })); - await listThreads({ - projectId: 'proj_1', - flowId: 'flow_1', - anchorType: 'release', - anchorKey: 'ver_abc', - status: 'open', - includeMessages: true, - limit: 20, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/threads?anchorType=release&anchorKey=ver_abc&status=open&includeMessages=true&limit=20', - ); - }); - - it('createThread POSTs the anchor and first message', async () => { - mockApiFetch.mockResolvedValue(ok({ id: 'thr_1' }, 201)); - await createThread({ - projectId: 'proj_1', - flowId: 'flow_1', - anchorType: 'step', - anchorKey: 'destination.ga4', - anchorLabel: 'GA4', - text: 'hi', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/threads', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - anchorType: 'step', - anchorKey: 'destination.ga4', - anchorLabel: 'GA4', - text: 'hi', - }), - }), - ); - }); - - it('addThreadMessage POSTs into the thread', async () => { - mockApiFetch.mockResolvedValue(ok({ id: 'thr_1' }, 201)); - await addThreadMessage({ - projectId: 'proj_1', - flowId: 'flow_1', - threadId: 'thr_1', - text: 'reply', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/flows/flow_1/threads/thr_1/messages', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ text: 'reply' }), - }), - ); - }); - - it('listKnowledge encodes the three narrowings and includeMessages', async () => { - mockApiFetch.mockResolvedValue(ok({ entries: [], hasMoreEntries: false })); - await listKnowledge({ - projectId: 'proj_1', - pageKey: 'https://shop.example/cart', - frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', - markId: 'm1', - includeMessages: true, - limit: 10, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/knowledge?pageKey=https%3A%2F%2Fshop.example%2Fcart&frameId=frm_V1StGXR8Z5jdHi6BmyT7K&markId=m1&includeMessages=true&limit=10', - ); - }); - - it('surfaces FEATURE_NOT_AVAILABLE with the feature-naming message', async () => { - mockApiFetch.mockResolvedValue( - ok( - { - error: { - code: 'FEATURE_NOT_AVAILABLE', - message: 'hub is not available on your current plan', - }, - }, - 403, - ), - ); - await expect( - listKnowledge({ projectId: 'proj_1', includeMessages: false }), - ).rejects.toMatchObject({ - code: 'FEATURE_NOT_AVAILABLE', - message: 'hub is not available on your current plan', - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/list-pagination-handlers.test.ts b/packages/cli/src/__tests__/unit/commands/list-pagination-handlers.test.ts deleted file mode 100644 index f53a53ef0..000000000 --- a/packages/cli/src/__tests__/unit/commands/list-pagination-handlers.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { apiFetch } from '../../../core/http.js'; -import { listProjectsCommand } from '../../../commands/projects/index.js'; -import { listFlowsCommand } from '../../../commands/flows/index.js'; -import { listDeploymentsCommand } from '../../../commands/deployments/index.js'; -import { setupMockApiClient } from '../../helpers/mock-api-client.js'; - -jest.mock('../../../core/api-client.js'); -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ - apiFetch: jest.fn(), -})); -jest.mock('../../../core/output.js', () => ({ - writeResult: jest.fn().mockResolvedValue(undefined), -})); - -const { GET: mockGet } = setupMockApiClient(); -const mockApiFetch = jest.mocked(apiFetch); - -describe('CLI list-command pagination forwarding', () => { - afterEach(() => jest.clearAllMocks()); - - describe('listProjectsCommand', () => { - it('forwards --cursor and --limit through to GET /api/projects', async () => { - mockGet.mockResolvedValue({ - data: { projects: [], total: 0, nextCursor: null }, - }); - - await listProjectsCommand({ cursor: 'c1', limit: 25 }); - - expect(mockGet).toHaveBeenCalledWith('/api/projects', { - params: { query: { cursor: 'c1', limit: 25 } }, - }); - }); - }); - - describe('listFlowsCommand', () => { - it('forwards --cursor and --limit through to GET /api/projects/{pid}/flows', async () => { - mockGet.mockResolvedValue({ data: { flows: [], total: 0 } }); - - await listFlowsCommand({ project: 'proj_1', cursor: 'fc1', limit: 50 }); - - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}/flows', { - params: { - path: { projectId: 'proj_1' }, - query: { - sort: undefined, - order: undefined, - include_deleted: undefined, - cursor: 'fc1', - limit: 50, - }, - }, - }); - }); - }); - - describe('listDeploymentsCommand', () => { - it('forwards --cursor and --limit through to /api/projects/{pid}/deployments', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deployments: [], total: 0 }), { - status: 200, - }), - ); - - await listDeploymentsCommand({ - project: 'proj_1', - cursor: 'dc1', - limit: 30, - }); - - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_1/deployments?cursor=dc1&limit=30', - ); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/commands/projects.test.ts b/packages/cli/src/__tests__/unit/commands/projects.test.ts deleted file mode 100644 index c391aa2bb..000000000 --- a/packages/cli/src/__tests__/unit/commands/projects.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { ApiError } from '../../../core/api-error.js'; -import { requireProjectId } from '../../../core/auth.js'; -import { - getDefaultProject, - clearDefaultProject, -} from '../../../lib/config-file.js'; -import { - listProjects, - getProject, - createProject, - updateProject, - deleteProject, -} from '../../../commands/projects/index.js'; -import { setupMockApiClient } from '../../helpers/mock-api-client.js'; - -jest.mock('../../../core/api-client.js'); -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../lib/config-file.js', () => ({ - ...jest.requireActual('../../../lib/config-file.js'), - getDefaultProject: jest.fn().mockReturnValue(null), - clearDefaultProject: jest.fn(), -})); - -const mockGetDefaultProject = jest.mocked(getDefaultProject); -const mockClearDefaultProject = jest.mocked(clearDefaultProject); - -const { - GET: mockGet, - POST: mockPost, - PATCH: mockPatch, - DELETE: mockDelete, -} = setupMockApiClient(); - -const mockRequireProjectId = jest.mocked(requireProjectId); - -describe('projects', () => { - afterEach(() => jest.clearAllMocks()); - - describe('listProjects', () => { - it('calls GET /api/projects', async () => { - mockGet.mockResolvedValue({ - data: { projects: [], total: 0, nextCursor: null }, - }); - const result = await listProjects(); - expect(mockGet).toHaveBeenCalledWith('/api/projects', { - params: { query: { cursor: undefined, limit: undefined } }, - }); - expect(result).toEqual({ projects: [], total: 0, nextCursor: null }); - }); - - it('forwards cursor and limit', async () => { - mockGet.mockResolvedValue({ - data: { projects: [], total: 0, nextCursor: null }, - }); - await listProjects({ cursor: 'abc', limit: 10 }); - expect(mockGet).toHaveBeenCalledWith('/api/projects', { - params: { query: { cursor: 'abc', limit: 10 } }, - }); - }); - - it('throws on error response', async () => { - mockGet.mockResolvedValue({ - error: { error: { message: 'Unauthorized' } }, - }); - await expect(listProjects()).rejects.toThrow('Unauthorized'); - }); - - it('carries the code of the refusal', async () => { - mockGet.mockResolvedValue({ - error: { - error: { - code: 'FORBIDDEN', - message: 'Requires member role or higher', - }, - }, - }); - const refusal = listProjects(); - await expect(refusal).rejects.toBeInstanceOf(ApiError); - await expect(refusal).rejects.toMatchObject({ - code: 'FORBIDDEN', - message: 'Requires member role or higher', - }); - }); - }); - - describe('getProject', () => { - it('uses provided projectId', async () => { - mockGet.mockResolvedValue({ data: { id: 'proj_123' } }); - await getProject({ projectId: 'proj_123' }); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}', { - params: { path: { projectId: 'proj_123' } }, - }); - }); - - it('falls back to requireProjectId()', async () => { - mockGet.mockResolvedValue({ data: { id: 'proj_default' } }); - await getProject(); - expect(mockRequireProjectId).toHaveBeenCalled(); - expect(mockGet).toHaveBeenCalledWith('/api/projects/{projectId}', { - params: { path: { projectId: 'proj_default' } }, - }); - }); - }); - - describe('createProject', () => { - it('POSTs with name', async () => { - mockPost.mockResolvedValue({ - data: { id: 'proj_new', name: 'Test' }, - }); - await createProject({ name: 'Test' }); - expect(mockPost).toHaveBeenCalledWith('/api/projects', { - body: { name: 'Test' }, - }); - }); - }); - - describe('updateProject', () => { - it('PATCHes with name', async () => { - mockPatch.mockResolvedValue({ - data: { id: 'proj_123', name: 'Updated' }, - }); - await updateProject({ projectId: 'proj_123', name: 'Updated' }); - expect(mockPatch).toHaveBeenCalledWith('/api/projects/{projectId}', { - params: { path: { projectId: 'proj_123' } }, - body: { name: 'Updated' }, - }); - }); - }); - - describe('deleteProject', () => { - it('sends DELETE and returns success', async () => { - mockDelete.mockResolvedValue({ data: undefined }); - const result = await deleteProject({ projectId: 'proj_123' }); - expect(mockDelete).toHaveBeenCalledWith('/api/projects/{projectId}', { - params: { path: { projectId: 'proj_123' } }, - }); - expect(result).toEqual({ success: true }); - }); - - it('clears the default when deleting it via an explicit projectId', async () => { - mockDelete.mockResolvedValue({ data: undefined }); - mockGetDefaultProject.mockReturnValue('proj_123'); - - await deleteProject({ projectId: 'proj_123' }); - - expect(mockClearDefaultProject).toHaveBeenCalledTimes(1); - }); - - it('clears the default when deleting it via requireProjectId fallback', async () => { - mockDelete.mockResolvedValue({ data: undefined }); - // No explicit projectId: resolved via requireProjectId() => 'proj_default'. - mockGetDefaultProject.mockReturnValue('proj_default'); - - await deleteProject(); - - expect(mockDelete).toHaveBeenCalledWith('/api/projects/{projectId}', { - params: { path: { projectId: 'proj_default' } }, - }); - expect(mockClearDefaultProject).toHaveBeenCalledTimes(1); - }); - - it('leaves the default intact when deleting a non-default project', async () => { - mockDelete.mockResolvedValue({ data: undefined }); - mockGetDefaultProject.mockReturnValue('proj_default'); - - await deleteProject({ projectId: 'proj_other' }); - - expect(mockClearDefaultProject).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/config/fetch-content.test.ts b/packages/cli/src/__tests__/unit/config/fetch-content.test.ts index 2005ccb35..bf993f9e6 100644 --- a/packages/cli/src/__tests__/unit/config/fetch-content.test.ts +++ b/packages/cli/src/__tests__/unit/config/fetch-content.test.ts @@ -1,4 +1,4 @@ -import { fetchContentString } from '../../../config/utils.js'; +import { fetchContentString, loadJsonConfig } from '../../../config/utils.js'; const APP_URL = 'https://app.example.test'; const TOKEN = 'tok_secret'; @@ -31,7 +31,7 @@ function recorder() { return { fetchFn, calls }; } -describe('fetchContentString auth scoping', () => { +describe('fetchContentString sends no credential', () => { const originalEnv = process.env; const originalFetch = globalThis.fetch; @@ -48,13 +48,13 @@ describe('fetchContentString auth scoping', () => { globalThis.fetch = originalFetch; }); - it('sends the bearer to the configured app origin', async () => { + it('sends no Authorization header to the app origin either', async () => { const { fetchFn, calls } = recorder(); globalThis.fetch = fetchFn; - await fetchContentString(`${APP_URL}/api/flows/fl_1/bundle.js`); + await fetchContentString(`${APP_URL}/api/flow-config/signed-abc`); - expect(calls[0]?.authorization).toBe(`Bearer ${TOKEN}`); + expect(calls[0]?.authorization).toBeUndefined(); }); it.each([ @@ -77,15 +77,17 @@ describe('fetchContentString auth scoping', () => { expect(calls[0]?.authorization).toBeUndefined(); }); - it('refuses to send the bearer to the app over plain http', async () => { + it('reads an app URL over plain http, since no credential goes along', async () => { const { fetchFn, calls } = recorder(); globalThis.fetch = fetchFn; process.env.WALKEROS_APP_URL = 'http://app.example.test'; - await expect( - fetchContentString('http://app.example.test/api/flows/fl_1/bundle.js'), - ).rejects.toThrow(/plain http/); - expect(calls).toHaveLength(0); + await fetchContentString( + 'http://app.example.test/api/flow-config/signed-abc', + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.authorization).toBeUndefined(); }); it('leaves a plain http URL that carries no bearer alone', async () => { @@ -106,3 +108,46 @@ describe('fetchContentString auth scoping', () => { ).resolves.toBe('{}'); }); }); + +describe('fetchContentString failures', () => { + const originalEnv = process.env; + const originalFetch = globalThis.fetch; + const URL_ = 'https://config.example.test/flow.json'; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WALKEROS_TOKEN; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + }); + + it.each([404, 403, 410])('names HTTP %i and the URL', async (status) => { + globalThis.fetch = async () => new Response('{}', { status }); + await expect(fetchContentString(URL_)).rejects.toThrow( + new RegExp(`^Failed to fetch ${URL_.replace(/\./g, '\\.')}: ${status}`), + ); + }); + + it('names an unreachable host with the network code', async () => { + globalThis.fetch = async () => { + throw Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + }), + }); + }; + await expect(fetchContentString(URL_)).rejects.toThrow( + `Could not reach ${URL_}: fetch failed (ECONNREFUSED)`, + ); + }); + + it('loadJsonConfig keeps a reach failure instead of calling it invalid JSON', async () => { + globalThis.fetch = async () => { + throw new TypeError('fetch failed'); + }; + await expect(loadJsonConfig(URL_)).rejects.toThrow(/^Could not reach /); + }); +}); diff --git a/packages/cli/src/__tests__/unit/core/api-client.test.ts b/packages/cli/src/__tests__/unit/core/api-client.test.ts deleted file mode 100644 index 6da5fc499..000000000 --- a/packages/cli/src/__tests__/unit/core/api-client.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createApiClient } from '../../../core/api-client.js'; -import { ApiError } from '../../../core/api-error.js'; -import { resolveAccessToken } from '../../../core/auth.js'; -import { resolveAppUrl } from '../../../lib/config-file.js'; - -jest.mock('../../../core/auth.js', () => ({ - resolveAccessToken: jest.fn(), -})); - -jest.mock('../../../lib/config-file.js', () => ({ - resolveAppUrl: jest.fn().mockReturnValue('https://app.walkeros.io'), -})); - -const mockResolveAccessToken = jest.mocked(resolveAccessToken); -const mockResolveAppUrl = jest.mocked(resolveAppUrl); - -describe('createApiClient', () => { - const originalFetch = global.fetch; - let sentAuthorization: Array; - - beforeEach(() => { - sentAuthorization = []; - global.fetch = jest.fn(async (input: RequestInfo | URL) => { - const request = input instanceof Request ? input : null; - sentAuthorization.push(request?.headers.get('authorization') ?? null); - return new Response(JSON.stringify({ projects: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }) as typeof fetch; - }); - - afterEach(() => { - global.fetch = originalFetch; - jest.clearAllMocks(); - }); - - it('creates a client with GET and POST methods', () => { - const client = createApiClient(); - expect(client).toBeDefined(); - expect(typeof client.GET).toBe('function'); - expect(typeof client.POST).toBe('function'); - expect(typeof client.PATCH).toBe('function'); - expect(typeof client.DELETE).toBe('function'); - }); - - it('does not resolve a token until a request is made', () => { - createApiClient(); - expect(mockResolveAccessToken).not.toHaveBeenCalled(); - }); - - it('attaches the resolved bearer to the outgoing request', async () => { - mockResolveAccessToken.mockResolvedValue('at_first'); - const client = createApiClient(); - - await client.GET('/api/projects'); - - expect(sentAuthorization).toEqual(['Bearer at_first']); - }); - - it('resolves the token per request, so a refresh reaches a long-lived client', async () => { - // The stdio MCP server builds one client and keeps it for hours. A token - // captured at construction would go stale and never recover. - mockResolveAccessToken - .mockResolvedValueOnce('at_first') - .mockResolvedValueOnce('at_refreshed'); - const client = createApiClient(); - - await client.GET('/api/projects'); - await client.GET('/api/projects'); - - expect(sentAuthorization).toEqual([ - 'Bearer at_first', - 'Bearer at_refreshed', - ]); - }); - - it('throws when no token resolves', async () => { - mockResolveAccessToken.mockResolvedValue(null); - const client = createApiClient(); - - const request = client.GET('/api/projects'); - await expect(request).rejects.toBeInstanceOf(ApiError); - await expect(request).rejects.toMatchObject({ - code: 'UNAUTHORIZED', - message: expect.stringContaining('Not authenticated'), - }); - }); - - it('refuses to send the bearer over plain http off the local machine', async () => { - mockResolveAccessToken.mockResolvedValue('at_first'); - mockResolveAppUrl.mockReturnValueOnce('http://app.walkeros.io'); - const client = createApiClient(); - - await expect(client.GET('/api/projects')).rejects.toThrow(/plain http/); - expect(sentAuthorization).toEqual([]); - }); - - it('allows a loopback app URL over plain http', async () => { - mockResolveAccessToken.mockResolvedValue('at_first'); - mockResolveAppUrl.mockReturnValueOnce('http://localhost:3000'); - const client = createApiClient(); - - await client.GET('/api/projects'); - - expect(sentAuthorization).toEqual(['Bearer at_first']); - }); -}); diff --git a/packages/cli/src/__tests__/unit/core/auth.test.ts b/packages/cli/src/__tests__/unit/core/auth.test.ts index b391a7795..13e41e785 100644 --- a/packages/cli/src/__tests__/unit/core/auth.test.ts +++ b/packages/cli/src/__tests__/unit/core/auth.test.ts @@ -5,7 +5,6 @@ import { resolveAccessToken, getAuthHeaders, credentialSource, - requireProjectId, resetLegacyTokenNotice, } from '../../../core/auth.js'; import { readConfig, writeConfig } from '../../../lib/config-file.js'; @@ -341,28 +340,4 @@ describe('core/auth', () => { expect(credentialSource()).toBeNull(); }); }); - - describe('requireProjectId', () => { - it('returns env var when set', () => { - process.env.WALKEROS_PROJECT_ID = 'proj-from-env'; - expect(requireProjectId()).toBe('proj-from-env'); - }); - - it('returns config defaultProjectId when env var not set', () => { - writeConfig({ defaultProjectId: 'proj-from-config' }); - expect(requireProjectId()).toBe('proj-from-config'); - }); - - it('prefers env var over config when both set', () => { - process.env.WALKEROS_PROJECT_ID = 'proj-from-env'; - writeConfig({ defaultProjectId: 'proj-from-config' }); - expect(requireProjectId()).toBe('proj-from-env'); - }); - - it('throws when neither env var nor config is set', () => { - expect(() => requireProjectId()).toThrow( - 'No project selected. Set WALKEROS_PROJECT_ID or configure a default project.', - ); - }); - }); }); diff --git a/packages/cli/src/__tests__/unit/core/http.test.ts b/packages/cli/src/__tests__/unit/core/http.test.ts deleted file mode 100644 index a9f8b6090..000000000 --- a/packages/cli/src/__tests__/unit/core/http.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - apiFetch, - publicFetch, - deployFetch, - mergeAuthHeaders, -} from '../../../core/http.js'; - -jest.mock('../../../lib/config-file.js', () => ({ - resolveAppUrl: jest.fn().mockReturnValue('https://stage.app.walkeros.io'), - resolveDeployToken: jest.fn().mockReturnValue(null), -})); - -jest.mock('../../../core/auth.js', () => ({ - resolveAccessToken: jest.fn().mockResolvedValue('test-token'), -})); - -import { resolveAppUrl, resolveDeployToken } from '../../../lib/config-file.js'; -import { resolveAccessToken } from '../../../core/auth.js'; - -const mockResolveAccessToken = jest.mocked(resolveAccessToken); -const mockResolveAppUrl = jest.mocked(resolveAppUrl); - -describe('core/http', () => { - const originalFetch = global.fetch; - let mockFetch: jest.Mock; - - beforeEach(() => { - mockFetch = jest.fn().mockResolvedValue({ ok: true }); - global.fetch = mockFetch; - mockResolveAccessToken.mockResolvedValue('test-token'); - }); - - afterEach(() => { - global.fetch = originalFetch; - jest.clearAllMocks(); - }); - - describe('apiFetch', () => { - it('prepends base URL and adds auth header', async () => { - await apiFetch('/api/feedback', { method: 'POST' }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://stage.app.walkeros.io/api/feedback', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer test-token', - }), - }), - ); - }); - - it('refuses to send the bearer over plain http off the local machine', async () => { - mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); - - await expect(apiFetch('/api/feedback')).rejects.toThrow(/plain http/); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('allows a loopback app URL over plain http', async () => { - mockResolveAppUrl.mockReturnValueOnce('http://localhost:3000'); - - await apiFetch('/api/feedback'); - - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/feedback', - expect.anything(), - ); - }); - - it('preserves existing headers', async () => { - await apiFetch('/api/test', { - headers: { 'X-Custom': 'value' }, - }); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['X-Custom']).toBe('value'); - expect(headers['Authorization']).toBe('Bearer test-token'); - }); - - it('sends no auth header when no token resolves', async () => { - mockResolveAccessToken.mockResolvedValue(null); - - await apiFetch('/api/test'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBeUndefined(); - }); - - it('awaits the resolved token rather than embedding the promise', async () => { - // A missed `await` would stringify a Promise into the header, which is a - // silent auth failure rather than a type error once spread into an object. - await apiFetch('/api/test'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - expect(String(headers['Authorization'])).not.toContain('Promise'); - }); - }); - - describe('publicFetch', () => { - it('prepends base URL without auth header', async () => { - await publicFetch('/api/oauth/device_authorization', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://stage.app.walkeros.io/api/oauth/device_authorization', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }), - ); - expect(mockResolveAccessToken).not.toHaveBeenCalled(); - }); - - it('carries no credential, so plain http is the caller\u2019s to choose', async () => { - mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); - - await publicFetch('/api/oauth/device_authorization'); - - expect(mockFetch).toHaveBeenCalledWith( - 'http://stage.app.walkeros.io/api/oauth/device_authorization', - expect.anything(), - ); - }); - }); - - describe('deployFetch', () => { - it('refuses to send the deploy token over plain http off the local machine', async () => { - jest.mocked(resolveDeployToken).mockReturnValueOnce('deploy-tok'); - mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); - - await expect(deployFetch('/api/projects/p1/x')).rejects.toThrow( - /plain http/, - ); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('uses deploy token when available', async () => { - jest.mocked(resolveDeployToken).mockReturnValueOnce('deploy-tok'); - - await deployFetch('/api/projects/p1/runners/heartbeat', { - method: 'POST', - }); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBe('Bearer deploy-tok'); - }); - - it('falls back to user token when no deploy token', async () => { - await deployFetch('/api/projects/p1/runners/heartbeat', { - method: 'POST', - }); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - }); - - it('throws when neither a deploy token nor a session resolves', async () => { - mockResolveAccessToken.mockResolvedValue(null); - - await expect( - deployFetch('/api/projects/p1/runners/heartbeat'), - ).rejects.toThrow('No authentication token available'); - }); - }); - - describe('mergeAuthHeaders', () => { - it('merges bearer token into existing headers', () => { - const result = mergeAuthHeaders('my-token', { - 'Content-Type': 'application/json', - }); - expect(result).toEqual({ - 'Content-Type': 'application/json', - Authorization: 'Bearer my-token', - }); - }); - - it('returns headers without auth when token is null', () => { - const result = mergeAuthHeaders(null, { - 'Content-Type': 'application/json', - }); - expect(result).toEqual({ 'Content-Type': 'application/json' }); - }); - - it('returns empty object when no token and no headers', () => { - const result = mergeAuthHeaders(null); - expect(result).toEqual({}); - }); - }); -}); diff --git a/packages/cli/src/__tests__/unit/core/oauth-client.test.ts b/packages/cli/src/__tests__/unit/core/oauth-client.test.ts index 62cbcb565..448a4f839 100644 --- a/packages/cli/src/__tests__/unit/core/oauth-client.test.ts +++ b/packages/cli/src/__tests__/unit/core/oauth-client.test.ts @@ -6,6 +6,10 @@ import { refreshTokens, revokeRefreshToken, } from '../../../core/oauth-client.js'; +import { + resetClientContext, + setClientContext, +} from '../../../core/client-context.js'; const APP_URL = 'https://app.example.test'; @@ -89,6 +93,41 @@ function tokenBody(overrides: Record = {}) { } describe('oauth-client', () => { + it('sends the client context on every OAuth request', async () => { + setClientContext({ type: 'cli', version: '9.9.9' }); + const seen: Headers[] = []; + const fetchFn: typeof fetch = async (_input, init) => { + seen.push(new Headers(init?.headers)); + return jsonResponse({ error: 'authorization_pending' }, 400); + }; + await pollDeviceToken(APP_URL, 'dev_1', fetchFn); + await refreshTokens(APP_URL, 'rt_1', fetchFn).catch(() => undefined); + await revokeRefreshToken(APP_URL, 'rt_1', fetchFn); + expect(seen.map((h) => h.get('X-WalkerOS-Client'))).toEqual([ + 'cli', + 'cli', + 'cli', + ]); + resetClientContext(); + }); + + it('names the upgrade an outdated client needs', async () => { + const fetchFn: typeof fetch = async () => + jsonResponse( + { + error: { + code: 'CLIENT_OUTDATED', + message: + 'This endpoint requires @walkeros/cli >= 4.10.0 (you are on 4.9.0).', + }, + }, + 426, + ); + await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( + '>= 4.10.0', + ); + }); + describe('startDeviceAuthorization', () => { it('posts a form-encoded client_id, scope and api resource', async () => { const { fetchFn, calls } = recorder(() => diff --git a/packages/cli/src/__tests__/unit/core/sse.test.ts b/packages/cli/src/__tests__/unit/core/sse.test.ts deleted file mode 100644 index 3230953d5..000000000 --- a/packages/cli/src/__tests__/unit/core/sse.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { parseSSEEvents } from '../../../core/sse.js'; - -describe('parseSSEEvents', () => { - it('parses a single complete event', () => { - const buffer = 'event: status\ndata: {"status":"bundling"}\n\n'; - const result = parseSSEEvents(buffer); - - expect(result.parsed).toEqual([ - { type: 'status', data: '{"status":"bundling"}' }, - ]); - expect(result.remainder).toBe(''); - }); - - it('parses multiple events', () => { - const buffer = - 'event: status\ndata: {"status":"bundling"}\n\n' + - 'event: status\ndata: {"status":"published"}\n\n' + - 'event: done\ndata: {}\n\n'; - - const result = parseSSEEvents(buffer); - - expect(result.parsed).toHaveLength(3); - expect(result.parsed[0]).toEqual({ - type: 'status', - data: '{"status":"bundling"}', - }); - expect(result.parsed[1]).toEqual({ - type: 'status', - data: '{"status":"published"}', - }); - expect(result.parsed[2]).toEqual({ type: 'done', data: '{}' }); - expect(result.remainder).toBe(''); - }); - - it('keeps incomplete block as remainder', () => { - const buffer = 'event: status\ndata: {"status":"bundling"}\n\nevent: sta'; - const result = parseSSEEvents(buffer); - - expect(result.parsed).toHaveLength(1); - expect(result.parsed[0]).toEqual({ - type: 'status', - data: '{"status":"bundling"}', - }); - expect(result.remainder).toBe('event: sta'); - }); - - it('defaults event type to message when no event field', () => { - const buffer = 'data: hello world\n\n'; - const result = parseSSEEvents(buffer); - - expect(result.parsed).toEqual([{ type: 'message', data: 'hello world' }]); - }); - - it('joins multi-line data fields', () => { - const buffer = 'event: log\ndata: line1\ndata: line2\ndata: line3\n\n'; - const result = parseSSEEvents(buffer); - - expect(result.parsed).toEqual([ - { type: 'log', data: 'line1\nline2\nline3' }, - ]); - }); - - it('ignores comment lines', () => { - const buffer = ': heartbeat\n\n'; - const result = parseSSEEvents(buffer); - - expect(result.parsed).toHaveLength(0); - expect(result.remainder).toBe(''); - }); - - it('ignores retry and id fields', () => { - const buffer = 'retry: 3000\n\nevent: status\nid: 1\ndata: {"ok":true}\n\n'; - const result = parseSSEEvents(buffer); - - // retry block has no data lines, so it's skipped - expect(result.parsed).toHaveLength(1); - expect(result.parsed[0]).toEqual({ - type: 'status', - data: '{"ok":true}', - }); - }); - - it('returns empty parsed for empty buffer', () => { - const result = parseSSEEvents(''); - expect(result.parsed).toHaveLength(0); - expect(result.remainder).toBe(''); - }); - - it('returns empty parsed with only whitespace blocks', () => { - const buffer = '\n\n\n\n'; - const result = parseSSEEvents(buffer); - expect(result.parsed).toHaveLength(0); - }); - - it('handles incremental buffer accumulation', () => { - // Simulate streaming: first chunk is incomplete - let buffer = 'event: status\ndata: {"st'; - let result = parseSSEEvents(buffer); - expect(result.parsed).toHaveLength(0); - expect(result.remainder).toBe('event: status\ndata: {"st'); - - // Second chunk completes the event - buffer = result.remainder + 'atus":"active"}\n\n'; - result = parseSSEEvents(buffer); - expect(result.parsed).toHaveLength(1); - expect(result.parsed[0]).toEqual({ - type: 'status', - data: '{"status":"active"}', - }); - expect(result.remainder).toBe(''); - }); - - it('trims leading space from data values', () => { - const buffer = 'data: spaced value\n\n'; - const result = parseSSEEvents(buffer); - expect(result.parsed[0]?.data).toBe('spaced value'); - }); -}); diff --git a/packages/cli/src/__tests__/unit/entries-cli-free.test.ts b/packages/cli/src/__tests__/unit/entries-cli-free.test.ts new file mode 100644 index 000000000..2a9b33b94 --- /dev/null +++ b/packages/cli/src/__tests__/unit/entries-cli-free.test.ts @@ -0,0 +1,44 @@ +/** + * The CLI-free entries load in a server process: the app registers the shared + * MCP tool specs through them. A throwing module factory stands in for the + * bundler and the package manager, so an entry that reaches either, at import + * or during a run, fails here. + */ +jest.mock('esbuild', () => { + throw new Error('esbuild was loaded'); +}); +jest.mock('pacote', () => { + throw new Error('pacote was loaded'); +}); + +const FLOW = { + version: 4, + flows: { + default: { + config: { platform: 'web' }, + sources: {}, + destinations: {}, + }, + }, +}; + +describe('CLI-free entries', () => { + it('@walkeros/cli/validate validates a flow without esbuild or pacote', async () => { + const entry = await import('../../validate.js'); + const result = await entry.validate('flow', FLOW, { offline: true }); + expect(result.errors).toEqual([]); + expect(entry.VALIDATE_CODES.length).toBeGreaterThan(0); + }); + + it('@walkeros/cli/dev carries the example helpers without esbuild or pacote', async () => { + const entry = await import('../../dev.js'); + expect(typeof entry.resolveExportName).toBe('function'); + expect(typeof entry.selectDevExamples).toBe('function'); + }); + + it('the guard is live: the bundler trips it', async () => { + await expect(import('../../commands/bundle/bundler.js')).rejects.toThrow( + 'esbuild was loaded', + ); + }); +}); diff --git a/packages/cli/src/__tests__/unit/examples/flow-complete.test.ts b/packages/cli/src/__tests__/unit/examples/flow-complete.test.ts index fe3423efa..c8661b6d8 100644 --- a/packages/cli/src/__tests__/unit/examples/flow-complete.test.ts +++ b/packages/cli/src/__tests__/unit/examples/flow-complete.test.ts @@ -187,7 +187,9 @@ describe('flow-complete manifest', () => { it('teaches only real commands', () => { for (const { command } of flowCompleteCli) { expect(command).toMatch(/^(walkeros|runneros) /); - expect(command).not.toMatch(/^walkeros (simulate|run) /); + expect(command).not.toMatch( + /^walkeros (simulate|run|projects|flows|deploy|deployments|previews|secrets|observe|journeys|hub|frames|feedback) /, + ); } }); @@ -377,7 +379,15 @@ describe('flow-complete.md', () => { it('uses only real commands', () => { expect(lines.length).toBeGreaterThan(0); for (const line of lines) - expect(line).not.toMatch(/^walkeros (simulate|run) /); + expect(line).not.toMatch( + /^walkeros (simulate|run|projects|flows|deploy|deployments|previews|secrets|observe|journeys|hub|frames|feedback) /, + ); + }); + + it('teaches no removed MCP tool', () => { + expect(guide).not.toMatch( + /\b(project_manage|flow_manage|deploy_manage|observe_session)\b/, + ); }); it.each(lines.filter((line) => line.includes('--simulate')))( diff --git a/packages/cli/src/__tests__/unit/lib/config-file.test.ts b/packages/cli/src/__tests__/unit/lib/config-file.test.ts index feab23a3a..c16020018 100644 --- a/packages/cli/src/__tests__/unit/lib/config-file.test.ts +++ b/packages/cli/src/__tests__/unit/lib/config-file.test.ts @@ -12,248 +12,22 @@ import { readConfig, writeConfig, writeTelemetryOnlyConfig, - setDefaultProject, - getDefaultProject, - resolveProjectId, hasStoredCredential, - clearDefaultProject, - setFeedbackPreference, - getFeedbackPreference, getConfigPath, type WalkerOSConfig, } from '../../../lib/config-file.js'; const testDir = join(tmpdir(), `config-file-test-${Date.now()}`); -const baseConfig = { - token: 'sk-test-123', - email: 'test@example.com', - appUrl: 'https://app.walkeros.io', -}; - -describe('config-file feedbackPreference', () => { - const originalEnv = process.env; - - beforeEach(() => { - process.env = { ...originalEnv, XDG_CONFIG_HOME: testDir }; - mkdirSync(join(testDir, 'walkeros'), { recursive: true }); - }); - - afterEach(() => { - process.env = originalEnv; - rmSync(testDir, { recursive: true, force: true }); - }); - - describe('setFeedbackPreference', () => { - it('writes preference to existing config', () => { - writeConfig(baseConfig); - setFeedbackPreference(true); - - const raw = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw.anonymousFeedback).toBe(true); - }); - - it('does nothing when no config exists', () => { - setFeedbackPreference(false); - - expect(readConfig()).toBeNull(); - }); - }); - - describe('getFeedbackPreference', () => { - it('returns stored preference', () => { - writeConfig({ ...baseConfig, anonymousFeedback: false }); - expect(getFeedbackPreference()).toBe(false); - }); - - it('returns undefined when not set', () => { - writeConfig(baseConfig); - expect(getFeedbackPreference()).toBeUndefined(); - }); - }); -}); - -describe('config-file defaultProject', () => { - const originalEnv = process.env; - - beforeEach(() => { - // Redirect XDG_CONFIG_HOME so getConfigDir() points to our temp dir. - // getConfigDir returns join(XDG_CONFIG_HOME, 'walkeros'), so we set - // XDG_CONFIG_HOME to testDir and the config lands in testDir/walkeros/. - process.env = { ...originalEnv, XDG_CONFIG_HOME: testDir }; - // The container may export both; every case states its own env. - delete process.env.WALKEROS_TOKEN; - delete process.env.WALKEROS_PROJECT_ID; - mkdirSync(join(testDir, 'walkeros'), { recursive: true }); - }); - - afterEach(() => { - process.env = originalEnv; - rmSync(testDir, { recursive: true, force: true }); - }); - - describe('setDefaultProject', () => { - it('writes projectId to config', () => { - writeConfig(baseConfig); - setDefaultProject('proj_abc'); - - const raw = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw.defaultProjectId).toBe('proj_abc'); - }); - - it('preserves existing config fields', () => { - writeConfig({ ...baseConfig, anonymousFeedback: true }); - setDefaultProject('proj_xyz'); - - const raw = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw.token).toBe(baseConfig.token); - expect(raw.email).toBe(baseConfig.email); - expect(raw.appUrl).toBe(baseConfig.appUrl); - expect(raw.anonymousFeedback).toBe(true); - expect(raw.defaultProjectId).toBe('proj_xyz'); - }); - - it('throws when not authenticated (no config)', () => { - expect(() => setDefaultProject('proj_abc')).toThrow( - expect.objectContaining({ - code: 'UNAUTHORIZED', - message: expect.stringContaining('Not authenticated'), - }), - ); - }); - - it('throws for a config that holds no credential (telemetry only)', () => { - writeTelemetryOnlyConfig({ telemetryEnabled: false }); - expect(() => setDefaultProject('proj_abc')).toThrow( - expect.objectContaining({ code: 'UNAUTHORIZED' }), - ); - expect(readConfig()?.defaultProjectId).toBeUndefined(); - }); - - it('accepts an OAuth login (accessToken, no static token)', () => { - writeConfig({ - accessToken: 'at_1', - accessTokenExpiresAt: '2026-10-05T00:00:00.000Z', - refreshToken: 'rt_1', - }); - setDefaultProject('proj_oauth'); - expect(readConfig()?.defaultProjectId).toBe('proj_oauth'); - expect(readConfig()?.refreshToken).toBe('rt_1'); - }); - - it('creates a config holding only the default for an env-only token', () => { - rmSync(join(testDir, 'walkeros'), { recursive: true, force: true }); - process.env.WALKEROS_TOKEN = 'wos_pat_env'; - setDefaultProject('proj_env'); - - expect(JSON.parse(readFileSync(getConfigPath(), 'utf-8'))).toEqual({ - defaultProjectId: 'proj_env', - }); - expect(statSync(getConfigPath()).mode & 0o777).toBe(0o600); - expect(statSync(join(testDir, 'walkeros')).mode & 0o777).toBe(0o700); - }); - - it('surfaces a config directory it cannot write as an error', () => { - process.env.WALKEROS_TOKEN = 'wos_pat_env'; - // A file where the config directory belongs: the write cannot succeed. - rmSync(join(testDir, 'walkeros'), { recursive: true, force: true }); - writeFileSync(join(testDir, 'walkeros'), 'not a directory'); - // The filesystem error itself, not an UNAUTHORIZED refusal. - expect(() => setDefaultProject('proj_env')).toThrow( - expect.objectContaining({ - code: expect.stringMatching(/^E(EXIST|NOTDIR)$/), - }), - ); - }); - - it('refuses while WALKEROS_PROJECT_ID names another project', () => { - writeConfig(baseConfig); - process.env.WALKEROS_PROJECT_ID = 'proj_pinned'; - expect(() => setDefaultProject('proj_other')).toThrow( - /WALKEROS_PROJECT_ID is set to proj_pinned/, - ); - expect(readConfig()?.defaultProjectId).toBeUndefined(); - }); - - it('accepts the project WALKEROS_PROJECT_ID already names', () => { - writeConfig(baseConfig); - process.env.WALKEROS_PROJECT_ID = 'proj_pinned'; - setDefaultProject('proj_pinned'); - expect(readConfig()?.defaultProjectId).toBe('proj_pinned'); - }); - }); - - describe('hasStoredCredential', () => { - const cases: Array<[string, WalkerOSConfig | null, boolean]> = [ - ['a legacy static token', { token: 'sk' }, true], - ['an OAuth access token', { accessToken: 'at' }, true], - ['a telemetry-only config', { telemetryEnabled: true }, false], - ['no config', null, false], - ]; - it.each(cases)('%s', (_label, config, expected) => { - expect(hasStoredCredential(config)).toBe(expected); - }); - }); - - describe('resolveProjectId', () => { - it('prefers WALKEROS_PROJECT_ID over the config default', () => { - writeConfig({ ...baseConfig, defaultProjectId: 'proj_config' }); - process.env.WALKEROS_PROJECT_ID = 'proj_env'; - expect(resolveProjectId()).toBe('proj_env'); - }); - - it('falls back to the config default, then null', () => { - expect(resolveProjectId()).toBeNull(); - writeConfig({ ...baseConfig, defaultProjectId: 'proj_config' }); - expect(resolveProjectId()).toBe('proj_config'); - }); - }); - - describe('getDefaultProject', () => { - it('returns stored projectId', () => { - writeConfig({ ...baseConfig, defaultProjectId: 'proj_abc' }); - expect(getDefaultProject()).toBe('proj_abc'); - }); - - it('returns null when not set', () => { - writeConfig(baseConfig); - expect(getDefaultProject()).toBeNull(); - }); - - it('returns null when no config exists', () => { - expect(getDefaultProject()).toBeNull(); - }); - }); - - describe('clearDefaultProject', () => { - it('removes defaultProjectId from config', () => { - writeConfig({ ...baseConfig, defaultProjectId: 'proj_abc' }); - clearDefaultProject(); - - const raw = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw.defaultProjectId).toBeUndefined(); - expect(getDefaultProject()).toBeNull(); - }); - - it('preserves other config fields', () => { - writeConfig({ - ...baseConfig, - anonymousFeedback: true, - defaultProjectId: 'proj_abc', - }); - clearDefaultProject(); - - const raw = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); - expect(raw.token).toBe(baseConfig.token); - expect(raw.email).toBe(baseConfig.email); - expect(raw.anonymousFeedback).toBe(true); - expect(raw.defaultProjectId).toBeUndefined(); - }); - - it('does nothing when no config exists', () => { - clearDefaultProject(); - expect(readConfig()).toBeNull(); - }); +describe('hasStoredCredential', () => { + const cases: Array<[string, WalkerOSConfig | null, boolean]> = [ + ['a legacy static token', { token: 'sk' }, true], + ['an OAuth access token', { accessToken: 'at' }, true], + ['a telemetry-only config', { telemetryEnabled: true }, false], + ['no config', null, false], + ]; + it.each(cases)('%s', (_label, config, expected) => { + expect(hasStoredCredential(config)).toBe(expected); }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 17185a200..db1c3fe27 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -15,40 +15,14 @@ import { validateCommand } from './commands/validate/index.js'; import { registerCacheCommand } from './commands/cache.js'; import { loginCommand } from './commands/login/index.js'; import { logoutCommand } from './commands/logout/index.js'; -import { whoamiCommand } from './commands/auth/index.js'; -import { - listProjectsCommand, - getProjectCommand, - createProjectCommand, - updateProjectCommand, - deleteProjectCommand, -} from './commands/projects/index.js'; -import { - listFlowsCommand, - getFlowCommand, - createFlowCommand, - updateFlowCommand, - deleteFlowCommand, - duplicateFlowCommand, -} from './commands/flows/index.js'; -import { - deployCommand, - getDeploymentCommand, -} from './commands/deploy/index.js'; -import { - createDeployCommand, - listDeploymentsCommand, - deleteDeploymentCommand, - getDeploymentBySlugCommand, -} from './commands/deployments/index.js'; -import { feedbackCommand } from './commands/feedback/index.js'; +import { authTokenCommand, whoamiCommand } from './commands/auth/index.js'; +import { registerApiCommand } from './commands/api/index.js'; import { diagnosticsCommand } from './commands/diagnostics/index.js'; import { telemetryStatusCommand, telemetryEnableCommand, telemetryDisableCommand, } from './commands/telemetry/index.js'; -import { writeResult } from './core/output.js'; setClientContext({ type: 'cli', version: VERSION }); @@ -221,7 +195,7 @@ program // Setup command // Note: positional arg is `` (component selector like // `destination.NAME`), not `[file]`. The flow config path comes from -// `-c/--config`, mirroring how `deploy start ` selects an entity. +// `-c/--config`. program .command('setup ') .description( @@ -335,396 +309,27 @@ authCmd }); }); -// Projects command group -const projectsCmd = program - .command('projects') - .description('Manage walkerOS projects'); - -projectsCmd - .command('list') - .description('List all projects') - .option('--cursor ', 'cursor for next page (from previous response)') - .option('--limit ', 'page size', parseInt) - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (options) => { - await listProjectsCommand(options); - }); - -projectsCmd - .command('get [projectId]') - .description('Get project details') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (projectId, options) => { - await getProjectCommand(projectId, options); - }); - -projectsCmd - .command('create ') - .description('Create a new project') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (name, options) => { - await createProjectCommand(name, options); - }); - -projectsCmd - .command('update [projectId]') - .description('Update a project') - .requiredOption('--name ', 'new project name') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (projectId, options) => { - await updateProjectCommand(projectId, options); - }); - -projectsCmd - .command('delete [projectId]') - .description('Delete a project') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (projectId, options) => { - await deleteProjectCommand(projectId, options); - }); - -// Flows command group -const flowsCmd = program.command('flows').description('Manage walkerOS flows'); - -flowsCmd - .command('list') - .description('List all flows in a project') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('--sort ', 'sort by: name, updated_at, created_at') - .option('--order ', 'sort order: asc, desc') - .option('--include-deleted', 'include soft-deleted flows') - .option('--cursor ', 'cursor for next page (from previous response)') - .option('--limit ', 'page size', parseInt) - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (options) => { - await listFlowsCommand(options); - }); - -flowsCmd - .command('get ') - .description('Get a flow with its full content') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (flowId, options) => { - await getFlowCommand(flowId, options); - }); - -flowsCmd - .command('create ') - .description('Create a new flow') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('-c, --content ', 'Flow.Json JSON string, file path or URL') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (name, options) => { - await createFlowCommand(name, options); - }); - -flowsCmd - .command('update ') - .description('Update a flow') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('--name ', 'new flow name') - .option( - '-c, --content ', - 'new Flow.Json JSON string, file path or URL', - ) - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (flowId, options) => { - await updateFlowCommand(flowId, options); - }); - -flowsCmd - .command('delete ') - .description('Delete a flow and its frames') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (flowId, options) => { - await deleteFlowCommand(flowId, options); - }); - -flowsCmd - .command('duplicate ') - .description('Duplicate a flow') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('--name ', 'name for the copy') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (flowId, options) => { - await duplicateFlowCommand(flowId, options); - }); - -// Unified deploy command group -const deployCmd = program - .command('deploy') - .description('Create, manage, and deploy flows'); - -// deploy create [config] - placeholder until Task 2 -deployCmd - .command('create [config]') - .description( - 'Create a deployment (infers type from flow config or remote flow)', - ) - .option('--label ', 'human-readable label') - .option('-f, --flow ', 'flow name for multi-flow configs') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (config, options) => { - await createDeployCommand(config, options); - }); - -// deploy start - existing cloud deploy (unchanged logic) -deployCmd - .command('start ') - .description('Deploy a flow to walkerOS cloud (auto-detects web or server)') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('-f, --flow ', 'flow name for multi-config flows') - .option('--no-wait', 'do not wait for deployment to complete') - .option( - '--timeout ', - 'timeout in seconds for deployment polling (default: 720, the 12-minute server deploy budget)', - ) - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (flowId, options) => { - await deployCommand(flowId, options); - }); - -// deploy list -deployCmd - .command('list') - .description('List all deployments in a project') - .option('--project ', 'project ID (defaults to WALKEROS_PROJECT_ID)') - .option('--type ', 'filter by type: web, server') - .option('--status ', 'filter by status') - .option('--cursor ', 'cursor for next page (from previous response)') - .option('--limit ', 'page size', parseInt) - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (options) => { - await listDeploymentsCommand(options); - }); - -// deploy status -deployCmd - .command('status ') - .description('Get deployment details by ID or slug') - .option('--project ', 'project ID') - .option('-o, --output ', 'output file path') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (idOrSlug, options) => { - await getDeploymentBySlugCommand(idOrSlug, options); - }); - -// deploy delete -deployCmd - .command('delete ') - .description('Delete a deployment') - .option('--project ', 'project ID') - .option('--json', 'output as JSON') - .option('-v, --verbose', 'verbose output') - .option('-s, --silent', 'suppress output') - .action(async (idOrSlug, options) => { - await deleteDeploymentCommand(idOrSlug, options); - }); - -// ── Previews ───────────────────────────────────────────────────────────── -const previewsCmd = program - .command('previews') - .description('Manage preview bundles for testing flow changes on live sites'); - -previewsCmd - .command('list ') - .description('List previews for a flow') - .option('--project ', 'Project ID (overrides default)') - .action(async (flowId, options) => { - try { - const { listPreviews } = await import('./commands/previews/index.js'); - const result = (await listPreviews({ - projectId: options.project, - flowId, - })) as { previews: unknown }; - await writeResult(JSON.stringify(result.previews, null, 2), {}); - } catch (err) { - handleCliError(err); - } - }); - -previewsCmd - .command('get ') - .description('Get preview details') - .option('--project ', 'Project ID (overrides default)') - .action(async (flowId, previewId, options) => { - try { - const { getPreview } = await import('./commands/previews/index.js'); - const result = await getPreview({ - projectId: options.project, - flowId, - previewId, - }); - await writeResult(JSON.stringify(result, null, 2), {}); - } catch (err) { - handleCliError(err); - } - }); - -previewsCmd - .command('create ') - .description('Create a preview bundle for a flow settings entry') - .option('-f, --flow ', 'Flow settings name (resolved to ID)') - .option('-s, --settings-id ', 'Flow settings ID (alternative to --flow)') - .option('-u, --url ', 'Site URL to construct activation URL') - .option('--project ', 'Project ID (overrides default)') - .action(async (flowId, options) => { - try { - // Validate --url BEFORE creating the preview - otherwise an invalid URL - // would produce a server-side preview that can't be used, wasting a - // quota slot and forcing manual cleanup. - if (options.url) { - try { - new URL(options.url); - } catch { - throw new Error(`Invalid --url value: ${options.url}`); - } - } - const { createPreview } = await import('./commands/previews/index.js'); - const { printPreviewCreated } = - await import('./commands/previews/output.js'); - const preview = await createPreview({ - projectId: options.project, - flowId, - flowName: options.flow, - flowSettingsId: options.settingsId, - // Pass the target URL so the server re-mints an origin-bound grant for - // it; the activation URL is app-signed and cannot be built client-side. - url: options.url, - }); - printPreviewCreated(preview, { url: options.url }); - } catch (err) { - handleCliError(err); - } - }); - -previewsCmd - .command('delete ') - .description('Delete a preview') - .option('-y, --yes', 'Skip confirmation prompt') - .option('--project ', 'Project ID (overrides default)') - .action(async (flowId, previewId, options) => { - try { - if (!options.yes) { - throw new Error('Confirmation required. Use --yes to skip.'); - } - const { deletePreview } = await import('./commands/previews/index.js'); - await deletePreview({ - projectId: options.project, - flowId, - previewId, - }); - process.stderr.write(`Deleted ${previewId}\n`); - } catch (err) { - handleCliError(err); - } +authCmd + .command('token') + .description('Print the current access token, for any HTTP client') + .action(async () => { + process.exitCode = await authTokenCommand(); }); -// Observe command group -const observeCmd = program - .command('observe') - .description('Observe live events flowing through a flow'); - -observeCmd - .command('start ') - .description( - 'Start an observe session: mints it via the app and prints the web and server attach info', - ) - .option( - '-f, --flow ', - 'Flow settings name (auto-resolved when the flow has exactly one)', - ) - .option('--project ', 'Project ID (overrides default)') - .option( - '--level ', - 'Observation detail level: off, standard, or trace', - ) - .option('--replace', 'Replace an existing active session for this flow') - .option('--no-wait', 'Do not wait for the session to settle') - .option( - '--timeout ', - 'Seconds to wait for the session to settle (default: 300)', - ) - .option('--json', 'Output the session as JSON') - .action(async (flowId, options) => { - const { observeStartCommand } = await import('./commands/observe/index.js'); - await observeStartCommand(flowId, { - project: options.project, - flow: options.flow, - level: options.level, - replace: options.replace, - wait: options.wait, - timeout: options.timeout, - json: options.json, - }); - }); +// Any app API operation, by method and path +registerApiCommand(program); // Diagnostics command program .command('diagnostics') .description( - 'Show the CLI version, the app URL and its source, app health, and the API contract verdict against that app', + 'Show the CLI version, the app URL and its source, and whether the app answers', ) .option('--json', 'output as JSON') .action(async (options) => { await diagnosticsCommand({ json: options.json }); }); -// Feedback command -program - .command('feedback ') - .description('Send feedback to walkerOS') - .action(async (text) => { - await feedbackCommand(text); - }); - // Telemetry command group const telemetryCmd = program .command('telemetry') @@ -751,7 +356,7 @@ registerCacheCommand(program); // Telemetry hooks - populate cmdPath/cmdStart for action-bound commands. program.hook('preAction', (_thisCmd, actionCmd) => { cmdStart = Date.now(); - // Space-joined command path: `walkeros projects list` → "projects list". + // Space-joined command path: `walkeros auth login` → "auth login". const parts: string[] = []; let cur: Command | null = actionCmd; while (cur && cur.name() !== 'walkeros') { @@ -763,9 +368,10 @@ program.hook('preAction', (_thisCmd, actionCmd) => { program.hook('postAction', async () => { try { + // A command that reports failure through the exit code returns normally. await emitter.send( 'cmd invoke', - { command: cmdPath, outcome: 'success' }, + { command: cmdPath, outcome: process.exitCode ? 'error' : 'success' }, Date.now() - cmdStart, { command: cmdPath }, ); diff --git a/packages/cli/src/commands/api/__tests__/api-ls.test.ts b/packages/cli/src/commands/api/__tests__/api-ls.test.ts new file mode 100644 index 000000000..e25ecdadb --- /dev/null +++ b/packages/cli/src/commands/api/__tests__/api-ls.test.ts @@ -0,0 +1,166 @@ +import '../../../__tests__/helpers/setup-msw.js'; +import { server } from '../../../__tests__/helpers/msw-server.js'; +import { http, HttpResponse } from 'msw'; +import { Writable } from 'node:stream'; +import { + EXIT, + filterOperations, + listOperations, + referencedComponents, + runApiList, +} from '../index.js'; + +const APP = 'https://app.example.test'; + +const DOCUMENT = { + openapi: '3.1.0', + info: { title: 'walkerOS', version: 'public' }, + paths: { + '/api/projects/{projectId}/flows/{flowId}': { + parameters: [{ name: 'flowId', in: 'path' }], + get: { + summary: 'Get flow', + responses: { + '200': { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Flow' }, + }, + }, + }, + }, + }, + }, + '/api/health': { get: { summary: 'Health' } }, + '/api/projects/{projectId}/flows/{flowId}/deploy': { + post: { summary: 'Deploy flow' }, + }, + }, + components: { + schemas: { + Flow: { + type: 'object', + properties: { config: { $ref: '#/components/schemas/Config' } }, + }, + Config: { type: 'object' }, + Unused: { type: 'string' }, + }, + }, +}; + +function sink() { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk: Buffer, _e, done) { + chunks.push(chunk.toString('utf-8')); + done(); + }, + }); + return { stream, text: () => chunks.join('') }; +} + +const env = process.env; +beforeEach(() => { + process.env = { ...env, WALKEROS_APP_URL: APP }; +}); +afterEach(() => { + process.env = env; +}); + +describe('listOperations and filterOperations', () => { + it('lists method operations only, sorted by path then method', () => { + expect( + listOperations(DOCUMENT).map((o) => `${o.method} ${o.path}`), + ).toEqual([ + 'GET /api/health', + 'GET /api/projects/{projectId}/flows/{flowId}', + 'POST /api/projects/{projectId}/flows/{flowId}/deploy', + ]); + }); + + it.each([ + ['deploy', ['POST /api/projects/{projectId}/flows/{flowId}/deploy']], + ['GET FLOW', ['GET /api/projects/{projectId}/flows/{flowId}']], + [ + undefined, + [ + 'GET /api/health', + 'GET /api/projects/{projectId}/flows/{flowId}', + 'POST /api/projects/{projectId}/flows/{flowId}/deploy', + ], + ], + ])('filters by %s, case-insensitive', (filter, expected) => { + expect( + filterOperations(listOperations(DOCUMENT), filter).map( + (o) => `${o.method} ${o.path}`, + ), + ).toEqual(expected); + }); + + it('follows component references transitively and keeps nothing else', () => { + const ops = filterOperations(listOperations(DOCUMENT), 'get flow'); + expect(referencedComponents(DOCUMENT, ops)).toEqual({ + schemas: { + Flow: DOCUMENT.components.schemas.Flow, + Config: DOCUMENT.components.schemas.Config, + }, + }); + }); +}); + +describe('runApiList', () => { + it('prints one line per matching operation from the live document', async () => { + server.use( + http.get(`${APP}/api/openapi.json`, () => HttpResponse.json(DOCUMENT)), + ); + const out = sink(); + const err = sink(); + expect( + await runApiList( + { filter: 'flow' }, + { stdout: out.stream, stderr: err.stream }, + ), + ).toBe(EXIT.ok); + expect(out.text()).toBe( + 'GET /api/projects/{projectId}/flows/{flowId} Get flow\n' + + 'POST /api/projects/{projectId}/flows/{flowId}/deploy Deploy flow\n', + ); + }); + + it('prints operations and their referenced components with --json', async () => { + server.use( + http.get(`${APP}/api/openapi.json`, () => HttpResponse.json(DOCUMENT)), + ); + const out = sink(); + expect( + await runApiList( + { filter: 'get flow', json: true }, + { stdout: out.stream, stderr: sink().stream }, + ), + ).toBe(EXIT.ok); + const parsed: unknown = JSON.parse(out.text()); + expect(parsed).toMatchObject({ + operations: [ + { + method: 'GET', + path: '/api/projects/{projectId}/flows/{flowId}', + summary: 'Get flow', + }, + ], + components: { schemas: { Flow: {}, Config: {} } }, + }); + }); + + it.each([ + ['unreachable', () => HttpResponse.error(), EXIT.unreachable], + ['a 503', () => HttpResponse.json({}, { status: 503 }), EXIT.http], + ['not a document', () => HttpResponse.json({ hello: 'world' }), EXIT.http], + ])('fails visibly when the document is %s', async (_label, answer, code) => { + server.use(http.get(`${APP}/api/openapi.json`, answer)); + const err = sink(); + expect( + await runApiList({}, { stdout: sink().stream, stderr: err.stream }), + ).toBe(code); + expect(err.text().trim().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/commands/api/__tests__/api.test.ts b/packages/cli/src/commands/api/__tests__/api.test.ts new file mode 100644 index 000000000..589c24932 --- /dev/null +++ b/packages/cli/src/commands/api/__tests__/api.test.ts @@ -0,0 +1,408 @@ +import '../../../__tests__/helpers/setup-msw.js'; +import { server } from '../../../__tests__/helpers/msw-server.js'; +import { http, HttpResponse } from 'msw'; +import { Readable, Writable } from 'node:stream'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { + EXIT, + registerApiCommand, + runApi, + type ApiCommandIO, +} from '../index.js'; +import { + resetClientContext, + setClientContext, +} from '../../../core/client-context.js'; + +const APP = 'https://app.example.test'; + +/** A writable that keeps what it got and says when the first chunk came. */ +function sink() { + const chunks: Buffer[] = []; + let first: () => void = () => {}; + const firstChunk = new Promise((resolve) => { + first = resolve; + }); + const stream = new Writable({ + write(chunk: Buffer, _encoding, done) { + chunks.push(Buffer.from(chunk)); + first(); + done(); + }, + }); + return { + stream, + firstChunk, + text: () => Buffer.concat(chunks).toString('utf-8'), + }; +} + +function streams(stdin = '') { + const out = sink(); + const err = sink(); + const io: ApiCommandIO = { + stdout: out.stream, + stderr: err.stream, + stdin: Readable.from([Buffer.from(stdin)]), + }; + return { io, out, err }; +} + +/** Counts every request msw sees, so a case can prove nothing was sent. */ +function countRequests(): () => number { + let calls = 0; + server.use( + http.all('*', () => { + calls += 1; + return HttpResponse.json({}, { status: 200 }); + }), + ); + return () => calls; +} + +const env = process.env; + +beforeEach(() => { + process.env = { + ...env, + WALKEROS_APP_URL: APP, + WALKEROS_TOKEN: 'wos_pat_test', + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'walkeros-api-')), + }; + delete process.env.WALKEROS_PROJECT_ID; + setClientContext({ type: 'cli', version: '9.9.9' }); +}); + +afterEach(() => { + process.env = env; + resetClientContext(); + process.exitCode = undefined; +}); + +describe('walkeros api: exit codes by answer', () => { + it.each([ + [200, EXIT.ok], + [201, EXIT.ok], + [204, EXIT.ok], + [400, EXIT.http], + [403, EXIT.http], + [404, EXIT.http], + [426, EXIT.http], + [500, EXIT.http], + [401, EXIT.auth], + ])('HTTP %i exits %i, body on stdout unchanged', async (status, code) => { + const body = + status === 204 + ? null + : JSON.stringify({ + error: { code: `C${status}`, message: `m${status}` }, + }); + server.use( + http.get(`${APP}/api/x`, () => new HttpResponse(body, { status })), + ); + const { io, out, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [] }, io), + ).toBe(code); + expect(out.text()).toBe(body ?? ''); + if (code === EXIT.ok) { + expect(err.text()).toBe(''); + } else { + expect(err.text().trim().split('\n')).toHaveLength(1); + expect(err.text()).toContain(`HTTP ${status}`); + expect(err.text()).toContain(`m${status}`); + } + }); + + it('exits 4 when the app cannot be reached', async () => { + server.use(http.get(`${APP}/api/x`, () => HttpResponse.error())); + const { io, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [] }, io), + ).toBe(EXIT.unreachable); + expect(err.text()).toMatch(/could not reach/i); + }); + + it('exits 3 without a credential and sends nothing', async () => { + delete process.env.WALKEROS_TOKEN; + const calls = countRequests(); + const { io, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [] }, io), + ).toBe(EXIT.auth); + expect(calls()).toBe(0); + expect(err.text()).toMatch(/walkeros auth login|WALKEROS_TOKEN/); + }); + + it('prints a non-JSON error body exactly as it came', async () => { + const html = 'Bad Gateway'; + server.use( + http.get( + `${APP}/api/x`, + () => + new HttpResponse(html, { + status: 502, + headers: { 'Content-Type': 'text/html' }, + }), + ), + ); + const { io, out, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [] }, io), + ).toBe(EXIT.http); + expect(out.text()).toBe(html); + expect(err.text().trim()).toMatch(/^walkeros api: HTTP 502/); + }); + + it('writes a streamed answer as it arrives, before the stream ends', async () => { + let release: () => void = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + const encoder = new TextEncoder(); + server.use( + http.get( + `${APP}/api/stream`, + () => + new HttpResponse( + new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode('event: status\ndata: deploying\n\n'), + ); + await released; + controller.enqueue( + encoder.encode('event: status\ndata: active\n\n'), + ); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + ), + ); + const { io, out } = streams(); + const run = runApi({ method: 'GET', path: '/api/stream', header: [] }, io); + try { + const arrived = await Promise.race([ + out.firstChunk.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 2000), + ), + ]); + expect(arrived).toBe(true); + expect(out.text()).toBe('event: status\ndata: deploying\n\n'); + } finally { + release(); + } + expect(await run).toBe(EXIT.ok); + expect(out.text()).toBe( + 'event: status\ndata: deploying\n\nevent: status\ndata: active\n\n', + ); + }); +}); + +describe('walkeros api: usage errors exit 2 and send nothing', () => { + it.each([ + ['an unknown method', { method: 'FETCH', path: '/api/x', header: [] }], + [ + 'a path without a leading slash', + { method: 'GET', path: 'api/x', header: [] }, + ], + ['DELETE without --yes', { method: 'DELETE', path: '/api/x', header: [] }], + [ + '--data on GET', + { method: 'GET', path: '/api/x', header: [], data: '{}' }, + ], + [ + 'a header without a colon', + { method: 'GET', path: '/api/x', header: ['NoColon'] }, + ], + [ + 'an unreadable --data file', + { + method: 'POST', + path: '/api/x', + header: [], + data: '@/does/not/exist.json', + }, + ], + [ + '{projectId} with no project', + { method: 'GET', path: '/api/projects/{projectId}', header: [] }, + ], + [ + 'another placeholder left', + { method: 'GET', path: '/api/flows/{flowId}', header: [] }, + ], + ])('%s', async (_label, options) => { + const calls = countRequests(); + const { io, err } = streams(); + expect(await runApi(options, io)).toBe(EXIT.usage); + expect(calls()).toBe(0); + expect(err.text().trim().length).toBeGreaterThan(0); + }); + + it('a credential bound for a plain http remote app', async () => { + process.env.WALKEROS_APP_URL = 'http://app.example.test'; + const calls = countRequests(); + const { io, err } = streams(); + expect( + await runApi({ method: 'GET', path: '/api/x', header: [] }, io), + ).toBe(EXIT.usage); + expect(calls()).toBe(0); + expect(err.text()).toMatch(/plain http/); + }); +}); + +describe('walkeros api: the request', () => { + it('sends DELETE with --yes', async () => { + let method = ''; + server.use( + http.delete(`${APP}/api/x`, ({ request }) => { + method = request.method; + return new HttpResponse(null, { status: 204 }); + }), + ); + const { io } = streams(); + expect( + await runApi( + { method: 'delete', path: '/api/x', header: [], yes: true }, + io, + ), + ).toBe(EXIT.ok); + expect(method).toBe('DELETE'); + }); + + it.each([ + ['--project', { project: 'proj_a/b' }, undefined, 'proj_a%2Fb'], + ['WALKEROS_PROJECT_ID', {}, 'proj_env', 'proj_env'], + [ + '--project over WALKEROS_PROJECT_ID', + { project: 'proj_flag' }, + 'proj_env', + 'proj_flag', + ], + ])( + 'fills {projectId} from %s', + async (_label, flags, envProject, expected) => { + if (envProject) process.env.WALKEROS_PROJECT_ID = envProject; + let url = ''; + server.use( + http.get(`${APP}/api/projects/:projectId/flows`, ({ request }) => { + url = request.url; + return HttpResponse.json([]); + }), + ); + const { io } = streams(); + expect( + await runApi( + { + method: 'GET', + path: '/api/projects/{projectId}/flows?limit=5', + header: [], + ...flags, + }, + io, + ), + ).toBe(EXIT.ok); + expect(url).toBe(`${APP}/api/projects/${expected}/flows?limit=5`); + }, + ); + + it.each([ + ['stdin', '-', '{"name":"from stdin"}'], + ['a file', 'FILE', '{"name":"from file"}'], + ['the argument', '{"name":"inline"}', '{"name":"inline"}'], + ])( + 'sends --data from %s unchanged, with the headers', + async (_label, data, body) => { + let seen: { body: string; headers: Headers } | undefined; + server.use( + http.post(`${APP}/api/x`, async ({ request }) => { + seen = { body: await request.text(), headers: request.headers }; + return HttpResponse.json({ ok: true }, { status: 201 }); + }), + ); + let dataArg = data; + if (data === 'FILE') { + const file = join( + mkdtempSync(join(tmpdir(), 'walkeros-api-data-')), + 'body.json', + ); + writeFileSync(file, body); + dataArg = `@${file}`; + } + const { io } = streams(data === '-' ? body : ''); + expect( + await runApi( + { + method: 'POST', + path: '/api/x', + data: dataArg, + header: ['Idempotency-Key: run-1', 'X-Extra: a: b'], + }, + io, + ), + ).toBe(EXIT.ok); + expect(seen?.body).toBe(body); + expect(seen?.headers.get('content-type')).toBe('application/json'); + expect(seen?.headers.get('idempotency-key')).toBe('run-1'); + expect(seen?.headers.get('x-extra')).toBe('a: b'); + expect(seen?.headers.get('authorization')).toBe('Bearer wos_pat_test'); + expect(seen?.headers.get('x-walkeros-client')).toBe('cli'); + expect(seen?.headers.get('x-walkeros-client-version')).toBe('9.9.9'); + }, + ); +}); + +describe('walkeros api: wiring', () => { + class Exit extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } + } + const exit = (code: number): never => { + throw new Exit(code); + }; + + it('runs the request and sets the exit code', async () => { + server.use( + http.get(`${APP}/api/x`, () => HttpResponse.json({}, { status: 404 })), + ); + const program = new Command().exitOverride(); + registerApiCommand(program, exit); + await program.parseAsync(['api', 'GET', '/api/x'], { from: 'user' }); + expect(process.exitCode).toBe(EXIT.http); + }); + + it('dispatches api ls rather than reading ls as a method', async () => { + server.use( + http.get(`${APP}/api/openapi.json`, () => + HttpResponse.json({ + paths: { '/api/health': { get: { summary: 'Health' } } }, + }), + ), + ); + const program = new Command().exitOverride(); + registerApiCommand(program, exit); + await program.parseAsync(['api', 'ls', 'health'], { from: 'user' }); + expect(process.exitCode).toBe(EXIT.ok); + }); + + it.each([[['api']], [['api', 'GET', '/api/x', '--bogus']]])( + 'exits 2 on a commander usage error: %j', + async (argv) => { + const program = new Command().exitOverride(); + registerApiCommand(program, exit); + await expect( + program.parseAsync(argv, { from: 'user' }), + ).rejects.toMatchObject({ + code: EXIT.usage, + }); + }, + ); +}); diff --git a/packages/cli/src/commands/api/index.ts b/packages/cli/src/commands/api/index.ts new file mode 100644 index 000000000..bc886338b --- /dev/null +++ b/packages/cli/src/commands/api/index.ts @@ -0,0 +1,515 @@ +import { once } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import type { Command, CommanderError } from 'commander'; +import { z } from 'zod'; +import { appErrorMessage, appFetch } from '../../core/app-fetch.js'; +import { APP_PATHS } from '../../core/app-paths.js'; +import { resolveAccessToken } from '../../core/auth.js'; +import { fetchFailureReason } from '../../core/fetch-failure.js'; +import { readStdin } from '../../core/stdin.js'; +import { resolveAppUrl } from '../../lib/config-file.js'; +import { requireSecureUrl } from '../../lib/secure-url.js'; + +/** + * `walkeros api `: one authenticated request to the walkerOS + * app, its answer copied to stdout as it arrives. The CLI knows no operation + * of the app beyond its own fixed ones; the person names the path. + */ + +export const EXIT = { + ok: 0, + http: 1, + usage: 2, + auth: 3, + unreachable: 4, +} as const; +export type ApiExit = (typeof EXIT)[keyof typeof EXIT]; + +export interface ApiCommandIO { + stdout: NodeJS.WritableStream; + stderr: NodeJS.WritableStream; + stdin: NodeJS.ReadableStream; +} + +export interface ApiRequestOptions { + method: string; + /** Starts with `/`; query parameters ride in it. */ + path: string; + /** `''`, `'@file.json'` or `'-'` (stdin). */ + data?: string; + /** Each `'Name: value'`; repeatable. */ + header: string[]; + /** Fills `{projectId}`; falls back to WALKEROS_PROJECT_ID. */ + project?: string; + yes?: boolean; +} + +/** Shown when there is no credential to send. */ +export const NOT_SIGNED_IN = + 'not signed in: run walkeros auth login, or set WALKEROS_TOKEN'; + +const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); +/** Error bodies are read back for the stderr line; a bigger one is not parsed. */ +const ERROR_BODY_LIMIT = 64 * 1024; + +class UsageError extends Error {} + +function defaultIO(): ApiCommandIO { + return { + stdout: process.stdout, + stderr: process.stderr, + stdin: process.stdin, + }; +} + +function line(out: NodeJS.WritableStream, text: string): void { + out.write(`walkeros api: ${text}\n`); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseJson(text: string): unknown { + try { + const value: unknown = JSON.parse(text); + return value; + } catch { + return undefined; + } +} + +/** The origin of a URL for a message, or the URL itself when it is not one. */ +function originOf(url: string): string { + try { + return new URL(url).origin; + } catch { + return url; + } +} + +function fillPath(path: string, project: string | undefined): string { + if (!path.startsWith('/')) + throw new UsageError(`the path must start with /: ${path}`); + let filled = path; + if (filled.includes('{projectId}')) { + if (!project) + throw new UsageError( + '{projectId} needs --project or WALKEROS_PROJECT_ID', + ); + filled = filled.split('{projectId}').join(encodeURIComponent(project)); + } + const left = /\{[^}]+\}/.exec(filled); + if (left) throw new UsageError(`fill ${left[0]} in the path`); + return filled; +} + +function parseHeaders(values: readonly string[]): Record { + const headers: Record = {}; + for (const value of values) { + const colon = value.indexOf(':'); + if (colon <= 0) throw new UsageError(`a header is 'Name: value': ${value}`); + headers[value.slice(0, colon).trim()] = value.slice(colon + 1).trim(); + } + return headers; +} + +function hasHeader(headers: Record, name: string): boolean { + const lower = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lower); +} + +/** The body as given: the argument, a file's content, or stdin. */ +async function readData( + data: string, + stdin: NodeJS.ReadableStream, +): Promise { + if (data === '-') { + try { + return await readStdin(stdin); + } catch (error) { + throw new UsageError(`--data -: ${messageOf(error)}`); + } + } + if (data.startsWith('@')) { + const file = data.slice(1); + try { + return await readFile(file, 'utf-8'); + } catch (error) { + throw new UsageError( + `cannot read the --data file ${file}: ${messageOf(error)}`, + ); + } + } + return data; +} + +interface PreparedRequest { + method: string; + path: string; + headers: Record; + body?: string; +} + +/** Every usage check, in order, before anything is resolved or sent. */ +async function prepare( + options: ApiRequestOptions, + stdin: NodeJS.ReadableStream, +): Promise { + const method = options.method.toUpperCase(); + if (!METHODS.has(method)) + throw new UsageError( + `unknown method ${options.method}: use GET, POST, PUT, PATCH or DELETE`, + ); + if (method === 'DELETE' && !options.yes) + throw new UsageError('DELETE needs --yes'); + if (method === 'GET' && options.data !== undefined) + throw new UsageError('GET takes no --data'); + const path = fillPath( + options.path, + options.project || process.env.WALKEROS_PROJECT_ID, + ); + const headers = parseHeaders(options.header); + if (options.data === undefined) return { method, path, headers }; + const body = await readData(options.data, stdin); + if (!hasHeader(headers, 'Content-Type')) + headers['Content-Type'] = 'application/json'; + return { method, path, headers, body }; +} + +/** Copy the answer to stdout as it arrives; keep an error body for the stderr line. */ +async function relay(response: Response, io: ApiCommandIO): Promise { + const kept: Uint8Array[] = []; + let keptBytes = 0; + try { + const reader = response.body?.getReader(); + for (;;) { + if (!reader) break; + const { done, value } = await reader.read(); + if (done) break; + if (!io.stdout.write(value)) await once(io.stdout, 'drain'); + if (!response.ok && keptBytes < ERROR_BODY_LIMIT) { + kept.push(value); + keptBytes += value.byteLength; + } + } + } catch (error) { + line( + io.stderr, + `the connection broke while reading the answer: ${fetchFailureReason(error)}`, + ); + return EXIT.unreachable; + } + if (response.ok) return EXIT.ok; + const message = appErrorMessage( + parseJson(Buffer.concat(kept).toString('utf-8')), + ); + line( + io.stderr, + `HTTP ${response.status} ${response.statusText}`.trimEnd() + + (message ? `: ${message}` : ''), + ); + return response.status === 401 ? EXIT.auth : EXIT.http; +} + +/** + * Send one request to the app and print its answer. Exit 0 for 2xx, 1 for any + * other status, 2 for a usage error (nothing sent), 3 without a credential or + * on 401, 4 when the app cannot be reached or the answer breaks off. + */ +export async function runApi( + options: ApiRequestOptions, + io: ApiCommandIO = defaultIO(), +): Promise { + let request: PreparedRequest; + try { + request = await prepare(options, io.stdin); + } catch (error) { + if (!(error instanceof UsageError)) throw error; + line(io.stderr, error.message); + return EXIT.usage; + } + + let token: string | null; + try { + token = await resolveAccessToken(); + } catch (error) { + line(io.stderr, messageOf(error)); + return EXIT.unreachable; + } + if (!token && !hasHeader(request.headers, 'Authorization')) { + line(io.stderr, NOT_SIGNED_IN); + return EXIT.auth; + } + + const appUrl = resolveAppUrl(); + try { + requireSecureUrl(`${appUrl.replace(/\/+$/, '')}${request.path}`); + } catch (error) { + line(io.stderr, messageOf(error)); + return EXIT.usage; + } + + let response: Response; + try { + response = await appFetch(request.path, { + method: request.method, + baseUrl: appUrl, + token, + headers: request.headers, + body: request.body, + }); + } catch (error) { + line( + io.stderr, + `could not reach ${originOf(appUrl)}: ${fetchFailureReason(error)}`, + ); + return EXIT.unreachable; + } + return relay(response, io); +} + +export interface ApiOperation { + /** Upper case. */ + method: string; + path: string; + summary?: string; + description?: string; + parameters?: unknown[]; + requestBody?: unknown; + responses?: Record; +} + +export interface ApiListOptions { + filter?: string; + json?: boolean; +} + +const OPERATION_METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'patch', + 'head', + 'options', +] as const; + +const DocumentSchema = z.object({ + paths: z.record(z.string(), z.record(z.string(), z.unknown())), + components: z + .record(z.string(), z.record(z.string(), z.unknown())) + .optional(), +}); + +const OperationSchema = z.object({ + summary: z.string().optional(), + description: z.string().optional(), + parameters: z.array(z.unknown()).optional(), + requestBody: z.unknown().optional(), + responses: z.record(z.string(), z.unknown()).optional(), +}); + +const COMPONENT_REF = /^#\/components\/([^/]+)\/([^/]+)$/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Every operation of an OpenAPI document, sorted by path, then method. */ +export function listOperations(document: unknown): ApiOperation[] { + const parsed = DocumentSchema.safeParse(document); + if (!parsed.success) return []; + const operations: ApiOperation[] = []; + for (const [path, item] of Object.entries(parsed.data.paths)) { + for (const method of OPERATION_METHODS) { + if (!(method in item)) continue; + const operation = OperationSchema.safeParse(item[method]); + if (!operation.success) continue; + operations.push({ + method: method.toUpperCase(), + path, + ...operation.data, + }); + } + } + const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + return operations.sort( + (a, b) => order(a.path, b.path) || order(a.method, b.method), + ); +} + +/** Operations whose `METHOD /path summary` contains the filter, case-insensitive. */ +export function filterOperations( + operations: ApiOperation[], + filter?: string, +): ApiOperation[] { + if (!filter) return operations; + const needle = filter.toLowerCase(); + return operations.filter((operation) => + `${operation.method} ${operation.path} ${operation.summary ?? ''}` + .toLowerCase() + .includes(needle), + ); +} + +/** The components the operations reference (`#/components//`), followed transitively. */ +export function referencedComponents( + document: unknown, + operations: ApiOperation[], +): Record> { + const parsed = DocumentSchema.safeParse(document); + const available = (parsed.success && parsed.data.components) || {}; + const found: Record> = {}; + const visited = new Set(); + const queue: unknown[] = [...operations]; + for (let index = 0; index < queue.length; index += 1) { + const value = queue[index]; + if (Array.isArray(value)) { + queue.push(...value); + continue; + } + if (!isRecord(value)) continue; + for (const [key, child] of Object.entries(value)) { + if (key !== '$ref' || typeof child !== 'string') { + queue.push(child); + continue; + } + const match = COMPONENT_REF.exec(child); + if (!match || visited.has(child)) continue; + visited.add(child); + const [, kind, name] = match; + const component = available[kind]?.[name]; + if (component === undefined) continue; + found[kind] = { ...found[kind], [name]: component }; + queue.push(component); + } + } + return found; +} + +function formatOperation(operation: ApiOperation): string { + const summary = operation.summary ? ` ${operation.summary}` : ''; + return `${operation.method.padEnd(6)} ${operation.path}${summary}`; +} + +/** + * List the operations of the app's live public API document. Exit 0 (also + * when nothing matches), 1 for a non-2xx answer or a body that is not an + * OpenAPI document, 4 when the app cannot be reached. + */ +export async function runApiList( + options: ApiListOptions, + io: Pick = defaultIO(), +): Promise { + const appUrl = resolveAppUrl(); + let text: string; + try { + const response = await appFetch(APP_PATHS.openapi, { baseUrl: appUrl }); + if (!response.ok) { + line( + io.stderr, + `HTTP ${response.status} ${response.statusText}`.trimEnd() + + ` for ${APP_PATHS.openapi}`, + ); + return EXIT.http; + } + text = await response.text(); + } catch (error) { + line( + io.stderr, + `could not reach ${originOf(appUrl)}: ${fetchFailureReason(error)}`, + ); + return EXIT.unreachable; + } + + const document = parseJson(text); + if (!DocumentSchema.safeParse(document).success) { + line(io.stderr, 'the answer is not an OpenAPI document (no paths)'); + return EXIT.http; + } + + const operations = filterOperations(listOperations(document), options.filter); + if (operations.length === 0) { + line(io.stderr, `no operation matches "${options.filter ?? ''}"`); + return EXIT.ok; + } + + io.stdout.write( + options.json + ? JSON.stringify( + { + operations, + components: referencedComponents(document, operations), + }, + null, + 2, + ) + '\n' + : operations + .map((operation) => formatOperation(operation) + '\n') + .join(''), + ); + return EXIT.ok; +} + +const OptionsSchema = z.object({ + data: z.string().optional(), + header: z.array(z.string()).default([]), + project: z.string().optional(), + yes: z.boolean().optional(), +}); + +const collect = (value: string, previous: string[]): string[] => [ + ...previous, + value, +]; + +export function registerApiCommand( + program: Command, + exit: (code: number) => never = (code) => process.exit(code), +): void { + const usageExit = (error: CommanderError): never => + exit(error.exitCode === 0 ? 0 : EXIT.usage); + const api = program + .command('api') + .description( + 'Send an authenticated request to the walkerOS app API and print the answer as it arrives', + ) + .argument('', 'GET, POST, PUT, PATCH or DELETE') + .argument( + '', + 'the API path with its query, for example /api/projects/{projectId}/flows', + ) + .option( + '-d, --data ', + 'request body: JSON, @file.json, or - for stdin', + ) + .option( + '-H, --header
', + "extra header 'Name: value' (repeatable)", + collect, + [], + ) + .option( + '-p, --project ', + 'fills {projectId} (default: WALKEROS_PROJECT_ID)', + ) + .option('-y, --yes', 'confirm a DELETE') + .exitOverride(usageExit) + .action(async (method: string, path: string, raw: unknown) => { + const options = OptionsSchema.parse(raw); + process.exitCode = await runApi({ method, path, ...options }); + }); + + api + .command('ls [filter]') + .description( + 'List the public API operations from the live OpenAPI document of the app', + ) + .option('--json', 'print each operation with its parameters and schemas') + .exitOverride(usageExit) + .action(async (filter: string | undefined, raw: unknown) => { + const { json } = z.object({ json: z.boolean().optional() }).parse(raw); + process.exitCode = await runApiList({ filter, json }); + }); +} diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index b56ef32c0..ec4ca85d1 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,20 +1,76 @@ -import { createApiClient } from '../../core/api-client.js'; +import { z } from 'zod'; import { handleCliError } from '../../core/api-error.js'; +import { appErrorMessage, appFetch } from '../../core/app-fetch.js'; +import { APP_PATHS } from '../../core/app-paths.js'; +import { resolveAccessToken } from '../../core/auth.js'; import { createCLILogger } from '../../core/cli-logger.js'; import { writeResult } from '../../core/output.js'; import type { GlobalOptions } from '../../types/global.js'; +import { + EXIT, + NOT_SIGNED_IN, + type ApiCommandIO, + type ApiExit, +} from '../api/index.js'; // === Programmatic API === -export async function whoami() { - const client = createApiClient(); - const { data, error } = await client.GET('/api/auth/whoami'); - if (error) throw new Error(error.error?.message || 'Not authenticated'); - return data; +/** The parsed JSON body, or null when the body is not JSON. */ +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +/** Who the current credential belongs to, as the app answers it. */ +export async function whoami(): Promise { + const token = await resolveAccessToken(); + if (!token) + throw new Error('Not authenticated. Run `walkeros auth login` first.'); + const response = await appFetch(APP_PATHS.whoami, { token }); + const body = await readJson(response); + if (!response.ok) + throw new Error(appErrorMessage(body) ?? `HTTP ${response.status}`); + return body; +} + +/** + * Print the current access token and a newline on stdout, so any HTTP client + * can call the app (`gh auth token` pattern). Exit 3 without a credential, 4 + * when a refresh cannot reach the app. + */ +export async function authTokenCommand( + io: Pick = { + stdout: process.stdout, + stderr: process.stderr, + }, +): Promise { + let token: string | null; + try { + token = await resolveAccessToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + io.stderr.write(`walkeros auth token: ${message}\n`); + return EXIT.unreachable; + } + if (!token) { + io.stderr.write(`walkeros auth token: ${NOT_SIGNED_IN}\n`); + return EXIT.auth; + } + io.stdout.write(`${token}\n`); + return EXIT.ok; } // === CLI Command Handler === +const IdentitySchema = z.object({ + email: z.string().optional(), + userId: z.string().optional(), + projectId: z.string().nullish(), +}); + export interface WhoamiCommandOptions extends GlobalOptions { json?: boolean; output?: string; @@ -29,8 +85,13 @@ export async function whoamiCommand( if (options.json) { await writeResult(JSON.stringify(result, null, 2), options); } else { - const data = result as Record; - if (data.email) logger.info(`${data.email}`); + const identity = IdentitySchema.safeParse(result); + if (!identity.success) { + logger.info(JSON.stringify(result)); + return; + } + const data = identity.data; + if (data.email) logger.info(data.email); if (data.userId) logger.info(`User: ${data.userId}`); if (data.projectId) logger.info(`Project: ${data.projectId}`); } diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts deleted file mode 100644 index 2c6bc6600..000000000 --- a/packages/cli/src/commands/deploy/index.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { createApiClient } from '../../core/api-client.js'; -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ResponseJson } from '../../core/api-request.js'; -import { - ApiError, - handleCliError, - throwApiError, - throwApiResponseError, -} from '../../core/api-error.js'; -import { parseSSEEvents } from '../../core/sse.js'; -import { createCLILogger } from '../../core/cli-logger.js'; -import { writeResult } from '../../core/output.js'; -import type { GlobalOptions } from '../../types/global.js'; -import { getFlow } from '../flows/index.js'; - -/** Response body of POST .../settings/{settingsId}/deploy. */ -type DeploySettingsResponse = ResponseJson< - 'POST /api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy', - 201 ->; - -// === Helpers === - -async function resolveSettingsId(options: { - flowId: string; - projectId: string; - flowName: string; -}): Promise { - const flow = await getFlow({ - flowId: options.flowId, - projectId: options.projectId, - }); - const settings = (flow as { settings?: Array<{ id: string; name: string }> }) - .settings; - if (!settings?.length) { - throw new Error('Flow has no settings.'); - } - const match = settings.find((c) => c.name === options.flowName); - if (!match) { - throw new Error( - `Flow "${options.flowName}" not found. Available: ${settings.map((c) => c.name).join(', ')}`, - ); - } - return match.id; -} - -async function getAvailableFlowNames(options: { - flowId: string; - projectId: string; -}): Promise { - const flow = await getFlow({ - flowId: options.flowId, - projectId: options.projectId, - }); - const settings = (flow as { settings?: Array<{ name: string }> }).settings; - return settings?.map((c) => c.name) ?? []; -} - -/** - * Default client-side wait budget for a streamed deploy, in milliseconds. - * - * The server's deploy budget is 10 minutes; this default exceeds it so a - * slow-but-healthy long deploy is never aborted client-side and misread as a - * failure in CI. `--wait ` (the `timeout` option) overrides it. - */ -export const DEFAULT_DEPLOY_WAIT_MS = 12 * 60 * 1000; - -// === SSE Streaming === - -interface DeploymentResult { - status: string; - substatus?: string | null; - type?: string; - containerUrl?: string | null; - publicUrl?: string | null; - errorMessage?: string | null; - [key: string]: unknown; -} - -export async function streamDeploymentStatus( - projectId: string, - deploymentId: string, - options: { - timeout?: number; - signal?: AbortSignal; - onStatus?: (status: string, substatus: string | null) => void; - }, -): Promise { - const timeoutMs = options.timeout ?? DEFAULT_DEPLOY_WAIT_MS; - - const response = await apiRequest( - 'GET /api/projects/{projectId}/deployments/{deploymentId}/stream', - { - path: { projectId, deploymentId }, - headers: { Accept: 'text/event-stream' }, - signal: options.signal ?? AbortSignal.timeout(timeoutMs), - }, - ); - - if (!response.ok) throw new Error(`Stream failed: ${response.status}`); - if (!response.body) throw new Error('No response body'); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let result: DeploymentResult | null = null; - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const { parsed, remainder } = parseSSEEvents(buffer); - buffer = remainder; - - for (const event of parsed) { - if (event.type === 'status') { - const data = JSON.parse(event.data); - result = data; - options.onStatus?.(data.status, data.substatus ?? null); - } - if (event.type === 'done') { - return result!; - } - } - } - } finally { - reader.cancel().catch(() => {}); - } - - if (!result) throw new Error('Stream ended without terminal status'); - return result; -} - -// === Programmatic API === - -export interface DeployOptions { - flowId: string; - projectId?: string; - wait?: boolean; - flowName?: string; - timeout?: number; // ms, default DEFAULT_DEPLOY_WAIT_MS (12 minutes) - signal?: AbortSignal; - onStatus?: (status: string, substatus: string | null) => void; -} - -export async function deploy(options: DeployOptions) { - const projectId = options.projectId ?? requireProjectId(); - const client = createApiClient(); - - if (options.flowName) { - const settingsId = await resolveSettingsId({ - flowId: options.flowId, - projectId, - flowName: options.flowName, - }); - return deploySettings({ - ...options, - projectId, - settingsId, - timeout: options.timeout, - signal: options.signal, - onStatus: options.onStatus, - }); - } - - // Legacy path. A fresh Idempotency-Key per invocation means a retry after a - // failed attempt is a new deploy, not a silent replay of `already_created`. - const { data, error } = await client.POST( - '/api/projects/{projectId}/flows/{flowId}/deploy', - { - params: { path: { projectId, flowId: options.flowId } }, - headers: { 'Idempotency-Key': randomUUID() }, - }, - ); - - if (error) { - try { - throwApiError(error, 'Failed to start deployment'); - } catch (e) { - if (e instanceof ApiError && e.code === 'AMBIGUOUS_CONFIG') { - const names = await getAvailableFlowNames({ - flowId: options.flowId, - projectId, - }); - throw new ApiError( - `This flow has multiple settings. Use --flow to specify one.\nAvailable: ${names.join(', ')}`, - { code: 'AMBIGUOUS_CONFIG' }, - ); - } - throw e; - } - } - - if (!options.wait) return data; - - // Stream deployment status via SSE - const result = await streamDeploymentStatus(projectId, data.deploymentId, { - timeout: options.timeout, - signal: options.signal, - onStatus: options.onStatus, - }); - - return { ...data, ...result }; -} - -// Uses `apiRequest`, which answers the raw `Response`, rather than the typed -// `client.POST()`: the bounded Retry-After retry below inspects -// `response.status` and the `Retry-After` header, and the `--wait` path then -// streams `text/event-stream`. openapi-fetch's `{ data, error }` client hides -// the raw `Response`, so it cannot drive either path. -async function deploySettings(options: { - flowId: string; - projectId: string; - settingsId: string; - wait?: boolean; - timeout?: number; - signal?: AbortSignal; - onStatus?: (status: string, substatus: string | null) => void; -}) { - const { flowId, projectId, settingsId } = options; - - // 1. Trigger per-settings deploy. A fresh Idempotency-Key per invocation - // means a retry after a failed attempt is a new deploy, not a silent replay - // of `already_created`. - const triggerDeploy = () => - apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy', - { - path: { projectId, flowId, settingsId }, - headers: { 'Idempotency-Key': randomUUID() }, - }, - ); - - let response = await triggerDeploy(); - - // For `--wait`, honor a single bounded Retry-After on a rate-limited trigger - // before giving up, so a brief deploy-rate ceiling does not fail an - // otherwise healthy automated deploy. - if (!response.ok && options.wait) { - let retryBody: unknown = {}; - try { - retryBody = await response.clone().json(); - } catch { - retryBody = {}; - } - try { - throwApiResponseError( - response, - retryBody, - `Deploy failed (${response.status})`, - ); - } catch (e) { - if (e instanceof ApiError && e.retryable) { - const waitSeconds = Math.min(e.retryAfterSeconds ?? 5, 60); - options.onStatus?.('rate_limited', `retrying in ${waitSeconds}s`); - await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); - response = await triggerDeploy(); - } else { - throw e; - } - } - } - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiResponseError(response, body, `Deploy failed (${response.status})`); - } - - const data: DeploySettingsResponse = await response.json(); - if (!options.wait) return data; - - // 2. Stream deployment status via SSE - const result = await streamDeploymentStatus(projectId, data.deploymentId, { - timeout: options.timeout, - signal: options.signal, - onStatus: options.onStatus, - }); - - return { ...data, ...result }; -} - -export async function getDeployment(options: { - flowId: string; - projectId?: string; - flowName?: string; -}) { - const projectId = options.projectId ?? requireProjectId(); - - if (options.flowName) { - const settingsId = await resolveSettingsId({ - flowId: options.flowId, - projectId, - flowName: options.flowName, - }); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy', - { path: { projectId, flowId: options.flowId, settingsId } }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiResponseError(response, body, 'Failed to get deployment'); - } - return response.json(); - } - - const client = createApiClient(); - - const { data, error } = await client.GET( - '/api/projects/{projectId}/flows/{flowId}/deploy', - { params: { path: { projectId, flowId: options.flowId } } }, - ); - if (error) throwApiError(error, 'Failed to get deployment'); - return data; -} - -// === CLI Commands === - -interface DeployCommandOptions extends GlobalOptions { - project?: string; - flow?: string; - wait?: boolean; - timeout?: string; - output?: string; - json?: boolean; -} - -// Progress labels keyed on the statuses the deploy stream ACTUALLY emits. The -// server status enum is idle | deploying | published | active | stopped | -// failed; the bundle phase is reported as `deploying`/`building`, not a -// separate `bundling` status. Keys are `status` or `status:substatus`. -const statusLabels: Record = { - 'deploying:building': 'Building bundle...', - 'deploying:publishing': 'Publishing to web...', - 'deploying:provisioning': 'Provisioning container...', - 'deploying:starting': 'Starting container...', - deploying: 'Deploying...', - active: 'Container is live', - published: 'Published', - stopped: 'Stopped', - failed: 'Deployment failed', -}; - -/** - * Map a streamed `(status, substatus)` to a human progress line, falling back - * to the bare status label, then to a safe `Status: ` line for an unknown - * status so an unexpected value still renders something instead of nothing. - */ -export function renderStatusLabel( - status: string, - substatus: string | null, -): string { - const key = substatus ? `${status}:${substatus}` : status; - return statusLabels[key] || statusLabels[status] || `Status: ${status}`; -} - -export async function deployCommand( - flowId: string, - options: DeployCommandOptions, -) { - const log = createCLILogger(options); - - const timeoutMs = options.timeout - ? parseInt(options.timeout, 10) * 1000 - : undefined; - - try { - const result = await deploy({ - flowId, - projectId: options.project, - flowName: options.flow, - wait: options.wait !== false, - timeout: timeoutMs, - onStatus: options.json - ? undefined - : (status, substatus) => { - log.info(renderStatusLabel(status, substatus)); - }, - }); - - if (options.json) { - await writeResult(JSON.stringify(result, null, 2), options); - return; - } - - const r = result as Record; - - if (r.status === 'published') { - log.info(`Published: ${r.publicUrl}`); - } else if (r.status === 'active') { - log.info(`Active: ${r.containerUrl}`); - } else if (r.status === 'failed') { - log.error(`Failed: ${r.errorMessage || 'Unknown error'}`); - process.exit(1); - } else if (r.status === 'deploying') { - // Non-wait path: the deploy-trigger response reports `deploying` (the - // server status enum has no `bundling`; the build phase is a substatus). - log.info(`Deployment started: ${r.deploymentId} (${r.type})`); - } else { - log.info(`Status: ${r.status}`); - } - } catch (err) { - handleCliError(err); - } -} - -export async function getDeploymentCommand( - flowId: string, - options: DeployCommandOptions, -) { - const log = createCLILogger(options); - - try { - const result = await getDeployment({ - flowId, - projectId: options.project, - flowName: options.flow, - }); - - if (options.json) { - await writeResult(JSON.stringify(result, null, 2), options); - return; - } - - if (!result) { - log.info('No deployment found'); - return; - } - - const r = result as Record; - log.info(`Deployment: ${r.id}`); - log.info(`Type: ${r.type}`); - log.info(`Status: ${r.status}`); - if (r.containerUrl) log.info(`Endpoint: ${r.containerUrl}`); - if (r.publicUrl) log.info(`URL: ${r.publicUrl}`); - if (r.errorMessage) log.error(`Error: ${r.errorMessage}`); - } catch (err) { - handleCliError(err); - } -} diff --git a/packages/cli/src/commands/deployments/index.ts b/packages/cli/src/commands/deployments/index.ts deleted file mode 100644 index c791b6e72..000000000 --- a/packages/cli/src/commands/deployments/index.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { getPlatform, isObject } from '@walkeros/core'; -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ApiRequestInit, ResponseJson } from '../../core/api-request.js'; -import { handleCliError, throwApiError } from '../../core/api-error.js'; -import { createCLILogger } from '../../core/cli-logger.js'; -import { writeResult } from '../../core/output.js'; -import { resolveAppUrl } from '../../lib/config-file.js'; -import { VERSION } from '../../version.js'; -import { loadFlowConfig } from '../../config/loader.js'; -import type { GlobalOptions } from '../../types/global.js'; - -// === Programmatic API === - -type ListDeploymentsQuery = NonNullable< - ApiRequestInit<'GET /api/projects/{projectId}/deployments'>['query'] ->; - -/** A deployment status, as the contract's list filter declares it. */ -export type DeploymentStatus = NonNullable; - -/** A deployment type, as the contract's list filter declares it. */ -export type DeploymentType = NonNullable; - -/** Every deployment status. The compiler holds it equal to the contract's. */ -export const DEPLOYMENT_STATUSES = { - idle: true, - deploying: true, - published: true, - active: true, - stopped: true, - failed: true, -} satisfies Record; - -/** Every deployment type. The compiler holds it equal to the contract's. */ -export const DEPLOYMENT_TYPES = { - web: true, - server: true, -} satisfies Record; - -function isMember( - members: Readonly>, - value: string, -): value is T { - return Object.hasOwn(members, value); -} - -export function isDeploymentStatus(value: string): value is DeploymentStatus { - return isMember(DEPLOYMENT_STATUSES, value); -} - -export interface ListDeploymentsOptions { - projectId?: string; - type?: DeploymentType; - status?: DeploymentStatus; - flowId?: string; - cursor?: string; - limit?: number; -} - -export async function listDeployments(options: ListDeploymentsOptions = {}) { - const id = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/deployments', - { - path: { projectId: id }, - query: { - type: options.type, - status: options.status, - flowId: options.flowId || undefined, - cursor: options.cursor || undefined, - limit: options.limit, - }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to list deployments'); - } - return response.json(); -} - -/** - * Summary of an active deployment returned by listDeployments, used when - * disambiguating which deployment to operate on for a given flow. - */ -export interface DeploymentSummaryForFlow { - slug: string; - type: string; - status: string; - updatedAt: string; -} - -/** - * Error thrown by deleteDeploymentByFlowId and other flow-scoped helpers when - * the flow has multiple active (non-deleted) deployments and the caller did - * not disambiguate with an explicit slug. - * - * Callers (e.g. the MCP layer) can translate this into a structured error. - * The CLI package does not depend on MCP helpers. - */ -export class DeploymentAmbiguityError extends Error { - readonly code = 'MULTIPLE_DEPLOYMENTS'; - readonly details: DeploymentSummaryForFlow[]; - constructor(message: string, details: DeploymentSummaryForFlow[]) { - super(message); - this.name = 'DeploymentAmbiguityError'; - this.details = details; - } -} - -/** - * Delete a deployment identified by flowId, optionally disambiguated by slug - * when the flow has more than one active deployment. Throws - * DeploymentAmbiguityError when the flow has >= 2 active deployments and no - * slug is provided. Throws a plain Error when the flow has no matches or when - * a provided slug does not belong to the flow. - */ -export async function deleteDeploymentByFlowId(options: { - projectId?: string; - flowId: string; - slug?: string; -}): Promise { - const { flowId, slug } = options; - const projectId = options.projectId ?? requireProjectId(); - - const listResp = (await listDeployments({ projectId, flowId })) as { - deployments?: DeploymentSummaryForFlow[]; - }; - const matches = listResp.deployments ?? []; - - if (matches.length === 0) { - throw new Error(`No deployments found for flow ${flowId}`); - } - - if (slug !== undefined) { - const hit = matches.find((m) => m.slug === slug); - if (!hit) { - throw new Error(`No deployment with slug ${slug} in flow ${flowId}`); - } - return deleteDeployment({ slug: hit.slug, projectId }); - } - - if (matches.length > 1) { - throw new DeploymentAmbiguityError( - `Flow ${flowId} has ${matches.length} active deployments; pass slug to disambiguate`, - matches.map((m) => ({ - slug: m.slug, - type: m.type, - status: m.status, - updatedAt: m.updatedAt, - })), - ); - } - return deleteDeployment({ slug: matches[0].slug, projectId }); -} - -export async function getDeploymentBySlug(options: { - slug: string; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - - const response = await apiRequest( - 'GET /api/projects/{projectId}/deployments/{deploymentId}', - { path: { projectId: id, deploymentId: options.slug } }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to get deployment'); - } - return response.json(); -} - -export async function createDeployment(options: { - type: 'web' | 'server'; - label?: string; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - - const response = await apiRequest( - 'POST /api/projects/{projectId}/deployments', - { - path: { projectId: id }, - body: { type: options.type, label: options.label }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to create deployment'); - } - return response.json(); -} - -export async function deleteDeployment(options: { - slug: string; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - - const response = await apiRequest( - 'DELETE /api/projects/{projectId}/deployments/{deploymentId}', - { path: { projectId: id, deploymentId: options.slug } }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to delete deployment'); - } - const data = await response.json().catch(() => null); - return data ?? { success: true }; -} - -// === CLI Command Handlers === - -interface DeploymentsCommandOptions extends GlobalOptions { - json?: boolean; - output?: string; - project?: string; - type?: string; - status?: string; - label?: string; - cursor?: string; - limit?: number; -} - -/** A flag's value narrowed to its members; any other value is an error. */ -function flagValue( - flag: string, - value: string | undefined, - members: Readonly>, -): T | undefined { - if (value === undefined) return undefined; - if (isMember(members, value)) return value; - throw new Error(`${flag} must be one of: ${Object.keys(members).join(', ')}`); -} - -async function handleResult( - fn: () => Promise, - options: DeploymentsCommandOptions, -): Promise { - try { - const result = await fn(); - await writeResult(JSON.stringify(result, null, 2), options); - } catch (error) { - handleCliError(error); - } -} - -export async function listDeploymentsCommand( - options: DeploymentsCommandOptions, -): Promise { - await handleResult( - () => - listDeployments({ - projectId: options.project, - type: flagValue('--type', options.type, DEPLOYMENT_TYPES), - status: flagValue('--status', options.status, DEPLOYMENT_STATUSES), - cursor: options.cursor, - limit: options.limit, - }), - options, - ); -} - -export async function getDeploymentBySlugCommand( - slug: string, - options: DeploymentsCommandOptions, -): Promise { - await handleResult( - () => getDeploymentBySlug({ slug, projectId: options.project }), - options, - ); -} - -export async function createDeploymentCommand( - options: DeploymentsCommandOptions, -): Promise { - await handleResult( - () => - createDeployment({ - type: options.type as 'web' | 'server', - label: options.label, - projectId: options.project, - }), - options, - ); -} - -export async function deleteDeploymentCommand( - slug: string, - options: DeploymentsCommandOptions, -): Promise { - await handleResult( - () => deleteDeployment({ slug, projectId: options.project }), - options, - ); -} - -export async function createDeployCommand( - config: string | undefined, - options: DeploymentsCommandOptions & { flow?: string }, -): Promise { - const log = createCLILogger(options); - - try { - let type: 'web' | 'server'; - - if (!config) { - log.error( - 'Config required. Provide a flow config file or remote flow ID (cfg_xxx).', - ); - process.exit(1); - } - - // Detect: local file path vs remote flow ID - const isRemoteFlow = config.startsWith('cfg_'); - - if (isRemoteFlow) { - // Fetch flow from API to determine type - const id = options.project ?? requireProjectId(); - const resp = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}', - { path: { projectId: id, flowId: config } }, - ); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throwApiError(body, `Failed to fetch flow ${config}`); - } - const flow: ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}', - 200 - > = await resp.json(); - if (!flow.config) throw new Error('Flow has no config'); - - const flows = flow.config.flows; - if (!flows) throw new Error('Invalid flow config: missing flows'); - const flowName = options.flow ?? Object.keys(flows)[0]; - if (!flowName) throw new Error('No flows found in config'); - const flowSettings = flows[flowName]; - if (!isObject(flowSettings)) throw new Error('Invalid flow config'); - - // A v4 flow declares its platform at flows..config.platform. - const platform = isObject(flowSettings.config) - ? flowSettings.config.platform - : undefined; - if (platform !== 'web' && platform !== 'server') - throw new Error( - `Flow "${flowName}" must have config.platform set to "web" or "server"`, - ); - type = platform; - } else { - // Local file: use config loader + core getPlatform - const result = await loadFlowConfig(config, { - flowName: options.flow, - }); - type = getPlatform(result.flowSettings); - } - - // Create deployment via API - const deployment = await createDeployment({ - type, - label: options.label, - projectId: options.project, - }); - - const result = deployment as Record; - - if (options.json) { - await writeResult(JSON.stringify(result, null, 2), options); - return; - } - - // Human-readable output. The create-deployment response does not include a - // deploy token: tokens are minted separately (admin-only) and never stored - // in plaintext, so we point the user at creating one rather than printing a - // missing field. - log.info(`Deployment created: ${result.id}`); - log.info(` Slug: ${result.slug}`); - log.info(` Type: ${result.type}`); - - if (type === 'server') { - for (const line of selfHostServerHint({ - config: isRemoteFlow ? 'flow.json' : config, - flowId: isRemoteFlow ? config : undefined, - projectId: options.project ?? requireProjectId(), - deploymentId: String(result.id), - appUrl: resolveAppUrl(), - version: VERSION, - })) - log.info(line); - } - } catch (err) { - handleCliError(err); - } -} - -/** - * The self-host instructions for a server deployment: build `dist/` with the - * CLI, then run it in the `walkeros/flow` image (or with `runneros`) pinned to - * this CLI's version. Mirrors the app's self-hosted command: the whole `dist/` - * directory mounted at `/app/flow`, the token passed through from the shell, - * and the project, flow and deployment ids the heartbeat needs. - * Keep in step with the app's CI-executed source of this shape, - * `components/src/flow/deploy/self-host-command.ts` (a copy, not an import: - * public packages never depend on the app). - */ -export function selfHostServerHint(input: { - config: string; - /** Known for a remote flow; a local config prints a placeholder. */ - flowId?: string; - projectId: string; - deploymentId: string; - appUrl: string; - version: string; -}): string[] { - const env: Array<[string, string]> = [ - ['WALKEROS_PROJECT_ID', input.projectId], - ['WALKEROS_FLOW_ID', input.flowId ?? ''], - ['WALKEROS_DEPLOYMENT_ID', input.deploymentId], - ['WALKEROS_APP_URL', input.appUrl], - ]; - return [ - '', - 'Create a deploy token for this flow in the app', - '(Settings, Self-hosted deploy token) and export it as', - 'WALKEROS_DEPLOY_TOKEN. Then build and run it with Docker:', - ` walkeros bundle ${input.config} -o dist/`, - ' docker run --rm -p 8080:8080 \\', - ' -v "$PWD/dist:/app/flow:ro" \\', - ' -e WALKEROS_DEPLOY_TOKEN \\', - ...env.map(([name, value]) => ` -e ${name}="${value}" \\`), - ` walkeros/flow:${input.version}`, - '', - 'Or run the built flow with Node, with the same variables exported:', - ` npx --yes --package=@walkeros/runner@${input.version} runneros start dist/flow.mjs`, - ]; -} diff --git a/packages/cli/src/commands/diagnostics/index.ts b/packages/cli/src/commands/diagnostics/index.ts index 6ac1f0dd5..a460be5cb 100644 --- a/packages/cli/src/commands/diagnostics/index.ts +++ b/packages/cli/src/commands/diagnostics/index.ts @@ -1,11 +1,8 @@ import { readConfig, resolveAppUrl } from '../../lib/config-file.js'; import { VERSION } from '../../version.js'; -import { - compareContract, - fetchHealth, - formatContract, -} from '../../core/contract.js'; -import type { ContractComparison, HealthResult } from '../../core/contract.js'; +import { appFetch } from '../../core/app-fetch.js'; +import { APP_PATHS } from '../../core/app-paths.js'; +import { fetchFailureReason } from '../../core/fetch-failure.js'; import { writeResult } from '../../core/output.js'; // === Programmatic API === @@ -13,11 +10,26 @@ import { writeResult } from '../../core/output.js'; /** Where the app URL comes from, in `resolveAppUrl()` order. */ export type AppUrlSource = 'env' | 'config' | 'default'; +export interface HealthResult { + reachable: boolean; + status?: string; + appVersion?: string; + /** The HTTP status, when the app answered with a non-2xx one. */ + httpStatus?: number; + /** Why the ping failed, when it did not reach the app. */ + error?: string; +} + export interface Diagnostics { cli: { version: string }; appUrl: { resolved: string; source: AppUrlSource }; app: HealthResult; - contract: ContractComparison; +} + +const HEALTH_TIMEOUT_MS = 5000; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } function appUrlSource(): AppUrlSource { @@ -27,25 +39,49 @@ function appUrlSource(): AppUrlSource { } /** - * This CLI's version, the app it talks to and where that URL comes from, the - * app's public health route and the contract verdict against the same app. - * Sends no credential, so it works logged out. + * Ping the app's public health route without a credential. Resolves + * `{ reachable: false, error }` only when the request did not reach the app; + * a non-2xx status still counts as reachable and is named in `httpStatus`. + * `baseUrl` defaults to `resolveAppUrl()`. + */ +export async function fetchHealth(baseUrl?: string): Promise { + try { + const response = await appFetch(APP_PATHS.health, { + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + ...(baseUrl === undefined ? {} : { baseUrl }), + }); + const body: unknown = await response.json().catch(() => undefined); + const result: HealthResult = { reachable: true }; + if (!response.ok) result.httpStatus = response.status; + if (isRecord(body)) { + if (typeof body.status === 'string') result.status = body.status; + if (typeof body.appVersion === 'string') + result.appVersion = body.appVersion; + } + return result; + } catch (error) { + return { + reachable: false, + error: fetchFailureReason(error, HEALTH_TIMEOUT_MS), + }; + } +} + +/** + * This CLI's version, the app it talks to and where that URL comes from, and + * whether the app answers its health route. Sends no credential, so it works + * logged out. */ export async function diagnostics(): Promise { const resolved = resolveAppUrl().replace(/\/+$/, ''); - const [app, contract] = await Promise.all([ - fetchHealth(resolved), - compareContract({ baseUrl: resolved }), - ]); return { cli: { version: VERSION }, appUrl: { resolved, source: appUrlSource() }, - app, - contract, + app: await fetchHealth(resolved), }; } -/** The report as text lines, one fact each. */ +/** The report as three text lines: cli, app, health. */ export function formatDiagnostics(report: Diagnostics): string[] { const { app } = report; const health = app.reachable @@ -60,7 +96,6 @@ export function formatDiagnostics(report: Diagnostics): string[] { `cli ${report.cli.version}`, `app ${report.appUrl.resolved} (${report.appUrl.source})`, health, - formatContract(report.contract), ]; } @@ -70,7 +105,7 @@ export interface DiagnosticsCommandOptions { json?: boolean; } -/** Prints the report. Exits 0 whatever the verdict. */ +/** Prints the report. Exits 0 whether or not the app answers. */ export async function diagnosticsCommand( options: DiagnosticsCommandOptions, ): Promise { diff --git a/packages/cli/src/commands/feedback/index.ts b/packages/cli/src/commands/feedback/index.ts deleted file mode 100644 index 56abf9fa5..000000000 --- a/packages/cli/src/commands/feedback/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createInterface } from 'readline'; -import { readConfig, writeConfig } from '../../lib/config-file.js'; -import { apiRequest } from '../../core/api-request.js'; -import { handleCliError } from '../../core/api-error.js'; -import { createCLILogger } from '../../core/cli-logger.js'; - -// === Programmatic API === - -export interface FeedbackOptions { - anonymous?: boolean; - version?: string; -} - -export async function feedback( - text: string, - options?: FeedbackOptions, -): Promise { - const config = readConfig(); - - const anonymous = options?.anonymous ?? config?.anonymousFeedback ?? true; - - const payload: { - text: string; - userId?: string; - projectId?: string; - version?: string; - } = { text }; - - if (options?.version) { - payload.version = options.version; - } - - if (!anonymous && config?.email) { - payload.userId = config.email; - const projectId = process.env.WALKEROS_PROJECT_ID; - if (projectId) { - payload.projectId = projectId; - } - } - - const response = await apiRequest('POST /api/feedback', { - auth: 'none', - body: payload, - }); - - if (!response.ok) { - throw new Error( - `Feedback submission failed: ${response.status} ${response.statusText}`, - ); - } -} - -// === CLI Command Handler === - -export async function feedbackCommand(text: string): Promise { - const logger = createCLILogger({}); - try { - const config = readConfig(); - let anonymous: boolean; - - if (config?.anonymousFeedback === undefined) { - // First time: prompt user for consent - const answer = await promptUser( - 'Include your user and project info with feedback? (y/N) ', - ); - const anonymousFeedback = !answer.toLowerCase().startsWith('y'); - - // Persist the choice - if (config) { - writeConfig({ ...config, anonymousFeedback }); - } else { - writeConfig({ - token: '', - email: '', - appUrl: '', - anonymousFeedback, - }); - } - anonymous = anonymousFeedback; - } else { - anonymous = config.anonymousFeedback; - } - - await feedback(text, { anonymous }); - logger.info('Feedback sent. Thanks!'); - } catch (error) { - handleCliError(error); - } -} - -function promptUser(question: string): Promise { - return new Promise((resolve) => { - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - }); - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} diff --git a/packages/cli/src/commands/flows/index.ts b/packages/cli/src/commands/flows/index.ts deleted file mode 100644 index f51ff62d8..000000000 --- a/packages/cli/src/commands/flows/index.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { loadJsonConfig } from '../../config/index.js'; -import { createApiClient } from '../../core/api-client.js'; -import { handleCliError, throwApiError } from '../../core/api-error.js'; -import { requireProjectId } from '../../core/auth.js'; -import { writeResult } from '../../core/output.js'; -import { isStdinPiped, readStdin } from '../../core/stdin.js'; -import type { paths } from '../../types/api.gen.js'; -import type { GlobalOptions } from '../../types/global.js'; -import { listProjects } from '../projects/index.js'; - -// === Types extracted from API spec === - -type FlowSummary = - paths['/api/projects/{projectId}/flows']['get']['responses']['200']['content']['application/json']['flows'][number]; - -type Project = - paths['/api/projects']['get']['responses']['200']['content']['application/json']['projects'][number]; - -export interface ProjectFlows { - project: { id: Project['id']; name: Project['name'] }; - flows: FlowSummary[]; -} - -// === Programmatic API === - -export interface ListFlowsOptions { - projectId?: string; - sort?: 'name' | 'updated_at' | 'created_at'; - order?: 'asc' | 'desc'; - includeDeleted?: boolean; - cursor?: string; - limit?: number; -} - -export async function listFlows(options: ListFlowsOptions = {}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.GET('/api/projects/{projectId}/flows', { - params: { - path: { projectId: id }, - query: { - sort: options.sort, - order: options.order, - include_deleted: options.includeDeleted ? 'true' : undefined, - cursor: options.cursor, - limit: options.limit, - }, - }, - }); - if (error) throwApiError(error, 'Failed to list flows'); - return data; -} - -export async function listAllFlows( - options?: Omit, -): Promise { - const { projects } = await listProjects(); - const results: ProjectFlows[] = []; - - for (const project of projects) { - const data = await listFlows({ ...options, projectId: project.id }); - results.push({ - project: { id: project.id, name: project.name }, - flows: data.flows, - }); - } - - return results; -} - -export async function getFlow(options: { - flowId: string; - projectId?: string; - fields?: string[]; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.GET( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { - path: { projectId: id, flowId: options.flowId }, - query: options.fields ? { fields: options.fields.join(',') } : {}, - }, - }, - ); - if (error) throwApiError(error, 'Failed to get flow'); - return data; -} - -export async function createFlow(options: { - name: string; - content: Record; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.POST('/api/projects/{projectId}/flows', { - params: { path: { projectId: id } }, - // Config is user-provided JSON; server validates the full schema - body: { name: options.name, config: options.content } as never, - }); - if (error) throwApiError(error, 'Failed to create flow'); - return data; -} - -export async function updateFlow(options: { - flowId: string; - name?: string; - content?: Record; - projectId?: string; - mergePatch?: boolean; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const body: Record = {}; - if (options.name !== undefined) body.name = options.name; - if (options.content !== undefined) body.config = options.content; - const { data, error } = await client.PATCH( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { path: { projectId: id, flowId: options.flowId } }, - // Dynamically constructed body; server validates the full schema - body: body as never, - ...(options.mergePatch && { - headers: { 'Content-Type': 'application/merge-patch+json' }, - }), - }, - ); - if (error) throwApiError(error, 'Failed to update flow'); - return data; -} - -export async function deleteFlow(options: { - flowId: string; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.DELETE( - '/api/projects/{projectId}/flows/{flowId}', - { - params: { path: { projectId: id, flowId: options.flowId } }, - }, - ); - if (error) throwApiError(error, 'Failed to delete flow'); - return data ?? { success: true }; -} - -export async function duplicateFlow(options: { - flowId: string; - name?: string; - projectId?: string; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.POST( - '/api/projects/{projectId}/flows/{flowId}/duplicate', - { - params: { path: { projectId: id, flowId: options.flowId } }, - body: { name: options.name }, - }, - ); - if (error) throwApiError(error, 'Failed to duplicate flow'); - return data; -} - -// === CLI Command Handlers === - -interface FlowsCommandOptions extends GlobalOptions { - json?: boolean; - output?: string; - project?: string; -} - -async function handleResult( - fn: () => Promise, - options: FlowsCommandOptions, -): Promise { - try { - const result = await fn(); - await writeResult(JSON.stringify(result, null, 2), options); - } catch (error) { - handleCliError(error); - } -} - -export async function listFlowsCommand( - options: FlowsCommandOptions & { - sort?: string; - order?: string; - includeDeleted?: boolean; - cursor?: string; - limit?: number; - }, -): Promise { - await handleResult( - () => - listFlows({ - projectId: options.project, - sort: options.sort as ListFlowsOptions['sort'], - order: options.order as ListFlowsOptions['order'], - includeDeleted: options.includeDeleted, - cursor: options.cursor, - limit: options.limit, - }), - options, - ); -} - -export async function getFlowCommand( - flowId: string, - options: FlowsCommandOptions, -): Promise { - await handleResult( - () => getFlow({ flowId, projectId: options.project }), - options, - ); -} - -export async function createFlowCommand( - name: string, - options: FlowsCommandOptions & { content?: string }, -): Promise { - await handleResult(async () => { - const content = options.content - ? await loadFlowContent(options.content) - : JSON.parse(await readFlowStdin()); - return createFlow({ name, content, projectId: options.project }); - }, options); -} - -export async function updateFlowCommand( - flowId: string, - options: FlowsCommandOptions & { name?: string; content?: string }, -): Promise { - await handleResult( - async () => - updateFlow({ - flowId, - name: options.name, - content: options.content - ? await loadFlowContent(options.content) - : undefined, - projectId: options.project, - }), - options, - ); -} - -export async function deleteFlowCommand( - flowId: string, - options: FlowsCommandOptions, -): Promise { - await handleResult( - () => deleteFlow({ flowId, projectId: options.project }), - options, - ); -} - -export async function duplicateFlowCommand( - flowId: string, - options: FlowsCommandOptions & { name?: string }, -): Promise { - await handleResult( - () => - duplicateFlow({ flowId, name: options.name, projectId: options.project }), - options, - ); -} - -/** `--content` takes inline JSON, a file path or a URL. */ -function loadFlowContent(input: string): Promise> { - return loadJsonConfig>(input); -} - -async function readFlowStdin(): Promise { - if (!isStdinPiped()) { - throw new Error('Content required: use --content or pipe via stdin'); - } - return readStdin(); -} diff --git a/packages/cli/src/commands/frames/index.ts b/packages/cli/src/commands/frames/index.ts deleted file mode 100644 index 7cb7e278a..000000000 --- a/packages/cli/src/commands/frames/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ResponseJson } from '../../core/api-request.js'; -import { throwApiResponseError } from '../../core/api-error.js'; -import type { components } from '../../types/api.gen.js'; - -type FrameResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/frames/{frameId}', - 200 ->; -// The list operation answers one of two shapes, chosen by `pageKey`. -type FrameListResponse = components['schemas']['FrameListResponse']; -type FrameLeanListResponse = components['schemas']['FrameLeanListResponse']; - -async function readJson(response: Response, fallback: string): Promise { - if (!response.ok) { - const body: unknown = await response.json().catch(() => ({})); - throwApiResponseError(response, body, fallback); - } - return response.json(); -} - -// Frames belong to a flow: every read names the flow they live in. - -export interface ListFramesOptions { - projectId?: string; - flowId: string; -} - -/** Every live frame of the flow, without marks. Requires the frames feature. */ -export async function listFrames( - options: ListFramesOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/frames', - { path: { projectId: pid, flowId: options.flowId } }, - ); - return readJson(response, 'Failed to list frames'); -} - -export interface ListPageFramesOptions { - projectId?: string; - flowId: string; - pageKey: string; -} - -/** The frames of the flow on one page, with their marks. */ -export async function listPageFrames( - options: ListPageFramesOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/frames', - { - path: { projectId: pid, flowId: options.flowId }, - query: { pageKey: options.pageKey }, - }, - ); - return readJson(response, 'Failed to list page frames'); -} - -export interface GetFrameOptions { - projectId?: string; - flowId: string; - frameId: string; -} - -export async function getFrame( - options: GetFrameOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/frames/{frameId}', - { - path: { - projectId: pid, - flowId: options.flowId, - frameId: options.frameId, - }, - }, - ); - return readJson(response, 'Failed to read frame'); -} diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts deleted file mode 100644 index 5ee9d1f91..000000000 --- a/packages/cli/src/commands/hub/index.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ApiRequestInit, ResponseJson } from '../../core/api-request.js'; -import { throwApiResponseError } from '../../core/api-error.js'; -import type { components } from '../../types/api.gen.js'; - -type VersionAnnotation = ResponseJson< - 'PUT /api/projects/{projectId}/flows/{flowId}/releases/annotations', - 200 ->; -type StepHistoryResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/releases/step-history', - 200 ->; -type ListHubThreadsResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/threads', - 200 ->; -type HubThreadResponse = ResponseJson< - 'POST /api/projects/{projectId}/flows/{flowId}/threads', - 201 ->; -type ListKnowledgeResponse = ResponseJson< - 'GET /api/projects/{projectId}/knowledge', - 200 ->; - -// === Release wire shapes, aliased onto the generated components === - -/** The rationale summary a release index row carries when asked for one. */ -export type ReleaseRationaleSummary = - components['schemas']['ReleaseRationaleSummary']; - -/** The release index. Each row carries `rationale` when one was asked for. */ -export type ReleaseIndexResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/releases', - 200 ->; - -/** The diff a release carries against its spine predecessor. */ -export type ReleaseDiffResponse = components['schemas']['ReleaseDiff']; - -/** One release in full: rationale plus the diff the server computed. */ -export type ReleaseDetailResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/releases/{versionId}', - 200 ->; - -// === Programmatic API === - -async function readJson(response: Response, fallback: string): Promise { - if (!response.ok) { - const body: unknown = await response.json().catch(() => ({})); - throwApiResponseError(response, body, fallback); - } - return response.json(); -} - -export interface ListReleasesOptions { - projectId?: string; - flowId: string; - limit?: number; - offset?: number; -} - -/** The release index WITH its rationale summary. Requires the hub feature. */ -export async function listReleases( - options: ListReleasesOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/releases', - { - path: { projectId: pid, flowId: options.flowId }, - query: { - rationale: 'true', - limit: options.limit, - offset: options.offset, - }, - }, - ); - return readJson(response, 'Failed to list releases'); -} - -/** How a release is addressed: by spine id, or by spine number. */ -export type ReleaseRef = { versionId: string } | { versionNumber: number }; - -export interface GetReleaseOptions { - projectId?: string; - flowId: string; - ref: ReleaseRef; -} - -/** - * One release in full: rationale plus the diff the SERVER computed against the - * spine predecessor. The path segment is the id or the number; the app decides - * which it was. - */ -export async function getRelease( - options: GetReleaseOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const segment = - 'versionId' in options.ref - ? options.ref.versionId - : String(options.ref.versionNumber); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/releases/{versionId}', - { path: { projectId: pid, flowId: options.flowId, versionId: segment } }, - ); - return readJson(response, 'Failed to read release'); -} - -export interface ListStepHistoryOptions { - projectId?: string; - flowId: string; - step: string; - flow?: string; - limit?: number; -} - -export async function listStepHistory( - options: ListStepHistoryOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/releases/step-history', - { - path: { projectId: pid, flowId: options.flowId }, - query: { step: options.step, flow: options.flow, limit: options.limit }, - }, - ); - return readJson(response, 'Failed to read step history'); -} - -export interface SetReleaseRationaleOptions { - projectId?: string; - flowId: string; - versionId: string; - /** - * The rationale to store. `null` CLEARS the one already there: `humanText` - * is the only field a client may write, and null is how the route says - * "remove it". Without it there would be no way back from a rationale - * written by mistake. - */ - text: string | null; -} - -export async function setReleaseRationale( - options: SetReleaseRationaleOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'PUT /api/projects/{projectId}/flows/{flowId}/releases/annotations', - { - path: { projectId: pid, flowId: options.flowId }, - body: { versionId: options.versionId, humanText: options.text }, - }, - ); - return readJson(response, 'Failed to write release rationale'); -} - -type ListThreadsQuery = NonNullable< - ApiRequestInit<'GET /api/projects/{projectId}/flows/{flowId}/threads'>['query'] ->; -/** What a thread anchors to, as the contract declares it. */ -export type ThreadAnchorType = NonNullable; -/** A thread's status, as the contract declares it. */ -export type ThreadStatus = NonNullable; - -export interface ListThreadsOptions { - projectId?: string; - flowId: string; - anchorType?: ThreadAnchorType; - anchorKey?: string; - status?: ThreadStatus; - includeMessages: boolean; - limit?: number; -} - -export async function listThreads( - options: ListThreadsOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/threads', - { - path: { projectId: pid, flowId: options.flowId }, - query: { - anchorType: options.anchorType, - anchorKey: options.anchorKey, - status: options.status, - includeMessages: options.includeMessages ? 'true' : 'false', - limit: options.limit, - }, - }, - ); - return readJson(response, 'Failed to list threads'); -} - -export interface CreateThreadOptions { - projectId?: string; - flowId: string; - anchorType: ThreadAnchorType; - anchorKey: string; - anchorLabel?: string; - text: string; -} - -export async function createThread( - options: CreateThreadOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/threads', - { - path: { projectId: pid, flowId: options.flowId }, - body: { - anchorType: options.anchorType, - anchorKey: options.anchorKey, - ...(options.anchorLabel !== undefined - ? { anchorLabel: options.anchorLabel } - : {}), - text: options.text, - }, - }, - ); - return readJson(response, 'Failed to open thread'); -} - -export interface AddThreadMessageOptions { - projectId?: string; - flowId: string; - threadId: string; - text: string; -} - -export async function addThreadMessage( - options: AddThreadMessageOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/threads/{threadId}/messages', - { - path: { - projectId: pid, - flowId: options.flowId, - threadId: options.threadId, - }, - body: { text: options.text }, - }, - ); - return readJson(response, 'Failed to add message'); -} - -export interface ListKnowledgeOptions { - projectId?: string; - pageKey?: string; - frameId?: string; - markId?: string; - includeMessages: boolean; - limit?: number; -} - -export async function listKnowledge( - options: ListKnowledgeOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest('GET /api/projects/{projectId}/knowledge', { - path: { projectId: pid }, - query: { - pageKey: options.pageKey, - frameId: options.frameId, - markId: options.markId, - includeMessages: options.includeMessages ? 'true' : 'false', - limit: options.limit, - }, - }); - return readJson(response, 'Failed to read knowledge'); -} diff --git a/packages/cli/src/commands/journeys/index.ts b/packages/cli/src/commands/journeys/index.ts deleted file mode 100644 index 5aa297f6c..000000000 --- a/packages/cli/src/commands/journeys/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import { throwApiError } from '../../core/api-error.js'; - -// === Programmatic API === - -export interface ListJourneysOptions { - projectId?: string; - flowId: string; - /** Return only journeys for one trace, when given. */ - traceId?: string; - /** Max journeys to return (most recent kept). */ - limit?: number; -} - -/** - * Read a flow's active Observe session journeys from the app. The flow's session - * is resolved app-side (`observe_sessions.flow_id` is UNIQUE), so the caller - * passes `flowId`, not a session id; a flow with no active session returns an - * envelope with `sessionId: null` and empty journeys rather than an error. - */ -export async function listJourneys(options: ListJourneysOptions) { - const id = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/journeys', - { - path: { projectId: id, flowId: options.flowId }, - query: { traceId: options.traceId || undefined, limit: options.limit }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to read flow journeys'); - } - return response.json(); -} diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index c5d027ae1..e1931b7ca 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -12,7 +12,8 @@ import { getConfigPath, } from '../../lib/config-file.js'; import { requireSecureUrl } from '../../lib/secure-url.js'; -import { apiRequest } from '../../core/api-request.js'; +import { appFetch } from '../../core/app-fetch.js'; +import { APP_PATHS } from '../../core/app-paths.js'; import type { GlobalOptions } from '../../types/global.js'; /** @@ -175,9 +176,9 @@ async function fetchEmail( fetchFn: typeof globalThis.fetch, ): Promise { try { - const response = await apiRequest('GET /api/auth/whoami', { - auth: { token: accessToken }, + const response = await appFetch(APP_PATHS.whoami, { baseUrl: appUrl, + token: accessToken, fetch: fetchFn, signal: AbortSignal.timeout(WHOAMI_TIMEOUT_MS), }); @@ -208,8 +209,8 @@ async function persistSession( token: undefined, }); - // Unconditional: a stale address is worse than none, since `walkeros - // feedback` sends it as the reporter's identity. + // Unconditional: a stale address is worse than none, since it names the + // account this session belongs to. const email = await fetchEmail(appUrl, tokens.accessToken, fetchFn); writeConfig({ email }); } diff --git a/packages/cli/src/commands/observe/__tests__/observe.test.ts b/packages/cli/src/commands/observe/__tests__/observe.test.ts deleted file mode 100644 index 2bee7323f..000000000 --- a/packages/cli/src/commands/observe/__tests__/observe.test.ts +++ /dev/null @@ -1,722 +0,0 @@ -/** - * `walkeros observe start ` - thin client of the app's observe-session - * mint boundary. - * - * Env-injection only: tests set WALKEROS_* env vars and replace - * `globalThis.fetch` with a router closure; no module mocks. The command sends - * and renders the CURRENT generated API contract - * (`CreateObserveSessionRequest { settingsName, replace?, level? }`, - * `ObserveSessionResponse { ..., web: { activationUrl, credential, ... }, - * server: { endpoint, env }, expiresAt, recordsReceived }`). The pins assert - * the CLI renders exactly what the contract carries - the app-minted - * activation URL (which rides the elbObserve companion), the web credential, - * the server env trio, the expiry deadline, and the record count - and reads - * the server part from `server.env`/`server.endpoint`, never the transitional - * top-level `serverEndpoint` mirror. - */ -import { mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import type { components } from '../../../types/api.gen.js'; -import { - startObserveSession, - getObserveSession, - endObserveSession, - formatObserveSession, - observeStartCommand, -} from '../index.js'; - -type ObserveSessionResponse = components['schemas']['ObserveSessionResponse']; - -// === Env-injection harness === - -const ENV_KEYS = [ - 'WALKEROS_APP_URL', - 'WALKEROS_TOKEN', - 'WALKEROS_PROJECT_ID', - 'XDG_CONFIG_HOME', -] as const; - -const savedEnv: Partial> = - {}; -const realFetch = globalThis.fetch; - -interface RecordedCall { - url: string; - method: string; - body: unknown; -} - -/** Replace globalThis.fetch with a router; returns the recorded calls. */ -function installFetch( - handler: (url: string, init?: RequestInit) => Response | Promise, -): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url; - calls.push({ - url, - method: init?.method ?? 'GET', - body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, - }); - return handler(url, init); - }; - return calls; -} - -function json(body: unknown, status: number): Response { - return new Response(JSON.stringify(body), { status }); -} - -// === Fixtures (shaped by the CURRENT ObserveSessionResponse contract) === - -const ACTIVATION_URL = - 'https://shop.example/?elbPreview=eyJncjRudA.gr4nt.s1g&elbObserve=obsw_pb1.ses_abc123.webtok'; -const CREDENTIAL = 'obsw_pb1.ses_abc123.webtok'; -const SERVER_ENDPOINT = 'https://obs-ses-abc123.containers.test'; - -const liveSession: ObserveSessionResponse = { - id: 'ses_abc123', - projectId: 'proj_test', - flowId: 'fl_1', - status: 'live', - errorMessage: null, - configSnapshot: {}, - observedFlowName: 'web', - serverFlowName: 'server', - // Transitional mirror of server.endpoint; the CLI must not consume it. - serverEndpoint: SERVER_ENDPOINT, - web: { - activationUrl: ACTIVATION_URL, - credential: CREDENTIAL, - previewEnabled: true, - bundleUrl: 'https://cdn.test/preview/proj_test/walker.k9x2m4p7abcd.js', - }, - server: { - endpoint: SERVER_ENDPOINT, - env: { - WALKEROS_OBSERVER_URL: 'https://observer.test', - WALKEROS_DEPLOYMENT_ID: 'ses_abc123', - WALKEROS_INGEST_TOKEN: 'srv_ingest_tok', - }, - }, - expiresAt: '2026-07-19T00:00:00.000Z', - recordsReceived: 42, - createdBy: 'user_1', - createdAt: '2026-07-18T00:00:00.000Z', -}; - -const armingSession: ObserveSessionResponse = { - ...liveSession, - status: 'arming', - serverEndpoint: null, - web: null, - server: null, - recordsReceived: 0, -}; - -const failedSession: ObserveSessionResponse = { - ...armingSession, - status: 'failed', - errorMessage: 'container provision timed out', -}; - -const unauthorizedBody = { - error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, -}; - -beforeEach(() => { - for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; - process.env.WALKEROS_APP_URL = 'https://app.test'; - process.env.WALKEROS_TOKEN = 'tok_test'; - process.env.WALKEROS_PROJECT_ID = 'proj_test'; - // Point config reads at an empty dir so a real ~/.config/walkeros login on - // the dev machine can never leak into these tests. - process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), 'observe-cli-')); -}); - -afterEach(() => { - for (const key of ENV_KEYS) { - const value = savedEnv[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - globalThis.fetch = realFetch; - process.exitCode = undefined; - jest.restoreAllMocks(); -}); - -// === Formatter === - -describe('formatObserveSession', () => { - it('renders the web block from a live session: activation URL, credential, bundle URL', () => { - const { stderr } = formatObserveSession(liveSession); - expect(stderr).toContain('ses_abc123'); - expect(stderr).toContain('live'); - // The app-minted activation URL is echoed verbatim; it already carries the - // elbObserve companion, so the credential rides the link. - expect(stderr).toContain(ACTIVATION_URL); - expect(stderr).toContain('elbObserve='); - expect(stderr).toContain(CREDENTIAL); - expect(stderr).toContain( - 'https://cdn.test/preview/proj_test/walker.k9x2m4p7abcd.js', - ); - }); - - it('renders the server env trio, expiry, and record count from the contract', () => { - const { stderr } = formatObserveSession(liveSession); - expect(stderr).toContain('WALKEROS_OBSERVER_URL=https://observer.test'); - expect(stderr).toContain('WALKEROS_DEPLOYMENT_ID=ses_abc123'); - expect(stderr).toContain('WALKEROS_INGEST_TOKEN=srv_ingest_tok'); - expect(stderr).toContain('2026-07-19T00:00:00.000Z'); - expect(stderr).toContain('42'); - }); - - it('consumes server.endpoint, never the transitional top-level serverEndpoint', () => { - // A divergent fixture proves which field the CLI reads. The app mirrors - // server.endpoint onto serverEndpoint for published consumers; this CLI - // consumes the new shape. - const divergent: ObserveSessionResponse = { - ...liveSession, - serverEndpoint: 'https://legacy.transitional.test', - }; - const { stdoutLast, stderr } = formatObserveSession(divergent); - expect(stdoutLast).toBe(SERVER_ENDPOINT); - expect(stderr).toContain(SERVER_ENDPOINT); - expect(stderr).not.toContain('https://legacy.transitional.test'); - }); - - it('points at a redeploy when the web deployment cannot verify grants', () => { - const withoutPreview: ObserveSessionResponse = { - ...liveSession, - web: liveSession.web - ? { ...liveSession.web, activationUrl: null, previewEnabled: false } - : null, - }; - const { stderr } = formatObserveSession(withoutPreview); - expect(stderr).toContain('redeploy'); - expect(stderr).not.toContain('null'); - }); - - it('renders an arming session without printing null', () => { - const { stdoutLast, stderr } = formatObserveSession(armingSession); - expect(stderr).toContain('arming'); - expect(stderr).not.toContain('null'); - expect(stdoutLast).toBeNull(); - }); - - it('renders a failed session with its error message', () => { - const { stdoutLast, stderr } = formatObserveSession(failedSession); - expect(stderr).toContain('failed'); - expect(stderr).toContain('container provision timed out'); - expect(stdoutLast).toBeNull(); - }); - - it('states once that sessions idle out server-side', () => { - const { stderr } = formatObserveSession(liveSession); - expect(stderr.match(/idle out server-side/g)).toHaveLength(1); - }); -}); - -// === Programmatic mint === - -describe('startObserveSession', () => { - it('POSTs settingsName, replace, and level to the observe-sessions mint route', async () => { - const calls = installFetch(() => json(armingSession, 201)); - const result = await startObserveSession({ - projectId: 'proj_x', - flowId: 'fl_1', - settingsName: 'demo', - replace: true, - level: 'trace', - }); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe( - 'https://app.test/api/projects/proj_x/flows/fl_1/observe-sessions', - ); - expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.body).toEqual({ - settingsName: 'demo', - replace: true, - level: 'trace', - }); - expect(result).toEqual(armingSession); - }); - - it('falls back to WALKEROS_PROJECT_ID and omits replace/level when not set', async () => { - const calls = installFetch(() => json(armingSession, 201)); - await startObserveSession({ flowId: 'fl_1', settingsName: 'demo' }); - expect(calls[0]?.url).toBe( - 'https://app.test/api/projects/proj_test/flows/fl_1/observe-sessions', - ); - expect(calls[0]?.body).toEqual({ settingsName: 'demo' }); - }); - - it('sends origins through to the mint body when given', async () => { - const calls = installFetch(() => json(armingSession, 201)); - await startObserveSession({ - flowId: 'fl_1', - settingsName: 'demo', - origins: ['https://shop.example', 'https://checkout.example'], - }); - expect(calls[0]?.body).toEqual({ - settingsName: 'demo', - origins: ['https://shop.example', 'https://checkout.example'], - }); - }); - - it('omits an empty origins array rather than sending a bare []', async () => { - const calls = installFetch(() => json(armingSession, 201)); - await startObserveSession({ - flowId: 'fl_1', - settingsName: 'demo', - origins: [], - }); - expect(calls[0]?.body).toEqual({ settingsName: 'demo' }); - }); - - it('throws an ApiError carrying the HTTP status on failure', async () => { - installFetch(() => - json( - { error: { code: 'CONFLICT', message: 'Flow topology not supported' } }, - 409, - ), - ); - await expect( - startObserveSession({ flowId: 'fl_1', settingsName: 'demo' }), - ).rejects.toMatchObject({ - name: 'ApiError', - status: 409, - message: 'Flow topology not supported', - }); - }); -}); - -describe('getObserveSession', () => { - it('GETs the session route and returns the contract envelope', async () => { - const calls = installFetch(() => json(liveSession, 200)); - const result = await getObserveSession({ - projectId: 'proj_x', - flowId: 'fl_1', - sessionId: 'ses_abc123', - }); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe( - 'https://app.test/api/projects/proj_x/flows/fl_1/observe-sessions/ses_abc123', - ); - expect(calls[0]?.method).toBe('GET'); - expect(result).toEqual(liveSession); - }); -}); - -// === Programmatic end === - -describe('endObserveSession', () => { - it('DELETEs the session route and resolves on 204', async () => { - const calls = installFetch(() => new Response(null, { status: 204 })); - await expect( - endObserveSession({ - projectId: 'proj_x', - flowId: 'fl_1', - sessionId: 'ses_abc123', - }), - ).resolves.toBeUndefined(); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe( - 'https://app.test/api/projects/proj_x/flows/fl_1/observe-sessions/ses_abc123', - ); - expect(calls[0]?.method).toBe('DELETE'); - // The end route takes no body; the session is addressed by path alone. - expect(calls[0]?.body).toBeUndefined(); - }); - - it('falls back to WALKEROS_PROJECT_ID when no project is given', async () => { - const calls = installFetch(() => new Response(null, { status: 204 })); - await endObserveSession({ flowId: 'fl_1', sessionId: 'ses_abc123' }); - expect(calls[0]?.url).toBe( - 'https://app.test/api/projects/proj_test/flows/fl_1/observe-sessions/ses_abc123', - ); - }); - - it('throws an ApiError carrying the HTTP status on failure', async () => { - installFetch(() => - json( - { error: { code: 'NOT_FOUND', message: 'Observe session not found' } }, - 404, - ), - ); - await expect( - endObserveSession({ flowId: 'fl_1', sessionId: 'ses_gone' }), - ).rejects.toMatchObject({ - name: 'ApiError', - status: 404, - message: 'Observe session not found', - }); - }); - - it('surfaces a 401 as an ApiError so a stale token fails loudly', async () => { - installFetch(() => json(unauthorizedBody, 401)); - await expect( - endObserveSession({ flowId: 'fl_1', sessionId: 'ses_abc123' }), - ).rejects.toMatchObject({ name: 'ApiError', status: 401 }); - }); -}); - -// === Command === - -describe('observeStartCommand', () => { - let stderrSpy: jest.SpyInstance; - let stdoutSpy: jest.SpyInstance; - let exitSpy: jest.SpyInstance; - let consoleErrorSpy: jest.SpyInstance; - - const stderrText = () => - stderrSpy.mock.calls.map((call) => String(call[0])).join(''); - const stdoutText = () => - stdoutSpy.mock.calls.map((call) => String(call[0])).join(''); - // handleCliError prints its machine-readable line via console.error. - const consoleErrorText = () => - consoleErrorSpy.mock.calls.map((call) => String(call[0])).join('\n'); - - beforeEach(() => { - stderrSpy = jest - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - stdoutSpy = jest - .spyOn(process.stdout, 'write') - .mockImplementation(() => true); - consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - // A wrong code path calling process.exit must fail the test, not kill the - // jest worker. - exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`process.exit(${String(code)})`); - }); - }); - - it('mints, waits for the session to settle, and prints the session blocks', async () => { - let sessionGets = 0; - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat') && init?.method === 'POST') { - return json({ ok: true }, 200); - } - if (url.endsWith('/observe-sessions/ses_abc123')) { - sessionGets += 1; - return json(sessionGets === 1 ? armingSession : liveSession, 200); - } - throw new Error(`Unexpected request: ${url}`); - }); - - await observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }); - - expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.body).toEqual({ settingsName: 'demo' }); - // Poll loop kept going until the session left `arming`. - expect(sessionGets).toBeGreaterThanOrEqual(2); - const err = stderrText(); - expect(err).toContain('ses_abc123'); - expect(err).toContain(ACTIVATION_URL); - expect(err).toContain('WALKEROS_INGEST_TOKEN=srv_ingest_tok'); - expect(err).toContain(SERVER_ENDPOINT); - expect(stdoutText()).toContain(SERVER_ENDPOINT); - expect(exitSpy).not.toHaveBeenCalled(); - expect(process.exitCode).toBeUndefined(); - }); - - it('heartbeats the session on each wait poll tick', async () => { - let sessionGets = 0; - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat') && init?.method === 'POST') { - return json({ ok: true }, 200); - } - if (url.endsWith('/observe-sessions/ses_abc123')) { - sessionGets += 1; - return json(sessionGets < 3 ? armingSession : liveSession, 200); - } - throw new Error(`Unexpected request: ${url}`); - }); - - await observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }); - - const heartbeats = calls.filter( - (call) => - call.method === 'POST' && - call.url === - 'https://app.test/api/projects/proj_test/flows/fl_1/observe-sessions/ses_abc123/heartbeat', - ); - // One heartbeat per poll tick keeps the session out of janitor reach - // while the user is actively setting up. - expect(heartbeats.length).toBeGreaterThanOrEqual(3); - }); - - it('a failing heartbeat never gates the poll: the session still settles', async () => { - let sessionGets = 0; - installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat') && init?.method === 'POST') { - // Heartbeat extends grace, never gates: a 500 here must not surface. - return json({ error: { code: 'INTERNAL', message: 'boom' } }, 500); - } - if (url.endsWith('/observe-sessions/ses_abc123')) { - sessionGets += 1; - return json(sessionGets === 1 ? armingSession : liveSession, 200); - } - throw new Error(`Unexpected request: ${url}`); - }); - - await observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }); - - expect(stderrText()).toContain(SERVER_ENDPOINT); - expect(process.exitCode).toBeUndefined(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it('with --no-wait sends no heartbeat: liveness never depends on this process', async () => { - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - throw new Error(`Unexpected request: ${url}`); - }); - - await observeStartCommand('fl_1', { - flow: 'demo', - wait: false, - pollIntervalMs: 0, - }); - - expect(calls).toHaveLength(1); - expect(calls.some((call) => call.url.endsWith('/heartbeat'))).toBe(false); - }); - - it('maps --replace onto the contract replace field', async () => { - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat')) return json({ ok: true }, 200); - return json(liveSession, 200); - }); - - await observeStartCommand('fl_1', { - flow: 'demo', - replace: true, - pollIntervalMs: 0, - }); - - expect(calls[0]?.body).toEqual({ settingsName: 'demo', replace: true }); - }); - - it('sends --level through to the mint body', async () => { - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat')) return json({ ok: true }, 200); - return json(liveSession, 200); - }); - - await observeStartCommand('fl_1', { - flow: 'demo', - level: 'trace', - pollIntervalMs: 0, - }); - - expect(calls[0]?.body).toEqual({ settingsName: 'demo', level: 'trace' }); - }); - - it('rejects an invalid --level loudly before any network call', async () => { - const calls = installFetch(() => json(armingSession, 201)); - - await expect( - observeStartCommand('fl_1', { - flow: 'demo', - level: 'full', - pollIntervalMs: 0, - }), - ).rejects.toThrow('process.exit(1)'); - - expect(calls).toHaveLength(0); - expect(consoleErrorText()).toContain('Invalid --level'); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it('resolves the settings name from the flow when --flow is omitted and only one exists', async () => { - const calls = installFetch((url, init) => { - if (url.includes('/flows/fl_1') && !url.includes('observe-sessions')) { - return json( - { - id: 'fl_1', - name: 'Demo flow', - config: {}, - settings: [{ id: 'fs_1', name: 'production' }], - createdAt: '2026-07-18T00:00:00.000Z', - updatedAt: '2026-07-18T00:00:00.000Z', - deletedAt: null, - }, - 200, - ); - } - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat')) return json({ ok: true }, 200); - return json(liveSession, 200); - }); - - await observeStartCommand('fl_1', { pollIntervalMs: 0 }); - - const mint = calls.find( - (call) => call.method === 'POST' && !call.url.endsWith('/heartbeat'), - ); - expect(mint?.body).toEqual({ settingsName: 'production' }); - }); - - it('with a token, a 401 fails loudly via the machine error line, not the CTA', async () => { - // Stale/invalid credentials (the CI case): a script must see a failure, - // never success-with-empty-stdout. The signup CTA is only for the - // no-token-at-all funnel. - installFetch(() => json(unauthorizedBody, 401)); - - await expect( - observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }), - ).rejects.toThrow('process.exit(1)'); - - expect(consoleErrorText()).toContain('error: code=UNAUTHORIZED'); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(stderrText()).not.toContain('walkeros auth login'); - }); - - it('missing token prints the CTA without any network call, exit stays clean', async () => { - delete process.env.WALKEROS_TOKEN; - const calls = installFetch(() => json(unauthorizedBody, 401)); - - await observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }); - - expect(calls).toHaveLength(0); - expect(stderrText()).toContain('walkeros auth login'); - // Non-fatal funnel: no exit call, no failure exit code, no machine error line. - expect(exitSpy).not.toHaveBeenCalled(); - expect(process.exitCode).toBeUndefined(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('is loud when --timeout expires with the session still arming', async () => { - installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat')) return json({ ok: true }, 200); - return json(armingSession, 200); - }); - - await observeStartCommand('fl_1', { - flow: 'demo', - timeout: '0', - pollIntervalMs: 0, - }); - - const err = stderrText(); - // The still-arming state is rendered, but the expiry is explicit and the - // exit must not read as success. - expect(err).toContain('arming'); - expect(err).toContain('Timed out after 0s'); - expect(process.exitCode).toBe(1); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it('rejects a non-numeric --timeout loudly before any network call', async () => { - const calls = installFetch(() => json(armingSession, 201)); - - await expect( - observeStartCommand('fl_1', { - flow: 'demo', - timeout: 'soon', - pollIntervalMs: 0, - }), - ).rejects.toThrow('process.exit(1)'); - - expect(calls).toHaveLength(0); - expect(consoleErrorText()).toContain('Invalid --timeout'); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it('rejects a partially-numeric or empty --timeout (no silent truncation)', async () => { - // parseInt would read `10seconds` as 10; a unit typo like `5m` must not - // silently become 5 seconds. Empty and non-finite values are equally out. - for (const bad of ['10seconds', '5m', '', ' ', 'Infinity']) { - // Each case must prove its own rejection: the spies accumulate across - // iterations, so a stale message from a prior case could mask a miss. - consoleErrorSpy.mockClear(); - exitSpy.mockClear(); - const calls = installFetch(() => json(armingSession, 201)); - - await expect( - observeStartCommand('fl_1', { - flow: 'demo', - timeout: bad, - pollIntervalMs: 0, - }), - ).rejects.toThrow('process.exit(1)'); - - expect(calls).toHaveLength(0); - expect(consoleErrorText()).toContain('Invalid --timeout'); - expect(exitSpy).toHaveBeenCalledWith(1); - } - }); - - it('sets a non-zero exit code when the session settles failed', async () => { - installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - if (url.endsWith('/heartbeat')) return json({ ok: true }, 200); - return json(failedSession, 200); - }); - - await observeStartCommand('fl_1', { flow: 'demo', pollIntervalMs: 0 }); - - expect(stderrText()).toContain('container provision timed out'); - expect(process.exitCode).toBe(1); - // exitCode over process.exit: the message still prints and jest survives. - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it('with --no-wait renders the mint response as-is without polling', async () => { - const calls = installFetch((url, init) => { - if (url.endsWith('/observe-sessions') && init?.method === 'POST') { - return json(armingSession, 201); - } - throw new Error(`Unexpected request: ${url}`); - }); - - await observeStartCommand('fl_1', { - flow: 'demo', - wait: false, - pollIntervalMs: 0, - }); - - expect(calls).toHaveLength(1); - expect(stderrText()).toContain('arming'); - // Opting out of waiting is not a timeout: exit stays clean. - expect(stderrText()).not.toContain('Timed out'); - expect(process.exitCode).toBeUndefined(); - }); -}); diff --git a/packages/cli/src/commands/observe/index.ts b/packages/cli/src/commands/observe/index.ts deleted file mode 100644 index f7bc6d80b..000000000 --- a/packages/cli/src/commands/observe/index.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ResponseJson } from '../../core/api-request.js'; -import { handleCliError, throwApiResponseError } from '../../core/api-error.js'; -import { writeResult } from '../../core/output.js'; -import { credentialSource } from '../../core/auth.js'; -import type { GlobalOptions } from '../../types/global.js'; -import type { components } from '../../types/api.gen.js'; -import { getFlow } from '../flows/index.js'; - -type ObserveSessionResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}', - 200 ->; -type CreateObserveSessionRequest = - components['schemas']['CreateObserveSessionRequest']; -type ObserveLevel = components['schemas']['ObserveLevel']; - -const OBSERVE_LEVELS: readonly ObserveLevel[] = ['off', 'standard', 'trace']; - -function isObserveLevel(value: string): value is ObserveLevel { - return (OBSERVE_LEVELS as readonly string[]).includes(value); -} - -// === Programmatic API === - -export interface StartObserveSessionOptions { - projectId?: string; - flowId: string; - /** Flow settings name; the mint contract requires it. */ - settingsName: string; - /** Replace an existing active session for the flow (`--replace`). */ - replace?: boolean; - /** Observation detail level for the session. */ - level?: ObserveLevel; - /** Origins the session's web activation grant may activate on. */ - origins?: string[]; -} - -/** - * Mint an observe session via the app's authenticated boundary. Thin client: - * the app validates topology, inserts the row, and provisions detached; the - * mint response comes back immediately (usually `arming`, with the web and - * server parts still null - GET assembles them once the session settles). - */ -export async function startObserveSession( - options: StartObserveSessionOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const body: CreateObserveSessionRequest = { - settingsName: options.settingsName, - ...(options.replace ? { replace: true } : {}), - ...(options.level !== undefined ? { level: options.level } : {}), - // An empty list carries no origin the grant could bind to, so it is - // omitted rather than sent as a bare [] the app would have to interpret. - ...(options.origins?.length ? { origins: options.origins } : {}), - }; - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/observe-sessions', - { path: { projectId: pid, flowId: options.flowId }, body }, - ); - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - throwApiResponseError( - response, - errorBody, - 'Failed to start observe session', - ); - } - return response.json(); -} - -export interface GetObserveSessionOptions { - projectId?: string; - flowId: string; - sessionId: string; -} - -export async function getObserveSession( - options: GetObserveSessionOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}', - { - path: { - projectId: pid, - flowId: options.flowId, - sessionId: options.sessionId, - }, - }, - ); - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - throwApiResponseError(response, errorBody, 'Failed to get observe session'); - } - return response.json(); -} - -export interface EndObserveSessionOptions { - projectId?: string; - flowId: string; - sessionId: string; -} - -/** - * End a session through the app's authenticated boundary: the app tears down - * the container, revokes credentials, deletes the web preview, and drops the - * row. Ends the WHOLE session including every attached arm. Idempotent - * app-side, and the 204 carries no body, so this resolves with nothing; a - * session scoped to another flow or project is a 404, never a cross-tenant end. - */ -export async function endObserveSession( - options: EndObserveSessionOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'DELETE /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}', - { - path: { - projectId: pid, - flowId: options.flowId, - sessionId: options.sessionId, - }, - }, - ); - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - throwApiResponseError(response, errorBody, 'Failed to end observe session'); - } -} - -/** - * POST the session heartbeat, swallowing every failure. The heartbeat extends - * the janitor's idle grace while the CLI user is actively setting up - it - * never gates anything, so an unreachable app or a 5xx must not surface into - * the poll loop. Session liveness never depends on this process. - */ -export async function heartbeatObserveSession(options: { - projectId?: string; - flowId: string; - sessionId: string; -}): Promise { - const pid = options.projectId ?? requireProjectId(); - try { - await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/heartbeat', - { - path: { - projectId: pid, - flowId: options.flowId, - sessionId: options.sessionId, - }, - }, - ); - } catch { - // Best-effort by design; the next tick tries again. - } -} - -/** - * Poll GET until the session leaves `arming` (or the deadline passes). Always - * runs at least one GET: the mint response never carries the web/server parts, - * the app assembles them on GET once provisioning settles. Each tick also - * heartbeats the session so the janitor's idle reaper cannot claim it while - * the user is actively waiting; the heartbeat extends grace, never gates - * (failures are swallowed). Session liveness is server-managed - abandoning - * this poll never ends the session. - */ -export async function waitForObserveSessionSettled(options: { - projectId?: string; - flowId: string; - sessionId: string; - timeoutMs: number; - pollIntervalMs: number; -}): Promise { - const deadline = Date.now() + options.timeoutMs; - for (;;) { - await heartbeatObserveSession(options); - const session = await getObserveSession(options); - const remainingMs = deadline - Date.now(); - if (session.status !== 'arming' || remainingMs <= 0) return session; - // Cap the sleep to the remaining budget so the deadline is honored - // precisely instead of overshooting by up to a full poll interval. - await new Promise((resolve) => - setTimeout(resolve, Math.min(options.pollIntervalMs, remainingMs)), - ); - } -} - -// === Rendering === - -export interface FormatObserveSessionResult { - /** Machine-readable stdout payload: the live server endpoint, when armed. */ - stdoutLast: string | null; - stderr: string; -} - -/** - * Render a session from the fields the API contract carries. The web - * activation URL is app-minted (grants are app-signed and origin-bound) and - * already rides the `elbObserve` credential companion, so the CLI echoes it - * verbatim and never builds one client-side. The server part is read from - * `server.endpoint`/`server.env` (the transitional top-level `serverEndpoint` - * mirror is not consumed). - */ -export function formatObserveSession( - session: ObserveSessionResponse, -): FormatObserveSessionResult { - const arming = session.status === 'arming'; - const lines = [`Observe session ${session.id} (${session.status})`]; - if (session.errorMessage) lines.push(` Error: ${session.errorMessage}`); - lines.push(''); - - if (session.web) { - lines.push(' Web'); - lines.push(` Bundle URL: ${session.web.bundleUrl}`); - lines.push(` Credential: ${session.web.credential}`); - if (session.web.activationUrl) { - // App-minted, origin-bound; carries the elbObserve companion so the - // observed tab picks up the session credential from the link itself. - lines.push(` Activate: ${session.web.activationUrl}`); - } else if (session.web.previewEnabled) { - lines.push( - ` Activate: no activation link minted yet${arming ? ' (still arming)' : ''}; open the Observe window in the app to mint one`, - ); - } else { - lines.push( - ' Activate: not available: the deployed web bundle cannot verify activation grants (redeploy the web flow first)', - ); - } - } else { - lines.push(` Web: no web part${arming ? ' yet (still arming)' : ''}`); - } - lines.push(''); - - if (session.server) { - lines.push(' Server'); - if (session.serverFlowName) { - lines.push(` Flow: ${session.serverFlowName}`); - } - lines.push( - session.server.endpoint - ? ` Endpoint: ${session.server.endpoint}` - : ` Endpoint: not live${arming ? ' yet (still arming)' : ''}`, - ); - // The env trio is the session's own identity: export it into a server - // runtime to feed this exact session. - lines.push(' Env:'); - lines.push( - ` WALKEROS_OBSERVER_URL=${session.server.env.WALKEROS_OBSERVER_URL}`, - ); - lines.push( - ` WALKEROS_DEPLOYMENT_ID=${session.server.env.WALKEROS_DEPLOYMENT_ID}`, - ); - lines.push( - ` WALKEROS_INGEST_TOKEN=${session.server.env.WALKEROS_INGEST_TOKEN}`, - ); - } else { - lines.push( - ` Server: no server part${arming ? ' yet (still arming)' : ''}`, - ); - } - lines.push(''); - lines.push(` Expires: ${session.expiresAt}`); - lines.push(` Records: ${session.recordsReceived}`); - lines.push(''); - lines.push( - ' The session lives server-side; closing this terminal does not end it.', - ); - lines.push( - ' Sessions idle out server-side when no tab or traffic keeps them alive.', - ); - - return { - stdoutLast: session.server?.endpoint ?? null, - stderr: lines.join('\n'), - }; -} - -// === CLI Command === - -export interface ObserveStartCommandOptions extends GlobalOptions { - project?: string; - /** Flow settings name; auto-resolved when the flow has exactly one. */ - flow?: string; - /** Observation detail level: off, standard, or trace. */ - level?: string; - /** Maps onto the contract's `replace` field. */ - replace?: boolean; - /** Wait for the session to settle (default true; `--no-wait` disables). */ - wait?: boolean; - /** Seconds to wait for the session to settle (default 300). */ - timeout?: string; - json?: boolean; - /** Test seam: poll cadence while waiting (default 3000ms). */ - pollIntervalMs?: number; -} - -const DEFAULT_WAIT_SECONDS = 300; -const DEFAULT_POLL_INTERVAL_MS = 3000; - -/** - * The login/signup funnel for terminal-native users: a start with NO - * resolvable credentials at all is a CTA naming the next command, never a - * hard error wall (exit stays 0, no network call). A request that fails 401 - * despite a token (stale/invalid credentials, the CI case) is NOT this - * funnel: it goes through `handleCliError` so scripts fail loudly instead of - * reading success-with-empty-stdout. - */ -function printLoginCta(): void { - process.stderr.write( - [ - 'Not logged in.', - '', - 'Starting an observe session needs a walkerOS account.', - ' 1. Run: walkeros auth login (opens the browser; creates your account if you do not have one)', - ' 2. Re-run: walkeros observe start ', - '', - ].join('\n') + '\n', - ); -} - -/** Resolve the settings name when the flow has exactly one settings entry. */ -async function resolveSingleSettingsName(options: { - projectId: string; - flowId: string; -}): Promise { - const flow = await getFlow(options); - const settings = flow.settings ?? []; - const only = settings[0]; - if (!only) throw new Error('Flow has no settings.'); - if (settings.length > 1) { - throw new Error( - `This flow has multiple settings. Use --flow to specify one.\nAvailable: ${settings.map((entry) => entry.name).join(', ')}`, - ); - } - return only.name; -} - -export async function observeStartCommand( - flowId: string, - options: ObserveStartCommandOptions, -): Promise { - try { - // No credentials at all: funnel straight to login, no network round trip. - if (credentialSource() === null) { - printLoginCta(); - return; - } - - // Validate flags BEFORE any network call: a session should not be minted - // for an invalid flag (the previews validate-before-create precedent). - let level: ObserveLevel | undefined; - if (options.level !== undefined) { - if (!isObserveLevel(options.level)) { - throw new Error( - `Invalid --level value: ${options.level} (expected ${OBSERVE_LEVELS.join(', ')})`, - ); - } - level = options.level; - } - - // An invalid deadline would make the poll loop never expire. Full-string - // numeric parse, not parseInt: `5m` must error loudly, not silently - // truncate to 5 seconds. - let timeoutSeconds = DEFAULT_WAIT_SECONDS; - if (options.timeout !== undefined) { - const rawTimeout = options.timeout.trim(); - timeoutSeconds = rawTimeout === '' ? Number.NaN : Number(rawTimeout); - if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) { - throw new Error( - `Invalid --timeout value: ${options.timeout} (expected a non-negative number of seconds)`, - ); - } - } - - const projectId = options.project ?? requireProjectId(); - const settingsName = - options.flow ?? (await resolveSingleSettingsName({ projectId, flowId })); - - let session = await startObserveSession({ - projectId, - flowId, - settingsName, - ...(options.replace === true ? { replace: true } : {}), - ...(level !== undefined ? { level } : {}), - }); - - // Always at least one GET when waiting: even a session born live gets its - // web/server parts assembled by GET, never by the mint response. - let timedOut = false; - if (options.wait !== false) { - session = await waitForObserveSessionSettled({ - projectId, - flowId, - sessionId: session.id, - timeoutMs: timeoutSeconds * 1000, - pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, - }); - // The poll loop only hands back an `arming` session when the deadline - // passed. `--no-wait` rendering an arming mint stays a clean exit (the - // user opted out of waiting); an expired wait must not read as success. - timedOut = session.status === 'arming'; - } - - if (options.json) { - await writeResult(JSON.stringify(session, null, 2), {}); - } else { - const { stdoutLast, stderr } = formatObserveSession(session); - process.stderr.write(stderr + '\n'); - if (stdoutLast) process.stdout.write(stdoutLast + '\n'); - } - - // exitCode over process.exit: the rendered output above still flushes and - // the process ends non-zero for CI to branch on. - if (timedOut) { - process.stderr.write( - `Timed out after ${timeoutSeconds}s: the session is still arming. It keeps arming server-side; check it in the app or re-run walkeros observe start.\n`, - ); - process.exitCode = 1; - } - if (session.status === 'failed') process.exitCode = 1; - } catch (error) { - handleCliError(error); - } -} diff --git a/packages/cli/src/commands/previews/__tests__/output.test.ts b/packages/cli/src/commands/previews/__tests__/output.test.ts deleted file mode 100644 index f65026172..000000000 --- a/packages/cli/src/commands/previews/__tests__/output.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { formatPreviewCreated } from '../output'; - -describe('formatPreviewCreated', () => { - // activationUrl is always a server-minted, origin-bound, app-signed grant URL. - // For the --url case, createPreview re-mints it for the target origin before - // this formatter runs, so the formatter only echoes it. The preview response - // carries no raw token at all, so nothing token-shaped can be printed or - // appended to a URL. - const grantActivationUrl = - 'https://my-site.com/?elbPreview=eyJhbGciOiJFUzI1NiJ9.gr4nt.s1g'; - const basePreview = { - id: 'prv_abc', - activationUrl: grantActivationUrl, - bundleUrl: 'https://cdn.walkeros.io/preview/proj_x/walker.k9x2m4p7abcd.js', - createdBy: 'user_alex', - createdAt: '2026-04-21T00:00:00Z', - }; - - it('without url, echoes the server activationUrl as the stdout last line', () => { - const { stdoutLast, stderr } = formatPreviewCreated(basePreview, {}); - expect(stdoutLast).toBe(grantActivationUrl); - expect(stderr).toContain('prv_abc'); - // The activation URL is complete as returned; the instructions must print - // it directly, never tell the user to append it to another URL. - expect(stderr).toContain(`Activate: ${grantActivationUrl}`); - expect(stderr).not.toContain('Append https://'); - expect(stderr).toContain('user_alex'); - // The response carries no raw token, so no standalone Token line exists. - expect(stderr).not.toContain('Token:'); - }); - - it('without url and no minted grant, points at --url instead of printing null', () => { - const { stdoutLast, stderr } = formatPreviewCreated( - { ...basePreview, activationUrl: null }, - {}, - ); - expect(stdoutLast).toBeNull(); - expect(stderr).not.toContain('null'); - expect(stderr).toContain('--url'); - }); - - it('with url, prints the server grant activation URL verbatim', () => { - const { stdoutLast } = formatPreviewCreated(basePreview, { - url: 'https://my-site.com', - }); - expect(stdoutLast).toBe(grantActivationUrl); - }); - - it('with url, stderr shows the grant activation URL and an off deactivation URL', () => { - const { stderr } = formatPreviewCreated(basePreview, { - url: 'https://my-site.com/path?foo=1', - }); - expect(stderr).toContain(grantActivationUrl); - expect(stderr).toContain('https://my-site.com/path?foo=1&elbPreview=off'); - }); - - it('throws on invalid URL input', () => { - expect(() => - formatPreviewCreated(basePreview, { url: 'not a url' }), - ).toThrow(); - }); -}); diff --git a/packages/cli/src/commands/previews/__tests__/previews.test.ts b/packages/cli/src/commands/previews/__tests__/previews.test.ts deleted file mode 100644 index 01c7cf210..000000000 --- a/packages/cli/src/commands/previews/__tests__/previews.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { requireProjectId } from '../../../core/auth.js'; -import { apiFetch } from '../../../core/http.js'; -import { getFlow } from '../../flows/index.js'; -import { - listPreviews, - getPreview, - createPreview, - deletePreview, - regrantPreview, -} from '../index.js'; - -jest.mock('../../../core/auth.js', () => ({ - ...jest.requireActual('../../../core/auth.js'), - requireProjectId: jest.fn().mockReturnValue('proj_default'), -})); -jest.mock('../../../core/http.js', () => ({ - apiFetch: jest.fn(), -})); -jest.mock('../../flows/index.js', () => ({ - getFlow: jest.fn(), -})); - -const mockApiFetch = jest.mocked(apiFetch); -const mockRequireProjectId = jest.mocked(requireProjectId); -const mockGetFlow = jest.mocked(getFlow); - -describe('previews', () => { - afterEach(() => jest.clearAllMocks()); - - describe('listPreviews', () => { - it('calls GET /api/projects/{pid}/flows/{fid}/previews', async () => { - const mockResp = { previews: [], total: 0 }; - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify(mockResp), { status: 200 }), - ); - const result = await listPreviews({ - projectId: 'proj_x', - flowId: 'fl_y', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews', - ); - expect(result).toEqual(mockResp); - }); - - it('falls back to requireProjectId()', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ previews: [] }), { status: 200 }), - ); - await listPreviews({ flowId: 'fl_y' }); - expect(mockRequireProjectId).toHaveBeenCalled(); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/flows/fl_y/previews', - ); - }); - - it('throws on non-ok response', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Unauthorized' } }), { - status: 401, - }), - ); - await expect( - listPreviews({ projectId: 'proj_x', flowId: 'fl_y' }), - ).rejects.toThrow('Unauthorized'); - }); - }); - - describe('getPreview', () => { - it('GETs /previews/{previewId}', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ id: 'prv_x' }), { status: 200 }), - ); - await getPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews/prv_x', - ); - }); - - it('throws on error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Not found' } }), { - status: 404, - }), - ); - await expect( - getPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'missing', - }), - ).rejects.toThrow('Not found'); - }); - }); - - describe('createPreview', () => { - it('POSTs with flowSettingsId directly when provided', async () => { - mockApiFetch.mockResolvedValue( - new Response( - JSON.stringify({ - id: 'prv_x', - activationUrl: 'https://site.example/?elbPreview=eyJ0.gr4nt.s1g', - bundleUrl: 'https://cdn/walker.abc.js', - createdBy: 'u', - createdAt: '2026-04-21T00:00:00Z', - }), - { status: 201 }, - ), - ); - await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - }); - expect(mockApiFetch).toHaveBeenCalledTimes(1); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ flowSettingsId: 'fs_a' }), - }), - ); - expect(mockGetFlow).not.toHaveBeenCalled(); - }); - - it('forwards source in the POST body when provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ id: 'prv_x' }), { status: 201 }), - ); - await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - source: { kind: 'deployment-version', deploymentVersionId: 'dpv_1' }, - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - flowSettingsId: 'fs_a', - source: { - kind: 'deployment-version', - deploymentVersionId: 'dpv_1', - }, - }), - }), - ); - }); - - it('omits source from the POST body when not provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ id: 'prv_x' }), { status: 201 }), - ); - await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - }); - const [, init] = mockApiFetch.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ - flowSettingsId: 'fs_a', - }); - }); - - it('resolves flowName via getFlow then POSTs with resolved settingsId', async () => { - mockGetFlow.mockResolvedValue({ - id: 'fl_y', - settings: [ - { id: 'fs_a', name: 'demo' }, - { id: 'fs_b', name: 'staging' }, - ], - } as never); - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ id: 'prv_x' }), { status: 201 }), - ); - await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowName: 'demo', - }); - expect(mockGetFlow).toHaveBeenCalledWith({ - projectId: 'proj_x', - flowId: 'fl_y', - }); - expect(mockApiFetch).toHaveBeenCalledTimes(1); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ flowSettingsId: 'fs_a' }), - }), - ); - }); - - it('throws when neither flowName nor flowSettingsId provided', async () => { - await expect( - createPreview({ projectId: 'proj_x', flowId: 'fl_y' }), - ).rejects.toThrow(/flow/i); - }); - - it('throws when flowName does not match any settings entry', async () => { - mockGetFlow.mockResolvedValue({ - id: 'fl_y', - settings: [{ id: 'fs_b', name: 'staging' }], - } as never); - await expect( - createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowName: 'demo', - }), - ).rejects.toThrow(/not found|demo/i); - }); - - it('throws on API error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Bad request' } }), { - status: 400, - }), - ); - await expect( - createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - }), - ).rejects.toThrow('Bad request'); - }); - - describe('with --url (server-minted, origin-bound grant)', () => { - const createBody = { - id: 'prv_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - projectId: 'proj_x', - bundleUrl: 'https://cdn/walker.k9x2m4p7abcd.js', - activationUrl: - 'https://default-origin.example/?elbPreview=eyJ0.def4ult.s1g', - createdBy: 'u', - createdAt: '2026-04-21T00:00:00Z', - grant: 'eyJ0.def4ult.s1g', - }; - const grantBody = { - grant: 'eyJhbGciOiJFUzI1NiJ9.my5ite.s1g', - activationUrl: - 'https://my-site.com/?elbPreview=eyJhbGciOiJFUzI1NiJ9.my5ite.s1g', - sessionExpiresAt: '2026-04-22T00:00:00Z', - }; - - it('mints an origin-bound grant for the url origin and returns its activationUrl', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(createBody), { status: 201 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(grantBody), { status: 200 }), - ); - const result = await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - url: 'https://my-site.com/some/path?foo=1', - }); - // create POST first, then the grant POST for the derived origin - expect(mockApiFetch).toHaveBeenCalledTimes(2); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 1, - '/api/projects/proj_x/flows/fl_y/previews', - expect.objectContaining({ method: 'POST' }), - ); - expect(mockApiFetch).toHaveBeenNthCalledWith( - 2, - '/api/projects/proj_x/flows/fl_y/previews/prv_x/grant', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ origins: ['https://my-site.com'] }), - }), - ); - expect(result.activationUrl).toBe(grantBody.activationUrl); - }); - - it('does not mint a grant without url (single call, keeps the default activationUrl)', async () => { - mockApiFetch.mockResolvedValueOnce( - new Response(JSON.stringify(createBody), { status: 201 }), - ); - const result = await createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - }); - expect(mockApiFetch).toHaveBeenCalledTimes(1); - expect(result.activationUrl).toBe(createBody.activationUrl); - }); - - it('throws on an invalid url before creating the preview (no network call)', async () => { - await expect( - createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - url: 'not a url', - }), - ).rejects.toThrow(/invalid.*url/i); - expect(mockApiFetch).not.toHaveBeenCalled(); - }); - - it('throws when the grant route fails', async () => { - mockApiFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(createBody), { status: 201 }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { message: 'Grant denied' } }), - { status: 403 }, - ), - ); - await expect( - createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - url: 'https://my-site.com', - }), - ).rejects.toThrow('Grant denied'); - }); - - it('names the orphaned preview when the grant mint fails after create', async () => { - // The preview exists once the first POST succeeded; a grant failure - // must surface its id so the caller can delete it or retry the mint. - mockApiFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(createBody), { status: 201 }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { message: 'Grant denied' } }), - { status: 403 }, - ), - ); - await expect( - createPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - flowSettingsId: 'fs_a', - url: 'https://my-site.com', - }), - ).rejects.toThrow(/preview prv_x/); - }); - }); - }); - - describe('regrantPreview', () => { - const grantBody = { - grant: 'eyJhbGciOiJFUzI1NiJ9.my5ite.s1g', - activationUrl: - 'https://my-site.com/?elbPreview=eyJhbGciOiJFUzI1NiJ9.my5ite.s1g', - sessionExpiresAt: '2026-04-22T00:00:00Z', - }; - - it('POSTs origins to the grant route and returns the minted grant', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify(grantBody), { status: 200 }), - ); - const result = await regrantPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - origins: ['https://my-site.com'], - }); - expect(mockApiFetch).toHaveBeenCalledTimes(1); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews/prv_x/grant', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ origins: ['https://my-site.com'] }), - }), - ); - expect(result).toEqual(grantBody); - }); - - it('includes sessionId in the body when provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify(grantBody), { status: 200 }), - ); - await regrantPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - origins: ['https://my-site.com'], - sessionId: 'ses_1', - }); - const [, init] = mockApiFetch.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ - origins: ['https://my-site.com'], - sessionId: 'ses_1', - }); - }); - - it('omits sessionId from the body when not provided', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify(grantBody), { status: 200 }), - ); - await regrantPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - origins: ['https://my-site.com'], - }); - const [, init] = mockApiFetch.mock.calls[0] as [string, RequestInit]; - expect(Object.keys(JSON.parse(init.body as string))).toEqual(['origins']); - }); - - it('falls back to requireProjectId()', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify(grantBody), { status: 200 }), - ); - await regrantPreview({ - flowId: 'fl_y', - previewId: 'prv_x', - origins: ['https://my-site.com'], - }); - expect(mockRequireProjectId).toHaveBeenCalled(); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_default/flows/fl_y/previews/prv_x/grant', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('throws when the grant route fails', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Grant denied' } }), { - status: 403, - }), - ); - await expect( - regrantPreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - origins: ['https://my-site.com'], - }), - ).rejects.toThrow('Grant denied'); - }); - }); - - describe('deletePreview', () => { - it('DELETEs /previews/{previewId} and returns a confirmation record on 204 No Content', async () => { - mockApiFetch.mockResolvedValue(new Response(null, { status: 204 })); - const result = await deletePreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - }); - expect(mockApiFetch).toHaveBeenCalledWith( - '/api/projects/proj_x/flows/fl_y/previews/prv_x', - expect.objectContaining({ method: 'DELETE' }), - ); - expect(result).toEqual({ deleted: true, previewId: 'prv_x' }); - }); - - it('returns parsed body when server replies with JSON 200', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ deleted: true }), { status: 200 }), - ); - const result = await deletePreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - }); - expect(result).toEqual({ deleted: true }); - }); - - it('returns a confirmation record when a JSON surface yields no body', async () => { - mockApiFetch.mockResolvedValue(new Response('', { status: 200 })); - const result = await deletePreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'prv_x', - }); - expect(result).toEqual({ deleted: true, previewId: 'prv_x' }); - }); - - it('throws on error', async () => { - mockApiFetch.mockResolvedValue( - new Response(JSON.stringify({ error: { message: 'Not found' } }), { - status: 404, - }), - ); - await expect( - deletePreview({ - projectId: 'proj_x', - flowId: 'fl_y', - previewId: 'missing', - }), - ).rejects.toThrow('Not found'); - }); - }); -}); diff --git a/packages/cli/src/commands/previews/index.ts b/packages/cli/src/commands/previews/index.ts deleted file mode 100644 index 2f7d330de..000000000 --- a/packages/cli/src/commands/previews/index.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ApiRequestInit, ResponseJson } from '../../core/api-request.js'; -import { throwApiError } from '../../core/api-error.js'; -import { getFlow } from '../flows/index.js'; - -type PreviewResponse = ResponseJson< - 'GET /api/projects/{projectId}/flows/{flowId}/previews/{previewId}', - 200 ->; -type CreatePreviewBody = - ApiRequestInit<'POST /api/projects/{projectId}/flows/{flowId}/previews'>['body']; -type MintGrantResponse = ResponseJson< - 'POST /api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant', - 200 ->; - -// === Programmatic API === - -export interface ListPreviewsOptions { - projectId?: string; - flowId: string; -} - -export async function listPreviews( - options: ListPreviewsOptions, -): Promise< - ResponseJson<'GET /api/projects/{projectId}/flows/{flowId}/previews', 200> -> { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/previews', - { path: { projectId: pid, flowId: options.flowId } }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to list previews'); - } - return response.json(); -} - -export interface GetPreviewOptions { - projectId?: string; - flowId: string; - previewId: string; -} - -export async function getPreview( - options: GetPreviewOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/previews/{previewId}', - { - path: { - projectId: pid, - flowId: options.flowId, - previewId: options.previewId, - }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to get preview'); - } - return response.json(); -} - -export interface CreatePreviewOptions { - projectId?: string; - flowId: string; - flowName?: string; - flowSettingsId?: string; - /** What the preview should run: the flow's draft (default) or a deployed - * version's stored config. Anchored to the generated API contract so a new - * request field becomes a type error here rather than silent drift. */ - source?: NonNullable['source']; - /** Target site URL. When present, the CLI asks the server to re-mint an - * origin-bound activation grant for this URL's origin. Grants are - * app-signed and origin-bound, so a client cannot forge a working activation - * URL for an arbitrary origin by string-appending a token — only the server - * can mint one. The returned preview's `activationUrl` is the grant URL for - * this origin. */ - url?: string; -} - -export async function createPreview( - options: CreatePreviewOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - - // Derive (and validate) the target origin up front so an invalid --url fails - // before we create a server-side preview, which would otherwise waste a quota - // slot and force manual cleanup. - let origin: string | undefined; - if (options.url !== undefined) { - try { - origin = new URL(options.url).origin; - } catch { - throw new Error(`Invalid --url value: ${options.url}`); - } - } - - let settingsId = options.flowSettingsId; - if (!settingsId) { - if (!options.flowName) { - throw new Error('Either flowName or flowSettingsId is required'); - } - // Resolve flow settings name → id (same pattern deploy uses) - const flow = await getFlow({ projectId: pid, flowId: options.flowId }); - const settings = ( - flow as { settings?: Array<{ id: string; name: string }> } - ).settings; - const match = settings?.find((s) => s.name === options.flowName); - if (!match) { - throw new Error( - `Flow settings named "${options.flowName}" not found on flow ${options.flowId}`, - ); - } - settingsId = match.id; - } - - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/previews', - { - path: { projectId: pid, flowId: options.flowId }, - body: { - flowSettingsId: settingsId, - ...(options.source ? { source: options.source } : {}), - }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to create preview'); - } - const created: ResponseJson< - 'POST /api/projects/{projectId}/flows/{flowId}/previews', - 201 - > = await response.json(); - - // No target URL: the create response already carries a valid grant-based - // activationUrl for the flow's default/configured origin. Return it as-is. - if (origin === undefined) return created; - - // Target URL given: re-mint an origin-bound grant for that origin. The - // server signs the grant; the CLI cannot produce a valid one client-side. - const grantResponse = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant', - { - path: { projectId: pid, flowId: options.flowId, previewId: created.id }, - body: { origins: [origin] }, - }, - ); - if (!grantResponse.ok) { - const body = await grantResponse.json().catch(() => ({})); - // The preview already exists at this point. A failed mint must name it, - // or the caller has no handle to delete the orphan or retry the mint. - try { - throwApiError(body, 'Failed to mint preview activation grant'); - } catch (error) { - if (error instanceof Error) { - error.message += ` (preview ${created.id} was created but has no working activation URL; delete it or mint a grant with regrantPreview)`; - } - throw error; - } - } - const grant: MintGrantResponse = await grantResponse.json(); - return { ...created, activationUrl: grant.activationUrl }; -} - -export interface RegrantPreviewOptions { - projectId?: string; - flowId: string; - previewId: string; - /** Bare `https://host[:port]` origins the grant may activate on; the - * returned `activationUrl` targets the first. */ - origins: string[]; - /** Observe session id — binds the minted grant to that session so - * forwarded events reach its container. Opaque to the CLI. */ - sessionId?: string; -} - -/** - * Mint a fresh, origin-bound activation grant for an existing preview. - * Grants are app-signed and origin-bound, so a client cannot forge a working - * activation URL by string-appending a token — only the server can mint one. - */ -export async function regrantPreview( - options: RegrantPreviewOptions, -): Promise { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant', - { - path: { - projectId: pid, - flowId: options.flowId, - previewId: options.previewId, - }, - body: { - origins: options.origins, - ...(options.sessionId ? { sessionId: options.sessionId } : {}), - }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to mint preview activation grant'); - } - return response.json(); -} - -export interface DeletePreviewOptions { - projectId?: string; - flowId: string; - previewId: string; -} - -export async function deletePreview(options: DeletePreviewOptions) { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'DELETE /api/projects/{projectId}/flows/{flowId}/previews/{previewId}', - { - path: { - projectId: pid, - flowId: options.flowId, - previewId: options.previewId, - }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to delete preview'); - } - // App returns 204 No Content on success; older surfaces may return JSON. - // Always resolve to a record so every consumer (MCP, CLI command) gets a - // serializable confirmation instead of a bare null. - const confirmation = { deleted: true, previewId: options.previewId }; - if (response.status === 204) return confirmation; - return (await response.json().catch(() => null)) ?? confirmation; -} diff --git a/packages/cli/src/commands/previews/output.ts b/packages/cli/src/commands/previews/output.ts deleted file mode 100644 index 897b3ebaa..000000000 --- a/packages/cli/src/commands/previews/output.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface FormatOptions { - url?: string; -} - -export interface FormatResult { - /** Machine-readable stdout payload: the activation URL, or null when the - * server has not minted a grant for any site yet. */ - stdoutLast: string | null; - stderr: string; -} - -export interface PreviewPrintable { - id: string; - activationUrl: string | null; - bundleUrl: string; - createdBy: string; - createdAt: string; -} - -export function formatPreviewCreated( - preview: PreviewPrintable, - options: FormatOptions, -): FormatResult { - // The activation URL is always server-minted: an app-signed, origin-bound - // grant. The CLI cannot forge one for an arbitrary origin, so it never - // reconstructs it client-side (the response carries no raw token to build - // one from). For --url, createPreview already re-minted it for the target - // origin, so we echo it verbatim here. Without --url the server may not - // have minted a grant for any site yet (activationUrl null); point at - // --url instead of printing a literal "null". - const stdoutLast = preview.activationUrl; - let deactivationUrl: string | null = null; - - if (options.url) { - // Deactivation uses the `off` clear-sentinel (not a grant), so it is safe - // to build client-side. `new URL` throws TypeError on invalid input. - const off = new URL(options.url); - off.searchParams.set('elbPreview', 'off'); - deactivationUrl = off.toString(); - } - - const lines = [ - `Preview created (${preview.id})`, - ` Created by: ${preview.createdBy}`, - ` Bundle URL: ${preview.bundleUrl}`, - '', - preview.activationUrl - ? ` Activate: ${preview.activationUrl}` - : ' Activate: No activation grant minted yet. Re-run with --url to mint one.', - deactivationUrl - ? ` Deactivate: ${deactivationUrl}` - : ` Deactivate: Append ?elbPreview=off to any URL on your site`, - '', - ]; - - return { stdoutLast, stderr: lines.join('\n') }; -} - -export function printPreviewCreated( - preview: PreviewPrintable, - options: FormatOptions, -): void { - const { stdoutLast, stderr } = formatPreviewCreated(preview, options); - process.stderr.write(stderr + '\n'); - if (stdoutLast) process.stdout.write(stdoutLast + '\n'); -} diff --git a/packages/cli/src/commands/projects/index.ts b/packages/cli/src/commands/projects/index.ts deleted file mode 100644 index 3c7095b2d..000000000 --- a/packages/cli/src/commands/projects/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { createApiClient } from '../../core/api-client.js'; -import { handleCliError, throwApiError } from '../../core/api-error.js'; -import { requireProjectId } from '../../core/auth.js'; -import { writeResult } from '../../core/output.js'; -import { - getDefaultProject, - clearDefaultProject, -} from '../../lib/config-file.js'; -import type { GlobalOptions } from '../../types/global.js'; - -// === Programmatic API === - -export interface ListProjectsOptions { - cursor?: string; - limit?: number; -} - -export async function listProjects(options: ListProjectsOptions = {}) { - const client = createApiClient(); - const { data, error } = await client.GET('/api/projects', { - params: { query: { cursor: options.cursor, limit: options.limit } }, - }); - if (error) throwApiError(error, 'Failed to list projects'); - return data; -} - -export async function getProject(options: { projectId?: string } = {}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.GET('/api/projects/{projectId}', { - params: { path: { projectId: id } }, - }); - if (error) throwApiError(error, 'Failed to get project'); - return data; -} - -export async function createProject(options: { name: string }) { - const client = createApiClient(); - const { data, error } = await client.POST('/api/projects', { - body: { name: options.name }, - }); - if (error) throwApiError(error, 'Failed to create project'); - return data; -} - -export async function updateProject(options: { - projectId?: string; - name: string; -}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.PATCH('/api/projects/{projectId}', { - params: { path: { projectId: id } }, - body: { name: options.name }, - }); - if (error) throwApiError(error, 'Failed to update project'); - return data; -} - -export async function deleteProject(options: { projectId?: string } = {}) { - const id = options.projectId ?? requireProjectId(); - const client = createApiClient(); - const { data, error } = await client.DELETE('/api/projects/{projectId}', { - params: { path: { projectId: id } }, - }); - if (error) throwApiError(error, 'Failed to delete project'); - // Drop a now-stale default. Compare against the resolved `id`, not - // `options.projectId`, so deleting the default with no explicit id (resolved - // via requireProjectId()) still clears it. - if (getDefaultProject() === id) clearDefaultProject(); - return data ?? { success: true }; -} - -// === CLI Command Handlers === - -interface ProjectsCommandOptions extends GlobalOptions { - json?: boolean; - output?: string; - project?: string; - name?: string; - cursor?: string; - limit?: number; -} - -async function handleResult( - fn: () => Promise, - options: ProjectsCommandOptions, -): Promise { - try { - const result = await fn(); - await writeResult(JSON.stringify(result, null, 2), options); - } catch (error) { - handleCliError(error); - } -} - -export async function listProjectsCommand( - options: ProjectsCommandOptions, -): Promise { - await handleResult( - () => listProjects({ cursor: options.cursor, limit: options.limit }), - options, - ); -} - -export async function getProjectCommand( - projectId: string | undefined, - options: ProjectsCommandOptions, -): Promise { - await handleResult( - () => getProject({ projectId: projectId ?? options.project }), - options, - ); -} - -export async function createProjectCommand( - name: string, - options: ProjectsCommandOptions, -): Promise { - await handleResult(() => createProject({ name }), options); -} - -export async function updateProjectCommand( - projectId: string | undefined, - options: ProjectsCommandOptions, -): Promise { - const name = options.name; - if (!name) { - throw new Error('Missing required option: --name '); - } - await handleResult( - () => - updateProject({ - projectId: projectId ?? options.project, - name, - }), - options, - ); -} - -export async function deleteProjectCommand( - projectId: string | undefined, - options: ProjectsCommandOptions, -): Promise { - await handleResult( - () => deleteProject({ projectId: projectId ?? options.project }), - options, - ); -} diff --git a/packages/cli/src/commands/secrets/index.ts b/packages/cli/src/commands/secrets/index.ts deleted file mode 100644 index edee1f229..000000000 --- a/packages/cli/src/commands/secrets/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { requireProjectId } from '../../core/auth.js'; -import { apiRequest } from '../../core/api-request.js'; -import type { ResponseJson } from '../../core/api-request.js'; -import { throwApiError } from '../../core/api-error.js'; - -// === Programmatic API === - -export interface ListSecretsOptions { - projectId?: string; - flowId: string; -} - -export async function listSecrets( - options: ListSecretsOptions, -): Promise< - ResponseJson<'GET /api/projects/{projectId}/flows/{flowId}/secrets', 200> -> { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/secrets', - { path: { projectId: pid, flowId: options.flowId } }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to list secrets'); - } - return response.json(); -} - -export interface CreateSecretOptions { - projectId?: string; - flowId: string; - name: string; - value: string; -} - -export async function createSecret( - options: CreateSecretOptions, -): Promise< - ResponseJson<'POST /api/projects/{projectId}/flows/{flowId}/secrets', 201> -> { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'POST /api/projects/{projectId}/flows/{flowId}/secrets', - { - path: { projectId: pid, flowId: options.flowId }, - body: { name: options.name, value: options.value }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to create secret'); - } - return response.json(); -} - -export interface UpdateSecretOptions { - projectId?: string; - flowId: string; - secretId: string; - value: string; -} - -export async function updateSecret( - options: UpdateSecretOptions, -): Promise< - ResponseJson< - 'PUT /api/projects/{projectId}/flows/{flowId}/secrets/{secretId}', - 200 - > -> { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'PUT /api/projects/{projectId}/flows/{flowId}/secrets/{secretId}', - { - path: { - projectId: pid, - flowId: options.flowId, - secretId: options.secretId, - }, - body: { value: options.value }, - }, - ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to update secret'); - } - return response.json(); -} - -export interface DeleteSecretOptions { - projectId?: string; - flowId: string; - secretId: string; -} - -export async function deleteSecret( - options: DeleteSecretOptions, -): Promise<{ success: true }> { - const pid = options.projectId ?? requireProjectId(); - const response = await apiRequest( - 'DELETE /api/projects/{projectId}/flows/{flowId}/secrets/{secretId}', - { - path: { - projectId: pid, - flowId: options.flowId, - secretId: options.secretId, - }, - }, - ); - // App returns 204 No Content (and 204 even when the secret is missing). - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throwApiError(body, 'Failed to delete secret'); - } - return { success: true }; -} diff --git a/packages/cli/src/config/utils.ts b/packages/cli/src/config/utils.ts index 3a44b470f..57748c1da 100644 --- a/packages/cli/src/config/utils.ts +++ b/packages/cli/src/config/utils.ts @@ -4,10 +4,7 @@ import fs from 'fs-extra'; import path from 'path'; -import { mergeAuthHeaders } from '../core/http.js'; -import { resolveAccessToken } from '../core/auth.js'; -import { resolveAppUrl } from '../lib/config-file.js'; -import { requireSecureUrl } from '../lib/secure-url.js'; +import { fetchFailureReason } from '../core/fetch-failure.js'; /** * Check if a string is a valid URL @@ -24,18 +21,17 @@ export function isUrl(str: string): boolean { } } -/** - * Whether a URL points at the configured walkerOS app. - * - * Compares origins rather than prefixes: a host such as - * `https://app.walkeros.io.example.com` starts with the app URL but belongs to - * somebody else, and the port is part of who a host is. - */ -function isAppOrigin(url: string): boolean { - try { - return new URL(url).origin === new URL(resolveAppUrl()).origin; - } catch { - return false; +/** A config input named an http(s) URL that could not be read. */ +export class ConfigFetchError extends Error { + readonly url: string; + /** The HTTP status, when the host answered. */ + readonly status?: number; + + constructor(message: string, url: string, status?: number) { + super(message); + this.name = 'ConfigFetchError'; + this.url = url; + this.status = status; } } @@ -43,25 +39,28 @@ function isAppOrigin(url: string): boolean { * Fetch content from a URL as a string. * Shared helper for all URL-loading paths. * - * The session bearer goes to the walkerOS app and nowhere else. A config input - * is any URL the person names, so attaching auth to all of them would hand - * their walkerOS session to whichever host they were pointed at. + * A plain GET with no credential for every host: a config input is any URL + * the person names, and a link to a saved flow carries its own signature. * * @param url - HTTP/HTTPS URL to fetch * @returns Response body as a string - * @throws Error if fetch fails or response is not OK + * @throws ConfigFetchError if the host cannot be reached or answers non-OK */ export async function fetchContentString(url: string): Promise { - const token = isAppOrigin(url) ? await resolveAccessToken() : null; - // Only when a bearer is going along: an unauthenticated config download is - // the person naming any URL they like, and http is theirs to choose. - if (token) requireSecureUrl(url); - const response = await fetch(url, { - headers: mergeAuthHeaders(token), - }); + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw new ConfigFetchError( + `Could not reach ${url}: ${fetchFailureReason(error)}`, + url, + ); + } if (!response.ok) { - throw new Error( - `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + throw new ConfigFetchError( + `Failed to fetch ${url}: ${response.status} ${response.statusText}`.trimEnd(), + url, + response.status, ); } return response.text(); @@ -175,13 +174,10 @@ export async function loadConfig( // Distinguish between JSON parse errors and resolution errors const errorMessage = error instanceof Error ? error.message : String(error); - // Re-throw resolution errors (file not found, fetch failed) as-is - if ( - errorMessage.includes('not found') || - errorMessage.includes('Failed to fetch') - ) { + // Re-throw resolution errors (a URL that could not be read, a file not + // found) as-is + if (error instanceof ConfigFetchError || errorMessage.includes('not found')) throw error; - } // JSON-looking inline content with parse errors if (trimmed.startsWith('{') || trimmed.startsWith('[')) { diff --git a/packages/cli/src/core/__tests__/api-error-426.test.ts b/packages/cli/src/core/__tests__/api-error-426.test.ts deleted file mode 100644 index d86dab54d..000000000 --- a/packages/cli/src/core/__tests__/api-error-426.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ApiError, throwApiError } from '../api-error'; - -describe('ApiError CLIENT_OUTDATED (HTTP 426)', () => { - it('populates upgrade metadata from CLIENT_OUTDATED payload', () => { - const payload = { - error: { - code: 'CLIENT_OUTDATED', - message: - 'This endpoint requires @walkeros/cli >= 3.5.0 (you are on 3.3.1).', - minVersion: '3.5.0', - clientVersion: '3.3.1', - client: 'cli', - upgrade: 'npm install -g @walkeros/cli@latest', - docs: 'https://walkeros.io/docs/upgrading', - }, - }; - - expect(() => throwApiError(payload, 'fallback')).toThrow(ApiError); - - try { - throwApiError(payload, 'fallback'); - } catch (e) { - const err = e as ApiError; - expect(err).toBeInstanceOf(ApiError); - expect(err.code).toBe('CLIENT_OUTDATED'); - expect(err.message).toBe( - 'This endpoint requires @walkeros/cli >= 3.5.0 (you are on 3.3.1).', - ); - expect(err.minVersion).toBe('3.5.0'); - expect(err.clientVersion).toBe('3.3.1'); - expect(err.client).toBe('cli'); - expect(err.upgrade).toBe('npm install -g @walkeros/cli@latest'); - expect(err.docs).toBe('https://walkeros.io/docs/upgrading'); - } - }); - - it('does NOT populate upgrade fields for non-CLIENT_OUTDATED errors', () => { - const payload = { - error: { - code: 'VALIDATION_ERROR', - message: 'Request validation failed', - // These fields should be ignored for non-CLIENT_OUTDATED codes, - // even if the server accidentally includes them. - minVersion: '3.5.0', - upgrade: 'npm install', - }, - }; - - try { - throwApiError(payload, 'fallback'); - } catch (e) { - const err = e as ApiError; - expect(err.code).toBe('VALIDATION_ERROR'); - expect(err.minVersion).toBeUndefined(); - expect(err.clientVersion).toBeUndefined(); - expect(err.client).toBeUndefined(); - expect(err.upgrade).toBeUndefined(); - expect(err.docs).toBeUndefined(); - } - }); - - it('falls back to generic message on malformed payload (regression)', () => { - expect(() => throwApiError({ nope: true }, 'Fallback message')).toThrow( - 'Fallback message', - ); - - try { - throwApiError({ nope: true }, 'Fallback message'); - } catch (e) { - const err = e as ApiError; - expect(err.code).toBeUndefined(); - expect(err.minVersion).toBeUndefined(); - expect(err.upgrade).toBeUndefined(); - } - }); - - it('ApiError constructor accepts upgrade fields directly', () => { - const err = new ApiError('Upgrade needed', { - code: 'CLIENT_OUTDATED', - minVersion: '3.5.0', - clientVersion: '3.3.1', - client: 'cli', - upgrade: 'npm install -g @walkeros/cli@latest', - docs: 'https://walkeros.io/docs/upgrading', - }); - expect(err.code).toBe('CLIENT_OUTDATED'); - expect(err.minVersion).toBe('3.5.0'); - expect(err.clientVersion).toBe('3.3.1'); - expect(err.client).toBe('cli'); - expect(err.upgrade).toBe('npm install -g @walkeros/cli@latest'); - expect(err.docs).toBe('https://walkeros.io/docs/upgrading'); - }); -}); diff --git a/packages/cli/src/core/__tests__/api-error.test.ts b/packages/cli/src/core/__tests__/api-error.test.ts index 2a09cfbf5..83c42808c 100644 --- a/packages/cli/src/core/__tests__/api-error.test.ts +++ b/packages/cli/src/core/__tests__/api-error.test.ts @@ -1,10 +1,4 @@ -import { - ApiError, - throwApiError, - throwApiResponseError, - handleCliError, - EXIT_RETRYABLE, -} from '../api-error'; +import { ApiError, handleCliError, EXIT_RETRYABLE } from '../api-error'; describe('ApiError', () => { it('should carry code, message, and details', () => { @@ -40,77 +34,6 @@ describe('ApiError', () => { }); }); -describe('throwApiResponseError', () => { - function fakeResponse(status: number, retryAfter?: string): Response { - const headers = new Headers(); - if (retryAfter !== undefined) headers.set('Retry-After', retryAfter); - return { status, headers } as unknown as Response; - } - - it('marks a 429 with numeric Retry-After as retryable with a wait hint', () => { - const response = fakeResponse(429, '30'); - const body = { - error: { code: 'RATE_LIMITED', message: 'Too many deploys' }, - }; - - try { - throwApiResponseError(response, body, 'Deploy failed'); - throw new Error('expected throw'); - } catch (e) { - const err = e as ApiError; - expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(429); - expect(err.retryable).toBe(true); - expect(err.retryAfterSeconds).toBe(30); - expect(err.code).toBe('RATE_LIMITED'); - } - }); - - it('treats a 503 with Retry-After as retryable', () => { - const response = fakeResponse(503, '5'); - try { - throwApiResponseError(response, {}, 'Service unavailable'); - throw new Error('expected throw'); - } catch (e) { - const err = e as ApiError; - expect(err.status).toBe(503); - expect(err.retryable).toBe(true); - expect(err.retryAfterSeconds).toBe(5); - } - }); - - it('marks a 409 (already in progress) as non-retryable', () => { - const response = fakeResponse(409); - const body = { - error: { code: 'DEPLOY_IN_PROGRESS', message: 'Already deploying' }, - }; - try { - throwApiResponseError(response, body, 'Deploy failed'); - throw new Error('expected throw'); - } catch (e) { - const err = e as ApiError; - expect(err.status).toBe(409); - expect(err.retryable).toBe(false); - expect(err.code).toBe('DEPLOY_IN_PROGRESS'); - } - }); - - it('handles an HTTP-date Retry-After value', () => { - const future = new Date(Date.now() + 12_000).toUTCString(); - const response = fakeResponse(429, future); - try { - throwApiResponseError(response, {}, 'Deploy failed'); - throw new Error('expected throw'); - } catch (e) { - const err = e as ApiError; - expect(err.retryable).toBe(true); - // Allow a little slack for clock/rounding. - expect(err.retryAfterSeconds).toBeGreaterThanOrEqual(10); - expect(err.retryAfterSeconds).toBeLessThanOrEqual(13); - } - }); -}); - describe('handleCliError machine-readable output', () => { let errorSpy: jest.SpyInstance; let exitSpy: jest.SpyInstance; @@ -170,47 +93,3 @@ describe('handleCliError machine-readable output', () => { expect(output()).toMatch(/^error: code=UNKNOWN\b/m); }); }); - -describe('throwApiError', () => { - it('should extract structured error from openapi-fetch error object', () => { - const apiError = { - error: { - code: 'VALIDATION_ERROR', - message: 'Request validation failed', - details: { - errors: [ - { - path: 'config.version', - message: 'Only version 3 is supported.', - }, - ], - }, - }, - }; - - expect(() => throwApiError(apiError, 'Failed to create flow')).toThrow( - ApiError, - ); - - try { - throwApiError(apiError, 'Failed to create flow'); - } catch (e) { - const err = e as ApiError; - expect(err.message).toBe('Request validation failed'); - expect(err.code).toBe('VALIDATION_ERROR'); - expect(err.details).toEqual([ - { path: 'config.version', message: 'Only version 3 is supported.' }, - ]); - } - }); - - it('should fall back to generic message when error has no structure', () => { - expect(() => throwApiError({}, 'Failed to create flow')).toThrow( - 'Failed to create flow', - ); - }); - - it('should handle null/undefined error', () => { - expect(() => throwApiError(null, 'Fallback')).toThrow('Fallback'); - }); -}); diff --git a/packages/cli/src/core/__tests__/api-request.test.ts b/packages/cli/src/core/__tests__/api-request.test.ts deleted file mode 100644 index 0b312a75a..000000000 --- a/packages/cli/src/core/__tests__/api-request.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { apiRequest, operationUrl } from '../api-request.js'; -import { resetClientContext, setClientContext } from '../client-context.js'; - -jest.mock('../../lib/config-file.js', () => ({ - resolveAppUrl: jest.fn(), - resolveDeployToken: jest.fn(), -})); - -jest.mock('../auth.js', () => ({ - resolveAccessToken: jest.fn(), -})); - -import { resolveAppUrl, resolveDeployToken } from '../../lib/config-file.js'; -import { resolveAccessToken } from '../auth.js'; - -const mockResolveAppUrl = jest.mocked(resolveAppUrl); -const mockResolveDeployToken = jest.mocked(resolveDeployToken); -const mockResolveAccessToken = jest.mocked(resolveAccessToken); - -interface Sent { - url: string; - method?: string; - headers: Record; - body?: string; - signal?: AbortSignal | null; - redirect?: RequestRedirect; -} - -function headersOf(init: RequestInit | undefined): Record { - const raw = init?.headers; - if (raw === undefined) return {}; - return Object.fromEntries(new Headers(raw).entries()); -} - -/** A fetch that records what it was asked to send. */ -function recorder(): { fetchFn: typeof fetch; sent: Sent[] } { - const sent: Sent[] = []; - const fetchFn: typeof fetch = async (input, init) => { - sent.push({ - url: String(input), - method: init?.method, - headers: headersOf(init), - body: typeof init?.body === 'string' ? init.body : undefined, - signal: init?.signal, - redirect: init?.redirect, - }); - return new Response('{}', { status: 200 }); - }; - return { fetchFn, sent }; -} - -describe('apiRequest', () => { - const originalFetch = global.fetch; - let globalSent: Sent[]; - - beforeEach(() => { - const { fetchFn, sent } = recorder(); - global.fetch = fetchFn; - globalSent = sent; - mockResolveAppUrl.mockReturnValue('https://app.test'); - mockResolveDeployToken.mockReturnValue(null); - mockResolveAccessToken.mockResolvedValue('user-token'); - }); - - afterEach(() => { - global.fetch = originalFetch; - resetClientContext(); - jest.clearAllMocks(); - }); - - it('fills the template with each path parameter encoded once', async () => { - await apiRequest( - 'GET /api/projects/{projectId}/flows/{flowId}/frames/{frameId}', - { - path: { projectId: 'proj 1', flowId: 'flow?1', frameId: 'frm/a%20b' }, - }, - ); - expect(globalSent[0]?.url).toBe( - 'https://app.test/api/projects/proj%201/flows/flow%3F1/frames/frm%2Fa%2520b', - ); - }); - - it('serializes the query in order, leaving out undefined values', async () => { - await apiRequest('GET /api/projects/{projectId}/knowledge', { - path: { projectId: 'proj_1' }, - query: { - pageKey: 'https://shop.example/cart?x=1', - frameId: undefined, - includeMessages: 'true', - limit: 10, - }, - }); - expect(globalSent[0]?.url).toBe( - 'https://app.test/api/projects/proj_1/knowledge?pageKey=https%3A%2F%2Fshop.example%2Fcart%3Fx%3D1&includeMessages=true&limit=10', - ); - }); - - it('sends a contract enum query value as declared', async () => { - // A value outside the enum ('running') is a compile error here: the query - // is typed exactly as the contract declares it. - await apiRequest('GET /api/projects/{projectId}/deployments', { - path: { projectId: 'proj_1' }, - query: { status: 'active', type: 'web' }, - }); - expect(globalSent[0]?.url).toBe( - 'https://app.test/api/projects/proj_1/deployments?status=active&type=web', - ); - }); - - it('sends no query string when every value is undefined', async () => { - await apiRequest('GET /api/projects/{projectId}/deployments', { - path: { projectId: 'proj_1' }, - query: { cursor: undefined }, - }); - expect(globalSent[0]?.url).toBe( - 'https://app.test/api/projects/proj_1/deployments', - ); - }); - - describe("auth 'user' (default)", () => { - it('attaches the access token', async () => { - await apiRequest('GET /api/auth/whoami'); - expect(globalSent[0]?.headers.authorization).toBe('Bearer user-token'); - }); - - it('refuses plain http off the local machine before sending', async () => { - mockResolveAppUrl.mockReturnValue('http://app.test'); - await expect(apiRequest('GET /api/auth/whoami')).rejects.toThrow( - /plain http/, - ); - expect(globalSent).toHaveLength(0); - }); - - it('applies the same rule to an explicit baseUrl', async () => { - await expect( - apiRequest('GET /api/auth/whoami', { baseUrl: 'http://other.test' }), - ).rejects.toThrow(/plain http/); - expect(globalSent).toHaveLength(0); - }); - }); - - describe("auth 'deploy'", () => { - const op = 'GET /api/projects/{projectId}/flows/{flowId}/secrets/values'; - const path = { projectId: 'proj_1', flowId: 'flow_1' }; - - it('prefers the deploy token', async () => { - mockResolveDeployToken.mockReturnValue('deploy-token'); - await apiRequest(op, { auth: 'deploy', path }); - expect(globalSent[0]?.headers.authorization).toBe('Bearer deploy-token'); - }); - - it('falls back to the access token', async () => { - await apiRequest(op, { auth: 'deploy', path }); - expect(globalSent[0]?.headers.authorization).toBe('Bearer user-token'); - }); - - it.each([ - ['through deployFetch', {}], - ['with an explicit baseUrl', { baseUrl: 'https://other.test' }], - ])('refuses without either token %s', async (_label, transport) => { - mockResolveAccessToken.mockResolvedValue(null); - await expect( - apiRequest(op, { auth: 'deploy', path, ...transport }), - ).rejects.toThrow( - 'No authentication token available. Set WALKEROS_DEPLOY_TOKEN or run walkeros auth login.', - ); - expect(globalSent).toHaveLength(0); - }); - }); - - it.each([ - ['through publicFetch', {}], - ['with an explicit baseUrl', { baseUrl: 'https://other.test' }], - ])("auth 'none' sends no credential %s", async (_label, transport) => { - await apiRequest('GET /api/health', { auth: 'none', ...transport }); - expect(globalSent[0]?.headers.authorization).toBeUndefined(); - expect(mockResolveAccessToken).not.toHaveBeenCalled(); - }); - - it('sends exactly a given bearer', async () => { - await apiRequest('GET /api/auth/whoami', { auth: { token: 'fresh' } }); - expect(globalSent[0]?.headers.authorization).toBe('Bearer fresh'); - expect(mockResolveAccessToken).not.toHaveBeenCalled(); - }); - - it.each([ - ['through apiFetch', {}], - ['with an explicit baseUrl', { baseUrl: 'https://other.test' }], - ])('carries the client-context headers %s', async (_label, transport) => { - setClientContext({ type: 'mcp', version: '9.9.9' }); - await apiRequest('GET /api/auth/whoami', transport); - expect(globalSent[0]?.headers).toMatchObject({ - 'user-agent': 'walkeros-mcp/9.9.9', - 'x-walkeros-client': 'mcp', - 'x-walkeros-client-version': '9.9.9', - }); - }); - - it('lets a caller header replace a context header in any letter case', async () => { - setClientContext({ type: 'mcp', version: '9.9.9' }); - const { fetchFn, sent } = recorder(); - await apiRequest('GET /api/packages', { - auth: 'none', - fetch: fetchFn, - headers: { 'X-Walkeros-Client': 'walkeros-mcp/9.9.9' }, - }); - expect(sent[0]?.headers['x-walkeros-client']).toBe('walkeros-mcp/9.9.9'); - }); - - it('uses baseUrl instead of the resolved app URL', async () => { - await apiRequest('GET /api/health', { - auth: 'none', - baseUrl: 'https://other.test', - }); - expect(globalSent[0]?.url).toBe('https://other.test/api/health'); - expect(mockResolveAppUrl).not.toHaveBeenCalled(); - }); - - it('uses an injected fetch instead of the global one', async () => { - const { fetchFn, sent } = recorder(); - await apiRequest('GET /api/health', { auth: 'none', fetch: fetchFn }); - expect(sent.map((s) => s.url)).toEqual(['https://app.test/api/health']); - expect(globalSent).toHaveLength(0); - }); - - it('sends a JSON body with its content type', async () => { - await apiRequest('POST /api/feedback', { - auth: 'none', - body: { text: 'hello' }, - }); - expect(globalSent[0]).toMatchObject({ - method: 'POST', - body: '{"text":"hello"}', - headers: { 'content-type': 'application/json' }, - }); - }); - - it('sends a form body with its content type, the signal and the redirect mode', async () => { - const signal = new AbortController().signal; - await apiRequest('POST /api/oauth/revoke', { - auth: 'none', - form: { token: 'rt_1', token_type_hint: 'refresh_token' }, - headers: { Accept: 'application/json' }, - signal, - redirect: 'error', - }); - expect(globalSent[0]).toMatchObject({ - method: 'POST', - body: 'token=rt_1&token_type_hint=refresh_token', - headers: { - accept: 'application/json', - 'content-type': 'application/x-www-form-urlencoded', - }, - redirect: 'error', - }); - expect(globalSent[0]?.signal).toBe(signal); - }); - - it('keeps a content type the caller set', async () => { - await apiRequest('POST /api/feedback', { - auth: 'none', - body: { text: 'hello' }, - headers: { 'content-type': 'application/json; charset=utf-8' }, - }); - expect(globalSent[0]?.headers['content-type']).toBe( - 'application/json; charset=utf-8', - ); - }); -}); - -describe('operationUrl', () => { - it('builds the absolute URL of an operation', () => { - expect( - operationUrl('POST /api/telemetry', { baseUrl: 'https://app.test' }), - ).toBe('https://app.test/api/telemetry'); - }); -}); diff --git a/packages/cli/src/core/__tests__/app-fetch.test.ts b/packages/cli/src/core/__tests__/app-fetch.test.ts new file mode 100644 index 000000000..f7f13ccd9 --- /dev/null +++ b/packages/cli/src/core/__tests__/app-fetch.test.ts @@ -0,0 +1,75 @@ +import { appFetch, appErrorMessage } from '../app-fetch.js'; +import { resetClientContext, setClientContext } from '../client-context.js'; + +interface Seen { + url: string; + method: string; + headers: Headers; +} + +function recorder() { + const seen: Seen[] = []; + const fetchFn: typeof fetch = async (input, init) => { + seen.push({ + url: String(input), + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + }); + return new Response('{}', { status: 200 }); + }; + return { seen, fetchFn }; +} + +describe('appFetch', () => { + beforeEach(() => setClientContext({ type: 'cli', version: '9.9.9' })); + afterEach(() => resetClientContext()); + + it('sends the client context on every request', async () => { + const { seen, fetchFn } = recorder(); + await appFetch('/api/health', { + baseUrl: 'https://app.test/', + fetch: fetchFn, + }); + expect(seen[0].url).toBe('https://app.test/api/health'); + expect(seen[0].headers.get('X-WalkerOS-Client')).toBe('cli'); + expect(seen[0].headers.get('X-WalkerOS-Client-Version')).toBe('9.9.9'); + }); + + it('lets a caller header win and sends the bearer', async () => { + const { seen, fetchFn } = recorder(); + await appFetch('/api/x', { + baseUrl: 'https://app.test', + token: 'tok', + headers: { 'x-walkeros-client': 'custom', 'Idempotency-Key': 'k1' }, + method: 'POST', + fetch: fetchFn, + }); + expect(seen[0].method).toBe('POST'); + expect(seen[0].headers.get('X-WalkerOS-Client')).toBe('custom'); + expect(seen[0].headers.get('Idempotency-Key')).toBe('k1'); + expect(seen[0].headers.get('Authorization')).toBe('Bearer tok'); + }); + + it('refuses to send a credential over plain http to a remote host', async () => { + const { seen, fetchFn } = recorder(); + await expect( + appFetch('/api/x', { + baseUrl: 'http://app.test', + token: 'tok', + fetch: fetchFn, + }), + ).rejects.toThrow(/plain http/); + expect(seen).toHaveLength(0); + }); + + it.each([ + [ + { error: { code: 'CLIENT_OUTDATED', message: 'needs >= 4.10.0' } }, + 'needs >= 4.10.0', + ], + [{ error: 'invalid_grant' }, undefined], + ['', undefined], + ])('reads the app error envelope of %j', (body, message) => { + expect(appErrorMessage(body)).toBe(message); + }); +}); diff --git a/packages/cli/src/core/__tests__/contract.test.ts b/packages/cli/src/core/__tests__/contract.test.ts deleted file mode 100644 index 28bdd02dd..000000000 --- a/packages/cli/src/core/__tests__/contract.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { createHash } from 'node:crypto'; -import { - canonicalContractHash, - compareContract, - fetchHealth, - fetchOpenApi, - formatContract, -} from '../contract.js'; -import type { ClientOperation, ContractComparison } from '../contract.js'; -import { operationDigests } from '../openapi-subset.js'; -import { - resetClientContext, - setClientContext, - type ClientContext, -} from '../client-context.js'; - -jest.mock('../../lib/config-file.js', () => ({ - resolveAppUrl: () => 'https://app.test', -})); - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -// Reference implementation of the app's computeContractHash, replicated here so -// the test fails loudly if contract.ts ever diverges from the app algorithm: -// sha256 hex of JSON.stringify(canonicalize(stripInfoVersion(doc))). -function refCanonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(refCanonicalize); - if (isRecord(value)) { - const result: Record = {}; - for (const key of Object.keys(value).sort()) { - result[key] = refCanonicalize(value[key]); - } - return result; - } - return value; -} -function refStripInfoVersion(doc: unknown): unknown { - if (!isRecord(doc) || !isRecord(doc.info)) return doc; - const { version: _v, ...restInfo } = doc.info; - return { ...doc, info: restInfo }; -} -function refHash(doc: unknown): string { - return createHash('sha256') - .update(JSON.stringify(refCanonicalize(refStripInfoVersion(doc)))) - .digest('hex'); -} - -describe('canonicalContractHash (app parity)', () => { - it('matches the reference algorithm byte-for-byte', () => { - const doc = { - openapi: '3.1.0', - info: { title: 'x', version: '9.9.9' }, - paths: { '/b': { get: {} }, '/a': { post: {} } }, - }; - expect(canonicalContractHash(doc)).toBe(refHash(doc)); - }); - - it('is key-order independent', () => { - const a = { info: { title: 't', version: '1.0.0' }, paths: { p: 1 } }; - const b = { paths: { p: 1 }, info: { version: '1.0.0', title: 't' } }; - expect(canonicalContractHash(a)).toBe(canonicalContractHash(b)); - }); - - it('ignores info.version but reflects content changes', () => { - const base = { info: { title: 't', version: '1.0.0' }, paths: { a: 1 } }; - const bumped = { info: { title: 't', version: '2.0.0' }, paths: { a: 1 } }; - const changed = { info: { title: 't', version: '1.0.0' }, paths: { a: 2 } }; - expect(canonicalContractHash(base)).toBe(canonicalContractHash(bumped)); - expect(canonicalContractHash(base)).not.toBe( - canonicalContractHash(changed), - ); - }); -}); - -// === A small app: two client operations, one app-only operation === - -const CLIENT_LABEL = '4.7.0+aaaaaaaa'; -const LIST = 'GET /api/projects'; -const GET_ONE = 'GET /api/projects/{projectId}'; -const MANIFEST = [ - { op: LIST, usedBy: ['mcp project_manage', 'walkeros projects list'] }, - { op: GET_ONE, usedBy: ['mcp project_manage', 'walkeros projects get'] }, -]; - -interface ServerShape { - label?: string; - /** An operation only the app's own UI calls. */ - appOnlyOperation?: boolean; - /** A different description on a client operation. */ - description?: string; - /** The project schema gains a required property. */ - requiredOwner?: boolean; - /** The server does not offer `GET /api/projects/{projectId}`. */ - withoutGetOne?: boolean; -} - -function serverDoc(shape: ServerShape = {}) { - const project = { - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - ...(shape.requiredOwner ? { owner: { type: 'string' } } : {}), - }, - required: shape.requiredOwner ? ['id', 'name', 'owner'] : ['id', 'name'], - }; - const ok = (ref: string) => ({ - '200': { - description: 'OK', - content: { 'application/json': { schema: { $ref: ref } } }, - }, - }); - return { - openapi: '3.1.0', - info: { title: 'walkerOS', version: shape.label ?? CLIENT_LABEL }, - paths: { - '/api/projects': { - get: { - description: shape.description ?? 'List projects', - responses: ok('#/components/schemas/ProjectList'), - }, - }, - ...(shape.withoutGetOne - ? {} - : { - '/api/projects/{projectId}': { - parameters: [ - { - name: 'projectId', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - get: { responses: ok('#/components/schemas/Project') }, - }, - }), - ...(shape.appOnlyOperation - ? { - '/api/billing/portal': { - post: { responses: { '204': { description: 'No content' } } }, - }, - } - : {}), - }, - components: { - schemas: { - Project: project, - ProjectList: { - type: 'object', - properties: { - projects: { - type: 'array', - items: { $ref: '#/components/schemas/Project' }, - }, - }, - required: ['projects'], - }, - }, - }, - }; -} - -/** The client's baked data: the manifest plus digests of the client's own copy. */ -const BAKED_DIGESTS = operationDigests( - serverDoc(), - MANIFEST.map((entry) => entry.op), -); -const OPERATIONS: ClientOperation[] = MANIFEST.map((entry) => ({ - ...entry, - digest: BAKED_DIGESTS[entry.op], -})); - -const CLI_4_8: ClientContext = { type: 'cli', version: '4.8.0' }; - -function health(extra: Record = {}) { - return { - status: 'ok', - appVersion: 'abc1234', - contractVersion: CLIENT_LABEL, - contractHash: 'f'.repeat(64), - minSupportedClient: '4.7.0', - ...extra, - }; -} - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -interface AppAnswers { - health?: unknown; - healthError?: Error; - openapi?: unknown; - openapiStatus?: number; -} - -const realFetch = global.fetch; - -/** Answers the two public routes; records each request URL and its headers. */ -function mockApp(answers: AppAnswers): { - urls: string[]; - headers: Headers[]; -} { - const urls: string[] = []; - const headers: Headers[] = []; - const mock: typeof fetch = async (input, init) => { - const url = String(input); - urls.push(url); - headers.push(new Headers(init?.headers)); - if (url.endsWith('/api/health')) { - if (answers.healthError) throw answers.healthError; - return json(answers.health ?? health()); - } - if (url.endsWith('/api/openapi.json')) { - return json(answers.openapi ?? serverDoc(), answers.openapiStatus); - } - return json({ error: 'not found' }, 404); - }; - global.fetch = mock; - return { urls, headers }; -} - -afterEach(() => { - global.fetch = realFetch; - resetClientContext(); - delete process.env.WALKEROS_TOKEN; -}); - -describe('fetchHealth', () => { - it('probes the baseUrl it is given', async () => { - const { urls } = mockApp({}); - await fetchHealth('https://passed.test'); - expect(urls).toEqual(['https://passed.test/api/health']); - }); - - it('probes the locally resolved app URL when no baseUrl is given', async () => { - const { urls } = mockApp({}); - await fetchHealth(); - expect(urls).toEqual(['https://app.test/api/health']); - }); - - it('sends no credential, also with a token in the environment', async () => { - process.env.WALKEROS_TOKEN = 'secret-token'; - const { headers } = mockApp({}); - await fetchHealth('https://passed.test'); - expect(headers[0].has('authorization')).toBe(false); - }); - - it('parses the contract fields and minSupportedClient', async () => { - mockApp({ health: health() }); - expect(await fetchHealth()).toEqual({ - reachable: true, - status: 'ok', - appVersion: 'abc1234', - contractVersion: CLIENT_LABEL, - contractHash: 'f'.repeat(64), - minSupportedClient: '4.7.0', - }); - }); - - it('reports a timeout as the reason', async () => { - mockApp({ - healthError: new DOMException('aborted', 'TimeoutError'), - }); - expect(await fetchHealth()).toEqual({ - reachable: false, - error: 'timeout after 5000 ms', - }); - }); - - it('reports a network failure with its code', async () => { - mockApp({ - healthError: new TypeError('fetch failed', { - cause: Object.assign(new Error('connect'), { code: 'ECONNREFUSED' }), - }), - }); - expect(await fetchHealth()).toEqual({ - reachable: false, - error: 'fetch failed (ECONNREFUSED)', - }); - }); - - it('counts a non-2xx answer as reachable, with its status', async () => { - const mock: typeof fetch = async () => - new Response('unavailable', { status: 503 }); - global.fetch = mock; - expect(await fetchHealth()).toEqual({ reachable: true, httpStatus: 503 }); - }); - - it('counts a non-object body as reachable without fields', async () => { - mockApp({ health: 'not json' }); - expect(await fetchHealth()).toEqual({ reachable: true }); - }); -}); - -describe('fetchOpenApi', () => { - it('returns the live document', async () => { - const { urls } = mockApp({}); - const result = await fetchOpenApi('https://passed.test'); - expect(urls).toEqual(['https://passed.test/api/openapi.json']); - expect(result).toEqual({ - ok: true, - url: 'https://passed.test/api/openapi.json', - document: serverDoc(), - }); - }); - - it('reports a non-2xx status', async () => { - mockApp({ openapi: { error: 'down' }, openapiStatus: 503 }); - expect(await fetchOpenApi('https://passed.test')).toEqual({ - ok: false, - url: 'https://passed.test/api/openapi.json', - error: 'HTTP 503', - }); - }); - - it('reports a document without paths', async () => { - mockApp({ openapi: { openapi: '3.1.0' } }); - expect(await fetchOpenApi('https://passed.test')).toEqual({ - ok: false, - url: 'https://passed.test/api/openapi.json', - error: 'no paths', - }); - }); -}); - -describe('compareContract', () => { - const input = { - bakedVersion: CLIENT_LABEL, - operations: OPERATIONS, - client: CLI_4_8, - }; - - it('compares against input.baseUrl, health and document alike', async () => { - const { urls } = mockApp({}); - await compareContract({ ...input, baseUrl: 'https://stage.test' }); - expect(urls).toEqual([ - 'https://stage.test/api/health', - 'https://stage.test/api/openapi.json', - ]); - }); - - it('compares against the locally resolved app URL when baseUrl is omitted', async () => { - const { urls } = mockApp({}); - const out = await compareContract(input); - expect(out.appUrl).toBe('https://app.test'); - expect(urls).toEqual([ - 'https://app.test/api/health', - 'https://app.test/api/openapi.json', - ]); - }); - - it('sends no credential', async () => { - process.env.WALKEROS_TOKEN = 'secret-token'; - const { headers } = mockApp({}); - await compareContract(input); - expect(headers.every((h) => !h.has('authorization'))).toBe(true); - }); - - type Case = { - name: string; - app: AppAnswers; - client?: ClientContext; - expected: Partial; - }; - - const cases: Case[] = [ - // Server ahead of the client. - { - name: 'an app-only operation added under a new label: in-sync', - app: { - health: health({ contractVersion: '4.7.0+dddddddd' }), - openapi: serverDoc({ appOnlyOperation: true, label: '4.7.0+dddddddd' }), - }, - expected: { - verdict: 'in-sync', - server: '4.7.0+dddddddd', - operations: 2, - missing: [], - changed: [], - }, - }, - { - name: 'only a description changed: in-sync', - app: { openapi: serverDoc({ description: 'Every project you own' }) }, - expected: { verdict: 'in-sync', missing: [], changed: [] }, - }, - { - name: 'a client response differs with the same floor: changed', - app: { - health: health({ contractVersion: '4.7.0+bbbbbbbb' }), - openapi: serverDoc({ label: '4.7.0+bbbbbbbb', requiredOwner: true }), - }, - expected: { - verdict: 'changed', - server: '4.7.0+bbbbbbbb', - missing: [], - changed: [LIST, GET_ONE], - }, - }, - { - name: 'a higher floor the client version meets, with a difference: changed', - app: { - health: health({ - contractVersion: '4.8.0+cccccccc', - minSupportedClient: '4.8.0', - }), - openapi: serverDoc({ label: '4.8.0+cccccccc', requiredOwner: true }), - }, - expected: { verdict: 'changed', server: '4.8.0+cccccccc' }, - }, - { - name: 'the floor above the client: client-outdated, naming @walkeros/cli', - app: { health: health({ minSupportedClient: '4.9.0' }) }, - expected: { - verdict: 'client-outdated', - requires: { package: '@walkeros/cli', version: '4.9.0' }, - }, - }, - { - name: 'the floor above the MCP: client-outdated, naming @walkeros/mcp', - app: { health: health({ minSupportedClient: '4.9.0' }) }, - client: { type: 'mcp', version: '4.8.0' }, - expected: { - verdict: 'client-outdated', - client: { - package: '@walkeros/mcp', - version: '4.8.0', - contract: CLIENT_LABEL, - }, - requires: { package: '@walkeros/mcp', version: '4.9.0' }, - }, - }, - { - name: 'the client at the floor: not outdated', - app: { health: health({ minSupportedClient: '4.8.0' }) }, - expected: { verdict: 'in-sync' }, - }, - // Server behind the client. - { - name: 'a manifest operation missing: server-older, with its callers', - app: { - health: health({ contractVersion: '4.6.1', minSupportedClient: '' }), - openapi: serverDoc({ label: '4.6.1', withoutGetOne: true }), - }, - expected: { - verdict: 'server-older', - server: '4.6.1', - missing: [ - { - op: GET_ONE, - usedBy: ['mcp project_manage', 'walkeros projects get'], - }, - ], - changed: [], - }, - }, - { - name: 'legacy label 2.1.0 with a changed operation: server-older', - app: { - health: { status: 'ok', contractVersion: '2.1.0' }, - openapi: serverDoc({ label: '2.1.0', requiredOwner: true }), - }, - expected: { - verdict: 'server-older', - server: '2.1.0', - missing: [], - changed: [LIST, GET_ONE], - }, - }, - { - name: 'legacy label 4.7.1 with a changed operation: server-older', - app: { - health: { status: 'ok', contractVersion: '4.7.1' }, - openapi: serverDoc({ label: '4.7.1', requiredOwner: true }), - }, - expected: { - verdict: 'server-older', - server: '4.7.1', - changed: [LIST, GET_ONE], - }, - }, - { - name: 'a lower floor with a changed operation: server-older', - app: { - health: health({ contractVersion: '4.6.1+bbbbbbbb' }), - openapi: serverDoc({ label: '4.6.1+bbbbbbbb', requiredOwner: true }), - }, - expected: { verdict: 'server-older', server: '4.6.1+bbbbbbbb' }, - }, - { - name: 'no label with a changed operation: server-older', - app: { - health: { status: 'ok' }, - openapi: serverDoc({ label: 'dev', requiredOwner: true }), - }, - expected: { verdict: 'server-older', server: 'dev' }, - }, - { - name: 'a missing operation outranks a higher floor: server-older', - app: { - health: health({ contractVersion: '4.9.0+cccccccc' }), - openapi: serverDoc({ label: '4.9.0+cccccccc', withoutGetOne: true }), - }, - expected: { verdict: 'server-older' }, - }, - // Unknown. - { - name: 'health unreachable: unknown', - app: { healthError: new DOMException('aborted', 'TimeoutError') }, - expected: { - verdict: 'unknown', - reason: 'GET https://app.test/api/health: timeout after 5000 ms', - }, - }, - { - name: 'openapi 503: unknown', - app: { openapi: { error: 'down' }, openapiStatus: 503 }, - expected: { - verdict: 'unknown', - reason: 'GET https://app.test/api/openapi.json: HTTP 503', - }, - }, - { - name: 'openapi without paths: unknown', - app: { openapi: { openapi: '3.1.0', info: { version: CLIENT_LABEL } } }, - expected: { - verdict: 'unknown', - reason: 'GET https://app.test/api/openapi.json: no paths', - }, - }, - ]; - - it.each(cases)('$name', async ({ app, client, expected }) => { - mockApp(app); - const out = await compareContract({ - ...input, - ...(client ? { client } : {}), - }); - expect(out).toMatchObject(expected); - }); - - it('answers unknown without client operations to compare', async () => { - const { urls } = mockApp({}); - const out = await compareContract({ ...input, operations: [] }); - expect(out).toMatchObject({ - verdict: 'unknown', - reason: 'no baked client operations', - }); - expect(urls).toEqual(['https://app.test/api/health']); - }); - - it('names the package of the process client context by default', async () => { - setClientContext({ type: 'mcp', version: '4.6.0' }); - mockApp({ health: health({ minSupportedClient: '4.7.0' }) }); - const out = await compareContract({ - bakedVersion: CLIENT_LABEL, - operations: OPERATIONS, - }); - expect(out.verdict).toBe('client-outdated'); - expect(out.requires).toEqual({ - package: '@walkeros/mcp', - version: '4.7.0', - }); - }); -}); - -describe('formatContract', () => { - const client = { - package: '@walkeros/cli', - version: '4.8.0', - contract: '4.7.0+1a2b3c4d', - }; - const appUrl = 'https://app.walkeros.io'; - - const lines: Array<[string, ContractComparison, string]> = [ - [ - 'in-sync', - { - verdict: 'in-sync', - appUrl, - client, - server: '4.7.0+5e6f7a8b', - operations: 54, - missing: [], - changed: [], - }, - 'contract: in-sync (server 4.7.0+5e6f7a8b, client 4.7.0+1a2b3c4d, 54 operations)', - ], - [ - 'changed', - { - verdict: 'changed', - appUrl, - client, - server: '4.7.0+9f8e7d6c', - operations: 54, - missing: [], - changed: ['GET /api/projects/{projectId}/frames'], - }, - 'contract: changed (server 4.7.0+9f8e7d6c, client 4.7.0+1a2b3c4d); changed: GET /api/projects/{projectId}/frames', - ], - [ - 'server-older', - { - verdict: 'server-older', - appUrl, - client, - server: '2.1.0', - operations: 54, - missing: [ - { - op: 'POST /api/oauth/device_authorization', - usedBy: ['mcp auth', 'walkeros auth login'], - }, - { - op: 'GET /api/projects/{projectId}/frames', - usedBy: ['mcp frame_manage'], - }, - ], - changed: ['GET /api/projects', 'GET /api/health'], - }, - 'contract: server-older (server 2.1.0, client 4.7.0+1a2b3c4d); not offered (2): POST /api/oauth/device_authorization (mcp auth, walkeros auth login), GET /api/projects/{projectId}/frames (mcp frame_manage); changed: 2', - ], - [ - 'client-outdated', - { - verdict: 'client-outdated', - appUrl, - client, - server: '4.9.0+00000000', - requires: { package: '@walkeros/cli', version: '4.9.0' }, - }, - 'contract: client-outdated (https://app.walkeros.io requires @walkeros/cli >= 4.9.0; this is 4.8.0)', - ], - [ - 'unknown', - { - verdict: 'unknown', - appUrl, - client, - reason: 'GET https://app.walkeros.io/api/health: timeout after 5000 ms', - }, - 'contract: unknown (GET https://app.walkeros.io/api/health: timeout after 5000 ms)', - ], - [ - 'in-process', - { verdict: 'in-process', appUrl, client, server: '4.7.0+1a2b3c4d' }, - 'contract: in-process (server 4.7.0+1a2b3c4d)', - ], - ]; - - it.each(lines)('%s', (_verdict, comparison, line) => { - expect(formatContract(comparison)).toBe(line); - }); - - it('states facts only', () => { - for (const [, comparison] of lines) { - expect(formatContract(comparison)).not.toMatch(/\b(run|try|upgrade)\b/i); - } - }); -}); diff --git a/packages/cli/src/core/__tests__/openapi-subset.test.ts b/packages/cli/src/core/__tests__/openapi-subset.test.ts deleted file mode 100644 index 8f17c4e96..000000000 --- a/packages/cli/src/core/__tests__/openapi-subset.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - canonicalize, - documentOperations, - labelFloorRegressed, - manifestEntries, - manifestOperations, - operationDigests, - pruneToOperations, - subsetUnchanged, -} from '../openapi-subset.js'; - -type Json = { [key: string]: unknown }; - -const ref = (name: string): Json => ({ $ref: `#/components/schemas/${name}` }); - -function jsonBody(schema: Json): Json { - return { content: { 'application/json': { schema } } }; -} - -/** A small document covering every shape the subset walks. */ -function fixture(): Json { - return { - openapi: '3.1.0', - info: { - title: 'walkerOS Tag Manager API', - version: '4.7.0+80fb4d79', - description: 'API for managing flows.', - summary: 'The API.', - }, - servers: [{ url: '/', description: 'Current server' }], - tags: [{ name: 'Projects', description: 'Project routes' }], - externalDocs: { url: 'https://www.walkeros.io' }, - webhooks: {}, - paths: { - '/api/things/{thingId}': { - summary: 'Things', - parameters: [ - { - name: 'thingId', - in: 'path', - required: true, - description: 'Thing id', - schema: { type: 'string', example: 'thg_1' }, - }, - ], - get: { - summary: 'Read a thing', - description: 'Reads one thing.', - externalDocs: { url: 'https://www.walkeros.io/things' }, - tags: ['Things'], - operationId: 'getThing', - parameters: [ - { - name: 'description', - in: 'query', - description: 'A query parameter named description', - schema: { type: 'string' }, - example: 'x', - }, - { $ref: '#/components/parameters/Limit' }, - ], - responses: { - '200': { - description: 'The thing', - headers: { - 'X-Thing-Version': { - description: 'Version', - schema: ref('Version'), - }, - }, - content: { - 'application/json': { - schema: ref('Thing'), - example: { id: 'thg_1' }, - }, - }, - }, - '404': { $ref: '#/components/responses/NotFound' }, - }, - }, - delete: { - summary: 'Delete a thing', - responses: { '204': { description: 'Deleted' } }, - }, - }, - '/api/widgets': { - post: { - requestBody: jsonBody(ref('WidgetRequest')), - responses: { - '201': { description: 'Created', ...jsonBody(ref('Widget')) }, - }, - }, - }, - '/api/unused': { - get: { - responses: { - '200': { description: 'x', ...jsonBody(ref('Unused')) }, - }, - }, - }, - }, - components: { - securitySchemes: { - bearer: { type: 'http', scheme: 'bearer', description: 'Bearer' }, - }, - parameters: { - Limit: { - name: 'limit', - in: 'query', - description: 'Page size', - schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, - }, - UnusedParam: { name: 'x', in: 'query', schema: { type: 'string' } }, - }, - responses: { - NotFound: { description: 'Not found', ...jsonBody(ref('Error')) }, - }, - schemas: { - Thing: { - type: 'object', - title: 'Thing', - description: 'A thing.', - required: ['id', 'description', 'title'], - properties: { - id: { type: 'string', description: 'Id', example: 'thg_1' }, - description: { type: 'string', description: 'Its description' }, - title: { type: ['string', 'null'], title: 'Title' }, - examples: { type: 'array', items: { type: 'string' } }, - kind: { - type: 'string', - enum: ['a', 'b'], - default: 'a', - deprecated: true, - }, - created: { - type: 'string', - format: 'date-time', - readOnly: true, - nullable: true, - }, - secret: { type: 'string', writeOnly: true, pattern: '^s_' }, - meta: { type: 'object', 'x-internal': { description: 'kept' } }, - child: ref('Child'), - entry: { - oneOf: [ref('EntryThread'), ref('EntryNote')], - discriminator: { - propertyName: 'kind', - mapping: { - thread: '#/components/schemas/EntryThread', - description: '#/components/schemas/EntryNote', - }, - }, - }, - }, - }, - Child: { - type: 'object', - properties: { grandchild: ref('Grandchild') }, - }, - Grandchild: { - type: 'object', - properties: { value: { type: 'integer', description: 'Leaf' } }, - }, - Node: { - type: 'object', - properties: { next: ref('Node') }, - }, - EntryThread: { - type: 'object', - properties: { kind: { const: 'thread' } }, - }, - EntryNote: { type: 'object', properties: { kind: { const: 'note' } } }, - Version: { type: 'string' }, - Error: { type: 'object', properties: { message: { type: 'string' } } }, - WidgetRequest: { - type: 'object', - properties: { root: ref('Node') }, - examples: [{ root: {} }], - }, - Widget: { type: 'object', properties: { id: { type: 'string' } } }, - Unused: { type: 'object', properties: { id: { type: 'string' } } }, - }, - }, - }; -} - -const OPS = ['GET /api/things/{thingId}', 'POST /api/widgets']; - -function isJson(value: unknown): value is Json { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** The value itself, narrowed to an object, so a test can mutate it in place. */ -function record(value: unknown): Json { - if (!isJson(value)) - throw new Error(`not an object: ${JSON.stringify(value)}`); - return value; -} - -function at(value: unknown, ...keys: string[]): unknown { - let current = value; - for (const key of keys) current = record(current)[key]; - return current; -} - -describe('pruneToOperations', () => { - const pruned = pruneToOperations(fixture(), OPS); - - it('keeps exactly the listed operations, with their path-level parameters', () => { - expect(documentOperations(pruned)).toEqual(OPS); - expect(Object.keys(record(pruned.paths))).toEqual([ - '/api/things/{thingId}', - '/api/widgets', - ]); - expect(at(pruned, 'paths', '/api/things/{thingId}', 'parameters')).toEqual([ - { - name: 'thingId', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ]); - }); - - it('keeps the $ref closure and drops unreferenced components', () => { - const components = record(pruned.components); - // Nested (Thing -> Child -> Grandchild), recursive (Node -> Node), refs in - // parameters, request bodies, responses and headers, and discriminator - // mapping targets. - expect(Object.keys(record(components.schemas)).sort()).toEqual( - [ - 'Child', - 'EntryNote', - 'EntryThread', - 'Error', - 'Grandchild', - 'Node', - 'Thing', - 'Version', - 'Widget', - 'WidgetRequest', - ].sort(), - ); - expect(Object.keys(record(components.parameters))).toEqual(['Limit']); - expect(Object.keys(record(components.responses))).toEqual(['NotFound']); - expect(components.securitySchemes).toEqual({ - bearer: { type: 'http', scheme: 'bearer' }, - }); - }); - - it('keeps properties, parameters and required entries named like doc keywords', () => { - const thing = at(pruned, 'components', 'schemas', 'Thing'); - expect(Object.keys(record(at(thing, 'properties')))).toEqual([ - 'id', - 'description', - 'title', - 'examples', - 'kind', - 'created', - 'secret', - 'meta', - 'child', - 'entry', - ]); - expect(at(thing, 'properties', 'description')).toEqual({ type: 'string' }); - expect(at(thing, 'required')).toEqual(['id', 'description', 'title']); - expect( - at(pruned, 'paths', '/api/things/{thingId}', 'get', 'parameters'), - ).toEqual([ - { name: 'description', in: 'query', schema: { type: 'string' } }, - { $ref: '#/components/parameters/Limit' }, - ]); - expect( - at(thing, 'properties', 'entry', 'discriminator', 'mapping'), - ).toEqual({ - thread: '#/components/schemas/EntryThread', - description: '#/components/schemas/EntryNote', - }); - }); - - it.each<[string, string[], unknown]>([ - [ - 'an operation', - ['paths', '/api/things/{thingId}', 'get'], - { - operationId: 'getThing', - }, - ], - [ - 'a parameter', - ['components', 'parameters', 'Limit'], - { - name: 'limit', - in: 'query', - schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, - }, - ], - [ - 'a schema', - ['components', 'schemas', 'Grandchild'], - { type: 'object', properties: { value: { type: 'integer' } } }, - ], - [ - 'info', - ['info'], - { title: 'walkerOS Tag Manager API', version: '4.7.0+80fb4d79' }, - ], - ])('strips doc keywords at %s level', (_level, keys, expected) => { - const node = record(at(pruned, ...keys)); - if (keys[0] === 'paths') { - // The operation keeps its wire parts; compare the rest. - const { parameters: _p, responses: _r, ...rest } = node; - expect(rest).toEqual(expected); - } else { - expect(node).toEqual(expected); - } - }); - - it('drops the top-level tags, webhooks and externalDocs and strips servers', () => { - expect(Object.keys(pruned)).toEqual([ - 'openapi', - 'info', - 'servers', - 'paths', - 'components', - ]); - expect(pruned.servers).toEqual([{ url: '/' }]); - }); - - it('keeps an empty description on every Response object', () => { - const get = at(pruned, 'paths', '/api/things/{thingId}', 'get'); - expect(at(get, 'responses', '200', 'description')).toBe(''); - expect(at(get, 'responses', '200', 'headers')).toEqual({ - 'X-Thing-Version': { schema: ref('Version') }, - }); - expect(at(get, 'responses', '200', 'content')).toEqual({ - 'application/json': { schema: ref('Thing') }, - }); - expect(at(get, 'responses', '404')).toEqual({ - $ref: '#/components/responses/NotFound', - }); - expect(at(pruned, 'components', 'responses', 'NotFound')).toEqual({ - description: '', - ...jsonBody(ref('Error')), - }); - }); - - it.each([ - [ - 'enum and default', - 'kind', - { type: 'string', enum: ['a', 'b'], default: 'a', deprecated: true }, - ], - [ - 'format, readOnly and nullable', - 'created', - { type: 'string', format: 'date-time', readOnly: true, nullable: true }, - ], - [ - 'writeOnly and pattern', - 'secret', - { type: 'string', writeOnly: true, pattern: '^s_' }, - ], - [ - 'x- extensions', - 'meta', - { type: 'object', 'x-internal': { description: 'kept' } }, - ], - ])('keeps %s unchanged', (_label, property, expected) => { - expect( - at(pruned, 'components', 'schemas', 'Thing', 'properties', property), - ).toEqual(expected); - }); - - it('throws for a manifest operation the input lacks', () => { - expect(() => - pruneToOperations(fixture(), [...OPS, 'GET /api/missing']), - ).toThrow('manifest operation not in input: GET /api/missing'); - }); - - it('changes nothing when pruning a pruned document', () => { - expect(pruneToOperations(pruned, OPS)).toEqual(pruned); - expect(JSON.stringify(pruneToOperations(pruned, OPS))).toBe( - JSON.stringify(pruned), - ); - }); -}); - -describe('operationDigests', () => { - const base = operationDigests(fixture(), OPS); - - it('is equal across a description-only change', () => { - const changed = fixture(); - const thing = record(at(changed, 'components', 'schemas', 'Thing')); - thing.description = 'A different description.'; - record(at(thing, 'properties', 'id')).description = 'Changed'; - record(at(changed, 'paths', '/api/things/{thingId}', 'get')).summary = - 'Changed'; - expect(operationDigests(changed, OPS)).toEqual(base); - }); - - it('changes only for the operations that reach a changed schema', () => { - const changed = fixture(); - const grandchild = record( - at(changed, 'components', 'schemas', 'Grandchild'), - ); - record(at(grandchild, 'properties', 'value')).type = 'string'; - const digests = operationDigests(changed, OPS); - expect(digests['GET /api/things/{thingId}']).not.toBe( - base['GET /api/things/{thingId}'], - ); - expect(digests['POST /api/widgets']).toBe(base['POST /api/widgets']); - }); - - it('is equal across an operation tags change', () => { - const changed = fixture(); - record(at(changed, 'paths', '/api/things/{thingId}', 'get')).tags = [ - 'Other', - ]; - expect(operationDigests(changed, OPS)).toEqual(base); - }); - - it('terminates on a recursive schema and leaves out operations the input lacks', () => { - const digests = operationDigests(fixture(), [...OPS, 'GET /api/missing']); - expect(Object.keys(digests)).toEqual(OPS); - expect(digests['POST /api/widgets']).toMatch(/^[0-9a-f]{64}$/); - }); -}); - -/** - * Schema properties named like keywords (`default`, `required`, `mapping`), - * each a name in a `properties` map, never a keyword. - */ -function keywordNamedProperties(target: Json = { type: 'string' }): Json { - return { - openapi: '3.1.0', - info: { title: 'walkerOS', version: '4.7.0+80fb4d79' }, - paths: { - '/api/settings': { - get: { - responses: { - '200': { - description: 'OK', - content: { 'application/json': { schema: ref('Settings') } }, - }, - }, - }, - }, - }, - components: { - schemas: { - Settings: { - type: 'object', - properties: { - default: ref('Fallback'), - required: ref('Rules'), - mapping: { type: 'object', additionalProperties: true }, - }, - required: ['default'], - }, - Fallback: target, - Rules: { type: 'array', items: { type: 'string' } }, - Unused: { type: 'string' }, - }, - }, - }; -} - -describe('properties named like keywords', () => { - const op = ['GET /api/settings']; - - it('keeps the components they reference', () => { - const pruned = pruneToOperations(keywordNamedProperties(), op); - expect(Object.keys(record(at(pruned, 'components', 'schemas')))).toEqual([ - 'Settings', - 'Fallback', - 'Rules', - ]); - }); - - it('changes the digest when a component they reference changes', () => { - const base = operationDigests(keywordNamedProperties(), op); - const changed = operationDigests( - keywordNamedProperties({ type: 'integer' }), - op, - ); - expect(changed[op[0]]).not.toBe(base[op[0]]); - - const rules = keywordNamedProperties(); - record(at(rules, 'components', 'schemas', 'Rules', 'items')).type = - 'integer'; - expect(operationDigests(rules, op)[op[0]]).not.toBe(base[op[0]]); - }); - - it('reads a property named mapping as a schema, not a discriminator map', () => { - expect(() => operationDigests(keywordNamedProperties(), op)).not.toThrow(); - expect( - at( - pruneToOperations(keywordNamedProperties(), op), - 'components', - 'schemas', - 'Settings', - 'properties', - 'mapping', - ), - ).toEqual({ type: 'object', additionalProperties: true }); - }); -}); - -describe('subsetUnchanged', () => { - const withLabel = (version: string): Json => ({ - ...fixture(), - info: { title: 'walkerOS Tag Manager API', version }, - }); - - it('is true for a label-only change with the same floor', () => { - expect( - subsetUnchanged(withLabel('4.7.0+80fb4d79'), withLabel('4.7.0+1a2b3c4d')), - ).toBe(true); - }); - - it('is true for an identical subset', () => { - expect(subsetUnchanged(withLabel('4.7.1'), withLabel('4.7.1'))).toBe(true); - }); - - it('is false for a floor change', () => { - expect( - subsetUnchanged(withLabel('4.7.0+80fb4d79'), withLabel('4.8.0+1a2b3c4d')), - ).toBe(false); - }); - - it('is false for a legacy label next to a floored one', () => { - expect( - subsetUnchanged(withLabel('4.7.0'), withLabel('4.7.0+1a2b3c4d')), - ).toBe(false); - }); - - it('is false for a wire change', () => { - const next = withLabel('4.7.0+80fb4d79'); - record( - at(next, 'components', 'schemas', 'Grandchild', 'properties', 'value'), - ).type = 'string'; - expect(subsetUnchanged(withLabel('4.7.0+80fb4d79'), next)).toBe(false); - }); - - it('is false without a previous subset', () => { - expect(subsetUnchanged(undefined, withLabel('4.7.0+80fb4d79'))).toBe(false); - }); -}); - -describe('canonicalize', () => { - it('orders keys recursively and keeps array order', () => { - expect(JSON.stringify(canonicalize({ b: [2, { d: 1, c: 0 }], a: 1 }))).toBe( - '{"a":1,"b":[2,{"c":0,"d":1}]}', - ); - }); -}); - -describe('labelFloorRegressed', () => { - it.each([ - ['4.7.0+80fb4d79', '4.7.0+1a2b3c4d', false], - ['4.7.0+80fb4d79', '4.8.0+1a2b3c4d', false], - ['4.7.0+80fb4d79', '5.0.0+1a2b3c4d', false], - ['4.7.0+80fb4d79', '4.6.1+1a2b3c4d', true], - ['4.10.0+80fb4d79', '4.9.0+1a2b3c4d', true], - ['4.7.0+80fb4d79', '4.7.0-next.1+1a2b3c4d', true], - ['4.7.0+80fb4d79', '4.6.1', true], - ])('%s to %s regressed: %s', (base, head, regressed) => { - expect(labelFloorRegressed(base, head)).toBe(regressed); - }); - - it.each([ - ['4.7.1', '4.7.0+80fb4d79'], - ['1.1.0', '1.0.0'], - ['3.1.0', '4.7.0+80fb4d79'], - ])('skips the legacy base %s (no build metadata)', (base, head) => { - expect(labelFloorRegressed(base, head)).toBe(false); - }); - - it('throws for a label that is not a version', () => { - expect(() => labelFloorRegressed('4.7.0+80fb4d79', 'latest')).toThrow( - 'invalid contract label: latest', - ); - }); -}); - -describe('the shipped client operations manifest', () => { - const root = join(dirname(fileURLToPath(import.meta.url)), '../../..'); - const manifest: unknown = JSON.parse( - readFileSync(join(root, 'openapi/client-operations.json'), 'utf-8'), - ); - const spec: unknown = JSON.parse( - readFileSync(join(root, 'openapi/spec.json'), 'utf-8'), - ); - const ops = manifestOperations(manifest); - - it('is sorted by path, then method, without duplicates', () => { - const key = (op: string) => { - const space = op.indexOf(' '); - return `${op.slice(space + 1)} ${op.slice(0, space)}`; - }; - const sorted = [...ops].sort((a, b) => - key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, - ); - expect(ops).toEqual(sorted); - expect(new Set(ops).size).toBe(ops.length); - }); - - it('matches the operations of the shipped spec exactly', () => { - expect([...documentOperations(spec)].sort()).toEqual([...ops].sort()); - }); - - it('is what the shipped spec prunes to', () => { - expect(pruneToOperations(spec, ops)).toEqual(spec); - }); - - it('keeps each entry with its callers', () => { - expect(manifestEntries(manifest).map((entry) => entry.op)).toEqual(ops); - expect( - manifestEntries([{ op: 'GET /api/x', usedBy: ['walkeros x'] }]), - ).toEqual([{ op: 'GET /api/x', usedBy: ['walkeros x'] }]); - }); - - it('rejects any other shape', () => { - expect(() => manifestOperations({ ops: [] })).toThrow( - 'client operations manifest is not a list', - ); - expect(() => manifestOperations([{ op: 'GET /api/x' }])).toThrow( - 'client operations manifest entry is not { op, usedBy }', - ); - expect(() => - manifestOperations([{ op: 'FETCH /api/x', usedBy: [] }]), - ).toThrow('invalid operation: FETCH /api/x'); - }); -}); diff --git a/packages/cli/src/core/api-client.ts b/packages/cli/src/core/api-client.ts deleted file mode 100644 index d99b10ed0..000000000 --- a/packages/cli/src/core/api-client.ts +++ /dev/null @@ -1,37 +0,0 @@ -import createClient from 'openapi-fetch'; -import type { paths } from '../types/api.gen.js'; -import { notAuthenticatedError } from './api-error.js'; -import { resolveAccessToken } from './auth.js'; -import { resolveAppUrl } from '../lib/config-file.js'; -import { requireSecureUrl } from '../lib/secure-url.js'; -import { clientContextHeaders } from './client-context.js'; - -export function createApiClient() { - // Note: openapi-fetch fixes headers at createClient time. The CLI entry - // point (and MCP boot path) call setClientContext before any API client is - // constructed, so the client-context headers captured here are stable. - const client = createClient({ - baseUrl: resolveAppUrl(), - headers: { - 'Content-Type': 'application/json', - ...clientContextHeaders(), - }, - }); - - client.use({ - // Authorization is attached per request, not at construction: the stdio - // MCP server builds one client and keeps it for hours, so a token captured - // here would go stale and never pick up a refresh. - async onRequest({ request }) { - const token = await resolveAccessToken(); - if (!token) throw notAuthenticatedError(); - // Checked against the outgoing URL rather than the base one, so a path - // that resolved somewhere else still cannot take the bearer with it. - requireSecureUrl(request.url); - request.headers.set('Authorization', `Bearer ${token}`); - return request; - }, - }); - - return client; -} diff --git a/packages/cli/src/core/api-error.ts b/packages/cli/src/core/api-error.ts index 33ee9b03d..d38a8f766 100644 --- a/packages/cli/src/core/api-error.ts +++ b/packages/cli/src/core/api-error.ts @@ -54,106 +54,6 @@ export class ApiError extends Error { } } -/** - * The refusal for a request that needs a login when none is stored. It carries - * `UNAUTHORIZED`, the code the app answers an unauthenticated request with, so - * a caller handles both the same way. - */ -export function notAuthenticatedError(): ApiError { - return new ApiError('Not authenticated. Run `walkeros auth login` first.', { - code: 'UNAUTHORIZED', - }); -} - -/** - * Build ApiError options from an openapi-style error body. - * The error shape is: { error: { code, message, details: { errors: [] } } } - * Returns `null` when the body has no recognizable `error` object. - */ -function extractApiErrorOptions( - error: unknown, - fallbackMessage: string, -): { message: string; options: ApiErrorOptions } | null { - if ( - !error || - typeof error !== 'object' || - !('error' in error) || - typeof (error as Record).error !== 'object' - ) { - return null; - } - const inner = (error as { error: Record }).error; - const message = (inner.message as string) || fallbackMessage; - const code = inner.code as string | undefined; - const details = (inner.details as Record)?.errors as - | ApiErrorDetail[] - | undefined; - const options: ApiErrorOptions = { code, details }; - if (code === 'CLIENT_OUTDATED') { - options.minVersion = inner.minVersion as string | undefined; - options.clientVersion = inner.clientVersion as string | undefined; - options.client = inner.client as string | undefined; - options.upgrade = inner.upgrade as string | undefined; - options.docs = inner.docs as string | undefined; - } - return { message, options }; -} - -/** - * Extract structured error from an openapi-fetch error response and throw. - * - * For `code === 'CLIENT_OUTDATED'` (HTTP 426), also extracts the upgrade - * metadata: `minVersion`, `clientVersion`, `client`, `upgrade`, `docs`. - */ -export function throwApiError(error: unknown, fallbackMessage: string): never { - const extracted = extractApiErrorOptions(error, fallbackMessage); - if (extracted) throw new ApiError(extracted.message, extracted.options); - throw new ApiError(fallbackMessage); -} - -/** - * Parse a `Retry-After` header value into seconds. Accepts both the - * delta-seconds form (`"30"`) and the HTTP-date form. Returns `undefined` - * when the value is absent or unparseable. - */ -export function parseRetryAfter(value: string | null): number | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10); - const when = Date.parse(trimmed); - if (Number.isNaN(when)) return undefined; - return Math.max(0, Math.round((when - Date.now()) / 1000)); -} - -/** - * Like `throwApiError`, but classifies retryability from the HTTP response. - * A 429 is always retryable; a 503 is retryable when it carries a - * `Retry-After` hint. The parsed wait (seconds) is attached for `--wait` - * callers to honor and for the machine-readable error line. - */ -export function throwApiResponseError( - response: Pick, - body: unknown, - fallbackMessage: string, -): never { - const status = response.status; - const retryAfterSeconds = parseRetryAfter( - response.headers.get('retry-after'), - ); - const retryable = - status === 429 || (status === 503 && retryAfterSeconds !== undefined); - - const extracted = extractApiErrorOptions(body, fallbackMessage); - const message = extracted?.message ?? fallbackMessage; - const options: ApiErrorOptions = { - ...(extracted?.options ?? {}), - status, - retryable, - retryAfterSeconds, - }; - throw new ApiError(message, options); -} - /** * Emit a stable, parseable machine-readable error line so CI can branch on * the failure without string-matching prose. Format: diff --git a/packages/cli/src/core/api-request.ts b/packages/cli/src/core/api-request.ts deleted file mode 100644 index 456094dfe..000000000 --- a/packages/cli/src/core/api-request.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { paths } from '../types/api.gen.js'; -import { resolveAppUrl, resolveDeployToken } from '../lib/config-file.js'; -import { requireSecureUrl } from '../lib/secure-url.js'; -import { resolveAccessToken } from './auth.js'; -import { clientContextHeaders } from './client-context.js'; -import { apiFetch, deployFetch, publicFetch } from './http.js'; -import { parseOperation } from './openapi-subset.js'; - -type Method = - | 'get' - | 'put' - | 'post' - | 'delete' - | 'options' - | 'head' - | 'patch' - | 'trace'; - -/** - * Every app operation a walkerOS client calls, as `" "`. Derived - * from the client subset of the contract (`openapi/client-operations.json`), - * so an operation missing from that manifest is not a key here. - */ -export type OperationKey = { - [P in keyof paths & string]: { - [M in Method]: undefined extends paths[P][M] - ? never - : `${Uppercase} ${P}`; - }[Method]; -}[keyof paths & string]; - -type OperationOf = K extends `${infer M} ${infer P}` - ? P extends keyof paths - ? NonNullable & keyof paths[P]]> - : never - : never; - -type ParametersOf = - OperationOf extends { parameters: infer X } ? X : never; - -type PathParams = - ParametersOf extends { path: infer X } ? X : never; - -type QueryParams = - ParametersOf extends { query?: infer X } ? Exclude : never; - -type RequestBodyOf = - OperationOf extends { requestBody?: infer B } - ? Exclude - : never; - -type ContentOf = [ - RequestBodyOf, -] extends [never] - ? never - : RequestBodyOf extends { content: infer C } - ? Type extends keyof C - ? C[Type] - : never - : never; - -type BodyRequired = - OperationOf extends { requestBody: unknown } ? true : false; - -type PathInit = [PathParams] extends [never] - ? { path?: undefined } - : { path: PathParams }; - -type QueryInit = [QueryParams] extends [never] - ? { query?: undefined } - : object extends QueryParams - ? { query?: QueryParams } - : { query: QueryParams }; - -type JsonInit = [ - ContentOf, -] extends [never] - ? { body?: undefined } - : BodyRequired extends true - ? { body: ContentOf } - : { body?: ContentOf }; - -type FormInit = [ - ContentOf, -] extends [never] - ? { form?: undefined } - : { form: ContentOf }; - -/** - * Which credential a request carries: - * - `'user'`: the access token (`WALKEROS_TOKEN`, then the stored session), - * only over a secure URL; without one the request goes out unauthenticated. - * - `'deploy'`: the deploy token, then the access token; without either the - * request is refused. - * - `'none'`: no credential. - * - `{ token }`: exactly this bearer, only over a secure URL. - */ -export type ApiAuth = 'user' | 'deploy' | 'none' | { token: string }; - -/** How a request travels, independent of the operation. */ -export interface ApiTransport { - /** Default `'user'`. */ - auth?: ApiAuth; - /** The app to call, without a trailing slash. Default `resolveAppUrl()`. */ - baseUrl?: string; - /** Default the global `fetch`. */ - fetch?: typeof fetch; - headers?: Readonly>; - signal?: AbortSignal; - redirect?: RequestRedirect; -} - -/** - * The request for one operation: its path parameters, query and body, each - * typed from the contract, plus the transport. - */ -export type ApiRequestInit = ApiTransport & - PathInit & - QueryInit & - JsonInit & - FormInit; - -type InitArgs = - object extends ApiRequestInit - ? [init?: ApiRequestInit] - : [init: ApiRequestInit]; - -type ResponsesOf = - OperationOf extends { responses: infer R } ? R : never; - -/** The parsed JSON body of an operation's response with status `S`. */ -export type ResponseJson< - K extends OperationKey, - S extends keyof ResponsesOf, -> = ResponsesOf[S] extends { content: { 'application/json': infer J } } - ? J - : never; - -/** The untyped request shape every typed {@link ApiRequestInit} fits. */ -interface RequestShape extends ApiTransport { - path?: Readonly>; - query?: Readonly>; - body?: unknown; - form?: Readonly>; -} - -const DEPLOY_TOKEN_MISSING = - 'No authentication token available. Set WALKEROS_DEPLOY_TOKEN or run walkeros auth login.'; - -/** - * The operation's path with each path parameter encoded once, plus the - * serialized query. Query entries keep their order; `undefined` and `null` - * values are left out, an array value repeats its key. - */ -function operationTarget( - op: string, - path: RequestShape['path'], - query: RequestShape['query'], -): string { - const template = parseOperation(op).path; - const filled = template.replace(/\{([^}]+)\}/g, (_match, name: string) => { - const value = path?.[name]; - if (value === undefined) { - throw new Error(`${op}: path parameter ${name} is missing`); - } - return encodeURIComponent(String(value)); - }); - const search = new URLSearchParams(); - for (const [key, value] of Object.entries(query ?? {})) { - if (value === undefined || value === null) continue; - for (const item of Array.isArray(value) ? value : [value]) { - search.append(key, String(item)); - } - } - const serialized = search.toString(); - return serialized ? `${filled}?${serialized}` : filled; -} - -function hasHeader(headers: Record, name: string): boolean { - const lower = name.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === lower); -} - -/** Later layers win; a name replaces the same name in any letter case. */ -function mergeHeaders( - ...layers: ReadonlyArray>> -): Record { - const merged: Record = {}; - for (const layer of layers) { - for (const [name, value] of Object.entries(layer)) { - const lower = name.toLowerCase(); - for (const existing of Object.keys(merged)) { - if (existing.toLowerCase() === lower) delete merged[existing]; - } - merged[name] = value; - } - } - return merged; -} - -function formBody(form: Readonly>): string { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(form)) { - if (value !== undefined && value !== null) - params.append(key, String(value)); - } - return params.toString(); -} - -/** Method, caller headers and serialized body, without any credential. */ -function requestParts( - op: string, - init: RequestShape, -): { method: string; headers: Record; body?: string } { - const method = parseOperation(op).method.toUpperCase(); - const headers: Record = { ...init.headers }; - if (init.form !== undefined) { - if (!hasHeader(headers, 'Content-Type')) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - return { method, headers, body: formBody(init.form) }; - } - if (init.body !== undefined) { - if (!hasHeader(headers, 'Content-Type')) { - headers['Content-Type'] = 'application/json'; - } - return { method, headers, body: JSON.stringify(init.body) }; - } - return { method, headers }; -} - -/** - * Only what the request sets: a GET carries no `method`, a request without - * headers no `headers`. - */ -function requestInit( - parts: ReturnType, - init: RequestShape, - headers: Record, -): RequestInit { - const request: RequestInit = {}; - if (parts.method !== 'GET') request.method = parts.method; - if (Object.keys(headers).length > 0) request.headers = headers; - if (parts.body !== undefined) request.body = parts.body; - if (init.signal) request.signal = init.signal; - if (init.redirect) request.redirect = init.redirect; - return request; -} - -async function credential(auth: ApiAuth): Promise { - if (auth === 'none') return null; - if (auth === 'user') return resolveAccessToken(); - if (auth === 'deploy') { - const token = resolveDeployToken() ?? (await resolveAccessToken()); - if (!token) throw new Error(DEPLOY_TOKEN_MISSING); - return token; - } - return auth.token; -} - -/** - * Call one app operation. `op` names it as `" "`; `path`, - * `query` and `body` (or `form`) are typed from that operation, and the - * result is the raw `Response`, whose parsed body {@link ResponseJson} types. - * - * Every request carries the client-context headers. Without `baseUrl` and - * `fetch`, a `'user'`, `'deploy'` or `'none'` request goes through - * `apiFetch`, `deployFetch` or `publicFetch`. - */ -export function apiRequest( - op: K, - ...init: InitArgs -): Promise; -export async function apiRequest( - op: OperationKey, - init: RequestShape = {}, -): Promise { - const target = operationTarget(op, init.path, init.query); - const parts = requestParts(op, init); - const auth = init.auth ?? 'user'; - - if ( - init.baseUrl === undefined && - init.fetch === undefined && - typeof auth === 'string' - ) { - const request = requestInit(parts, init, parts.headers); - const send = - auth === 'user' - ? apiFetch - : auth === 'deploy' - ? deployFetch - : publicFetch; - return Object.keys(request).length > 0 - ? send(target, request) - : send(target); - } - - const url = `${init.baseUrl ?? resolveAppUrl()}${target}`; - const token = await credential(auth); - if (token) requireSecureUrl(url); - const headers = mergeHeaders( - clientContextHeaders(), - parts.headers, - token ? { Authorization: `Bearer ${token}` } : {}, - ); - const send = init.fetch ?? fetch; - return send(url, requestInit(parts, init, headers)); -} - -/** - * The absolute URL of one operation, for a caller that hands the URL to its - * own transport (the telemetry destination). - */ -export function operationUrl( - op: K, - init: { baseUrl: string } & PathInit & QueryInit, -): string; -export function operationUrl( - op: OperationKey, - init: Pick & { baseUrl: string }, -): string { - return `${init.baseUrl}${operationTarget(op, init.path, init.query)}`; -} diff --git a/packages/cli/src/core/app-fetch.ts b/packages/cli/src/core/app-fetch.ts new file mode 100644 index 000000000..dade98e3e --- /dev/null +++ b/packages/cli/src/core/app-fetch.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { resolveAppUrl } from '../lib/config-file.js'; +import { requireSecureUrl } from '../lib/secure-url.js'; +import { clientContextHeaders } from './client-context.js'; + +export interface AppFetchInit { + method?: string; + /** The app, default `resolveAppUrl()`; trailing slashes are trimmed. */ + baseUrl?: string; + /** Sent as `Authorization: Bearer ` unless the caller set Authorization. */ + token?: string | null; + headers?: Readonly>; + body?: string; + signal?: AbortSignal; + redirect?: RequestRedirect; + fetch?: typeof fetch; +} + +const AppErrorSchema = z.object({ + error: z.object({ message: z.string().min(1) }), +}); + +/** The message of the app's error envelope `{ error: { message } }`, or undefined. */ +export function appErrorMessage(body: unknown): string | undefined { + const parsed = AppErrorSchema.safeParse(body); + return parsed.success ? parsed.data.error.message : undefined; +} + +/** + * Call the walkerOS app. Every request carries the client-context headers + * (`User-Agent`, `X-WalkerOS-Client`, `X-WalkerOS-Client-Version`); caller + * headers win over them; a request carrying Authorization goes only to a + * secure URL (`requireSecureUrl`). `path` starts with `/` and may carry a query. + */ +export async function appFetch( + path: string, + init: AppFetchInit = {}, +): Promise { + const url = `${(init.baseUrl ?? resolveAppUrl()).replace(/\/+$/, '')}${path}`; + const headers = new Headers(clientContextHeaders()); + for (const [name, value] of Object.entries(init.headers ?? {})) + headers.set(name, value); + if (init.token && !headers.has('Authorization')) + headers.set('Authorization', `Bearer ${init.token}`); + if (headers.has('Authorization')) requireSecureUrl(url); + const request: RequestInit = { headers }; + if (init.method && init.method !== 'GET') request.method = init.method; + if (init.body !== undefined) request.body = init.body; + if (init.signal) request.signal = init.signal; + if (init.redirect) request.redirect = init.redirect; + return (init.fetch ?? fetch)(url, request); +} diff --git a/packages/cli/src/core/app-paths.ts b/packages/cli/src/core/app-paths.ts new file mode 100644 index 000000000..6ecba27a3 --- /dev/null +++ b/packages/cli/src/core/app-paths.ts @@ -0,0 +1,15 @@ +/** + * The app operations the CLI itself calls: the OAuth device flow, identity, + * health, telemetry and the public API document. The app keeps every one in + * its public API set, so its change rule protects them. The only file in the + * CLI that names an app path; `walkeros api` takes its path from the person. + */ +export const APP_PATHS = { + deviceAuthorization: '/api/oauth/device_authorization', + token: '/api/oauth/token', + revoke: '/api/oauth/revoke', + whoami: '/api/auth/whoami', + health: '/api/health', + telemetry: '/api/telemetry', + openapi: '/api/openapi.json', +} as const; diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index fb4219e51..b821178f9 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -3,7 +3,6 @@ import { writeConfig, clearAuthFields, resolveAppUrl, - resolveProjectId, hasStoredCredential, type WalkerOSConfig, } from '../lib/config-file.js'; @@ -151,12 +150,3 @@ export function credentialSource(): 'env' | 'config' | null { if (hasStoredCredential(readConfig())) return 'config'; return null; } - -export function requireProjectId(): string { - const projectId = resolveProjectId(); - if (!projectId) - throw new Error( - 'No project selected. Set WALKEROS_PROJECT_ID or configure a default project.', - ); - return projectId; -} diff --git a/packages/cli/src/core/contract.ts b/packages/cli/src/core/contract.ts deleted file mode 100644 index 6880af831..000000000 --- a/packages/cli/src/core/contract.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { createHash } from 'node:crypto'; -import semver from 'semver'; -import { resolveAppUrl } from '../lib/config-file.js'; -import { VERSION } from '../version.js'; -import { apiRequest, operationUrl } from './api-request.js'; -import { getClientContext, type ClientContext } from './client-context.js'; -import { canonicalize, operationDigests } from './openapi-subset.js'; - -/** - * One app operation this client calls: `" "`, the commands and - * tools that call it (from `openapi/client-operations.json`) and the wire - * digest of its shape in the client's `openapi/spec.json`. - */ -export interface ClientOperation { - op: string; - usedBy: string[]; - digest: string; -} - -// Build-time defines, injected by tsup (see tsup.config.ts). In source/test -// (un-bundled) contexts these are absent, so the runtime falls back to a -// placeholder via `typeof` guards in the exported constants below. -declare const __CONTRACT_VERSION__: string; -declare const __CONTRACT_HASH__: string; -declare const __CLIENT_OPERATION_DIGESTS__: readonly ClientOperation[]; - -const PLACEHOLDER = '0.0.0-unbundled'; - -/** - * The contract label this client was built against (`+`), - * baked from the bundled `openapi/spec.json` `info.version` at build time. - */ -export const bakedContractVersion: string = - typeof __CONTRACT_VERSION__ === 'string' ? __CONTRACT_VERSION__ : PLACEHOLDER; - -/** - * Canonical content hash of the bundled client subset (`openapi/spec.json`), - * baked at build time with {@link canonicalContractHash}. - */ -export const bakedContractHash: string = - typeof __CONTRACT_HASH__ === 'string' ? __CONTRACT_HASH__ : ''; - -/** - * Every app operation this client calls, with its usedBy and the wire digest - * of the bundled `openapi/spec.json`, baked at build time. - */ -export const bakedClientOperations: readonly ClientOperation[] = - typeof __CLIENT_OPERATION_DIGESTS__ === 'object' - ? __CLIENT_OPERATION_DIGESTS__ - : []; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * Strip `info.version` so a version-only bump does not change the content hash. - * Mirrors the app's `stripInfoVersion`. - */ -function stripInfoVersion(doc: unknown): unknown { - if (!isRecord(doc) || !isRecord(doc.info)) { - return doc; - } - const restInfo: Record = {}; - for (const [key, value] of Object.entries(doc.info)) { - if (key !== 'version') restInfo[key] = value; - } - return { ...doc, info: restInfo }; -} - -/** - * Deterministic sha256 hex of an OpenAPI document's content. - * - * Parity contract: identical to the app's `computeContractHash` - * (app/src/lib/api/contract-version.ts): sha256 of - * `JSON.stringify(canonicalize(stripInfoVersion(doc)))`, so the same document - * hashes the same on both sides. - */ -export function canonicalContractHash(doc: unknown): string { - const canonical = canonicalize(stripInfoVersion(doc)); - return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); -} - -export interface HealthResult { - reachable: boolean; - status?: string; - appVersion?: string; - contractVersion?: string; - contractHash?: string; - /** The lowest client version the server accepts. */ - minSupportedClient?: string; - /** The HTTP status, when the app answered with a non-2xx one. */ - httpStatus?: number; - /** Why the probe failed, when it did not reach the app. */ - error?: string; -} - -const HEALTH_TIMEOUT_MS = 5000; -const OPENAPI_TIMEOUT_MS = 10000; - -function trimTrailingSlashes(url: string): string { - return url.replace(/\/+$/, ''); -} - -/** - * Why a request did not reach the app: `timeout after ms`, or the - * network error with its code, such as `fetch failed (ECONNREFUSED)`. - */ -function failureReason(error: unknown, timeoutMs: number): string { - // Read by shape, not `instanceof`: an abort's DOMException can come from - // another realm. - if (!isRecord(error)) return String(error); - if (error.name === 'TimeoutError') return `timeout after ${timeoutMs} ms`; - const message = - typeof error.message === 'string' ? error.message : String(error); - const cause = error.cause; - const code = - isRecord(cause) && typeof cause.code === 'string' ? cause.code : undefined; - return code ? `${message} (${code})` : message; -} - -/** - * Tokenless reachability + contract probe of the app's PUBLIC health route - * (`GET /api/health`). Sends no credential (never `createApiClient`, whose - * every request rejects without one) and defensively parses the JSON body. - * Resolves `{ reachable: false, error }` only on a real network/timeout - * failure; a non-2xx status still counts as reachable. - * - * `baseUrl` names the app to probe, without a trailing slash. Omitted, it - * falls back to `resolveAppUrl()`, the local machine's chain - * (`WALKEROS_APP_URL`, then the CLI config file, then the built-in default), - * which is what every `walkeros` binary invocation wants. A caller that is - * NOT the local CLI has to pass its own: an in-process host has no CLI config - * to read, so the fallback would silently probe a different backend than the - * one that caller talks to. - */ -export async function fetchHealth(baseUrl?: string): Promise { - try { - const res = await apiRequest('GET /api/health', { - auth: 'none', - signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), - ...(baseUrl === undefined ? {} : { baseUrl }), - }); - const body: unknown = await res.json().catch(() => undefined); - const result: HealthResult = { reachable: true }; - if (!res.ok) result.httpStatus = res.status; - if (isRecord(body)) { - if (typeof body.status === 'string') result.status = body.status; - if (typeof body.appVersion === 'string') - result.appVersion = body.appVersion; - if (typeof body.contractVersion === 'string') - result.contractVersion = body.contractVersion; - if (typeof body.contractHash === 'string') - result.contractHash = body.contractHash; - if (typeof body.minSupportedClient === 'string') - result.minSupportedClient = body.minSupportedClient; - } - return result; - } catch (error) { - return { - reachable: false, - error: failureReason(error, HEALTH_TIMEOUT_MS), - }; - } -} - -/** The live OpenAPI document of an app, or why it could not be read. */ -export type OpenApiResult = - | { ok: true; url: string; document: { [key: string]: unknown } } - | { ok: false; url: string; error: string }; - -/** - * The app's PUBLIC OpenAPI document (`GET /api/openapi.json`), without a - * credential. `error` is `HTTP `, `response is not JSON`, `no paths`, - * a timeout or the network error. `baseUrl` defaults like - * {@link fetchHealth}. - */ -export async function fetchOpenApi(baseUrl?: string): Promise { - const base = baseUrl ?? trimTrailingSlashes(resolveAppUrl()); - const url = operationUrl('GET /api/openapi.json', { baseUrl: base }); - let res: Response; - try { - res = await apiRequest('GET /api/openapi.json', { - auth: 'none', - baseUrl: base, - signal: AbortSignal.timeout(OPENAPI_TIMEOUT_MS), - }); - } catch (error) { - return { ok: false, url, error: failureReason(error, OPENAPI_TIMEOUT_MS) }; - } - if (!res.ok) return { ok: false, url, error: `HTTP ${res.status}` }; - const document: unknown = await res.json().catch(() => undefined); - if (!isRecord(document)) { - return { ok: false, url, error: 'response is not JSON' }; - } - if (!isRecord(document.paths)) return { ok: false, url, error: 'no paths' }; - return { ok: true, url, document }; -} - -/** - * The verdict on this client against the live app, per operation: - * - `in-sync`: every client operation has the client's wire shape. - * - `changed`: some client operations differ from the server's and the - * server's label names a floor (`+`) not below the client's. - * When the server's floor is higher, the server is the newer deploy: - * upgrade the client. With the same floor a hash has no order, so the - * server may be a newer or an older deploy. - * - `server-older`: the server lacks client operations, or changed some and - * its label names a lower floor or none (a label without build metadata - * predates floors). - * - `client-outdated`: the server's `minSupportedClient` is above this - * client's version. - * - `unknown`: the health route or the OpenAPI document could not be read, or - * the client has no operations to compare. - * - `in-process`: a door served inside the app it reports; never computed by - * {@link compareContract}. - */ -export type ContractVerdict = - | 'in-sync' - | 'changed' - | 'server-older' - | 'client-outdated' - | 'unknown' - | 'in-process'; - -/** The client a verdict is about. */ -export interface ContractClient { - /** `@walkeros/cli`, `@walkeros/mcp` or `@walkeros/runner`. */ - package: string; - version: string; - /** The contract label the client was built against. */ - contract: string; -} - -/** A client operation the server does not offer, with its callers. */ -export interface MissingOperation { - op: string; - usedBy: string[]; -} - -export interface ContractComparison { - verdict: ContractVerdict; - /** The app compared against, without a trailing slash. */ - appUrl: string; - client: ContractClient; - /** The server's contract label, when it names one. */ - server?: string; - /** The lowest client version the server accepts, when it names one. */ - minSupportedClient?: string; - /** How many client operations were compared. */ - operations?: number; - missing?: MissingOperation[]; - /** Client operations whose wire shape differs from the client's. */ - changed?: string[]; - /** `client-outdated`: the package and the lowest version the server accepts. */ - requires?: { package: string; version: string }; - /** `unknown`: what could not be read. */ - reason?: string; -} - -export interface CompareContractInput { - /** - * The app to compare against, without a trailing slash. Omitted, the local - * machine's app URL; see {@link fetchHealth}. - */ - baseUrl?: string; - /** The client asking. Default: the process client context, else this CLI. */ - client?: ClientContext; - /** The client's contract label. Default {@link bakedContractVersion}. */ - bakedVersion?: string; - /** The client's operations. Default {@link bakedClientOperations}. */ - operations?: readonly ClientOperation[]; -} - -function isBelow(version: string, floor: string): boolean { - return ( - semver.valid(version) !== null && - semver.valid(floor) !== null && - semver.lt(version, floor) - ); -} - -/** - * Whether a server label orders below the client's. Only a `+` - * label names a floor; a label without build metadata, or none at all, comes - * from a server older than floors, so it counts as below. - */ -function serverFloorBelow(server: string | undefined, client: string): boolean { - if (server === undefined || !server.includes('+')) return true; - if (semver.valid(server) === null) return true; - return isBelow(server, client); -} - -function documentLabel(document: { - [key: string]: unknown; -}): string | undefined { - const info = document.info; - return isRecord(info) && typeof info.version === 'string' - ? info.version - : undefined; -} - -/** - * Compare this client with the live app, operation by operation. First match - * wins: - * 1. the health route fails: `unknown`; - * 2. health names a `minSupportedClient` above the client's version: - * `client-outdated`; - * 3. the client has no operations, or the OpenAPI document fails or has no - * paths: `unknown`; - * 4. the live document, pruned to the client's operations, gives `missing` - * (operations it lacks) and `changed` (digests that differ); - * 5. `missing` not empty: `server-older`; - * 6. `changed` not empty: `server-older` when the server label names a floor - * below the client's or no floor at all, else `changed`; - * 7. `in-sync`. Operations no client calls and the stripped documentation - * keywords never count. - * - * Sends no credential. Never throws. - */ -export async function compareContract( - input: CompareContractInput = {}, -): Promise { - const appUrl = trimTrailingSlashes(input.baseUrl ?? resolveAppUrl()); - const context = input.client ?? - getClientContext() ?? { type: 'cli', version: VERSION }; - const client: ContractClient = { - package: `@walkeros/${context.type}`, - version: context.version, - contract: input.bakedVersion ?? bakedContractVersion, - }; - const operations = input.operations ?? bakedClientOperations; - - const health = await fetchHealth(appUrl); - if (!health.reachable) { - const url = operationUrl('GET /api/health', { baseUrl: appUrl }); - return { - verdict: 'unknown', - appUrl, - client, - reason: `GET ${url}: ${health.error ?? 'no response'}`, - }; - } - const known: ContractComparison = { - verdict: 'unknown', - appUrl, - client, - ...(health.contractVersion !== undefined && { - server: health.contractVersion, - }), - ...(health.minSupportedClient !== undefined && { - minSupportedClient: health.minSupportedClient, - }), - }; - if ( - health.minSupportedClient !== undefined && - isBelow(client.version, health.minSupportedClient) - ) { - return { - ...known, - verdict: 'client-outdated', - requires: { package: client.package, version: health.minSupportedClient }, - }; - } - - if (operations.length === 0) { - return { ...known, reason: 'no baked client operations' }; - } - - const live = await fetchOpenApi(appUrl); - if (!live.ok) { - return { ...known, reason: `GET ${live.url}: ${live.error}` }; - } - let liveDigests: Record; - try { - liveDigests = operationDigests( - live.document, - operations.map(({ op }) => op), - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { ...known, reason: `GET ${live.url}: ${message}` }; - } - - const missing: MissingOperation[] = operations - .filter(({ op }) => !Object.hasOwn(liveDigests, op)) - .map(({ op, usedBy }) => ({ op, usedBy })); - const changed = operations - .filter(({ op, digest }) => { - return Object.hasOwn(liveDigests, op) && liveDigests[op] !== digest; - }) - .map(({ op }) => op); - const server = known.server ?? documentLabel(live.document); - const compared: ContractComparison = { - ...known, - ...(server !== undefined && { server }), - operations: operations.length, - missing, - changed, - }; - - if (missing.length > 0) return { ...compared, verdict: 'server-older' }; - if (changed.length > 0) { - const older = serverFloorBelow(server, client.contract); - return { ...compared, verdict: older ? 'server-older' : 'changed' }; - } - return { ...compared, verdict: 'in-sync' }; -} - -/** `op (caller, caller)` for each missing operation. */ -function missingList(missing: readonly MissingOperation[]): string { - return missing - .map(({ op, usedBy }) => - usedBy.length > 0 ? `${op} (${usedBy.join(', ')})` : op, - ) - .join(', '); -} - -/** - * One line stating a {@link ContractComparison}, for a terminal or a log: - * `contract: ()`, plus what differs. - */ -export function formatContract(c: ContractComparison): string { - const server = c.server ?? 'without a label'; - const labels = `server ${server}, client ${c.client.contract}`; - const missing = c.missing ?? []; - const changed = c.changed ?? []; - switch (c.verdict) { - case 'in-sync': - return `contract: in-sync (${labels}, ${c.operations ?? 0} operations)`; - case 'changed': - return `contract: changed (${labels}); changed: ${changed.join(', ')}`; - case 'server-older': { - const parts = [`contract: server-older (${labels})`]; - if (missing.length > 0) { - parts.push(`not offered (${missing.length}): ${missingList(missing)}`); - } - if (changed.length > 0) parts.push(`changed: ${changed.length}`); - return parts.join('; '); - } - case 'client-outdated': { - const pkg = c.requires?.package ?? c.client.package; - const floor = c.requires?.version ?? c.minSupportedClient; - const requires = - floor === undefined ? '' : ` requires ${pkg} >= ${floor}`; - return `contract: client-outdated (${c.appUrl}${requires}; this is ${c.client.version})`; - } - case 'unknown': - return `contract: unknown (${c.reason ?? 'no reason given'})`; - case 'in-process': - return `contract: in-process (server ${server})`; - } -} diff --git a/packages/cli/src/core/fetch-failure.ts b/packages/cli/src/core/fetch-failure.ts new file mode 100644 index 000000000..5aa27b239 --- /dev/null +++ b/packages/cli/src/core/fetch-failure.ts @@ -0,0 +1,22 @@ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Why a request did not reach its host: `timeout after ms`, or the + * network error with its code, such as `fetch failed (ECONNREFUSED)`. Read by + * shape, not instanceof: an abort's DOMException can come from another realm. + */ +export function fetchFailureReason(error: unknown, timeoutMs?: number): string { + if (!isRecord(error)) return String(error); + if (error.name === 'TimeoutError') + return timeoutMs === undefined + ? 'timeout' + : `timeout after ${timeoutMs} ms`; + const message = + typeof error.message === 'string' ? error.message : String(error); + const cause = error.cause; + const code = + isRecord(cause) && typeof cause.code === 'string' ? cause.code : undefined; + return code ? `${message} (${code})` : message; +} diff --git a/packages/cli/src/core/http.ts b/packages/cli/src/core/http.ts deleted file mode 100644 index 6fca1eb4c..000000000 --- a/packages/cli/src/core/http.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { resolveAppUrl, resolveDeployToken } from '../lib/config-file.js'; -import { requireSecureUrl } from '../lib/secure-url.js'; -import { resolveAccessToken } from './auth.js'; -import { clientContextHeaders } from './client-context.js'; - -/** - * Normalize headers from any RequestInit format to a plain object. - */ -function normalizeHeaders(headers?: HeadersInit): Record { - if (!headers) return {}; - if (headers instanceof Headers) return Object.fromEntries(headers.entries()); - if (Array.isArray(headers)) return Object.fromEntries(headers); - return headers as Record; -} - -/** - * Merge a bearer token into a headers object. - * Shared by apiFetch, deployFetch, and runtime callers that manage their own tokens. - */ -export function mergeAuthHeaders( - token: string | null | undefined, - headers?: HeadersInit, -): Record { - const normalized = normalizeHeaders(headers); - if (token) normalized.Authorization = `Bearer ${token}`; - return normalized; -} - -/** - * Build the final outbound header set by merging client-context headers - * (User-Agent, X-WalkerOS-Client, X-WalkerOS-Client-Version) with auth. - * Caller-provided headers win over client-context defaults. - */ -function buildHeaders( - token: string | null | undefined, - headers?: HeadersInit, -): Record { - return { - ...clientContextHeaders(), - ...mergeAuthHeaders(token, headers), - }; -} - -/** - * Authenticated fetch — resolves base URL + adds auth token. - * Use for all API calls that require WALKEROS_TOKEN. - * - * The transport is checked only once a credential is actually going out. - * `resolveAppUrl` keeps answering with whatever is configured, which is what - * the paths that merely REPORT the target (diagnostics, health, telemetry) - * need from it. - */ -export async function apiFetch( - path: string, - init?: RequestInit, -): Promise { - const baseUrl = resolveAppUrl(); - const token = await resolveAccessToken(); - if (token) requireSecureUrl(baseUrl); - return fetch(`${baseUrl}${path}`, { - ...init, - headers: buildHeaders(token, init?.headers), - }); -} - -/** - * Unauthenticated fetch — resolves base URL, no auth. - * Use for public endpoints (login device flow, feedback). - */ -export async function publicFetch( - path: string, - init?: RequestInit, -): Promise { - const baseUrl = resolveAppUrl(); - return fetch(`${baseUrl}${path}`, { - ...init, - headers: { - ...clientContextHeaders(), - ...normalizeHeaders(init?.headers), - }, - }); -} - -/** - * Deploy-authenticated fetch — uses deploy token with fallback to user token. - * Use for runtime operations (heartbeat, config polling, secrets). - */ -export async function deployFetch( - path: string, - init?: RequestInit, -): Promise { - const baseUrl = resolveAppUrl(); - const token = resolveDeployToken() ?? (await resolveAccessToken()); - if (!token) - throw new Error( - 'No authentication token available. Set WALKEROS_DEPLOY_TOKEN or run walkeros auth login.', - ); - requireSecureUrl(baseUrl); - return fetch(`${baseUrl}${path}`, { - ...init, - headers: buildHeaders(token, init?.headers), - }); -} diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index e41291596..9815622a0 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -12,14 +12,6 @@ export { getAuthHeaders, credentialSource, } from './auth.js'; -export { - apiFetch, - publicFetch, - deployFetch, - mergeAuthHeaders, -} from './http.js'; -export { parseSSEEvents } from './sse.js'; -export type { SSEEvent, SSEParseResult } from './sse.js'; export * from './event-validation.js'; export * from './package-path.js'; export * from './import-specifier.js'; diff --git a/packages/cli/src/core/oauth-client.ts b/packages/cli/src/core/oauth-client.ts index ff8af7b0e..95cd6ce79 100644 --- a/packages/cli/src/core/oauth-client.ts +++ b/packages/cli/src/core/oauth-client.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { requireSecureUrl } from '../lib/secure-url.js'; -import { apiRequest } from './api-request.js'; -import type { ApiTransport } from './api-request.js'; +import { appErrorMessage, appFetch } from './app-fetch.js'; +import { APP_PATHS } from './app-paths.js'; /** * OAuth 2.1 client for the walkerOS authorization server. @@ -104,6 +104,10 @@ function errorCode(body: unknown): string | null { return parsed.success ? parsed.data.error : null; } +/** + * The OAuth error first, then the app's own error envelope (a version floor + * answers with one), then the bare status. + */ function describe(response: Response, body: unknown): string { const parsed = ErrorSchema.safeParse(body); if (parsed.success) { @@ -111,7 +115,7 @@ function describe(response: Response, body: unknown): string { ? `${parsed.data.error}: ${parsed.data.error_description}` : parsed.data.error; } - return `HTTP ${response.status}`; + return appErrorMessage(body) ?? `HTTP ${response.status}`; } /** @@ -121,19 +125,25 @@ function describe(response: Response, body: unknown): string { * rather than a hop, because following one would hand the token (or the code * that buys it) to whichever host the answer named. */ -function credentialTransport( +function postForm( appUrl: string, + path: string, + form: Record, fetchFn: typeof fetch, signal?: AbortSignal, -): ApiTransport { - return { - auth: 'none', +): Promise { + return appFetch(path, { + method: 'POST', baseUrl: requireSecureUrl(appUrl), - fetch: fetchFn, - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(form).toString(), redirect: 'error', - ...(signal ? { signal } : {}), - }; + fetch: fetchFn, + signal, + }); } function toTokenSet(body: unknown): TokenSet { @@ -157,20 +167,19 @@ export async function startDeviceAuthorization( appUrl: string, fetchFn: typeof fetch = globalThis.fetch, ): Promise { - const response = await apiRequest('POST /api/oauth/device_authorization', { - ...credentialTransport( - appUrl, - fetchFn, - AbortSignal.timeout(DEVICE_AUTHORIZATION_TIMEOUT_MS), - ), - form: { + const response = await postForm( + appUrl, + APP_PATHS.deviceAuthorization, + { client_id: CLI_CLIENT_ID, scope: CLI_SCOPE, // RFC 8707. The token comes back bound to the API, so a leaked CLI token // cannot be replayed against the MCP resource. resource: `${appUrl}/api`, }, - }); + fetchFn, + AbortSignal.timeout(DEVICE_AUTHORIZATION_TIMEOUT_MS), + ); const body = await readJson(response); if (!response.ok) throw new Error(describe(response, body)); @@ -207,14 +216,17 @@ export async function pollDeviceToken( ): Promise { let response: Response; try { - response = await apiRequest('POST /api/oauth/token', { - ...credentialTransport(appUrl, fetchFn, signal), - form: { + response = await postForm( + appUrl, + APP_PATHS.token, + { grant_type: DEVICE_CODE_GRANT, device_code: deviceCode, client_id: CLI_CLIENT_ID, }, - }); + fetchFn, + signal, + ); } catch (error) { // Only an abort of the caller's own signal is an outcome rather than a // fault: the authorization is untouched, so it is still pending and the @@ -263,18 +275,17 @@ export async function refreshTokens( refreshToken: string, fetchFn: typeof fetch = globalThis.fetch, ): Promise { - const response = await apiRequest('POST /api/oauth/token', { - ...credentialTransport( - appUrl, - fetchFn, - AbortSignal.timeout(REFRESH_TIMEOUT_MS), - ), - form: { + const response = await postForm( + appUrl, + APP_PATHS.token, + { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLI_CLIENT_ID, }, - }); + fetchFn, + AbortSignal.timeout(REFRESH_TIMEOUT_MS), + ); const body = await readJson(response); @@ -297,18 +308,17 @@ export async function revokeRefreshToken( fetchFn: typeof fetch = globalThis.fetch, ): Promise { try { - await apiRequest('POST /api/oauth/revoke', { - ...credentialTransport( - appUrl, - fetchFn, - AbortSignal.timeout(REVOKE_TIMEOUT_MS), - ), - form: { + await postForm( + appUrl, + APP_PATHS.revoke, + { token: refreshToken, token_type_hint: 'refresh_token', client_id: CLI_CLIENT_ID, }, - }); + fetchFn, + AbortSignal.timeout(REVOKE_TIMEOUT_MS), + ); } catch { // Offline, or the server is down. The local credential is still cleared. } diff --git a/packages/cli/src/core/openapi-subset.ts b/packages/cli/src/core/openapi-subset.ts deleted file mode 100644 index 890e8ed29..000000000 --- a/packages/cli/src/core/openapi-subset.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { createHash } from 'node:crypto'; - -/** - * The client subset of the app's OpenAPI document: the operations a walkerOS - * client calls (listed in `openapi/client-operations.json`), the components - * they reference, and nothing that only documents them. - * - * Imports only `node:crypto` and uses erasable TypeScript only, so plain - * `node` runs this file without a build (`scripts/generate-client-spec.mjs` - * and the release checks import it directly). - */ - -type JsonObject = { [key: string]: unknown }; - -const HTTP_METHODS: ReadonlySet = new Set([ - 'get', - 'put', - 'post', - 'delete', - 'options', - 'head', - 'patch', - 'trace', -]); - -/** - * Keywords that only document. Stripped wherever they are keywords; `tags` - * groups operations for documentation and never reaches the wire. - */ -const DOC_KEYWORDS: ReadonlySet = new Set([ - 'description', - 'summary', - 'title', - 'example', - 'examples', - 'externalDocs', - 'tags', -]); - -/** Keywords whose value is data, never a document node: kept verbatim. */ -const VERBATIM_KEYWORDS: ReadonlySet = new Set([ - 'enum', - 'const', - 'default', - 'required', - 'security', - 'mapping', - 'scopes', -]); - -/** - * Keywords whose value maps NAMES to nodes: a property, a status code, a - * media type, a component. The names are never keywords, so a property named - * `description` survives. - */ -const NAME_MAPS: ReadonlySet = new Set([ - 'properties', - 'patternProperties', - 'dependentSchemas', - '$defs', - 'definitions', - 'paths', - 'webhooks', - 'schemas', - 'responses', - 'parameters', - 'requestBodies', - 'headers', - 'securitySchemes', - 'links', - 'callbacks', - 'pathItems', - 'content', - 'encoding', - 'variables', -]); - -/** Top-level keys a client subset does not carry. */ -const DROPPED_TOP_LEVEL: ReadonlySet = new Set([ - 'tags', - 'webhooks', - 'externalDocs', -]); - -const NOTHING_KEPT: ReadonlySet = new Set(); - -function isRecord(value: unknown): value is JsonObject { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function isVerbatim(key: string): boolean { - return VERBATIM_KEYWORDS.has(key) || key.startsWith('x-'); -} - -/** - * Recursively order object keys so equal documents serialize byte-identically - * regardless of key order. Arrays keep their order. Identical to the app's - * `canonicalize` (app/src/lib/api/contract-version.ts). - */ -export function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalize); - if (isRecord(value)) { - const result: JsonObject = {}; - for (const key of Object.keys(value).sort()) { - result[key] = canonicalize(value[key]); - } - return result; - } - return value; -} - -/** `" "` split into the lowercase method key and the path. */ -export function parseOperation(op: string): { method: string; path: string } { - const space = op.indexOf(' '); - const method = space > 0 ? op.slice(0, space).toLowerCase() : ''; - const path = op.slice(space + 1); - if (!HTTP_METHODS.has(method) || !path.startsWith('/')) { - throw new Error(`invalid operation: ${op}`); - } - return { method, path }; -} - -/** One entry of `client-operations.json`. */ -export interface ManifestEntry { - /** `" "`. */ - op: string; - /** The commands, tools and exports that call it. */ - usedBy: string[]; -} - -function isStringList(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every((item) => typeof item === 'string') - ); -} - -/** - * The entries of a parsed `client-operations.json`: a list of - * `{ op, usedBy }`. Throws on any other shape. - */ -export function manifestEntries(manifest: unknown): ManifestEntry[] { - if (!Array.isArray(manifest)) { - throw new Error('client operations manifest is not a list'); - } - return manifest.map((entry) => { - if ( - !isRecord(entry) || - typeof entry.op !== 'string' || - !isStringList(entry.usedBy) - ) { - throw new Error( - `client operations manifest entry is not { op, usedBy }: ${JSON.stringify(entry)}`, - ); - } - parseOperation(entry.op); - return { op: entry.op, usedBy: entry.usedBy }; - }); -} - -/** The `op` of every entry of a parsed `client-operations.json`. */ -export function manifestOperations(manifest: unknown): string[] { - return manifestEntries(manifest).map((entry) => entry.op); -} - -/** Every `" "` a document declares, in document order. */ -export function documentOperations(doc: unknown): string[] { - const paths = isRecord(doc) && isRecord(doc.paths) ? doc.paths : {}; - const operations: string[] = []; - for (const [path, item] of Object.entries(paths)) { - if (!isRecord(item)) continue; - for (const key of Object.keys(item)) { - if (HTTP_METHODS.has(key)) - operations.push(`${key.toUpperCase()} ${path}`); - } - } - return operations; -} - -function hasOperation(doc: unknown, op: string): boolean { - const { method, path } = parseOperation(op); - if (!isRecord(doc) || !isRecord(doc.paths)) return false; - const item = doc.paths[path]; - return isRecord(item) && isRecord(item[method]); -} - -function stripNode(node: unknown): unknown { - if (Array.isArray(node)) return node.map(stripNode); - if (!isRecord(node)) return node; - return stripObject(node, NOTHING_KEPT); -} - -function stripObject(node: JsonObject, kept: ReadonlySet): JsonObject { - const out: JsonObject = {}; - for (const [key, value] of Object.entries(node)) { - if (kept.has(key) || isVerbatim(key)) out[key] = value; - else if (DOC_KEYWORDS.has(key)) continue; - else if (NAME_MAPS.has(key) && isRecord(value)) { - out[key] = stripNameMap(value, key === 'responses'); - } else out[key] = stripNode(value); - } - return out; -} - -function stripNameMap(map: JsonObject, responses: boolean): JsonObject { - const out: JsonObject = {}; - for (const [name, node] of Object.entries(map)) { - out[name] = responses ? stripResponse(node) : stripNode(node); - } - return out; -} - -/** OpenAPI requires a Response object's `description`; it stays, emptied. */ -function stripResponse(node: unknown): unknown { - if (!isRecord(node) || typeof node.$ref === 'string') return stripNode(node); - return { description: '', ...stripObject(node, NOTHING_KEPT) }; -} - -/** The whole document with doc keywords stripped; `info` keeps its title. */ -function stripDocument(doc: JsonObject): JsonObject { - const out: JsonObject = {}; - for (const [key, value] of Object.entries(doc)) { - if (DROPPED_TOP_LEVEL.has(key)) continue; - if (key === 'info' && isRecord(value)) { - out.info = stripObject(value, new Set(['title', 'version'])); - } else if (isVerbatim(key)) out[key] = value; - else if (NAME_MAPS.has(key) && isRecord(value)) { - out[key] = stripNameMap(value, false); - } else out[key] = stripNode(value); - } - return out; -} - -/** - * Every `$ref` and discriminator mapping target under a node. Walked by - * structure like the stripping: the keys of a name map are names, never - * keywords, so a property named `default` or `mapping` is a schema. - */ -function collectRefs(node: unknown, refs: Set): void { - if (Array.isArray(node)) { - for (const item of node) collectRefs(item, refs); - return; - } - if (!isRecord(node)) return; - for (const [key, value] of Object.entries(node)) { - if (key === '$ref' && typeof value === 'string') refs.add(value); - else if (NAME_MAPS.has(key) && isRecord(value)) { - for (const named of Object.values(value)) collectRefs(named, refs); - } else if (key === 'discriminator' && isRecord(value)) { - const mapping = isRecord(value.mapping) ? value.mapping : {}; - for (const target of Object.values(mapping)) { - if (typeof target === 'string') refs.add(target); - } - } else if (!isVerbatim(key)) collectRefs(value, refs); - } -} - -/** `#/components/
/` as its section and name. */ -function componentOf(ref: string): { section: string; name: string } { - const parts = ref.startsWith('#/') - ? ref - .slice(2) - .split('/') - .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')) - : []; - if (parts.length < 3 || parts[0] !== 'components') { - throw new Error(`unsupported $ref: ${ref}`); - } - return { section: parts[1], name: parts[2] }; -} - -function pruneComponents( - components: JsonObject, - roots: unknown[], -): JsonObject | undefined { - const referenced = new Map>(); - const pending: string[] = []; - const queue = (node: unknown): void => { - const refs = new Set(); - collectRefs(node, refs); - pending.push(...refs); - }; - for (const root of roots) queue(root); - if (isRecord(components.securitySchemes)) queue(components.securitySchemes); - - for (let ref = pending.pop(); ref !== undefined; ref = pending.pop()) { - const { section, name } = componentOf(ref); - const names = referenced.get(section) ?? new Set(); - if (names.has(name)) continue; - const entries = components[section]; - if (!isRecord(entries) || !(name in entries)) { - throw new Error(`$ref target not in input: ${ref}`); - } - names.add(name); - referenced.set(section, names); - queue(entries[name]); - } - - const out: JsonObject = {}; - for (const [section, entries] of Object.entries(components)) { - if (!isRecord(entries)) continue; - if (section === 'securitySchemes') { - out[section] = entries; - continue; - } - const names = referenced.get(section); - if (!names) continue; - const kept: JsonObject = {}; - for (const [name, node] of Object.entries(entries)) { - if (names.has(name)) kept[name] = node; - } - out[section] = kept; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * The client subset of an OpenAPI document: exactly `ops` (each with its - * path-level parameters), the components they reach through `$ref`, and the - * security schemes, with every documentation keyword stripped. A Response - * object keeps `description: ''`; `info` keeps `title` and `version`. The - * top-level `tags`, `webhooks` and `externalDocs` are dropped. - * - * Throws `manifest operation not in input: ` for an operation the - * document lacks. Pruning a pruned document changes nothing. - */ -export function pruneToOperations( - doc: unknown, - ops: readonly string[], -): JsonObject { - if (!isRecord(doc) || !isRecord(doc.paths)) { - throw new Error('input is not an OpenAPI document with paths'); - } - for (const op of ops) { - if (!hasOperation(doc, op)) { - throw new Error(`manifest operation not in input: ${op}`); - } - } - - const listed = new Set( - ops.map((op) => { - const { method, path } = parseOperation(op); - return `${method} ${path}`; - }), - ); - const stripped = stripDocument(doc); - const allPaths = isRecord(stripped.paths) ? stripped.paths : {}; - - const paths: JsonObject = {}; - for (const [path, item] of Object.entries(allPaths)) { - if (!isRecord(item)) continue; - const kept: JsonObject = {}; - let operations = 0; - for (const [key, value] of Object.entries(item)) { - if (!HTTP_METHODS.has(key)) kept[key] = value; - else if (listed.has(`${key} ${path}`)) { - kept[key] = value; - operations += 1; - } - } - if (operations > 0) paths[path] = kept; - } - - const out: JsonObject = {}; - const roots: unknown[] = []; - for (const [key, value] of Object.entries(stripped)) { - if (key === 'components') continue; - out[key] = key === 'paths' ? paths : value; - roots.push(out[key]); - } - if (isRecord(stripped.components)) { - const components = pruneComponents(stripped.components, roots); - if (components) out.components = components; - } - return out; -} - -/** The component a `$ref` names, inside a pruned document. */ -function resolveRef(doc: JsonObject, ref: string): unknown { - const { section, name } = componentOf(ref); - const components = isRecord(doc.components) ? doc.components : {}; - const entries = components[section]; - return isRecord(entries) ? entries[name] : undefined; -} - -/** - * A node with every `$ref` replaced by its target, except a cycle's. Walked - * by structure like {@link collectRefs}: the keys of a name map are names. - */ -function inlineRefs( - node: unknown, - doc: JsonObject, - stack: readonly string[], -): unknown { - if (Array.isArray(node)) { - return node.map((item) => inlineRefs(item, doc, stack)); - } - if (!isRecord(node)) return node; - const out: JsonObject = {}; - for (const [key, value] of Object.entries(node)) { - if (key === '$ref' && typeof value === 'string' && !stack.includes(value)) { - out[key] = inlineRefs(resolveRef(doc, value), doc, [...stack, value]); - } else if (key === '$ref') out[key] = value; - else if (NAME_MAPS.has(key) && isRecord(value)) { - const named: JsonObject = {}; - for (const [name, child] of Object.entries(value)) { - named[name] = inlineRefs(child, doc, stack); - } - out[key] = named; - } else if (isVerbatim(key)) out[key] = value; - else out[key] = inlineRefs(value, doc, stack); - } - return out; -} - -/** - * A wire digest per operation: the sha256 hex of the canonical operation - * subtree (its path-level parameters and the operation) of the client subset, - * with every `$ref` inlined. A `$ref` already on the current inlining path - * stays a `$ref`. Canonicalized like {@link canonicalize}, so documentation - * changes leave a digest equal and a wire change in a referenced schema - * changes the digest of exactly the operations that reach it. - * - * Operations the document lacks are absent from the result. - */ -export function operationDigests( - doc: unknown, - ops: readonly string[], -): Record { - const present = ops.filter((op) => hasOperation(doc, op)); - const pruned = pruneToOperations(doc, present); - const paths = isRecord(pruned.paths) ? pruned.paths : {}; - const digests: Record = {}; - for (const op of present) { - const { method, path } = parseOperation(op); - const item = paths[path]; - if (!isRecord(item)) continue; - const subtree = inlineRefs( - { pathParameters: item.parameters ?? [], operation: item[method] }, - pruned, - [], - ); - digests[op] = createHash('sha256') - .update(JSON.stringify(canonicalize(subtree))) - .digest('hex'); - } - return digests; -} - -/** A contract label's floor: its version without the build metadata. */ -interface Floor { - core: [number, number, number]; - prerelease: string[]; -} - -function parseFloor(label: string): Floor { - const match = - /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - label, - ); - if (!match) throw new Error(`invalid contract label: ${label}`); - return { - core: [Number(match[1]), Number(match[2]), Number(match[3])], - prerelease: match[4] ? match[4].split('.') : [], - }; -} - -/** Semver precedence of two floors: negative when `a` is the lower one. */ -function compareFloors(a: Floor, b: Floor): number { - for (let i = 0; i < 3; i += 1) { - if (a.core[i] !== b.core[i]) return a.core[i] - b.core[i]; - } - // A release ranks above any prerelease of the same version. - if (a.prerelease.length === 0 || b.prerelease.length === 0) { - return b.prerelease.length - a.prerelease.length; - } - const length = Math.min(a.prerelease.length, b.prerelease.length); - for (let i = 0; i < length; i += 1) { - const x = a.prerelease[i]; - const y = b.prerelease[i]; - if (x === y) continue; - const xNumeric = /^\d+$/.test(x); - const yNumeric = /^\d+$/.test(y); - if (xNumeric && yNumeric) return Number(x) - Number(y); - if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; - return x < y ? -1 : 1; - } - return a.prerelease.length - b.prerelease.length; -} - -/** - * Whether the contract label's floor dropped from `base` to `head`. A label - * is `+`, and the floor orders labels the way semver does, - * ignoring the build metadata. A base without build metadata is a - * release-line label that names no floor, so nothing is ordered against it. - * Throws `invalid contract label: