From e3b9600522634e3ec9c86e48a6ef7099274b8f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:11:55 +0200 Subject: [PATCH 01/10] chore(tooling): align TypeScript and Oxc conventions Use TypeScript 7.0.2 for CLI and black-box test typechecking, with an explicit compiler alias so OpenAPI generation retains the working TypeScript 6 JavaScript API. Align Oxc versions across the CLI and isolated starter consumer. Apply shared formatter defaults across existing source and update scaffold import assertions for single quotes. Keep runtime tests and coverage on Bun. Validation: full canonical verification with integration, 42 browser tests, generated artifact drift checks, and AST comparison of formatter-only source changes. --- cli/.oxfmtrc.json | 7 +- cli/AGENTS.md | 12 +- cli/bun.lock | 147 ++- cli/knip.json | 2 +- cli/lint/no-relative-import-paths.js | 6 +- cli/openapi/openapi.yaml | 1007 +++++++++-------- cli/package.json | 13 +- cli/scripts/check-data-app.ts | 19 +- cli/scripts/command-reference.test.ts | 103 +- cli/scripts/command-reference.ts | 267 +++-- cli/scripts/generate-command-reference.ts | 27 +- cli/scripts/generate-openapi-index.ts | 34 +- cli/scripts/generate-openapi-types.ts | 16 +- cli/scripts/generated-artifact.test.ts | 68 +- cli/scripts/generated-artifact.ts | 20 +- cli/scripts/npm-bundle.ts | 24 +- cli/scripts/package-data-app.ts | 17 +- cli/scripts/publish-release.ts | 192 ++-- cli/scripts/release-manifest.ts | 31 +- cli/scripts/release.test.ts | 823 ++++++++------ cli/scripts/release.ts | 236 ++-- cli/scripts/script-output.test.ts | 50 +- cli/scripts/smoke-data-app.ts | 203 ++-- cli/scripts/smoke-npm-bundle.ts | 54 +- cli/src/cli.ts | 158 +-- cli/src/commands/api/index.test.ts | 230 ++-- cli/src/commands/api/index.ts | 35 +- cli/src/commands/api/lib/body.ts | 90 +- cli/src/commands/api/lib/command.ts | 69 +- .../commands/api/lib/http-conformance.test.ts | 60 +- cli/src/commands/api/lib/http.test.ts | 396 ++++--- cli/src/commands/api/lib/http.ts | 61 +- cli/src/commands/api/lib/model.ts | 14 +- cli/src/commands/api/lib/render.ts | 24 +- cli/src/commands/api/lib/views.test.ts | 36 +- cli/src/commands/api/lib/views.ts | 52 +- cli/src/commands/api/routes.ts | 49 +- cli/src/commands/api/spec.ts | 20 +- cli/src/commands/app/build.ts | 30 +- cli/src/commands/app/create.test.ts | 569 ++++++---- cli/src/commands/app/create.ts | 129 ++- cli/src/commands/app/dev.ts | 44 +- cli/src/commands/app/index.test.ts | 184 +-- cli/src/commands/app/index.ts | 38 +- cli/src/commands/app/lib/dev-proxy.test.ts | 45 +- cli/src/commands/app/lib/dev-proxy.ts | 60 +- cli/src/commands/app/lib/distribution.test.ts | 107 +- cli/src/commands/app/lib/distribution.ts | 76 +- cli/src/commands/app/lib/package.test.ts | 131 ++- cli/src/commands/app/lib/package.ts | 137 ++- cli/src/commands/app/lib/payload.ts | 2 +- cli/src/commands/app/lib/remote.ts | 47 +- cli/src/commands/app/lib/run.ts | 88 +- cli/src/commands/app/lib/scope.test.ts | 30 +- cli/src/commands/app/lib/scope.ts | 52 +- cli/src/commands/app/lint.test.ts | 322 +++--- cli/src/commands/app/lint.ts | 148 ++- cli/src/commands/app/publish.ts | 59 +- cli/src/commands/app/remote.test.ts | 145 ++- cli/src/commands/app/update.ts | 72 +- cli/src/commands/app/upgrade.test.ts | 312 +++-- cli/src/commands/app/upgrade.ts | 133 ++- cli/src/commands/app/validate.ts | 40 +- cli/src/commands/append/index.test.ts | 44 +- cli/src/commands/append/index.ts | 40 +- cli/src/commands/append/lib/args.ts | 14 +- cli/src/commands/append/lib/data.test.ts | 20 +- cli/src/commands/append/lib/data.ts | 14 +- cli/src/commands/append/status.ts | 28 +- cli/src/commands/catalogs/create.ts | 33 +- cli/src/commands/catalogs/index.ts | 48 +- cli/src/commands/catalogs/lib/requests.ts | 15 +- cli/src/commands/completion/generate.ts | 20 +- cli/src/commands/completion/index.test.ts | 186 +-- cli/src/commands/completion/index.ts | 26 +- cli/src/commands/completion/install.ts | 32 +- cli/src/commands/completion/lib/completion.ts | 181 +-- cli/src/commands/completion/lib/format.ts | 202 ++-- cli/src/commands/completion/lib/model.ts | 56 +- cli/src/commands/completion/lib/spec.test.ts | 703 +++++++----- cli/src/commands/completion/lib/spec.ts | 61 +- cli/src/commands/doctor/index.test.ts | 276 +++-- cli/src/commands/doctor/index.ts | 40 +- cli/src/commands/doctor/lib/checks.ts | 182 +-- cli/src/commands/doctor/lib/model.ts | 10 +- cli/src/commands/doctor/lib/render.test.ts | 26 +- cli/src/commands/doctor/lib/render.ts | 77 +- cli/src/commands/doctor/lib/runner.test.ts | 211 ++-- cli/src/commands/doctor/lib/runner.ts | 38 +- cli/src/commands/duckdb/index.test.ts | 160 +-- cli/src/commands/duckdb/index.ts | 115 +- cli/src/commands/index.ts | 36 +- cli/src/commands/login/index.test.ts | 646 ++++++----- cli/src/commands/login/index.ts | 287 ++--- cli/src/commands/logout/index.test.ts | 165 +-- cli/src/commands/logout/index.ts | 22 +- cli/src/commands/profile/configure.ts | 36 +- cli/src/commands/profile/current.ts | 16 +- cli/src/commands/profile/delete.ts | 22 +- cli/src/commands/profile/env.ts | 24 +- cli/src/commands/profile/index.ts | 48 +- cli/src/commands/profile/lib/profile.ts | 46 +- cli/src/commands/profile/list.ts | 18 +- cli/src/commands/profile/rename.ts | 22 +- cli/src/commands/profile/show.ts | 34 +- cli/src/commands/profile/status.ts | 42 +- cli/src/commands/profile/switch.ts | 40 +- cli/src/commands/query/cancel.ts | 34 +- cli/src/commands/query/index.test.ts | 263 +++-- cli/src/commands/query/index.ts | 55 +- cli/src/commands/query/lib/args.ts | 12 +- cli/src/commands/query/show.ts | 26 +- cli/src/commands/schema/index.test.ts | 136 ++- cli/src/commands/schema/index.ts | 58 +- cli/src/commands/schema/lib/args.ts | 9 +- cli/src/commands/schema/lib/views.ts | 92 +- cli/src/commands/update/index.ts | 123 +- cli/src/commands/upload/index.test.ts | 53 +- cli/src/commands/upload/index.ts | 42 +- cli/src/commands/upload/lib/args.ts | 12 +- cli/src/commands/upsert/index.test.ts | 34 +- cli/src/commands/upsert/index.ts | 42 +- cli/src/commands/upsert/lib/args.ts | 8 +- cli/src/context.ts | 12 +- cli/src/lib/args.ts | 15 +- cli/src/lib/auth.test.ts | 180 +-- cli/src/lib/auth.ts | 103 +- cli/src/lib/cli-args.ts | 10 +- cli/src/lib/cli-session.test.ts | 48 +- cli/src/lib/cli-session.ts | 4 +- cli/src/lib/command-descriptor.test.ts | 247 ++-- cli/src/lib/command-descriptor.ts | 102 +- cli/src/lib/command-output.test.ts | 122 +- cli/src/lib/command-output.ts | 55 +- cli/src/lib/command-parser.ts | 165 ++- cli/src/lib/command.test.ts | 241 ++-- cli/src/lib/command.ts | 63 +- cli/src/lib/compiled-executable-path.test.ts | 62 +- cli/src/lib/config-files.ts | 90 +- cli/src/lib/config.test.ts | 131 ++- cli/src/lib/config.ts | 52 +- cli/src/lib/early-bootstrap.test.ts | 43 +- cli/src/lib/early-bootstrap.ts | 31 +- cli/src/lib/env.test.ts | 174 +-- cli/src/lib/env.ts | 140 ++- cli/src/lib/errors.test.ts | 234 ++-- cli/src/lib/errors.ts | 111 +- cli/src/lib/executable-path.ts | 24 +- cli/src/lib/execution-context.ts | 19 +- .../lib/fixtures/compiled-executable-path.ts | 2 +- cli/src/lib/global-flags.test.ts | 160 ++- cli/src/lib/global-flags.ts | 86 +- cli/src/lib/http-headers.test.ts | 32 +- cli/src/lib/http-request.test.ts | 248 ++-- cli/src/lib/http-request.ts | 54 +- cli/src/lib/http-retry.ts | 21 +- cli/src/lib/http.test.ts | 509 +++++---- cli/src/lib/http.ts | 227 ++-- cli/src/lib/lakehouse-ndjson.ts | 71 +- cli/src/lib/lakehouse-provision.test.ts | 313 +++-- cli/src/lib/lakehouse-provision.ts | 92 +- cli/src/lib/lakehouse-transport.ts | 33 +- cli/src/lib/lakehouse/args.test.ts | 56 +- cli/src/lib/lakehouse/args.ts | 64 +- cli/src/lib/lakehouse/file.ts | 4 +- cli/src/lib/lakehouse/query.test.ts | 376 +++--- cli/src/lib/lakehouse/query.ts | 45 +- cli/src/lib/log.ts | 2 +- cli/src/lib/management-endpoint.ts | 8 +- cli/src/lib/management-output.test.ts | 99 +- cli/src/lib/management-output.ts | 32 +- cli/src/lib/management/auth.test.ts | 132 ++- cli/src/lib/management/catalogs.ts | 65 +- cli/src/lib/management/model.test.ts | 48 +- cli/src/lib/management/model.ts | 58 +- cli/src/lib/management/render.test.ts | 63 +- cli/src/lib/management/render.ts | 27 +- cli/src/lib/management/views.ts | 40 +- cli/src/lib/oauth-flow.test.ts | 208 ++-- cli/src/lib/oauth-flow.ts | 171 +-- cli/src/lib/oauth-profile.test.ts | 263 +++-- cli/src/lib/oauth-profile.ts | 43 +- cli/src/lib/object.ts | 6 +- cli/src/lib/openapi-spec.test.ts | 21 +- cli/src/lib/openapi-spec.ts | 11 +- cli/src/lib/pager.test.ts | 194 +++- cli/src/lib/pager.ts | 36 +- cli/src/lib/parse-api-json.ts | 6 +- cli/src/lib/pluralize.test.ts | 32 +- cli/src/lib/pluralize.ts | 12 +- cli/src/lib/profile-configure-core.test.ts | 134 ++- cli/src/lib/profile-configure-core.ts | 374 +++--- ...rofile-configure-credential-status.test.ts | 216 ++-- .../lib/profile-configure-data-plane.test.ts | 56 +- cli/src/lib/profile-configure-interactive.ts | 256 +++-- cli/src/lib/profile-configure.test.ts | 322 ++++-- cli/src/lib/profile-configure.ts | 224 ++-- cli/src/lib/profile-status.test.ts | 106 +- cli/src/lib/profile-status.ts | 57 +- cli/src/lib/profile-store.test.ts | 100 +- cli/src/lib/profile-store.ts | 131 ++- cli/src/lib/profile/model.test.ts | 425 ++++--- cli/src/lib/profile/model.ts | 407 ++++--- cli/src/lib/profile/render.test.ts | 55 +- cli/src/lib/profile/render.ts | 36 +- cli/src/lib/profile/views.test.ts | 103 +- cli/src/lib/profile/views.ts | 295 +++-- cli/src/lib/progress.test.ts | 59 +- cli/src/lib/progress.ts | 54 +- cli/src/lib/query-column-types.test.ts | 116 +- cli/src/lib/query-column-types.ts | 86 +- cli/src/lib/query-destination.test.ts | 112 +- cli/src/lib/query-destination.ts | 18 +- cli/src/lib/query-format.test.ts | 583 +++++----- cli/src/lib/query-format.ts | 404 ++++--- cli/src/lib/query-output-args.test.ts | 153 +-- cli/src/lib/query-output-args.ts | 180 +-- cli/src/lib/query-output.ts | 116 +- cli/src/lib/redact.test.ts | 48 +- cli/src/lib/redact.ts | 67 +- cli/src/lib/relative-time.test.ts | 71 +- cli/src/lib/relative-time.ts | 18 +- cli/src/lib/runtime.ts | 16 +- cli/src/lib/secrets.test.ts | 109 +- cli/src/lib/secrets.ts | 178 ++- cli/src/lib/service-account-provision.test.ts | 78 +- cli/src/lib/service-account-provision.ts | 84 +- cli/src/lib/sleep.ts | 2 +- cli/src/lib/stream-lines.test.ts | 32 +- cli/src/lib/stream-lines.ts | 12 +- cli/src/lib/tabular-result.ts | 48 +- cli/src/lib/timeout-args.ts | 12 +- cli/src/lib/updater-config.ts | 86 +- cli/src/lib/updater.test.ts | 940 ++++++++------- cli/src/lib/updater.ts | 620 ++++++---- cli/src/lib/url-policy.test.ts | 26 +- cli/src/lib/url-policy.ts | 21 +- cli/src/lib/usage.test.ts | 344 +++--- cli/src/lib/usage.ts | 329 +++--- cli/src/lib/user-directories.ts | 40 +- cli/src/package-metadata.ts | 4 +- cli/src/release-manifest.ts | 62 +- cli/src/test-utils/cli.ts | 30 +- cli/src/test-utils/keychain.ts | 38 +- cli/src/test-utils/lakehouse.ts | 40 +- cli/src/test-utils/runtime.ts | 6 +- cli/src/test-utils/terminal.ts | 42 +- cli/src/test-utils/time.ts | 2 +- cli/src/ui/document.ts | 46 +- cli/src/ui/error.ts | 19 +- cli/src/ui/layouts/query.ts | 2 +- cli/src/ui/layouts/tree.test.ts | 47 +- cli/src/ui/layouts/tree.ts | 2 +- cli/src/ui/prompts.ts | 61 +- cli/src/ui/renderers/terminal.ts | 71 +- cli/src/ui/shell/render.test.ts | 18 +- cli/src/ui/shell/render.ts | 10 +- cli/src/ui/terminal/spacing.ts | 2 +- cli/src/ui/terminal/styles.test.ts | 258 +++-- cli/src/ui/terminal/styles.ts | 205 ++-- cli/src/ui/terminal/table.test.ts | 275 ++--- cli/src/ui/terminal/table.ts | 65 +- cli/src/version.ts | 2 +- data-app/starter/.oxfmtrc.json | 7 +- data-app/starter/bun.lock | 86 +- data-app/starter/package.json | 4 +- data-app/starter/src/App.tsx | 4 +- data-app/starter/src/assets.d.ts | 2 +- data-app/starter/src/config.ts | 13 +- data-app/starter/src/data-context.ts | 14 +- data-app/starter/src/main.tsx | 6 +- data-app/starter/src/operations.ts | 4 +- data-app/starter/src/preview.ts | 12 +- data-app/starter/src/server.ts | 8 +- data-app/tests/calendar.spec.ts | 44 +- data-app/tests/harness.spec.ts | 129 ++- data-app/tests/package.json | 3 +- data-app/tests/playwright.config.ts | 17 +- data-app/tests/server.ts | 71 +- data-app/tests/starter.spec.ts | 547 +++++---- scripts/verify.sh | 2 +- 281 files changed, 17573 insertions(+), 12547 deletions(-) diff --git a/cli/.oxfmtrc.json b/cli/.oxfmtrc.json index 60961c94..f462b1a7 100644 --- a/cli/.oxfmtrc.json +++ b/cli/.oxfmtrc.json @@ -1,4 +1,9 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["dist/**", "src/generated/**", "bun.lock"] + "ignorePatterns": ["dist/**", "src/generated/**", "bun.lock"], + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid", + "printWidth": 80, + "endOfLine": "lf" } diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 68b85b7e..5baacdd1 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -167,13 +167,17 @@ metadata; do not inspect or rewrite raw argv in parent handlers. Minimal pattern (management HTTP command): ```typescript -import { defineCommand } from "@/lib/command.ts"; -import { sendHttp } from "@/lib/http-request.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { sendHttp } from '@/lib/http-request.ts'; export const myfeatureCommand = defineCommand({ - meta: { name: "myfeature", description: "…" }, + meta: { name: 'myfeature', description: '…' }, async run({ execution, sink }) { - const request = { plane: "management", method: "GET", endpoint: "/path" } as const; + const request = { + plane: 'management', + method: 'GET', + endpoint: '/path', + } as const; const response = await sendHttp(request, execution); sink.writeJson(JSON.parse(response)); }, diff --git a/cli/bun.lock b/cli/bun.lock index f8bd11da..51de258a 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -14,10 +14,11 @@ "eslint-plugin-no-relative-import-paths": "1.6.1", "knip": "6.32.2", "openapi-typescript": "7.13.0", - "oxfmt": "0.63.0", - "oxlint": "1.78.0", - "oxlint-tsgolint": "7.0.2001", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", "typescript": "6.0.3", + "typescript-checker": "npm:typescript@7.0.2", }, }, }, @@ -116,93 +117,93 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2003", "", { "os": "linux", "cpu": "arm64" }, "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2003", "", { "os": "linux", "cpu": "x64" }, "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2003", "", { "os": "win32", "cpu": "arm64" }, "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "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" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], @@ -216,6 +217,46 @@ "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], @@ -280,11 +321,11 @@ "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], - "oxfmt": ["oxfmt@0.63.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.63.0", "@oxfmt/binding-android-arm64": "0.63.0", "@oxfmt/binding-darwin-arm64": "0.63.0", "@oxfmt/binding-darwin-x64": "0.63.0", "@oxfmt/binding-freebsd-x64": "0.63.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", "@oxfmt/binding-linux-arm64-gnu": "0.63.0", "@oxfmt/binding-linux-arm64-musl": "0.63.0", "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-musl": "0.63.0", "@oxfmt/binding-linux-s390x-gnu": "0.63.0", "@oxfmt/binding-linux-x64-gnu": "0.63.0", "@oxfmt/binding-linux-x64-musl": "0.63.0", "@oxfmt/binding-openharmony-arm64": "0.63.0", "@oxfmt/binding-win32-arm64-msvc": "0.63.0", "@oxfmt/binding-win32-ia32-msvc": "0.63.0", "@oxfmt/binding-win32-x64-msvc": "0.63.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw=="], + "oxfmt": ["oxfmt@0.71.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.71.0", "@oxfmt/binding-android-arm64": "0.71.0", "@oxfmt/binding-darwin-arm64": "0.71.0", "@oxfmt/binding-darwin-x64": "0.71.0", "@oxfmt/binding-freebsd-x64": "0.71.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.71.0", "@oxfmt/binding-linux-arm-musleabihf": "0.71.0", "@oxfmt/binding-linux-arm64-gnu": "0.71.0", "@oxfmt/binding-linux-arm64-musl": "0.71.0", "@oxfmt/binding-linux-ppc64-gnu": "0.71.0", "@oxfmt/binding-linux-riscv64-gnu": "0.71.0", "@oxfmt/binding-linux-riscv64-musl": "0.71.0", "@oxfmt/binding-linux-s390x-gnu": "0.71.0", "@oxfmt/binding-linux-x64-gnu": "0.71.0", "@oxfmt/binding-linux-x64-musl": "0.71.0", "@oxfmt/binding-openharmony-arm64": "0.71.0", "@oxfmt/binding-win32-arm64-msvc": "0.71.0", "@oxfmt/binding-win32-ia32-msvc": "0.71.0", "@oxfmt/binding-win32-x64-msvc": "0.71.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg=="], - "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], + "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], - "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -308,7 +349,7 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -316,6 +357,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript-checker": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], diff --git a/cli/knip.json b/cli/knip.json index 3176d6f6..89c041f2 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -10,6 +10,6 @@ "project": ["src/**/*.ts!", "!src/test-utils/**!", "scripts/**/*.ts!"], "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, - "ignoreDependencies": ["undici"], + "ignoreDependencies": ["undici", "typescript-checker"], "ignoreBinaries": ["less"] } diff --git a/cli/lint/no-relative-import-paths.js b/cli/lint/no-relative-import-paths.js index 213a2e99..1d9d08de 100644 --- a/cli/lint/no-relative-import-paths.js +++ b/cli/lint/no-relative-import-paths.js @@ -1,14 +1,14 @@ -import { createRequire } from "node:module"; +import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const eslintPlugin = require("eslint-plugin-no-relative-import-paths"); +const eslintPlugin = require('eslint-plugin-no-relative-import-paths'); const plugin = { ...eslintPlugin, meta: { ...eslintPlugin.meta, - name: "no-relative-import-paths", + name: 'no-relative-import-paths', }, }; diff --git a/cli/openapi/openapi.yaml b/cli/openapi/openapi.yaml index 71c2dd17..e5a8f0da 100644 --- a/cli/openapi/openapi.yaml +++ b/cli/openapi/openapi.yaml @@ -20,7 +20,7 @@ components: bearerAuth: type: http scheme: bearer - description: "Management API key token (e.g. `atm_...`)" + description: 'Management API key token (e.g. `atm_...`)' schemas: Error: @@ -33,7 +33,14 @@ components: properties: code: type: string - enum: [unauthorized, forbidden, not_found, bad_request, unprocessable_content] + enum: + [ + unauthorized, + forbidden, + not_found, + bad_request, + unprocessable_content, + ] message: type: string details: @@ -53,11 +60,11 @@ components: region: anyOf: - type: string - - type: "null" + - type: 'null' endpoint: anyOf: - type: string - - type: "null" + - type: 'null' provider: type: string enum: @@ -88,7 +95,7 @@ components: type: object properties: bucket: - "$ref": "#/components/schemas/Bucket" + '$ref': '#/components/schemas/Bucket' required: - bucket BucketsListResponse: @@ -97,7 +104,7 @@ components: buckets: type: array items: - "$ref": "#/components/schemas/Bucket" + '$ref': '#/components/schemas/Bucket' required: - buckets Connection: @@ -131,11 +138,11 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' labels: type: array items: - "$ref": "#/components/schemas/Label" + '$ref': '#/components/schemas/Label' catalog: type: string environment_id: @@ -150,7 +157,7 @@ components: anyOf: - type: object additionalProperties: {} - - type: "null" + - type: 'null' required: - id - name @@ -168,18 +175,18 @@ components: dataset: anyOf: - type: string - - type: "null" + - type: 'null' project_id_override: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionBucketTablesConfig: type: object properties: bucket_id: anyOf: - type: string - - type: "null" + - type: 'null' file_format: anyOf: - type: string @@ -187,11 +194,11 @@ components: - parquet - csv - json - - type: "null" + - type: 'null' assume_immutable: anyOf: - type: boolean - - type: "null" + - type: 'null' tables: type: object additionalProperties: @@ -204,33 +211,33 @@ components: bucket_id: anyOf: - type: string - - type: "null" + - type: 'null' path: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionGlueConfig: type: object properties: warehouse: anyOf: - type: string - - type: "null" + - type: 'null' default_region: anyOf: - type: string - - type: "null" + - type: 'null' role_arn: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionIcebergTablesConfig: type: object properties: bucket_id: anyOf: - type: string - - type: "null" + - type: 'null' tables: type: object additionalProperties: @@ -243,70 +250,70 @@ components: host: anyOf: - type: string - - type: "null" + - type: 'null' port: anyOf: - type: integer - - type: "null" + - type: 'null' database: anyOf: - type: string - - type: "null" + - type: 'null' username: anyOf: - type: string - - type: "null" + - type: 'null' password: anyOf: - type: string - - type: "null" + - type: 'null' ssh_tunnel: anyOf: - - "$ref": "#/components/schemas/ConnectionSshTunnel" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSshTunnel' + - type: 'null' srv: anyOf: - type: boolean - - type: "null" + - type: 'null' tls: anyOf: - type: boolean - - type: "null" + - type: 'null' auth_source: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionMysqlConfig: type: object properties: host: anyOf: - type: string - - type: "null" + - type: 'null' port: anyOf: - type: integer - - type: "null" + - type: 'null' database: anyOf: - type: string - - type: "null" + - type: 'null' username: anyOf: - type: string - - type: "null" + - type: 'null' password: anyOf: - type: string - - type: "null" + - type: 'null' schema: anyOf: - type: string - - type: "null" + - type: 'null' ssh_tunnel: anyOf: - - "$ref": "#/components/schemas/ConnectionSshTunnel" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSshTunnel' + - type: 'null' ssl_mode: anyOf: - type: string @@ -314,38 +321,38 @@ components: - disabled - preferred - verify_identity - - type: "null" + - type: 'null' ConnectionPostgresConfig: type: object properties: host: anyOf: - type: string - - type: "null" + - type: 'null' port: anyOf: - type: integer - - type: "null" + - type: 'null' database: anyOf: - type: string - - type: "null" + - type: 'null' username: anyOf: - type: string - - type: "null" + - type: 'null' password: anyOf: - type: string - - type: "null" + - type: 'null' schema: anyOf: - type: string - - type: "null" + - type: 'null' ssh_tunnel: anyOf: - - "$ref": "#/components/schemas/ConnectionSshTunnel" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSshTunnel' + - type: 'null' sslmode: anyOf: - type: string @@ -356,27 +363,27 @@ components: - require - verify-ca - verify-full - - type: "null" + - type: 'null' ConnectionR2CatalogConfig: type: object properties: warehouse: anyOf: - type: string - - type: "null" + - type: 'null' endpoint: anyOf: - type: string - - type: "null" + - type: 'null' token: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionResponse: type: object properties: connection: - "$ref": "#/components/schemas/Connection" + '$ref': '#/components/schemas/Connection' required: - connection ConnectionS3TablesConfig: @@ -385,95 +392,95 @@ components: warehouse: anyOf: - type: string - - type: "null" + - type: 'null' default_region: anyOf: - type: string - - type: "null" + - type: 'null' aws_access_key_id: anyOf: - type: string - - type: "null" + - type: 'null' aws_secret_access_key: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionSnowflakeConfig: type: object properties: account_url: anyOf: - type: string - - type: "null" + - type: 'null' warehouse: anyOf: - type: string - - type: "null" + - type: 'null' username: anyOf: - type: string - - type: "null" + - type: 'null' password: anyOf: - type: string - - type: "null" + - type: 'null' database: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionSshTunnel: type: object properties: bastion_host: anyOf: - type: string - - type: "null" + - type: 'null' bastion_port: anyOf: - type: integer - - type: "null" + - type: 'null' bastion_username: anyOf: - type: string - - type: "null" + - type: 'null' ConnectionStandardConfig: type: object properties: host: anyOf: - type: string - - type: "null" + - type: 'null' port: anyOf: - type: integer - - type: "null" + - type: 'null' database: anyOf: - type: string - - type: "null" + - type: 'null' username: anyOf: - type: string - - type: "null" + - type: 'null' password: anyOf: - type: string - - type: "null" + - type: 'null' schema: anyOf: - type: string - - type: "null" + - type: 'null' ssh_tunnel: anyOf: - - "$ref": "#/components/schemas/ConnectionSshTunnel" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSshTunnel' + - type: 'null' ConnectionsListResponse: type: object properties: connections: type: array items: - "$ref": "#/components/schemas/Connection" + '$ref': '#/components/schemas/Connection' required: - connections CreateBucketRequest: @@ -488,11 +495,11 @@ components: region: anyOf: - type: string - - type: "null" + - type: 'null' endpoint: anyOf: - type: string - - type: "null" + - type: 'null' required: - name - access_key_id @@ -522,65 +529,65 @@ components: read_only: anyOf: - type: boolean - - type: "null" + - type: 'null' label_ids: anyOf: - type: array items: type: string - - type: "null" + - type: 'null' description: anyOf: - type: string - - type: "null" + - type: 'null' standard_config: anyOf: - - "$ref": "#/components/schemas/ConnectionStandardConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionStandardConfig' + - type: 'null' mysql_config: anyOf: - - "$ref": "#/components/schemas/ConnectionMysqlConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionMysqlConfig' + - type: 'null' postgres_config: anyOf: - - "$ref": "#/components/schemas/ConnectionPostgresConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionPostgresConfig' + - type: 'null' mongo_config: anyOf: - - "$ref": "#/components/schemas/ConnectionMongoConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionMongoConfig' + - type: 'null' bigquery_config: anyOf: - - "$ref": "#/components/schemas/ConnectionBigQueryConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionBigQueryConfig' + - type: 'null' snowflake_config: anyOf: - - "$ref": "#/components/schemas/ConnectionSnowflakeConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSnowflakeConfig' + - type: 'null' bucket_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionBucketTablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionBucketTablesConfig' + - type: 'null' iceberg_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionIcebergTablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionIcebergTablesConfig' + - type: 'null' duckdb_config: anyOf: - - "$ref": "#/components/schemas/ConnectionDuckDBConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionDuckDBConfig' + - type: 'null' r2_catalog_config: anyOf: - - "$ref": "#/components/schemas/ConnectionR2CatalogConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionR2CatalogConfig' + - type: 'null' s3_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionS3TablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionS3TablesConfig' + - type: 'null' glue_config: anyOf: - - "$ref": "#/components/schemas/ConnectionGlueConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionGlueConfig' + - type: 'null' required: - name - engine @@ -590,17 +597,17 @@ components: label: anyOf: - type: string - - type: "null" + - type: 'null' expires_at: anyOf: - type: string format: date-time - - type: "null" + - type: 'null' CreateCredentialResponse: type: object properties: credential: - "$ref": "#/components/schemas/Credential" + '$ref': '#/components/schemas/Credential' password: type: string required: @@ -614,25 +621,25 @@ components: bucket_id: anyOf: - type: string - - type: "null" + - type: 'null' read_only: anyOf: - type: boolean - - type: "null" + - type: 'null' label_ids: anyOf: - type: array items: type: string - - type: "null" + - type: 'null' snapshot_retention_days: anyOf: - type: integer - - type: "null" + - type: 'null' description: anyOf: - type: string - - type: "null" + - type: 'null' required: - name CreateEnvironmentRequest: @@ -650,17 +657,17 @@ components: - type: string enum: - fsn1 - - type: "null" + - type: 'null' cloud_provider_aws_region: anyOf: - type: string enum: - eu-west-1 - - type: "null" + - type: 'null' compute_units: anyOf: - type: integer - - type: "null" + - type: 'null' required: - name - cloud_provider @@ -682,11 +689,11 @@ components: enum: - ro - rw - - type: "null" + - type: 'null' with_service_oauth_token: anyOf: - type: boolean - - type: "null" + - type: 'null' required: - label CreateServiceAccountRequest: @@ -725,17 +732,17 @@ components: anyOf: - type: string format: date-time - - type: "null" + - type: 'null' revoked_at: anyOf: - type: string format: date-time - - type: "null" + - type: 'null' last_rotated_at: anyOf: - type: string format: date-time - - type: "null" + - type: 'null' required: - id - label @@ -748,7 +755,7 @@ components: type: object properties: credential: - "$ref": "#/components/schemas/Credential" + '$ref': '#/components/schemas/Credential' required: - credential Database: @@ -767,11 +774,11 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' labels: type: array items: - "$ref": "#/components/schemas/Label" + '$ref': '#/components/schemas/Label' catalog: type: string bucket_id: @@ -779,7 +786,7 @@ components: snapshot_retention_days: anyOf: - type: integer - - type: "null" + - type: 'null' environment_id: type: string created_at: @@ -804,7 +811,7 @@ components: type: object properties: database: - "$ref": "#/components/schemas/Database" + '$ref': '#/components/schemas/Database' required: - database DatabasesListResponse: @@ -813,7 +820,7 @@ components: databases: type: array items: - "$ref": "#/components/schemas/Database" + '$ref': '#/components/schemas/Database' required: - databases Environment: @@ -853,22 +860,22 @@ components: type: object properties: environment: - "$ref": "#/components/schemas/Environment" + '$ref': '#/components/schemas/Environment' required: - environment EnvironmentServiceAccountResponse: type: object properties: service_account: - "$ref": "#/components/schemas/ServiceAccount" + '$ref': '#/components/schemas/ServiceAccount' role_assignments: type: array items: - "$ref": "#/components/schemas/RoleAssignment" + '$ref': '#/components/schemas/RoleAssignment' service_oauth_token: anyOf: - type: string - - type: "null" + - type: 'null' required: - service_account - role_assignments @@ -913,11 +920,11 @@ components: email: anyOf: - type: string - - type: "null" + - type: 'null' slug: anyOf: - type: string - - type: "null" + - type: 'null' required: - id - type @@ -941,7 +948,7 @@ components: role_assignments: type: array items: - "$ref": "#/components/schemas/RoleAssignment" + '$ref': '#/components/schemas/RoleAssignment' required: - role_assignments RoleInput: @@ -966,7 +973,7 @@ components: - organization - environment - catalog - - type: "null" + - type: 'null' resource_id: type: string required: @@ -989,7 +996,7 @@ components: type: object properties: service_account: - "$ref": "#/components/schemas/ServiceAccount" + '$ref': '#/components/schemas/ServiceAccount' required: - service_account UpdateBucketRequest: @@ -998,117 +1005,117 @@ components: name: anyOf: - type: string - - type: "null" + - type: 'null' access_key_id: anyOf: - type: string - - type: "null" + - type: 'null' secret_access_key: anyOf: - type: string - - type: "null" + - type: 'null' region: anyOf: - type: string - - type: "null" + - type: 'null' endpoint: anyOf: - type: string - - type: "null" + - type: 'null' UpdateConnectionRequest: type: object properties: name: anyOf: - type: string - - type: "null" + - type: 'null' read_only: anyOf: - type: boolean - - type: "null" + - type: 'null' label_ids: anyOf: - type: array items: type: string - - type: "null" + - type: 'null' description: anyOf: - type: string - - type: "null" + - type: 'null' standard_config: anyOf: - - "$ref": "#/components/schemas/ConnectionStandardConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionStandardConfig' + - type: 'null' mysql_config: anyOf: - - "$ref": "#/components/schemas/ConnectionMysqlConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionMysqlConfig' + - type: 'null' postgres_config: anyOf: - - "$ref": "#/components/schemas/ConnectionPostgresConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionPostgresConfig' + - type: 'null' mongo_config: anyOf: - - "$ref": "#/components/schemas/ConnectionMongoConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionMongoConfig' + - type: 'null' bigquery_config: anyOf: - - "$ref": "#/components/schemas/ConnectionBigQueryConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionBigQueryConfig' + - type: 'null' snowflake_config: anyOf: - - "$ref": "#/components/schemas/ConnectionSnowflakeConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionSnowflakeConfig' + - type: 'null' bucket_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionBucketTablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionBucketTablesConfig' + - type: 'null' iceberg_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionIcebergTablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionIcebergTablesConfig' + - type: 'null' duckdb_config: anyOf: - - "$ref": "#/components/schemas/ConnectionDuckDBConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionDuckDBConfig' + - type: 'null' r2_catalog_config: anyOf: - - "$ref": "#/components/schemas/ConnectionR2CatalogConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionR2CatalogConfig' + - type: 'null' s3_tables_config: anyOf: - - "$ref": "#/components/schemas/ConnectionS3TablesConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionS3TablesConfig' + - type: 'null' glue_config: anyOf: - - "$ref": "#/components/schemas/ConnectionGlueConfig" - - type: "null" + - '$ref': '#/components/schemas/ConnectionGlueConfig' + - type: 'null' UpdateDatabaseRequest: type: object properties: name: anyOf: - type: string - - type: "null" + - type: 'null' read_only: anyOf: - type: boolean - - type: "null" + - type: 'null' label_ids: anyOf: - type: array items: type: string - - type: "null" + - type: 'null' snapshot_retention_days: anyOf: - type: integer - - type: "null" + - type: 'null' description: anyOf: - type: string - - type: "null" + - type: 'null' UpdateEnvironmentRequest: type: object properties: @@ -1122,7 +1129,7 @@ components: roles: type: array items: - "$ref": "#/components/schemas/RoleInput" + '$ref': '#/components/schemas/RoleInput' required: - roles UpdateServiceAccountRequest: @@ -1138,11 +1145,11 @@ components: id: anyOf: - type: string - - type: "null" + - type: 'null' invitation_id: anyOf: - type: string - - type: "null" + - type: 'null' iac_id: type: string email: @@ -1150,7 +1157,7 @@ components: name: anyOf: - type: string - - type: "null" + - type: 'null' required: - id - invitation_id @@ -1161,22 +1168,22 @@ components: type: object properties: user: - "$ref": "#/components/schemas/User" + '$ref': '#/components/schemas/User' required: - user WhoamiResponse: type: object properties: principal: - "$ref": "#/components/schemas/Principal" + '$ref': '#/components/schemas/Principal' organization: - "$ref": "#/components/schemas/Organization" + '$ref': '#/components/schemas/Organization' authentication_scope: type: string environment_slug: anyOf: - type: string - - type: "null" + - type: 'null' required: - principal - organization @@ -1188,19 +1195,19 @@ components: content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: '#/components/schemas/Error' Forbidden: description: Insufficient permissions content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: '#/components/schemas/Error' NotFound: description: Resource not found content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: '#/components/schemas/Error' paths: /whoami: @@ -1210,39 +1217,39 @@ paths: description: Returns the principal (user or service account) and organization associated with the API key. tags: [Authentication] responses: - "200": + '200': description: Authenticated principal and organization content: application/json: schema: - $ref: "#/components/schemas/WhoamiResponse" + $ref: '#/components/schemas/WhoamiResponse' examples: user: summary: User principal value: principal: - id: "019d7223-796e-7917-8d7e-ca256daa7979" + id: '019d7223-796e-7917-8d7e-ca256daa7979' type: User name: Jane Doe email: jane@example.com organization: - id: "019d7223-796e-7917-8d7e-ca256daa7980" + id: '019d7223-796e-7917-8d7e-ca256daa7980' name: Acme Corp slug: acme-corp service_account: summary: ServiceAccount principal value: principal: - id: "019d7223-796e-7917-8d7e-ca256daa7981" + id: '019d7223-796e-7917-8d7e-ca256daa7981' type: ServiceAccount name: CI Deploy slug: ci-deploy organization: - id: "019d7223-796e-7917-8d7e-ca256daa7980" + id: '019d7223-796e-7917-8d7e-ca256daa7980' name: Acme Corp slug: acme-corp - "401": - $ref: "#/components/responses/Unauthorized" + '401': + $ref: '#/components/responses/Unauthorized' /environments/{id}: get: @@ -1257,21 +1264,21 @@ paths: schema: type: string examples: - - "019d7223-796e-7917-8d7e-ca256daa7979" - - "production" + - '019d7223-796e-7917-8d7e-ca256daa7979' + - 'production' responses: - "200": + '200': description: Environment content: application/json: schema: - $ref: "#/components/schemas/EnvironmentResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" + $ref: '#/components/schemas/EnvironmentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' delete: operationId: deleteEnvironment summary: Delete an environment @@ -1284,17 +1291,17 @@ paths: schema: type: string examples: - - "019d7223-796e-7917-8d7e-ca256daa7979" - - "production" + - '019d7223-796e-7917-8d7e-ca256daa7979' + - 'production' responses: - "204": + '204': description: Environment deleted - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /environments: post: @@ -1306,24 +1313,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateEnvironmentRequest" + $ref: '#/components/schemas/CreateEnvironmentRequest' responses: - "201": + '201': description: Environment created content: application/json: schema: - $ref: "#/components/schemas/EnvironmentResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": + $ref: '#/components/schemas/EnvironmentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': description: Validation failed content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: '#/components/schemas/Error' /service_accounts: post: @@ -1335,24 +1342,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateServiceAccountRequest" + $ref: '#/components/schemas/CreateServiceAccountRequest' responses: - "201": + '201': description: Service account created content: application/json: schema: - $ref: "#/components/schemas/ServiceAccountResponse" - "400": + $ref: '#/components/schemas/ServiceAccountResponse' + '400': description: Missing required parameter content: application/json: schema: - $ref: "#/components/schemas/Error" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' /service_accounts/{id}: get: @@ -1367,17 +1374,17 @@ paths: type: string format: uuid responses: - "200": + '200': description: Service account content: application/json: - schema: { $ref: "#/components/schemas/ServiceAccountResponse" } - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" + schema: { $ref: '#/components/schemas/ServiceAccountResponse' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' patch: operationId: updateServiceAccount summary: Update a service account @@ -1393,24 +1400,24 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateServiceAccountRequest" } + schema: { $ref: '#/components/schemas/UpdateServiceAccountRequest' } responses: - "200": + '200': description: Service account updated content: application/json: - schema: { $ref: "#/components/schemas/ServiceAccountResponse" } - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "422": + schema: { $ref: '#/components/schemas/ServiceAccountResponse' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: deleteServiceAccount summary: Delete a service account @@ -1423,19 +1430,19 @@ paths: type: string format: uuid responses: - "204": + '204': description: Service account deleted - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "422": + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': description: Service account still has active credentials content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /environments/{environment_id}/service_accounts: post: @@ -1457,14 +1464,14 @@ paths: schema: type: string examples: - - "019d7223-796e-7917-8d7e-ca256daa7979" - - "production" + - '019d7223-796e-7917-8d7e-ca256daa7979' + - 'production' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateEnvironmentServiceAccountRequest" + $ref: '#/components/schemas/CreateEnvironmentServiceAccountRequest' examples: environmentReadOnly: summary: Read-only on the whole environment @@ -1483,24 +1490,24 @@ paths: analytics: ro staging: rw responses: - "201": + '201': description: Service account created content: application/json: schema: - $ref: "#/components/schemas/EnvironmentServiceAccountResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "422": + $ref: '#/components/schemas/EnvironmentServiceAccountResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': description: Validation failed content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: '#/components/schemas/Error' /environments/{environment_id}/buckets: get: @@ -1513,14 +1520,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Buckets content: application/json: - schema: { $ref: "#/components/schemas/BucketsListResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/BucketsListResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } post: operationId: createBucket summary: Connect a bucket @@ -1534,21 +1541,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/CreateBucketRequest" } + schema: { $ref: '#/components/schemas/CreateBucketRequest' } responses: - "201": + '201': description: Bucket created content: application/json: - schema: { $ref: "#/components/schemas/BucketResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/BucketResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /environments/{environment_id}/buckets/{id}: get: @@ -1565,14 +1572,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Bucket content: application/json: - schema: { $ref: "#/components/schemas/BucketResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/BucketResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } patch: operationId: updateBucket summary: Update a bucket @@ -1590,21 +1597,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateBucketRequest" } + schema: { $ref: '#/components/schemas/UpdateBucketRequest' } responses: - "200": + '200': description: Bucket updated content: application/json: - schema: { $ref: "#/components/schemas/BucketResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/BucketResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: deleteBucket summary: Delete a bucket @@ -1619,15 +1626,15 @@ paths: required: true schema: { type: string } responses: - "204": { description: Bucket deleted } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + '204': { description: Bucket deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Built-in buckets cannot be deleted content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /environments/{environment_id}/connections: get: @@ -1640,14 +1647,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Connections content: application/json: - schema: { $ref: "#/components/schemas/ConnectionsListResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/ConnectionsListResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } post: operationId: createConnection summary: Create a connection @@ -1661,21 +1668,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/CreateConnectionRequest" } + schema: { $ref: '#/components/schemas/CreateConnectionRequest' } responses: - "201": + '201': description: Connection created content: application/json: - schema: { $ref: "#/components/schemas/ConnectionResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/ConnectionResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /environments/{environment_id}/connections/{id}: get: @@ -1692,14 +1699,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Connection content: application/json: - schema: { $ref: "#/components/schemas/ConnectionResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/ConnectionResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } patch: operationId: updateConnection summary: Update a connection @@ -1717,21 +1724,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateConnectionRequest" } + schema: { $ref: '#/components/schemas/UpdateConnectionRequest' } responses: - "200": + '200': description: Connection updated content: application/json: - schema: { $ref: "#/components/schemas/ConnectionResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/ConnectionResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: deleteConnection summary: Delete a connection @@ -1746,10 +1753,10 @@ paths: required: true schema: { type: string } responses: - "204": { description: Connection deleted } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + '204': { description: Connection deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /environments/{environment_id}/databases: get: @@ -1762,14 +1769,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Databases content: application/json: - schema: { $ref: "#/components/schemas/DatabasesListResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/DatabasesListResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } post: operationId: createDatabase summary: Create a database @@ -1783,21 +1790,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/CreateDatabaseRequest" } + schema: { $ref: '#/components/schemas/CreateDatabaseRequest' } responses: - "201": + '201': description: Database created content: application/json: - schema: { $ref: "#/components/schemas/DatabaseResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/DatabaseResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /environments/{environment_id}/databases/{id}: get: @@ -1814,14 +1821,14 @@ paths: required: true schema: { type: string } responses: - "200": + '200': description: Database content: application/json: - schema: { $ref: "#/components/schemas/DatabaseResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/DatabaseResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } patch: operationId: updateDatabase summary: Update a database @@ -1839,21 +1846,21 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateDatabaseRequest" } + schema: { $ref: '#/components/schemas/UpdateDatabaseRequest' } responses: - "200": + '200': description: Database updated content: application/json: - schema: { $ref: "#/components/schemas/DatabaseResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/DatabaseResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: deleteDatabase summary: Delete a database @@ -1868,10 +1875,10 @@ paths: required: true schema: { type: string } responses: - "204": { description: Database deleted } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + '204': { description: Database deleted } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /users/lookup: get: @@ -1885,19 +1892,19 @@ paths: description: Email address of the user to look up schema: { type: string } responses: - "200": + '200': description: User content: application/json: - schema: { $ref: "#/components/schemas/UserResponse" } - "400": + schema: { $ref: '#/components/schemas/UserResponse' } + '400': description: Missing required parameter content: application/json: - schema: { $ref: "#/components/schemas/Error" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/Error' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /users: post: @@ -1909,25 +1916,25 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/CreateUserRequest" } + schema: { $ref: '#/components/schemas/CreateUserRequest' } responses: - "200": + '200': description: Existing pending invitation content: application/json: - schema: { $ref: "#/components/schemas/UserResponse" } - "201": + schema: { $ref: '#/components/schemas/UserResponse' } + '201': description: Invitation created content: application/json: - schema: { $ref: "#/components/schemas/UserResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": + schema: { $ref: '#/components/schemas/UserResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': description: Validation failed or the email is already an organization member content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /users/{id}: get: @@ -1941,14 +1948,14 @@ paths: description: Opaque iac_id returned by the user API; either a user UUID or invitation_id= schema: { type: string } responses: - "200": + '200': description: User content: application/json: - schema: { $ref: "#/components/schemas/UserResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/UserResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } delete: operationId: deleteUser summary: Cancel an invitation or remove a user from the organization @@ -1960,14 +1967,14 @@ paths: description: Opaque iac_id returned by the user API schema: { type: string } responses: - "204": { description: User or invitation is absent } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": + '204': { description: User or invitation is absent } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': description: The authenticated principal cannot remove itself content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /users/{user_id}/role_assignments: get: @@ -1983,14 +1990,14 @@ paths: schema: { type: string }, } responses: - "200": + '200': description: Role assignments content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/RoleAssignmentsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } put: operationId: setUserRoleAssignments summary: Replace a user's role assignments @@ -2007,21 +2014,22 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateRoleAssignmentsRequest" } + schema: + { $ref: '#/components/schemas/UpdateRoleAssignmentsRequest' } responses: - "200": + '200': description: Role assignments replaced content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/RoleAssignmentsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: resetUserRoleAssignments summary: Reset a user's role assignments to organization:member @@ -2035,14 +2043,14 @@ paths: schema: { type: string }, } responses: - "204": { description: Role assignments reset } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": + '204': { description: Role assignments reset } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /service_accounts/{service_account_id}/role_assignments: get: @@ -2050,57 +2058,73 @@ paths: summary: List a service account's direct role assignments tags: [Role Assignments] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } responses: - "200": + '200': description: Role assignments content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/RoleAssignmentsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } put: operationId: setServiceAccountRoleAssignments summary: Replace a service account's role assignments tags: [Role Assignments] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateRoleAssignmentsRequest" } + schema: + { $ref: '#/components/schemas/UpdateRoleAssignmentsRequest' } responses: - "200": + '200': description: Role assignments replaced content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/RoleAssignmentsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } delete: operationId: resetServiceAccountRoleAssignments summary: Reset a service account's role assignments to organization:member tags: [Role Assignments] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } responses: - "204": { description: Role assignments reset } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + '204': { description: Role assignments reset } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /users/{user_id}/environments/{environment_id}/credentials: post: @@ -2115,26 +2139,31 @@ paths: description: Opaque user iac_id, schema: { type: string }, } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } requestBody: required: false content: application/json: - schema: { $ref: "#/components/schemas/CreateCredentialRequest" } + schema: { $ref: '#/components/schemas/CreateCredentialRequest' } responses: - "201": + '201': description: Credential created content: application/json: - schema: { $ref: "#/components/schemas/CreateCredentialResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/CreateCredentialResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /users/{user_id}/environments/{environment_id}/credentials/{id}: get: @@ -2149,30 +2178,40 @@ paths: description: Opaque user iac_id, schema: { type: string }, } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } - { name: id, in: path, required: true, schema: { type: string } } responses: - "200": + '200': description: Credential content: application/json: - schema: { $ref: "#/components/schemas/CredentialResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/CredentialResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } delete: operationId: revokeUserCredential summary: Revoke a user's credential tags: [Credentials] parameters: - { name: user_id, in: path, required: true, schema: { type: string } } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } - { name: id, in: path, required: true, schema: { type: string } } responses: - "204": { description: Credential revoked } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + '204': { description: Credential revoked } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /service_accounts/{service_account_id}/environments/{environment_id}/credentials: post: @@ -2180,27 +2219,37 @@ paths: summary: Create a credential for a service account (returns the password once) tags: [Credentials] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } requestBody: required: false content: application/json: - schema: { $ref: "#/components/schemas/CreateCredentialRequest" } + schema: { $ref: '#/components/schemas/CreateCredentialRequest' } responses: - "201": + '201': description: Credential created content: application/json: - schema: { $ref: "#/components/schemas/CreateCredentialResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": + schema: { $ref: '#/components/schemas/CreateCredentialResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': description: Validation failed content: application/json: - schema: { $ref: "#/components/schemas/Error" } + schema: { $ref: '#/components/schemas/Error' } /service_accounts/{service_account_id}/environments/{environment_id}/credentials/{id}: get: @@ -2208,28 +2257,48 @@ paths: summary: Get a service account's credential (no secret) tags: [Credentials] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } - { name: id, in: path, required: true, schema: { type: string } } responses: - "200": + '200': description: Credential content: application/json: - schema: { $ref: "#/components/schemas/CredentialResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + schema: { $ref: '#/components/schemas/CredentialResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } delete: operationId: revokeServiceAccountCredential summary: Revoke a service account's credential tags: [Credentials] parameters: - - { name: service_account_id, in: path, required: true, schema: { type: string } } - - { name: environment_id, in: path, required: true, schema: { type: string } } + - { + name: service_account_id, + in: path, + required: true, + schema: { type: string }, + } + - { + name: environment_id, + in: path, + required: true, + schema: { type: string }, + } - { name: id, in: path, required: true, schema: { type: string } } responses: - "204": { description: Credential revoked } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } + '204': { description: Credential revoked } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } diff --git a/cli/package.json b/cli/package.json index 934fbd82..82a6a60e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@altertable/cli", "version": "1.12.0", - "description": "Altertable CLI — query and manage your data platform", + "description": "Altertable CLI \u2014 query and manage your data platform", "homepage": "https://github.com/altertable-ai/altertable-cli#readme", "bugs": { "url": "https://github.com/altertable-ai/altertable-cli/issues" @@ -21,7 +21,7 @@ "type": "module", "scripts": { "dev": "bun run src/cli.ts", - "typecheck": "tsc --noEmit", + "typecheck": "bun node_modules/typescript-checker/bin/tsc --noEmit", "lint": "oxlint --type-aware", "lint:fix": "oxlint --type-aware --fix", "format": "oxfmt", @@ -57,10 +57,11 @@ "eslint-plugin-no-relative-import-paths": "1.6.1", "knip": "6.32.2", "openapi-typescript": "7.13.0", - "oxfmt": "0.63.0", - "oxlint": "1.78.0", - "oxlint-tsgolint": "7.0.2001", - "typescript": "6.0.3" + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "typescript": "6.0.3", + "typescript-checker": "npm:typescript@7.0.2" }, "engines": { "bun": ">=1.1.0" diff --git a/cli/scripts/check-data-app.ts b/cli/scripts/check-data-app.ts index e51aa073..d10bf589 100644 --- a/cli/scripts/check-data-app.ts +++ b/cli/scripts/check-data-app.ts @@ -1,18 +1,19 @@ -import { join } from "node:path"; -import { dataAppDirectory } from "@/commands/app/lib/distribution.ts"; +import { join } from 'node:path'; +import { dataAppDirectory } from '@/commands/app/lib/distribution.ts'; -for (const project of ["starter", "tests"]) { +for (const project of ['starter', 'tests']) { const commands = [ - ["install", "--frozen-lockfile"], - ...["typecheck", "lint", "format:check"].map((name) => ["run", name]), - ...(project === "starter" ? [["run", "build"]] : []), + ['install', '--frozen-lockfile'], + ...['typecheck', 'lint', 'format:check'].map(name => ['run', name]), + ...(project === 'starter' ? [['run', 'build']] : []), ]; for (const args of commands) { const child = Bun.spawn([process.execPath, ...args], { cwd: join(dataAppDirectory, project), - stdout: "inherit", - stderr: "inherit", + stdout: 'inherit', + stderr: 'inherit', }); - if (await child.exited) throw new Error(`${project}: bun ${args.join(" ")} failed`); + if (await child.exited) + throw new Error(`${project}: bun ${args.join(' ')} failed`); } } diff --git a/cli/scripts/command-reference.test.ts b/cli/scripts/command-reference.test.ts index ba341f21..56adb75d 100644 --- a/cli/scripts/command-reference.test.ts +++ b/cli/scripts/command-reference.test.ts @@ -1,21 +1,24 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { buildMainCommand } from "@/cli.ts"; -import { resolveCommandDescriptor, validateCommandDescriptor } from "@/lib/command-descriptor.ts"; +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildMainCommand } from '@/cli.ts'; +import { + resolveCommandDescriptor, + validateCommandDescriptor, +} from '@/lib/command-descriptor.ts'; import { ALTERTABLE_COMMAND_GROUPS, COMMAND_ARGUMENT_TYPES, COMMAND_FLAG_SCOPES, POSITIONAL_COMPLETION_KINDS, -} from "@/lib/command.ts"; -import { VERSION } from "@/version.ts"; +} from '@/lib/command.ts'; +import { VERSION } from '@/version.ts'; import { buildCommandReference, renderCliReferenceSchema, renderCommandReferenceJson, renderCommandReferenceMarkdown, -} from "@/../scripts/command-reference.ts"; +} from '@/../scripts/command-reference.ts'; async function resolveReference() { const descriptor = await resolveCommandDescriptor(buildMainCommand()); @@ -53,74 +56,102 @@ type ReferenceSchema = { }; }; -describe("command reference", () => { - test("renders the canonical model into command documentation", async () => { +describe('command reference', () => { + test('renders the canonical model into command documentation', async () => { const reference = renderCommandReferenceMarkdown(await resolveReference()); expect(reference).toContain( - "```bash\naltertable query [options] \naltertable query show|cancel\n```", + '```bash\naltertable query [options] \naltertable query show|cancel\n```' + ); + expect(reference).toContain('```bash\naltertable profile env [NAME]\n```'); + expect(reference).toContain( + '```bash\naltertable completion generate \n```' ); - expect(reference).toContain("```bash\naltertable profile env [NAME]\n```"); - expect(reference).toContain("```bash\naltertable completion generate \n```"); - expect(reference).toContain("```bash\naltertable catalog create \n```"); - expect(reference).toContain("`-h, --help`"); - expect(reference).toContain("`-v, --version`"); - expect(reference).toContain("`--to ` | Destination as catalog.schema.table Required."); expect(reference).toContain( - "`-f, --raw-field ` | String request parameter key=value (repeatable; gh api -f semantics) Repeatable.", + '```bash\naltertable catalog create \n```' ); - expect(reference).not.toContain("altertable profile rename"); - expect(readFileSync(join(import.meta.dir, "../../COMMANDS.md"), "utf8")).toBe(reference); + expect(reference).toContain('`-h, --help`'); + expect(reference).toContain('`-v, --version`'); + expect(reference).toContain( + '`--to ` | Destination as catalog.schema.table Required.' + ); + expect(reference).toContain( + '`-f, --raw-field ` | String request parameter key=value (repeatable; gh api -f semantics) Repeatable.' + ); + expect(reference).not.toContain('altertable profile rename'); + expect( + readFileSync(join(import.meta.dir, '../../COMMANDS.md'), 'utf8') + ).toBe(reference); }); - test("serializes the same model into the versioned JSON contract", async () => { + test('serializes the same model into the versioned JSON contract', async () => { const reference = renderCommandReferenceJson(await resolveReference()); const parsed = JSON.parse(reference); const upload = parsed.groups - .find((group: { id: string }) => group.id === "ingest") - .commands.find((command: { id: string }) => command.id === "altertable-upload"); + .find((group: { id: string }) => group.id === 'ingest') + .commands.find( + (command: { id: string }) => command.id === 'altertable-upload' + ); expect(parsed).toMatchObject({ schemaVersion: 1, cliVersion: VERSION, globalOptions: expect.arrayContaining([ - expect.objectContaining({ name: "help", aliases: ["h"], scope: "global" }), + expect.objectContaining({ + name: 'help', + aliases: ['h'], + scope: 'global', + }), ]), }); expect(upload).toMatchObject({ - command: "altertable upload", - usage: ["altertable upload [options] "], + command: 'altertable upload', + usage: ['altertable upload [options] '], aliases: [], - arguments: [expect.objectContaining({ name: "file", required: true })], + arguments: [expect.objectContaining({ name: 'file', required: true })], subcommands: [], }); expect(reference).not.toContain('"rootDescription"'); expect(reference).not.toContain('"parserRequired"'); expect(reference).not.toContain('"requiredExplicitly"'); expect(reference).not.toContain('"rename"'); - expect(readFileSync(join(import.meta.dir, "../../cli-reference.json"), "utf8")).toBe(reference); + expect( + readFileSync(join(import.meta.dir, '../../cli-reference.json'), 'utf8') + ).toBe(reference); }); - test("renders the published schema from the CLI command contract", () => { + test('renders the published schema from the CLI command contract', () => { const renderedSchema = renderCliReferenceSchema(); const schema = JSON.parse(renderedSchema) as ReferenceSchema; - expect(readFileSync(join(import.meta.dir, "../../cli-reference.schema.json"), "utf8")).toBe( - renderedSchema, - ); + expect( + readFileSync( + join(import.meta.dir, '../../cli-reference.schema.json'), + 'utf8' + ) + ).toBe(renderedSchema); expect(schema.properties.schemaVersion.const).toBe(1); - expect(schema.$defs.argument.properties.type.enum).toEqual([...COMMAND_ARGUMENT_TYPES]); - expect(schema.$defs.argument.properties.scope.enum).toEqual([...COMMAND_FLAG_SCOPES]); + expect(schema.$defs.argument.properties.type.enum).toEqual([ + ...COMMAND_ARGUMENT_TYPES, + ]); + expect(schema.$defs.argument.properties.scope.enum).toEqual([ + ...COMMAND_FLAG_SCOPES, + ]); expect(schema.$defs.argument.properties.positionalCompletion.enum).toEqual([ ...POSITIONAL_COMPLETION_KINDS, ]); expect(schema.$defs.group.properties.id.enum).toEqual( - ALTERTABLE_COMMAND_GROUPS.map(({ id }) => id), + ALTERTABLE_COMMAND_GROUPS.map(({ id }) => id) ); expect(schema.additionalProperties).toBeFalse(); expect(schema.$defs.argument.additionalProperties).toBeFalse(); expect(schema.$defs.command.additionalProperties).toBeFalse(); expect(schema.$defs.group.additionalProperties).toBeFalse(); - expect(schema.required).toEqual(["schemaVersion", "cliVersion", "globalOptions", "groups"]); + expect(schema.required).toEqual([ + 'schemaVersion', + 'cliVersion', + 'globalOptions', + 'groups', + ]); }); }); diff --git a/cli/scripts/command-reference.ts b/cli/scripts/command-reference.ts index 92105e28..cc8d0c75 100644 --- a/cli/scripts/command-reference.ts +++ b/cli/scripts/command-reference.ts @@ -4,19 +4,19 @@ import { COMMAND_FLAG_SCOPES, POSITIONAL_COMPLETION_KINDS, type AltertableCommandGroup, -} from "@/lib/command.ts"; +} from '@/lib/command.ts'; import { aliasFlagToken, visibleCommandDescriptors, type CommandArgumentDescriptor, type CommandDescriptor, -} from "@/lib/command-descriptor.ts"; +} from '@/lib/command-descriptor.ts'; export const CLI_REFERENCE_SCHEMA_VERSION = 1; export type CliReferenceArgument = Omit< CommandArgumentDescriptor, - "parserRequired" | "requiredExplicitly" + 'parserRequired' | 'requiredExplicitly' >; export type CliReferenceCommand = { @@ -50,7 +50,8 @@ type CommandReferenceModel = CliReference & { function argumentValueLabel(argument: CliReferenceArgument): string { if (argument.valueHint) return argument.valueHint.toUpperCase(); - if (argument.values.length > 0) return argument.values.join("|").toUpperCase(); + if (argument.values.length > 0) + return argument.values.join('|').toUpperCase(); return argument.name.toUpperCase(); } @@ -62,10 +63,10 @@ function positionalToken(argument: CliReferenceArgument): string { function optionLabel(argument: CliReferenceArgument): string { const aliases = argument.aliases.map(aliasFlagToken); const value = - argument.type === "string" || argument.type === "enum" + argument.type === 'string' || argument.type === 'enum' ? ` <${argumentValueLabel(argument)}>` - : ""; - return [...aliases, `--${argument.name}${value}`].join(", "); + : ''; + return [...aliases, `--${argument.name}${value}`].join(', '); } function projectArgument({ @@ -80,23 +81,32 @@ function buildUsage( descriptor: CommandDescriptor, commandPath: readonly string[], positionals: readonly CliReferenceArgument[], - hasOptions: boolean, + hasOptions: boolean ): string[] { const subcommands = visibleCommandDescriptors(descriptor.subcommands); const usage: string[] = []; - if (descriptor.metadata.invocations.includes("direct")) { + if (descriptor.metadata.invocations.includes('direct')) { usage.push( - [...commandPath, hasOptions ? "[options]" : undefined, ...positionals.map(positionalToken)] + [ + ...commandPath, + hasOptions ? '[options]' : undefined, + ...positionals.map(positionalToken), + ] .filter((token): token is string => token !== undefined) - .join(" "), + .join(' ') ); } - if (descriptor.metadata.invocations.includes("subcommand") && subcommands.length > 0) { + if ( + descriptor.metadata.invocations.includes('subcommand') && + subcommands.length > 0 + ) { usage.push( - `${commandPath.join(" ")} ${subcommands - .map((subcommand) => subcommand.metadata.name ?? subcommand.key ?? "command") - .join("|")}`, + `${commandPath.join(' ')} ${subcommands + .map( + subcommand => subcommand.metadata.name ?? subcommand.key ?? 'command' + ) + .join('|')}` ); } @@ -105,34 +115,38 @@ function buildUsage( function buildCommand( descriptor: CommandDescriptor, - parentPath: readonly string[], + parentPath: readonly string[] ): CliReferenceCommand { - const name = descriptor.metadata.name ?? descriptor.key ?? "command"; + const name = descriptor.metadata.name ?? descriptor.key ?? 'command'; const commandPath = [...parentPath, name]; const commandArguments = descriptor.arguments.map(projectArgument); - const positionals = commandArguments.filter((argument) => argument.type === "positional"); - const options = commandArguments.filter((argument) => argument.type !== "positional"); + const positionals = commandArguments.filter( + argument => argument.type === 'positional' + ); + const options = commandArguments.filter( + argument => argument.type !== 'positional' + ); return { - id: commandPath.join("-"), - command: commandPath.join(" "), + id: commandPath.join('-'), + command: commandPath.join(' '), description: descriptor.metadata.description, usage: buildUsage(descriptor, commandPath, positionals, options.length > 0), aliases: descriptor.metadata.aliases, arguments: positionals, options, examples: descriptor.metadata.examples, - subcommands: visibleCommandDescriptors(descriptor.subcommands).map((subcommand) => - buildCommand(subcommand, commandPath), + subcommands: visibleCommandDescriptors(descriptor.subcommands).map( + subcommand => buildCommand(subcommand, commandPath) ), }; } export function buildCommandReference( root: CommandDescriptor, - cliVersion: string, + cliVersion: string ): CommandReferenceModel { - const rootName = root.metadata.name ?? "altertable"; + const rootName = root.metadata.name ?? 'altertable'; const topLevelCommands = visibleCommandDescriptors(root.subcommands); return { @@ -140,99 +154,106 @@ export function buildCommandReference( cliVersion, rootDescription: root.metadata.description, globalOptions: root.arguments - .filter((argument) => argument.type !== "positional") + .filter(argument => argument.type !== 'positional') .map(projectArgument), groups: ALTERTABLE_COMMAND_GROUPS.map(({ id, title }) => ({ id, title, commands: topLevelCommands - .filter((command) => command.metadata.commandGroup === id) - .map((command) => buildCommand(command, [rootName])), - })).filter((group) => group.commands.length > 0), + .filter(command => command.metadata.commandGroup === id) + .map(command => buildCommand(command, [rootName])), + })).filter(group => group.commands.length > 0), }; } function escapeTableCell(value: string): string { - return value.replaceAll("|", "\\|").replace(/\s+/g, " ").trim(); + return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim(); } function argumentDescription(argument: CliReferenceArgument): string { const details = [argument.description]; - if (argument.scope === "root-only") { - details.push("Scope: root only."); + if (argument.scope === 'root-only') { + details.push('Scope: root only.'); } if (argument.required) { - details.push("Required."); + details.push('Required.'); } if (argument.repeatable) { - details.push("Repeatable."); + details.push('Repeatable.'); } if (argument.values.length > 0) { - details.push(`Values: ${argument.values.join(", ")}.`); + details.push(`Values: ${argument.values.join(', ')}.`); } if (argument.default !== undefined) { details.push(`Default: ${JSON.stringify(argument.default)}.`); } - return details.filter(Boolean).join(" "); + return details.filter(Boolean).join(' '); } function renderArgumentTable( title: string, commandArguments: readonly CliReferenceArgument[], - label: (argument: CliReferenceArgument) => string, + label: (argument: CliReferenceArgument) => string ): string[] { if (commandArguments.length === 0) return []; return [ `**${title}**`, - "", - `| ${title === "Arguments" ? "Argument" : "Option"} | Description |`, - "| --- | --- |", + '', + `| ${title === 'Arguments' ? 'Argument' : 'Option'} | Description |`, + '| --- | --- |', ...commandArguments.map( - (argument) => - `| \`${escapeTableCell(label(argument))}\` | ${escapeTableCell(argumentDescription(argument))} |`, + argument => + `| \`${escapeTableCell(label(argument))}\` | ${escapeTableCell(argumentDescription(argument))} |` ), - "", + '', ]; } function commandName(command: CliReferenceCommand): string { - return command.command.slice(command.command.lastIndexOf(" ") + 1); + return command.command.slice(command.command.lastIndexOf(' ') + 1); } -function renderCommand(command: CliReferenceCommand, headingLevel: number): string[] { +function renderCommand( + command: CliReferenceCommand, + headingLevel: number +): string[] { const lines = [ - `${"#".repeat(headingLevel)} \`${command.command}\``, - "", + `${'#'.repeat(headingLevel)} \`${command.command}\``, + '', command.description, - "", - "**Usage**", - "", - "```bash", + '', + '**Usage**', + '', + '```bash', ...command.usage, - "```", - "", + '```', + '', ]; if (command.aliases.length > 0) { - lines.push(`**Aliases:** ${command.aliases.map((alias) => `\`${alias}\``).join(", ")}`, ""); + lines.push( + `**Aliases:** ${command.aliases.map(alias => `\`${alias}\``).join(', ')}`, + '' + ); } lines.push( - ...renderArgumentTable("Arguments", command.arguments, positionalToken), - ...renderArgumentTable("Options", command.options, optionLabel), + ...renderArgumentTable('Arguments', command.arguments, positionalToken), + ...renderArgumentTable('Options', command.options, optionLabel) ); if (command.subcommands.length > 0) { lines.push( - "**Subcommands**", - "", + '**Subcommands**', + '', ...command.subcommands.map( - (subcommand) => `- \`${commandName(subcommand)}\` — ${subcommand.description}`, + subcommand => + `- \`${commandName(subcommand)}\` — ${subcommand.description}` ), - "", + '' ); } if (command.examples.length > 0) { - lines.push("**Examples**", "", "```bash", ...command.examples, "```", ""); + lines.push('**Examples**', '', '```bash', ...command.examples, '```', ''); } for (const subcommand of command.subcommands) { lines.push(...renderCommand(subcommand, Math.min(headingLevel + 1, 6))); @@ -240,110 +261,118 @@ function renderCommand(command: CliReferenceCommand, headingLevel: number): stri return lines; } -export function renderCommandReferenceMarkdown(reference: CommandReferenceModel): string { +export function renderCommandReferenceMarkdown( + reference: CommandReferenceModel +): string { const lines = [ - "", - "", - "# Altertable CLI command reference", - "", + '', + '', + '# Altertable CLI command reference', + '', `${reference.rootDescription} `, - "", - ...renderArgumentTable("Global options", reference.globalOptions, optionLabel), + '', + ...renderArgumentTable( + 'Global options', + reference.globalOptions, + optionLabel + ), ]; for (const group of reference.groups) { - lines.push(`## ${group.title}`, ""); + lines.push(`## ${group.title}`, ''); for (const command of group.commands) { lines.push(...renderCommand(command, 3)); } } - return `${lines.join("\n").trim()}\n`; + return `${lines.join('\n').trim()}\n`; } -export function renderCommandReferenceJson(reference: CommandReferenceModel): string { +export function renderCommandReferenceJson( + reference: CommandReferenceModel +): string { const { rootDescription: _rootDescription, ...publicReference } = reference; return `${JSON.stringify(publicReference, null, 2)}\n`; } export function renderCliReferenceSchema(): string { const schema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://altertable.ai/schemas/cli-reference/v1.json", - title: "Altertable CLI reference", + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://altertable.ai/schemas/cli-reference/v1.json', + title: 'Altertable CLI reference', description: - "Versioned public documentation contract generated from the Altertable CLI command descriptor.", - type: "object", + 'Versioned public documentation contract generated from the Altertable CLI command descriptor.', + type: 'object', additionalProperties: false, - required: ["schemaVersion", "cliVersion", "globalOptions", "groups"], + required: ['schemaVersion', 'cliVersion', 'globalOptions', 'groups'], properties: { schemaVersion: { const: CLI_REFERENCE_SCHEMA_VERSION }, - cliVersion: { type: "string" }, - globalOptions: { type: "array", items: { $ref: "#/$defs/argument" } }, - groups: { type: "array", items: { $ref: "#/$defs/group" } }, + cliVersion: { type: 'string' }, + globalOptions: { type: 'array', items: { $ref: '#/$defs/argument' } }, + groups: { type: 'array', items: { $ref: '#/$defs/group' } }, }, $defs: { argument: { - type: "object", + type: 'object', additionalProperties: false, required: [ - "name", - "aliases", - "type", - "description", - "required", - "repeatable", - "scope", - "values", + 'name', + 'aliases', + 'type', + 'description', + 'required', + 'repeatable', + 'scope', + 'values', ], properties: { - name: { type: "string" }, - aliases: { type: "array", items: { type: "string" } }, + name: { type: 'string' }, + aliases: { type: 'array', items: { type: 'string' } }, type: { enum: COMMAND_ARGUMENT_TYPES }, - description: { type: "string" }, - required: { type: "boolean" }, - repeatable: { type: "boolean" }, + description: { type: 'string' }, + required: { type: 'boolean' }, + repeatable: { type: 'boolean' }, scope: { enum: COMMAND_FLAG_SCOPES }, - values: { type: "array", items: { type: "string" } }, + values: { type: 'array', items: { type: 'string' } }, positionalCompletion: { enum: POSITIONAL_COMPLETION_KINDS }, - valueHint: { type: "string" }, + valueHint: { type: 'string' }, default: {}, }, }, command: { - type: "object", + type: 'object', additionalProperties: false, required: [ - "id", - "command", - "description", - "usage", - "aliases", - "arguments", - "options", - "examples", - "subcommands", + 'id', + 'command', + 'description', + 'usage', + 'aliases', + 'arguments', + 'options', + 'examples', + 'subcommands', ], properties: { - id: { type: "string" }, - command: { type: "string" }, - description: { type: "string" }, - usage: { type: "array", items: { type: "string" } }, - aliases: { type: "array", items: { type: "string" } }, - arguments: { type: "array", items: { $ref: "#/$defs/argument" } }, - options: { type: "array", items: { $ref: "#/$defs/argument" } }, - examples: { type: "array", items: { type: "string" } }, - subcommands: { type: "array", items: { $ref: "#/$defs/command" } }, + id: { type: 'string' }, + command: { type: 'string' }, + description: { type: 'string' }, + usage: { type: 'array', items: { type: 'string' } }, + aliases: { type: 'array', items: { type: 'string' } }, + arguments: { type: 'array', items: { $ref: '#/$defs/argument' } }, + options: { type: 'array', items: { $ref: '#/$defs/argument' } }, + examples: { type: 'array', items: { type: 'string' } }, + subcommands: { type: 'array', items: { $ref: '#/$defs/command' } }, }, }, group: { - type: "object", + type: 'object', additionalProperties: false, - required: ["id", "title", "commands"], + required: ['id', 'title', 'commands'], properties: { id: { enum: ALTERTABLE_COMMAND_GROUPS.map(({ id }) => id) }, - title: { type: "string" }, - commands: { type: "array", items: { $ref: "#/$defs/command" } }, + title: { type: 'string' }, + commands: { type: 'array', items: { $ref: '#/$defs/command' } }, }, }, }, diff --git a/cli/scripts/generate-command-reference.ts b/cli/scripts/generate-command-reference.ts index c1c961c6..737e2004 100644 --- a/cli/scripts/generate-command-reference.ts +++ b/cli/scripts/generate-command-reference.ts @@ -1,17 +1,20 @@ -import { join } from "node:path"; -import { buildMainCommand } from "@/cli.ts"; -import { resolveCommandDescriptor, validateCommandDescriptor } from "@/lib/command-descriptor.ts"; -import { VERSION } from "@/version.ts"; +import { join } from 'node:path'; +import { buildMainCommand } from '@/cli.ts'; +import { + resolveCommandDescriptor, + validateCommandDescriptor, +} from '@/lib/command-descriptor.ts'; +import { VERSION } from '@/version.ts'; import { buildCommandReference, renderCliReferenceSchema, renderCommandReferenceJson, renderCommandReferenceMarkdown, -} from "@/../scripts/command-reference.ts"; +} from '@/../scripts/command-reference.ts'; import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, -} from "@/../scripts/generated-artifact.ts"; +} from '@/../scripts/generated-artifact.ts'; const descriptor = await resolveCommandDescriptor(buildMainCommand()); validateCommandDescriptor(descriptor); @@ -19,15 +22,15 @@ const reference = buildCommandReference(descriptor, VERSION); const mode = parseGeneratedArtifactMode(process.argv.slice(2)); const artifacts = [ { - outputPath: join(import.meta.dir, "../../COMMANDS.md"), + outputPath: join(import.meta.dir, '../../COMMANDS.md'), content: renderCommandReferenceMarkdown(reference), }, { - outputPath: join(import.meta.dir, "../../cli-reference.json"), + outputPath: join(import.meta.dir, '../../cli-reference.json'), content: renderCommandReferenceJson(reference), }, { - outputPath: join(import.meta.dir, "../../cli-reference.schema.json"), + outputPath: join(import.meta.dir, '../../cli-reference.schema.json'), content: renderCliReferenceSchema(), }, ]; @@ -36,7 +39,9 @@ for (const artifact of artifacts) { updateOrCheckGeneratedArtifact({ ...artifact, mode, - generateCommand: "bun run generate:commands", + generateCommand: 'bun run generate:commands', }); - console.log(`${mode === "check" ? "Checked" : "Wrote"} ${artifact.outputPath}`); + console.log( + `${mode === 'check' ? 'Checked' : 'Wrote'} ${artifact.outputPath}` + ); } diff --git a/cli/scripts/generate-openapi-index.ts b/cli/scripts/generate-openapi-index.ts index 654bc482..bbbc45e7 100644 --- a/cli/scripts/generate-openapi-index.ts +++ b/cli/scripts/generate-openapi-index.ts @@ -1,23 +1,31 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, -} from "@/../scripts/generated-artifact.ts"; +} from '@/../scripts/generated-artifact.ts'; -const HTTP_METHODS = ["get", "post", "patch", "delete", "put"] as const; +const HTTP_METHODS = ['get', 'post', 'patch', 'delete', 'put'] as const; type OpenapiDocument = { paths?: Record< string, - Partial> + Partial< + Record< + (typeof HTTP_METHODS)[number], + { operationId?: string; summary?: string } + > + > >; }; -const specPath = join(import.meta.dir, "../openapi/openapi.yaml"); -const outputPath = join(import.meta.dir, "../src/generated/openapi-operations.ts"); +const specPath = join(import.meta.dir, '../openapi/openapi.yaml'); +const outputPath = join( + import.meta.dir, + '../src/generated/openapi-operations.ts' +); -const specText = readFileSync(specPath, "utf8"); +const specText = readFileSync(specPath, 'utf8'); const document = Bun.YAML.parse(specText) as OpenapiDocument; type OpenapiOperationEntry = { @@ -42,7 +50,7 @@ for (const [path, pathItem] of Object.entries(document.paths ?? {})) { operationId: operation.operationId, method: method.toUpperCase(), path, - summary: operation.summary ?? "", + summary: operation.summary ?? '', }); } } @@ -56,8 +64,8 @@ operations.sort((left, right) => { }); const lines = operations.map( - (operation) => - ` { operationId: ${JSON.stringify(operation.operationId)}, method: ${JSON.stringify(operation.method)}, path: ${JSON.stringify(operation.path)}, summary: ${JSON.stringify(operation.summary)} },`, + operation => + ` { operationId: ${JSON.stringify(operation.operationId)}, method: ${JSON.stringify(operation.method)}, path: ${JSON.stringify(operation.path)}, summary: ${JSON.stringify(operation.summary)} },` ); const output = `/* AUTO-GENERATED by scripts/generate-openapi-index.ts — do not edit */ @@ -70,12 +78,12 @@ export type OpenapiOperation = { }; export const OPENAPI_OPERATIONS: OpenapiOperation[] = [ -${lines.join("\n")} +${lines.join('\n')} ]; `; const mode = parseGeneratedArtifactMode(process.argv.slice(2)); updateOrCheckGeneratedArtifact({ outputPath, content: output, mode }); console.log( - `${mode === "check" ? "Checked" : "Wrote"} ${outputPath} (${operations.length} operations)`, + `${mode === 'check' ? 'Checked' : 'Wrote'} ${outputPath} (${operations.length} operations)` ); diff --git a/cli/scripts/generate-openapi-types.ts b/cli/scripts/generate-openapi-types.ts index 4b8b60bd..b8c1ff87 100644 --- a/cli/scripts/generate-openapi-types.ts +++ b/cli/scripts/generate-openapi-types.ts @@ -1,15 +1,15 @@ -import openapiTS, { astToString } from "openapi-typescript"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import openapiTS, { astToString } from 'openapi-typescript'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, -} from "@/../scripts/generated-artifact.ts"; +} from '@/../scripts/generated-artifact.ts'; -const specPath = join(import.meta.dir, "../openapi/openapi.yaml"); -const outputPath = join(import.meta.dir, "../src/generated/openapi-types.ts"); +const specPath = join(import.meta.dir, '../openapi/openapi.yaml'); +const outputPath = join(import.meta.dir, '../src/generated/openapi-types.ts'); -const spec = readFileSync(specPath, "utf8"); +const spec = readFileSync(specPath, 'utf8'); const ast = await openapiTS(spec); const types = astToString(ast); const mode = parseGeneratedArtifactMode(process.argv.slice(2)); @@ -18,4 +18,4 @@ updateOrCheckGeneratedArtifact({ content: `/* AUTO-GENERATED by scripts/generate-openapi-types.ts — do not edit */\n\n${types}\n`, mode, }); -console.log(`${mode === "check" ? "Checked" : "Wrote"} ${outputPath}`); +console.log(`${mode === 'check' ? 'Checked' : 'Wrote'} ${outputPath}`); diff --git a/cli/scripts/generated-artifact.test.ts b/cli/scripts/generated-artifact.test.ts index 4937dac3..add2e617 100644 --- a/cli/scripts/generated-artifact.test.ts +++ b/cli/scripts/generated-artifact.test.ts @@ -1,18 +1,20 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, -} from "@/../scripts/generated-artifact.ts"; +} from '@/../scripts/generated-artifact.ts'; const testDirectories: string[] = []; function createTestArtifactPath(): string { - const directory = mkdtempSync(join(tmpdir(), "altertable-generated-artifact-")); + const directory = mkdtempSync( + join(tmpdir(), 'altertable-generated-artifact-') + ); testDirectories.push(directory); - return join(directory, "artifact.txt"); + return join(directory, 'artifact.txt'); } afterEach(() => { @@ -21,48 +23,56 @@ afterEach(() => { } }); -describe("generated artifact synchronization", () => { - test("selects check mode explicitly", () => { - expect(parseGeneratedArtifactMode([])).toBe("write"); - expect(parseGeneratedArtifactMode(["--check"])).toBe("check"); +describe('generated artifact synchronization', () => { + test('selects check mode explicitly', () => { + expect(parseGeneratedArtifactMode([])).toBe('write'); + expect(parseGeneratedArtifactMode(['--check'])).toBe('check'); }); - test("writes generated content", () => { + test('writes generated content', () => { const outputPath = createTestArtifactPath(); - updateOrCheckGeneratedArtifact({ outputPath, content: "generated\n", mode: "write" }); + updateOrCheckGeneratedArtifact({ + outputPath, + content: 'generated\n', + mode: 'write', + }); - expect(readFileSync(outputPath, "utf8")).toBe("generated\n"); + expect(readFileSync(outputPath, 'utf8')).toBe('generated\n'); }); - test("checks current content without writing", () => { + test('checks current content without writing', () => { const outputPath = createTestArtifactPath(); - writeFileSync(outputPath, "generated\n"); + writeFileSync(outputPath, 'generated\n'); - updateOrCheckGeneratedArtifact({ outputPath, content: "generated\n", mode: "check" }); + updateOrCheckGeneratedArtifact({ + outputPath, + content: 'generated\n', + mode: 'check', + }); - expect(readFileSync(outputPath, "utf8")).toBe("generated\n"); + expect(readFileSync(outputPath, 'utf8')).toBe('generated\n'); }); - test("reports stale and missing output with regeneration guidance", () => { + test('reports stale and missing output with regeneration guidance', () => { const stalePath = createTestArtifactPath(); - writeFileSync(stalePath, "old\n"); + writeFileSync(stalePath, 'old\n'); expect(() => updateOrCheckGeneratedArtifact({ outputPath: stalePath, - content: "new\n", - mode: "check", - generateCommand: "bun run generate:commands", - }), - ).toThrow("Run: bun run generate:commands"); + content: 'new\n', + mode: 'check', + generateCommand: 'bun run generate:commands', + }) + ).toThrow('Run: bun run generate:commands'); expect(() => updateOrCheckGeneratedArtifact({ outputPath: createTestArtifactPath(), - content: "new\n", - mode: "check", - }), - ).toThrow("Generated artifact is stale"); + content: 'new\n', + mode: 'check', + }) + ).toThrow('Generated artifact is stale'); }); }); diff --git a/cli/scripts/generated-artifact.ts b/cli/scripts/generated-artifact.ts index c6f88c35..af598abe 100644 --- a/cli/scripts/generated-artifact.ts +++ b/cli/scripts/generated-artifact.ts @@ -1,6 +1,6 @@ -import { readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from 'node:fs'; -export type GeneratedArtifactMode = "write" | "check"; +export type GeneratedArtifactMode = 'write' | 'check'; type GeneratedArtifactOptions = { outputPath: string; @@ -9,28 +9,32 @@ type GeneratedArtifactOptions = { generateCommand?: string; }; -export function parseGeneratedArtifactMode(argv: readonly string[]): GeneratedArtifactMode { - return argv.includes("--check") ? "check" : "write"; +export function parseGeneratedArtifactMode( + argv: readonly string[] +): GeneratedArtifactMode { + return argv.includes('--check') ? 'check' : 'write'; } export function updateOrCheckGeneratedArtifact({ outputPath, content, mode, - generateCommand = "bun run generate", + generateCommand = 'bun run generate', }: GeneratedArtifactOptions): void { - if (mode === "write") { + if (mode === 'write') { writeFileSync(outputPath, content); return; } let current: string | undefined; try { - current = readFileSync(outputPath, "utf8"); + current = readFileSync(outputPath, 'utf8'); } catch { // Missing output is reported as stale with the same remediation. } if (current !== content) { - throw new Error(`Generated artifact is stale: ${outputPath}\nRun: ${generateCommand}`); + throw new Error( + `Generated artifact is stale: ${outputPath}\nRun: ${generateCommand}` + ); } } diff --git a/cli/scripts/npm-bundle.ts b/cli/scripts/npm-bundle.ts index 169d8a25..f7bd4754 100644 --- a/cli/scripts/npm-bundle.ts +++ b/cli/scripts/npm-bundle.ts @@ -1,18 +1,20 @@ -import { mkdir } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; -import { dataAppPlugin } from "@/../scripts/package-data-app.ts"; +import { mkdir } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { dataAppPlugin } from '@/../scripts/package-data-app.ts'; export const NPM_BUNDLE_OPTIONS = { - target: "bun", + target: 'bun', minify: false, - sourcemap: "none", + sourcemap: 'none', } as const; -const repositoryRoot = join(import.meta.dir, "../.."); -const entrypoint = join(repositoryRoot, "cli/src/cli.ts"); -export const NPM_BUNDLE_PATH = join(repositoryRoot, "cli/dist/cli.js"); +const repositoryRoot = join(import.meta.dir, '../..'); +const entrypoint = join(repositoryRoot, 'cli/src/cli.ts'); +export const NPM_BUNDLE_PATH = join(repositoryRoot, 'cli/dist/cli.js'); -export async function buildNpmBundle(outputPath = NPM_BUNDLE_PATH): Promise { +export async function buildNpmBundle( + outputPath = NPM_BUNDLE_PATH +): Promise { await mkdir(dirname(outputPath), { recursive: true }); const result = await Bun.build({ entrypoints: [entrypoint], @@ -22,7 +24,9 @@ export async function buildNpmBundle(outputPath = NPM_BUNDLE_PATH): Promise ({ - contents: `export const dataAppPayload = ${JSON.stringify(await readDataAppPayload())};`, - loader: "js", - })); + build.onLoad( + { filter: /[/\\]commands[/\\]app[/\\]lib[/\\]payload\.ts$/ }, + async () => ({ + contents: `export const dataAppPayload = ${JSON.stringify(await readDataAppPayload())};`, + loader: 'js', + }) + ); }, }; } diff --git a/cli/scripts/publish-release.ts b/cli/scripts/publish-release.ts index 4a4c8d62..0bfabb3d 100644 --- a/cli/scripts/publish-release.ts +++ b/cli/scripts/publish-release.ts @@ -1,12 +1,14 @@ -import { basename, join, resolve } from "node:path"; -import { isRecord } from "../src/lib/object.ts"; +import { basename, join, resolve } from 'node:path'; +import { isRecord } from '../src/lib/object.ts'; -export type NpmVersionLookup = { status: "published"; version: string } | { status: "missing" }; +export type NpmVersionLookup = + | { status: 'published'; version: string } + | { status: 'missing' }; -export const MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION = "11.5.1"; -export const SETUP_NODE_AUTH_TOKEN_PLACEHOLDER = "XXXXX-XXXXX-XXXXX-XXXXX"; -export const RELEASE_METADATA_FILE = "release-manifest.json"; -export const RELEASE_CHECKSUMS_FILE = "checksums.txt"; +export const MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION = '11.5.1'; +export const SETUP_NODE_AUTH_TOKEN_PLACEHOLDER = 'XXXXX-XXXXX-XXXXX-XXXXX'; +export const RELEASE_METADATA_FILE = 'release-manifest.json'; +export const RELEASE_CHECKSUMS_FILE = 'checksums.txt'; type PackageIdentity = { name: string; @@ -15,24 +17,31 @@ type PackageIdentity = { function parseVersion(version: string): [number, number, number] { const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()); - if (!match) throw new Error(`Invalid npm version: ${version.trim() || ""}.`); + if (!match) + throw new Error(`Invalid npm version: ${version.trim() || ''}.`); return [Number(match[1]), Number(match[2]), Number(match[3])]; } export function assertTrustedPublishingEnvironment( environment: Record, - npmVersion: string, + npmVersion: string ): void { // setup-node exports this non-secret sentinel when registry-url is configured. const nodeAuthToken = environment.NODE_AUTH_TOKEN; const hasLongLivedNodeAuthToken = - Boolean(nodeAuthToken) && nodeAuthToken !== SETUP_NODE_AUTH_TOKEN_PLACEHOLDER; + Boolean(nodeAuthToken) && + nodeAuthToken !== SETUP_NODE_AUTH_TOKEN_PLACEHOLDER; if (hasLongLivedNodeAuthToken || environment.NPM_TOKEN) { - throw new Error("npm publishing must use OIDC; remove long-lived npm publish tokens."); + throw new Error( + 'npm publishing must use OIDC; remove long-lived npm publish tokens.' + ); } - if (!environment.ACTIONS_ID_TOKEN_REQUEST_URL || !environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + if ( + !environment.ACTIONS_ID_TOKEN_REQUEST_URL || + !environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) { throw new Error( - "npm trusted publishing requires GitHub Actions OIDC credentials (id-token: write).", + 'npm trusted publishing requires GitHub Actions OIDC credentials (id-token: write).' ); } @@ -42,7 +51,7 @@ export function assertTrustedPublishingEnvironment( if (actual[index]! > minimum[index]!) return; if (actual[index]! < minimum[index]!) { throw new Error( - `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}; received ${npmVersion.trim()}.`, + `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}; received ${npmVersion.trim()}.` ); } } @@ -51,73 +60,110 @@ export function assertTrustedPublishingEnvironment( export function parseNpmVersionLookup( exitCode: number, stdout: string, - stderr: string, + stderr: string ): NpmVersionLookup { if (exitCode === 0) { const version = JSON.parse(stdout) as unknown; - if (typeof version !== "string") { - throw new Error(`npm view returned an invalid version response: ${stdout.trim()}`); + if (typeof version !== 'string') { + throw new Error( + `npm view returned an invalid version response: ${stdout.trim()}` + ); } - return { status: "published", version }; + return { status: 'published', version }; } if (/\bE404\b|404 Not Found/i.test(`${stdout}\n${stderr}`)) { - return { status: "missing" }; + return { status: 'missing' }; } - throw new Error(`npm view failed (${exitCode}): ${stderr.trim() || stdout.trim()}`); + throw new Error( + `npm view failed (${exitCode}): ${stderr.trim() || stdout.trim()}` + ); } -export function npmPublicationRequired(lookup: NpmVersionLookup, expectedVersion: string): boolean { - if (lookup.status === "missing") return true; +export function npmPublicationRequired( + lookup: NpmVersionLookup, + expectedVersion: string +): boolean { + if (lookup.status === 'missing') return true; if (lookup.version !== expectedVersion) { - throw new Error(`npm returned version ${lookup.version}; expected ${expectedVersion}.`); + throw new Error( + `npm returned version ${lookup.version}; expected ${expectedVersion}.` + ); } return false; } -export function parsePackageIdentity(value: unknown, releaseTag: string): PackageIdentity { - if (!isRecord(value) || typeof value.name !== "string" || typeof value.version !== "string") { - throw new Error("Release package.json must contain string name and version fields."); +export function parsePackageIdentity( + value: unknown, + releaseTag: string +): PackageIdentity { + if ( + !isRecord(value) || + typeof value.name !== 'string' || + typeof value.version !== 'string' + ) { + throw new Error( + 'Release package.json must contain string name and version fields.' + ); } if (releaseTag !== `v${value.version}`) { - throw new Error(`Release tag ${releaseTag} does not match package version ${value.version}.`); + throw new Error( + `Release tag ${releaseTag} does not match package version ${value.version}.` + ); } return { name: value.name, version: value.version }; } -export function releaseAssetNames(value: unknown, releaseTag: string): string[] { - if (!isRecord(value) || value.tag !== releaseTag || !Array.isArray(value.artifacts)) { - throw new Error(`Release metadata must describe ${releaseTag} and contain an artifact list.`); +export function releaseAssetNames( + value: unknown, + releaseTag: string +): string[] { + if ( + !isRecord(value) || + value.tag !== releaseTag || + !Array.isArray(value.artifacts) + ) { + throw new Error( + `Release metadata must describe ${releaseTag} and contain an artifact list.` + ); } const names = value.artifacts.map((artifact, index) => { - if (!isRecord(artifact) || typeof artifact.file !== "string") { - throw new Error(`Release metadata artifact ${index} is missing its file name.`); + if (!isRecord(artifact) || typeof artifact.file !== 'string') { + throw new Error( + `Release metadata artifact ${index} is missing its file name.` + ); } if ( artifact.file.length === 0 || - artifact.file.includes("\\") || + artifact.file.includes('\\') || basename(artifact.file) !== artifact.file || - artifact.file === "." || - artifact.file === ".." + artifact.file === '.' || + artifact.file === '..' ) { throw new Error(`Unsafe release asset name: ${artifact.file}.`); } return artifact.file; }); - const completeNames = [...names, RELEASE_METADATA_FILE, RELEASE_CHECKSUMS_FILE]; + const completeNames = [ + ...names, + RELEASE_METADATA_FILE, + RELEASE_CHECKSUMS_FILE, + ]; if (new Set(completeNames).size !== completeNames.length) { - throw new Error("Release metadata contains duplicate asset names."); + throw new Error('Release metadata contains duplicate asset names.'); } return completeNames; } export async function readReleaseAssetPaths( releaseDirectory: string, - releaseTag: string, + releaseTag: string ): Promise { const metadataPath = join(releaseDirectory, RELEASE_METADATA_FILE); const metadata = (await Bun.file(metadataPath).json()) as unknown; - const paths = releaseAssetNames(metadata, releaseTag).map((name) => join(releaseDirectory, name)); + const paths = releaseAssetNames(metadata, releaseTag).map(name => + join(releaseDirectory, name) + ); for (const path of paths) { const file = Bun.file(path); if (!(await file.exists()) || file.size === 0) { @@ -127,31 +173,38 @@ export async function readReleaseAssetPaths( return paths; } -export function githubReleaseUploadCommand(tag: string, assetPaths: string[]): string[] { - return ["gh", "release", "upload", tag, ...assetPaths, "--clobber"]; +export function githubReleaseUploadCommand( + tag: string, + assetPaths: string[] +): string[] { + return ['gh', 'release', 'upload', tag, ...assetPaths, '--clobber']; } export function githubReleasePublishCommand(tag: string): string[] { - return ["gh", "release", "edit", tag, "--draft=false", "--latest"]; + return ['gh', 'release', 'edit', tag, '--draft=false', '--latest']; } async function run(command: string[], cwd: string): Promise { - const child = Bun.spawn(command, { cwd, stdout: "inherit", stderr: "inherit" }); + const child = Bun.spawn(command, { + cwd, + stdout: 'inherit', + stderr: 'inherit', + }); const exitCode = await child.exited; if (exitCode !== 0) { - throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`); } } async function runCapture( command: string[], - cwd: string, + cwd: string ): Promise<{ exitCode: number; stdout: string; stderr: string; }> { - const child = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" }); + const child = Bun.spawn(command, { cwd, stdout: 'pipe', stderr: 'pipe' }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), @@ -160,57 +213,70 @@ async function runCapture( return { exitCode, stdout, stderr }; } -async function publishNpmIfMissing(releaseRoot: string, releaseTag: string): Promise { - const packageDirectory = join(releaseRoot, "cli"); +async function publishNpmIfMissing( + releaseRoot: string, + releaseTag: string +): Promise { + const packageDirectory = join(releaseRoot, 'cli'); const packageIdentity = parsePackageIdentity( - (await Bun.file(join(packageDirectory, "package.json")).json()) as unknown, - releaseTag, + (await Bun.file(join(packageDirectory, 'package.json')).json()) as unknown, + releaseTag + ); + const npmVersionResult = await runCapture( + ['npm', '--version'], + packageDirectory ); - const npmVersionResult = await runCapture(["npm", "--version"], packageDirectory); if (npmVersionResult.exitCode !== 0) { throw new Error( - `npm --version failed (${npmVersionResult.exitCode}): ${npmVersionResult.stderr.trim() || npmVersionResult.stdout.trim()}`, + `npm --version failed (${npmVersionResult.exitCode}): ${npmVersionResult.stderr.trim() || npmVersionResult.stdout.trim()}` ); } assertTrustedPublishingEnvironment(Bun.env, npmVersionResult.stdout); const specification = `${packageIdentity.name}@${packageIdentity.version}`; const lookupResult = await runCapture( - ["npm", "view", specification, "version", "--json"], - packageDirectory, + ['npm', 'view', specification, 'version', '--json'], + packageDirectory ); const lookup = parseNpmVersionLookup( lookupResult.exitCode, lookupResult.stdout, - lookupResult.stderr, + lookupResult.stderr ); if (!npmPublicationRequired(lookup, packageIdentity.version)) { console.log(`${specification} is already published; continuing.`); return; } - await run(["npm", "publish", "--access", "public"], packageDirectory); + await run(['npm', 'publish', '--access', 'public'], packageDirectory); } async function main(arguments_: string[]): Promise { const command = arguments_[0]; - const releaseRoot = resolve(process.env.RELEASE_ROOT?.trim() || join(import.meta.dir, "../..")); + const releaseRoot = resolve( + process.env.RELEASE_ROOT?.trim() || join(import.meta.dir, '../..') + ); const releaseTag = process.env.RELEASE_TAG?.trim(); - if (!releaseTag) throw new Error("RELEASE_TAG is required."); + if (!releaseTag) throw new Error('RELEASE_TAG is required.'); - if (command === "upload-github") { - const assetPaths = await readReleaseAssetPaths(join(releaseRoot, "dist"), releaseTag); + if (command === 'upload-github') { + const assetPaths = await readReleaseAssetPaths( + join(releaseRoot, 'dist'), + releaseTag + ); await run(githubReleaseUploadCommand(releaseTag, assetPaths), releaseRoot); return; } - if (command === "publish-npm") { + if (command === 'publish-npm') { await publishNpmIfMissing(releaseRoot, releaseTag); return; } - if (command === "publish-github") { + if (command === 'publish-github') { await run(githubReleasePublishCommand(releaseTag), releaseRoot); return; } - throw new Error("Usage: publish-release.ts "); + throw new Error( + 'Usage: publish-release.ts ' + ); } if (import.meta.main) { diff --git a/cli/scripts/release-manifest.ts b/cli/scripts/release-manifest.ts index a2a89a9b..ea2e237b 100644 --- a/cli/scripts/release-manifest.ts +++ b/cli/scripts/release-manifest.ts @@ -1,28 +1,35 @@ -import { RELEASE_TARGETS, type ReleaseAssetName } from "@/release-manifest.ts"; +import { RELEASE_TARGETS, type ReleaseAssetName } from '@/release-manifest.ts'; -export { RELEASE_CHECKSUMS_ASSET, RELEASE_TARGETS } from "@/release-manifest.ts"; -export type { ReleaseTarget } from "@/release-manifest.ts"; +export { + RELEASE_CHECKSUMS_ASSET, + RELEASE_TARGETS, +} from '@/release-manifest.ts'; +export type { ReleaseTarget } from '@/release-manifest.ts'; -export const RELEASE_BUNDLE_ASSET = "altertable-cli.js"; -export const RELEASE_CLI_REFERENCE_ASSET = "cli-reference.json"; -export const RELEASE_CLI_REFERENCE_SCHEMA_ASSET = "cli-reference.schema.json"; -export const RELEASE_METADATA_ASSET = "release-manifest.json"; +export const RELEASE_BUNDLE_ASSET = 'altertable-cli.js'; +export const RELEASE_CLI_REFERENCE_ASSET = 'cli-reference.json'; +export const RELEASE_CLI_REFERENCE_SCHEMA_ASSET = 'cli-reference.schema.json'; +export const RELEASE_METADATA_ASSET = 'release-manifest.json'; -type ReleaseBunTarget = (typeof RELEASE_TARGETS)[number]["bunTarget"]; +type ReleaseBunTarget = (typeof RELEASE_TARGETS)[number]['bunTarget']; export function findReleaseTargetByBunTarget(bunTarget: string) { - return RELEASE_TARGETS.find((target) => target.bunTarget === bunTarget); + return RELEASE_TARGETS.find(target => target.bunTarget === bunTarget); } export function findReleaseTargetByPlatform(platform: string) { - return RELEASE_TARGETS.find((target) => target.platform === platform); + return RELEASE_TARGETS.find(target => target.platform === platform); } export function releaseCiMatrix(): { - include: Array<{ target: ReleaseBunTarget; artifact: ReleaseAssetName; runner: string }>; + include: Array<{ + target: ReleaseBunTarget; + artifact: ReleaseAssetName; + runner: string; + }>; } { return { - include: RELEASE_TARGETS.map((target) => ({ + include: RELEASE_TARGETS.map(target => ({ target: target.bunTarget, artifact: target.asset, runner: target.runner, diff --git a/cli/scripts/release.test.ts b/cli/scripts/release.test.ts index 65b60186..8979044a 100644 --- a/cli/scripts/release.test.ts +++ b/cli/scripts/release.test.ts @@ -1,7 +1,7 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { afterEach, describe, expect, test } from 'bun:test'; +import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { assertReleaseTag, assertToolchain, @@ -18,8 +18,8 @@ import { stageCommandReferenceAssets, SUPPORTED_BUN_RUNTIME_RANGE, writeReleaseMetadata, -} from "@/../scripts/release.ts"; -import { NPM_BUNDLE_PATH } from "@/../scripts/npm-bundle.ts"; +} from '@/../scripts/release.ts'; +import { NPM_BUNDLE_PATH } from '@/../scripts/npm-bundle.ts'; import { githubReleasePublishCommand, githubReleaseUploadCommand, @@ -31,9 +31,9 @@ import { releaseAssetNames, readReleaseAssetPaths, SETUP_NODE_AUTH_TOKEN_PLACEHOLDER, -} from "@/../scripts/publish-release.ts"; -import { UpdaterConfig } from "@/lib/updater-config.ts"; -import { releaseAssetName } from "@/lib/updater.ts"; +} from '@/../scripts/publish-release.ts'; +import { UpdaterConfig } from '@/lib/updater-config.ts'; +import { releaseAssetName } from '@/lib/updater.ts'; import { RELEASE_BUNDLE_ASSET, RELEASE_CHECKSUMS_ASSET, @@ -42,11 +42,11 @@ import { RELEASE_METADATA_ASSET, RELEASE_TARGETS, releaseCiMatrix, -} from "@/../scripts/release-manifest.ts"; -import { VERSION } from "@/version.ts"; +} from '@/../scripts/release-manifest.ts'; +import { VERSION } from '@/version.ts'; const temporaryDirectories: string[] = []; -const repositoryRoot = join(import.meta.dir, "../.."); +const repositoryRoot = join(import.meta.dir, '../..'); type WorkflowStep = { env?: Record; @@ -68,14 +68,17 @@ type Workflow = { jobs: Record; on?: { workflow_dispatch?: { - inputs?: Record; + inputs?: Record< + string, + { description?: string; required?: boolean; type?: string } + >; }; }; }; async function readWorkflow(name: string): Promise { return Bun.YAML.parse( - await readFile(join(repositoryRoot, ".github/workflows", name), "utf8"), + await readFile(join(repositoryRoot, '.github/workflows', name), 'utf8') ) as Workflow; } @@ -85,55 +88,55 @@ function workflowNeeds(job: WorkflowJob): string[] { } function workflowStepIndex(job: WorkflowJob, name: string): number { - return job.steps?.findIndex((step) => step.name === name) ?? -1; + return job.steps?.findIndex(step => step.name === name) ?? -1; } async function fileSha256(path: string): Promise { - const hasher = new Bun.CryptoHasher("sha256"); + const hasher = new Bun.CryptoHasher('sha256'); hasher.update(await Bun.file(path).arrayBuffer()); - return hasher.digest("hex"); + return hasher.digest('hex'); } afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), + .map(directory => rm(directory, { recursive: true, force: true })) ); }); -describe("release target manifest", () => { - test("defines unique updater-compatible targets and artifacts", () => { +describe('release target manifest', () => { + test('defines unique updater-compatible targets and artifacts', () => { expect(RELEASE_TARGETS).toHaveLength(4); - expect(new Set(RELEASE_TARGETS.map((target) => target.bunTarget)).size).toBe( - RELEASE_TARGETS.length, + expect(new Set(RELEASE_TARGETS.map(target => target.bunTarget)).size).toBe( + RELEASE_TARGETS.length ); - expect(new Set(RELEASE_TARGETS.map((target) => target.platform)).size).toBe( - RELEASE_TARGETS.length, + expect(new Set(RELEASE_TARGETS.map(target => target.platform)).size).toBe( + RELEASE_TARGETS.length ); - expect(new Set(RELEASE_TARGETS.map((target) => target.asset)).size).toBe( - RELEASE_TARGETS.length, - ); - expect(RELEASE_TARGETS.find((target) => target.platform === "linux-x64")?.bunTarget).toBe( - "bun-linux-x64-baseline", + expect(new Set(RELEASE_TARGETS.map(target => target.asset)).size).toBe( + RELEASE_TARGETS.length ); + expect( + RELEASE_TARGETS.find(target => target.platform === 'linux-x64')?.bunTarget + ).toBe('bun-linux-x64-baseline'); expect(Object.keys(UpdaterConfig.releasePlatforms).sort()).toEqual( - RELEASE_TARGETS.map((target) => target.platform).sort(), + RELEASE_TARGETS.map(target => target.platform).sort() ); for (const target of RELEASE_TARGETS) { expect(releaseAssetName(target.platform)).toBe(target.asset); } - expect(RELEASE_TARGETS.map((target) => target.asset).sort()).toEqual([ - "altertable-darwin-arm64", - "altertable-darwin-x64", - "altertable-linux-arm64", - "altertable-linux-x64", + expect(RELEASE_TARGETS.map(target => target.asset).sort()).toEqual([ + 'altertable-darwin-arm64', + 'altertable-darwin-x64', + 'altertable-linux-arm64', + 'altertable-linux-x64', ]); }); - test("generates the complete GitHub Actions matrix", () => { + test('generates the complete GitHub Actions matrix', () => { expect(releaseCiMatrix()).toEqual({ - include: RELEASE_TARGETS.map((target) => ({ + include: RELEASE_TARGETS.map(target => ({ target: target.bunTarget, artifact: target.asset, runner: target.runner, @@ -141,47 +144,60 @@ describe("release target manifest", () => { }); }); - test("uses supported runner images for every release target", () => { - expect(RELEASE_TARGETS.find(({ platform }) => platform === "darwin-arm64")?.runner).toBe( - "macos-15", + test('uses supported runner images for every release target', () => { + expect( + RELEASE_TARGETS.find(({ platform }) => platform === 'darwin-arm64') + ?.runner + ).toBe('macos-15'); + expect(RELEASE_TARGETS.map(({ runner }) => runner)).not.toContain( + 'macos-14' ); - expect(RELEASE_TARGETS.map(({ runner }) => runner)).not.toContain("macos-14"); }); - test("detects native targets without changing public asset names", () => { - expect(nativeReleaseTarget("darwin", "arm64").asset).toBe("altertable-darwin-arm64"); - expect(nativeReleaseTarget("linux", "x64").asset).toBe("altertable-linux-x64"); - expect(() => nativeReleaseTarget("win32", "x64")).toThrow( - "Unsupported native release platform: win32-x64.", + test('detects native targets without changing public asset names', () => { + expect(nativeReleaseTarget('darwin', 'arm64').asset).toBe( + 'altertable-darwin-arm64' + ); + expect(nativeReleaseTarget('linux', 'x64').asset).toBe( + 'altertable-linux-x64' + ); + expect(() => nativeReleaseTarget('win32', 'x64')).toThrow( + 'Unsupported native release platform: win32-x64.' ); }); - test("signs and verifies macOS release binaries", () => { - const darwinTarget = RELEASE_TARGETS.find(({ platform }) => platform === "darwin-arm64")!; - const linuxTarget = RELEASE_TARGETS.find(({ platform }) => platform === "linux-arm64")!; - const executable = "/tmp/altertable-test-binary"; + test('signs and verifies macOS release binaries', () => { + const darwinTarget = RELEASE_TARGETS.find( + ({ platform }) => platform === 'darwin-arm64' + )!; + const linuxTarget = RELEASE_TARGETS.find( + ({ platform }) => platform === 'linux-arm64' + )!; + const executable = '/tmp/altertable-test-binary'; expect(darwinSignatureCommand(darwinTarget, executable)).toEqual([ - "codesign", - "--force", - "--sign", - "-", - "--timestamp=none", + 'codesign', + '--force', + '--sign', + '-', + '--timestamp=none', executable, ]); expect(signatureVerificationCommand(darwinTarget, executable)).toEqual([ - "codesign", - "--verify", - "--strict", + 'codesign', + '--verify', + '--strict', executable, ]); expect(darwinSignatureCommand(linuxTarget, executable)).toBeUndefined(); - expect(signatureVerificationCommand(linuxTarget, executable)).toBeUndefined(); + expect( + signatureVerificationCommand(linuxTarget, executable) + ).toBeUndefined(); }); }); -describe("release toolchain contract", () => { - test("pins the build toolchain without raising the package runtime floor", async () => { +describe('release toolchain contract', () => { + test('pins the build toolchain without raising the package runtime floor', async () => { const contract = await readToolchainContract(); expect(contract.bunVersion).toBe(Bun.version); @@ -191,63 +207,72 @@ describe("release toolchain contract", () => { expect(await assertToolchain()).toEqual(contract); let error: unknown; try { - await assertToolchain("0.0.0"); + await assertToolchain('0.0.0'); } catch (cause) { error = cause; } expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe( - `Release builds require Bun ${contract.bunVersion}; received 0.0.0.`, + `Release builds require Bun ${contract.bunVersion}; received 0.0.0.` ); }); - test("requires release tags to match the CLI version", () => { + test('requires release tags to match the CLI version', () => { expect(expectedReleaseTag).toBe(`v${VERSION}`); expect(() => assertReleaseTag(expectedReleaseTag)).not.toThrow(); - expect(() => assertReleaseTag("v0.0.0")).toThrow( - `Release tag must be ${expectedReleaseTag}; received v0.0.0.`, + expect(() => assertReleaseTag('v0.0.0')).toThrow( + `Release tag must be ${expectedReleaseTag}; received v0.0.0.` ); }); - test("uses hardened standalone compiler flags", () => { + test('uses hardened standalone compiler flags', () => { const target = RELEASE_TARGETS[0]; - const command = compileCommand(target, "/tmp/altertable-test-binary"); - - expect(command).toContain("--compile"); - expect(command).toContain("--minify"); - expect(command).toContain("--bytecode"); - expect(command).toContain("--reject-unresolved"); - expect(command).toContain("--no-compile-autoload-dotenv"); - expect(command).toContain("--no-compile-autoload-bunfig"); + const command = compileCommand(target, '/tmp/altertable-test-binary'); + + expect(command).toContain('--compile'); + expect(command).toContain('--minify'); + expect(command).toContain('--bytecode'); + expect(command).toContain('--reject-unresolved'); + expect(command).toContain('--no-compile-autoload-dotenv'); + expect(command).toContain('--no-compile-autoload-bunfig'); expect(command).toContain(`--target=${target.bunTarget}`); - expect(command).toContain("--outfile=/tmp/altertable-test-binary"); - expect(new Set(HARDENED_COMPILE_FLAGS).size).toBe(HARDENED_COMPILE_FLAGS.length); + expect(command).toContain('--outfile=/tmp/altertable-test-binary'); + expect(new Set(HARDENED_COMPILE_FLAGS).size).toBe( + HARDENED_COMPILE_FLAGS.length + ); }); }); -describe("release metadata", () => { - test("writes a complete, checksummed release inventory atomically", async () => { - const directory = await mkdtemp(join(tmpdir(), "altertable-release-test-")); +describe('release metadata', () => { + test('writes a complete, checksummed release inventory atomically', async () => { + const directory = await mkdtemp(join(tmpdir(), 'altertable-release-test-')); temporaryDirectories.push(directory); for (const target of RELEASE_TARGETS) { await Bun.write(join(directory, target.asset), target.bunTarget); } - await Bun.write(join(directory, RELEASE_BUNDLE_ASSET), "console.log('altertable');\n"); + await Bun.write( + join(directory, RELEASE_BUNDLE_ASSET), + "console.log('altertable');\n" + ); const metadata = await writeReleaseMetadata(directory); const onDisk = JSON.parse( - await readFile(join(directory, RELEASE_METADATA_ASSET), "utf8"), + await readFile(join(directory, RELEASE_METADATA_ASSET), 'utf8') ) as typeof metadata; - const checksums = await readFile(join(directory, RELEASE_CHECKSUMS_ASSET), "utf8"); + const checksums = await readFile( + join(directory, RELEASE_CHECKSUMS_ASSET), + 'utf8' + ); const checksumEntries = new Map( checksums .trim() - .split("\n") - .map((line) => { + .split('\n') + .map(line => { const [digest, file] = line.split(/\s+/, 2); - if (!digest || !file) throw new Error(`Invalid checksum line: ${line}`); + if (!digest || !file) + throw new Error(`Invalid checksum line: ${line}`); return [file, digest] as const; - }), + }) ); expect(metadata.schemaVersion).toBe(RELEASE_MANIFEST_SCHEMA_VERSION); @@ -260,57 +285,72 @@ describe("release metadata", () => { expect(artifact.bytes).toBeGreaterThan(0); expect(artifact.sha256).toMatch(/^[0-9a-f]{64}$/); expect(checksumEntries.get(artifact.file)).toBe( - await fileSha256(join(directory, artifact.file)), + await fileSha256(join(directory, artifact.file)) ); } for (const target of RELEASE_TARGETS) { - const artifact = metadata.artifacts.find(({ file }) => file === target.asset); + const artifact = metadata.artifacts.find( + ({ file }) => file === target.asset + ); expect(artifact?.recipe).toEqual({ - builder: "bun-build-executable", + builder: 'bun-build-executable', bunVersion: Bun.version, target: target.bunTarget, flags: HARDENED_COMPILE_FLAGS, }); } - expect(metadata.artifacts.find(({ kind }) => kind === "javascript")?.recipe).toEqual({ - builder: "bun-build", + expect( + metadata.artifacts.find(({ kind }) => kind === 'javascript')?.recipe + ).toEqual({ + builder: 'bun-build', bunVersion: Bun.version, ...JAVASCRIPT_BUNDLE_OPTIONS, }); const documentationArtifacts = metadata.artifacts.filter( - ({ kind }) => kind === "documentation", + ({ kind }) => kind === 'documentation' ); expect(documentationArtifacts).toHaveLength(2); for (const artifact of documentationArtifacts) { expect(artifact.recipe).toEqual({ - builder: "command-reference", + builder: 'command-reference', schemaVersion: 1, }); - expect(await readFile(join(directory, artifact.file), "utf8")).toBe( - await readFile(join(repositoryRoot, artifact.file), "utf8"), + expect(await readFile(join(directory, artifact.file), 'utf8')).toBe( + await readFile(join(repositoryRoot, artifact.file), 'utf8') ); } expect(checksumEntries.get(RELEASE_METADATA_ASSET)).toBe( - await fileSha256(join(directory, RELEASE_METADATA_ASSET)), + await fileSha256(join(directory, RELEASE_METADATA_ASSET)) ); - expect([...checksumEntries.keys()].sort((left, right) => left.localeCompare(right))).toEqual( - [...metadata.artifacts.map(({ file }) => file), RELEASE_METADATA_ASSET].sort((left, right) => - left.localeCompare(right), - ), + expect( + [...checksumEntries.keys()].sort((left, right) => + left.localeCompare(right) + ) + ).toEqual( + [ + ...metadata.artifacts.map(({ file }) => file), + RELEASE_METADATA_ASSET, + ].sort((left, right) => left.localeCompare(right)) ); }); - test("stages the exact npm bundle as the GitHub JavaScript asset", async () => { - const directory = await mkdtemp(join(tmpdir(), "altertable-release-bundle-test-")); + test('stages the exact npm bundle as the GitHub JavaScript asset', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'altertable-release-bundle-test-') + ); temporaryDirectories.push(directory); const releaseBundle = await buildJavaScriptBundle(directory); - expect(await fileSha256(releaseBundle)).toBe(await fileSha256(NPM_BUNDLE_PATH)); + expect(await fileSha256(releaseBundle)).toBe( + await fileSha256(NPM_BUNDLE_PATH) + ); }); - test("stages version-matched CLI reference assets", async () => { - const directory = await mkdtemp(join(tmpdir(), "altertable-release-reference-test-")); + test('stages version-matched CLI reference assets', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'altertable-release-reference-test-') + ); temporaryDirectories.push(directory); const artifacts = await stageCommandReferenceAssets(directory); @@ -321,72 +361,94 @@ describe("release metadata", () => { ]); for (const artifact of artifacts) { expect(await fileSha256(join(directory, artifact.file))).toBe( - await fileSha256(join(repositoryRoot, artifact.file)), + await fileSha256(join(repositoryRoot, artifact.file)) ); } }); }); -describe("retry-safe npm publication", () => { - test("distinguishes published versions, missing versions, and registry failures", () => { - expect(parseNpmVersionLookup(0, '"1.1.0"\n', "")).toEqual({ - status: "published", - version: "1.1.0", +describe('retry-safe npm publication', () => { + test('distinguishes published versions, missing versions, and registry failures', () => { + expect(parseNpmVersionLookup(0, '"1.1.0"\n', '')).toEqual({ + status: 'published', + version: '1.1.0', + }); + expect(parseNpmVersionLookup(1, '', 'npm error code E404')).toEqual({ + status: 'missing', }); - expect(parseNpmVersionLookup(1, "", "npm error code E404")).toEqual({ status: "missing" }); - expect(() => parseNpmVersionLookup(1, "", "network timeout")).toThrow( - "npm view failed (1): network timeout", + expect(() => parseNpmVersionLookup(1, '', 'network timeout')).toThrow( + 'npm view failed (1): network timeout' ); - expect(npmPublicationRequired({ status: "missing" }, "1.1.0")).toBe(true); - expect(npmPublicationRequired({ status: "published", version: "1.1.0" }, "1.1.0")).toBe(false); + expect(npmPublicationRequired({ status: 'missing' }, '1.1.0')).toBe(true); + expect( + npmPublicationRequired({ status: 'published', version: '1.1.0' }, '1.1.0') + ).toBe(false); expect(() => - npmPublicationRequired({ status: "published", version: "1.0.0" }, "1.1.0"), - ).toThrow("npm returned version 1.0.0; expected 1.1.0"); + npmPublicationRequired({ status: 'published', version: '1.0.0' }, '1.1.0') + ).toThrow('npm returned version 1.0.0; expected 1.1.0'); }); - test("requires tokenless GitHub OIDC and a compatible npm CLI before publishing", () => { + test('requires tokenless GitHub OIDC and a compatible npm CLI before publishing', () => { const oidcEnvironment = { - ACTIONS_ID_TOKEN_REQUEST_TOKEN: "ephemeral-token", - ACTIONS_ID_TOKEN_REQUEST_URL: "https://actions.example.test/oidc", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'ephemeral-token', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://actions.example.test/oidc', }; expect(() => - assertTrustedPublishingEnvironment(oidcEnvironment, MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION), + assertTrustedPublishingEnvironment( + oidcEnvironment, + MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION + ) ).not.toThrow(); expect(() => assertTrustedPublishingEnvironment( - { ...oidcEnvironment, NODE_AUTH_TOKEN: SETUP_NODE_AUTH_TOKEN_PLACEHOLDER }, - MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION, - ), + { + ...oidcEnvironment, + NODE_AUTH_TOKEN: SETUP_NODE_AUTH_TOKEN_PLACEHOLDER, + }, + MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION + ) ).not.toThrow(); - expect(() => assertTrustedPublishingEnvironment(oidcEnvironment, "12.0.0")).not.toThrow(); - expect(() => assertTrustedPublishingEnvironment(oidcEnvironment, "11.5.0")).toThrow( - `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}`, + expect(() => + assertTrustedPublishingEnvironment(oidcEnvironment, '12.0.0') + ).not.toThrow(); + expect(() => + assertTrustedPublishingEnvironment(oidcEnvironment, '11.5.0') + ).toThrow( + `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}` ); - expect(() => assertTrustedPublishingEnvironment({}, "12.0.0")).toThrow( - "npm trusted publishing requires GitHub Actions OIDC credentials", + expect(() => assertTrustedPublishingEnvironment({}, '12.0.0')).toThrow( + 'npm trusted publishing requires GitHub Actions OIDC credentials' ); expect(() => assertTrustedPublishingEnvironment( - { ...oidcEnvironment, NODE_AUTH_TOKEN: "secret" }, - "12.0.0", - ), - ).toThrow("npm publishing must use OIDC"); + { ...oidcEnvironment, NODE_AUTH_TOKEN: 'secret' }, + '12.0.0' + ) + ).toThrow('npm publishing must use OIDC'); expect(() => - assertTrustedPublishingEnvironment({ ...oidcEnvironment, NPM_TOKEN: "secret" }, "12.0.0"), - ).toThrow("npm publishing must use OIDC"); + assertTrustedPublishingEnvironment( + { ...oidcEnvironment, NPM_TOKEN: 'secret' }, + '12.0.0' + ) + ).toThrow('npm publishing must use OIDC'); expect(() => assertTrustedPublishingEnvironment( - { ...oidcEnvironment, NODE_AUTH_TOKEN: `${SETUP_NODE_AUTH_TOKEN_PLACEHOLDER}-modified` }, - "12.0.0", - ), - ).toThrow("npm publishing must use OIDC"); + { + ...oidcEnvironment, + NODE_AUTH_TOKEN: `${SETUP_NODE_AUTH_TOKEN_PLACEHOLDER}-modified`, + }, + '12.0.0' + ) + ).toThrow('npm publishing must use OIDC'); }); }); -describe("GitHub release publication", () => { - test("uploads the complete manifest-owned asset set before publishing the draft", async () => { - const releaseDirectory = await mkdtemp(join(tmpdir(), "altertable-release-upload-test-")); +describe('GitHub release publication', () => { + test('uploads the complete manifest-owned asset set before publishing the draft', async () => { + const releaseDirectory = await mkdtemp( + join(tmpdir(), 'altertable-release-upload-test-') + ); temporaryDirectories.push(releaseDirectory); const assetNames = [ ...RELEASE_TARGETS.map(({ asset }) => asset), @@ -396,26 +458,28 @@ describe("GitHub release publication", () => { RELEASE_METADATA_ASSET, RELEASE_CHECKSUMS_ASSET, ]; - for (const asset of assetNames) await Bun.write(join(releaseDirectory, asset), asset); + for (const asset of assetNames) + await Bun.write(join(releaseDirectory, asset), asset); await Bun.write( join(releaseDirectory, RELEASE_METADATA_ASSET), JSON.stringify({ - tag: "v1.1.0", + tag: 'v1.1.0', artifacts: [ ...RELEASE_TARGETS.map(({ asset }) => ({ file: asset })), { file: RELEASE_BUNDLE_ASSET }, { file: RELEASE_CLI_REFERENCE_ASSET }, { file: RELEASE_CLI_REFERENCE_SCHEMA_ASSET }, ], - }), + }) ); - const assetPaths = await readReleaseAssetPaths(releaseDirectory, "v1.1.0"); - const upload = githubReleaseUploadCommand("v1.1.0", assetPaths); - const publish = githubReleasePublishCommand("v1.1.0"); + const assetPaths = await readReleaseAssetPaths(releaseDirectory, 'v1.1.0'); + const upload = githubReleaseUploadCommand('v1.1.0', assetPaths); + const publish = githubReleasePublishCommand('v1.1.0'); - expect(upload.slice(0, 4)).toEqual(["gh", "release", "upload", "v1.1.0"]); - for (const target of RELEASE_TARGETS) expect(upload.join("\n")).toContain(target.asset); + expect(upload.slice(0, 4)).toEqual(['gh', 'release', 'upload', 'v1.1.0']); + for (const target of RELEASE_TARGETS) + expect(upload.join('\n')).toContain(target.asset); for (const asset of [ RELEASE_BUNDLE_ASSET, RELEASE_CLI_REFERENCE_ASSET, @@ -423,207 +487,270 @@ describe("GitHub release publication", () => { RELEASE_METADATA_ASSET, RELEASE_CHECKSUMS_ASSET, ]) { - expect(upload.join("\n")).toContain(asset); + expect(upload.join('\n')).toContain(asset); } - expect(upload.at(-1)).toBe("--clobber"); - expect(publish).toEqual(["gh", "release", "edit", "v1.1.0", "--draft=false", "--latest"]); + expect(upload.at(-1)).toBe('--clobber'); + expect(publish).toEqual([ + 'gh', + 'release', + 'edit', + 'v1.1.0', + '--draft=false', + '--latest', + ]); }); - test("validates the release package identity and manifest-owned asset names", () => { - expect(parsePackageIdentity({ name: "@altertable/cli", version: "1.2.0" }, "v1.2.0")).toEqual({ - name: "@altertable/cli", - version: "1.2.0", + test('validates the release package identity and manifest-owned asset names', () => { + expect( + parsePackageIdentity( + { name: '@altertable/cli', version: '1.2.0' }, + 'v1.2.0' + ) + ).toEqual({ + name: '@altertable/cli', + version: '1.2.0', }); expect(() => - parsePackageIdentity({ name: "@altertable/cli", version: "1.2.0" }, "v1.3.0"), - ).toThrow("does not match package version"); - for (const file of ["", ".", "..", "../secret", "nested/secret", "nested\\secret"]) { - expect(() => releaseAssetNames({ tag: "v1.2.0", artifacts: [{ file }] }, "v1.2.0")).toThrow( - "Unsafe release asset name", - ); + parsePackageIdentity( + { name: '@altertable/cli', version: '1.2.0' }, + 'v1.3.0' + ) + ).toThrow('does not match package version'); + for (const file of [ + '', + '.', + '..', + '../secret', + 'nested/secret', + 'nested\\secret', + ]) { + expect(() => + releaseAssetNames({ tag: 'v1.2.0', artifacts: [{ file }] }, 'v1.2.0') + ).toThrow('Unsafe release asset name'); } }); }); -describe("release infrastructure wiring", () => { - test("updates the generated CLI reference version in release PRs", async () => { +describe('release infrastructure wiring', () => { + test('updates the generated CLI reference version in release PRs', async () => { const releasePleaseConfig = JSON.parse( - await readFile(join(repositoryRoot, "release-please-config.json"), "utf8"), + await readFile(join(repositoryRoot, 'release-please-config.json'), 'utf8') ) as { - packages: Record; + packages: Record; }; - expect(releasePleaseConfig.packages["."]?.["extra-files"]).toContainEqual({ - type: "json", - path: "cli-reference.json", - jsonpath: "$.cliVersion", + expect(releasePleaseConfig.packages['.']?.['extra-files']).toContainEqual({ + type: 'json', + path: 'cli-reference.json', + jsonpath: '$.cliVersion', }); - expect(releasePleaseConfig.packages["."]?.["extra-files"]).toContainEqual({ - type: "generic", - path: "cli-reference.schema.json", + expect(releasePleaseConfig.packages['.']?.['extra-files']).toContainEqual({ + type: 'generic', + path: 'cli-reference.schema.json', glob: false, }); }); - test("keeps target literals out of build and workflow orchestration", async () => { + test('keeps target literals out of build and workflow orchestration', async () => { const files = [ - "Makefile", - ".github/workflows/test.yml", - ".github/workflows/release-please.yml", + 'Makefile', + '.github/workflows/test.yml', + '.github/workflows/release-please.yml', ]; for (const file of files) { - const contents = await readFile(join(repositoryRoot, file), "utf8"); + const contents = await readFile(join(repositoryRoot, file), 'utf8'); for (const target of RELEASE_TARGETS) { expect(contents).not.toContain(target.bunTarget); } } }); - test("enforces hardened workflow conventions", async () => { + test('enforces hardened workflow conventions', async () => { const workflows = [ - "codeql.yml", - "dependency-review.yml", - "release-please.yml", - "semantic-pull-request.yml", - "test.yml", - "verify.yml", + 'codeql.yml', + 'dependency-review.yml', + 'release-please.yml', + 'semantic-pull-request.yml', + 'test.yml', + 'verify.yml', ]; for (const workflow of workflows) { - const contents = await readFile(join(repositoryRoot, ".github/workflows", workflow), "utf8"); - expect(contents).toContain("permissions:"); - expect(contents).toContain("timeout-minutes:"); - expect(contents).not.toContain("runs-on: ubuntu-latest"); - expect(contents).not.toContain("continue-on-error: true"); - - const actionReferences = [...contents.matchAll(/uses:\s+[^@\s]+@([^\s]+)/g)]; + const contents = await readFile( + join(repositoryRoot, '.github/workflows', workflow), + 'utf8' + ); + expect(contents).toContain('permissions:'); + expect(contents).toContain('timeout-minutes:'); + expect(contents).not.toContain('runs-on: ubuntu-latest'); + expect(contents).not.toContain('continue-on-error: true'); + + const actionReferences = [ + ...contents.matchAll(/uses:\s+[^@\s]+@([^\s]+)/g), + ]; for (const reference of actionReferences) { expect(reference[1]).toMatch(/^[0-9a-f]{40}$/); } - if (contents.includes("actions/checkout@")) { - expect(contents).toContain("persist-credentials: false"); + if (contents.includes('actions/checkout@')) { + expect(contents).toContain('persist-credentials: false'); } } }); - test("gates ordered release publication on canonical and native verification", async () => { - const workflow = await readWorkflow("release-please.yml"); + test('gates ordered release publication on canonical and native verification', async () => { + const workflow = await readWorkflow('release-please.yml'); const releasePleaseConfig = JSON.parse( - await readFile(join(repositoryRoot, "release-please-config.json"), "utf8"), + await readFile(join(repositoryRoot, 'release-please-config.json'), 'utf8') ) as { packages: Record }; - const preparation = workflow.jobs["release-please"] ?? {}; - const context = workflow.jobs["release-context"] ?? {}; - const verification = workflow.jobs["verify-release"] ?? {}; - const matrix = workflow.jobs["release-matrix"] ?? {}; - const native = workflow.jobs["release-native"] ?? {}; - const publication = workflow.jobs["release-artifacts"] ?? {}; - - expect(releasePleaseConfig.packages["."]?.draft).toBe(true); + const preparation = workflow.jobs['release-please'] ?? {}; + const context = workflow.jobs['release-context'] ?? {}; + const verification = workflow.jobs['verify-release'] ?? {}; + const matrix = workflow.jobs['release-matrix'] ?? {}; + const native = workflow.jobs['release-native'] ?? {}; + const publication = workflow.jobs['release-artifacts'] ?? {}; + + expect(releasePleaseConfig.packages['.']?.draft).toBe(true); expect(workflow.on?.workflow_dispatch?.inputs?.release_tag).toEqual({ - description: "Existing draft or assetless release tag to recover (for example, v1.8.0)", + description: + 'Existing draft or assetless release tag to recover (for example, v1.8.0)', required: true, - type: "string", + type: 'string', }); expect(preparation.if).toContain("github.event_name == 'push'"); - expect(workflowNeeds(context)).toEqual(["release-please"]); - expect(context.if).toContain("workflow_dispatch"); - expect(context.if).toContain("!cancelled()"); - expect(context.if).not.toContain("always()"); - expect(context.permissions?.contents).toBe("write"); + expect(workflowNeeds(context)).toEqual(['release-please']); + expect(context.if).toContain('workflow_dispatch'); + expect(context.if).toContain('!cancelled()'); + expect(context.if).not.toContain('always()'); + expect(context.permissions?.contents).toBe('write'); const contextScript = context.steps?.find( - ({ name }) => name === "Resolve automatic or recovery release", + ({ name }) => name === 'Resolve automatic or recovery release' )?.run; - expect(contextScript).toContain("gh api --paginate --slurp"); - expect(contextScript).toContain("releases?per_page=100"); - expect(contextScript).toContain("select(.tag_name == $tag)"); - expect(contextScript).toContain("asset_count=\"$(jq -r '.[0].assets | length'"); - expect(contextScript).toContain('"${is_draft}" != "true" && "${asset_count}" != "0"'); - expect(contextScript).toContain("already has assets"); - expect(contextScript).toContain("git/ref/tags/${RECOVERY_TAG}"); + expect(contextScript).toContain('gh api --paginate --slurp'); + expect(contextScript).toContain('releases?per_page=100'); + expect(contextScript).toContain('select(.tag_name == $tag)'); + expect(contextScript).toContain( + "asset_count=\"$(jq -r '.[0].assets | length'" + ); + expect(contextScript).toContain( + '"${is_draft}" != "true" && "${asset_count}" != "0"' + ); + expect(contextScript).toContain('already has assets'); + expect(contextScript).toContain('git/ref/tags/${RECOVERY_TAG}'); expect(contextScript).toContain('"${tag_ref}" != "${release_ref}"'); - expect(contextScript).not.toContain("gh release view"); - expect(contextScript).toContain("immutable commit SHA"); - expect(contextScript).toContain("Release recovery must run from refs/heads/main"); - expect(contextScript).toContain("orchestration_ref=${TRIGGER_SHA}"); - expect(verification.uses).toBe("./.github/workflows/verify.yml"); - expect(workflowNeeds(verification)).toEqual(["release-context"]); - expect(verification.if).toContain("!cancelled()"); - expect(verification.if).toContain("needs.release-context.result == 'success'"); - expect(verification.if).not.toContain("always()"); - expect(verification.with?.ref).toContain("release_ref"); - expect(workflowNeeds(matrix)).toEqual(["release-context", "verify-release"]); - expect(matrix.if).toContain("!cancelled()"); + expect(contextScript).not.toContain('gh release view'); + expect(contextScript).toContain('immutable commit SHA'); + expect(contextScript).toContain( + 'Release recovery must run from refs/heads/main' + ); + expect(contextScript).toContain('orchestration_ref=${TRIGGER_SHA}'); + expect(verification.uses).toBe('./.github/workflows/verify.yml'); + expect(workflowNeeds(verification)).toEqual(['release-context']); + expect(verification.if).toContain('!cancelled()'); + expect(verification.if).toContain( + "needs.release-context.result == 'success'" + ); + expect(verification.if).not.toContain('always()'); + expect(verification.with?.ref).toContain('release_ref'); + expect(workflowNeeds(matrix)).toEqual([ + 'release-context', + 'verify-release', + ]); + expect(matrix.if).toContain('!cancelled()'); expect(matrix.if).toContain("needs.release-context.result == 'success'"); expect(matrix.if).toContain("needs.verify-release.result == 'success'"); - expect(matrix.if).not.toContain("always()"); - expect(workflowNeeds(native)).toEqual(["release-context", "verify-release", "release-matrix"]); - expect(native.if).toContain("!cancelled()"); + expect(matrix.if).not.toContain('always()'); + expect(workflowNeeds(native)).toEqual([ + 'release-context', + 'verify-release', + 'release-matrix', + ]); + expect(native.if).toContain('!cancelled()'); expect(native.if).toContain("needs.release-context.result == 'success'"); expect(native.if).toContain("needs.verify-release.result == 'success'"); expect(native.if).toContain("needs.release-matrix.result == 'success'"); - expect(native.if).not.toContain("always()"); - expect(workflowNeeds(publication)).toEqual(["release-context", "release-native"]); - expect(publication.if).toContain("!cancelled()"); - expect(publication.if).toContain("needs.release-context.result == 'success'"); - expect(publication.if).toContain("needs.release-native.result == 'success'"); - expect(publication.if).not.toContain("always()"); + expect(native.if).not.toContain('always()'); + expect(workflowNeeds(publication)).toEqual([ + 'release-context', + 'release-native', + ]); + expect(publication.if).toContain('!cancelled()'); + expect(publication.if).toContain( + "needs.release-context.result == 'success'" + ); + expect(publication.if).toContain( + "needs.release-native.result == 'success'" + ); + expect(publication.if).not.toContain('always()'); const releaseCheckout = publication.steps?.find( - ({ name }) => name === "Check out released revision", + ({ name }) => name === 'Check out released revision' ); const orchestrationCheckout = publication.steps?.find( - ({ name }) => name === "Check out current release orchestration", + ({ name }) => name === 'Check out current release orchestration' ); - expect(releaseCheckout?.with?.ref).toContain("release_ref"); - expect(orchestrationCheckout?.with?.ref).toContain("orchestration_ref"); - expect(orchestrationCheckout?.with?.path).toBe(".release-orchestration"); - expect(orchestrationCheckout?.with?.["sparse-checkout"]).toBe( - "cli/scripts/publish-release.ts\ncli/src/lib/object.ts\n", + expect(releaseCheckout?.with?.ref).toContain('release_ref'); + expect(orchestrationCheckout?.with?.ref).toContain('orchestration_ref'); + expect(orchestrationCheckout?.with?.path).toBe('.release-orchestration'); + expect(orchestrationCheckout?.with?.['sparse-checkout']).toBe( + 'cli/scripts/publish-release.ts\ncli/src/lib/object.ts\n' ); const orderedSteps = [ - "Download tested release binaries", - "Build JavaScript release bundle", - "Finalize exact release artifacts", - "Smoke test npm bundle on release toolchain", - "Install minimum supported npm runtime", - "Smoke test exact npm bundle on minimum runtime", - "Restore release build toolchain", - "Verify release checksums", - "Attest release provenance", - "Upload release assets", - "Publish npm package", - "Publish completed GitHub release", - ].map((name) => workflowStepIndex(publication, name)); - expect(orderedSteps.every((index) => index >= 0)).toBe(true); - expect(orderedSteps).toEqual([...orderedSteps].sort((left, right) => left - right)); + 'Download tested release binaries', + 'Build JavaScript release bundle', + 'Finalize exact release artifacts', + 'Smoke test npm bundle on release toolchain', + 'Install minimum supported npm runtime', + 'Smoke test exact npm bundle on minimum runtime', + 'Restore release build toolchain', + 'Verify release checksums', + 'Attest release provenance', + 'Upload release assets', + 'Publish npm package', + 'Publish completed GitHub release', + ].map(name => workflowStepIndex(publication, name)); + expect(orderedSteps.every(index => index >= 0)).toBe(true); + expect(orderedSteps).toEqual( + [...orderedSteps].sort((left, right) => left - right) + ); expect(publication.steps?.[orderedSteps[5] ?? -1]?.run).toBe( - "bun run scripts/smoke-npm-bundle.ts --expected-bun=1.1.0 --scaffold-only", + 'bun run scripts/smoke-npm-bundle.ts --expected-bun=1.1.0 --scaffold-only' + ); + const publishScript = + 'bun run .release-orchestration/cli/scripts/publish-release.ts'; + expect(publication.steps?.[orderedSteps[9] ?? -1]?.run).toBe( + `${publishScript} upload-github` + ); + expect(publication.steps?.[orderedSteps[10] ?? -1]?.run).toBe( + `${publishScript} publish-npm` ); - const publishScript = "bun run .release-orchestration/cli/scripts/publish-release.ts"; - expect(publication.steps?.[orderedSteps[9] ?? -1]?.run).toBe(`${publishScript} upload-github`); - expect(publication.steps?.[orderedSteps[10] ?? -1]?.run).toBe(`${publishScript} publish-npm`); expect(publication.steps?.[orderedSteps[11] ?? -1]?.run).toBe( - `${publishScript} publish-github`, + `${publishScript} publish-github` ); for (const index of orderedSteps.slice(9)) { - expect(publication.steps?.[index]?.env?.RELEASE_ROOT).toBe("${{ github.workspace }}"); - expect(publication.steps?.[index]?.env?.RELEASE_TAG).toContain("tag_name"); + expect(publication.steps?.[index]?.env?.RELEASE_ROOT).toBe( + '${{ github.workspace }}' + ); + expect(publication.steps?.[index]?.env?.RELEASE_TAG).toContain( + 'tag_name' + ); } }); - test("builds release publication from only the orchestration checkout", async () => { - const workflow = await readWorkflow("release-please.yml"); - const checkout = workflow.jobs["release-artifacts"]?.steps?.find( - ({ name }) => name === "Check out current release orchestration", + test('builds release publication from only the orchestration checkout', async () => { + const workflow = await readWorkflow('release-please.yml'); + const checkout = workflow.jobs['release-artifacts']?.steps?.find( + ({ name }) => name === 'Check out current release orchestration' ); - const sparseCheckout = checkout?.with?.["sparse-checkout"]; - if (typeof sparseCheckout !== "string") { - throw new Error("Release orchestration sparse checkout is missing"); + const sparseCheckout = checkout?.with?.['sparse-checkout']; + if (typeof sparseCheckout !== 'string') { + throw new Error('Release orchestration sparse checkout is missing'); } const paths = sparseCheckout .trim() - .split("\n") - .map((path) => path.trim()); - const directory = await mkdtemp(join(tmpdir(), "altertable-release-orchestration-test-")); + .split('\n') + .map(path => path.trim()); + const directory = await mkdtemp( + join(tmpdir(), 'altertable-release-orchestration-test-') + ); try { for (const path of paths) { const destination = join(directory, path); @@ -631,9 +758,9 @@ describe("release infrastructure wiring", () => { await copyFile(join(repositoryRoot, path), destination); } const result = await Bun.build({ - entrypoints: [join(directory, "cli/scripts/publish-release.ts")], - outdir: join(directory, "dist"), - target: "bun", + entrypoints: [join(directory, 'cli/scripts/publish-release.ts')], + outdir: join(directory, 'dist'), + target: 'bun', }); expect(result.success).toBe(true); } finally { @@ -641,92 +768,120 @@ describe("release infrastructure wiring", () => { } }); - test("retains recoverable release binaries longer than diagnostic artifacts", async () => { - const releaseWorkflow = await readWorkflow("release-please.yml"); - const verificationWorkflow = await readWorkflow("verify.yml"); - const branchWorkflow = await readWorkflow("test.yml"); + test('retains recoverable release binaries longer than diagnostic artifacts', async () => { + const releaseWorkflow = await readWorkflow('release-please.yml'); + const verificationWorkflow = await readWorkflow('verify.yml'); + const branchWorkflow = await readWorkflow('test.yml'); - const releaseUpload = releaseWorkflow.jobs["release-native"]?.steps?.find( - ({ name }) => name === "Upload tested release binary", + const releaseUpload = releaseWorkflow.jobs['release-native']?.steps?.find( + ({ name }) => name === 'Upload tested release binary' ); const coverageUpload = verificationWorkflow.jobs.repository?.steps?.find( - ({ name }) => name === "Upload coverage report", + ({ name }) => name === 'Upload coverage report' ); const branchUpload = branchWorkflow.jobs.compile?.steps?.find( - ({ name }) => name === "Upload compiled binary", + ({ name }) => name === 'Upload compiled binary' ); - expect(releaseUpload?.with?.["retention-days"]).toBe(30); - expect(coverageUpload?.with?.["retention-days"]).toBe(7); - expect(branchUpload?.with?.["retention-days"]).toBe(7); + expect(releaseUpload?.with?.['retention-days']).toBe(30); + expect(coverageUpload?.with?.['retention-days']).toBe(7); + expect(branchUpload?.with?.['retention-days']).toBe(7); }); - test("publishes npm through trusted publishing without a long-lived token", async () => { - const workflow = await readWorkflow("release-please.yml"); - const publication = workflow.jobs["release-artifacts"] ?? {}; - const publishStep = publication.steps?.find(({ name }) => name === "Publish npm package"); + test('publishes npm through trusted publishing without a long-lived token', async () => { + const workflow = await readWorkflow('release-please.yml'); + const publication = workflow.jobs['release-artifacts'] ?? {}; + const publishStep = publication.steps?.find( + ({ name }) => name === 'Publish npm package' + ); - expect(publication.permissions?.["id-token"]).toBe("write"); - expect(publishStep?.run).toContain(".release-orchestration/cli/scripts/publish-release.ts"); + expect(publication.permissions?.['id-token']).toBe('write'); + expect(publishStep?.run).toContain( + '.release-orchestration/cli/scripts/publish-release.ts' + ); expect(publishStep?.env?.NODE_AUTH_TOKEN).toBeUndefined(); expect(publishStep?.env?.NPM_TOKEN).toBeUndefined(); expect( - await readFile(join(repositoryRoot, ".github/workflows/release-please.yml"), "utf8"), - ).not.toContain("NPM_TOKEN"); + await readFile( + join(repositoryRoot, '.github/workflows/release-please.yml'), + 'utf8' + ) + ).not.toContain('NPM_TOKEN'); }); - test("pins the integration image and waits for its TCP listener", async () => { - const workflow = await readFile(join(repositoryRoot, ".github/workflows/verify.yml"), "utf8"); + test('pins the integration image and waits for its TCP listener', async () => { + const workflow = await readFile( + join(repositoryRoot, '.github/workflows/verify.yml'), + 'utf8' + ); expect(workflow).toContain( - "ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977", + 'ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977' ); - expect(workflow).toContain("/dev/tcp/127.0.0.1/15000"); - expect(workflow).not.toContain("altertable-mock:latest"); + expect(workflow).toContain('/dev/tcp/127.0.0.1/15000'); + expect(workflow).not.toContain('altertable-mock:latest'); expect(workflow).not.toContain('--health-cmd "exit 0"'); }); test("keeps the Bun 1.1 runtime path free of Bun's newer YAML API", async () => { const packageJson = JSON.parse( - await readFile(join(repositoryRoot, "cli/package.json"), "utf8"), + await readFile(join(repositoryRoot, 'cli/package.json'), 'utf8') ) as { dependencies: Record; engines: { bun: string } }; - const openapiSpec = await readFile(join(repositoryRoot, "cli/src/lib/openapi-spec.ts"), "utf8"); + const openapiSpec = await readFile( + join(repositoryRoot, 'cli/src/lib/openapi-spec.ts'), + 'utf8' + ); expect(packageJson.engines.bun).toBe(SUPPORTED_BUN_RUNTIME_RANGE); - expect(packageJson.dependencies.yaml).toBe("2.9.0"); - expect(openapiSpec).not.toContain("Bun.YAML"); + expect(packageJson.dependencies.yaml).toBe('2.9.0'); + expect(openapiSpec).not.toContain('Bun.YAML'); - const verification = await readWorkflow("verify.yml"); - const runtimeJob = verification.jobs["npm-runtime"] ?? {}; + const verification = await readWorkflow('verify.yml'); + const runtimeJob = verification.jobs['npm-runtime'] ?? {}; expect( - runtimeJob.steps?.find(({ name }) => name === "Install minimum supported runtime"), + runtimeJob.steps?.find( + ({ name }) => name === 'Install minimum supported runtime' + ) ).toEqual( - expect.objectContaining({ uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/) }), + expect.objectContaining({ + uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/), + }) ); expect( - runtimeJob.steps?.find(({ name }) => name === "Smoke test npm bundle on minimum runtime") - ?.run, - ).toBe("bun run cli/scripts/smoke-npm-bundle.ts --expected-bun=1.1.0 --scaffold-only"); + runtimeJob.steps?.find( + ({ name }) => name === 'Smoke test npm bundle on minimum runtime' + )?.run + ).toBe( + 'bun run cli/scripts/smoke-npm-bundle.ts --expected-bun=1.1.0 --scaffold-only' + ); }); - test("routes branch CI through the same canonical verification workflow", async () => { - const workflow = await readWorkflow("test.yml"); + test('routes branch CI through the same canonical verification workflow', async () => { + const workflow = await readWorkflow('test.yml'); const verification = workflow.jobs.verify ?? {}; const required = workflow.jobs.required ?? {}; - expect(verification.uses).toBe("./.github/workflows/verify.yml"); - expect(verification.with?.ref).toContain("github.sha"); - expect(workflowNeeds(workflow.jobs["release-matrix"] ?? {})).toContain("verify"); - expect(workflowNeeds(workflow.jobs.compile ?? {})).toContain("verify"); - expect(required.name).toBe("Required"); - expect(required.if).toBe("always()"); - expect(workflowNeeds(required)).toEqual(["verify", "release-matrix", "compile"]); + expect(verification.uses).toBe('./.github/workflows/verify.yml'); + expect(verification.with?.ref).toContain('github.sha'); + expect(workflowNeeds(workflow.jobs['release-matrix'] ?? {})).toContain( + 'verify' + ); + expect(workflowNeeds(workflow.jobs.compile ?? {})).toContain('verify'); + expect(required.name).toBe('Required'); + expect(required.if).toBe('always()'); + expect(workflowNeeds(required)).toEqual([ + 'verify', + 'release-matrix', + 'compile', + ]); - const enforcement = required.steps?.find(({ name }) => name === "Enforce required CI results"); + const enforcement = required.steps?.find( + ({ name }) => name === 'Enforce required CI results' + ); expect(enforcement?.env).toEqual({ - VERIFY_RESULT: "${{ needs.verify.result }}", - RELEASE_MATRIX_RESULT: "${{ needs.release-matrix.result }}", - COMPILE_RESULT: "${{ needs.compile.result }}", + VERIFY_RESULT: '${{ needs.verify.result }}', + RELEASE_MATRIX_RESULT: '${{ needs.release-matrix.result }}', + COMPILE_RESULT: '${{ needs.compile.result }}', }); expect(enforcement?.run).toContain('exit "$failed"'); }); diff --git a/cli/scripts/release.ts b/cli/scripts/release.ts index f2ac5b84..83cc2751 100644 --- a/cli/scripts/release.ts +++ b/cli/scripts/release.ts @@ -1,10 +1,14 @@ -import { randomUUID } from "node:crypto"; -import { copyFile, mkdir, rename, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { smokeDataApp } from "@/../scripts/smoke-data-app.ts"; -import packageJson from "../package.json" with { type: "json" }; -import { buildNpmBundle, NPM_BUNDLE_OPTIONS, NPM_BUNDLE_PATH } from "@/../scripts/npm-bundle.ts"; -import { VERSION } from "@/version.ts"; +import { randomUUID } from 'node:crypto'; +import { copyFile, mkdir, rename, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; +import packageJson from '../package.json' with { type: 'json' }; +import { + buildNpmBundle, + NPM_BUNDLE_OPTIONS, + NPM_BUNDLE_PATH, +} from '@/../scripts/npm-bundle.ts'; +import { VERSION } from '@/version.ts'; import { findReleaseTargetByBunTarget, findReleaseTargetByPlatform, @@ -16,29 +20,29 @@ import { RELEASE_TARGETS, releaseCiMatrix, type ReleaseTarget, -} from "@/../scripts/release-manifest.ts"; -import { CLI_REFERENCE_SCHEMA_VERSION } from "@/../scripts/command-reference.ts"; +} from '@/../scripts/release-manifest.ts'; +import { CLI_REFERENCE_SCHEMA_VERSION } from '@/../scripts/command-reference.ts'; export const RELEASE_MANIFEST_SCHEMA_VERSION = 3; -export const SUPPORTED_BUN_RUNTIME_RANGE = ">=1.1.0"; +export const SUPPORTED_BUN_RUNTIME_RANGE = '>=1.1.0'; export const HARDENED_COMPILE_FLAGS = [ - "--compile", - "--minify", - "--bytecode", - "--format=esm", - "--sourcemap=inline", - "--reject-unresolved", - "--no-compile-autoload-dotenv", - "--no-compile-autoload-bunfig", + '--compile', + '--minify', + '--bytecode', + '--format=esm', + '--sourcemap=inline', + '--reject-unresolved', + '--no-compile-autoload-dotenv', + '--no-compile-autoload-bunfig', ] as const; export { NPM_BUNDLE_OPTIONS as JAVASCRIPT_BUNDLE_OPTIONS }; -const repositoryRoot = join(import.meta.dir, "../.."); +const repositoryRoot = join(import.meta.dir, '../..'); const entrypoint = NPM_BUNDLE_PATH; -const defaultOutputDirectory = join(repositoryRoot, "dist"); -const bunVersionFile = join(repositoryRoot, ".bun-version"); +const defaultOutputDirectory = join(repositoryRoot, 'dist'); +const bunVersionFile = join(repositoryRoot, '.bun-version'); type PackageJson = typeof packageJson & { engines: { bun: string }; @@ -46,7 +50,7 @@ type PackageJson = typeof packageJson & { }; export type ReleaseArtifact = { - kind: "native" | "javascript" | "documentation"; + kind: 'native' | 'javascript' | 'documentation'; file: string; bytes: number; sha256: string; @@ -54,20 +58,20 @@ export type ReleaseArtifact = { bunTarget?: string; recipe: | { - builder: "bun-build-executable"; + builder: 'bun-build-executable'; bunVersion: string; target: string; flags: readonly string[]; } | { - builder: "bun-build"; + builder: 'bun-build'; bunVersion: string; - target: "bun"; + target: 'bun'; minify: boolean; - sourcemap: "none"; + sourcemap: 'none'; } | { - builder: "command-reference"; + builder: 'command-reference'; schemaVersion: number; }; }; @@ -100,28 +104,30 @@ export async function readToolchainContract(): Promise { }; } -export async function assertToolchain(actualBunVersion = Bun.version): Promise { +export async function assertToolchain( + actualBunVersion = Bun.version +): Promise { const contract = await readToolchainContract(); const expectedPackageManager = `bun@${contract.bunVersion}`; if (contract.packageManager !== expectedPackageManager) { throw new Error( - `packageManager must be ${expectedPackageManager}; received ${contract.packageManager}.`, + `packageManager must be ${expectedPackageManager}; received ${contract.packageManager}.` ); } if (contract.enginesBun !== SUPPORTED_BUN_RUNTIME_RANGE) { throw new Error( - `engines.bun must preserve the supported runtime range ${SUPPORTED_BUN_RUNTIME_RANGE}; received ${contract.enginesBun}.`, + `engines.bun must preserve the supported runtime range ${SUPPORTED_BUN_RUNTIME_RANGE}; received ${contract.enginesBun}.` ); } if (actualBunVersion !== contract.bunVersion) { throw new Error( - `Release builds require Bun ${contract.bunVersion}; received ${actualBunVersion}.`, + `Release builds require Bun ${contract.bunVersion}; received ${actualBunVersion}.` ); } if (packageJson.version !== VERSION) { throw new Error( - `cli/package.json version (${packageJson.version}) must match cli/src/version.ts (${VERSION}).`, + `cli/package.json version (${packageJson.version}) must match cli/src/version.ts (${VERSION}).` ); } return contract; @@ -129,14 +135,19 @@ export async function assertToolchain(actualBunVersion = Bun.version): Promise { await assertToolchain(); await mkdir(outputDirectory, { recursive: true }); @@ -187,7 +200,7 @@ export async function compileReleaseTarget( } export async function buildJavaScriptBundle( - outputDirectory = defaultOutputDirectory, + outputDirectory = defaultOutputDirectory ): Promise { await assertToolchain(); await mkdir(outputDirectory, { recursive: true }); @@ -195,14 +208,17 @@ export async function buildJavaScriptBundle( await buildNpmBundle(); await copyFile(NPM_BUNDLE_PATH, outputPath); await assertNonemptyFile(outputPath); - if ((await sha256(Bun.file(NPM_BUNDLE_PATH))) !== (await sha256(Bun.file(outputPath)))) { - throw new Error("GitHub and npm JavaScript release bundles differ."); + if ( + (await sha256(Bun.file(NPM_BUNDLE_PATH))) !== + (await sha256(Bun.file(outputPath))) + ) { + throw new Error('GitHub and npm JavaScript release bundles differ.'); } return outputPath; } export async function stageCommandReferenceAssets( - outputDirectory = defaultOutputDirectory, + outputDirectory = defaultOutputDirectory ): Promise { await mkdir(outputDirectory, { recursive: true }); const sourcePaths = [ @@ -216,12 +232,12 @@ export async function stageCommandReferenceAssets( }; if (reference.cliVersion !== VERSION) { throw new Error( - `${RELEASE_CLI_REFERENCE_ASSET} version must be ${VERSION}; received ${String(reference.cliVersion)}.`, + `${RELEASE_CLI_REFERENCE_ASSET} version must be ${VERSION}; received ${String(reference.cliVersion)}.` ); } if (reference.schemaVersion !== CLI_REFERENCE_SCHEMA_VERSION) { throw new Error( - `${RELEASE_CLI_REFERENCE_ASSET} schema version must be ${CLI_REFERENCE_SCHEMA_VERSION}; received ${String(reference.schemaVersion)}.`, + `${RELEASE_CLI_REFERENCE_ASSET} schema version must be ${CLI_REFERENCE_SCHEMA_VERSION}; received ${String(reference.schemaVersion)}.` ); } @@ -231,7 +247,7 @@ export async function stageCommandReferenceAssets( const schemaVersion = schema.properties?.schemaVersion?.const; if (schemaVersion !== CLI_REFERENCE_SCHEMA_VERSION) { throw new Error( - `${RELEASE_CLI_REFERENCE_SCHEMA_ASSET} must describe schema version ${CLI_REFERENCE_SCHEMA_VERSION}; received ${String(schemaVersion)}.`, + `${RELEASE_CLI_REFERENCE_SCHEMA_ASSET} must describe schema version ${CLI_REFERENCE_SCHEMA_VERSION}; received ${String(schemaVersion)}.` ); } @@ -245,16 +261,16 @@ export async function stageCommandReferenceAssets( await assertNonemptyFile(outputPath); const file = Bun.file(outputPath); return { - kind: "documentation", + kind: 'documentation', file: fileName, bytes: file.size, sha256: await sha256(file), recipe: { - builder: "command-reference", + builder: 'command-reference', schemaVersion: CLI_REFERENCE_SCHEMA_VERSION, }, }; - }), + }) ); } @@ -262,7 +278,7 @@ export async function buildAllReleaseArtifacts( options: { outputDirectory?: string; tag?: string; - } = {}, + } = {} ): Promise { await assertToolchain(); const outputDirectory = options.outputDirectory ?? defaultOutputDirectory; @@ -278,7 +294,7 @@ export async function buildAllReleaseArtifacts( export async function writeReleaseMetadata( outputDirectory = defaultOutputDirectory, - tag = expectedReleaseTag, + tag = expectedReleaseTag ): Promise { await assertToolchain(); assertReleaseTag(tag); @@ -290,27 +306,30 @@ export async function writeReleaseMetadata( throw new Error(`Missing or empty release artifact: ${target.asset}.`); } return { - kind: "native", + kind: 'native', file: target.asset, bytes: file.size, sha256: await sha256(file), platform: target.platform, bunTarget: target.bunTarget, recipe: { - builder: "bun-build-executable", + builder: 'bun-build-executable', bunVersion: Bun.version, target: target.bunTarget, flags: HARDENED_COMPILE_FLAGS, }, }; - }), + }) ); const bundle = Bun.file(join(outputDirectory, RELEASE_BUNDLE_ASSET)); if (!(await bundle.exists()) || bundle.size === 0) { - throw new Error(`Missing or empty release artifact: ${RELEASE_BUNDLE_ASSET}.`); + throw new Error( + `Missing or empty release artifact: ${RELEASE_BUNDLE_ASSET}.` + ); } - const documentationArtifacts = await stageCommandReferenceAssets(outputDirectory); + const documentationArtifacts = + await stageCommandReferenceAssets(outputDirectory); const metadata: ReleaseMetadata = { schemaVersion: RELEASE_MANIFEST_SCHEMA_VERSION, @@ -323,12 +342,12 @@ export async function writeReleaseMetadata( artifacts: [ ...nativeArtifacts, { - kind: "javascript", + kind: 'javascript', file: RELEASE_BUNDLE_ASSET, bytes: bundle.size, sha256: await sha256(bundle), recipe: { - builder: "bun-build", + builder: 'bun-build', bunVersion: Bun.version, ...NPM_BUNDLE_OPTIONS, }, @@ -341,7 +360,10 @@ export async function writeReleaseMetadata( await writeAtomic(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); const checksumEntries = [ - ...metadata.artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })), + ...metadata.artifacts.map(artifact => ({ + file: artifact.file, + sha256: artifact.sha256, + })), { file: RELEASE_METADATA_ASSET, sha256: await sha256(Bun.file(metadataPath)), @@ -349,7 +371,7 @@ export async function writeReleaseMetadata( ].sort((left, right) => left.file.localeCompare(right.file)); await writeAtomic( join(outputDirectory, RELEASE_CHECKSUMS_ASSET), - `${checksumEntries.map((entry) => `${entry.sha256} ${entry.file}`).join("\n")}\n`, + `${checksumEntries.map(entry => `${entry.sha256} ${entry.file}`).join('\n')}\n` ); return metadata; @@ -369,51 +391,53 @@ async function writeAtomic(path: string, contents: string): Promise { } async function sha256(file: Blob): Promise { - const hasher = new Bun.CryptoHasher("sha256"); + const hasher = new Bun.CryptoHasher('sha256'); hasher.update(await file.arrayBuffer()); - return hasher.digest("hex"); + return hasher.digest('hex'); } async function run(command: string[]): Promise { const child = Bun.spawn(command, { cwd: repositoryRoot, - stdout: "inherit", - stderr: "inherit", + stdout: 'inherit', + stderr: 'inherit', }); const exitCode = await child.exited; if (exitCode !== 0) { - throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`); } } async function runCapture(command: string[]): Promise { const child = Bun.spawn(command, { cwd: repositoryRoot, - stdout: "pipe", - stderr: "inherit", + stdout: 'pipe', + stderr: 'inherit', }); const output = await new Response(child.stdout).text(); const exitCode = await child.exited; if (exitCode !== 0) { - throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`); } return output; } export async function smokeReleaseTarget( target: ReleaseTarget, - outputDirectory = defaultOutputDirectory, + outputDirectory = defaultOutputDirectory ): Promise { const executable = join(outputDirectory, target.asset); await assertNonemptyFile(executable); const verifySignature = signatureVerificationCommand(target, executable); if (verifySignature) await run(verifySignature); - const version = (await runCapture([executable, "--version"])).trim(); + const version = (await runCapture([executable, '--version'])).trim(); if (version !== VERSION) { - throw new Error(`${target.asset} reported version ${version}; expected ${VERSION}.`); + throw new Error( + `${target.asset} reported version ${version}; expected ${VERSION}.` + ); } - const help = await runCapture([executable, "--help"]); - if (!help.includes("Altertable CLI") || !help.includes("Commands")) { + const help = await runCapture([executable, '--help']); + if (!help.includes('Altertable CLI') || !help.includes('Commands')) { throw new Error(`${target.asset} returned incomplete help output.`); } await smokeDataApp([executable]); @@ -421,86 +445,96 @@ export async function smokeReleaseTarget( } function optionValue(arguments_: string[], name: string): string | undefined { - return arguments_.find((argument) => argument.startsWith(`${name}=`))?.slice(`${name}=`.length); + return arguments_ + .find(argument => argument.startsWith(`${name}=`)) + ?.slice(`${name}=`.length); } async function main(arguments_: string[]): Promise { const command = arguments_[0]; - if (command === "matrix") { + if (command === 'matrix') { console.log(JSON.stringify(releaseCiMatrix())); return; } - if (command === "verify") { + if (command === 'verify') { await assertToolchain(); - const tag = optionValue(arguments_, "--tag"); + const tag = optionValue(arguments_, '--tag'); if (tag) { assertReleaseTag(tag); } - console.log(`Release contract verified for Bun ${Bun.version} and ${expectedReleaseTag}.`); + console.log( + `Release contract verified for Bun ${Bun.version} and ${expectedReleaseTag}.` + ); return; } - if (command === "finalize") { - const outputDirectory = optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory; + if (command === 'finalize') { + const outputDirectory = + optionValue(arguments_, '--output-dir') ?? defaultOutputDirectory; const metadata = await writeReleaseMetadata( outputDirectory, - optionValue(arguments_, "--tag") ?? expectedReleaseTag, + optionValue(arguments_, '--tag') ?? expectedReleaseTag ); console.log(`Finalized ${metadata.artifacts.length} release artifacts.`); return; } - if (command === "smoke") { - const requestedTarget = optionValue(arguments_, "--target"); - const target = requestedTarget ? findReleaseTargetByBunTarget(requestedTarget) : undefined; + if (command === 'smoke') { + const requestedTarget = optionValue(arguments_, '--target'); + const target = requestedTarget + ? findReleaseTargetByBunTarget(requestedTarget) + : undefined; if (!target) { throw new Error( requestedTarget ? `Unsupported Bun release target: ${requestedTarget}.` - : "smoke requires --target=.", + : 'smoke requires --target=.' ); } await smokeReleaseTarget( target, - optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory, + optionValue(arguments_, '--output-dir') ?? defaultOutputDirectory ); return; } - if (command !== "build") { + if (command !== 'build') { throw new Error( - "Usage: release.ts [--native|--bundle|--all|--target=]", + 'Usage: release.ts [--native|--bundle|--all|--target=]' ); } - const outputDirectory = optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory; - if (arguments_.includes("--all")) { + const outputDirectory = + optionValue(arguments_, '--output-dir') ?? defaultOutputDirectory; + if (arguments_.includes('--all')) { const metadata = await buildAllReleaseArtifacts({ outputDirectory, - tag: optionValue(arguments_, "--tag"), + tag: optionValue(arguments_, '--tag'), }); - console.log(`Built and finalized ${metadata.artifacts.length} release artifacts.`); + console.log( + `Built and finalized ${metadata.artifacts.length} release artifacts.` + ); return; } - if (arguments_.includes("--bundle")) { + if (arguments_.includes('--bundle')) { const outputPath = await buildJavaScriptBundle(outputDirectory); console.log(`Built ${outputPath} (Bun JavaScript bundle).`); return; } - const requestedTarget = optionValue(arguments_, "--target"); + const requestedTarget = optionValue(arguments_, '--target'); const target = requestedTarget ? findReleaseTargetByBunTarget(requestedTarget) - : arguments_.includes("--native") + : arguments_.includes('--native') ? nativeReleaseTarget() : undefined; if (!target) { throw new Error( requestedTarget ? `Unsupported Bun release target: ${requestedTarget}.` - : "build requires --native, --bundle, --all, or --target=.", + : 'build requires --native, --bundle, --all, or --target=.' ); } diff --git a/cli/scripts/script-output.test.ts b/cli/scripts/script-output.test.ts index ab23e367..25b567e2 100644 --- a/cli/scripts/script-output.test.ts +++ b/cli/scripts/script-output.test.ts @@ -1,56 +1,58 @@ -import { describe, expect, test } from "bun:test"; -import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; -import { renderQueryMarkdown } from "@/lib/query-format.ts"; +import { describe, expect, test } from 'bun:test'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; +import { renderQueryMarkdown } from '@/lib/query-format.ts'; -describe("script output lossless guarantees", () => { - test("CSV round-trips cells with commas, quotes, newlines, and Unicode", () => { +describe('script output lossless guarantees', () => { + test('CSV round-trips cells with commas, quotes, newlines, and Unicode', () => { const trickyValue = 'say "hello",\nworld — café'; const result = { metadata: {}, - columns: ["label", "note"], - rows: [{ label: trickyValue, note: "plain" }], + columns: ['label', 'note'], + rows: [{ label: trickyValue, note: 'plain' }], }; const csv = renderQueryCsv(result); expect(csv).toContain('""hello""'); - expect(csv).toContain("café"); - expect(csv).not.toContain("…"); + expect(csv).toContain('café'); + expect(csv).not.toContain('…'); // Newlines inside quoted fields produce multi-line CSV; verify round-trip via split lines - const lines = csv.split("\n"); + const lines = csv.split('\n'); expect(lines.length).toBeGreaterThan(1); - const rejoined = lines.slice(1).join("\n"); + const rejoined = lines.slice(1).join('\n'); expect(rejoined.startsWith('"')).toBe(true); - expect(rejoined).toContain("world — café"); + expect(rejoined).toContain('world — café'); }); - test("JSON output preserves full cell values without truncation", () => { - const longPayload = { nested: { value: "x".repeat(500) } }; + test('JSON output preserves full cell values without truncation', () => { + const longPayload = { nested: { value: 'x'.repeat(500) } }; const result = { metadata: { init_time_ms: 42 }, - columns: ["payload"], + columns: ['payload'], rows: [{ payload: longPayload }], }; const json = renderQueryJson(result); - const parsed = JSON.parse(json) as { rows: Array<{ payload: { nested: { value: string } } }> }; + const parsed = JSON.parse(json) as { + rows: Array<{ payload: { nested: { value: string } } }>; + }; expect(parsed.rows[0]?.payload.nested.value).toBe(longPayload.nested.value); - expect(json).not.toContain("…"); - expect(json).not.toContain("\u001b"); + expect(json).not.toContain('…'); + expect(json).not.toContain('\u001b'); }); - test("markdown escapes pipe characters in cells", () => { + test('markdown escapes pipe characters in cells', () => { const result = { metadata: {}, - columns: ["label"], - rows: [{ label: "a|b|c" }], + columns: ['label'], + rows: [{ label: 'a|b|c' }], }; - const output = renderQueryMarkdown(result, ["label"], { - layout: "table", + const output = renderQueryMarkdown(result, ['label'], { + layout: 'table', maxColumnWidth: 80, terminalWidth: 120, }); - expect(output).toContain("a\\|b\\|c"); + expect(output).toContain('a\\|b\\|c'); expect(output).not.toMatch(/\| a \|/); }); }); diff --git a/cli/scripts/smoke-data-app.ts b/cli/scripts/smoke-data-app.ts index aa67c2ec..d01e2f32 100644 --- a/cli/scripts/smoke-data-app.ts +++ b/cli/scripts/smoke-data-app.ts @@ -1,49 +1,75 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; /** Exercise the shipped CLI outside the checkout, including its embedded starter and published runtime. */ -export async function smokeDataApp(command: string[], scaffoldOnly = false): Promise { - const directory = await mkdtemp(join(tmpdir(), "altertable-packaged-app-")); - const app = join(directory, "app"); +export async function smokeDataApp( + command: string[], + scaffoldOnly = false +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'altertable-packaged-app-')); + const app = join(directory, 'app'); let upstream: Bun.Server | undefined; const env = { ...process.env }; - for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; + for (const key of Object.keys(env)) + if (key.startsWith('ALTERTABLE_')) delete env[key]; // App scripts stream their own output and require human mode. - env.ALTERTABLE_AGENT_MODE = "0"; - env.ALTERTABLE_CONFIG_HOME = join(directory, "config"); + env.ALTERTABLE_AGENT_MODE = '0'; + env.ALTERTABLE_CONFIG_HOME = join(directory, 'config'); async function run(arguments_: string[], cwd: string): Promise { - const child = Bun.spawn(arguments_, { cwd, env, stdout: "pipe", stderr: "pipe" }); + const child = Bun.spawn(arguments_, { + cwd, + env, + stdout: 'pipe', + stderr: 'pipe', + }); const [stdout, stderr, code] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); if (code !== 0) - throw new Error(`Packaged app smoke failed: ${arguments_.join(" ")}\n${stderr}\n${stdout}`); + throw new Error( + `Packaged app smoke failed: ${arguments_.join(' ')}\n${stderr}\n${stdout}` + ); } async function preview(singleFile = false): Promise { - const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + const reservation = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: () => new Response(), + }); const port = reservation.port!; await reservation.stop(true); const url = `http://127.0.0.1:${port}`; - const child = Bun.spawn([...command, "app", "dev", "--dir", app, "--port", String(port)], { - cwd: directory, - env, - stdout: "pipe", - stderr: "pipe", - }); + const child = Bun.spawn( + [...command, 'app', 'dev', '--dir', app, '--port', String(port)], + { + cwd: directory, + env, + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), ]); async function stopPreview(): Promise { - child.kill("SIGTERM"); + child.kill('SIGTERM'); try { - await withTimeout(child.exited, 5000, "Packaged data app preview did not stop."); + await withTimeout( + child.exited, + 5000, + 'Packaged data app preview did not stop.' + ); } catch (error) { - child.kill("SIGKILL"); - await withTimeout(child.exited, 5000, "Packaged data app preview could not be killed."); + child.kill('SIGKILL'); + await withTimeout( + child.exited, + 5000, + 'Packaged data app preview could not be killed.' + ); throw error; } } @@ -52,10 +78,10 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro const deadline = Date.now() + 20_000; let ready = false; while (Date.now() < deadline && child.exitCode === null) { - ready = await fetch(`${url}${singleFile ? "/bundle.js" : "/"}`, { + ready = await fetch(`${url}${singleFile ? '/bundle.js' : '/'}`, { signal: AbortSignal.timeout(1000), }) - .then(async (response) => { + .then(async response => { await response.text(); return response.ok; }) @@ -63,109 +89,144 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro if (ready) break; await Bun.sleep(50); } - if (!ready) throw new Error("Packaged data app preview did not start."); + if (!ready) throw new Error('Packaged data app preview did not start.'); if (singleFile) { - const bundle = await fetch(`${url}/bundle.js`, { signal: AbortSignal.timeout(5000) }); - if (!(await bundle.text()).includes("File preview smoke")) - throw new Error("File preview did not bundle the app."); + const bundle = await fetch(`${url}/bundle.js`, { + signal: AbortSignal.timeout(5000), + }); + if (!(await bundle.text()).includes('File preview smoke')) + throw new Error('File preview did not bundle the app.'); } - const query = await fetch(`${url}${singleFile ? "/api/sql" : "/api/data/connection"}`, { - signal: AbortSignal.timeout(5000), - method: "POST", - headers: { origin: url, "content-type": "application/json" }, - body: JSON.stringify(singleFile ? { statement: "select 1", limit: 1, params: {} } : {}), - }); + const query = await fetch( + `${url}${singleFile ? '/api/sql' : '/api/data/connection'}`, + { + signal: AbortSignal.timeout(5000), + method: 'POST', + headers: { origin: url, 'content-type': 'application/json' }, + body: JSON.stringify( + singleFile ? { statement: 'select 1', limit: 1, params: {} } : {} + ), + } + ); if (!query.ok) throw new Error(`Preview SQL failed (${query.status}).`); - const result = (await query.json()) as { rows?: unknown[][]; data?: unknown }; + const result = (await query.json()) as { + rows?: unknown[][]; + data?: unknown; + }; if (singleFile ? result.rows?.[0]?.[0] !== 1 : result.data !== true) - throw new Error("Preview did not return the lakehouse query result."); + throw new Error('Preview did not return the lakehouse query result.'); } catch (error) { failure = error; } finally { await stopPreview(); } - const [stdout, stderr] = await withTimeout(output, 5000, "Preview output did not close."); + const [stdout, stderr] = await withTimeout( + output, + 5000, + 'Preview output did not close.' + ); if (failure || (singleFile && child.exitCode !== 0)) throw new Error( - `Packaged preview failed: ${failure instanceof Error ? failure.message : child.exitCode}\n${stderr}\n${stdout}`, + `Packaged preview failed: ${failure instanceof Error ? failure.message : child.exitCode}\n${stderr}\n${stdout}` ); if ( await fetch(url, { signal: AbortSignal.timeout(1000) }).then( - async (response) => { + async response => { await response.text(); return true; }, (error: unknown) => { - if (error instanceof Error && error.name === "TimeoutError") throw error; + if (error instanceof Error && error.name === 'TimeoutError') + throw error; return false; - }, + } ) ) - throw new Error("Preview is still serving after shutdown."); + throw new Error('Preview is still serving after shutdown.'); } try { await run( - [...command, "app", "create", "package-smoke", "--dir", app, "--without-profile"], - directory, + [ + ...command, + 'app', + 'create', + 'package-smoke', + '--dir', + app, + '--without-profile', + ], + directory ); - const manifest = await Bun.file(join(app, "package.json")).json(); + const manifest = await Bun.file(join(app, 'package.json')).json(); for (const name of [ - "@altertable/data-app", - "@altertable/data-app-react", - "@altertable/data-app-bun", + '@altertable/data-app', + '@altertable/data-app-react', + '@altertable/data-app-bun', ]) { - if (manifest.dependencies?.[name] !== "0.71.0") - throw new Error(`Packaged starter must pin ${name} to the tested release`); + if (manifest.dependencies?.[name] !== '0.71.0') + throw new Error( + `Packaged starter must pin ${name} to the tested release` + ); } - for (const name of ["@altertable/data-app-embed", "@altertable/data-app-worker"]) { - if (manifest.dependencies?.[name]) throw new Error(`Packaged starter does not use ${name}`); + for (const name of [ + '@altertable/data-app-embed', + '@altertable/data-app-worker', + ]) { + if (manifest.dependencies?.[name]) + throw new Error(`Packaged starter does not use ${name}`); } - if (await Bun.file(join(app, ".altertable/runtime/package.json")).exists()) - throw new Error("Packaged starter must not vendor the runtime"); + if (await Bun.file(join(app, '.altertable/runtime/package.json')).exists()) + throw new Error('Packaged starter must not vendor the runtime'); if (scaffoldOnly) return; upstream = Bun.serve({ - hostname: "127.0.0.1", + hostname: '127.0.0.1', port: 0, fetch(request) { if ( - request.headers.get("authorization") !== - `Basic ${Buffer.from("preview-user:preview-smoke-secret").toString("base64")}` + request.headers.get('authorization') !== + `Basic ${Buffer.from('preview-user:preview-smoke-secret').toString('base64')}` ) - return new Response("Unauthorized", { status: 401 }); + return new Response('Unauthorized', { status: 401 }); return new Response( '{"query_id":"smoke"}\n[{"name":"connection_check","type":"INTEGER"}]\n[1]', { - headers: { "content-type": "application/x-ndjson" }, - }, + headers: { 'content-type': 'application/x-ndjson' }, + } ); }, }); Object.assign(env, { ALTERTABLE_API_BASE: upstream.url.origin, - ALTERTABLE_LAKEHOUSE_USERNAME: "preview-user", - ALTERTABLE_LAKEHOUSE_PASSWORD: "preview-smoke-secret", + ALTERTABLE_LAKEHOUSE_USERNAME: 'preview-user', + ALTERTABLE_LAKEHOUSE_PASSWORD: 'preview-smoke-secret', }); - await run([process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], app); + await run( + [process.execPath, 'install', '--frozen-lockfile', '--ignore-scripts'], + app + ); await run( [ process.execPath, - "-e", + '-e', 'import { createDataHandler } from "@altertable/data-app/server"; import { localLakehouse } from "@altertable/data-app-bun"; if (typeof createDataHandler !== "function" || typeof localLakehouse !== "function") process.exit(1);', ], - app, + app ); - await run([...command, "app", "lint", "--dir", app], directory); + await run([...command, 'app', 'lint', '--dir', app], directory); await preview(); await Bun.write( - join(app, "index.tsx"), + join(app, 'index.tsx'), `import { defineDataApp as declareApp } from "@altertable/data-app"; import { mountDataApp } from "@altertable/data-app-react"; const app = declareApp({ title: "File preview smoke", description: "Smoke test", scope: { organization: "Test", environment: "test" }, queries: {} }); function View() { return

File preview smoke

; } -mountDataApp({ app, component: View });`, +mountDataApp({ app, component: View });` + ); + manifest.scripts.dev = 'bun src/preview.ts'; + await Bun.write( + join(app, 'package.json'), + `${JSON.stringify(manifest, null, 2)}\n` ); - manifest.scripts.dev = "bun src/preview.ts"; - await Bun.write(join(app, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); await preview(true); } finally { await upstream?.stop(true); @@ -176,7 +237,7 @@ mountDataApp({ app, component: View });`, async function withTimeout( promise: Promise, milliseconds: number, - message: string, + message: string ): Promise { let timer: ReturnType | undefined; try { diff --git a/cli/scripts/smoke-npm-bundle.ts b/cli/scripts/smoke-npm-bundle.ts index f3f3f3e6..9a2a2ea5 100644 --- a/cli/scripts/smoke-npm-bundle.ts +++ b/cli/scripts/smoke-npm-bundle.ts @@ -1,52 +1,66 @@ -import { smokeDataApp } from "@/../scripts/smoke-data-app.ts"; -import { join } from "node:path"; +import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; +import { join } from 'node:path'; -const repositoryRoot = join(import.meta.dir, "../.."); -const defaultBundlePath = join(repositoryRoot, "cli/dist/cli.js"); +const repositoryRoot = join(import.meta.dir, '../..'); +const defaultBundlePath = join(repositoryRoot, 'cli/dist/cli.js'); function optionValue(arguments_: string[], name: string): string | undefined { - return arguments_.find((argument) => argument.startsWith(`${name}=`))?.slice(`${name}=`.length); + return arguments_ + .find(argument => argument.startsWith(`${name}=`)) + ?.slice(`${name}=`.length); } -async function runBundle(bundlePath: string, arguments_: string[]): Promise { +async function runBundle( + bundlePath: string, + arguments_: string[] +): Promise { const child = Bun.spawn([process.execPath, bundlePath, ...arguments_], { cwd: repositoryRoot, - stdout: "pipe", - stderr: "inherit", + stdout: 'pipe', + stderr: 'inherit', }); const output = await new Response(child.stdout).text(); const exitCode = await child.exited; if (exitCode !== 0) { - throw new Error(`npm bundle exited with ${exitCode}: ${arguments_.join(" ")}`); + throw new Error( + `npm bundle exited with ${exitCode}: ${arguments_.join(' ')}` + ); } return output; } -export async function smokeNpmBundle(arguments_ = Bun.argv.slice(2)): Promise { - const expectedBun = optionValue(arguments_, "--expected-bun"); +export async function smokeNpmBundle( + arguments_ = Bun.argv.slice(2) +): Promise { + const expectedBun = optionValue(arguments_, '--expected-bun'); if (expectedBun && Bun.version !== expectedBun) { throw new Error(`Expected Bun ${expectedBun}; received ${Bun.version}.`); } - const bundlePath = optionValue(arguments_, "--bundle") ?? defaultBundlePath; - const version = (await runBundle(bundlePath, ["--version"])).trim(); + const bundlePath = optionValue(arguments_, '--bundle') ?? defaultBundlePath; + const version = (await runBundle(bundlePath, ['--version'])).trim(); if (!/^\d+\.\d+\.\d+(?:-.+)?$/.test(version)) { throw new Error(`npm bundle returned an invalid version: ${version}.`); } - const help = await runBundle(bundlePath, ["--no-agent", "--help"]); - if (!help.includes("Altertable CLI") || !help.includes("Commands")) { - throw new Error("npm bundle help output is incomplete."); + const help = await runBundle(bundlePath, ['--no-agent', '--help']); + if (!help.includes('Altertable CLI') || !help.includes('Commands')) { + throw new Error('npm bundle help output is incomplete.'); } - const specification = JSON.parse(await runBundle(bundlePath, ["api", "spec", "--json"])) as { + const specification = JSON.parse( + await runBundle(bundlePath, ['api', 'spec', '--json']) + ) as { openapi?: string; }; - if (specification.openapi !== "3.1.0") { - throw new Error("npm bundle returned an unexpected OpenAPI document."); + if (specification.openapi !== '3.1.0') { + throw new Error('npm bundle returned an unexpected OpenAPI document.'); } - await smokeDataApp([process.execPath, bundlePath], arguments_.includes("--scaffold-only")); + await smokeDataApp( + [process.execPath, bundlePath], + arguments_.includes('--scaffold-only') + ); console.log(`Bun ${Bun.version}: npm bundle smoke test passed.`); } diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 977a9a58..1d862ab4 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,88 +1,105 @@ #!/usr/bin/env bun -import { VERSION } from "@/version.ts"; -import { getCliContext, getBootstrapCliContext, isJsonOutput, setCliContext } from "@/context.ts"; -import { createCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { mergeGlobalFlagsFromArgs, parseGlobalOutputFlags } from "@/lib/global-flags.ts"; -import { buildTopLevelCommands } from "@/commands/index.ts"; +import { VERSION } from '@/version.ts'; +import { + getCliContext, + getBootstrapCliContext, + isJsonOutput, + setCliContext, +} from '@/context.ts'; +import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; +import { + mergeGlobalFlagsFromArgs, + parseGlobalOutputFlags, +} from '@/lib/global-flags.ts'; +import { buildTopLevelCommands } from '@/commands/index.ts'; import { CliError, EXIT_SUCCESS, getCliExitCode, isCommandParseError, shouldShowCommandExamplesOnError, -} from "@/lib/errors.ts"; -import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; -import { defineArguments, defineCommand, type Command } from "@/lib/command.ts"; -import { executeCommand, resolveCommandSelection } from "@/lib/command-parser.ts"; +} from '@/lib/errors.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; +import { defineArguments, defineCommand, type Command } from '@/lib/command.ts'; +import { + executeCommand, + resolveCommandSelection, +} from '@/lib/command-parser.ts'; import { resolveSubCommandForUsage, showAltertableUsage, showCommandExamplesForArgs, -} from "@/lib/usage.ts"; -import { findEarlyBootstrapExit } from "@/lib/early-bootstrap.ts"; -import { applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; -import { maybeShowUpdateNotice } from "@/lib/updater.ts"; -import { validateEnvironment } from "@/lib/env.ts"; +} from '@/lib/usage.ts'; +import { findEarlyBootstrapExit } from '@/lib/early-bootstrap.ts'; +import { applyTerminalColorFromContext } from '@/ui/terminal/styles.ts'; +import { maybeShowUpdateNotice } from '@/lib/updater.ts'; +import { validateEnvironment } from '@/lib/env.ts'; const ROOT_ARGS = defineArguments({ help: { - type: "boolean", - alias: "h", - description: "Show this help", - flagScope: "global", + type: 'boolean', + alias: 'h', + description: 'Show this help', + flagScope: 'global', }, version: { - type: "boolean", - alias: "v", - description: "Show the Altertable CLI version", - flagScope: "root-only", + type: 'boolean', + alias: 'v', + description: 'Show the Altertable CLI version', + flagScope: 'root-only', }, debug: { - type: "boolean", - alias: "d", - description: "Enable debug output", - flagScope: "global", + type: 'boolean', + alias: 'd', + description: 'Enable debug output', + flagScope: 'global', }, json: { - type: "boolean", - description: "Machine-readable JSON output", - flagScope: "global", + type: 'boolean', + description: 'Machine-readable JSON output', + flagScope: 'global', }, agent: { - type: "boolean", - description: "Agent-friendly preset: structured JSON output, no pager or terminal styling", - flagScope: "global", + type: 'boolean', + description: + 'Agent-friendly preset: structured JSON output, no pager or terminal styling', + flagScope: 'global', }, - "no-agent": { - type: "boolean", - description: "Disable agent mode, including automatic detection", - flagScope: "global", + 'no-agent': { + type: 'boolean', + description: 'Disable agent mode, including automatic detection', + flagScope: 'global', }, - "no-color": { - type: "boolean", - description: "Disable terminal colors and styling", - flagScope: "global", + 'no-color': { + type: 'boolean', + description: 'Disable terminal colors and styling', + flagScope: 'global', }, profile: { - type: "string", - description: "Use a named profile for this command only", - flagScope: "global", + type: 'string', + description: 'Use a named profile for this command only', + flagScope: 'global', }, - "connect-timeout": { - type: "string", - description: "HTTP connect timeout in seconds (default 5)", - flagScope: "global", + 'connect-timeout': { + type: 'string', + description: 'HTTP connect timeout in seconds (default 5)', + flagScope: 'global', }, - "read-timeout": { - type: "string", - description: "HTTP read timeout in seconds (default 60; 0 = no limit for streams)", - flagScope: "global", + 'read-timeout': { + type: 'string', + description: + 'HTTP read timeout in seconds (default 60; 0 = no limit for streams)', + flagScope: 'global', }, - "ignore-ssl-errors": { - type: "boolean", + 'ignore-ssl-errors': { + type: 'boolean', description: - "Skip TLS certificate verification for HTTPS requests (for development only, e.g. self-signed local servers)", - flagScope: "global", + 'Skip TLS certificate verification for HTTPS requests (for development only, e.g. self-signed local servers)', + flagScope: 'global', }, }); @@ -93,13 +110,13 @@ export function buildMainCommand(): Command { mainCommand = defineCommand({ metadata: { - name: "altertable", + name: 'altertable', description: `Altertable CLI v${VERSION} • Query and manage your data platform from the terminal.`, examples: [ - "altertable profile configure", - "altertable profile show", - "altertable api route", - "altertable api /environments/production/databases", + 'altertable profile configure', + 'altertable profile show', + 'altertable api route', + 'altertable api /environments/production/databases', 'altertable query "SELECT * FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10"', ], }, @@ -150,24 +167,30 @@ async function bootstrap(): Promise { setCliContext(earlyContext); const earlyExit = findEarlyBootstrapExit(rawArgs); - if (earlyExit?.id === "help") { + if (earlyExit?.id === 'help') { const [command, parent] = await resolveSubCommandForUsage(main, rawArgs); await showAltertableUsage(command, parent, main); await maybeShowUpdateNotice({ context: getCliContext(), - commandName: (await resolveCommandSelection(main, rawArgs)).commandPath.at(0) ?? "help", + commandName: + (await resolveCommandSelection(main, rawArgs)).commandPath.at(0) ?? + 'help', }); return EXIT_SUCCESS; } - if (earlyExit?.id === "version") { + if (earlyExit?.id === 'version') { console.log(VERSION); return EXIT_SUCCESS; } validateEnvironment(); const selection = await executeCommand(main, rawArgs); - if (selection.command !== main && !selection.command.run && selection.command.subcommands) { + if ( + selection.command !== main && + !selection.command.run && + selection.command.subcommands + ) { await showAltertableUsage(selection.command, selection.parent, main); } await maybeShowUpdateNotice({ @@ -183,7 +206,10 @@ async function bootstrap(): Promise { if (showExamplesOnHumanOutput) { await showAltertableUsage(command, parent); } - } else if (showExamplesOnHumanOutput && shouldShowCommandExamplesOnError(error)) { + } else if ( + showExamplesOnHumanOutput && + shouldShowCommandExamplesOnError(error) + ) { await showCommandExamplesForArgs(main, rawArgs); } return handleCliError(error); @@ -191,7 +217,7 @@ async function bootstrap(): Promise { } if (import.meta.main) { - void bootstrap().then((exitCode) => { + void bootstrap().then(exitCode => { process.exitCode = exitCode; }); } diff --git a/cli/src/commands/api/index.test.ts b/cli/src/commands/api/index.test.ts index c97eb42e..73ae30a1 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -1,18 +1,22 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { buildMainCommand } from "@/cli.ts"; -import { apiCommand } from "@/commands/api/index.ts"; -import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; -import { setCliContext } from "@/context.ts"; -import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; -import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -import type { Command } from "@/lib/command.ts"; -import { executeCommand } from "@/lib/command-parser.ts"; - -describe("api", () => { +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildMainCommand } from '@/cli.ts'; +import { apiCommand } from '@/commands/api/index.ts'; +import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; +import { setCliContext } from '@/context.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; +import { + createCliRuntime, + getCliRuntime, + setCliRuntime, +} from '@/lib/runtime.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; +import type { Command } from '@/lib/command.ts'; +import { executeCommand } from '@/lib/command-parser.ts'; + +describe('api', () => { beforeEach(() => { setCliContext({ debug: false, json: false, agent: false }); }); @@ -21,44 +25,51 @@ describe("api", () => { setCliContext({ debug: false, json: false, agent: false }); }); - test("api spec prints YAML containing Altertable Management API", async () => { - const result = await runCommandWithTestRuntime(["api", "spec"], { + test('api spec prints YAML containing Altertable Management API', async () => { + const result = await runCommandWithTestRuntime(['api', 'spec'], { debug: false, json: false, agent: false, }); - const output = result.stdout.join(""); - expect(output).toContain("Altertable Management API"); - expect(output).toContain("openapi: 3.1.0"); - expect(output).not.toContain("AUTO-GENERATED"); + const output = result.stdout.join(''); + expect(output).toContain('Altertable Management API'); + expect(output).toContain('openapi: 3.1.0'); + expect(output).not.toContain('AUTO-GENERATED'); }); - test("api spec with JSON context prints parseable JSON with openapi 3.1.0", async () => { - const result = await runCommandWithTestRuntime(["api", "spec"], { + test('api spec with JSON context prints parseable JSON with openapi 3.1.0', async () => { + const result = await runCommandWithTestRuntime(['api', 'spec'], { debug: false, json: true, agent: false, }); - const output = result.stdout.join(""); - const document = JSON.parse(output) as { openapi?: string; info?: { title?: string } }; - expect(document.openapi).toBe("3.1.0"); - expect(document.info?.title).toBe("Altertable Management API"); + const output = result.stdout.join(''); + const document = JSON.parse(output) as { + openapi?: string; + info?: { title?: string }; + }; + expect(document.openapi).toBe('3.1.0'); + expect(document.info?.title).toBe('Altertable Management API'); }); - test("api spec subcommand runs without ENDPOINT validation errors", async () => { + test('api spec subcommand runs without ENDPOINT validation errors', async () => { const stdout: string[] = []; const stderr: string[] = []; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeHuman = (text) => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + runtime.output.writeHuman = text => { stdout.push(text); }; - runtime.output.writeRaw = (body) => { + runtime.output.writeRaw = body => { stdout.push(body); }; - runtime.output.writeStderr = (line) => { + runtime.output.writeStderr = line => { stderr.push(line); }; - runtime.output.writeMetadata = (lines) => { + runtime.output.writeMetadata = lines => { stderr.push(...lines); }; @@ -66,82 +77,97 @@ describe("api", () => { setCliRuntime(runtime); try { - await executeCommand(buildMainCommand(), ["api", "spec"]); + await executeCommand(buildMainCommand(), ['api', 'spec']); } finally { setCliRuntime(previousRuntime); } - const output = stdout.join(""); - const errorOutput = stderr.join(""); - expect(errorOutput).not.toContain("ENDPOINT"); - expect(output).toContain("openapi: 3.1.0"); - expect(output).not.toContain("AUTO-GENERATED"); + const output = stdout.join(''); + const errorOutput = stderr.join(''); + expect(errorOutput).not.toContain('ENDPOINT'); + expect(output).toContain('openapi: 3.1.0'); + expect(output).not.toContain('AUTO-GENERATED'); }); - test("OPENAPI_OPERATIONS lists createDatabase", () => { - expect(OPENAPI_OPERATIONS.some((operation) => operation.operationId === "createDatabase")).toBe( - true, - ); + test('OPENAPI_OPERATIONS lists createDatabase', () => { + expect( + OPENAPI_OPERATIONS.some( + operation => operation.operationId === 'createDatabase' + ) + ).toBe(true); }); - test("api route inspects one operation in human mode", async () => { - const result = await runCommandWithTestRuntime(["api", "route", "createDatabase"], { - debug: false, - json: false, - agent: false, - }); - const output = result.stdout.join(""); - expect(output).toContain("createDatabase"); - expect(output).toContain("Path:"); - expect(output).toContain("/environments/{environment_id}/databases"); - expect(output).toContain("environment_id"); + test('api route inspects one operation in human mode', async () => { + const result = await runCommandWithTestRuntime( + ['api', 'route', 'createDatabase'], + { + debug: false, + json: false, + agent: false, + } + ); + const output = result.stdout.join(''); + expect(output).toContain('createDatabase'); + expect(output).toContain('Path:'); + expect(output).toContain('/environments/{environment_id}/databases'); + expect(output).toContain('environment_id'); }); - test("api route operation detail includes path parameters in JSON mode", async () => { + test('api route operation detail includes path parameters in JSON mode', async () => { const result = await runCommandWithTestRuntime( - ["api", "route", "createServiceAccountCredential"], - { debug: false, json: true, agent: false }, + ['api', 'route', 'createServiceAccountCredential'], + { debug: false, json: true, agent: false } ); - const output = result.stdout.join(""); - const operation = JSON.parse(output) as { operationId?: string; parameters?: string[] }; - expect(operation.operationId).toBe("createServiceAccountCredential"); - expect(operation.parameters).toEqual(["service_account_id", "environment_id"]); + const output = result.stdout.join(''); + const operation = JSON.parse(output) as { + operationId?: string; + parameters?: string[]; + }; + expect(operation.operationId).toBe('createServiceAccountCredential'); + expect(operation.parameters).toEqual([ + 'service_account_id', + 'environment_id', + ]); }); - test("buildMainCommand top-level names include api and exclude connections", async () => { + test('buildMainCommand top-level names include api and exclude connections', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - const topLevelNames = spec.subcommands.map((command) => command.name); - expect(topLevelNames).toContain("api"); - expect(topLevelNames).not.toContain("connections"); - expect(topLevelNames).not.toContain("service-accounts"); - expect(topLevelNames).not.toContain("databases"); - expect(topLevelNames).not.toContain("credentials"); + const topLevelNames = spec.subcommands.map(command => command.name); + expect(topLevelNames).toContain('api'); + expect(topLevelNames).not.toContain('connections'); + expect(topLevelNames).not.toContain('service-accounts'); + expect(topLevelNames).not.toContain('databases'); + expect(topLevelNames).not.toContain('credentials'); }); - test("api command tree exposes spec and route subcommands", () => { + test('api command tree exposes spec and route subcommands', () => { const subcommands = apiCommand.subcommands as Record; expect(subcommands.spec?.run).toBeDefined(); expect(subcommands.route?.run).toBeDefined(); }); - test("api routes remains a callable hidden alias", async () => { - const result = await runCommandWithTestRuntime(["api", "routes", "createDatabase"]); - expect(result.stdout.join("")).toContain("createDatabase"); + test('api routes remains a callable hidden alias', async () => { + const result = await runCommandWithTestRuntime([ + 'api', + 'routes', + 'createDatabase', + ]); + expect(result.stdout.join('')).toContain('createDatabase'); }); - describe("api HTTP invoker", () => { - let testHome = ""; - let mockFile = ""; - let logFile = ""; + describe('api HTTP invoker', () => { + let testHome = ''; + let mockFile = ''; + let logFile = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-api-test-")); - mockFile = join(testHome, "mocks.json"); - logFile = join(testHome, "http.log"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-api-test-')); + mockFile = join(testHome, 'mocks.json'); + logFile = join(testHome, 'http.log'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - process.env.ALTERTABLE_API_KEY = "atm_test"; + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; + process.env.ALTERTABLE_API_KEY = 'atm_test'; }); afterEach(() => { @@ -152,51 +178,51 @@ describe("api", () => { delete process.env.ALTERTABLE_API_KEY; }); - test("endpoint path without method issues a single HTTP request", async () => { + test('endpoint path without method issues a single HTTP request', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', body: '{"user":{"email":"dev@example.com"}}', }, - ]), + ]) ); - await runCommandWithTestRuntime(["api", "/whoami"]); + await runCommandWithTestRuntime(['api', '/whoami']); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("/rest/v1/whoami"); - expect(logContent).toContain("METHOD=GET"); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain('/rest/v1/whoami'); + expect(logContent).toContain('METHOD=GET'); }); - test("-X POST issues a single HTTP request", async () => { + test('-X POST issues a single HTTP request', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts", - method: "POST", + urlPattern: '/service_accounts', + method: 'POST', body: '{"service_account":{"id":"sa_1","label":"CI Bot","slug":"ci-bot"}}', }, - ]), + ]) ); await runCommandWithTestRuntime([ - "api", - "/service_accounts", - "-X", - "POST", - "-f", - "label=CI Bot", + 'api', + '/service_accounts', + '-X', + 'POST', + '-f', + 'label=CI Bot', ]); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); const payloadLines = logContent.match(/^PAYLOAD=.*$/gm) ?? []; expect(payloadLines).toHaveLength(1); - expect(logContent).toContain("/rest/v1/service_accounts"); - expect(logContent).not.toContain("/rest/v1/POST"); + expect(logContent).toContain('/rest/v1/service_accounts'); + expect(logContent).not.toContain('/rest/v1/POST'); }); }); }); diff --git a/cli/src/commands/api/index.ts b/cli/src/commands/api/index.ts index dfb9471d..b52b953b 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -1,19 +1,25 @@ -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { executeApiHttp, apiHttpResultOutput } from "@/commands/api/lib/http.ts"; -import { API_HTTP_BASE_ARGS, resolveApiCommandRequest } from "@/commands/api/lib/command.ts"; -import { apiSpecCommand } from "@/commands/api/spec.ts"; -import { apiRouteCommand, apiRoutesCommand } from "@/commands/api/routes.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { + executeApiHttp, + apiHttpResultOutput, +} from '@/commands/api/lib/http.ts'; +import { + API_HTTP_BASE_ARGS, + resolveApiCommandRequest, +} from '@/commands/api/lib/command.ts'; +import { apiSpecCommand } from '@/commands/api/spec.ts'; +import { apiRouteCommand, apiRoutesCommand } from '@/commands/api/routes.ts'; export const apiCommand = defineCommand({ metadata: { - name: "api", - commandGroup: "platform", - description: "Management REST API — HTTP invoker and OpenAPI spec.", + name: 'api', + commandGroup: 'platform', + description: 'Management REST API — HTTP invoker and OpenAPI spec.', examples: [ - "altertable api /whoami", - "altertable api route", - "altertable api /environments/production/connections", + 'altertable api /whoami', + 'altertable api route', + 'altertable api /environments/production/connections', 'altertable api /service_accounts -X POST -F label="CI Bot"', ], }, @@ -24,7 +30,10 @@ export const apiCommand = defineCommand({ spec: apiSpecCommand, }, async run({ args, execution, sink }) { - const result = await executeApiHttp(resolveApiCommandRequest(args), execution); + const result = await executeApiHttp( + resolveApiCommandRequest(args), + execution + ); const output = apiHttpResultOutput(result, sink); if (output) await writeCommandOutput(output, sink); }, diff --git a/cli/src/commands/api/lib/body.ts b/cli/src/commands/api/lib/body.ts index b486c903..2c6b9014 100644 --- a/cli/src/commands/api/lib/body.ts +++ b/cli/src/commands/api/lib/body.ts @@ -1,10 +1,10 @@ -import { readFileSync } from "node:fs"; -import { CliError, ParseError } from "@/lib/errors.ts"; +import { readFileSync } from 'node:fs'; +import { CliError, ParseError } from '@/lib/errors.ts'; -const BODY_METHOD_NAMES = ["POST", "PATCH", "PUT"] as const; +const BODY_METHOD_NAMES = ['POST', 'PATCH', 'PUT'] as const; const BODY_METHODS = new Set(BODY_METHOD_NAMES); -const BODY_METHOD_LIST = BODY_METHOD_NAMES.join(", "); -const FIELD_METHOD_NAMES = ["GET", "DELETE"] as const; +const BODY_METHOD_LIST = BODY_METHOD_NAMES.join(', '); +const FIELD_METHOD_NAMES = ['GET', 'DELETE'] as const; const FIELD_QUERY_METHODS = new Set(FIELD_METHOD_NAMES); export function readJsonInput(inputArg?: string): string | undefined { @@ -12,11 +12,11 @@ export function readJsonInput(inputArg?: string): string | undefined { return undefined; } let body: string; - if (inputArg === "-") { - body = readFileSync(0, "utf8"); + if (inputArg === '-') { + body = readFileSync(0, 'utf8'); } else { try { - body = readFileSync(inputArg, "utf8"); + body = readFileSync(inputArg, 'utf8'); } catch { throw new CliError(`File not found: ${inputArg}`); } @@ -29,18 +29,18 @@ function validateJsonBody(body: string): string { try { JSON.parse(body); } catch (error) { - throw new ParseError("Invalid JSON body.", { cause: error }); + throw new ParseError('Invalid JSON body.', { cause: error }); } return body; } function normalizeFieldEntries( - fields: Record | string[] | string | undefined, + fields: Record | string[] | string | undefined ): string[] { if (fields === undefined) { return []; } - if (typeof fields === "string") { + if (typeof fields === 'string') { return [fields]; } if (Array.isArray(fields)) { @@ -50,28 +50,34 @@ function normalizeFieldEntries( } function parseFieldEntry(entry: string): [string, string] { - const separatorIndex = entry.indexOf("="); + const separatorIndex = entry.indexOf('='); if (separatorIndex <= 0) { - throw new CliError(`Invalid field ${JSON.stringify(entry)}. Expected key=value.`); + throw new CliError( + `Invalid field ${JSON.stringify(entry)}. Expected key=value.` + ); } const key = entry.slice(0, separatorIndex); const value = entry.slice(separatorIndex + 1); if (key.length === 0) { - throw new CliError(`Invalid field ${JSON.stringify(entry)}. Expected key=value.`); + throw new CliError( + `Invalid field ${JSON.stringify(entry)}. Expected key=value.` + ); } return [key, value]; } type ExactJsonInteger = { - kind: "exact_json_integer"; + kind: 'exact_json_integer'; json: string; }; function canonicalizeJsonInteger(value: string): string { - const negative = value.startsWith("-"); + const negative = value.startsWith('-'); const digits = negative ? value.slice(1) : value; - const canonicalDigits = digits.replace(/^0+/, "") || "0"; - return negative && canonicalDigits !== "0" ? `-${canonicalDigits}` : canonicalDigits; + const canonicalDigits = digits.replace(/^0+/, '') || '0'; + return negative && canonicalDigits !== '0' + ? `-${canonicalDigits}` + : canonicalDigits; } export type ApiFieldValue = string | number | boolean | null | ExactJsonInteger; @@ -82,27 +88,29 @@ export type ParsedApiField = { }; function parseTypedFieldValue(value: string): ApiFieldValue { - if (value === "true") { + if (value === 'true') { return true; } - if (value === "false") { + if (value === 'false') { return false; } - if (value === "null") { + if (value === 'null') { return null; } if (/^-?\d+$/.test(value)) { const json = canonicalizeJsonInteger(value); const parsed = Number(json); - return Number.isSafeInteger(parsed) ? parsed : { kind: "exact_json_integer", json }; + return Number.isSafeInteger(parsed) + ? parsed + : { kind: 'exact_json_integer', json }; } - if (value === "@-") { - return readFileSync(0, "utf8"); + if (value === '@-') { + return readFileSync(0, 'utf8'); } - if (value.startsWith("@")) { + if (value.startsWith('@')) { const filePath = value.slice(1); try { - return readFileSync(filePath, "utf8"); + return readFileSync(filePath, 'utf8'); } catch { throw new CliError(`File not found: ${filePath}`); } @@ -111,32 +119,36 @@ function parseTypedFieldValue(value: string): ApiFieldValue { } function parseRawFields( - fields: Record | string[] | string | undefined, + fields: Record | string[] | string | undefined ): ParsedApiField[] { - return normalizeFieldEntries(fields).map((entry) => { + return normalizeFieldEntries(fields).map(entry => { const [key, value] = parseFieldEntry(entry); return { key, value }; }); } function parseTypedFields( - fields: Record | string[] | string | undefined, + fields: Record | string[] | string | undefined ): ParsedApiField[] { - return normalizeFieldEntries(fields).map((entry) => { + return normalizeFieldEntries(fields).map(entry => { const [key, value] = parseFieldEntry(entry); return { key, value: parseTypedFieldValue(value) }; }); } function isExactJsonInteger(value: ApiFieldValue): value is ExactJsonInteger { - return typeof value === "object" && value !== null && value.kind === "exact_json_integer"; + return ( + typeof value === 'object' && + value !== null && + value.kind === 'exact_json_integer' + ); } export function formatApiFieldValue(value: ApiFieldValue): string { if (isExactJsonInteger(value)) { return value.json; } - return value === null ? "null" : String(value); + return value === null ? 'null' : String(value); } function serializeApiFieldValue(value: ApiFieldValue): string { @@ -149,9 +161,9 @@ function serializeApiFields(fields: ParsedApiField[]): string { valuesByKey.set(field.key, field.value); } const properties = [...valuesByKey].map( - ([key, value]) => `${JSON.stringify(key)}:${serializeApiFieldValue(value)}`, + ([key, value]) => `${JSON.stringify(key)}:${serializeApiFieldValue(value)}` ); - return `{${properties.join(",")}}`; + return `{${properties.join(',')}}`; } export type ResolveApiBodyOptions = { @@ -168,20 +180,20 @@ export type ResolvedApiRequestPayload = { }; export function resolveApiRequestPayload( - options: ResolveApiBodyOptions, + options: ResolveApiBodyOptions ): ResolvedApiRequestPayload { const method = options.method.toUpperCase(); const inputArg = options.input; const rawFields = parseRawFields(options.rawFields ?? options.fields); const typedFields = parseTypedFields(options.typedFields); const parsedFields = [...rawFields, ...typedFields]; - const hasBody = inputArg !== undefined && inputArg !== ""; + const hasBody = inputArg !== undefined && inputArg !== ''; const hasFields = parsedFields.length > 0; if (hasBody && hasFields) { if (!BODY_METHODS.has(method)) { throw new CliError( - `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input.`, + `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input.` ); } @@ -194,7 +206,7 @@ export function resolveApiRequestPayload( if (FIELD_QUERY_METHODS.has(method)) { if (hasBody) { throw new CliError( - `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input.`, + `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input.` ); } @@ -206,7 +218,7 @@ export function resolveApiRequestPayload( if (!BODY_METHODS.has(method)) { if (hasBody || hasFields) { throw new CliError( - `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input or request fields.`, + `${method} requests do not accept a body. Use ${BODY_METHOD_LIST} for --input or request fields.` ); } return { queryFields: [] }; diff --git a/cli/src/commands/api/lib/command.ts b/cli/src/commands/api/lib/command.ts index 873c3adc..f2c76dd0 100644 --- a/cli/src/commands/api/lib/command.ts +++ b/cli/src/commands/api/lib/command.ts @@ -1,58 +1,67 @@ -import { resolveApiHttp } from "@/commands/api/lib/http.ts"; -import { optionalStringArg } from "@/lib/args.ts"; -import { defineArguments } from "@/lib/command.ts"; +import { resolveApiHttp } from '@/commands/api/lib/http.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { defineArguments } from '@/lib/command.ts'; export const API_HTTP_BASE_ARGS = defineArguments({ method: { - type: "enum", - alias: "X", - description: "HTTP method override (default GET, or POST when fields/input are provided)", - options: ["GET", "POST", "PATCH", "DELETE", "PUT"], + type: 'enum', + alias: 'X', + description: + 'HTTP method override (default GET, or POST when fields/input are provided)', + options: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT'], }, endpoint: { - type: "positional", - description: "Path under /rest/v1, e.g. /whoami", + type: 'positional', + description: 'Path under /rest/v1, e.g. /whoami', required: false, directRequired: true, }, - "raw-field": { - type: "string", - alias: "f", + 'raw-field': { + type: 'string', + alias: 'f', repeatable: true, - description: "String request parameter key=value (repeatable; gh api -f semantics)", + description: + 'String request parameter key=value (repeatable; gh api -f semantics)', }, field: { - type: "string", - alias: "F", + type: 'string', + alias: 'F', repeatable: true, - description: "Typed request parameter key=value (true, false, null, integers; repeatable)", + description: + 'Typed request parameter key=value (true, false, null, integers; repeatable)', }, - input: { type: "string", description: "File to use as the request body; use - for stdin" }, - env: { type: "string", description: "Replace {environment_id} in the path" }, + input: { + type: 'string', + description: 'File to use as the request body; use - for stdin', + }, + env: { type: 'string', description: 'Replace {environment_id} in the path' }, format: { - type: "enum", - description: "Serialized output format; use global --json for JSON", - options: ["csv", "markdown"], + type: 'enum', + description: 'Serialized output format; use global --json for JSON', + options: ['csv', 'markdown'], }, }); -export function resolveApiCommandRequest(args: Record, method?: string) { - const rawFields = repeatableStringArg(args["raw-field"]); +export function resolveApiCommandRequest( + args: Record, + method?: string +) { + const rawFields = repeatableStringArg(args['raw-field']); const typedFields = repeatableStringArg(args.field); return resolveApiHttp({ - method: optionalStringArg(args, "method") ?? method, - endpoint: optionalStringArg(args, "endpoint"), - input: optionalStringArg(args, "input"), + method: optionalStringArg(args, 'method') ?? method, + endpoint: optionalStringArg(args, 'endpoint'), + input: optionalStringArg(args, 'input'), rawFields, typedFields, - env: optionalStringArg(args, "env"), - format: optionalStringArg(args, "format"), + env: optionalStringArg(args, 'env'), + format: optionalStringArg(args, 'format'), }); } function repeatableStringArg(value: unknown): string | string[] | undefined { - if (typeof value === "string") return value; - if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + if (typeof value === 'string') return value; + if (Array.isArray(value) && value.every(entry => typeof entry === 'string')) { return value; } return undefined; diff --git a/cli/src/commands/api/lib/http-conformance.test.ts b/cli/src/commands/api/lib/http-conformance.test.ts index c5ec4de2..8e0f3c82 100644 --- a/cli/src/commands/api/lib/http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -1,19 +1,19 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; -import { setCliContext } from "@/context.ts"; -import { executeApiHttp, resolveApiHttp } from "@/commands/api/lib/http.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; +import { setCliContext } from '@/context.ts'; +import { executeApiHttp, resolveApiHttp } from '@/commands/api/lib/http.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; const PLACEHOLDER_VALUES: Record = { - environment_id: "production", - id: "id_1", - user_id: "user_1", - service_account_id: "sa_1", + environment_id: 'production', + id: 'id_1', + user_id: 'user_1', + service_account_id: 'sa_1', }; function substituteOpenapiPath(path: string): string { @@ -26,15 +26,15 @@ function substituteOpenapiPath(path: string): string { }); } -let testHome = ""; -let mockFile = ""; +let testHome = ''; +let mockFile = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-openapi-http-test-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-openapi-http-test-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - process.env.ALTERTABLE_API_KEY = "atm_test"; + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; + process.env.ALTERTABLE_API_KEY = 'atm_test'; setCliContext({ debug: false, json: true, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); }); @@ -46,22 +46,22 @@ afterEach(() => { delete process.env.ALTERTABLE_API_KEY; }); -describe("openapi HTTP conformance", () => { - test("covers all generated operations", () => { +describe('openapi HTTP conformance', () => { + test('covers all generated operations', () => { expect(OPENAPI_OPERATIONS.length).toBe(40); }); - test("every operation is reachable via executeApiHttp", async () => { - const mocks = OPENAPI_OPERATIONS.map((operation) => { + test('every operation is reachable via executeApiHttp', async () => { + const mocks = OPENAPI_OPERATIONS.map(operation => { const endpoint = substituteOpenapiPath(operation.path); const encodedPath = encodeManagementEndpoint(endpoint); return { urlPattern: encodedPath, method: operation.method, - body: operation.method === "DELETE" ? "" : "{}", + body: operation.method === 'DELETE' ? '' : '{}', }; }); - writeFileSync(mockFile, JSON.stringify(mocks), "utf8"); + writeFileSync(mockFile, JSON.stringify(mocks), 'utf8'); const runtime = getCliRuntime(); const execution = createExecutionContext(runtime); @@ -69,8 +69,10 @@ describe("openapi HTTP conformance", () => { for (const operation of OPENAPI_OPERATIONS) { const endpoint = substituteOpenapiPath(operation.path); const bodyFields = - operation.method === "POST" || operation.method === "PATCH" || operation.method === "PUT" - ? { fields: ["label=default"] } + operation.method === 'POST' || + operation.method === 'PATCH' || + operation.method === 'PUT' + ? { fields: ['label=default'] } : {}; await executeApiHttp( @@ -79,7 +81,7 @@ describe("openapi HTTP conformance", () => { endpoint, ...bodyFields, }), - execution, + execution ); } }); diff --git a/cli/src/commands/api/lib/http.test.ts b/cli/src/commands/api/lib/http.test.ts index fcc94ece..44340a14 100644 --- a/cli/src/commands/api/lib/http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -1,46 +1,49 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { readJsonInput, resolveApiRequestPayload } from "@/commands/api/lib/body.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setCliContext } from '@/context.ts'; +import { + readJsonInput, + resolveApiRequestPayload, +} from '@/commands/api/lib/body.ts'; import { apiHttpResultOutput, executeApiHttp, normalizeApiEndpoint, resolveApiHttp, type ApiHttpArgs, -} from "@/commands/api/lib/http.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { ParseError } from "@/lib/errors.ts"; +} from '@/commands/api/lib/http.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { ParseError } from '@/lib/errors.ts'; -let testHome = ""; -let mockFile = ""; -let logFile = ""; -let stdout = ""; +let testHome = ''; +let mockFile = ''; +let logFile = ''; +let stdout = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-api-http-test-")); - mockFile = join(testHome, "mocks.json"); - logFile = join(testHome, "http.log"); - stdout = ""; + testHome = mkdtempSync(join(tmpdir(), 'altertable-api-http-test-')); + mockFile = join(testHome, 'mocks.json'); + logFile = join(testHome, 'http.log'); + stdout = ''; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - process.env.ALTERTABLE_API_KEY = "atm_test"; + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; + process.env.ALTERTABLE_API_KEY = 'atm_test'; setCliContext({ debug: false, json: false, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); const runtime = getCliRuntime(); - runtime.output.writeRaw = (body) => { + runtime.output.writeRaw = body => { stdout += body; }; - runtime.output.writeHuman = (text) => { + runtime.output.writeHuman = text => { stdout += text; }; - runtime.output.writeJson = (data) => { + runtime.output.writeJson = data => { stdout += JSON.stringify(data); }; }); @@ -55,373 +58,410 @@ afterEach(() => { async function runApiOperation(args: ApiHttpArgs): Promise { const runtime = getCliRuntime(); - const result = await executeApiHttp(resolveApiHttp(args), createExecutionContext(runtime)); + const result = await executeApiHttp( + resolveApiHttp(args), + createExecutionContext(runtime) + ); const output = apiHttpResultOutput(result, runtime.output); if (output) { await writeCommandOutput(output, runtime.output); } } -describe("api-body", () => { - test("builds a JSON body from request fields", () => { +describe('api-body', () => { + test('builds a JSON body from request fields', () => { const payload = resolveApiRequestPayload({ - method: "POST", - rawFields: ["label=CI Bot", "name=Analytics"], + method: 'POST', + rawFields: ['label=CI Bot', 'name=Analytics'], + }); + expect(JSON.parse(payload.body ?? '')).toEqual({ + label: 'CI Bot', + name: 'Analytics', }); - expect(JSON.parse(payload.body ?? "")).toEqual({ label: "CI Bot", name: "Analytics" }); }); test("preserves typed integers outside JavaScript's safe range", () => { const payload = resolveApiRequestPayload({ - method: "POST", - typedFields: ["id=0009007199254740993", "negative=-0009007199254740993", "zero=-000"], + method: 'POST', + typedFields: [ + 'id=0009007199254740993', + 'negative=-0009007199254740993', + 'zero=-000', + ], }); - expect(payload.body).toBe('{"id":9007199254740993,"negative":-9007199254740993,"zero":0}'); - expect(() => JSON.parse(payload.body ?? "")).not.toThrow(); + expect(payload.body).toBe( + '{"id":9007199254740993,"negative":-9007199254740993,"zero":0}' + ); + expect(() => JSON.parse(payload.body ?? '')).not.toThrow(); expect( resolveApiHttp({ - endpoint: "/records", - method: "GET", - typedFields: ["id=0009007199254740993"], - }).endpoint, - ).toBe("/records?id=9007199254740993"); + endpoint: '/records', + method: 'GET', + typedFields: ['id=0009007199254740993'], + }).endpoint + ).toBe('/records?id=9007199254740993'); }); - test("keeps an explicit JSON request body from --input", () => { - const filePath = join(testHome, "payload.json"); - writeFileSync(filePath, '{"label":"raw"}', "utf8"); + test('keeps an explicit JSON request body from --input', () => { + const filePath = join(testHome, 'payload.json'); + writeFileSync(filePath, '{"label":"raw"}', 'utf8'); const payload = resolveApiRequestPayload({ - method: "POST", + method: 'POST', input: filePath, }); expect(payload).toEqual({ body: '{"label":"raw"}', queryFields: [] }); }); - test("keeps fields in the query when an explicit POST body is provided", () => { - const filePath = join(testHome, "payload-with-fields.json"); - writeFileSync(filePath, '{"label":"raw"}', "utf8"); + test('keeps fields in the query when an explicit POST body is provided', () => { + const filePath = join(testHome, 'payload-with-fields.json'); + writeFileSync(filePath, '{"label":"raw"}', 'utf8'); const payload = resolveApiRequestPayload({ - method: "POST", + method: 'POST', input: filePath, - rawFields: ["label=flags"], + rawFields: ['label=flags'], }); expect(payload).toEqual({ body: '{"label":"raw"}', - queryFields: [{ key: "label", value: "flags" }], + queryFields: [{ key: 'label', value: 'flags' }], }); }); - test("keeps GET fields out of the request body", () => { + test('keeps GET fields out of the request body', () => { const payload = resolveApiRequestPayload({ - method: "GET", - rawFields: ["label=query"], + method: 'GET', + rawFields: ['label=query'], }); - expect(payload).toEqual({ queryFields: [{ key: "label", value: "query" }] }); + expect(payload).toEqual({ + queryFields: [{ key: 'label', value: 'query' }], + }); }); - test("rejects explicit body input for methods without request bodies", () => { - const filePath = join(testHome, "get-payload.json"); - writeFileSync(filePath, '{"label":"bad"}', "utf8"); + test('rejects explicit body input for methods without request bodies', () => { + const filePath = join(testHome, 'get-payload.json'); + writeFileSync(filePath, '{"label":"bad"}', 'utf8'); expect(() => resolveApiRequestPayload({ - method: "GET", + method: 'GET', input: filePath, - }), - ).toThrow("GET requests do not accept a body"); + }) + ).toThrow('GET requests do not accept a body'); }); - test("readJsonInput reads file payloads", () => { - const filePath = join(testHome, "payload.json"); - writeFileSync(filePath, '{"name":"from-file"}', "utf8"); + test('readJsonInput reads file payloads', () => { + const filePath = join(testHome, 'payload.json'); + writeFileSync(filePath, '{"name":"from-file"}', 'utf8'); expect(readJsonInput(filePath)).toBe('{"name":"from-file"}'); }); - test("readJsonInput rejects invalid JSON from file payloads", () => { - const filePath = join(testHome, "invalid-payload.json"); - writeFileSync(filePath, "{not-json", "utf8"); + test('readJsonInput rejects invalid JSON from file payloads', () => { + const filePath = join(testHome, 'invalid-payload.json'); + writeFileSync(filePath, '{not-json', 'utf8'); expect(() => readJsonInput(filePath)).toThrow(ParseError); }); - test("rejects invalid JSON from --input files", () => { - const filePath = join(testHome, "invalid-input.json"); - writeFileSync(filePath, "{not-json", "utf8"); + test('rejects invalid JSON from --input files', () => { + const filePath = join(testHome, 'invalid-input.json'); + writeFileSync(filePath, '{not-json', 'utf8'); expect(() => resolveApiRequestPayload({ - method: "POST", + method: 'POST', input: filePath, - }), + }) ).toThrow(ParseError); }); - test("returns valid --input file payloads unchanged", () => { - const filePath = join(testHome, "valid-input.json"); - writeFileSync(filePath, '{"name":"from-input-file"}', "utf8"); + test('returns valid --input file payloads unchanged', () => { + const filePath = join(testHome, 'valid-input.json'); + writeFileSync(filePath, '{"name":"from-input-file"}', 'utf8'); const payload = resolveApiRequestPayload({ - method: "POST", + method: 'POST', input: filePath, }); - expect(payload).toEqual({ body: '{"name":"from-input-file"}', queryFields: [] }); + expect(payload).toEqual({ + body: '{"name":"from-input-file"}', + queryFields: [], + }); }); }); -describe("normalizeApiEndpoint", () => { - test("requires a leading slash and rejects full URLs", () => { - expect(normalizeApiEndpoint("/whoami")).toBe("/whoami"); - expect(normalizeApiEndpoint("whoami")).toBe("/whoami"); - expect(() => normalizeApiEndpoint("https://example.com/whoami")).toThrow("full URL"); +describe('normalizeApiEndpoint', () => { + test('requires a leading slash and rejects full URLs', () => { + expect(normalizeApiEndpoint('/whoami')).toBe('/whoami'); + expect(normalizeApiEndpoint('whoami')).toBe('/whoami'); + expect(() => normalizeApiEndpoint('https://example.com/whoami')).toThrow( + 'full URL' + ); }); - test("--env replaces {environment_id}", () => { - expect(normalizeApiEndpoint("/environments/{environment_id}/databases", "production")).toBe( - "/environments/production/databases", - ); + test('--env replaces {environment_id}', () => { + expect( + normalizeApiEndpoint( + '/environments/{environment_id}/databases', + 'production' + ) + ).toBe('/environments/production/databases'); }); }); -describe("executeApiHttp", () => { - test("GET writes generic tabular output in human mode", async () => { +describe('executeApiHttp', () => { + test('GET writes generic tabular output in human mode', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', body: '{"principal":{"name":"Jane"},"organization":{"name":"Acme"}}', }, - ]), + ]) ); - await runApiOperation({ method: "GET", endpoint: "/whoami" }); - expect(stdout).toContain("Acme"); + await runApiOperation({ method: 'GET', endpoint: '/whoami' }); + expect(stdout).toContain('Acme'); }); - test("GET --json writes raw API body", async () => { + test('GET --json writes raw API body', async () => { setCliContext({ debug: false, json: true, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); - stdout = ""; + stdout = ''; writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', body: '{"principal":{"name":"Jane"},"organization":{"name":"Acme"}}', }, - ]), + ]) ); - await runApiOperation({ method: "GET", endpoint: "/whoami" }); - expect(stdout).toBe('{"principal":{"name":"Jane"},"organization":{"name":"Acme"}}'); + await runApiOperation({ method: 'GET', endpoint: '/whoami' }); + expect(stdout).toBe( + '{"principal":{"name":"Jane"},"organization":{"name":"Acme"}}' + ); }); - test("POST -f builds JSON body", async () => { + test('POST -f builds JSON body', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts", - method: "POST", + urlPattern: '/service_accounts', + method: 'POST', body: '{"service_account":{"id":"sa_1","label":"CI Bot"}}', }, - ]), + ]) ); await runApiOperation({ - method: "POST", - endpoint: "/service_accounts", - fields: ["label=CI Bot"], + method: 'POST', + endpoint: '/service_accounts', + fields: ['label=CI Bot'], }); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); expect(logContent).toContain('PAYLOAD={"label":"CI Bot"}'); - expect(stdout).toContain("CI Bot"); + expect(stdout).toContain('CI Bot'); }); - test("defaults to POST when fields are supplied without a method", async () => { + test('defaults to POST when fields are supplied without a method', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts", - method: "POST", + urlPattern: '/service_accounts', + method: 'POST', body: '{"service_account":{"id":"sa_1","label":"CI Bot"}}', }, - ]), + ]) ); await runApiOperation({ - endpoint: "/service_accounts", - rawFields: ["label=CI Bot"], + endpoint: '/service_accounts', + rawFields: ['label=CI Bot'], }); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("METHOD=POST"); - expect(logContent).toContain("URL=https://app.example.com/rest/v1/service_accounts"); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain('METHOD=POST'); + expect(logContent).toContain( + 'URL=https://app.example.com/rest/v1/service_accounts' + ); expect(logContent).toContain('PAYLOAD={"label":"CI Bot"}'); }); - test("-X GET style requests put fields in the query string", async () => { + test('-X GET style requests put fields in the query string', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts?label=CI+Bot", - method: "GET", + urlPattern: '/service_accounts?label=CI+Bot', + method: 'GET', body: '{"service_accounts":[]}', }, - ]), + ]) ); await runApiOperation({ - method: "GET", - endpoint: "/service_accounts", - rawFields: ["label=CI Bot"], + method: 'GET', + endpoint: '/service_accounts', + rawFields: ['label=CI Bot'], }); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("METHOD=GET"); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain('METHOD=GET'); expect(logContent).toContain( - "URL=https://app.example.com/rest/v1/service_accounts?label=CI+Bot", + 'URL=https://app.example.com/rest/v1/service_accounts?label=CI+Bot' ); }); - test("typed fields preserve JSON types in request bodies", async () => { + test('typed fields preserve JSON types in request bodies', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts", - method: "POST", - body: "{}", + urlPattern: '/service_accounts', + method: 'POST', + body: '{}', }, - ]), + ]) ); await runApiOperation({ - endpoint: "/service_accounts", - typedFields: ["enabled=true", "priority=3", "description=null"], + endpoint: '/service_accounts', + typedFields: ['enabled=true', 'priority=3', 'description=null'], }); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain('PAYLOAD={"enabled":true,"priority":3,"description":null}'); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain( + 'PAYLOAD={"enabled":true,"priority":3,"description":null}' + ); }); - test("--input sends the body and moves fields to the query string", async () => { - const payloadPath = join(testHome, "payload.json"); - writeFileSync(payloadPath, '{"name":"from-file"}', "utf8"); + test('--input sends the body and moves fields to the query string', async () => { + const payloadPath = join(testHome, 'payload.json'); + writeFileSync(payloadPath, '{"name":"from-file"}', 'utf8'); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts?dry_run=true", - method: "POST", - body: "{}", + urlPattern: '/service_accounts?dry_run=true', + method: 'POST', + body: '{}', }, - ]), + ]) ); await runApiOperation({ - method: "POST", - endpoint: "/service_accounts", + method: 'POST', + endpoint: '/service_accounts', input: payloadPath, - rawFields: ["dry_run=true"], + rawFields: ['dry_run=true'], }); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("METHOD=POST"); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain('METHOD=POST'); expect(logContent).toContain( - "URL=https://app.example.com/rest/v1/service_accounts?dry_run=true", + 'URL=https://app.example.com/rest/v1/service_accounts?dry_run=true' ); expect(logContent).toContain('PAYLOAD={"name":"from-file"}'); }); - test("encodes special characters in path segments", async () => { - const id = "foo+bar"; + test('encodes special characters in path segments', async () => { + const id = 'foo+bar'; const encodedId = encodeURIComponent(id); writeFileSync( mockFile, JSON.stringify([ { urlPattern: `/service_accounts/${encodedId}`, - method: "GET", - body: "{}", + method: 'GET', + body: '{}', }, - ]), + ]) ); - await runApiOperation({ method: "GET", endpoint: `/service_accounts/${id}` }); + await runApiOperation({ + method: 'GET', + endpoint: `/service_accounts/${id}`, + }); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); expect(logContent).toContain( - `URL=https://app.example.com/rest/v1/service_accounts/${encodedId}`, + `URL=https://app.example.com/rest/v1/service_accounts/${encodedId}` ); }); - test("preserves query strings while encoding path segments", async () => { + test('preserves query strings while encoding path segments', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts?limit=10&label=CI%20Bot", - method: "GET", + urlPattern: '/service_accounts?limit=10&label=CI%20Bot', + method: 'GET', body: '{"service_accounts":[]}', }, - ]), + ]) ); await runApiOperation({ - method: "GET", - endpoint: "/service_accounts?limit=10&label=CI%20Bot", + method: 'GET', + endpoint: '/service_accounts?limit=10&label=CI%20Bot', }); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); expect(logContent).toContain( - "URL=https://app.example.com/rest/v1/service_accounts?limit=10&label=CI%20Bot", + 'URL=https://app.example.com/rest/v1/service_accounts?limit=10&label=CI%20Bot' ); }); - test("DELETE with empty body is silent in human mode", async () => { + test('DELETE with empty body is silent in human mode', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts/sa_1", - method: "DELETE", - body: "", + urlPattern: '/service_accounts/sa_1', + method: 'DELETE', + body: '', }, - ]), + ]) ); - await runApiOperation({ method: "DELETE", endpoint: "/service_accounts/sa_1" }); - expect(stdout).toBe(""); + await runApiOperation({ + method: 'DELETE', + endpoint: '/service_accounts/sa_1', + }); + expect(stdout).toBe(''); }); - test("DELETE with empty body emits deleted envelope with --json", async () => { + test('DELETE with empty body emits deleted envelope with --json', async () => { setCliContext({ debug: false, json: true, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); - stdout = ""; + stdout = ''; writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/service_accounts/sa_1", - method: "DELETE", - body: "", + urlPattern: '/service_accounts/sa_1', + method: 'DELETE', + body: '', }, - ]), + ]) ); - await runApiOperation({ method: "DELETE", endpoint: "/service_accounts/sa_1" }); + await runApiOperation({ + method: 'DELETE', + endpoint: '/service_accounts/sa_1', + }); expect(JSON.parse(stdout)).toEqual({ deleted: true }); }); }); diff --git a/cli/src/commands/api/lib/http.ts b/cli/src/commands/api/lib/http.ts index c3dbbe8c..2e40da65 100644 --- a/cli/src/commands/api/lib/http.ts +++ b/cli/src/commands/api/lib/http.ts @@ -2,14 +2,14 @@ import { formatApiFieldValue, resolveApiRequestPayload, type ParsedApiField, -} from "@/commands/api/lib/body.ts"; -import type { CommandOutputMode } from "@/lib/command-output.ts"; -import { CliError } from "@/lib/errors.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { parseManagementOutputFormat } from "@/lib/tabular-result.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; +} from '@/commands/api/lib/body.ts'; +import type { CommandOutputMode } from '@/lib/command-output.ts'; +import { CliError } from '@/lib/errors.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { parseManagementOutputFormat } from '@/lib/tabular-result.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; export type ApiHttpArgs = { method?: string; @@ -37,17 +37,17 @@ export type ApiHttpResult = { export function buildApiHttpRequest(resolved: ResolvedApiHttp): HttpRequest { return { - plane: "management", + plane: 'management', method: resolved.method, endpoint: resolved.endpoint, body: resolved.body, - contentType: resolved.body ? "application/json" : undefined, + contentType: resolved.body ? 'application/json' : undefined, }; } export async function executeApiHttp( resolved: ResolvedApiHttp, - execution: ExecutionContext, + execution: ExecutionContext ): Promise { return { method: resolved.method, @@ -59,24 +59,26 @@ export async function executeApiHttp( export function normalizeApiEndpoint(endpoint: string, env?: string): string { const trimmed = endpoint.trim(); if (trimmed.length === 0) { - throw new CliError("Endpoint path is required, e.g. /whoami."); + throw new CliError('Endpoint path is required, e.g. /whoami.'); } if (/^https?:\/\//i.test(trimmed)) { - throw new CliError("Pass a path relative to /rest/v1, not a full URL."); + throw new CliError('Pass a path relative to /rest/v1, not a full URL.'); } - let path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + let path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; if (env) { - path = path.replaceAll("{environment_id}", env); + path = path.replaceAll('{environment_id}', env); } return path; } -function hasFields(fields: Record | string[] | string | undefined): boolean { +function hasFields( + fields: Record | string[] | string | undefined +): boolean { if (fields === undefined) { return false; } - if (typeof fields === "string") { + if (typeof fields === 'string') { return fields.length > 0; } if (Array.isArray(fields)) { @@ -96,10 +98,10 @@ function resolveApiMethod(args: ApiHttpArgs): string { hasFields(args.typedFields) || hasFields(args.fields) ) { - return "POST"; + return 'POST'; } - return "GET"; + return 'GET'; } function appendQueryFields(endpoint: string, fields: ParsedApiField[]): string { @@ -107,7 +109,7 @@ function appendQueryFields(endpoint: string, fields: ParsedApiField[]): string { return endpoint; } - const separator = endpoint.includes("?") ? "&" : "?"; + const separator = endpoint.includes('?') ? '&' : '?'; const searchParams = new URLSearchParams(); for (const field of fields) { searchParams.append(field.key, formatApiFieldValue(field.value)); @@ -117,11 +119,14 @@ function appendQueryFields(endpoint: string, fields: ParsedApiField[]): string { export function resolveApiHttp(args: ApiHttpArgs): ResolvedApiHttp { if (!args.endpoint) { - throw new CliError("Endpoint path is required, e.g. /whoami."); + throw new CliError('Endpoint path is required, e.g. /whoami.'); } const method = resolveApiMethod(args); - const endpoint = normalizeApiEndpoint(args.endpoint, args.env ? String(args.env) : undefined); + const endpoint = normalizeApiEndpoint( + args.endpoint, + args.env ? String(args.env) : undefined + ); const payload = resolveApiRequestPayload({ method, input: args.input ? String(args.input) : undefined, @@ -141,11 +146,11 @@ export function resolveApiHttp(args: ApiHttpArgs): ResolvedApiHttp { export function apiHttpResultOutput( result: ApiHttpResult, - sink: OutputSink, + sink: OutputSink ): CommandOutputMode | undefined { - if (result.method === "DELETE" && result.response.trim().length === 0) { + if (result.method === 'DELETE' && result.response.trim().length === 0) { if (sink.json) { - return { kind: "deleted", message: "" }; + return { kind: 'deleted', message: '' }; } return; } @@ -155,8 +160,10 @@ export function apiHttpResultOutput( } return { - kind: "tabular", + kind: 'tabular', body: result.response, - format: result.format ? parseManagementOutputFormat(result.format) : undefined, + format: result.format + ? parseManagementOutputFormat(result.format) + : undefined, }; } diff --git a/cli/src/commands/api/lib/model.ts b/cli/src/commands/api/lib/model.ts index ab7f372e..9ed3f51c 100644 --- a/cli/src/commands/api/lib/model.ts +++ b/cli/src/commands/api/lib/model.ts @@ -1,5 +1,5 @@ -import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; -import { CliError } from "@/lib/errors.ts"; +import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; +import { CliError } from '@/lib/errors.ts'; const PATH_PARAMETER_PATTERN = /\{([^}]+)\}/g; @@ -19,11 +19,13 @@ export type ApiOperationDetails = { }; export function extractPathParameters(path: string): string[] { - return [...path.matchAll(PATH_PARAMETER_PATTERN)].map((match) => String(match[1])); + return [...path.matchAll(PATH_PARAMETER_PATTERN)].map(match => + String(match[1]) + ); } export function apiRouteRows(): ApiRouteRow[] { - return OPENAPI_OPERATIONS.map((operation) => ({ + return OPENAPI_OPERATIONS.map(operation => ({ method: operation.method, path: operation.path, operationId: operation.operationId, @@ -32,7 +34,9 @@ export function apiRouteRows(): ApiRouteRow[] { } export function apiOperationDetails(operationId: string): ApiOperationDetails { - const operation = OPENAPI_OPERATIONS.find((candidate) => candidate.operationId === operationId); + const operation = OPENAPI_OPERATIONS.find( + candidate => candidate.operationId === operationId + ); if (!operation) { throw new CliError(`Unknown API operation: ${operationId}`); } diff --git a/cli/src/commands/api/lib/render.ts b/cli/src/commands/api/lib/render.ts index 1b71d8a7..217466ea 100644 --- a/cli/src/commands/api/lib/render.ts +++ b/cli/src/commands/api/lib/render.ts @@ -1,6 +1,12 @@ -import type { ApiOperationDetails, ApiRouteRow } from "@/commands/api/lib/model.ts"; -import { buildApiOperationDetailsView, buildApiRoutesView } from "@/commands/api/lib/views.ts"; -import { renderDocument, renderDocumentText } from "@/ui/renderers/terminal.ts"; +import type { + ApiOperationDetails, + ApiRouteRow, +} from '@/commands/api/lib/model.ts'; +import { + buildApiOperationDetailsView, + buildApiRoutesView, +} from '@/commands/api/lib/views.ts'; +import { renderDocument, renderDocumentText } from '@/ui/renderers/terminal.ts'; const API_DETAILS_LABEL_WIDTH = 12; @@ -8,21 +14,23 @@ type ApiRoutesRenderOptions = { terminalWidth?: number; }; -export function formatApiOperationDetails(operation: ApiOperationDetails): string { +export function formatApiOperationDetails( + operation: ApiOperationDetails +): string { return renderDocument(buildApiOperationDetailsView(operation), { labelWidth: API_DETAILS_LABEL_WIDTH, - }).join("\n"); + }).join('\n'); } export function formatApiRoutes( rows: readonly ApiRouteRow[], - emptyMessage = "No operations found.", - options: ApiRoutesRenderOptions = {}, + emptyMessage = 'No operations found.', + options: ApiRoutesRenderOptions = {} ): string { return renderDocumentText( buildApiRoutesView(rows, { emptyMessage, terminalWidth: options.terminalWidth, - }), + }) ); } diff --git a/cli/src/commands/api/lib/views.test.ts b/cli/src/commands/api/lib/views.test.ts index 5443d43a..b3477195 100644 --- a/cli/src/commands/api/lib/views.test.ts +++ b/cli/src/commands/api/lib/views.test.ts @@ -1,31 +1,33 @@ -import { afterEach, expect, test } from "bun:test"; -import { buildApiOperationDetailsView } from "@/commands/api/lib/views.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { setTerminalColorMode } from "@/ui/terminal/styles.ts"; +import { afterEach, expect, test } from 'bun:test'; +import { buildApiOperationDetailsView } from '@/commands/api/lib/views.ts'; +import { renderDocumentText } from '@/ui/renderers/terminal.ts'; +import { setTerminalColorMode } from '@/ui/terminal/styles.ts'; afterEach(() => { setTerminalColorMode(undefined); }); -test("presentation documents carry semantics without terminal escapes", () => { - setTerminalColorMode("always"); +test('presentation documents carry semantics without terminal escapes', () => { + setTerminalColorMode('always'); const view = buildApiOperationDetailsView({ - operationId: "createDatabase", - method: "POST", - path: "/environments/{environment_id}/databases", - parameters: ["environment_id"], - summary: "Create a database", + operationId: 'createDatabase', + method: 'POST', + path: '/environments/{environment_id}/databases', + parameters: ['environment_id'], + summary: 'Create a database', }); const [section] = view.sections; const [block] = section?.blocks ?? []; - expect(JSON.stringify(view)).not.toContain("\u001b"); - expect(block?.kind).toBe("rows"); - if (block?.kind === "rows") { + expect(JSON.stringify(view)).not.toContain('\u001b'); + expect(block?.kind).toBe('rows'); + if (block?.kind === 'rows') { expect(block.rows[0]).toEqual({ - label: "Operation:", - value: [{ text: "createDatabase", style: "accent" }], + label: 'Operation:', + value: [{ text: 'createDatabase', style: 'accent' }], }); } - expect(renderDocumentText(view)).toContain("\u001b[96mcreateDatabase\u001b[39m"); + expect(renderDocumentText(view)).toContain( + '\u001b[96mcreateDatabase\u001b[39m' + ); }); diff --git a/cli/src/commands/api/lib/views.ts b/cli/src/commands/api/lib/views.ts index 48d8e5bc..0f04011d 100644 --- a/cli/src/commands/api/lib/views.ts +++ b/cli/src/commands/api/lib/views.ts @@ -1,4 +1,7 @@ -import type { ApiOperationDetails, ApiRouteRow } from "@/commands/api/lib/model.ts"; +import type { + ApiOperationDetails, + ApiRouteRow, +} from '@/commands/api/lib/model.ts'; import { document, rows, @@ -7,7 +10,7 @@ import { table, type DisplayDocument, type DisplayRow, -} from "@/ui/document.ts"; +} from '@/ui/document.ts'; export type ApiRoutesViewOptions = { emptyMessage?: string; @@ -16,24 +19,29 @@ export type ApiRoutesViewOptions = { function apiOperationRows(operation: ApiOperationDetails): DisplayRow[] { return [ - { label: "Operation:", value: [span(operation.operationId, "accent")] }, - { label: "Method:", value: operation.method }, - { label: "Path:", value: operation.path }, + { label: 'Operation:', value: [span(operation.operationId, 'accent')] }, + { label: 'Method:', value: operation.method }, + { label: 'Path:', value: operation.path }, { - label: "Parameters:", - value: operation.parameters.length > 0 ? operation.parameters.join(", ") : "(none)", + label: 'Parameters:', + value: + operation.parameters.length > 0 + ? operation.parameters.join(', ') + : '(none)', }, - { label: "Summary:", value: operation.summary }, + { label: 'Summary:', value: operation.summary }, ]; } -export function buildApiOperationDetailsView(operation: ApiOperationDetails): DisplayDocument { +export function buildApiOperationDetailsView( + operation: ApiOperationDetails +): DisplayDocument { return document(section(rows(apiOperationRows(operation)))); } export function buildApiRoutesView( routeRows: readonly ApiRouteRow[], - options: ApiRoutesViewOptions = {}, + options: ApiRoutesViewOptions = {} ): DisplayDocument { return document( section( @@ -41,29 +49,29 @@ export function buildApiRoutesView( rows: routeRows, columns: [ { - header: "METHOD", - cell: (row) => [span(row.method, "httpMethod")], + header: 'METHOD', + cell: row => [span(row.method, 'httpMethod')], }, { - header: "PATH", - cell: (row) => row.path, + header: 'PATH', + cell: row => row.path, }, { - header: "OPERATION", - cell: (row) => [span(row.operationId, "subtle")], + header: 'OPERATION', + cell: row => [span(row.operationId, 'subtle')], }, { - header: "SUMMARY", - cell: (row) => [span(row.summary, "muted")], + header: 'SUMMARY', + cell: row => [span(row.summary, 'muted')], }, ], - emptyMessage: options.emptyMessage ?? "No operations found.", + emptyMessage: options.emptyMessage ?? 'No operations found.', options: { terminalWidth: options.terminalWidth, horizontalScroll: true, - groupBy: (row) => row.path.split("/").filter(Boolean)[0] ?? "", + groupBy: row => row.path.split('/').filter(Boolean)[0] ?? '', }, - }), - ), + }) + ) ); } diff --git a/cli/src/commands/api/routes.ts b/cli/src/commands/api/routes.ts index 6d4a4862..91cb5204 100644 --- a/cli/src/commands/api/routes.ts +++ b/cli/src/commands/api/routes.ts @@ -1,48 +1,63 @@ -import { optionalStringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/commands/api/lib/model.ts"; -import { formatApiOperationDetails, formatApiRoutes } from "@/commands/api/lib/render.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; +import { optionalStringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { + apiOperationDetails, + apiOperationsJson, + apiRouteRows, +} from '@/commands/api/lib/model.ts'; +import { + formatApiOperationDetails, + formatApiRoutes, +} from '@/commands/api/lib/render.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; export const apiRouteCommand = defineCommand({ metadata: { - name: "route", - description: "List management API paths and methods from the bundled OpenAPI spec.", - examples: ["altertable api route", "altertable api route createDatabase"], + name: 'route', + description: + 'List management API paths and methods from the bundled OpenAPI spec.', + examples: ['altertable api route', 'altertable api route createDatabase'], }, args: { operation: { - type: "positional", - description: "Optional operationId to inspect, e.g. createDatabase", + type: 'positional', + description: 'Optional operationId to inspect, e.g. createDatabase', required: false, }, }, async run({ args, sink }) { - await runApiRoutesCommand(sink, optionalStringArg(args, "operation")); + await runApiRoutesCommand(sink, optionalStringArg(args, 'operation')); }, }); export const apiRoutesCommand = defineCommand({ ...apiRouteCommand, - metadata: { name: "routes", hidden: true, description: "Alias for altertable api route." }, + metadata: { + name: 'routes', + hidden: true, + description: 'Alias for altertable api route.', + }, }); -async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { +async function runApiRoutesCommand( + sink: OutputSink, + operationId?: string +): Promise { const operation = operationId ? apiOperationDetails(operationId) : undefined; await writeCommandOutput( operation ? { - kind: "normalized", + kind: 'normalized', data: operation, humanText: formatApiOperationDetails(operation), } : { - kind: "normalized", + kind: 'normalized', data: apiOperationsJson(), humanText: formatApiRoutes(apiRouteRows()), pageHumanText: true, }, - sink, + sink ); } diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts index 56af6601..83f08723 100644 --- a/cli/src/commands/api/spec.ts +++ b/cli/src/commands/api/spec.ts @@ -1,14 +1,14 @@ -import { getOpenapiSpecJson, getOpenapiSpecYaml } from "@/lib/openapi-spec.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; +import { getOpenapiSpecJson, getOpenapiSpecYaml } from '@/lib/openapi-spec.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; export const apiSpecCommand = defineCommand({ metadata: { - name: "spec", + name: 'spec', description: - "Print the bundled management OpenAPI specification (YAML by default; JSON with --json).", - examples: ["altertable api spec", "altertable api spec --json"], + 'Print the bundled management OpenAPI specification (YAML by default; JSON with --json).', + examples: ['altertable api spec', 'altertable api spec --json'], }, async run({ sink }) { await runApiSpecCommand(sink); @@ -18,8 +18,8 @@ export const apiSpecCommand = defineCommand({ async function runApiSpecCommand(sink: OutputSink): Promise { await writeCommandOutput( sink.json - ? { kind: "raw_api", body: getOpenapiSpecJson() } - : { kind: "human", text: getOpenapiSpecYaml() }, - sink, + ? { kind: 'raw_api', body: getOpenapiSpecJson() } + : { kind: 'human', text: getOpenapiSpecYaml() }, + sink ); } diff --git a/cli/src/commands/app/build.ts b/cli/src/commands/app/build.ts index b182304d..e57b57d7 100644 --- a/cli/src/commands/app/build.ts +++ b/cli/src/commands/app/build.ts @@ -1,32 +1,36 @@ -import { join } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { EXIT_GENERIC } from "@/lib/errors.ts"; +import { join } from 'node:path'; +import { defineCommand } from '@/lib/command.ts'; +import { EXIT_GENERIC } from '@/lib/errors.ts'; import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, -} from "@/commands/app/lib/run.ts"; +} from '@/commands/app/lib/run.ts'; export const appBuildCommand = defineCommand({ metadata: { - name: "build", - description: "Typecheck and build a local data app without Altertable credentials.", - examples: ["altertable app build", "altertable app build --dir ./my-app"], + name: 'build', + description: + 'Typecheck and build a local data app without Altertable credentials.', + examples: ['altertable app build', 'altertable app build --dir ./my-app'], }, args: { - dir: { type: "string", description: "App directory (default: current directory)." }, + dir: { + type: 'string', + description: 'App directory (default: current directory).', + }, }, async run({ args, runtime, sink }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); const directory = appDirectory(args.dir); - requireAppScripts(directory, ["typecheck", "build"]); - const installExitCode = await runAppCommand("install", directory); + requireAppScripts(directory, ['typecheck', 'build']); + const installExitCode = await runAppCommand('install', directory); if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; - const typecheckExitCode = await runAppCommand("typecheck", directory); + const typecheckExitCode = await runAppCommand('typecheck', directory); if (typecheckExitCode !== 0) return { exitCode: EXIT_GENERIC }; - const exitCode = await runAppCommand("build", directory); + const exitCode = await runAppCommand('build', directory); if (exitCode !== 0) return { exitCode: EXIT_GENERIC }; - sink.writeHuman(`Built data app in ${join(directory, "dist")}.`); + sink.writeHuman(`Built data app in ${join(directory, 'dist')}.`); }, }); diff --git a/cli/src/commands/app/create.test.ts b/cli/src/commands/app/create.test.ts index 9701675d..102f3d2a 100644 --- a/cli/src/commands/app/create.test.ts +++ b/cli/src/commands/app/create.test.ts @@ -1,18 +1,28 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, posix } from "node:path"; -import { readDataAppPayload } from "@/commands/app/lib/distribution.ts"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; -import { configSet, ensureProfileExists, setActiveProfile } from "@/lib/profile-store.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, posix } from 'node:path'; +import { readDataAppPayload } from '@/commands/app/lib/distribution.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; +import { recommendedDataAppVersion } from '@/commands/app/lib/package.ts'; +import { + configSet, + ensureProfileExists, + setActiveProfile, +} from '@/lib/profile-store.ts'; let home: string; let previousConfigHome: string | undefined; let previousProfile: string | undefined; beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "altertable-app-create-")); + home = mkdtempSync(join(tmpdir(), 'altertable-app-create-')); previousConfigHome = process.env.ALTERTABLE_CONFIG_HOME; previousProfile = process.env.ALTERTABLE_PROFILE; process.env.ALTERTABLE_CONFIG_HOME = home; @@ -21,383 +31,482 @@ beforeEach(() => { afterEach(() => { rmSync(home, { recursive: true, force: true }); - if (previousConfigHome === undefined) delete process.env.ALTERTABLE_CONFIG_HOME; + if (previousConfigHome === undefined) + delete process.env.ALTERTABLE_CONFIG_HOME; else process.env.ALTERTABLE_CONFIG_HOME = previousConfigHome; if (previousProfile === undefined) delete process.env.ALTERTABLE_PROFILE; else process.env.ALTERTABLE_PROFILE = previousProfile; }); -describe("app create", () => { - test("scaffolds a pinned package project without registry access", async () => { - const directory = join(home, "product-pulse"); +describe('app create', () => { + test('scaffolds a pinned package project without registry access', async () => { + const directory = join(home, 'product-pulse'); const result = await runCommandWithTestRuntime([ - "app", - "create", - "product-pulse", - "--dir", + 'app', + 'create', + 'product-pulse', + '--dir', directory, - "--without-profile", + '--without-profile', ]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout[0]!)).toMatchObject({ - name: "product-pulse", + name: 'product-pulse', directory, - scope: { organization: "Your organization", environment: "your environment" }, - scopeSource: "placeholder", + scope: { + organization: 'Your organization', + environment: 'your environment', + }, + scopeSource: 'placeholder', nextSteps: [ - "Set organization and environment in app.json.", - "Select a matching profile, or configure one with `altertable login --org --env `.", - "altertable app dev", - "altertable app lint --lakehouse", - "altertable app build", + 'Set organization and environment in app.json.', + 'Select a matching profile, or configure one with `altertable login --org --env `.', + 'altertable app dev', + 'altertable app lint --lakehouse', + 'altertable app build', ], }); - expect(JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))).toMatchObject({ - name: "product-pulse", + expect( + JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) + ).toMatchObject({ + name: 'product-pulse', scripts: { - typecheck: "tsc --noEmit", - lint: "oxlint --type-aware src", - format: "oxfmt src", - "format:check": "oxfmt --check src", - dev: "bun --hot src/server.ts", + typecheck: 'tsc --noEmit', + lint: 'oxlint --type-aware src', + format: 'oxfmt src', + 'format:check': 'oxfmt --check src', + dev: 'bun --hot src/server.ts', }, }); - expect(JSON.parse(readFileSync(join(directory, "app.json"), "utf8"))).toMatchObject({ - title: "Product Pulse", - scope: { organization: "Your organization", environment: "your environment" }, + expect( + JSON.parse(readFileSync(join(directory, 'app.json'), 'utf8')) + ).toMatchObject({ + title: 'Product Pulse', + scope: { + organization: 'Your organization', + environment: 'your environment', + }, }); - expect(readFileSync(join(directory, "bun.lock"), "utf8")).toContain('"name": "product-pulse"'); + expect(readFileSync(join(directory, 'bun.lock'), 'utf8')).toContain( + '"name": "product-pulse"' + ); expect( - JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).dependencies[ - "@altertable/data-app" - ], + JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) + .dependencies['@altertable/data-app'] ).toBe(recommendedDataAppVersion()); - expect(existsSync(join(directory, ".altertable/runtime"))).toBe(false); + expect(existsSync(join(directory, '.altertable/runtime'))).toBe(false); const paths = JSON.parse(result.stdout[0]!).files as string[]; - expect(paths.some((path) => path.startsWith(".altertable/"))).toBe(false); - expect(paths).toContain("src/App.tsx"); - expect(paths).toContain(".oxlintrc.json"); - expect(readFileSync(join(directory, "AGENTS.md"), "utf8")).toContain( - "https://github.com/altertable-ai/data-app/blob/main/examples/starter-local-data-app/AGENTS.md", + expect(paths.some(path => path.startsWith('.altertable/'))).toBe(false); + expect(paths).toContain('src/App.tsx'); + expect(paths).toContain('.oxlintrc.json'); + expect(readFileSync(join(directory, 'AGENTS.md'), 'utf8')).toContain( + 'https://github.com/altertable-ai/data-app/blob/main/examples/starter-local-data-app/AGENTS.md' ); - expect(readFileSync(join(directory, "src/App.tsx"), "utf8")).toContain( - "Connectivity-only screen", + expect(readFileSync(join(directory, 'src/App.tsx'), 'utf8')).toContain( + 'Connectivity-only screen' ); - expect(readFileSync(join(directory, "src/operations.ts"), "utf8")).toContain( - "supplies no analytical result", - ); - expect(paths.some((path) => /tests|fixtures|node_modules|\.txt$/.test(path))).toBe(false); + expect( + readFileSync(join(directory, 'src/operations.ts'), 'utf8') + ).toContain('supplies no analytical result'); + expect( + paths.some(path => /tests|fixtures|node_modules|\.txt$/.test(path)) + ).toBe(false); }); - test("requires an organization and environment before creating a default app", async () => { - const directory = join(home, "unconfigured-app"); + test('requires an organization and environment before creating a default app', async () => { + const directory = join(home, 'unconfigured-app'); expect( - runCommandWithTestRuntime(["app", "create", "unconfigured-app", "--dir", directory]), - ).rejects.toThrow('Profile "default" needs an organization and environment'); + runCommandWithTestRuntime([ + 'app', + 'create', + 'unconfigured-app', + '--dir', + directory, + ]) + ).rejects.toThrow( + 'Profile "default" needs an organization and environment' + ); expect(existsSync(directory)).toBe(false); }); - test("selected profile seeds the manifest and reports its scope to agents", async () => { - ensureProfileExists("altertable_production"); - configSet("organization_slug", "altertable", "altertable_production"); - configSet("organization_name", "Altertable", "altertable_production"); - configSet("api_key_env", "production", "altertable_production"); - const directory = join(home, "profiled-app"); + test('selected profile seeds the manifest and reports its scope to agents', async () => { + ensureProfileExists('altertable_production'); + configSet('organization_slug', 'altertable', 'altertable_production'); + configSet('organization_name', 'Altertable', 'altertable_production'); + configSet('api_key_env', 'production', 'altertable_production'); + const directory = join(home, 'profiled-app'); const result = await runCommandWithTestRuntime( - ["app", "create", "profiled-app", "--dir", directory], - { debug: false, json: false, agent: true, profile: "altertable_production" }, + ['app', 'create', 'profiled-app', '--dir', directory], + { + debug: false, + json: false, + agent: true, + profile: 'altertable_production', + } ); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout[0]!)).toMatchObject({ - scope: { organization: "Altertable", environment: "production" }, - scopeSource: "profile", - profile: "altertable_production", - organizationSlug: "altertable", + scope: { organization: 'Altertable', environment: 'production' }, + scopeSource: 'profile', + profile: 'altertable_production', + organizationSlug: 'altertable', nextSteps: [ - "altertable --profile altertable_production app dev", - "altertable --profile altertable_production app lint", - "altertable --profile altertable_production app build", + 'altertable --profile altertable_production app dev', + 'altertable --profile altertable_production app lint', + 'altertable --profile altertable_production app build', ], }); - expect(JSON.parse(readFileSync(join(directory, "app.json"), "utf8"))).toMatchObject({ - scope: { organization: "Altertable", environment: "production" }, + expect( + JSON.parse(readFileSync(join(directory, 'app.json'), 'utf8')) + ).toMatchObject({ + scope: { organization: 'Altertable', environment: 'production' }, }); - expect(readFileSync(join(directory, "src/index.html"), "utf8")).toContain( - "Altertable app", + expect(readFileSync(join(directory, 'src/index.html'), 'utf8')).toContain( + 'Altertable app' ); }); - test("a complete active profile seeds scope without an extra flag", async () => { - ensureProfileExists("current"); - configSet("organization_slug", "cousteau", "current"); - configSet("organization_name", "Cousteau", "current"); - configSet("api_key_env", "production", "current"); - setActiveProfile("current"); - const directory = join(home, "active-app"); + test('a complete active profile seeds scope without an extra flag', async () => { + ensureProfileExists('current'); + configSet('organization_slug', 'cousteau', 'current'); + configSet('organization_name', 'Cousteau', 'current'); + configSet('api_key_env', 'production', 'current'); + setActiveProfile('current'); + const directory = join(home, 'active-app'); const result = await runCommandWithTestRuntime([ - "app", - "create", - "active-app", - "--dir", + 'app', + 'create', + 'active-app', + '--dir', directory, ]); expect(JSON.parse(result.stdout[0]!)).toMatchObject({ - scope: { organization: "Cousteau", environment: "production" }, - scopeSource: "profile", - profile: "current", + scope: { organization: 'Cousteau', environment: 'production' }, + scopeSource: 'profile', + profile: 'current', }); - expect(JSON.parse(readFileSync(join(directory, "app.json"), "utf8")).scope).toEqual({ - organization: "Cousteau", - environment: "production", + expect( + JSON.parse(readFileSync(join(directory, 'app.json'), 'utf8')).scope + ).toEqual({ + organization: 'Cousteau', + environment: 'production', }); - expect(readFileSync(join(directory, "src/main.tsx"), "utf8")).toContain( - "mountDataApp({ app, component: App })", + expect(readFileSync(join(directory, 'src/main.tsx'), 'utf8')).toContain( + 'mountDataApp({ app, component: App })' + ); + expect(readFileSync(join(directory, 'src/App.tsx'), 'utf8')).not.toContain( + '{{APP_TITLE}}' ); - expect(readFileSync(join(directory, "src/App.tsx"), "utf8")).not.toContain("{{APP_TITLE}}"); }); - test("without-profile keeps offline scaffolding available with an active profile", async () => { - ensureProfileExists("current"); - configSet("organization_slug", "cousteau", "current"); - configSet("api_key_env", "production", "current"); - setActiveProfile("current"); - const directory = join(home, "offline-app"); + test('without-profile keeps offline scaffolding available with an active profile', async () => { + ensureProfileExists('current'); + configSet('organization_slug', 'cousteau', 'current'); + configSet('api_key_env', 'production', 'current'); + setActiveProfile('current'); + const directory = join(home, 'offline-app'); const result = await runCommandWithTestRuntime([ - "app", - "create", - "offline-app", - "--dir", + 'app', + 'create', + 'offline-app', + '--dir', directory, - "--without-profile", + '--without-profile', ]); expect(JSON.parse(result.stdout[0]!)).toMatchObject({ - scope: { organization: "Your organization", environment: "your environment" }, - scopeSource: "placeholder", + scope: { + organization: 'Your organization', + environment: 'your environment', + }, + scopeSource: 'placeholder', }); }); - test("a partially configured active profile stops before writing", async () => { - ensureProfileExists("current"); - configSet("organization_slug", "cousteau", "current"); - setActiveProfile("current"); - const directory = join(home, "partial-app"); + test('a partially configured active profile stops before writing', async () => { + ensureProfileExists('current'); + configSet('organization_slug', 'cousteau', 'current'); + setActiveProfile('current'); + const directory = join(home, 'partial-app'); let failure: unknown; try { - await runCommandWithTestRuntime(["app", "create", "partial-app", "--dir", directory]); + await runCommandWithTestRuntime([ + 'app', + 'create', + 'partial-app', + '--dir', + directory, + ]); } catch (error) { failure = error; } - expect((failure as Error).message).toContain("needs an organization and environment"); + expect((failure as Error).message).toContain( + 'needs an organization and environment' + ); expect(existsSync(directory)).toBe(false); }); - test("profile display names are escaped for each generated file format", async () => { - ensureProfileExists("special"); - configSet("organization_slug", "acme", "special"); - configSet("organization_name", 'Acme "North" & Co', "special"); - configSet("api_key_env", "production", "special"); - const directory = join(home, "special-app"); + test('profile display names are escaped for each generated file format', async () => { + ensureProfileExists('special'); + configSet('organization_slug', 'acme', 'special'); + configSet('organization_name', 'Acme "North" & Co', 'special'); + configSet('api_key_env', 'production', 'special'); + const directory = join(home, 'special-app'); const result = await runCommandWithTestRuntime( - ["app", "create", "special-app", "--dir", directory], - { debug: false, json: true, agent: false, profile: "special" }, + ['app', 'create', 'special-app', '--dir', directory], + { debug: false, json: true, agent: false, profile: 'special' } ); expect(result.exitCode).toBe(0); - expect(JSON.parse(readFileSync(join(directory, "app.json"), "utf8")).scope.organization).toBe( - 'Acme "North" & Co', - ); - expect(readFileSync(join(directory, "src/index.html"), "utf8")).toContain( - "Altertable app", + expect( + JSON.parse(readFileSync(join(directory, 'app.json'), 'utf8')).scope + .organization + ).toBe('Acme "North" & Co'); + expect(readFileSync(join(directory, 'src/index.html'), 'utf8')).toContain( + 'Altertable app' ); }); - test("an incomplete active profile stops before writing", async () => { - ensureProfileExists("current"); - setActiveProfile("current"); - const directory = join(home, "incomplete-app"); + test('an incomplete active profile stops before writing', async () => { + ensureProfileExists('current'); + setActiveProfile('current'); + const directory = join(home, 'incomplete-app'); let failure: unknown; try { - await runCommandWithTestRuntime(["app", "create", "incomplete-app", "--dir", directory]); + await runCommandWithTestRuntime([ + 'app', + 'create', + 'incomplete-app', + '--dir', + directory, + ]); } catch (error) { failure = error; } expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain("needs an organization and environment"); + expect((failure as Error).message).toContain( + 'needs an organization and environment' + ); expect(existsSync(directory)).toBe(false); - configSet("organization_slug", "cousteau", "current"); - configSet("api_key_env", "staging", "current"); + configSet('organization_slug', 'cousteau', 'current'); + configSet('api_key_env', 'staging', 'current'); const result = await runCommandWithTestRuntime([ - "app", - "create", - "incomplete-app", - "--dir", + 'app', + 'create', + 'incomplete-app', + '--dir', directory, ]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout[0]!)).toMatchObject({ - scope: { organization: "cousteau", environment: "staging" }, - profile: "current", + scope: { organization: 'cousteau', environment: 'staging' }, + profile: 'current', }); }); - test("installs the generated app with its frozen lockfile and checks authoring links and the project", async () => { - const directory = join(home, "first-check"); + test('installs the generated app with its frozen lockfile and checks authoring links and the project', async () => { + const directory = join(home, 'first-check'); await runCommandWithTestRuntime([ - "app", - "create", - "first-check", - "--dir", + 'app', + 'create', + 'first-check', + '--dir', directory, - "--without-profile", + '--without-profile', ]); - const install = Bun.spawnSync([process.execPath, "install", "--frozen-lockfile"], { - cwd: directory, - stdout: "pipe", - stderr: "pipe", - }); + const install = Bun.spawnSync( + [process.execPath, 'install', '--frozen-lockfile'], + { + cwd: directory, + stdout: 'pipe', + stderr: 'pipe', + } + ); expect(install.exitCode).toBe(0); expect( JSON.parse( - readFileSync(join(directory, "node_modules/@altertable/data-app/package.json"), "utf8"), - ).version, + readFileSync( + join(directory, 'node_modules/@altertable/data-app/package.json'), + 'utf8' + ) + ).version ).toBe(recommendedDataAppVersion()); - for (const name of ["data-app-react", "data-app-bun"]) { + for (const name of ['data-app-react', 'data-app-bun']) { expect( JSON.parse( - readFileSync(join(directory, `node_modules/@altertable/${name}/package.json`), "utf8"), - ).version, + readFileSync( + join(directory, `node_modules/@altertable/${name}/package.json`), + 'utf8' + ) + ).version ).toBe(recommendedDataAppVersion()); } - expect(existsSync(join(directory, "node_modules/@altertable/data-app-embed"))).toBe(false); - expect(existsSync(join(directory, "node_modules/@altertable/data-app-worker"))).toBe(false); + expect( + existsSync(join(directory, 'node_modules/@altertable/data-app-embed')) + ).toBe(false); + expect( + existsSync(join(directory, 'node_modules/@altertable/data-app-worker')) + ).toBe(false); const { starter } = await readDataAppPayload(); for (const name of Object.keys(starter)) { - if (!name.endsWith(".md")) continue; - const content = readFileSync(join(directory, name), "utf8"); + if (!name.endsWith('.md')) continue; + const content = readFileSync(join(directory, name), 'utf8'); for (const match of content.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { - const target = match[1]!.split("#")[0]!; + const target = match[1]!.split('#')[0]!; if (!target || /^[a-z]+:\/\//i.test(target)) continue; const path = posix.normalize(posix.join(posix.dirname(name), target)); - expect(existsSync(join(directory, path)), `${name} links to missing ${path}`).toBe(true); + expect( + existsSync(join(directory, path)), + `${name} links to missing ${path}` + ).toBe(true); } } - const result = await runCommandWithTestRuntime(["app", "lint", "--dir", directory], { - debug: false, - json: false, - agent: false, - }); + const result = await runCommandWithTestRuntime( + ['app', 'lint', '--dir', directory], + { + debug: false, + json: false, + agent: false, + } + ); expect(result.exitCode).toBe(0); - expect(result.stdout.join("\n")).toContain("client credential scan passed"); + expect(result.stdout.join('\n')).toContain('client credential scan passed'); writeFileSync( - join(directory, "src/lint-probe.tsx"), - 'import { useEffect, useState } from "react"; export function Probe() { const [count, setCount] = useState(0); useEffect(() => { setCount(1); }, []); return
{count}
; }', + join(directory, 'src/lint-probe.tsx'), + 'import { useEffect, useState } from "react"; export function Probe() { const [count, setCount] = useState(0); useEffect(() => { setCount(1); }, []); return
{count}
; }' ); - const lint = Bun.spawnSync([process.execPath, "run", "lint"], { + const lint = Bun.spawnSync([process.execPath, 'run', 'lint'], { cwd: directory, - stdout: "pipe", - stderr: "pipe", + stdout: 'pipe', + stderr: 'pipe', }); expect(lint.exitCode).toBe(1); - expect(new TextDecoder().decode(lint.stdout)).toContain("react(set-state-in-effect)"); + expect(new TextDecoder().decode(lint.stdout)).toContain( + 'react(set-state-in-effect)' + ); writeFileSync( - join(directory, "src/lint-probe.tsx"), - 'import { operations } from "./operations.ts"; export const probe = operations;', + join(directory, 'src/lint-probe.tsx'), + 'import { operations } from "./operations.ts"; export const probe = operations;' + ); + const relativeImportLint = Bun.spawnSync( + [process.execPath, 'run', 'lint'], + { + cwd: directory, + stdout: 'pipe', + stderr: 'pipe', + } ); - const relativeImportLint = Bun.spawnSync([process.execPath, "run", "lint"], { - cwd: directory, - stdout: "pipe", - stderr: "pipe", - }); expect(relativeImportLint.exitCode).toBe(1); expect(new TextDecoder().decode(relativeImportLint.stdout)).toContain( - "eslint(no-restricted-imports)", + 'eslint(no-restricted-imports)' ); }); - test("never overwrites an existing directory", async () => { - const directory = join(home, "existing"); - const marker = join(directory, "keep.txt"); + test('never overwrites an existing directory', async () => { + const directory = join(home, 'existing'); + const marker = join(directory, 'keep.txt'); await runCommandWithTestRuntime([ - "app", - "create", - "existing", - "--dir", + 'app', + 'create', + 'existing', + '--dir', directory, - "--without-profile", + '--without-profile', ]); - writeFileSync(marker, "keep"); + writeFileSync(marker, 'keep'); expect( runCommandWithTestRuntime([ - "app", - "create", - "existing", - "--dir", + 'app', + 'create', + 'existing', + '--dir', directory, - "--without-profile", - ]), - ).rejects.toThrow("Directory already exists"); - expect(readFileSync(marker, "utf8")).toBe("keep"); + '--without-profile', + ]) + ).rejects.toThrow('Directory already exists'); + expect(readFileSync(marker, 'utf8')).toBe('keep'); }); - test("rejects names that cannot safely become project names", async () => { - for (const name of ["../outside", "Product Pulse", "product_pulse", "app-"]) { + test('rejects names that cannot safely become project names', async () => { + for (const name of [ + '../outside', + 'Product Pulse', + 'product_pulse', + 'app-', + ]) { expect( - runCommandWithTestRuntime(["app", "create", name, "--dir", join(home, "app")]), - ).rejects.toThrow("lowercase kebab-case"); + runCommandWithTestRuntime([ + 'app', + 'create', + name, + '--dir', + join(home, 'app'), + ]) + ).rejects.toThrow('lowercase kebab-case'); } - expect(existsSync(join(home, "app"))).toBe(false); + expect(existsSync(join(home, 'app'))).toBe(false); }); - test("writes a concise human completion message", async () => { - const directory = join(home, "first-app"); + test('writes a concise human completion message', async () => { + const directory = join(home, 'first-app'); const result = await runCommandWithTestRuntime( - ["app", "create", "first-app", "--dir", directory, "--without-profile"], - { debug: false, json: false, agent: false }, + ['app', 'create', 'first-app', '--dir', directory, '--without-profile'], + { debug: false, json: false, agent: false } + ); + expect(result.stdout.join('\n')).toContain( + 'set the organization and environment in app.json' ); - expect(result.stdout.join("\n")).toContain("set the organization and environment in app.json"); - expect(result.stdout.join("\n")).toContain("`altertable app build`"); - expect(result.stdout.join("\n")).not.toContain("run altertable app dev."); + expect(result.stdout.join('\n')).toContain('`altertable app build`'); + expect(result.stdout.join('\n')).not.toContain('run altertable app dev.'); }); - test("returns the created directory in agent output", async () => { - const directory = join(home, "agent-app"); + test('returns the created directory in agent output', async () => { + const directory = join(home, 'agent-app'); const result = await runCommandWithTestRuntime( - ["app", "create", "agent-app", "--dir", directory, "--without-profile"], - { debug: false, json: false, agent: true }, + ['app', 'create', 'agent-app', '--dir', directory, '--without-profile'], + { debug: false, json: false, agent: true } ); - expect(JSON.parse(result.stdout[0]!)).toMatchObject({ name: "agent-app", directory }); + expect(JSON.parse(result.stdout[0]!)).toMatchObject({ + name: 'agent-app', + directory, + }); }); - test("upgrade reports the tested package version without changing a current app", async () => { - const directory = join(home, "current-app"); + test('upgrade reports the tested package version without changing a current app', async () => { + const directory = join(home, 'current-app'); await runCommandWithTestRuntime([ - "app", - "create", - "current-app", - "--dir", + 'app', + 'create', + 'current-app', + '--dir', directory, - "--without-profile", + '--without-profile', ]); - const operations = join(directory, "src/operations.ts"); - writeFileSync(operations, `${readFileSync(operations, "utf8")}\n// App-specific change.\n`); + const operations = join(directory, 'src/operations.ts'); + writeFileSync( + operations, + `${readFileSync(operations, 'utf8')}\n// App-specific change.\n` + ); for (const mode of [ { debug: false, json: true, agent: false }, { debug: false, json: false, agent: true }, ]) { - const result = await runCommandWithTestRuntime(["app", "upgrade", "--dir", directory], mode); + const result = await runCommandWithTestRuntime( + ['app', 'upgrade', '--dir', directory], + mode + ); expect(JSON.parse(result.stdout[0]!)).toEqual({ directory, upgraded: false, @@ -405,6 +514,6 @@ describe("app create", () => { nextSteps: [], }); } - expect(readFileSync(operations, "utf8")).toContain("App-specific change"); + expect(readFileSync(operations, 'utf8')).toContain('App-specific change'); }); }); diff --git a/cli/src/commands/app/create.ts b/cli/src/commands/app/create.ts index 7c542016..eff9bb23 100644 --- a/cli/src/commands/app/create.ts +++ b/cli/src/commands/app/create.ts @@ -1,104 +1,137 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; -import { readEnv } from "@/lib/env.ts"; -import { configGet } from "@/lib/profile-store.ts"; -import { createAppFiles } from "@/commands/app/lib/distribution.ts"; -import { dataAppPayload } from "@/commands/app/lib/payload.ts"; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { readEnv } from '@/lib/env.ts'; +import { configGet } from '@/lib/profile-store.ts'; +import { createAppFiles } from '@/commands/app/lib/distribution.ts'; +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; export const appCreateCommand = defineCommand({ metadata: { - name: "create", - description: "Create a local data app project with a live connection check.", + name: 'create', + description: + 'Create a local data app project with a live connection check.', examples: [ - "altertable app create product-pulse", - "altertable app create product-pulse --dir ./apps/product-pulse", - "altertable --profile production app create product-pulse", + 'altertable app create product-pulse', + 'altertable app create product-pulse --dir ./apps/product-pulse', + 'altertable --profile production app create product-pulse', ], }, args: { - name: { type: "positional", description: "App name in kebab-case", required: true }, - dir: { type: "string", description: "Destination directory (default: ./)." }, - "without-profile": { - type: "boolean", - description: "Create an offline scaffold with organization and environment placeholders.", + name: { + type: 'positional', + description: 'App name in kebab-case', + required: true, + }, + dir: { + type: 'string', + description: 'Destination directory (default: ./).', + }, + 'without-profile': { + type: 'boolean', + description: + 'Create an offline scaffold with organization and environment placeholders.', }, }, async run({ args, runtime, execution, sink }) { const name = String(args.name); if (name.length > 64 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) { throw new CliError( - "App name must be lowercase kebab-case, start with a letter, and be at most 64 characters.", + 'App name must be lowercase kebab-case, start with a letter, and be at most 64 characters.' ); } - if (args.dir !== undefined && (typeof args.dir !== "string" || args.dir.length === 0)) { - throw new CliError("--dir requires a directory path."); + if ( + args.dir !== undefined && + (typeof args.dir !== 'string' || args.dir.length === 0) + ) { + throw new CliError('--dir requires a directory path.'); } const directory = resolve(args.dir ?? name); const title = name - .split("-") - .map((word) => word[0]!.toUpperCase() + word.slice(1)) - .join(" "); + .split('-') + .map(word => word[0]!.toUpperCase() + word.slice(1)) + .join(' '); const explicitProfile = - runtime.context.profile !== undefined || readEnv("ALTERTABLE_PROFILE") !== undefined; - const withoutProfile = args["without-profile"] === true; + runtime.context.profile !== undefined || + readEnv('ALTERTABLE_PROFILE') !== undefined; + const withoutProfile = args['without-profile'] === true; if (withoutProfile && explicitProfile) { - throw new CliError("--without-profile cannot be combined with --profile."); + throw new CliError( + '--without-profile cannot be combined with --profile.' + ); } const candidateProfile = execution.profile; - const organizationSlug = configGet("organization_slug", candidateProfile).trim(); - const organizationName = configGet("organization_name", candidateProfile).trim(); - const environment = configGet("api_key_env", candidateProfile).trim(); + const organizationSlug = configGet( + 'organization_slug', + candidateProfile + ).trim(); + const organizationName = configGet( + 'organization_name', + candidateProfile + ).trim(); + const environment = configGet('api_key_env', candidateProfile).trim(); if (!withoutProfile && (!organizationSlug || !environment)) { throw new CliError( - `Profile "${candidateProfile}" needs an organization and environment before it can seed a data app. Run \`altertable login --org --env \` to connect, or use \`--without-profile\` for an offline scaffold.`, + `Profile "${candidateProfile}" needs an organization and environment before it can seed a data app. Run \`altertable login --org --env \` to connect, or use \`--without-profile\` for an offline scaffold.` ); } const profileName = withoutProfile ? undefined : candidateProfile; const scope = { - organization: profileName ? organizationName || organizationSlug : "Your organization", - environment: profileName ? environment : "your environment", + organization: profileName + ? organizationName || organizationSlug + : 'Your organization', + environment: profileName ? environment : 'your environment', }; - const files = Object.entries(createAppFiles(dataAppPayload, { name, title, scope })); + const files = Object.entries( + createAppFiles(dataAppPayload, { name, title, scope }) + ); await mkdir(dirname(directory), { recursive: true }); try { await mkdir(directory); } catch (error) { - if (error instanceof Error && "code" in error && error.code === "EEXIST") { + if ( + error instanceof Error && + 'code' in error && + error.code === 'EEXIST' + ) { throw new CliError(`Directory already exists: ${directory}`); } - throw new CliError(`Could not create data app directory: ${directory}`, { cause: error }); + throw new CliError(`Could not create data app directory: ${directory}`, { + cause: error, + }); } try { for (const [path, content] of files) { await mkdir(dirname(join(directory, path)), { recursive: true }); - await writeFile(join(directory, path), content, { flag: "wx" }); + await writeFile(join(directory, path), content, { flag: 'wx' }); } } catch (error) { await rm(directory, { recursive: true, force: true }); - throw new CliError(`Could not create data app in ${directory}.`, { cause: error }); + throw new CliError(`Could not create data app in ${directory}.`, { + cause: error, + }); } const nextSteps = profileName - ? ["app dev", "app lint", "app build"].map( - (command) => `altertable --profile ${profileName} ${command}`, + ? ['app dev', 'app lint', 'app build'].map( + command => `altertable --profile ${profileName} ${command}` ) : [ - "Set organization and environment in app.json.", - "Select a matching profile, or configure one with `altertable login --org --env `.", - "altertable app dev", - "altertable app lint --lakehouse", - "altertable app build", + 'Set organization and environment in app.json.', + 'Select a matching profile, or configure one with `altertable login --org --env `.', + 'altertable app dev', + 'altertable app lint --lakehouse', + 'altertable app build', ]; const result = { name, directory, scope, - scopeSource: profileName ? "profile" : "placeholder", + scopeSource: profileName ? 'profile' : 'placeholder', ...(profileName ? { profile: profileName, organizationSlug } : {}), files: files.map(([path]) => path), nextSteps, @@ -106,11 +139,11 @@ export const appCreateCommand = defineCommand({ if (sink.json) sink.writeJson(result); else if (profileName) sink.writeHuman( - `Created ${title} in ${directory}.\nScope: ${scope.organization} / ${scope.environment} (profile ${profileName}).\nFrom that directory, run \`${nextSteps[0]}\`.`, + `Created ${title} in ${directory}.\nScope: ${scope.organization} / ${scope.environment} (profile ${profileName}).\nFrom that directory, run \`${nextSteps[0]}\`.` ); else sink.writeHuman( - `Created ${title} in ${directory} with placeholder scope.\nBefore previewing, set the organization and environment in app.json and select a matching profile (or run \`altertable login --org --env \`).\nYou can run \`altertable app build\` from that directory now.`, + `Created ${title} in ${directory} with placeholder scope.\nBefore previewing, set the organization and environment in app.json and select a matching profile (or run \`altertable login --org --env \`).\nYou can run \`altertable app build\` from that directory now.` ); }, }); diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index c21d0001..6f8d7d2f 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,35 +1,41 @@ -import { checkAppScope } from "@/commands/app/lib/scope.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; -import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; +import { checkAppScope } from '@/commands/app/lib/scope.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError, EXIT_GENERIC } from '@/lib/errors.ts'; +import { startAppDevProxy } from '@/commands/app/lib/dev-proxy.ts'; import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, -} from "@/commands/app/lib/run.ts"; +} from '@/commands/app/lib/run.ts'; export const appDevCommand = defineCommand({ metadata: { - name: "dev", - description: "Preview a data app locally with lakehouse access.", + name: 'dev', + description: 'Preview a data app locally with lakehouse access.', examples: [ - "altertable app dev", - "altertable app dev --port 3022", - "altertable --profile staging app dev --dir ./my-app", + 'altertable app dev', + 'altertable app dev --port 3022', + 'altertable --profile staging app dev --dir ./my-app', ], }, args: { - dir: { type: "string", description: "App directory (default: current directory)." }, - port: { type: "string", description: "Local dev server port (1–65535; default: app setting)." }, + dir: { + type: 'string', + description: 'App directory (default: current directory).', + }, + port: { + type: 'string', + description: 'Local dev server port (1–65535; default: app setting).', + }, }, async run({ args, execution, runtime }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); const port = appPort(args.port); const directory = appDirectory(args.dir); - requireAppScripts(directory, ["dev"]); + requireAppScripts(directory, ['dev']); await checkAppScope(directory, execution.profile); - const installExitCode = await runAppCommand("install", directory); + const installExitCode = await runAppCommand('install', directory); if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; const proxy = startAppDevProxy(execution); try { @@ -37,7 +43,7 @@ export const appDevCommand = defineCommand({ ...proxy.environment, ...(port ? { PORT: port } : {}), }; - const exitCode = await runAppCommand("dev", directory, environment); + const exitCode = await runAppCommand('dev', directory, environment); return { exitCode: exitCode === 0 ? 0 : EXIT_GENERIC }; } finally { await proxy.stop(); @@ -47,8 +53,12 @@ export const appDevCommand = defineCommand({ function appPort(value: unknown): string | undefined { if (value === undefined) return undefined; - if (typeof value !== "string" || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) { - throw new CliError("--port must be an integer from 1 to 65535."); + if ( + typeof value !== 'string' || + !/^[1-9]\d{0,4}$/.test(value) || + Number(value) > 65535 + ) { + throw new CliError('--port must be an integer from 1 to 65535.'); } return value; } diff --git a/cli/src/commands/app/index.test.ts b/cli/src/commands/app/index.test.ts index 9b242417..74aac154 100644 --- a/cli/src/commands/app/index.test.ts +++ b/cli/src/commands/app/index.test.ts @@ -1,41 +1,41 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; let directory: string; let previousEnvironment: string | undefined; beforeEach(() => { - workspace = createLakehouseTestWorkspace("app-command"); + workspace = createLakehouseTestWorkspace('app-command'); previousEnvironment = process.env.ALTERTABLE_ENV; - process.env.ALTERTABLE_ENV = "production"; - directory = workspace.createDirectory("app"); + process.env.ALTERTABLE_ENV = 'production'; + directory = workspace.createDirectory('app'); workspace.writeFile( - "app/app.json", + 'app/app.json', JSON.stringify({ schemaVersion: 1, - title: "Test", - scope: { organization: "acme", environment: "production" }, - }), + title: 'Test', + scope: { organization: 'acme', environment: 'production' }, + }) ); workspace.writeFile( - "app/package.json", + 'app/package.json', JSON.stringify({ scripts: { - typecheck: "bun capture.ts typecheck", - dev: "bun capture.ts dev", - build: "bun capture.ts build", + typecheck: 'bun capture.ts typecheck', + dev: 'bun capture.ts dev', + build: 'bun capture.ts build', }, - }), + }) ); workspace.writeFile( - "app/capture.ts", + 'app/capture.ts', `import { writeFileSync } from "node:fs"; writeFileSync(import.meta.dir + "/" + process.argv[2] + ".json", JSON.stringify({ username: process.env.ALTERTABLE_LAKEHOUSE_USERNAME, @@ -46,7 +46,7 @@ writeFileSync(import.meta.dir + "/" + process.argv[2] + ".json", JSON.stringify( proxyUrl: process.env.ALTERTABLE_DATA_PROXY_URL, proxyToken: process.env.ALTERTABLE_DATA_PROXY_TOKEN, port: process.env.PORT, -}));`, +}));` ); }); @@ -58,8 +58,10 @@ afterEach(() => { else process.env.ALTERTABLE_ENV = previousEnvironment; }); -function captured(script: "typecheck" | "dev" | "build"): Record { - return JSON.parse(readFileSync(join(directory, `${script}.json`), "utf8")); +function captured( + script: 'typecheck' | 'dev' | 'build' +): Record { + return JSON.parse(readFileSync(join(directory, `${script}.json`), 'utf8')); } function runAppCommand(args: string[]) { @@ -70,113 +72,145 @@ function runAppCommand(args: string[]) { }); } -describe("app commands", () => { - test("dev rejects a mismatched scope before launching the app", async () => { +describe('app commands', () => { + test('dev rejects a mismatched scope before launching the app', async () => { workspace.writeFile( - "app/app.json", - JSON.stringify({ scope: { organization: "acme", environment: "staging" } }), + 'app/app.json', + JSON.stringify({ + scope: { organization: 'acme', environment: 'staging' }, + }) ); - const error = await runAppCommand(["app", "dev", "--dir", directory]).catch( - (error: unknown) => error, + const error = await runAppCommand(['app', 'dev', '--dir', directory]).catch( + (error: unknown) => error ); - expect(error).toMatchObject({ message: expect.stringContaining("does not match") }); - expect(existsSync(join(directory, "dev.json"))).toBe(false); + expect(error).toMatchObject({ + message: expect.stringContaining('does not match'), + }); + expect(existsSync(join(directory, 'dev.json'))).toBe(false); }); - test("dev gives the app only a loopback proxy without blocking on lakehouse access", async () => { + test('dev gives the app only a loopback proxy without blocking on lakehouse access', async () => { workspace.writeMocks([ - { urlPattern: "/query", method: "POST", status: 500, body: "temporary failure" }, + { + urlPattern: '/query', + method: 'POST', + status: 500, + body: 'temporary failure', + }, ]); - const result = await runAppCommand(["app", "dev", "--dir", directory]); + const result = await runAppCommand(['app', 'dev', '--dir', directory]); expect(result.exitCode).toBe(0); - const dev = captured("dev"); + const dev = captured('dev'); expect(dev.username).toBeUndefined(); expect(dev.password).toBeUndefined(); expect(dev.apiBase).toBeUndefined(); expect(dev.proxyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(dev.proxyToken).toMatch(/^[a-f0-9]{64}$/); - expect(existsSync(join(directory, "typecheck.json"))).toBe(false); + expect(existsSync(join(directory, 'typecheck.json'))).toBe(false); }); - test("dev passes a validated port only to the dev server", async () => { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: '{"ok":true}' }]); + test('dev passes a validated port only to the dev server', async () => { + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: '{"ok":true}' }, + ]); - const result = await runAppCommand(["app", "dev", "--dir", directory, "--port", "3022"]); + const result = await runAppCommand([ + 'app', + 'dev', + '--dir', + directory, + '--port', + '3022', + ]); expect(result.exitCode).toBe(0); - expect(captured("dev").port).toBe("3022"); - expect(existsSync(join(directory, "typecheck.json"))).toBe(false); + expect(captured('dev').port).toBe('3022'); + expect(existsSync(join(directory, 'typecheck.json'))).toBe(false); }); - test("dev rejects invalid ports before installing dependencies", () => { - for (const port of ["0", "65536", "-1", "abc", "3000.5", "03000"]) { - expect(runAppCommand(["app", "dev", "--dir", directory, "--port", port])).rejects.toThrow( - "--port must be an integer from 1 to 65535", - ); + test('dev rejects invalid ports before installing dependencies', () => { + for (const port of ['0', '65536', '-1', 'abc', '3000.5', '03000']) { + expect( + runAppCommand(['app', 'dev', '--dir', directory, '--port', port]) + ).rejects.toThrow('--port must be an integer from 1 to 65535'); } - expect(existsSync(join(directory, "typecheck.json"))).toBe(false); + expect(existsSync(join(directory, 'typecheck.json'))).toBe(false); }); - test("build does not pass Altertable credentials to the bundler", async () => { - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = Buffer.from("testuser:testpass").toString("base64"); - process.env.ALTERTABLE_API_KEY = "management-secret"; + test('build does not pass Altertable credentials to the bundler', async () => { + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = + Buffer.from('testuser:testpass').toString('base64'); + process.env.ALTERTABLE_API_KEY = 'management-secret'; - const result = await runAppCommand(["app", "build", "--dir", directory]); + const result = await runAppCommand(['app', 'build', '--dir', directory]); expect(result.exitCode).toBe(0); - expect(captured("build")).toEqual({}); - expect(captured("typecheck")).toEqual({}); - expect(result.stdout.join("\n")).toContain(`Built data app in ${directory}/dist.`); + expect(captured('build')).toEqual({}); + expect(captured('typecheck')).toEqual({}); + expect(result.stdout.join('\n')).toContain( + `Built data app in ${directory}/dist.` + ); }); - test("requires the selected script before launching Bun", async () => { + test('requires the selected script before launching Bun', async () => { workspace.writeFile( - "app/package.json", - JSON.stringify({ scripts: { typecheck: "bun capture.ts typecheck", dev: "bun capture.ts" } }), + 'app/package.json', + JSON.stringify({ + scripts: { + typecheck: 'bun capture.ts typecheck', + dev: 'bun capture.ts', + }, + }) ); - expect(runAppCommand(["app", "build", "--dir", directory])).rejects.toThrow( - 'needs a "build" script', + expect(runAppCommand(['app', 'build', '--dir', directory])).rejects.toThrow( + 'needs a "build" script' ); }); - test("validates a dev project before starting the proxy", async () => { + test('validates a dev project before starting the proxy', async () => { workspace.writeFile( - "app/package.json", - JSON.stringify({ scripts: { typecheck: "bun capture.ts typecheck" } }), + 'app/package.json', + JSON.stringify({ scripts: { typecheck: 'bun capture.ts typecheck' } }) ); - expect(runAppCommand(["app", "dev", "--dir", directory])).rejects.toThrow( - 'needs a "dev" script', + expect(runAppCommand(['app', 'dev', '--dir', directory])).rejects.toThrow( + 'needs a "dev" script' ); }); - test("rejects structured output for inherited app script output", async () => { + test('rejects structured output for inherited app script output', async () => { expect( - runCommandWithTestRuntime(["app", "build", "--dir", directory], { + runCommandWithTestRuntime(['app', 'build', '--dir', directory], { debug: false, json: true, agent: false, - }), - ).rejects.toThrow("cannot use --json or --agent"); + }) + ).rejects.toThrow('cannot use --json or --agent'); }); - test("starts dev without typechecking and stops build when typechecking fails", async () => { + test('starts dev without typechecking and stops build when typechecking fails', async () => { workspace.writeFile( - "app/package.json", + 'app/package.json', JSON.stringify({ - scripts: { typecheck: "exit 2", dev: "bun capture.ts dev", build: "bun capture.ts build" }, - }), + scripts: { + typecheck: 'exit 2', + dev: 'bun capture.ts dev', + build: 'bun capture.ts build', + }, + }) ); - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: '{"ok":true}' }]); + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: '{"ok":true}' }, + ]); - const dev = await runAppCommand(["app", "dev", "--dir", directory]); + const dev = await runAppCommand(['app', 'dev', '--dir', directory]); expect(dev.exitCode).toBe(0); - expect(existsSync(join(directory, "dev.json"))).toBe(true); + expect(existsSync(join(directory, 'dev.json'))).toBe(true); - const build = await runAppCommand(["app", "build", "--dir", directory]); + const build = await runAppCommand(['app', 'build', '--dir', directory]); expect(build.exitCode).toBe(1); - expect(existsSync(join(directory, "build.json"))).toBe(false); + expect(existsSync(join(directory, 'build.json'))).toBe(false); }); }); diff --git a/cli/src/commands/app/index.ts b/cli/src/commands/app/index.ts index e68e29d5..954ba9ca 100644 --- a/cli/src/commands/app/index.ts +++ b/cli/src/commands/app/index.ts @@ -1,28 +1,28 @@ -import { defineCommand } from "@/lib/command.ts"; -import { appBuildCommand } from "@/commands/app/build.ts"; -import { appCreateCommand } from "@/commands/app/create.ts"; -import { appDevCommand } from "@/commands/app/dev.ts"; -import { appLintCommand } from "@/commands/app/lint.ts"; -import { appPublishCommand } from "@/commands/app/publish.ts"; -import { appUpdateCommand } from "@/commands/app/update.ts"; -import { appUpgradeCommand } from "@/commands/app/upgrade.ts"; -import { appValidateCommand } from "@/commands/app/validate.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { appBuildCommand } from '@/commands/app/build.ts'; +import { appCreateCommand } from '@/commands/app/create.ts'; +import { appDevCommand } from '@/commands/app/dev.ts'; +import { appLintCommand } from '@/commands/app/lint.ts'; +import { appPublishCommand } from '@/commands/app/publish.ts'; +import { appUpdateCommand } from '@/commands/app/update.ts'; +import { appUpgradeCommand } from '@/commands/app/upgrade.ts'; +import { appValidateCommand } from '@/commands/app/validate.ts'; export const appCommand = defineCommand({ metadata: { - name: "app", - commandGroup: "platform", + name: 'app', + commandGroup: 'platform', description: - "Create, develop, lint, build, and upgrade local data apps; validate, publish, and update data apps.", + 'Create, develop, lint, build, and upgrade local data apps; validate, publish, and update data apps.', examples: [ - "altertable app create my-app", - "altertable app dev", - "altertable app lint", - "altertable app build", - "altertable app upgrade", - "altertable app validate --file index.tsx", + 'altertable app create my-app', + 'altertable app dev', + 'altertable app lint', + 'altertable app build', + 'altertable app upgrade', + 'altertable app validate --file index.tsx', "altertable app publish --title 'Revenue explorer' --file index.tsx", - "altertable app update APP-1 --file index.tsx", + 'altertable app update APP-1 --file index.tsx', ], }, subcommands: { diff --git a/cli/src/commands/app/lib/dev-proxy.test.ts b/cli/src/commands/app/lib/dev-proxy.test.ts index 67f765d5..f6efa7ef 100644 --- a/cli/src/commands/app/lib/dev-proxy.test.ts +++ b/cli/src/commands/app/lib/dev-proxy.test.ts @@ -1,12 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import type { sendHttpStream } from "@/lib/http-request.ts"; +import { describe, expect, test } from 'bun:test'; +import { startAppDevProxy } from '@/commands/app/lib/dev-proxy.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import type { sendHttpStream } from '@/lib/http-request.ts'; -describe("data app dev proxy", () => { - test("keeps credentials in the CLI and accepts only authenticated query requests", async () => { +describe('data app dev proxy', () => { + test('keeps credentials in the CLI and accepts only authenticated query requests', async () => { const calls: string[] = []; - const send: typeof sendHttpStream = async (request) => { + const send: typeof sendHttpStream = async request => { calls.push(request.body as string); return new ReadableStream({ start(controller) { @@ -15,20 +15,35 @@ describe("data app dev proxy", () => { }, }); }; - const proxy = startAppDevProxy({ profile: "test" } as ExecutionContext, send); + const proxy = startAppDevProxy( + { profile: 'test' } as ExecutionContext, + send + ); try { - expect(proxy.environment).not.toHaveProperty("ALTERTABLE_LAKEHOUSE_USERNAME"); - expect(proxy.environment).not.toHaveProperty("ALTERTABLE_LAKEHOUSE_PASSWORD"); + expect(proxy.environment).not.toHaveProperty( + 'ALTERTABLE_LAKEHOUSE_USERNAME' + ); + expect(proxy.environment).not.toHaveProperty( + 'ALTERTABLE_LAKEHOUSE_PASSWORD' + ); const url = `${proxy.environment.ALTERTABLE_DATA_PROXY_URL}/query`; - expect((await fetch(url, { method: "POST", body: "{}" })).status).toBe(401); + expect((await fetch(url, { method: 'POST', body: '{}' })).status).toBe( + 401 + ); expect(calls).toHaveLength(0); - const headers = { authorization: `Bearer ${proxy.environment.ALTERTABLE_DATA_PROXY_TOKEN}` }; - expect((await fetch(url, { method: "GET", headers })).status).toBe(404); + const headers = { + authorization: `Bearer ${proxy.environment.ALTERTABLE_DATA_PROXY_TOKEN}`, + }; + expect((await fetch(url, { method: 'GET', headers })).status).toBe(404); expect(calls).toHaveLength(0); - const response = await fetch(url, { method: "POST", headers, body: "{}" }); + const response = await fetch(url, { + method: 'POST', + headers, + body: '{}', + }); expect(response.status).toBe(200); expect(await response.text()).toBe('{"query_id":"q1"}\n'); - expect(calls).toEqual(["{}"]); + expect(calls).toEqual(['{}']); } finally { await proxy.stop(); } diff --git a/cli/src/commands/app/lib/dev-proxy.ts b/cli/src/commands/app/lib/dev-proxy.ts index 92c656d6..5883d974 100644 --- a/cli/src/commands/app/lib/dev-proxy.ts +++ b/cli/src/commands/app/lib/dev-proxy.ts @@ -1,55 +1,69 @@ -import { randomBytes, timingSafeEqual } from "node:crypto"; -import { ConfigurationError, HttpError } from "@/lib/errors.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttpStream } from "@/lib/http-request.ts"; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { ConfigurationError, HttpError } from '@/lib/errors.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttpStream } from '@/lib/http-request.ts'; /** Keep profile credentials in the CLI process while a local app is running. */ export function startAppDevProxy( execution: ExecutionContext, - send: typeof sendHttpStream = sendHttpStream, + send: typeof sendHttpStream = sendHttpStream ) { - const token = randomBytes(32).toString("hex"); + const token = randomBytes(32).toString('hex'); const server = Bun.serve({ - hostname: "127.0.0.1", + hostname: '127.0.0.1', port: 0, idleTimeout: 60, async fetch(request) { - const supplied = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""; + const supplied = + request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''; const valid = supplied.length === token.length && timingSafeEqual(Buffer.from(supplied), Buffer.from(token)); - if (!valid) return new Response("Unauthorized", { status: 401 }); - if (request.method !== "POST" || new URL(request.url).pathname !== "/query") { - return new Response("Not found", { status: 404 }); + if (!valid) return new Response('Unauthorized', { status: 401 }); + if ( + request.method !== 'POST' || + new URL(request.url).pathname !== '/query' + ) { + return new Response('Not found', { status: 404 }); } const body = await request.text(); - if (body.length > 100_000) return new Response("Query too large", { status: 413 }); + if (body.length > 100_000) + return new Response('Query too large', { status: 413 }); try { const stream = await send( { - plane: "lakehouse", - method: "POST", - endpoint: "/query", + plane: 'lakehouse', + method: 'POST', + endpoint: '/query', body, - contentType: "application/json", + contentType: 'application/json', }, - execution, + execution ); return new Response(stream, { - headers: { "content-type": "application/x-ndjson", "cache-control": "no-store" }, + headers: { + 'content-type': 'application/x-ndjson', + 'cache-control': 'no-store', + }, }); } catch (error) { if (error instanceof HttpError) { - return new Response("Lakehouse query failed", { status: error.status ?? 502 }); + return new Response('Lakehouse query failed', { + status: error.status ?? 502, + }); } if (error instanceof ConfigurationError) { console.error( - `Data app lakehouse access failed for profile ${execution.profile}. Run \`altertable login\` or update the profile, then retry.`, + `Data app lakehouse access failed for profile ${execution.profile}. Run \`altertable login\` or update the profile, then retry.` ); - return new Response("Lakehouse access needs attention", { status: 401 }); + return new Response('Lakehouse access needs attention', { + status: 401, + }); } - console.error(`Data app lakehouse query failed for profile ${execution.profile}.`); - return new Response("Lakehouse query failed", { status: 502 }); + console.error( + `Data app lakehouse query failed for profile ${execution.profile}.` + ); + return new Response('Lakehouse query failed', { status: 502 }); } }, }); diff --git a/cli/src/commands/app/lib/distribution.test.ts b/cli/src/commands/app/lib/distribution.test.ts index 4985af4b..2a23bc33 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -1,95 +1,112 @@ -import { afterEach, expect, test } from "bun:test"; -import { rejects } from "node:assert/strict"; -import { cp, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, posix } from "node:path"; +import { afterEach, expect, test } from 'bun:test'; +import { rejects } from 'node:assert/strict'; +import { cp, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, posix } from 'node:path'; import { createAppFiles, dataAppDirectory, readDataAppPayload, starterFiles, -} from "@/commands/app/lib/distribution.ts"; -import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; +} from '@/commands/app/lib/distribution.ts'; +import { recommendedDataAppVersion } from '@/commands/app/lib/package.ts'; const directories: string[] = []; afterEach(async () => { await Promise.all( - directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + directories + .splice(0) + .map(path => rm(path, { recursive: true, force: true })) ); }); -test("distribution embeds a deterministic starter with an exact package pin and no runtime", async () => { +test('distribution embeds a deterministic starter with an exact package pin and no runtime', async () => { const payload = await readDataAppPayload(); expect(await readDataAppPayload()).toEqual(payload); - expect(Object.keys(payload)).toEqual(["starter"]); - expect(Object.keys(payload.starter)).toContain("src/App.tsx"); + expect(Object.keys(payload)).toEqual(['starter']); + expect(Object.keys(payload.starter)).toContain('src/App.tsx'); for (const path of Object.keys(payload.starter)) { - expect(path).not.toMatch(/(^|\/)(tests|fixtures|node_modules|dist|\.altertable)(\/|$)|\.txt$/); + expect(path).not.toMatch( + /(^|\/)(tests|fixtures|node_modules|dist|\.altertable)(\/|$)|\.txt$/ + ); } const identity = { - name: "example", + name: 'example', title: 'A "quoted" title', - scope: { organization: "', + join(directory, 'src/operations.ts'), + 'export const operations = { totals: { checks: [{}], input: () => ({}), run: async () => ({}), policy: { maxQueryRows: 1, maxDurationMs: 1000 } } };' ); writeFileSync( - join(directory, "src/main.ts"), - 'import { operations } from "./operations.ts"; console.log(operations);', + join(directory, 'src/index.html'), + '' ); - const boundaryError = await checkAppProject(directory).catch((error: unknown) => error); - expect(boundaryError).toMatchObject({ message: "Data app validation failed." }); writeFileSync( - join(directory, "src/main.ts"), - 'import type { operations } from "./operations.ts"; const name: keyof typeof operations = "totals"; console.log(name);', + join(directory, 'src/main.ts'), + 'import { operations } from "./operations.ts"; console.log(operations);' + ); + const boundaryError = await checkAppProject(directory).catch( + (error: unknown) => error + ); + expect(boundaryError).toMatchObject({ + message: 'Data app validation failed.', + }); + writeFileSync( + join(directory, 'src/main.ts'), + 'import type { operations } from "./operations.ts"; const name: keyof typeof operations = "totals"; console.log(name);' ); await checkAppProject(directory); }); -test("lakehouse checks execute every declared input through public package exports", async () => { - mkdirSync(join(directory, "src")); - mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/core"), { recursive: true }); - mkdirSync(join(directory, "node_modules/@altertable/data-app/dist/server"), { recursive: true }); +test('lakehouse checks execute every declared input through public package exports', async () => { + mkdirSync(join(directory, 'src')); + mkdirSync(join(directory, 'node_modules/@altertable/data-app/dist/core'), { + recursive: true, + }); + mkdirSync(join(directory, 'node_modules/@altertable/data-app/dist/server'), { + recursive: true, + }); writeFileSync( - join(directory, "app.json"), - JSON.stringify({ schemaVersion: 1, title: "Live check" }), + join(directory, 'app.json'), + JSON.stringify({ schemaVersion: 1, title: 'Live check' }) ); writeFileSync( - join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), - "export function parseAppearance() {}", + join( + directory, + 'node_modules/@altertable/data-app/dist/core/appearance.js' + ), + 'export function parseAppearance() {}' ); writeFileSync( - join(directory, "node_modules/@altertable/data-app-bun/dist/index.js"), - "export function localLakehouse() { return {}; }", + join(directory, 'node_modules/@altertable/data-app-bun/dist/index.js'), + 'export function localLakehouse() { return {}; }' ); writeFileSync( - join(directory, "node_modules/@altertable/data-app/dist/server/index.js"), + join(directory, 'node_modules/@altertable/data-app/dist/server/index.js'), ` export function createDataHandler(operations) { return async (request) => { @@ -180,10 +239,10 @@ test("lakehouse checks execute every declared input through public package expor return new Response("ok"); }; } - `, + ` ); writeFileSync( - join(directory, "src/operations.ts"), + join(directory, 'src/operations.ts'), ` import { appendFileSync } from "node:fs"; export const operations = { totals: { @@ -191,57 +250,62 @@ test("lakehouse checks execute every declared input through public package expor policy: { maxQueryRows: 1, maxDurationMs: 1000 }, async run(context, input) { appendFileSync("executed.txt", String(input.day)); return input; }, } }; - `, + ` ); await checkAppProject(directory); - expect(Bun.file(join(directory, "executed.txt")).size).toBe(0); + expect(Bun.file(join(directory, 'executed.txt')).size).toBe(0); await checkAppProject(directory, {}); - expect(readFileSync(join(directory, "executed.txt"), "utf8")).toBe("12"); + expect(readFileSync(join(directory, 'executed.txt'), 'utf8')).toBe('12'); }); test.each([ - "@altertable/data-app/server", - "@altertable/data-app-bun", - "@altertable/data-app-bun/preview", - "@altertable/data-app-bun/reload", - "@altertable/data-app-worker", + '@altertable/data-app/server', + '@altertable/data-app-bun', + '@altertable/data-app-bun/preview', + '@altertable/data-app-bun/reload', + '@altertable/data-app-worker', ])( - "browser bundles reject the published %s entry even without credential strings", - async (entry) => { - mkdirSync(join(directory, "src")); + 'browser bundles reject the published %s entry even without credential strings', + async entry => { + mkdirSync(join(directory, 'src')); writeFileSync( - join(directory, "app.json"), - JSON.stringify({ schemaVersion: 1, title: "Boundary" }), + join(directory, 'app.json'), + JSON.stringify({ schemaVersion: 1, title: 'Boundary' }) ); writeFileSync( - join(directory, "node_modules/@altertable/data-app/dist/core/appearance.js"), - "export function parseAppearance() {}", + join( + directory, + 'node_modules/@altertable/data-app/dist/core/appearance.js' + ), + 'export function parseAppearance() {}' ); writeFileSync( - join(directory, "node_modules/@altertable/data-app/dist/server/index.js"), - "export const secretFreeHandler = 1;", + join(directory, 'node_modules/@altertable/data-app/dist/server/index.js'), + 'export const secretFreeHandler = 1;' ); writeFileSync( - join(directory, "node_modules/@altertable/data-app-bun/dist/index.js"), - "export const secretFreeHandler = 1;", + join(directory, 'node_modules/@altertable/data-app-bun/dist/index.js'), + 'export const secretFreeHandler = 1;' ); writeFileSync( - join(directory, "src/operations.ts"), - "export const operations = { test: { checks: [{}], input: v => v, run: async () => ({}), policy: { maxQueryRows: 1, maxDurationMs: 1000 } } };", + join(directory, 'src/operations.ts'), + 'export const operations = { test: { checks: [{}], input: v => v, run: async () => ({}), policy: { maxQueryRows: 1, maxDurationMs: 1000 } } };' ); writeFileSync( - join(directory, "src/index.html"), - '', + join(directory, 'src/index.html'), + '' ); writeFileSync( - join(directory, "src/main.ts"), - `import { secretFreeHandler } from "${entry}"; console.log(secretFreeHandler);`, + join(directory, 'src/main.ts'), + `import { secretFreeHandler } from "${entry}"; console.log(secretFreeHandler);` + ); + expect(checkAppProject(directory)).rejects.toThrow( + 'Data app validation failed.' ); - expect(checkAppProject(directory)).rejects.toThrow("Data app validation failed."); writeFileSync( - join(directory, "src/main.ts"), - `import type { secretFreeHandler } from "${entry}"; console.log("browser safe");`, + join(directory, 'src/main.ts'), + `import type { secretFreeHandler } from "${entry}"; console.log("browser safe");` ); await checkAppProject(directory); - }, + } ); diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index d2f6a585..17c2a118 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -1,20 +1,20 @@ -import { checkAppScope } from "@/commands/app/lib/scope.ts"; -import { readFile, readdir } from "node:fs/promises"; -import { join } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { getLakehouseCredentialPair } from "@/lib/auth.ts"; -import { resolveApiBase } from "@/lib/config.ts"; -import { ConfigurationError, EXIT_GENERIC } from "@/lib/errors.ts"; -import { copyProcessEnv } from "@/lib/env.ts"; -import { configureVerify } from "@/lib/profile-status.ts"; +import { checkAppScope } from '@/commands/app/lib/scope.ts'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { defineCommand } from '@/lib/command.ts'; +import { getLakehouseCredentialPair } from '@/lib/auth.ts'; +import { resolveApiBase } from '@/lib/config.ts'; +import { ConfigurationError, EXIT_GENERIC } from '@/lib/errors.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; +import { configureVerify } from '@/lib/profile-status.ts'; import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, -} from "@/commands/app/lib/run.ts"; -import { requirePublishedDataApp } from "@/commands/app/lib/package.ts"; -import projectCheckScript from "@/commands/app/lib/project-check.js.txt"; +} from '@/commands/app/lib/run.ts'; +import { requirePublishedDataApp } from '@/commands/app/lib/package.ts'; +import projectCheckScript from '@/commands/app/lib/project-check.js.txt'; type AppManifest = { schemaVersion: 1; @@ -24,37 +24,52 @@ type AppManifest = { export const appLintCommand = defineCommand({ metadata: { - name: "lint", + name: 'lint', description: - "Lint a local data app project: format, lint rules, types, contracts, build, and client credential boundary.", - examples: ["altertable app lint", "altertable app lint --lakehouse"], + 'Lint a local data app project: format, lint rules, types, contracts, build, and client credential boundary.', + examples: ['altertable app lint', 'altertable app lint --lakehouse'], }, args: { - dir: { type: "string", description: "App directory (default: current directory)." }, + dir: { + type: 'string', + description: 'App directory (default: current directory).', + }, lakehouse: { - type: "boolean", - description: "Run each operation’s declared checks against the selected lakehouse.", + type: 'boolean', + description: + 'Run each operation’s declared checks against the selected lakehouse.', }, }, async run({ args, execution, runtime, sink }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); const directory = appDirectory(args.dir); - requireAppScripts(directory, ["format:check", "lint", "typecheck", "build"]); + requireAppScripts(directory, [ + 'format:check', + 'lint', + 'typecheck', + 'build', + ]); const manifest = await readManifest(directory); if (args.lakehouse) await checkAppScope(directory, execution.profile); await requirePublishedDataApp(directory); - if ((await runAppCommand("install-frozen", directory)) !== 0) return { exitCode: EXIT_GENERIC }; - if ((await runAppCommand("format:check", directory)) !== 0) return { exitCode: EXIT_GENERIC }; - if ((await runAppCommand("lint", directory)) !== 0) return { exitCode: EXIT_GENERIC }; - if ((await runAppCommand("typecheck", directory)) !== 0) return { exitCode: EXIT_GENERIC }; + if ((await runAppCommand('install-frozen', directory)) !== 0) + return { exitCode: EXIT_GENERIC }; + if ((await runAppCommand('format:check', directory)) !== 0) + return { exitCode: EXIT_GENERIC }; + if ((await runAppCommand('lint', directory)) !== 0) + return { exitCode: EXIT_GENERIC }; + if ((await runAppCommand('typecheck', directory)) !== 0) + return { exitCode: EXIT_GENERIC }; await checkAppProject(directory); - if ((await runAppCommand("build", directory)) !== 0) return { exitCode: EXIT_GENERIC }; + if ((await runAppCommand('build', directory)) !== 0) + return { exitCode: EXIT_GENERIC }; await checkClientBundle(directory); if (args.lakehouse) { - const verified = await configureVerify(["lakehouse"], execution); + const verified = await configureVerify(['lakehouse'], execution); if (!verified.verified.lakehouse) throw new ConfigurationError( - verified.errors[0]?.message ?? "Lakehouse credentials verification failed.", + verified.errors[0]?.message ?? + 'Lakehouse credentials verification failed.' ); const credentials = getLakehouseCredentialPair(execution.profile); await checkClientBundle(directory, [credentials.password]); @@ -65,7 +80,7 @@ export const appLintCommand = defineCommand({ }); } sink.writeHuman( - `Checked ${manifest.title}: format, lint, types, operation contracts, build, and client credential scan passed${args.lakehouse ? "; lakehouse operation checks passed" : ""}.`, + `Checked ${manifest.title}: format, lint, types, operation contracts, build, and client credential scan passed${args.lakehouse ? '; lakehouse operation checks passed' : ''}.` ); }, }); @@ -73,20 +88,22 @@ export const appLintCommand = defineCommand({ export async function readManifest(directory: string): Promise { let value: unknown; try { - value = JSON.parse(await readFile(join(directory, "app.json"), "utf8")); + value = JSON.parse(await readFile(join(directory, 'app.json'), 'utf8')); } catch { - throw new ConfigurationError(`Cannot read a valid app.json in ${directory}.`); + throw new ConfigurationError( + `Cannot read a valid app.json in ${directory}.` + ); } if ( - typeof value !== "object" || + typeof value !== 'object' || value === null || - !("schemaVersion" in value) || + !('schemaVersion' in value) || value.schemaVersion !== 1 || - !("title" in value) || - typeof value.title !== "string" || + !('title' in value) || + typeof value.title !== 'string' || !value.title.trim() ) { - throw new ConfigurationError("app.json needs schemaVersion 1 and a title."); + throw new ConfigurationError('app.json needs schemaVersion 1 and a title.'); } return value as AppManifest; } @@ -94,64 +111,85 @@ export async function readManifest(directory: string): Promise { /** Validate app-owned modules in Bun, outside the compiled CLI's module graph. */ export async function checkAppProject( directory: string, - lakehouseEnvironment?: Record, + lakehouseEnvironment?: Record ): Promise { const env = copyProcessEnv(); for (const key of Object.keys(env)) { - if (key.startsWith("ALTERTABLE_")) delete env[key]; + if (key.startsWith('ALTERTABLE_')) delete env[key]; } Object.assign(env, lakehouseEnvironment); - env.BUN_BE_BUN = "1"; + env.BUN_BE_BUN = '1'; const child = Bun.spawn( [ process.execPath, - "-e", + '-e', projectCheckScript, - "--", - lakehouseEnvironment ? "--lakehouse" : "--contract", + '--', + lakehouseEnvironment ? '--lakehouse' : '--contract', ], - { cwd: directory, env, stdin: "ignore", stdout: "inherit", stderr: "inherit" }, + { + cwd: directory, + env, + stdin: 'ignore', + stdout: 'inherit', + stderr: 'inherit', + } ); if ((await child.exited) !== 0) { - throw new ConfigurationError("Data app validation failed."); + throw new ConfigurationError('Data app validation failed.'); } } -export async function checkClientBundle(directory: string, secrets: string[] = []): Promise { - const dist = join(directory, "dist"); +export async function checkClientBundle( + directory: string, + secrets: string[] = [] +): Promise { + const dist = join(directory, 'dist'); let files: string[]; try { files = await readdir(dist); } catch { - throw new ConfigurationError("Build did not create dist/."); + throw new ConfigurationError('Build did not create dist/.'); } - const clientScripts = files.filter((name) => /^index(?:-[\w]+)?\.js$/.test(name)); - if (!files.includes("index.html") || !clientScripts.length) { - throw new ConfigurationError("Build needs dist/index.html and a client JavaScript bundle."); + const clientScripts = files.filter(name => + /^index(?:-[\w]+)?\.js$/.test(name) + ); + if (!files.includes('index.html') || !clientScripts.length) { + throw new ConfigurationError( + 'Build needs dist/index.html and a client JavaScript bundle.' + ); } const clientAssets: string[] = []; - async function visit(path: string, prefix = "") { + async function visit(path: string, prefix = '') { for (const entry of await readdir(path, { withFileTypes: true })) { const name = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) await visit(join(path, entry.name), name); - else if (entry.isFile() && name !== "server.js" && name !== "server.js.map") + else if ( + entry.isFile() && + name !== 'server.js' && + name !== 'server.js.map' + ) clientAssets.push(name); } } await visit(dist); for (const name of clientAssets) { - const content = await readFile(join(dist, name), "utf8"); + const content = await readFile(join(dist, name), 'utf8'); if ( /ALTERTABLE_(?:API_BASE|LAKEHOUSE_USERNAME|LAKEHOUSE_PASSWORD|BASIC_AUTH_TOKEN|API_KEY|DATA_PROXY_URL|DATA_PROXY_TOKEN)/.test( - content, + content ) ) { throw new ConfigurationError( - `Client asset ${name} contains a credential environment reference.`, + `Client asset ${name} contains a credential environment reference.` ); } - if (secrets.some((secret) => secret.length >= 8 && content.includes(secret))) { - throw new ConfigurationError(`Client asset ${name} contains a lakehouse credential value.`); + if ( + secrets.some(secret => secret.length >= 8 && content.includes(secret)) + ) { + throw new ConfigurationError( + `Client asset ${name} contains a lakehouse credential value.` + ); } } } diff --git a/cli/src/commands/app/publish.ts b/cli/src/commands/app/publish.ts index b0d038a3..ad12ce0e 100644 --- a/cli/src/commands/app/publish.ts +++ b/cli/src/commands/app/publish.ts @@ -1,55 +1,64 @@ -import { optionalStringArg, stringArg } from "@/lib/args.ts"; -import { requireManagementPlane } from "@/lib/auth.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; +import { optionalStringArg, stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, readRequiredFile, type PublishedDataApp, -} from "@/commands/app/lib/remote.ts"; +} from '@/commands/app/lib/remote.ts'; export const appPublishCommand = defineCommand({ metadata: { - name: "publish", - description: "Create a data app from source.", - examples: ["altertable app publish --title 'Revenue explorer' --file index.tsx"], + name: 'publish', + description: 'Create a data app from source.', + examples: [ + "altertable app publish --title 'Revenue explorer' --file index.tsx", + ], }, args: { - title: { type: "string", description: "Title of the data app.", required: true }, + title: { + type: 'string', + description: 'Title of the data app.', + required: true, + }, file: { - type: "string", + type: 'string', description: - "Path to the single-file React source (index.tsx). Queries are declared in the file via defineDataApp.", + 'Path to the single-file React source (index.tsx). Queries are declared in the file via defineDataApp.', required: true, - completion: "file", + completion: 'file', + }, + description: { + type: 'string', + description: 'Optional description of the data app.', }, - description: { type: "string", description: "Optional description of the data app." }, }, async run({ args, execution, sink }) { const env = requireManagementPlane(execution.profile, { - requirement: "Publishing a data app requires the management API", + requirement: 'Publishing a data app requires the management API', }); - const title = stringArg(args, "title"); + const title = stringArg(args, 'title'); const response = parseApiJson( await sendHttp( - buildDataAppHttpRequest(env, "POST", "/data_apps", { + buildDataAppHttpRequest(env, 'POST', '/data_apps', { title, - index_tsx: await readRequiredFile(stringArg(args, "file"), "source"), - description: optionalStringArg(args, "description"), + index_tsx: await readRequiredFile(stringArg(args, 'file'), 'source'), + description: optionalStringArg(args, 'description'), }), - execution, - ), + execution + ) ) as { data_app?: PublishedDataApp }; const dataApp = response.data_app ?? {}; await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: { slug: dataApp.slug, url: dataApp.url }, - humanText: `Published ${dataApp.title ?? title} (${dataApp.slug ?? ""}).\n${dataApp.url ?? ""}`, + humanText: `Published ${dataApp.title ?? title} (${dataApp.slug ?? ''}).\n${dataApp.url ?? ''}`, }, - sink, + sink ); }, }); diff --git a/cli/src/commands/app/remote.test.ts b/cli/src/commands/app/remote.test.ts index eacbcdc9..247912e9 100644 --- a/cli/src/commands/app/remote.test.ts +++ b/cli/src/commands/app/remote.test.ts @@ -1,17 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; beforeEach(() => { - workspace = createLakehouseTestWorkspace("app-remote"); - process.env.ALTERTABLE_API_KEY = "atm_test"; - process.env.ALTERTABLE_ENV = "env-1"; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; + workspace = createLakehouseTestWorkspace('app-remote'); + process.env.ALTERTABLE_API_KEY = 'atm_test'; + process.env.ALTERTABLE_ENV = 'env-1'; + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; }); afterEach(() => { @@ -21,35 +21,48 @@ afterEach(() => { workspace.cleanup(); }); -const INDEX_TSX = "export default function App() { return
Hello
; }\n"; +const INDEX_TSX = + 'export default function App() { return
Hello
; }\n'; function writeAppFile() { - return workspace.writeFile("index.tsx", INDEX_TSX); + return workspace.writeFile('index.tsx', INDEX_TSX); } -describe("app validate", () => { - test("posts the source from disk and exits non-zero when the API returns errors", async () => { +describe('app validate', () => { + test('posts the source from disk and exits non-zero when the API returns errors', async () => { const file = writeAppFile(); workspace.writeMocks([ { - urlPattern: "/data_apps/validate", - method: "POST", + urlPattern: '/data_apps/validate', + method: 'POST', body: JSON.stringify({ - errors: [{ message: "Unexpected token", line: 3, column: 7, source: "esbuild" }], + errors: [ + { + message: 'Unexpected token', + line: 3, + column: 7, + source: 'esbuild', + }, + ], warnings: [], }), }, ]); - const result = await runCommandWithTestRuntime(["app", "validate", "--file", file], { - debug: false, - json: true, - agent: false, - }); + const result = await runCommandWithTestRuntime( + ['app', 'validate', '--file', file], + { + debug: false, + json: true, + agent: false, + } + ); expect(result.exitCode).toBe(6); expect(JSON.parse(result.stdout[0]!)).toEqual({ - errors: [{ message: "Unexpected token", line: 3, column: 7, source: "esbuild" }], + errors: [ + { message: 'Unexpected token', line: 3, column: 7, source: 'esbuild' }, + ], warnings: [], }); expect(JSON.parse(workspace.readPayloads()[0]!)).toEqual({ @@ -57,95 +70,109 @@ describe("app validate", () => { }); }); - test("reports a valid app", async () => { + test('reports a valid app', async () => { const file = writeAppFile(); workspace.writeMocks([ { - urlPattern: "/data_apps/validate", - method: "POST", + urlPattern: '/data_apps/validate', + method: 'POST', body: JSON.stringify({ errors: [], warnings: [] }), }, ]); - const result = await runCommandWithTestRuntime(["app", "validate", "--file", file], { - debug: false, - json: false, - agent: false, - }); + const result = await runCommandWithTestRuntime( + ['app', 'validate', '--file', file], + { + debug: false, + json: false, + agent: false, + } + ); expect(result.exitCode).toBe(0); - expect(result.stdout.join("\n")).toContain("Data app is valid."); + expect(result.stdout.join('\n')).toContain('Data app is valid.'); }); }); -describe("app publish", () => { - test("creates a data app from source", async () => { +describe('app publish', () => { + test('creates a data app from source', async () => { const file = writeAppFile(); workspace.writeMocks([ { - urlPattern: "/environments/env-1/data_apps", - method: "POST", + urlPattern: '/environments/env-1/data_apps', + method: 'POST', body: JSON.stringify({ data_app: { - slug: "APP-1", - url: "https://app.example.com/apps/APP-1", - title: "Revenue explorer", + slug: 'APP-1', + url: 'https://app.example.com/apps/APP-1', + title: 'Revenue explorer', }, }), }, ]); const result = await runCommandWithTestRuntime( - ["app", "publish", "--title", "Revenue explorer", "--file", file], - { debug: false, json: true, agent: false }, + ['app', 'publish', '--title', 'Revenue explorer', '--file', file], + { debug: false, json: true, agent: false } ); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout[0]!)).toEqual({ - slug: "APP-1", - url: "https://app.example.com/apps/APP-1", + slug: 'APP-1', + url: 'https://app.example.com/apps/APP-1', }); expect(JSON.parse(workspace.readPayloads()[0]!)).toMatchObject({ - title: "Revenue explorer", + title: 'Revenue explorer', index_tsx: INDEX_TSX, }); }); }); -describe("app update", () => { - test("patches only the provided fields", async () => { +describe('app update', () => { + test('patches only the provided fields', async () => { const file = writeAppFile(); workspace.writeMocks([ { - urlPattern: "/data_apps/APP-1", - method: "PATCH", + urlPattern: '/data_apps/APP-1', + method: 'PATCH', body: JSON.stringify({ - data_app: { slug: "APP-1", url: "https://app.example.com/apps/APP-1", title: "Stories" }, + data_app: { + slug: 'APP-1', + url: 'https://app.example.com/apps/APP-1', + title: 'Stories', + }, }), }, ]); - const result = await runCommandWithTestRuntime(["app", "update", "APP-1", "--file", file], { - debug: false, - json: true, - agent: false, - }); + const result = await runCommandWithTestRuntime( + ['app', 'update', 'APP-1', '--file', file], + { + debug: false, + json: true, + agent: false, + } + ); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout[0]!)).toEqual({ - slug: "APP-1", - url: "https://app.example.com/apps/APP-1", + slug: 'APP-1', + url: 'https://app.example.com/apps/APP-1', + }); + expect(JSON.parse(workspace.readPayloads()[0]!)).toEqual({ + index_tsx: INDEX_TSX, }); - expect(JSON.parse(workspace.readPayloads()[0]!)).toEqual({ index_tsx: INDEX_TSX }); }); - test("requires at least one field to change", async () => { - const error = await runCommandWithTestRuntime(["app", "update", "APP-1"]).catch( - (caught: unknown) => caught, - ); + test('requires at least one field to change', async () => { + const error = await runCommandWithTestRuntime([ + 'app', + 'update', + 'APP-1', + ]).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe( - "Provide at least one of --title, --description, or --file.", + 'Provide at least one of --title, --description, or --file.' ); }); }); diff --git a/cli/src/commands/app/update.ts b/cli/src/commands/app/update.ts index 313c6539..2b78d5dc 100644 --- a/cli/src/commands/app/update.ts +++ b/cli/src/commands/app/update.ts @@ -1,72 +1,84 @@ -import { optionalStringArg, stringArg } from "@/lib/args.ts"; -import { requireManagementPlane } from "@/lib/auth.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { CliError } from "@/lib/errors.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; +import { optionalStringArg, stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { CliError } from '@/lib/errors.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, readRequiredFile, type RemoteDataAppPayload, type PublishedDataApp, -} from "@/commands/app/lib/remote.ts"; +} from '@/commands/app/lib/remote.ts'; export const appUpdateCommand = defineCommand({ metadata: { - name: "update", - description: "Update a data app from source. Omitted fields are left unchanged.", + name: 'update', + description: + 'Update a data app from source. Omitted fields are left unchanged.', examples: [ - "altertable app update APP-1 --file index.tsx", + 'altertable app update APP-1 --file index.tsx', "altertable app update APP-1 --title 'Revenue explorer' --file index.tsx", ], }, args: { slug: { - type: "positional", - description: "Data app slug to update (e.g. APP-1).", + type: 'positional', + description: 'Data app slug to update (e.g. APP-1).', required: true, }, - title: { type: "string", description: "New title of the data app." }, + title: { type: 'string', description: 'New title of the data app.' }, file: { - type: "string", + type: 'string', description: - "Path to replacement single-file React source (index.tsx). Replaces the queries with those the file declares.", - completion: "file", + 'Path to replacement single-file React source (index.tsx). Replaces the queries with those the file declares.', + completion: 'file', + }, + description: { + type: 'string', + description: 'New description of the data app.', }, - description: { type: "string", description: "New description of the data app." }, }, async run({ args, execution, sink }) { const env = requireManagementPlane(execution.profile, { - requirement: "Updating a data app requires the management API", + requirement: 'Updating a data app requires the management API', }); - const slug = stringArg(args, "slug"); + const slug = stringArg(args, 'slug'); const body = await updatePayload(args); if (Object.keys(body).length === 0) { - throw new CliError("Provide at least one of --title, --description, or --file."); + throw new CliError( + 'Provide at least one of --title, --description, or --file.' + ); } const response = parseApiJson( - await sendHttp(buildDataAppHttpRequest(env, "PATCH", `/data_apps/${slug}`, body), execution), + await sendHttp( + buildDataAppHttpRequest(env, 'PATCH', `/data_apps/${slug}`, body), + execution + ) ) as { data_app?: PublishedDataApp }; const dataApp = response.data_app ?? {}; await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: { slug: dataApp.slug ?? slug, url: dataApp.url }, - humanText: `Updated ${dataApp.title ?? dataApp.slug ?? slug}.\n${dataApp.url ?? ""}`, + humanText: `Updated ${dataApp.title ?? dataApp.slug ?? slug}.\n${dataApp.url ?? ''}`, }, - sink, + sink ); }, }); -async function updatePayload(args: Record): Promise { +async function updatePayload( + args: Record +): Promise { const body: RemoteDataAppPayload = {}; - const title = optionalStringArg(args, "title"); - const description = optionalStringArg(args, "description"); - const file = optionalStringArg(args, "file"); + const title = optionalStringArg(args, 'title'); + const description = optionalStringArg(args, 'description'); + const file = optionalStringArg(args, 'file'); if (title !== undefined) body.title = title; if (description !== undefined) body.description = description; - if (file !== undefined) body.index_tsx = await readRequiredFile(file, "source"); + if (file !== undefined) + body.index_tsx = await readRequiredFile(file, 'source'); return body; } diff --git a/cli/src/commands/app/upgrade.test.ts b/cli/src/commands/app/upgrade.test.ts index 34559c5c..eb4c4dba 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -1,30 +1,35 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { createAppFiles, readDataAppPayload } from "@/commands/app/lib/distribution.ts"; +import { afterEach, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + createAppFiles, + readDataAppPayload, +} from '@/commands/app/lib/distribution.ts'; import { dataAppPackage, dataAppPackages, readAppLock, recommendedDataAppVersion, -} from "@/commands/app/lib/package.ts"; -import { upgradeApp } from "@/commands/app/upgrade.ts"; +} from '@/commands/app/lib/package.ts'; +import { upgradeApp } from '@/commands/app/upgrade.ts'; const directories: string[] = []; afterEach(async () => { await Promise.all( - directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + directories + .splice(0) + .map(path => rm(path, { recursive: true, force: true })) ); }); async function fixture() { - const directory = await mkdtemp(join(tmpdir(), "package-upgrade-")); + const directory = await mkdtemp(join(tmpdir(), 'package-upgrade-')); directories.push(directory); const files = createAppFiles(await readDataAppPayload(), { - name: "upgrade-test", - title: "Test", - scope: { organization: "Test", environment: "test" }, + name: 'upgrade-test', + title: 'Test', + scope: { organization: 'Test', environment: 'test' }, }); for (const [name, content] of Object.entries(files)) { await mkdir(dirname(join(directory, name)), { recursive: true }); @@ -35,51 +40,62 @@ async function fixture() { async function snapshot(directory: string) { return Promise.all( - ["package.json", "bun.lock", "src/App.tsx"].map((name) => - readFile(join(directory, name), "utf8"), - ), + ['package.json', 'bun.lock', 'src/App.tsx'].map(name => + readFile(join(directory, name), 'utf8') + ) ); } -async function expectUpgradeFailure(operation: () => Promise, message: string) { +async function expectUpgradeFailure( + operation: () => Promise, + message: string +) { const failure = await operation().catch((error: unknown) => error); expect(failure).toMatchObject({ message: expect.stringContaining(message) }); } async function updateManifest( directory: string, - edit: (pkg: { dependencies: Record; scripts: Record }) => void, + edit: (pkg: { + dependencies: Record; + scripts: Record; + }) => void ) { - const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + const pkg = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8') + ); edit(pkg); - await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); - const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + await writeFile(join(directory, 'package.json'), JSON.stringify(pkg)); + const { packages } = readAppLock( + await readFile(join(directory, 'bun.lock'), 'utf8'), + directory + ); await writeFile( - join(directory, "bun.lock"), + join(directory, 'bun.lock'), JSON.stringify({ lockfileVersion: 2, configVersion: 1, - workspaces: { "": { dependencies: pkg.dependencies } }, + workspaces: { '': { dependencies: pkg.dependencies } }, packages, - }), + }) ); } async function resolveLock(directory: string) { const payload = await readDataAppPayload(); - await writeFile(join(directory, "bun.lock"), payload.starter["bun.lock"]!); + await writeFile(join(directory, 'bun.lock'), payload.starter['bun.lock']!); } -test("an exact current pin is a no-op", async () => { +test('an exact current pin is a no-op', async () => { const directory = await fixture(); const before = await snapshot(directory); expect(await upgradeApp(directory)).toBe(false); expect(await snapshot(directory)).toEqual(before); }); -test("failed resolution restores the manifest and lockfile", async () => { +test('failed resolution restores the manifest and lockfile', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { + await updateManifest(directory, pkg => { pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; }); const before = await snapshot(directory); @@ -87,191 +103,237 @@ test("failed resolution restores the manifest and lockfile", async () => { () => upgradeApp(directory, { async resolveLock(path) { - await writeFile(join(path, "bun.lock"), "partial lock"); - throw new Error("resolution failed"); + await writeFile(join(path, 'bun.lock'), 'partial lock'); + throw new Error('resolution failed'); }, }), - "resolution failed", + 'resolution failed' ); expect(await snapshot(directory)).toEqual(before); }); -test("a stale resolved lockfile restores the manifest and lockfile", async () => { +test('a stale resolved lockfile restores the manifest and lockfile', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { + await updateManifest(directory, pkg => { pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; }); const before = await snapshot(directory); await expectUpgradeFailure( () => upgradeApp(directory, { resolveLock: async () => {} }), - "matching package.json", + 'matching package.json' ); expect(await snapshot(directory)).toEqual(before); }); -test("incompatible peers fail before mutation", async () => { +test('incompatible peers fail before mutation', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { - pkg.dependencies.react = "^18.0.0"; + await updateManifest(directory, pkg => { + pkg.dependencies.react = '^18.0.0'; }); const before = await snapshot(directory); - await expectUpgradeFailure(() => upgradeApp(directory), "Data app package needs react@"); + await expectUpgradeFailure( + () => upgradeApp(directory), + 'Data app package needs react@' + ); expect(await snapshot(directory)).toEqual(before); }); -test("missing peers use starter defaults while preserving custom scripts", async () => { +test('missing peers use starter defaults while preserving custom scripts', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { - delete pkg.dependencies["react-dom"]; - pkg.scripts.custom = "echo user-owned"; + await updateManifest(directory, pkg => { + delete pkg.dependencies['react-dom']; + pkg.scripts.custom = 'echo user-owned'; }); await upgradeApp(directory, { resolveLock }); - expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ - dependencies: { "react-dom": "19.3.0" }, - scripts: { custom: "echo user-owned" }, + expect( + JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) + ).toMatchObject({ + dependencies: { 'react-dom': '19.3.0' }, + scripts: { custom: 'echo user-owned' }, }); }); -test("registry upgrades pin a compatible range and preserve unrelated dependencies and app files", async () => { +test('registry upgrades pin a compatible range and preserve unrelated dependencies and app files', async () => { const directory = await fixture(); - const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + const pkg = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8') + ); pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; - pkg.dependencies.yaml = "2.9.0"; - pkg.scripts.custom = "echo user-owned"; - await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); - const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + pkg.dependencies.yaml = '2.9.0'; + pkg.scripts.custom = 'echo user-owned'; + await writeFile(join(directory, 'package.json'), JSON.stringify(pkg)); + const { packages } = readAppLock( + await readFile(join(directory, 'bun.lock'), 'utf8'), + directory + ); await writeFile( - join(directory, "bun.lock"), + join(directory, 'bun.lock'), JSON.stringify({ lockfileVersion: 2, configVersion: 1, - workspaces: { "": { dependencies: pkg.dependencies } }, + workspaces: { '': { dependencies: pkg.dependencies } }, packages, - }), + }) ); - const original = await readFile(join(directory, "src/App.tsx"), "utf8"); + const original = await readFile(join(directory, 'src/App.tsx'), 'utf8'); await upgradeApp(directory); - expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).toMatchObject({ - dependencies: { yaml: "2.9.0", [dataAppPackage]: recommendedDataAppVersion() }, - scripts: { custom: "echo user-owned" }, + expect( + JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) + ).toMatchObject({ + dependencies: { + yaml: '2.9.0', + [dataAppPackage]: recommendedDataAppVersion(), + }, + scripts: { custom: 'echo user-owned' }, }); - expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(original); + expect(await readFile(join(directory, 'src/App.tsx'), 'utf8')).toBe(original); }, 60_000); -test("a newer installed package is never downgraded", async () => { +test('a newer installed package is never downgraded', async () => { const directory = await fixture(); - const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - pkg.dependencies[dataAppPackage] = "9.0.0"; - await writeFile(join(directory, "package.json"), JSON.stringify(pkg)); - const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); - packages[dataAppPackage] = [`${dataAppPackage}@9.0.0`, "", {}, "integrity"]; + const pkg = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8') + ); + pkg.dependencies[dataAppPackage] = '9.0.0'; + await writeFile(join(directory, 'package.json'), JSON.stringify(pkg)); + const { packages } = readAppLock( + await readFile(join(directory, 'bun.lock'), 'utf8'), + directory + ); + packages[dataAppPackage] = [`${dataAppPackage}@9.0.0`, '', {}, 'integrity']; await writeFile( - join(directory, "bun.lock"), + join(directory, 'bun.lock'), JSON.stringify({ lockfileVersion: 2, configVersion: 1, - workspaces: { "": { dependencies: pkg.dependencies } }, + workspaces: { '': { dependencies: pkg.dependencies } }, packages, - }), + }) ); const before = await snapshot(directory); expect(await upgradeApp(directory)).toBe(false); expect(await snapshot(directory)).toEqual(before); }); -test("upgrade pins every declared split package and preserves dependency placement", async () => { +test('upgrade pins every declared split package and preserves dependency placement', async () => { const directory = await fixture(); - const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - const { packages } = readAppLock(await readFile(join(directory, "bun.lock"), "utf8"), directory); + const manifest = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8') + ); + const { packages } = readAppLock( + await readFile(join(directory, 'bun.lock'), 'utf8'), + directory + ); for (const name of dataAppPackages) { - manifest.dependencies[name] = "^0.70.0"; - packages[name] = [`${name}@0.70.0`, "", {}, "integrity"]; + manifest.dependencies[name] = '^0.70.0'; + packages[name] = [`${name}@0.70.0`, '', {}, 'integrity']; } - delete manifest.dependencies["@altertable/data-app-bun"]; - manifest.devDependencies["@altertable/data-app-bun"] = "^0.70.0"; - await writeFile(join(directory, "package.json"), JSON.stringify(manifest)); + delete manifest.dependencies['@altertable/data-app-bun']; + manifest.devDependencies['@altertable/data-app-bun'] = '^0.70.0'; + await writeFile(join(directory, 'package.json'), JSON.stringify(manifest)); await writeFile( - join(directory, "bun.lock"), - JSON.stringify({ workspaces: { "": manifest }, packages }), + join(directory, 'bun.lock'), + JSON.stringify({ workspaces: { '': manifest }, packages }) ); - const appSource = await readFile(join(directory, "src/App.tsx"), "utf8"); + const appSource = await readFile(join(directory, 'src/App.tsx'), 'utf8'); expect( await upgradeApp(directory, { async resolveLock(path) { - const next = JSON.parse(await readFile(join(path, "package.json"), "utf8")); + const next = JSON.parse( + await readFile(join(path, 'package.json'), 'utf8') + ); for (const name of dataAppPackages) - packages[name] = [`${name}@${recommendedDataAppVersion()}`, "", {}, "integrity"]; + packages[name] = [ + `${name}@${recommendedDataAppVersion()}`, + '', + {}, + 'integrity', + ]; await writeFile( - join(path, "bun.lock"), - JSON.stringify({ workspaces: { "": next }, packages }), + join(path, 'bun.lock'), + JSON.stringify({ workspaces: { '': next }, packages }) ); }, - }), + }) ).toBe(true); - const next = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + const next = JSON.parse( + await readFile(join(directory, 'package.json'), 'utf8') + ); for (const name of dataAppPackages) - expect(next.dependencies[name] ?? next.devDependencies[name]).toBe(recommendedDataAppVersion()); - expect(next.dependencies["@altertable/data-app-bun"]).toBeUndefined(); - expect(next.devDependencies["@altertable/data-app-bun"]).toBe(recommendedDataAppVersion()); - expect(await readFile(join(directory, "src/App.tsx"), "utf8")).toBe(appSource); + expect(next.dependencies[name] ?? next.devDependencies[name]).toBe( + recommendedDataAppVersion() + ); + expect(next.dependencies['@altertable/data-app-bun']).toBeUndefined(); + expect(next.devDependencies['@altertable/data-app-bun']).toBe( + recommendedDataAppVersion() + ); + expect(await readFile(join(directory, 'src/App.tsx'), 'utf8')).toBe( + appSource + ); }); -test("failed split-package resolution rolls back even when core resolves correctly", async () => { +test('failed split-package resolution rolls back even when core resolves correctly', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { - pkg.dependencies["@altertable/data-app-react"] = `^${recommendedDataAppVersion()}`; + await updateManifest(directory, pkg => { + pkg.dependencies['@altertable/data-app-react'] = + `^${recommendedDataAppVersion()}`; }); const before = await snapshot(directory); await expectUpgradeFailure( () => upgradeApp(directory, { async resolveLock(path) { - const { packages } = readAppLock(await readFile(join(path, "bun.lock"), "utf8"), path); - packages["@altertable/data-app-react"] = [ - "@altertable/data-app-react@0.70.0", - "", + const { packages } = readAppLock( + await readFile(join(path, 'bun.lock'), 'utf8'), + path + ); + packages['@altertable/data-app-react'] = [ + '@altertable/data-app-react@0.70.0', + '', {}, - "integrity", + 'integrity', ]; - const next = JSON.parse(await readFile(join(path, "package.json"), "utf8")); + const next = JSON.parse( + await readFile(join(path, 'package.json'), 'utf8') + ); await writeFile( - join(path, "bun.lock"), - JSON.stringify({ workspaces: { "": next }, packages }), + join(path, 'bun.lock'), + JSON.stringify({ workspaces: { '': next }, packages }) ); }, }), - "tested @altertable/data-app-react version", + 'tested @altertable/data-app-react version' ); expect(await snapshot(directory)).toEqual(before); }); -test("a newer optional split package prevents a partial upgrade", async () => { +test('a newer optional split package prevents a partial upgrade', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { - pkg.dependencies["@altertable/data-app-worker"] = "9.0.0"; + await updateManifest(directory, pkg => { + pkg.dependencies['@altertable/data-app-worker'] = '9.0.0'; }); - const lock = JSON.parse(await readFile(join(directory, "bun.lock"), "utf8")); - lock.packages["@altertable/data-app-worker"] = [ - "@altertable/data-app-worker@9.0.0", - "", + const lock = JSON.parse(await readFile(join(directory, 'bun.lock'), 'utf8')); + lock.packages['@altertable/data-app-worker'] = [ + '@altertable/data-app-worker@9.0.0', + '', {}, - "integrity", + 'integrity', ]; - await writeFile(join(directory, "bun.lock"), JSON.stringify(lock)); + await writeFile(join(directory, 'bun.lock'), JSON.stringify(lock)); const before = await snapshot(directory); expect(await upgradeApp(directory)).toBe(false); expect(await snapshot(directory)).toEqual(before); }); -test("portable-only upgrades do not add React or local-server packages", async () => { +test('portable-only upgrades do not add React or local-server packages', async () => { const directory = await fixture(); - await updateManifest(directory, (pkg) => { + await updateManifest(directory, pkg => { for (const name of [ - "@altertable/data-app-react", - "@altertable/data-app-bun", - "react", - "react-dom", + '@altertable/data-app-react', + '@altertable/data-app-bun', + 'react', + 'react-dom', ]) delete pkg.dependencies[name]; pkg.dependencies[dataAppPackage] = `^${recommendedDataAppVersion()}`; @@ -279,16 +341,24 @@ test("portable-only upgrades do not add React or local-server packages", async ( expect( await upgradeApp(directory, { async resolveLock(path) { - const next = JSON.parse(await readFile(join(path, "package.json"), "utf8")); - const { packages } = readAppLock(await readFile(join(path, "bun.lock"), "utf8"), path); + const next = JSON.parse( + await readFile(join(path, 'package.json'), 'utf8') + ); + const { packages } = readAppLock( + await readFile(join(path, 'bun.lock'), 'utf8'), + path + ); await writeFile( - join(path, "bun.lock"), - JSON.stringify({ workspaces: { "": next }, packages }), + join(path, 'bun.lock'), + JSON.stringify({ workspaces: { '': next }, packages }) ); }, - }), + }) ).toBe(true); - expect(JSON.parse(await readFile(join(directory, "package.json"), "utf8")).dependencies).toEqual({ + expect( + JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) + .dependencies + ).toEqual({ [dataAppPackage]: recommendedDataAppVersion(), }); }); diff --git a/cli/src/commands/app/upgrade.ts b/cli/src/commands/app/upgrade.ts index 81d8ce05..737cd617 100644 --- a/cli/src/commands/app/upgrade.ts +++ b/cli/src/commands/app/upgrade.ts @@ -1,10 +1,10 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { dataAppPayload } from "@/commands/app/lib/payload.ts"; -import { appDirectory } from "@/commands/app/lib/run.ts"; -import { copyProcessEnv } from "@/lib/env.ts"; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { defineCommand } from '@/lib/command.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; +import { appDirectory } from '@/commands/app/lib/run.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; import { dataAppPackage, dataAppPackages, @@ -13,21 +13,30 @@ import { readAppPackage, recommendedDataAppPeers, recommendedDataAppVersion, -} from "@/commands/app/lib/package.ts"; +} from '@/commands/app/lib/package.ts'; export const appUpgradeCommand = defineCommand({ metadata: { - name: "upgrade", - description: "Update a local data app's package to the CLI's tested version.", - examples: ["altertable app upgrade", "altertable app upgrade --dir ./my-app"], + name: 'upgrade', + description: + "Update a local data app's package to the CLI's tested version.", + examples: [ + 'altertable app upgrade', + 'altertable app upgrade --dir ./my-app', + ], + }, + args: { + dir: { + type: 'string', + description: 'App directory (default: current directory).', + }, }, - args: { dir: { type: "string", description: "App directory (default: current directory)." } }, async run({ args, sink }) { const directory = appDirectory(args.dir); const upgraded = await upgradeApp(directory); const { packages } = readAppLock( - await readFile(join(directory, "bun.lock"), "utf8"), - directory, + await readFile(join(directory, 'bun.lock'), 'utf8'), + directory ); const version = lockedVersion(packages, dataAppPackage)!; if (sink.json) { @@ -37,8 +46,8 @@ export const appUpgradeCommand = defineCommand({ runtimeVersion: version, nextSteps: upgraded ? [ - "Run `altertable app lint` in the app directory.", - "Restart any running `altertable app dev` server.", + 'Run `altertable app lint` in the app directory.', + 'Restart any running `altertable app dev` server.', ] : [], }); @@ -46,7 +55,7 @@ export const appUpgradeCommand = defineCommand({ sink.writeHuman( upgraded ? `Updated data app package to ${version}. Run \`altertable app lint\`, then restart any running \`altertable app dev\` server.` - : `Data app package ${version} is already current.`, + : `Data app package ${version} is already current.` ); } }, @@ -59,46 +68,54 @@ type UpgradeOptions = { /** Update only the package manifest and lockfile; restore both if resolution fails. */ export async function upgradeApp( directory: string, - options: UpgradeOptions = {}, + options: UpgradeOptions = {} ): Promise { const version = recommendedDataAppVersion(); const { source: originalPackage, manifest } = await readAppPackage(directory); const reference = manifest.dependencies?.[dataAppPackage]; if (!reference || !/^[\d~^<>=*]/.test(reference)) { throw new ConfigurationError( - `package.json must reference the published ${dataAppPackage} package.`, + `package.json must reference the published ${dataAppPackage} package.` ); } let lock: string; try { - lock = await readFile(join(directory, "bun.lock"), "utf8"); + lock = await readFile(join(directory, 'bun.lock'), 'utf8'); } catch { - throw new ConfigurationError(`Cannot read a valid bun.lock in ${directory}.`); + throw new ConfigurationError( + `Cannot read a valid bun.lock in ${directory}.` + ); } const { packages } = readAppLock(lock, directory, reference); - const dependencies = { ...manifest.devDependencies, ...manifest.dependencies }; - const names = dataAppPackages.filter((name) => dependencies[name] !== undefined); + const dependencies = { + ...manifest.devDependencies, + ...manifest.dependencies, + }; + const names = dataAppPackages.filter( + name => dependencies[name] !== undefined + ); for (const name of names) { const declared = dependencies[name]!; if (!/^[\d~^<>=*]/.test(declared)) - throw new ConfigurationError(`package.json must reference the published ${name} package.`); + throw new ConfigurationError( + `package.json must reference the published ${name} package.` + ); const locked = readAppLock(lock, directory, declared, name); const resolved = lockedVersion(locked.packages, name); if (!resolved || !Bun.semver.satisfies(resolved, declared)) throw new ConfigurationError( - `bun.lock does not resolve the declared ${name} package. Run bun install before upgrading.`, + `bun.lock does not resolve the declared ${name} package. Run bun install before upgrading.` ); // Never partially upgrade or downgrade a newer split-package installation. if (Bun.semver.order(resolved, version) > 0) return false; } - const defaults = JSON.parse(dataAppPayload.starter["package.json"]!).dependencies as Record< - string, - string - >; - let packageChanged = names.some((name) => dependencies[name] !== version); + const defaults = JSON.parse(dataAppPayload.starter['package.json']!) + .dependencies as Record; + let packageChanged = names.some(name => dependencies[name] !== version); const peers = recommendedDataAppPeers(names); for (const [name, range] of Object.entries(peers)) { - const existing = manifest.dependencies?.[name] ?? manifest.devDependencies?.[name]; + const existing = + manifest.dependencies?.[name] ?? manifest.devDependencies?.[name]; const resolved = lockedVersion(packages, name); if ( existing && @@ -109,7 +126,7 @@ export async function upgradeApp( continue; if (existing) throw new ConfigurationError( - `Data app package needs ${name}@${range}; app declares ${existing}. Update package.json and bun.lock before upgrading.`, + `Data app package needs ${name}@${range}; app declares ${existing}. Update package.json and bun.lock before upgrading.` ); const fallback = defaults[name]; if (!fallback || !Bun.semver.satisfies(fallback, range)) @@ -119,40 +136,42 @@ export async function upgradeApp( } if (!packageChanged) return false; for (const name of names) { - if (manifest.dependencies?.[name] !== undefined) manifest.dependencies[name] = version; - if (manifest.devDependencies?.[name] !== undefined) manifest.devDependencies[name] = version; + if (manifest.dependencies?.[name] !== undefined) + manifest.dependencies[name] = version; + if (manifest.devDependencies?.[name] !== undefined) + manifest.devDependencies[name] = version; } const nextPackage = `${JSON.stringify(manifest, null, 2)}\n`; try { - await writeFile(join(directory, "package.json"), nextPackage); + await writeFile(join(directory, 'package.json'), nextPackage); await (options.resolveLock ?? resolveLockfile)(directory); - const resolvedLock = await readFile(join(directory, "bun.lock"), "utf8"); + const resolvedLock = await readFile(join(directory, 'bun.lock'), 'utf8'); const resolved = readAppLock(resolvedLock, directory, version); for (const name of names) { readAppLock(resolvedLock, directory, version, name); if (lockedVersion(resolved.packages, name) !== version) throw new ConfigurationError( - `Dependency resolution did not lock the tested ${name} version.`, + `Dependency resolution did not lock the tested ${name} version.` ); } for (const [name, range] of Object.entries(peers)) { const peerVersion = lockedVersion(resolved.packages, name); if (!peerVersion || !Bun.semver.satisfies(peerVersion, range)) throw new ConfigurationError( - `Resolved ${name} is incompatible with data app's ${range} peer requirement.`, + `Resolved ${name} is incompatible with data app's ${range} peer requirement.` ); } // Bun may rewrite the root manifest; preserve all unrelated app-owned fields. - await writeFile(join(directory, "package.json"), nextPackage); + await writeFile(join(directory, 'package.json'), nextPackage); } catch (error) { const restored = await Promise.allSettled([ - writeFile(join(directory, "package.json"), originalPackage), - writeFile(join(directory, "bun.lock"), lock), + writeFile(join(directory, 'package.json'), originalPackage), + writeFile(join(directory, 'bun.lock'), lock), ]); - if (restored.some((result) => result.status === "rejected")) + if (restored.some(result => result.status === 'rejected')) throw new ConfigurationError( - "Upgrade failed and could not restore package.json and bun.lock.", - { cause: error }, + 'Upgrade failed and could not restore package.json and bun.lock.', + { cause: error } ); throw error; } @@ -161,20 +180,28 @@ export async function upgradeApp( async function resolveLockfile(directory: string): Promise { const env = copyProcessEnv(); - for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; + for (const key of Object.keys(env)) + if (key.startsWith('ALTERTABLE_')) delete env[key]; const { manifest } = await readAppPackage(directory); const names = dataAppPackages.filter( - (name) => - manifest.dependencies?.[name] !== undefined || manifest.devDependencies?.[name] !== undefined, + name => + manifest.dependencies?.[name] !== undefined || + manifest.devDependencies?.[name] !== undefined ); const child = Bun.spawn( - [process.execPath, "update", ...names, "--lockfile-only", "--ignore-scripts"], + [ + process.execPath, + 'update', + ...names, + '--lockfile-only', + '--ignore-scripts', + ], { cwd: directory, - env: { ...env, BUN_BE_BUN: "1" }, - stdout: "pipe", - stderr: "pipe", - }, + env: { ...env, BUN_BE_BUN: '1' }, + stdout: 'pipe', + stderr: 'pipe', + } ); const [stdout, stderr, code] = await Promise.all([ new Response(child.stdout).text(), @@ -183,6 +210,6 @@ async function resolveLockfile(directory: string): Promise { ]); if (code !== 0) throw new ConfigurationError( - `Could not resolve data app dependencies; upgrade rolled back.\n${stderr || stdout}`, + `Could not resolve data app dependencies; upgrade rolled back.\n${stderr || stdout}` ); } diff --git a/cli/src/commands/app/validate.ts b/cli/src/commands/app/validate.ts index 2db35ecc..c7efccf2 100644 --- a/cli/src/commands/app/validate.ts +++ b/cli/src/commands/app/validate.ts @@ -1,52 +1,52 @@ -import { stringArg } from "@/lib/args.ts"; -import { requireManagementPlane } from "@/lib/auth.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; +import { stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, formatValidationSummary, readRequiredFile, validationExitCode, type ValidateDataAppResponse, -} from "@/commands/app/lib/remote.ts"; +} from '@/commands/app/lib/remote.ts'; export const appValidateCommand = defineCommand({ metadata: { - name: "validate", - description: "Type-check and bundle a data app from source without saving.", - examples: ["altertable app validate --file index.tsx"], + name: 'validate', + description: 'Type-check and bundle a data app from source without saving.', + examples: ['altertable app validate --file index.tsx'], }, args: { file: { - type: "string", + type: 'string', description: - "Path to the single-file React source (index.tsx). Queries are declared in the file via defineDataApp.", + 'Path to the single-file React source (index.tsx). Queries are declared in the file via defineDataApp.', required: true, - completion: "file", + completion: 'file', }, }, async run({ args, execution, sink }) { const env = requireManagementPlane(execution.profile, { - requirement: "Validating a data app requires the management API", + requirement: 'Validating a data app requires the management API', }); - const indexTsx = await readRequiredFile(stringArg(args, "file"), "source"); + const indexTsx = await readRequiredFile(stringArg(args, 'file'), 'source'); const response = parseApiJson( await sendHttp( - buildDataAppHttpRequest(env, "POST", "/data_apps/validate", { + buildDataAppHttpRequest(env, 'POST', '/data_apps/validate', { index_tsx: indexTsx, }), - execution, - ), + execution + ) ) as ValidateDataAppResponse; await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: response, humanText: formatValidationSummary(response), }, - sink, + sink ); return { exitCode: validationExitCode(response) }; }, diff --git a/cli/src/commands/append/index.test.ts b/cli/src/commands/append/index.test.ts index 251c8423..c6130b01 100644 --- a/cli/src/commands/append/index.test.ts +++ b/cli/src/commands/append/index.test.ts @@ -1,41 +1,53 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; beforeEach(() => { - workspace = createLakehouseTestWorkspace("append-command"); + workspace = createLakehouseTestWorkspace('append-command'); }); afterEach(() => workspace.cleanup()); -describe("append command", () => { - test("sends JSON rows and the synchronous execution option", async () => { - workspace.writeMocks([{ urlPattern: "/append", method: "POST", body: '{"ok":true}' }]); +describe('append command', () => { + test('sends JSON rows and the synchronous execution option', async () => { + workspace.writeMocks([ + { urlPattern: '/append', method: 'POST', body: '{"ok":true}' }, + ]); - await runCommandWithTestRuntime(["append", '{"id":1}', "--to", "memory.main.users", "--sync"]); + await runCommandWithTestRuntime([ + 'append', + '{"id":1}', + '--to', + 'memory.main.users', + '--sync', + ]); - expect(workspace.readHttpLog()).toContain("URL=https://example.com/append?"); - expect(workspace.readHttpLog()).toContain("sync=true"); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ id: 1 }); + expect(workspace.readHttpLog()).toContain( + 'URL=https://example.com/append?' + ); + expect(workspace.readHttpLog()).toContain('sync=true'); + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ id: 1 }); }); - test("fetches append task status without run arguments", async () => { - const appendId = "11111111-2222-3333-4444-555555555555"; + test('fetches append task status without run arguments', async () => { + const appendId = '11111111-2222-3333-4444-555555555555'; workspace.writeMocks([ { urlPattern: `/tasks/${appendId}`, - method: "GET", + method: 'GET', body: `{"task_id":"${appendId}","status":"completed"}`, }, ]); - await runCommandWithTestRuntime(["append", "status", appendId]); + await runCommandWithTestRuntime(['append', 'status', appendId]); - expect(workspace.readHttpLog()).toContain(`URL=https://example.com/tasks/${appendId}`); + expect(workspace.readHttpLog()).toContain( + `URL=https://example.com/tasks/${appendId}` + ); }); }); diff --git a/cli/src/commands/append/index.ts b/cli/src/commands/append/index.ts index b1fae164..9a78c24c 100644 --- a/cli/src/commands/append/index.ts +++ b/cli/src/commands/append/index.ts @@ -1,22 +1,22 @@ -import { booleanArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { buildLakehouseAppendRequest } from "@/lib/lakehouse-transport.ts"; -import { parseLakehouseTarget } from "@/lib/lakehouse/args.ts"; -import { startProgress } from "@/lib/progress.ts"; -import { appendStatusCommand } from "@/commands/append/status.ts"; -import { appendGroupArgs } from "@/commands/append/lib/args.ts"; -import { parseAppendJsonContent } from "@/commands/append/lib/data.ts"; +import { booleanArg, stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { buildLakehouseAppendRequest } from '@/lib/lakehouse-transport.ts'; +import { parseLakehouseTarget } from '@/lib/lakehouse/args.ts'; +import { startProgress } from '@/lib/progress.ts'; +import { appendStatusCommand } from '@/commands/append/status.ts'; +import { appendGroupArgs } from '@/commands/append/lib/args.ts'; +import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; export const appendCommand = defineCommand({ metadata: { - name: "append", - commandGroup: "ingest", - description: "Append JSON rows to a table.", + name: 'append', + commandGroup: 'ingest', + description: 'Append JSON rows to a table.', examples: [ 'altertable append \'{"event":"checkout_completed","user_id":"usr_123","revenue":99}\' --to analytics.main.events', - "altertable append status ", + 'altertable append status ', ], }, args: appendGroupArgs, @@ -24,18 +24,20 @@ export const appendCommand = defineCommand({ status: appendStatusCommand, }, async run({ args, execution, sink }) { - const sync = booleanArg(args, "sync"); - const target = parseLakehouseTarget(stringArg(args, "to")); + const sync = booleanArg(args, 'sync'); + const target = parseLakehouseTarget(stringArg(args, 'to')); const request = buildLakehouseAppendRequest({ ...target, - jsonContent: parseAppendJsonContent(stringArg(args, "data")), + jsonContent: parseAppendJsonContent(stringArg(args, 'data')), options: { sync }, }); - const progress = sync ? startProgress("Waiting for append to complete…") : undefined; + const progress = sync + ? startProgress('Waiting for append to complete…') + : undefined; try { const response = await sendHttp(request, execution); progress?.done(); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); } catch (error) { progress?.fail(); throw error; diff --git a/cli/src/commands/append/lib/args.ts b/cli/src/commands/append/lib/args.ts index d42c8963..9f0c65e5 100644 --- a/cli/src/commands/append/lib/args.ts +++ b/cli/src/commands/append/lib/args.ts @@ -1,19 +1,19 @@ -import { defineArguments } from "@/lib/command.ts"; +import { defineArguments } from '@/lib/command.ts'; export const appendRunArgs = defineArguments({ data: { - type: "positional", - description: "JSON object, array, or @file", + type: 'positional', + description: 'JSON object, array, or @file', required: true, }, to: { - type: "string", - description: "Destination as catalog.schema.table", + type: 'string', + description: 'Destination as catalog.schema.table', required: true, }, sync: { - type: "boolean", - description: "Wait for the append operation to finish before returning", + type: 'boolean', + description: 'Wait for the append operation to finish before returning', }, }); diff --git a/cli/src/commands/append/lib/data.test.ts b/cli/src/commands/append/lib/data.test.ts index af272da3..b7e66013 100644 --- a/cli/src/commands/append/lib/data.test.ts +++ b/cli/src/commands/append/lib/data.test.ts @@ -1,20 +1,20 @@ -import { describe, expect, test } from "bun:test"; -import { parseAppendJsonContent } from "@/commands/append/lib/data.ts"; -import { CliError } from "@/lib/errors.ts"; +import { describe, expect, test } from 'bun:test'; +import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; +import { CliError } from '@/lib/errors.ts'; -describe("parseAppendJsonContent", () => { - test("preserves inline JSON without rounding large integers", () => { +describe('parseAppendJsonContent', () => { + test('preserves inline JSON without rounding large integers', () => { const input = '{ "id": 9007199254740993 }'; expect(parseAppendJsonContent(input)).toBe(input); }); - test("rejects non-JSON data", () => { - expect(() => parseAppendJsonContent("not-json")).toThrow(CliError); + test('rejects non-JSON data', () => { + expect(() => parseAppendJsonContent('not-json')).toThrow(CliError); }); - test("reports a missing input file", () => { - expect(() => parseAppendJsonContent("@/no/such/missing.json")).toThrow( - "File not found: /no/such/missing.json", + test('reports a missing input file', () => { + expect(() => parseAppendJsonContent('@/no/such/missing.json')).toThrow( + 'File not found: /no/such/missing.json' ); }); }); diff --git a/cli/src/commands/append/lib/data.ts b/cli/src/commands/append/lib/data.ts index c281e23a..e31ae7c7 100644 --- a/cli/src/commands/append/lib/data.ts +++ b/cli/src/commands/append/lib/data.ts @@ -1,26 +1,26 @@ -import { readFileSync } from "node:fs"; -import { CliError } from "@/lib/errors.ts"; +import { readFileSync } from 'node:fs'; +import { CliError } from '@/lib/errors.ts'; export function parseAppendJsonContent(dataArg: string): string { let jsonContent = dataArg; - if (jsonContent.startsWith("@")) { + if (jsonContent.startsWith('@')) { const filePath = jsonContent.slice(1); try { - jsonContent = readFileSync(filePath, "utf8"); + jsonContent = readFileSync(filePath, 'utf8'); } catch { throw new CliError(`File not found: ${filePath}`); } } const firstCharacter = jsonContent.trimStart()[0]; - if (firstCharacter !== "{" && firstCharacter !== "[") { - throw new CliError("Data must be a JSON object or array."); + if (firstCharacter !== '{' && firstCharacter !== '[') { + throw new CliError('Data must be a JSON object or array.'); } try { JSON.parse(jsonContent); return jsonContent; } catch { - throw new CliError("Data must be valid JSON."); + throw new CliError('Data must be valid JSON.'); } } diff --git a/cli/src/commands/append/status.ts b/cli/src/commands/append/status.ts index 12f03333..455d64f9 100644 --- a/cli/src/commands/append/status.ts +++ b/cli/src/commands/append/status.ts @@ -1,30 +1,30 @@ -import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; +import { stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; export const appendStatusCommand = defineCommand({ metadata: { - name: "status", - description: "Fetch status for an append operation.", + name: 'status', + description: 'Fetch status for an append operation.', }, args: { - "append-id": { - type: "positional", - description: "Append id returned by append", + 'append-id': { + type: 'positional', + description: 'Append id returned by append', required: true, }, }, async run({ args, execution, sink }) { const response = await sendHttp( { - plane: "lakehouse", - method: "GET", - endpoint: `/tasks/${encodeURIComponent(stringArg(args, "append-id"))}`, + plane: 'lakehouse', + method: 'GET', + endpoint: `/tasks/${encodeURIComponent(stringArg(args, 'append-id'))}`, retry: true, }, - execution, + execution ); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); }, }); diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts index 50fc5a15..bc1fd31e 100644 --- a/cli/src/commands/catalogs/create.ts +++ b/cli/src/commands/catalogs/create.ts @@ -1,31 +1,34 @@ -import { requireManagementPlane } from "@/lib/auth.ts"; -import { buildCatalogCreateRequest } from "@/commands/catalogs/lib/requests.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { buildCatalogCreateRequest } from '@/commands/catalogs/lib/requests.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; export const catalogsCreateCommand = defineCommand({ metadata: { - name: "create", - description: "Create an Altertable catalog.", - examples: ["altertable catalog create Analytics"], + name: 'create', + description: 'Create an Altertable catalog.', + examples: ['altertable catalog create Analytics'], }, args: { name: { - type: "positional", - description: "Catalog name", + type: 'positional', + description: 'Catalog name', required: true, }, }, async run({ args, execution, sink }) { const env = requireManagementPlane(execution.profile, { - requirement: "Creating a catalog requires the management API", + requirement: 'Creating a catalog requires the management API', }); const name = String(args.name); - const response = await sendHttp(buildCatalogCreateRequest(env, name), execution); + const response = await sendHttp( + buildCatalogCreateRequest(env, name), + execution + ); await writeCommandOutput( { - kind: "raw_api", + kind: 'raw_api', body: response, humanFormatter(data) { const parsed = data as { @@ -33,10 +36,10 @@ export const catalogsCreateCommand = defineCommand({ connection?: { slug?: string; name?: string; engine?: string }; }; const catalog = parsed.database ?? parsed.connection; - return `Created catalog "${catalog?.name ?? name}" (slug: ${catalog?.slug ?? ""}, engine: ${catalog?.engine ?? "altertable"}, environment: ${env}).`; + return `Created catalog "${catalog?.name ?? name}" (slug: ${catalog?.slug ?? ''}, engine: ${catalog?.engine ?? 'altertable'}, environment: ${env}).`; }, }, - sink, + sink ); }, }); diff --git a/cli/src/commands/catalogs/index.ts b/cli/src/commands/catalogs/index.ts index 3c0dfdf2..fbf409ce 100644 --- a/cli/src/commands/catalogs/index.ts +++ b/cli/src/commands/catalogs/index.ts @@ -1,19 +1,23 @@ -import { defineCommand } from "@/lib/command.ts"; -import { catalogsCreateCommand } from "@/commands/catalogs/create.ts"; -import { requireManagementPlane } from "@/lib/auth.ts"; -import { fetchManagementCatalogRows } from "@/lib/management/catalogs.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { formatCatalogsSummary, formatCatalogsTable } from "@/lib/management/render.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { catalogsCreateCommand } from '@/commands/catalogs/create.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { + formatCatalogsSummary, + formatCatalogsTable, +} from '@/lib/management/render.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export const catalogCommand = defineCommand({ metadata: { - name: "catalog", - commandGroup: "platform", - invocations: ["direct", "subcommand"], - description: "Manage catalogs (databases and connections) in the current environment.", - examples: ["altertable catalog", "altertable catalog create Analytics"], + name: 'catalog', + commandGroup: 'platform', + invocations: ['direct', 'subcommand'], + description: + 'Manage catalogs (databases and connections) in the current environment.', + examples: ['altertable catalog', 'altertable catalog create Analytics'], }, subcommands: { create: catalogsCreateCommand, @@ -21,20 +25,22 @@ export const catalogCommand = defineCommand({ async run({ execution, sink }) { const rows = await fetchManagementCatalogRows( requireManagementPlane(execution.profile, { - requirement: "Listing catalogs requires the management API", + requirement: 'Listing catalogs requires the management API', }), - execution, + execution ); const summary = formatCatalogsSummary(rows); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: { catalogs: rows, ...(summary !== null ? { summary } : {}) }, humanText: formatCatalogsTable(rows), metadataLines: - summary !== null ? ["", renderDisplayText([span(summary, "subtle")])] : undefined, + summary !== null + ? ['', renderDisplayText([span(summary, 'subtle')])] + : undefined, }, - sink, + sink ); }, }); @@ -42,9 +48,9 @@ export const catalogCommand = defineCommand({ export const catalogsCommand = defineCommand({ ...catalogCommand, metadata: { - name: "catalogs", + name: 'catalogs', hidden: true, - description: "Alias for altertable catalog.", - invocations: ["direct", "subcommand"], + description: 'Alias for altertable catalog.', + invocations: ['direct', 'subcommand'], }, }); diff --git a/cli/src/commands/catalogs/lib/requests.ts b/cli/src/commands/catalogs/lib/requests.ts index dcd8447a..50a009c4 100644 --- a/cli/src/commands/catalogs/lib/requests.ts +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -1,11 +1,14 @@ -import type { HttpRequest } from "@/lib/http-request.ts"; +import type { HttpRequest } from '@/lib/http-request.ts'; -export function buildCatalogCreateRequest(env: string, name: string): HttpRequest { +export function buildCatalogCreateRequest( + env: string, + name: string +): HttpRequest { return { - plane: "management", - method: "POST", + plane: 'management', + method: 'POST', endpoint: `/environments/${env}/databases`, - body: JSON.stringify({ name, engine: "altertable" }), - contentType: "application/json", + body: JSON.stringify({ name, engine: 'altertable' }), + contentType: 'application/json', }; } diff --git a/cli/src/commands/completion/generate.ts b/cli/src/commands/completion/generate.ts index 792c76f9..30c7cefa 100644 --- a/cli/src/commands/completion/generate.ts +++ b/cli/src/commands/completion/generate.ts @@ -1,32 +1,32 @@ -import { defineCommand, type Command } from "@/lib/command.ts"; -import { stringArg } from "@/lib/args.ts"; +import { defineCommand, type Command } from '@/lib/command.ts'; +import { stringArg } from '@/lib/args.ts'; import { formatCompletionScript, resolveShell, SUPPORTED_SHELLS, type GetRootCommand, -} from "@/commands/completion/lib/completion.ts"; +} from '@/commands/completion/lib/completion.ts'; export function createGenerateCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ metadata: { - name: "generate", - description: "Generate a shell completion script.", + name: 'generate', + description: 'Generate a shell completion script.', examples: [ - "altertable completion generate bash", - "altertable completion generate zsh > ~/.local/share/zsh/site-functions/_altertable", + 'altertable completion generate bash', + 'altertable completion generate zsh > ~/.local/share/zsh/site-functions/_altertable', ], }, args: { shell: { - type: "positional", - description: "Shell to generate completion for", + type: 'positional', + description: 'Shell to generate completion for', required: true, values: SUPPORTED_SHELLS, }, }, async run({ args, sink }) { - const shell = resolveShell(stringArg(args, "shell")); + const shell = resolveShell(stringArg(args, 'shell')); sink.writeRaw(await formatCompletionScript(shell, getRootCommand())); }, }); diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index 8921671b..afbdd3ee 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -1,85 +1,101 @@ -import { describe, expect, test } from "bun:test"; -import { defineCommand, type Command } from "@/lib/command.ts"; -import { executeCommand } from "@/lib/command-parser.ts"; -import { buildMainCommand } from "@/cli.ts"; -import { createCompletionCommand } from "@/commands/completion/index.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { describe, expect, test } from 'bun:test'; +import { defineCommand, type Command } from '@/lib/command.ts'; +import { executeCommand } from '@/lib/command-parser.ts'; +import { buildMainCommand } from '@/cli.ts'; +import { createCompletionCommand } from '@/commands/completion/index.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; const TERMINAL_CONTROL_PATTERN = new RegExp( `${String.fromCharCode(27)}\\[[0-9;]*m|${String.fromCharCode(27)}\\]8;;[^\\u0007]*\\u0007`, - "g", + 'g' ); const minimalRootCommand = defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, args: { - json: { type: "boolean", description: "Output raw JSON responses" }, - debug: { type: "boolean", alias: "d", description: "Enable debug output" }, + json: { type: 'boolean', description: 'Output raw JSON responses' }, + debug: { type: 'boolean', alias: 'd', description: 'Enable debug output' }, }, subcommands: { - query: { metadata: { name: "query" } }, + query: { metadata: { name: 'query' } }, api: { - metadata: { name: "api" }, + metadata: { name: 'api' }, subcommands: { connections: { - metadata: { name: "connections" }, + metadata: { name: 'connections' }, subcommands: { - list: { metadata: { name: "list" } }, + list: { metadata: { name: 'list' } }, }, }, }, }, catalog: { - metadata: { name: "catalog" }, + metadata: { name: 'catalog' }, subcommands: { - list: { metadata: { name: "list" } }, - create: { metadata: { name: "create" } }, + list: { metadata: { name: 'list' } }, + create: { metadata: { name: 'create' } }, }, }, - completion: { metadata: { name: "completion" } }, + completion: { metadata: { name: 'completion' } }, }, }); function visibleTerminalText(text: string): string { - return text.replace(TERMINAL_CONTROL_PATTERN, ""); + return text.replace(TERMINAL_CONTROL_PATTERN, ''); } -async function captureCompletionOutput(run: () => unknown, json = false): Promise { +async function captureCompletionOutput( + run: () => unknown, + json = false +): Promise { const output: string[] = []; const runtime = createCliRuntime({ debug: false, json, agent: false }); - runtime.output.writeHuman = (text) => output.push(text); - runtime.output.writeJson = (data) => output.push(JSON.stringify(data)); - runtime.output.writeRaw = (body) => output.push(body); + runtime.output.writeHuman = text => output.push(text); + runtime.output.writeJson = data => output.push(JSON.stringify(data)); + runtime.output.writeRaw = body => output.push(body); await runWithCliRuntime(runtime, run); - return output.join(""); + return output.join(''); } async function runCompletionCommand( completionCommand: Command, rawArgs: string[], - json = false, + json = false ): Promise { const rootCommand = defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, subcommands: { completion: completionCommand }, }); - return await captureCompletionOutput(() => executeCommand(rootCommand, rawArgs), json); + return await captureCompletionOutput( + () => executeCommand(rootCommand, rawArgs), + json + ); } -async function runCompletion(getRootCommand: () => Command, shell?: string): Promise { +async function runCompletion( + getRootCommand: () => Command, + shell?: string +): Promise { const completionCommand = createCompletionCommand(getRootCommand); - const rawArgs = shell ? ["completion", "generate", shell] : ["completion"]; + const rawArgs = shell ? ['completion', 'generate', shell] : ['completion']; return await runCompletionCommand(completionCommand, rawArgs); } async function runCompletionGenerate(shell: string): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); - return await runCompletionCommand(completionCommand, ["completion", "generate", shell]); + return await runCompletionCommand(completionCommand, [ + 'completion', + 'generate', + shell, + ]); } -async function runCompletionParent(rawArgs: string[], json = false): Promise { +async function runCompletionParent( + rawArgs: string[], + json = false +): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); return await runCompletionCommand(completionCommand, rawArgs, json); } @@ -88,83 +104,85 @@ async function runCompletionParentJson(rawArgs: string[]): Promise { return await runCompletionParent(rawArgs, true); } -describe("completion command", () => { - test("bash output contains query and api commands", async () => { - const output = await runCompletion(() => minimalRootCommand, "bash"); - expect(output).toContain("query"); - expect(output).toContain("api"); - expect(output).toContain("_altertable_completions"); - expect(output).toContain("altertable"); +describe('completion command', () => { + test('bash output contains query and api commands', async () => { + const output = await runCompletion(() => minimalRootCommand, 'bash'); + expect(output).toContain('query'); + expect(output).toContain('api'); + expect(output).toContain('_altertable_completions'); + expect(output).toContain('altertable'); }); - test("bash output contains nested subcommand words", async () => { - const output = await runCompletion(() => minimalRootCommand, "bash"); - expect(output).toContain("connections"); - expect(output).toContain("catalog"); - expect(output).toContain("list"); + test('bash output contains nested subcommand words', async () => { + const output = await runCompletion(() => minimalRootCommand, 'bash'); + expect(output).toContain('connections'); + expect(output).toContain('catalog'); + expect(output).toContain('list'); }); - test("bash output includes leaf command flags from real command tree", async () => { - const output = await runCompletion(buildMainCommand, "bash"); - expect(output).toContain("--raw-field"); - expect(output).toContain("--field"); - expect(output).toContain("--input"); + test('bash output includes leaf command flags from real command tree', async () => { + const output = await runCompletion(buildMainCommand, 'bash'); + expect(output).toContain('--raw-field'); + expect(output).toContain('--field'); + expect(output).toContain('--input'); }); - test("zsh output contains compdef and nested catalog create branch", async () => { - const output = await runCompletion(() => minimalRootCommand, "zsh"); - expect(output).toContain("#compdef altertable"); - expect(output).toContain("altertable"); - expect(output).toContain("catalog"); - expect(output).toContain("create"); + test('zsh output contains compdef and nested catalog create branch', async () => { + const output = await runCompletion(() => minimalRootCommand, 'zsh'); + expect(output).toContain('#compdef altertable'); + expect(output).toContain('altertable'); + expect(output).toContain('catalog'); + expect(output).toContain('create'); }); - test("fish output contains fish complete commands and api nesting", async () => { - const output = await runCompletion(() => minimalRootCommand, "fish"); - expect(output).toContain("altertable fish completion"); - expect(output).toContain("complete -c altertable"); + test('fish output contains fish complete commands and api nesting', async () => { + const output = await runCompletion(() => minimalRootCommand, 'fish'); + expect(output).toContain('altertable fish completion'); + expect(output).toContain('complete -c altertable'); expect(output).toContain("__altertable_using_context 'api' '0'"); }); - test("fish output scopes install flags to completion install", async () => { - const output = await runCompletion(buildMainCommand, "fish"); - expect(output).toContain("__altertable_using_context 'completion/install' 'any'"); - expect(output).toContain("-l no-rc"); + test('fish output scopes install flags to completion install', async () => { + const output = await runCompletion(buildMainCommand, 'fish'); + expect(output).toContain( + "__altertable_using_context 'completion/install' 'any'" + ); + expect(output).toContain('-l no-rc'); }); - test("fish output includes leaf command flags from real command tree", async () => { - const output = await runCompletion(buildMainCommand, "fish"); - expect(output).toContain("-l raw-field"); - expect(output).toContain("-l field"); + test('fish output includes leaf command flags from real command tree', async () => { + const output = await runCompletion(buildMainCommand, 'fish'); + expect(output).toContain('-l raw-field'); + expect(output).toContain('-l field'); }); - test("generate command writes explicit shell output", async () => { - const output = await runCompletionGenerate("fish"); - expect(output).toContain("altertable fish completion"); - expect(output).toContain("altertable completion generate fish"); + test('generate command writes explicit shell output', async () => { + const output = await runCompletionGenerate('fish'); + expect(output).toContain('altertable fish completion'); + expect(output).toContain('altertable completion generate fish'); }); - test("generate validates values from supported shell metadata", async () => { - expect(runCompletionGenerate("powershell")).rejects.toThrow( - "Unsupported shell: powershell. Use bash, fish, zsh.", + test('generate validates values from supported shell metadata', async () => { + expect(runCompletionGenerate('powershell')).rejects.toThrow( + 'Unsupported shell: powershell. Use bash, fish, zsh.' ); }); - test("bare completion shows guidance without dumping a script", async () => { + test('bare completion shows guidance without dumping a script', async () => { const output = await runCompletion(() => minimalRootCommand); const visibleOutput = visibleTerminalText(output); - expect(visibleOutput).toContain("Shell completion"); - expect(visibleOutput).toContain("altertable completion install"); - expect(visibleOutput).toContain("altertable completion generate zsh"); - expect(visibleOutput).not.toContain("altertable fish completion"); - expect(visibleOutput).not.toContain("_altertable_completions"); + expect(visibleOutput).toContain('Shell completion'); + expect(visibleOutput).toContain('altertable completion install'); + expect(visibleOutput).toContain('altertable completion generate zsh'); + expect(visibleOutput).not.toContain('altertable fish completion'); + expect(visibleOutput).not.toContain('_altertable_completions'); }); - test("bare completion emits guidance as json in json mode", async () => { - const output = await runCompletionParentJson(["completion"]); + test('bare completion emits guidance as json in json mode', async () => { + const output = await runCompletionParentJson(['completion']); expect(JSON.parse(output)).toMatchObject({ - install: "altertable completion install", - manual: "altertable completion generate zsh", + install: 'altertable completion install', + manual: 'altertable completion generate zsh', }); }); }); diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts index 3a27c057..1baf2614 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -1,23 +1,25 @@ -import { defineCommand, type Command } from "@/lib/command.ts"; -import { createGenerateCommand } from "@/commands/completion/generate.ts"; -import { createInstallCommand } from "@/commands/completion/install.ts"; +import { defineCommand, type Command } from '@/lib/command.ts'; +import { createGenerateCommand } from '@/commands/completion/generate.ts'; +import { createInstallCommand } from '@/commands/completion/install.ts'; import { COMPLETION_GUIDANCE, formatCompletionHelpMessage, type GetRootCommand, -} from "@/commands/completion/lib/completion.ts"; +} from '@/commands/completion/lib/completion.ts'; -export function createCompletionCommand(getRootCommand: GetRootCommand): Command { +export function createCompletionCommand( + getRootCommand: GetRootCommand +): Command { const command = defineCommand({ metadata: { - name: "completion", - commandGroup: "platform", - invocations: ["direct", "subcommand"], - description: "Generate or install shell completion scripts.", + name: 'completion', + commandGroup: 'platform', + invocations: ['direct', 'subcommand'], + description: 'Generate or install shell completion scripts.', examples: [ - "altertable completion install", - "altertable completion install zsh", - "altertable completion generate bash > ~/.local/share/bash-completion/completions/altertable", + 'altertable completion install', + 'altertable completion install zsh', + 'altertable completion generate bash > ~/.local/share/bash-completion/completions/altertable', ], }, subcommands: { diff --git a/cli/src/commands/completion/install.ts b/cli/src/commands/completion/install.ts index 283f55f3..7e5eefea 100644 --- a/cli/src/commands/completion/install.ts +++ b/cli/src/commands/completion/install.ts @@ -1,5 +1,5 @@ -import { defineCommand, type Command } from "@/lib/command.ts"; -import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand, type Command } from '@/lib/command.ts'; +import { optionalStringArg } from '@/lib/args.ts'; import { formatCompletionScript, formatInstallMessage, @@ -7,36 +7,38 @@ import { resolveShell, SUPPORTED_SHELLS, type GetRootCommand, -} from "@/commands/completion/lib/completion.ts"; +} from '@/commands/completion/lib/completion.ts'; export function createInstallCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ metadata: { - name: "install", - description: "Install shell completion for the current shell.", + name: 'install', + description: 'Install shell completion for the current shell.', examples: [ - "altertable completion install", - "altertable completion install fish", - "altertable completion install zsh --no-rc", + 'altertable completion install', + 'altertable completion install fish', + 'altertable completion install zsh --no-rc', ], }, args: { shell: { - type: "positional", - description: "Shell to install completion for (default: detected shell)", + type: 'positional', + description: + 'Shell to install completion for (default: detected shell)', required: false, values: SUPPORTED_SHELLS, }, - "no-rc": { - type: "boolean", - description: "Write the completion file without updating shell startup files.", + 'no-rc': { + type: 'boolean', + description: + 'Write the completion file without updating shell startup files.', }, }, async run({ args, sink }) { - const shell = resolveShell(optionalStringArg(args, "shell")); + const shell = resolveShell(optionalStringArg(args, 'shell')); const script = await formatCompletionScript(shell, getRootCommand()); const result = await installCompletion(shell, script, { - updateRc: args["no-rc"] !== true, + updateRc: args['no-rc'] !== true, }); if (sink.json) sink.writeJson(result); else sink.writeHuman(formatInstallMessage(result)); diff --git a/cli/src/commands/completion/lib/completion.ts b/cli/src/commands/completion/lib/completion.ts index 1e110177..e020f1aa 100644 --- a/cli/src/commands/completion/lib/completion.ts +++ b/cli/src/commands/completion/lib/completion.ts @@ -1,39 +1,47 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; -import type { Command } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; -import { readEnv } from "@/lib/env.ts"; -import { homeDir, configHome, dataHome } from "@/lib/user-directories.ts"; -import { document, rows, section, span, text, type DisplayText } from "@/ui/document.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import type { Command } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { readEnv } from '@/lib/env.ts'; +import { homeDir, configHome, dataHome } from '@/lib/user-directories.ts'; +import { + document, + rows, + section, + span, + text, + type DisplayText, +} from '@/ui/document.ts'; +import { renderDocumentText } from '@/ui/renderers/terminal.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; import { formatBashCompletion, formatFishCompletion, formatZshCompletion, -} from "@/commands/completion/lib/format.ts"; +} from '@/commands/completion/lib/format.ts'; export type GetRootCommand = () => Command; -export type SupportedShell = "bash" | "zsh" | "fish"; +export type SupportedShell = 'bash' | 'zsh' | 'fish'; export type InstallResult = { shell: SupportedShell; completionPath: string; rcPath?: string; rcUpdated: boolean; - startupAction: "updated" | "skipped" | "not-needed"; + startupAction: 'updated' | 'skipped' | 'not-needed'; }; type InstallTarget = { completionPath: string; rcPath?: string }; type InstallOptions = { updateRc?: boolean }; -export const SUPPORTED_SHELLS = ["bash", "fish", "zsh"] as const; -const START_MARKER = "# >>> altertable completion >>>"; -const END_MARKER = "# <<< altertable completion <<<"; -const COMPLETION_DOCS_URL = "https://github.com/altertable-ai/altertable-cli#shell-completion"; +export const SUPPORTED_SHELLS = ['bash', 'fish', 'zsh'] as const; +const START_MARKER = '# >>> altertable completion >>>'; +const END_MARKER = '# <<< altertable completion <<<'; +const COMPLETION_DOCS_URL = + 'https://github.com/altertable-ai/altertable-cli#shell-completion'; export const COMPLETION_GUIDANCE = { - install: "altertable completion install", - installShell: "altertable completion install zsh", - manual: "altertable completion generate zsh", + install: 'altertable completion install', + installShell: 'altertable completion install zsh', + manual: 'altertable completion generate zsh', docs: COMPLETION_DOCS_URL, }; @@ -42,7 +50,7 @@ export function isSupportedShell(shell: string): shell is SupportedShell { } function formatSupportedShells(): string { - return SUPPORTED_SHELLS.join(", "); + return SUPPORTED_SHELLS.join(', '); } function getShellName(shellPath: string): string { @@ -50,17 +58,19 @@ function getShellName(shellPath: string): string { } export function resolveShell(shell: unknown): SupportedShell { - if (typeof shell === "string" && shell.length > 0) { + if (typeof shell === 'string' && shell.length > 0) { const explicitShell = getShellName(shell); if (isSupportedShell(explicitShell)) return explicitShell; - throw new CliError(`Unsupported shell: ${shell}. Use ${formatSupportedShells()}.`); + throw new CliError( + `Unsupported shell: ${shell}. Use ${formatSupportedShells()}.` + ); } - const envShell = readEnv("SHELL"); - const detectedShell = envShell ? getShellName(envShell) : ""; + const envShell = readEnv('SHELL'); + const detectedShell = envShell ? getShellName(envShell) : ''; if (isSupportedShell(detectedShell)) return detectedShell; throw new CliError( - `Could not detect a supported shell. Pass one of: ${formatSupportedShells()}.`, + `Could not detect a supported shell. Pass one of: ${formatSupportedShells()}.` ); } @@ -69,20 +79,30 @@ function shellQuote(value: string): string { } function installTarget(shell: SupportedShell): InstallTarget { - if (shell === "bash") { + if (shell === 'bash') { return { - completionPath: join(dataHome(), "bash-completion", "completions", "altertable"), - rcPath: join(homeDir(), ".bashrc"), + completionPath: join( + dataHome(), + 'bash-completion', + 'completions', + 'altertable' + ), + rcPath: join(homeDir(), '.bashrc'), }; } - if (shell === "zsh") { + if (shell === 'zsh') { return { - completionPath: join(dataHome(), "zsh", "site-functions", "_altertable"), - rcPath: join(homeDir(), ".zshrc"), + completionPath: join(dataHome(), 'zsh', 'site-functions', '_altertable'), + rcPath: join(homeDir(), '.zshrc'), }; } return { - completionPath: join(configHome(), "fish", "completions", "altertable.fish"), + completionPath: join( + configHome(), + 'fish', + 'completions', + 'altertable.fish' + ), }; } @@ -90,24 +110,32 @@ function managedBlock(body: string): string { return `${START_MARKER}\n${body.trimEnd()}\n${END_MARKER}\n`; } -function rcBlock(shell: SupportedShell, target: InstallTarget): string | undefined { +function rcBlock( + shell: SupportedShell, + target: InstallTarget +): string | undefined { if (!target.rcPath) return undefined; - if (shell === "bash") { + if (shell === 'bash') { return managedBlock( - `if [ -f ${shellQuote(target.completionPath)} ]; then\n . ${shellQuote(target.completionPath)}\nfi`, + `if [ -f ${shellQuote(target.completionPath)} ]; then\n . ${shellQuote(target.completionPath)}\nfi` ); } return managedBlock( - `fpath=(${shellQuote(dirname(target.completionPath))} $fpath)\nautoload -Uz compinit\ncompinit`, + `fpath=(${shellQuote(dirname(target.completionPath))} $fpath)\nautoload -Uz compinit\ncompinit` ); } async function readOptionalFile(path: string): Promise { try { - return await readFile(path, "utf8"); + return await readFile(path, 'utf8'); } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return ""; + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return ''; } throw error; } @@ -119,7 +147,7 @@ function upsertManagedBlock(existing: string, block: string): string { if (start !== -1 && end !== -1 && end > start) { const before = existing.slice(0, start).trimEnd(); const after = existing.slice(end + END_MARKER.length).trimStart(); - return [before, block.trimEnd(), after].filter(Boolean).join("\n\n") + "\n"; + return [before, block.trimEnd(), after].filter(Boolean).join('\n\n') + '\n'; } const prefix = existing.trimEnd(); return prefix ? `${prefix}\n\n${block}` : block; @@ -127,34 +155,34 @@ function upsertManagedBlock(existing: string, block: string): string { export async function formatCompletionScript( shell: SupportedShell, - rootCommand: Command, + rootCommand: Command ): Promise { const spec = await buildCompletionSpec(rootCommand); - if (shell === "bash") return formatBashCompletion(spec); - if (shell === "zsh") return formatZshCompletion(spec); + if (shell === 'bash') return formatBashCompletion(spec); + if (shell === 'zsh') return formatZshCompletion(spec); return formatFishCompletion(spec); } export async function installCompletion( shell: SupportedShell, script: string, - options: InstallOptions = {}, + options: InstallOptions = {} ): Promise { const target = installTarget(shell); await mkdir(dirname(target.completionPath), { recursive: true }); - await writeFile(target.completionPath, `${script.trimEnd()}\n`, "utf8"); + await writeFile(target.completionPath, `${script.trimEnd()}\n`, 'utf8'); const block = options.updateRc === false ? undefined : rcBlock(shell, target); if (block && target.rcPath) { await mkdir(dirname(target.rcPath), { recursive: true }); const existing = await readOptionalFile(target.rcPath); - await writeFile(target.rcPath, upsertManagedBlock(existing, block), "utf8"); + await writeFile(target.rcPath, upsertManagedBlock(existing, block), 'utf8'); return { shell, completionPath: target.completionPath, rcPath: target.rcPath, rcUpdated: true, - startupAction: "updated", + startupAction: 'updated', }; } @@ -163,36 +191,39 @@ export async function installCompletion( completionPath: target.completionPath, rcPath: target.rcPath, rcUpdated: false, - startupAction: target.rcPath ? "skipped" : "not-needed", + startupAction: target.rcPath ? 'skipped' : 'not-needed', }; } export function formatInstallMessage(result: InstallResult): string { const startup: DisplayText = - result.startupAction === "updated" - ? [span("updated "), span(result.rcPath ?? "", "accent")] - : result.startupAction === "skipped" - ? [span("left unchanged "), span("(--no-rc)", "subtle")] - : "automatic"; + result.startupAction === 'updated' + ? [span('updated '), span(result.rcPath ?? '', 'accent')] + : result.startupAction === 'skipped' + ? [span('left unchanged '), span('(--no-rc)', 'subtle')] + : 'automatic'; const next = - result.startupAction === "skipped" - ? "Add the completion script to your shell startup file, then open a new terminal." - : "Open a new terminal, or reload your shell, to start using completion."; + result.startupAction === 'skipped' + ? 'Add the completion script to your shell startup file, then open a new terminal.' + : 'Open a new terminal, or reload your shell, to start using completion.'; return renderDocumentText( document( section( - text([[span("✓", "success"), span(" Shell completion installed")]]), + text([[span('✓', 'success'), span(' Shell completion installed')]]), rows([ - { label: "Shell:", value: result.shell }, - { label: "Script:", value: [span(result.completionPath, "accent")] }, - { label: "Startup:", value: startup }, - { label: "Next:", value: next }, - { label: "Docs:", value: [span("Shell completion", "accent", COMPLETION_DOCS_URL)] }, - ]), - ), + { label: 'Shell:', value: result.shell }, + { label: 'Script:', value: [span(result.completionPath, 'accent')] }, + { label: 'Startup:', value: startup }, + { label: 'Next:', value: next }, + { + label: 'Docs:', + value: [span('Shell completion', 'accent', COMPLETION_DOCS_URL)], + }, + ]) + ) ), - { labelWidth: "Startup:".length }, + { labelWidth: 'Startup:'.length } ); } @@ -200,18 +231,20 @@ export function formatCompletionHelpMessage(): string { return renderDocumentText( document( section( - text([[span("Shell completion", "accent")]]), + text([[span('Shell completion', 'accent')]]), rows([ - { label: "Install:", value: COMPLETION_GUIDANCE.install }, - { label: "Install shell:", value: COMPLETION_GUIDANCE.installShell }, - { label: "Manual:", value: COMPLETION_GUIDANCE.manual }, + { label: 'Install:', value: COMPLETION_GUIDANCE.install }, + { label: 'Install shell:', value: COMPLETION_GUIDANCE.installShell }, + { label: 'Manual:', value: COMPLETION_GUIDANCE.manual }, { - label: "Docs:", - value: [span("Shell completion", "accent", COMPLETION_GUIDANCE.docs)], + label: 'Docs:', + value: [ + span('Shell completion', 'accent', COMPLETION_GUIDANCE.docs), + ], }, - ]), - ), + ]) + ) ), - { labelWidth: "Install shell:".length }, + { labelWidth: 'Install shell:'.length } ); } diff --git a/cli/src/commands/completion/lib/format.ts b/cli/src/commands/completion/lib/format.ts index 4b678003..6dea5816 100644 --- a/cli/src/commands/completion/lib/format.ts +++ b/cli/src/commands/completion/lib/format.ts @@ -1,21 +1,24 @@ -import { buildCompletionModel, type CompletionModel } from "@/commands/completion/lib/model.ts"; +import { + buildCompletionModel, + type CompletionModel, +} from '@/commands/completion/lib/model.ts'; import type { CompletionContext, CompletionFlag, CompletionNode, -} from "@/commands/completion/lib/spec.ts"; -import { aliasFlagToken } from "@/lib/command-descriptor.ts"; +} from '@/commands/completion/lib/spec.ts'; +import { aliasFlagToken } from '@/lib/command-descriptor.ts'; -const FISH_BINARY_NAME = "altertable"; +const FISH_BINARY_NAME = 'altertable'; export function mergeCompletionFlags( nodeFlags: readonly CompletionFlag[], - rootFlags: readonly CompletionFlag[], + rootFlags: readonly CompletionFlag[] ): CompletionFlag[] { const flags = new Map(); for (const flag of [ ...nodeFlags, - ...rootFlags.filter((rootFlag) => rootFlag.scope !== "root-only"), + ...rootFlags.filter(rootFlag => rootFlag.scope !== 'root-only'), ]) { flags.set(flag.name, flag); } @@ -23,51 +26,55 @@ export function mergeCompletionFlags( } function formatNameList(names: readonly string[]): string { - return names.join(" "); + return names.join(' '); } function flagForms(flag: CompletionFlag): string[] { - return flag.alias ? [aliasFlagToken(flag.alias), `--${flag.name}`] : [`--${flag.name}`]; + return flag.alias + ? [aliasFlagToken(flag.alias), `--${flag.name}`] + : [`--${flag.name}`]; } -export function formatBashFlagWordList(flags: readonly CompletionFlag[]): string { - return flags.flatMap(flagForms).join(" "); +export function formatBashFlagWordList( + flags: readonly CompletionFlag[] +): string { + return flags.flatMap(flagForms).join(' '); } function formatFlagValueSpecList(flags: readonly CompletionFlag[]): string { return flags - .filter((flag) => flag.values && flag.values.length > 0) - .map((flag) => { - const names = flagForms(flag).join("|"); - return `"${names}=${flag.values?.join(",")}"`; + .filter(flag => flag.values && flag.values.length > 0) + .map(flag => { + const names = flagForms(flag).join('|'); + return `"${names}=${flag.values?.join(',')}"`; }) - .join(" "); + .join(' '); } function formatPath(segments: readonly string[]): string { - return segments.join("/"); + return segments.join('/'); } function formatShellCasePatterns(values: ReadonlySet): string { - return [...values].map((value) => `'${value}'`).join("|"); + return [...values].map(value => `'${value}'`).join('|'); } function formatEqualsCasePatterns(values: ReadonlySet): string { - return [...values].map((value) => `${value}=*`).join("|"); + return [...values].map(value => `${value}=*`).join('|'); } function formatSubcommandEdgePatterns(model: CompletionModel): string { return [...model.subcommandEdges] .sort() - .map((edge) => `'${edge}'`) - .join("|"); + .map(edge => `'${edge}'`) + .join('|'); } function formatSoleDirectEdgePatterns(model: CompletionModel): string { const patterns = [...model.soleDirectEdges] .sort() - .map((edge) => `'${edge}'`) - .join("|"); + .map(edge => `'${edge}'`) + .join('|'); return patterns || "'__altertable_no_sole_direct_operand__'"; } @@ -85,12 +92,14 @@ function formatBashNormalizer(model: CompletionModel): string { fi ((index += 1)) ;;` - : ""; - const ignoredPatterns = [equalsValueFlags, booleanFlags].filter(Boolean).join("|"); + : ''; + const ignoredPatterns = [equalsValueFlags, booleanFlags] + .filter(Boolean) + .join('|'); const ignoredFlagCase = ignoredPatterns ? ` ${ignoredPatterns}) ;;` - : ""; + : ''; return `_altertable_is_subcommand() { case "$1|$2" in @@ -173,7 +182,7 @@ ${ignoredFlagCase} function formatBashContextBlock( context: CompletionContext, - rootFlags: readonly CompletionFlag[], + rootFlags: readonly CompletionFlag[] ): string { const flags = mergeCompletionFlags(context.flags, rootFlags); const flagWords = formatBashFlagWordList(flags); @@ -183,17 +192,17 @@ function formatBashContextBlock( return fi ` - : ""; + : ''; const positionalBlocks = context.positionals .map((positional, index) => { - if (positional.completion === "finite" && positional.values?.length) { + if (positional.completion === 'finite' && positional.values?.length) { return ` if [[ \${#ALTERTABLE_POSITIONAL_WORDS[@]} -eq ${index} ]]; then - COMPREPLY=( $(compgen -W "${formatNameList([...positional.values, ...flagWords.split(" ")])}" -- "\${currentWord}") ) + COMPREPLY=( $(compgen -W "${formatNameList([...positional.values, ...flagWords.split(' ')])}" -- "\${currentWord}") ) return fi `; } - if (positional.completion === "file") { + if (positional.completion === 'file') { return ` if [[ \${#ALTERTABLE_POSITIONAL_WORDS[@]} -eq ${index} ]]; then COMPREPLY=() while IFS= read -r completion; do @@ -206,10 +215,13 @@ function formatBashContextBlock( fi `; } - return ""; + return ''; }) - .join(""); - const initialWords = formatNameList([...context.subcommands, ...flagWords.split(" ")]).trim(); + .join(''); + const initialWords = formatNameList([ + ...context.subcommands, + ...flagWords.split(' '), + ]).trim(); return ` '${formatPath(context.segments)}') ${flagValueBlock} if [[ \${ALTERTABLE_EXPECTS_FLAG_VALUE} -eq 1 ]]; then @@ -226,13 +238,15 @@ ${positionalBlocks} if [[ \${#ALTERTABLE_POSITIONAL_WORDS[@]} -eq 0 ]]; the export function formatBashCompletion(spec: CompletionNode): string { const model = buildCompletionModel(spec); - const topLevelCommands = formatNameList(spec.subcommands.map((command) => command.name)); + const topLevelCommands = formatNameList( + spec.subcommands.map(command => command.name) + ); const rootFlagWords = formatBashFlagWordList(spec.flags); const rootFlagValueSpecs = formatFlagValueSpecList(spec.flags); const normalizer = formatBashNormalizer(model); const contexts = model.contexts - .map((context) => formatBashContextBlock(context, spec.flags)) - .join("\n"); + .map(context => formatBashContextBlock(context, spec.flags)) + .join('\n'); return `# altertable bash completion # Preferred install: altertable completion install bash @@ -273,7 +287,7 @@ _altertable_completions() { _altertable_normalize_words if [[ -z "\${ALTERTABLE_COMMAND_PATH_STRING}" ]]; then - ${rootFlagValueSpecs ? `if _altertable_complete_flag_value ${rootFlagValueSpecs}; then return; fi` : ""} + ${rootFlagValueSpecs ? `if _altertable_complete_flag_value ${rootFlagValueSpecs}; then return; fi` : ''} if [[ \${ALTERTABLE_EXPECTS_FLAG_VALUE} -eq 1 ]]; then return; fi COMPREPLY=( $(compgen -W "${topLevelCommands} ${rootFlagWords}" -- "\${currentWord}") ) return @@ -302,12 +316,14 @@ function formatZshNormalizer(model: CompletionModel): string { fi (( index += 1 )) ;;` - : ""; - const ignoredPatterns = [equalsValueFlags, booleanFlags].filter(Boolean).join("|"); + : ''; + const ignoredPatterns = [equalsValueFlags, booleanFlags] + .filter(Boolean) + .join('|'); const ignoredFlagCase = ignoredPatterns ? ` ${ignoredPatterns}) ;;` - : ""; + : ''; return `_altertable_is_subcommand() { case "$1|$2" in @@ -390,7 +406,7 @@ ${ignoredFlagCase} function formatZshContextBlock( context: CompletionContext, - rootFlags: readonly CompletionFlag[], + rootFlags: readonly CompletionFlag[] ): string { const flags = mergeCompletionFlags(context.flags, rootFlags); const flagWords = formatBashFlagWordList(flags); @@ -398,17 +414,17 @@ function formatZshContextBlock( const flagValueBlock = flagValueSpecs ? ` if _altertable_complete_flag_value ${flagValueSpecs}; then return; fi ` - : ""; + : ''; const positionalBlocks = context.positionals .map((positional, index) => { - if (positional.completion === "finite" && positional.values?.length) { + if (positional.completion === 'finite' && positional.values?.length) { return ` if (( \${#ALTERTABLE_POSITIONAL_WORDS[@]} == ${index} )); then - _altertable_add_words ${formatNameList([...positional.values, ...flagWords.split(" ")])} + _altertable_add_words ${formatNameList([...positional.values, ...flagWords.split(' ')])} return fi `; } - if (positional.completion === "file") { + if (positional.completion === 'file') { return ` if (( \${#ALTERTABLE_POSITIONAL_WORDS[@]} == ${index} )); then _altertable_add_words ${flagWords} _files @@ -416,10 +432,13 @@ function formatZshContextBlock( fi `; } - return ""; + return ''; }) - .join(""); - const initialWords = formatNameList([...context.subcommands, ...flagWords.split(" ")]).trim(); + .join(''); + const initialWords = formatNameList([ + ...context.subcommands, + ...flagWords.split(' '), + ]).trim(); return ` '${formatPath(context.segments)}') ${flagValueBlock} if (( ALTERTABLE_EXPECTS_FLAG_VALUE )); then return; fi @@ -434,13 +453,15 @@ ${positionalBlocks} if (( \${#ALTERTABLE_POSITIONAL_WORDS[@]} == 0 )); then export function formatZshCompletion(spec: CompletionNode): string { const model = buildCompletionModel(spec); - const topLevelCommands = formatNameList(spec.subcommands.map((command) => command.name)); + const topLevelCommands = formatNameList( + spec.subcommands.map(command => command.name) + ); const rootFlagWords = formatBashFlagWordList(spec.flags); const rootFlagValueSpecs = formatFlagValueSpecList(spec.flags); const normalizer = formatZshNormalizer(model); const contexts = model.contexts - .map((context) => formatZshContextBlock(context, spec.flags)) - .join("\n"); + .map(context => formatZshContextBlock(context, spec.flags)) + .join('\n'); return `#compdef altertable # altertable zsh completion @@ -490,7 +511,7 @@ _altertable() { _altertable_normalize_words if [[ -z "\${ALTERTABLE_COMMAND_PATH_STRING}" ]]; then - ${rootFlagValueSpecs ? `if _altertable_complete_flag_value ${rootFlagValueSpecs}; then return; fi` : ""} + ${rootFlagValueSpecs ? `if _altertable_complete_flag_value ${rootFlagValueSpecs}; then return; fi` : ''} if (( ALTERTABLE_EXPECTS_FLAG_VALUE )); then return; fi _altertable_add_words ${topLevelCommands} ${rootFlagWords} return @@ -508,45 +529,62 @@ fi } function escapeFishDescription(description: string): string { - return description.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + return description.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } export function formatFishPathCondition( segments: readonly string[], - positionalCount: number | "any" = "any", + positionalCount: number | 'any' = 'any' ): string { return `__altertable_using_context '${formatPath(segments)}' '${positionalCount}'`; } -function formatFishFlagCompleteLine(flag: CompletionFlag, condition?: string): string { +function formatFishFlagCompleteLine( + flag: CompletionFlag, + condition?: string +): string { // fish takes short flags via -s and every long form via its own -l. - const aliasFlag = flag.alias ? `${flag.alias.length === 1 ? " -s " : " -l "}${flag.alias}` : ""; - const description = flag.description ? ` -d '${escapeFishDescription(flag.description)}'` : ""; + const aliasFlag = flag.alias + ? `${flag.alias.length === 1 ? ' -s ' : ' -l '}${flag.alias}` + : ''; + const description = flag.description + ? ` -d '${escapeFishDescription(flag.description)}'` + : ''; const values = - flag.values && flag.values.length > 0 ? ` -f -r -a "${flag.values.join(" ")}"` : ""; - const conditionArg = condition ? ` -n "${condition}"` : ""; + flag.values && flag.values.length > 0 + ? ` -f -r -a "${flag.values.join(' ')}"` + : ''; + const conditionArg = condition ? ` -n "${condition}"` : ''; return `complete -c ${FISH_BINARY_NAME}${aliasFlag} -l ${flag.name}${description}${values}${conditionArg}`; } function formatFishNormalizer(model: CompletionModel): string { - const valueFlags = [...model.valueFlags].map((flag) => `'${flag}'`).join(" "); - const equalsValueFlags = [...model.valueFlags].map((flag) => `'${flag}=*'`).join(" "); - const booleanFlags = [...model.booleanFlags].map((flag) => `'${flag}'`).join(" "); + const valueFlags = [...model.valueFlags].map(flag => `'${flag}'`).join(' '); + const equalsValueFlags = [...model.valueFlags] + .map(flag => `'${flag}=*'`) + .join(' '); + const booleanFlags = [...model.booleanFlags] + .map(flag => `'${flag}'`) + .join(' '); const edges = [...model.subcommandEdges] .sort() - .map((edge) => `'${edge}'`) - .join(" "); + .map(edge => `'${edge}'`) + .join(' '); const soleDirectEdges = [...model.soleDirectEdges] .sort() - .map((edge) => `'${edge}'`) - .join(" ") || "'__altertable_no_sole_direct_operand__'"; + .map(edge => `'${edge}'`) + .join(' ') || "'__altertable_no_sole_direct_operand__'"; const valueFlagCase = valueFlags ? ` case ${valueFlags} set index (math $index + 1)` - : ""; - const ignoredPatterns = [equalsValueFlags, booleanFlags].filter(Boolean).join(" "); - const ignoredFlagCase = ignoredPatterns ? ` case ${ignoredPatterns}` : ""; + : ''; + const ignoredPatterns = [equalsValueFlags, booleanFlags] + .filter(Boolean) + .join(' '); + const ignoredFlagCase = ignoredPatterns + ? ` case ${ignoredPatterns}` + : ''; return `function __altertable_is_subcommand switch "$argv[1]|$argv[2]" @@ -628,33 +666,37 @@ end`; export function formatFishCompletion(spec: CompletionNode): string { const model = buildCompletionModel(spec); - const topLevelCommands = formatNameList(spec.subcommands.map((command) => command.name)); + const topLevelCommands = formatNameList( + spec.subcommands.map(command => command.name) + ); const normalizer = formatFishNormalizer(model); const rootFlagLines = spec.flags - .map((flag) => + .map(flag => formatFishFlagCompleteLine( flag, - flag.scope === "root-only" ? formatFishPathCondition([], 0) : undefined, - ), + flag.scope === 'root-only' ? formatFishPathCondition([], 0) : undefined + ) ) - .join("\n"); + .join('\n'); const contextLines: string[] = []; for (const context of model.contexts) { if (context.subcommands.length > 0) { const condition = formatFishPathCondition(context.segments, 0); contextLines.push( - `complete -c ${FISH_BINARY_NAME} -f -n "${condition}" -a "${formatNameList(context.subcommands)}"`, + `complete -c ${FISH_BINARY_NAME} -f -n "${condition}" -a "${formatNameList(context.subcommands)}"` ); } for (const [index, positional] of context.positionals.entries()) { const condition = formatFishPathCondition(context.segments, index); - if (positional.completion === "finite" && positional.values?.length) { + if (positional.completion === 'finite' && positional.values?.length) { + contextLines.push( + `complete -c ${FISH_BINARY_NAME} -f -n "${condition}" -a "${formatNameList(positional.values)}"` + ); + } else if (positional.completion === 'file') { contextLines.push( - `complete -c ${FISH_BINARY_NAME} -f -n "${condition}" -a "${formatNameList(positional.values)}"`, + `complete -c ${FISH_BINARY_NAME} -F -n "${condition}"` ); - } else if (positional.completion === "file") { - contextLines.push(`complete -c ${FISH_BINARY_NAME} -F -n "${condition}"`); } } const flagCondition = formatFishPathCondition(context.segments); @@ -671,6 +713,6 @@ ${normalizer} ${rootFlagLines} complete -c ${FISH_BINARY_NAME} -f -n "${formatFishPathCondition([], 0)}" -a "${topLevelCommands}" -${contextLines.join("\n")} +${contextLines.join('\n')} `; } diff --git a/cli/src/commands/completion/lib/model.ts b/cli/src/commands/completion/lib/model.ts index ac032c1b..f921a338 100644 --- a/cli/src/commands/completion/lib/model.ts +++ b/cli/src/commands/completion/lib/model.ts @@ -3,8 +3,8 @@ import { type CompletionContext, type CompletionFlag, type CompletionNode, -} from "@/commands/completion/lib/spec.ts"; -import { aliasFlagToken } from "@/lib/command-descriptor.ts"; +} from '@/commands/completion/lib/spec.ts'; +import { aliasFlagToken } from '@/lib/command-descriptor.ts'; export type CompletionModel = { contexts: CompletionContext[]; @@ -22,27 +22,36 @@ export type NormalizedCompletionArgv = { }; function flagForms(flag: CompletionFlag): string[] { - return flag.alias ? [aliasFlagToken(flag.alias), `--${flag.name}`] : [`--${flag.name}`]; + return flag.alias + ? [aliasFlagToken(flag.alias), `--${flag.name}`] + : [`--${flag.name}`]; } -function allFlags(root: CompletionNode, contexts: readonly CompletionContext[]): CompletionFlag[] { - return [...root.flags, ...contexts.flatMap((context) => context.flags)]; +function allFlags( + root: CompletionNode, + contexts: readonly CompletionContext[] +): CompletionFlag[] { + return [...root.flags, ...contexts.flatMap(context => context.flags)]; } function commandEdgeKey(parent: readonly string[], child: string): string { - return `${parent.join("/")}|${child}`; + return `${parent.join('/')}|${child}`; } export function buildCompletionModel(root: CompletionNode): CompletionModel { const contexts = collectCompletionContexts(root); - const contextsByPath = new Map(contexts.map((context) => [context.segments.join("/"), context])); + const contextsByPath = new Map( + contexts.map(context => [context.segments.join('/'), context]) + ); const flags = allFlags(root, contexts); - const valueFlags = new Set(flags.filter((flag) => flag.takesValue).flatMap(flagForms)); + const valueFlags = new Set( + flags.filter(flag => flag.takesValue).flatMap(flagForms) + ); const booleanFlags = new Set( flags - .filter((flag) => !flag.takesValue) + .filter(flag => !flag.takesValue) .flatMap(flagForms) - .filter((flag) => !valueFlags.has(flag)), + .filter(flag => !valueFlags.has(flag)) ); const subcommandEdges = new Set(); const soleDirectEdges = new Set(); @@ -66,9 +75,14 @@ export function buildCompletionModel(root: CompletionNode): CompletionModel { }; for (const context of contexts) { - const normalized = normalizeCompletionArgv(model, [...context.segments, "--help"]); + const normalized = normalizeCompletionArgv(model, [ + ...context.segments, + '--help', + ]); if (findCompletionContext(model, normalized.commandPath) !== context) { - throw new TypeError(`Invalid completion context: ${context.segments.join(" ")}`); + throw new TypeError( + `Invalid completion context: ${context.segments.join(' ')}` + ); } } @@ -77,7 +91,7 @@ export function buildCompletionModel(root: CompletionNode): CompletionModel { export function normalizeCompletionArgv( model: CompletionModel, - tokens: readonly string[], + tokens: readonly string[] ): NormalizedCompletionArgv { const commandPath: string[] = []; const positionals: string[] = []; @@ -91,7 +105,7 @@ export function normalizeCompletionArgv( const word = tokens[index]; if (word === undefined) continue; - if (!afterSeparator && word === "--") { + if (!afterSeparator && word === '--') { afterSeparator = true; continue; } @@ -105,9 +119,10 @@ export function normalizeCompletionArgv( } if ( !afterSeparator && - (isInlineValueOption(model.valueFlags, word) || model.booleanFlags.has(word)) + (isInlineValueOption(model.valueFlags, word) || + model.booleanFlags.has(word)) ) { - if (ambiguousCommand && (word === "--help" || word === "-h")) { + if (ambiguousCommand && (word === '--help' || word === '-h')) { ambiguousCommand.helpRequested = true; } continue; @@ -152,12 +167,15 @@ export function normalizeCompletionArgv( export function findCompletionContext( model: CompletionModel, - commandPath: readonly string[], + commandPath: readonly string[] ): CompletionContext | undefined { - return model.contextsByPath.get(commandPath.join("/")); + return model.contextsByPath.get(commandPath.join('/')); } -function isInlineValueOption(valueOptions: ReadonlySet, token: string): boolean { +function isInlineValueOption( + valueOptions: ReadonlySet, + token: string +): boolean { for (const option of valueOptions) { if (token.startsWith(`${option}=`)) return true; } diff --git a/cli/src/commands/completion/lib/spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts index 29bd904c..ce45a468 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,15 +1,18 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { buildMainCommand } from "@/cli.ts"; -import { buildCompletionSpec, collectCompletionContexts } from "@/commands/completion/lib/spec.ts"; +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defineCommand } from '@/lib/command.ts'; +import { buildMainCommand } from '@/cli.ts'; +import { + buildCompletionSpec, + collectCompletionContexts, +} from '@/commands/completion/lib/spec.ts'; import { buildCompletionModel, findCompletionContext, normalizeCompletionArgv, -} from "@/commands/completion/lib/model.ts"; +} from '@/commands/completion/lib/model.ts'; import { formatBashCompletion, formatBashFlagWordList, @@ -17,16 +20,16 @@ import { formatFishPathCondition, formatZshCompletion, mergeCompletionFlags, -} from "@/commands/completion/lib/format.ts"; +} from '@/commands/completion/lib/format.ts'; type CompletionSpec = Awaited>; function findNode(spec: CompletionSpec, name: string) { - return spec.subcommands.find((node) => node.name === name); + return spec.subcommands.find(node => node.name === name); } function findChild(node: CompletionSpec | undefined, name: string) { - return node?.subcommands.find((child) => child.name === name); + return node?.subcommands.find(child => child.name === name); } function bashQuote(value: string): string { @@ -36,32 +39,38 @@ function bashQuote(value: string): string { function fishCommand(words: readonly string[]): string { return words .map( - (word) => `"${word.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$")}"`, + word => + `"${word.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$')}"` ) - .join(" "); + .join(' '); } function parseCompletionOutput(output: Uint8Array): string[] { return new TextDecoder() .decode(output) .trim() - .split("\n") - .map((line) => line.split("\t")[0] ?? "") + .split('\n') + .map(line => line.split('\t')[0] ?? '') .filter(Boolean); } -async function runBashCompletion(words: string[], cwd?: string): Promise { - const script = formatBashCompletion(await buildCompletionSpec(buildMainCommand())); +async function runBashCompletion( + words: string[], + cwd?: string +): Promise { + const script = formatBashCompletion( + await buildCompletionSpec(buildMainCommand()) + ); const source = `${script} -COMP_WORDS=(${words.map(bashQuote).join(" ")}) +COMP_WORDS=(${words.map(bashQuote).join(' ')}) COMP_CWORD=${words.length - 1} COMPREPLY=() _altertable_completions printf '%s\\n' "\${COMPREPLY[@]}" `; - const result = Bun.spawnSync(["bash", "-c", source], { - stdout: "pipe", - stderr: "pipe", + const result = Bun.spawnSync(['bash', '-c', source], { + stdout: 'pipe', + stderr: 'pipe', ...(cwd ? { cwd } : {}), }); if (result.exitCode !== 0) { @@ -71,7 +80,9 @@ printf '%s\\n' "\${COMPREPLY[@]}" } async function runZshCompletion(words: string[]): Promise { - const script = formatZshCompletion(await buildCompletionSpec(buildMainCommand())); + const script = formatZshCompletion( + await buildCompletionSpec(buildMainCommand()) + ); const source = `${script} compadd() { local afterSeparator=0 @@ -90,14 +101,14 @@ _files() { compadd -- "\${candidate}" done } -words=(${words.map(bashQuote).join(" ")}) +words=(${words.map(bashQuote).join(' ')}) CURRENT=${words.length} PREFIX="\${words[CURRENT]}" _altertable `; - const result = Bun.spawnSync(["zsh", "-f", "-c", source], { - stdout: "pipe", - stderr: "pipe", + const result = Bun.spawnSync(['zsh', '-f', '-c', source], { + stdout: 'pipe', + stderr: 'pipe', }); if (result.exitCode !== 0) { throw new Error(new TextDecoder().decode(result.stderr)); @@ -106,14 +117,19 @@ _altertable } async function runFishCompletion(words: string[]): Promise { - const script = formatFishCompletion(await buildCompletionSpec(buildMainCommand())); + const script = formatFishCompletion( + await buildCompletionSpec(buildMainCommand()) + ); const source = `${script} complete -C "$argv[1]" `; - const result = Bun.spawnSync(["fish", "--no-config", "-c", source, fishCommand(words)], { - stdout: "pipe", - stderr: "pipe", - }); + const result = Bun.spawnSync( + ['fish', '--no-config', '-c', source, fishCommand(words)], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); if (result.exitCode !== 0) { throw new Error(new TextDecoder().decode(result.stderr)); } @@ -127,36 +143,36 @@ const completionRunners = { }; const globalFlagForms = [ - ["--debug"], - ["-d"], - ["--json"], - ["--agent"], - ["--no-color"], - ["--profile", "production"], - ["--profile=production"], - ["--connect-timeout", "5"], - ["--connect-timeout=5"], - ["--read-timeout", "60"], - ["--read-timeout=60"], + ['--debug'], + ['-d'], + ['--json'], + ['--agent'], + ['--no-color'], + ['--profile', 'production'], + ['--profile=production'], + ['--connect-timeout', '5'], + ['--connect-timeout=5'], + ['--read-timeout', '60'], + ['--read-timeout=60'], ]; -describe("buildCompletionSpec", () => { - test("walks a minimal fake tree", async () => { +describe('buildCompletionSpec', () => { + test('walks a minimal fake tree', async () => { const root = defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, args: { - json: { type: "boolean", description: "Output raw JSON" }, - format: { type: "enum", options: ["json", "table"] }, + json: { type: 'boolean', description: 'Output raw JSON' }, + format: { type: 'enum', options: ['json', 'table'] }, }, subcommands: { alpha: { - metadata: { name: "alpha", description: "Alpha command" }, + metadata: { name: 'alpha', description: 'Alpha command' }, args: { - force: { type: "boolean", alias: "f" }, + force: { type: 'boolean', alias: 'f' }, }, subcommands: { sub: { - metadata: { name: "sub" }, + metadata: { name: 'sub' }, }, }, }, @@ -164,46 +180,53 @@ describe("buildCompletionSpec", () => { }); const spec = await buildCompletionSpec(root); - expect(spec.flags.map((flag) => flag.name)).toEqual(["format", "json"]); - expect(spec.flags.find((flag) => flag.name === "format")?.values).toEqual(["json", "table"]); + expect(spec.flags.map(flag => flag.name)).toEqual(['format', 'json']); + expect(spec.flags.find(flag => flag.name === 'format')?.values).toEqual([ + 'json', + 'table', + ]); expect(spec.subcommands).toHaveLength(1); - expect(spec.subcommands[0]?.name).toBe("alpha"); - expect(spec.subcommands[0]?.subcommands.map((node) => node.name)).toEqual(["sub"]); - expect(spec.subcommands[0]?.flags.map((flag) => flag.name)).toEqual(["force"]); + expect(spec.subcommands[0]?.name).toBe('alpha'); + expect(spec.subcommands[0]?.subcommands.map(node => node.name)).toEqual([ + 'sub', + ]); + expect(spec.subcommands[0]?.flags.map(flag => flag.name)).toEqual([ + 'force', + ]); }); - test("skips nested commands without metadata.name", async () => { + test('skips nested commands without metadata.name', async () => { const root = defineCommand({ subcommands: { - visible: { metadata: { name: "visible" } }, - hidden: { metadata: { description: "no name" } }, + visible: { metadata: { name: 'visible' } }, + hidden: { metadata: { description: 'no name' } }, }, }); const spec = await buildCompletionSpec(root); - expect(spec.subcommands.map((command) => command.name)).toEqual(["visible"]); + expect(spec.subcommands.map(command => command.name)).toEqual(['visible']); }); - test("skips commands marked hidden", async () => { + test('skips commands marked hidden', async () => { const root = defineCommand({ subcommands: { - visible: { metadata: { name: "visible" } }, - hidden: { metadata: { name: "hidden", hidden: true } }, + visible: { metadata: { name: 'visible' } }, + hidden: { metadata: { name: 'hidden', hidden: true } }, }, }); const spec = await buildCompletionSpec(root); - expect(spec.subcommands.map((command) => command.name)).toEqual(["visible"]); + expect(spec.subcommands.map(command => command.name)).toEqual(['visible']); }); - test("resolves asynchronous command metadata", async () => { + test('resolves asynchronous command metadata', async () => { const root = defineCommand({ - metadata: async () => ({ name: "altertable" }), + metadata: async () => ({ name: 'altertable' }), subcommands: { generated: defineCommand({ metadata: async () => ({ - name: "generated", - description: "Generated command", + name: 'generated', + description: 'Generated command', }), }), }, @@ -211,166 +234,179 @@ describe("buildCompletionSpec", () => { const spec = await buildCompletionSpec(root); - expect(spec.name).toBe("altertable"); + expect(spec.name).toBe('altertable'); expect(spec.subcommands).toEqual([ expect.objectContaining({ - name: "generated", - description: "Generated command", + name: 'generated', + description: 'Generated command', }), ]); }); - test("real root command includes expected top-level and nested commands", async () => { + test('real root command includes expected top-level and nested commands', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - const catalog = findNode(spec, "catalog"); - const api = findNode(spec, "api"); - - expect(findNode(spec, "query")).toBeDefined(); - expect(findNode(spec, "update")).toBeDefined(); - expect(findNode(spec, "upgrade")).toBeDefined(); - expect(spec.flags.some((flag) => flag.name === "agent")).toBe(true); - expect(spec.flags.some((flag) => flag.name === "no-agent")).toBe(true); + const catalog = findNode(spec, 'catalog'); + const api = findNode(spec, 'api'); + + expect(findNode(spec, 'query')).toBeDefined(); + expect(findNode(spec, 'update')).toBeDefined(); + expect(findNode(spec, 'upgrade')).toBeDefined(); + expect(spec.flags.some(flag => flag.name === 'agent')).toBe(true); + expect(spec.flags.some(flag => flag.name === 'no-agent')).toBe(true); expect(catalog).toBeDefined(); - expect(findNode(spec, "catalogs")).toBeUndefined(); + expect(findNode(spec, 'catalogs')).toBeUndefined(); expect(api).toBeDefined(); - expect(findNode(spec, "connections")).toBeUndefined(); - expect(catalog?.subcommands.map((node) => node.name)).toEqual(["create"]); - expect(api?.subcommands.map((node) => node.name)).toEqual(["route", "spec"]); - expect(api?.subcommands.some((node) => node.name === "routes")).toBe(false); - expect(api?.flags.some((flag) => flag.name === "method")).toBe(true); - expect(api?.flags.some((flag) => flag.name === "raw-field")).toBe(true); - expect(api?.flags.some((flag) => flag.name === "field")).toBe(true); - expect(api?.flags.some((flag) => flag.name === "input")).toBe(true); + expect(findNode(spec, 'connections')).toBeUndefined(); + expect(catalog?.subcommands.map(node => node.name)).toEqual(['create']); + expect(api?.subcommands.map(node => node.name)).toEqual(['route', 'spec']); + expect(api?.subcommands.some(node => node.name === 'routes')).toBe(false); + expect(api?.flags.some(flag => flag.name === 'method')).toBe(true); + expect(api?.flags.some(flag => flag.name === 'raw-field')).toBe(true); + expect(api?.flags.some(flag => flag.name === 'field')).toBe(true); + expect(api?.flags.some(flag => flag.name === 'input')).toBe(true); }); - test("includes completion top-level command", async () => { + test('includes completion top-level command', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - expect(findNode(spec, "completion")).toBeDefined(); + expect(findNode(spec, 'completion')).toBeDefined(); }); - test("extracts root json flag", async () => { + test('extracts root json flag', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - expect(spec.flags.some((flag) => flag.name === "json")).toBe(true); - expect(spec.flags.some((flag) => flag.name === "debug")).toBe(true); + expect(spec.flags.some(flag => flag.name === 'json')).toBe(true); + expect(spec.flags.some(flag => flag.name === 'debug')).toBe(true); expect(spec.flags).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: "help", alias: "h", scope: "global" }), - expect.objectContaining({ name: "version", alias: "v", scope: "root-only" }), - ]), + expect.objectContaining({ name: 'help', alias: 'h', scope: 'global' }), + expect.objectContaining({ + name: 'version', + alias: 'v', + scope: 'root-only', + }), + ]) ); }); - test("extracts fixed flag values from real commands", async () => { + test('extracts fixed flag values from real commands', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - const query = findNode(spec, "query"); + const query = findNode(spec, 'query'); - expect(query?.flags.find((flag) => flag.name === "layout")?.values).toEqual([ - "auto", - "table", - "line", + expect(query?.flags.find(flag => flag.name === 'layout')?.values).toEqual([ + 'auto', + 'table', + 'line', ]); - expect(query?.flags.find((flag) => flag.name === "pager")?.values).toEqual([ - "auto", - "always", - "never", + expect(query?.flags.find(flag => flag.name === 'pager')?.values).toEqual([ + 'auto', + 'always', + 'never', ]); }); - test("exposes empty soleDirectOperands when query has no operand collisions", async () => { + test('exposes empty soleDirectOperands when query has no operand collisions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - expect(findNode(spec, "query")?.soleDirectOperands).toEqual([]); + expect(findNode(spec, 'query')?.soleDirectOperands).toEqual([]); }); - test("extracts finite shell positional values from completion commands", async () => { + test('extracts finite shell positional values from completion commands', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - const completion = findNode(spec, "completion"); + const completion = findNode(spec, 'completion'); - expect(findChild(completion, "generate")?.positionals).toEqual([ + expect(findChild(completion, 'generate')?.positionals).toEqual([ expect.objectContaining({ - name: "shell", + name: 'shell', required: true, - completion: "finite", - values: ["bash", "fish", "zsh"], + completion: 'finite', + values: ['bash', 'fish', 'zsh'], }), ]); - expect(findChild(completion, "install")?.positionals).toEqual([ + expect(findChild(completion, 'install')?.positionals).toEqual([ expect.objectContaining({ - name: "shell", + name: 'shell', required: false, - completion: "finite", - values: ["bash", "fish", "zsh"], + completion: 'finite', + values: ['bash', 'fish', 'zsh'], }), ]); }); - test("sorts subcommands alphabetically", async () => { + test('sorts subcommands alphabetically', async () => { const spec = await buildCompletionSpec(buildMainCommand()); - const names = spec.subcommands.map((command) => command.name); - expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right))); + const names = spec.subcommands.map(command => command.name); + expect(names).toEqual( + [...names].sort((left, right) => left.localeCompare(right)) + ); }); }); -describe("completion format helpers", () => { - test("mergeCompletionFlags preserves node flags before root flags", () => { - const merged = mergeCompletionFlags([{ name: "label" }], [{ name: "json" }, { name: "debug" }]); - expect(merged.map((flag) => flag.name)).toEqual(["label", "json", "debug"]); +describe('completion format helpers', () => { + test('mergeCompletionFlags preserves node flags before root flags', () => { + const merged = mergeCompletionFlags( + [{ name: 'label' }], + [{ name: 'json' }, { name: 'debug' }] + ); + expect(merged.map(flag => flag.name)).toEqual(['label', 'json', 'debug']); }); - test("formatBashFlagWordList includes short and long flag forms", () => { - expect(formatBashFlagWordList([{ name: "json" }, { name: "force", alias: "f" }])).toBe( - "--json -f --force", - ); + test('formatBashFlagWordList includes short and long flag forms', () => { + expect( + formatBashFlagWordList([{ name: 'json' }, { name: 'force', alias: 'f' }]) + ).toBe('--json -f --force'); }); - test("formatFishPathCondition uses normalized path and positional state", () => { - expect(formatFishPathCondition(["api"], 1)).toBe("__altertable_using_context 'api' '1'"); + test('formatFishPathCondition uses normalized path and positional state', () => { + expect(formatFishPathCondition(['api'], 1)).toBe( + "__altertable_using_context 'api' '1'" + ); }); }); -describe("normalized completion argv", () => { - test("resolves command paths independently from flags and direct operands", async () => { - const model = buildCompletionModel(await buildCompletionSpec(buildMainCommand())); +describe('normalized completion argv', () => { + test('resolves command paths independently from flags and direct operands', async () => { + const model = buildCompletionModel( + await buildCompletionSpec(buildMainCommand()) + ); const matrix = [ { - argv: ["--profile", "production", "query", "SELECT 1"], - path: ["query"], - positionals: ["SELECT 1"], + argv: ['--profile', 'production', 'query', 'SELECT 1'], + path: ['query'], + positionals: ['SELECT 1'], }, { - argv: ["query", "--profile=production", "show", "qry_123"], - path: ["query", "show"], - positionals: ["qry_123"], + argv: ['query', '--profile=production', 'show', 'qry_123'], + path: ['query', 'show'], + positionals: ['qry_123'], }, { - argv: ["query", "show", "--help"], - path: ["query", "show"], + argv: ['query', 'show', '--help'], + path: ['query', 'show'], positionals: [], }, { - argv: ["query", "SELECT 1", "--layout", "table"], - path: ["query"], - positionals: ["SELECT 1"], + argv: ['query', 'SELECT 1', '--layout', 'table'], + path: ['query'], + positionals: ['SELECT 1'], }, { - argv: ["append", '{"event":"checkout"}', "--sync"], - path: ["append"], + argv: ['append', '{"event":"checkout"}', '--sync'], + path: ['append'], positionals: ['{"event":"checkout"}'], }, { - argv: ["api", "/whoami", "-X", "GET"], - path: ["api"], - positionals: ["/whoami"], + argv: ['api', '/whoami', '-X', 'GET'], + path: ['api'], + positionals: ['/whoami'], }, { - argv: ["completion", "install", "--no-rc"], - path: ["completion", "install"], + argv: ['completion', 'install', '--no-rc'], + path: ['completion', 'install'], positionals: [], }, { - argv: ["query", "--", "show"], - path: ["query"], - positionals: ["show"], + argv: ['query', '--', 'show'], + path: ['query'], + positionals: ['show'], }, ]; @@ -378,241 +414,328 @@ describe("normalized completion argv", () => { const normalized = normalizeCompletionArgv(model, entry.argv); expect(normalized.commandPath).toEqual(entry.path); expect(normalized.positionals).toEqual(entry.positionals); - expect(findCompletionContext(model, normalized.commandPath)).toBeDefined(); + expect( + findCompletionContext(model, normalized.commandPath) + ).toBeDefined(); } }); - test("tracks a pending value for global and command flags", async () => { - const model = buildCompletionModel(await buildCompletionSpec(buildMainCommand())); - - expect(normalizeCompletionArgv(model, ["query", "--profile"]).expectsFlagValue).toBe(true); - expect(normalizeCompletionArgv(model, ["query", "--layout"]).expectsFlagValue).toBe(true); - expect(normalizeCompletionArgv(model, ["query", "--layout=table"]).expectsFlagValue).toBe( - false, + test('tracks a pending value for global and command flags', async () => { + const model = buildCompletionModel( + await buildCompletionSpec(buildMainCommand()) ); + + expect( + normalizeCompletionArgv(model, ['query', '--profile']).expectsFlagValue + ).toBe(true); + expect( + normalizeCompletionArgv(model, ['query', '--layout']).expectsFlagValue + ).toBe(true); + expect( + normalizeCompletionArgv(model, ['query', '--layout=table']) + .expectsFlagValue + ).toBe(false); }); }); -describe("formatBashCompletion", () => { - test("includes nested case blocks", async () => { +describe('formatBashCompletion', () => { + test('includes nested case blocks', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatBashCompletion(spec); expect(output).toContain("'api')"); - expect(output).toContain("--method"); - expect(output).toContain("catalog"); + expect(output).toContain('--method'); + expect(output).toContain('catalog'); }); - test("includes leaf command flags", async () => { + test('includes leaf command flags', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatBashCompletion(spec); - expect(output).toContain("--raw-field"); - expect(output).toContain("--field"); - expect(output).toContain("--input"); + expect(output).toContain('--raw-field'); + expect(output).toContain('--field'); + expect(output).toContain('--input'); }); - test("includes flag value completions", async () => { + test('includes flag value completions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatBashCompletion(spec); - expect(output).toContain("_altertable_complete_flag_value"); + expect(output).toContain('_altertable_complete_flag_value'); expect(output).toContain('"--layout=auto,table,line"'); expect(output).toContain('"--pager=auto,always,never"'); }); - test("includes finite positional value completions", async () => { - const output = formatBashCompletion(await buildCompletionSpec(buildMainCommand())); + test('includes finite positional value completions', async () => { + const output = formatBashCompletion( + await buildCompletionSpec(buildMainCommand()) + ); expect(output).toContain('compgen -W "bash fish zsh --'); }); - test("completes finite positionals after command flags", async () => { - expect(await runBashCompletion(["altertable", "completion", "install", "--no-rc", ""])).toEqual( - expect.arrayContaining(["bash", "fish", "zsh"]), - ); + test('completes finite positionals after command flags', async () => { + expect( + await runBashCompletion([ + 'altertable', + 'completion', + 'install', + '--no-rc', + '', + ]) + ).toEqual(expect.arrayContaining(['bash', 'fish', 'zsh'])); }); - test("preserves whitespace in file and directory candidates", async () => { - const cwd = mkdtempSync(join(tmpdir(), "altertable-bash-completion-")); - writeFileSync(join(cwd, "order data.csv"), ""); - mkdirSync(join(cwd, "order exports")); + test('preserves whitespace in file and directory candidates', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'altertable-bash-completion-')); + writeFileSync(join(cwd, 'order data.csv'), ''); + mkdirSync(join(cwd, 'order exports')); try { - expect(await runBashCompletion(["altertable", "upload", "order"], cwd)).toEqual( - expect.arrayContaining(["order data.csv", "order exports"]), - ); + expect( + await runBashCompletion(['altertable', 'upload', 'order'], cwd) + ).toEqual(expect.arrayContaining(['order data.csv', 'order exports'])); } finally { rmSync(cwd, { recursive: true, force: true }); } }); }); -describe("collectCompletionContexts", () => { - test("returns leaf contexts with flags and no subcommands", async () => { +describe('collectCompletionContexts', () => { + test('returns leaf contexts with flags and no subcommands', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const contexts = collectCompletionContexts(spec); - const api = contexts.find((context) => context.segments.join("/") === "api"); - expect(api?.flags.some((flag) => flag.name === "field")).toBe(true); - expect(api?.flags.some((flag) => flag.name === "input")).toBe(true); - expect(api?.subcommands).toEqual(["route", "spec"]); + const api = contexts.find(context => context.segments.join('/') === 'api'); + expect(api?.flags.some(flag => flag.name === 'field')).toBe(true); + expect(api?.flags.some(flag => flag.name === 'input')).toBe(true); + expect(api?.subcommands).toEqual(['route', 'spec']); }); }); -describe("formatFishCompletion", () => { - test("escapes backslashes and apostrophes in descriptions", async () => { +describe('formatFishCompletion', () => { + test('escapes backslashes and apostrophes in descriptions', async () => { const spec = await buildCompletionSpec( defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, args: { output: { - type: "string", + type: 'string', description: "Write to C:\\Users\\O'Brien", }, }, - }), + }) ); - expect(formatFishCompletion(spec)).toContain(" -d 'Write to C:\\\\Users\\\\O\\'Brien'"); + expect(formatFishCompletion(spec)).toContain( + " -d 'Write to C:\\\\Users\\\\O\\'Brien'" + ); }); - test("includes scoped leaf flag completions", async () => { + test('includes scoped leaf flag completions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatFishCompletion(spec); - expect(output).toContain("-l field"); + expect(output).toContain('-l field'); }); - test("includes flag value completions", async () => { + test('includes flag value completions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatFishCompletion(spec); expect(output).toContain( - "-l layout -d 'Human layout: auto, table, or line' -f -r -a \"auto table line\"", + '-l layout -d \'Human layout: auto, table, or line\' -f -r -a "auto table line"' ); }); - test("includes finite positional value completions", async () => { - const output = formatFishCompletion(await buildCompletionSpec(buildMainCommand())); + test('includes finite positional value completions', async () => { + const output = formatFishCompletion( + await buildCompletionSpec(buildMainCommand()) + ); expect(output).toContain('-a "bash fish zsh"'); }); }); -describe("formatZshCompletion", () => { - test("includes leaf flag completions", async () => { +describe('formatZshCompletion', () => { + test('includes leaf flag completions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatZshCompletion(spec); - expect(output).toContain("--field"); - expect(output).toContain("--input"); + expect(output).toContain('--field'); + expect(output).toContain('--input'); }); - test("includes flag value completions", async () => { + test('includes flag value completions', async () => { const spec = await buildCompletionSpec(buildMainCommand()); const output = formatZshCompletion(spec); expect(output).toContain('"--layout=auto,table,line"'); expect(output).toContain('"--pager=auto,always,never"'); }); - test("includes finite positional value completions", async () => { - const output = formatZshCompletion(await buildCompletionSpec(buildMainCommand())); - expect(output).toContain("_altertable_add_words bash fish zsh"); + test('includes finite positional value completions', async () => { + const output = formatZshCompletion( + await buildCompletionSpec(buildMainCommand()) + ); + expect(output).toContain('_altertable_add_words bash fish zsh'); }); }); -describe("executable shell completion contract", () => { +describe('executable shell completion contract', () => { for (const [shell, runCompletion] of Object.entries(completionRunners)) { const shellTest = test.skipIf(Bun.which(shell) === null); shellTest(`${shell} routes direct operands to trailing flags`, async () => { - expect(await runCompletion(["altertable", "query", "SELECT 1", "--"])).toContain("--layout"); - expect(await runCompletion(["altertable", "append", '{"event":"checkout"}', "--"])).toContain( - "--to", - ); - expect(await runCompletion(["altertable", "api", "/whoami", "--"])).toContain("--method"); + expect( + await runCompletion(['altertable', 'query', 'SELECT 1', '--']) + ).toContain('--layout'); + expect( + await runCompletion([ + 'altertable', + 'append', + '{"event":"checkout"}', + '--', + ]) + ).toContain('--to'); + expect( + await runCompletion(['altertable', 'api', '/whoami', '--']) + ).toContain('--method'); }); - shellTest(`${shell} resolves nested commands without raw word indexes`, async () => { - const candidates = await runCompletion([ - "altertable", - "--json", - "query", - "show", - "qry_1", - "--", - ]); - expect(candidates).toContain("--profile"); - expect(candidates).not.toContain("--layout"); - }); - - shellTest(`${shell} treats query show as a subcommand, not a SQL direct operand`, async () => { - const showLeaf = await runCompletion([ - "altertable", - "query", - "show", - "--layout", - "table", - "--", - ]); - expect(showLeaf).not.toContain("--columns"); - expect(showLeaf).not.toContain("--layout"); - - const directSql = await runCompletion(["altertable", "query", "SELECT 1", "--"]); - expect(directSql).toContain("--columns"); - expect(directSql).toContain("--layout"); - - const nested = await runCompletion(["altertable", "query", "show", "qry_1", "--"]); - expect(nested).not.toContain("--columns"); - expect(nested).not.toContain("--layout"); - }); + shellTest( + `${shell} resolves nested commands without raw word indexes`, + async () => { + const candidates = await runCompletion([ + 'altertable', + '--json', + 'query', + 'show', + 'qry_1', + '--', + ]); + expect(candidates).toContain('--profile'); + expect(candidates).not.toContain('--layout'); + } + ); - shellTest(`${shell} completes and exhausts finite positional values`, async () => { - expect(await runCompletion(["altertable", "completion", "install", "--no-rc", ""])).toEqual( - expect.arrayContaining(["bash", "fish", "zsh"]), - ); + shellTest( + `${shell} treats query show as a subcommand, not a SQL direct operand`, + async () => { + const showLeaf = await runCompletion([ + 'altertable', + 'query', + 'show', + '--layout', + 'table', + '--', + ]); + expect(showLeaf).not.toContain('--columns'); + expect(showLeaf).not.toContain('--layout'); + + const directSql = await runCompletion([ + 'altertable', + 'query', + 'SELECT 1', + '--', + ]); + expect(directSql).toContain('--columns'); + expect(directSql).toContain('--layout'); + + const nested = await runCompletion([ + 'altertable', + 'query', + 'show', + 'qry_1', + '--', + ]); + expect(nested).not.toContain('--columns'); + expect(nested).not.toContain('--layout'); + } + ); - const exhausted = await runCompletion(["altertable", "completion", "install", "zsh", ""]); - for (const shellValue of ["bash", "fish", "zsh"]) { - expect(exhausted).not.toContain(shellValue); + shellTest( + `${shell} completes and exhausts finite positional values`, + async () => { + expect( + await runCompletion([ + 'altertable', + 'completion', + 'install', + '--no-rc', + '', + ]) + ).toEqual(expect.arrayContaining(['bash', 'fish', 'zsh'])); + + const exhausted = await runCompletion([ + 'altertable', + 'completion', + 'install', + 'zsh', + '', + ]); + for (const shellValue of ['bash', 'fish', 'zsh']) { + expect(exhausted).not.toContain(shellValue); + } + expect( + await runCompletion([ + 'altertable', + 'completion', + 'install', + 'zsh', + '--', + ]) + ).toContain('--no-rc'); } - expect(await runCompletion(["altertable", "completion", "install", "zsh", "--"])).toContain( - "--no-rc", - ); - }); + ); - shellTest(`${shell} composes flags with pending positional candidates`, async () => { - const candidates = await runCompletion(["altertable", "completion", "install", "--"]); - expect(candidates).toEqual( - expect.arrayContaining(["--no-rc", "--json", "--profile", "--help"]), - ); - expect(candidates).not.toContain("--version"); - expect(await runCompletion(["altertable", "--"])).toContain("--version"); - }); + shellTest( + `${shell} composes flags with pending positional candidates`, + async () => { + const candidates = await runCompletion([ + 'altertable', + 'completion', + 'install', + '--', + ]); + expect(candidates).toEqual( + expect.arrayContaining(['--no-rc', '--json', '--profile', '--help']) + ); + expect(candidates).not.toContain('--version'); + expect(await runCompletion(['altertable', '--'])).toContain( + '--version' + ); + } + ); shellTest(`${shell} completes file positionals`, async () => { - expect(await runCompletion(["altertable", "upload", "package.j"])).toContain("package.json"); - expect(await runCompletion(["altertable", "upsert", "package.j"])).toContain("package.json"); + expect( + await runCompletion(['altertable', 'upload', 'package.j']) + ).toContain('package.json'); + expect( + await runCompletion(['altertable', 'upsert', 'package.j']) + ).toContain('package.json'); }); - shellTest(`${shell} handles global flags in every position and equals values`, async () => { - for (const globalFlag of globalFlagForms) { - const invocations = [ - ["altertable", ...globalFlag, "completion", "generate", ""], - ["altertable", "completion", ...globalFlag, "generate", ""], - ["altertable", "completion", "generate", ...globalFlag, ""], - ]; - for (const invocation of invocations) { - expect(await runCompletion(invocation)).toEqual( - expect.arrayContaining(["bash", "fish", "zsh"]), - ); + shellTest( + `${shell} handles global flags in every position and equals values`, + async () => { + for (const globalFlag of globalFlagForms) { + const invocations = [ + ['altertable', ...globalFlag, 'completion', 'generate', ''], + ['altertable', 'completion', ...globalFlag, 'generate', ''], + ['altertable', 'completion', 'generate', ...globalFlag, ''], + ]; + for (const invocation of invocations) { + expect(await runCompletion(invocation)).toEqual( + expect.arrayContaining(['bash', 'fish', 'zsh']) + ); + } } } - }); + ); shellTest(`${shell} completes finite flag values`, async () => { - expect(await runCompletion(["altertable", "query", "--layout", ""])).toEqual( - expect.arrayContaining(["auto", "table", "line"]), - ); + expect( + await runCompletion(['altertable', 'query', '--layout', '']) + ).toEqual(expect.arrayContaining(['auto', 'table', 'line'])); }); shellTest(`${shell} honors the option separator`, async () => { - expect(await runCompletion(["altertable", "completion", "generate", "--", ""])).toEqual( - expect.arrayContaining(["bash", "fish", "zsh"]), - ); + expect( + await runCompletion(['altertable', 'completion', 'generate', '--', '']) + ).toEqual(expect.arrayContaining(['bash', 'fish', 'zsh'])); }); } }); diff --git a/cli/src/commands/completion/lib/spec.ts b/cli/src/commands/completion/lib/spec.ts index 15608587..c8b1a9fe 100644 --- a/cli/src/commands/completion/lib/spec.ts +++ b/cli/src/commands/completion/lib/spec.ts @@ -1,11 +1,14 @@ -import type { Command } from "@/lib/command.ts"; -import type { CommandFlagScope, PositionalCompletionKind } from "@/lib/command.ts"; +import type { Command } from '@/lib/command.ts'; +import type { + CommandFlagScope, + PositionalCompletionKind, +} from '@/lib/command.ts'; import { resolveCommandDescriptor, visibleCommandDescriptors, type CommandArgumentDescriptor, type CommandDescriptor, -} from "@/lib/command-descriptor.ts"; +} from '@/lib/command-descriptor.ts'; /** * Static completion spec projected from the normalized command descriptor. @@ -55,30 +58,32 @@ export type CompletionContext = { /** Max subcommand depth below root (top-level = 1, e.g. api → connections → list). */ const MAX_SUBCOMMAND_DEPTH = 3; -function extractFlags(commandArguments: readonly CommandArgumentDescriptor[]): CompletionFlag[] { +function extractFlags( + commandArguments: readonly CommandArgumentDescriptor[] +): CompletionFlag[] { return commandArguments - .filter((argument) => ["boolean", "string", "enum"].includes(argument.type)) - .map((argument) => ({ + .filter(argument => ['boolean', 'string', 'enum'].includes(argument.type)) + .map(argument => ({ name: argument.name, alias: argument.aliases[0], description: argument.description || undefined, values: argument.values.length > 0 ? argument.values : undefined, - takesValue: argument.type === "string" || argument.type === "enum", + takesValue: argument.type === 'string' || argument.type === 'enum', scope: argument.scope, })) .sort((left, right) => left.name.localeCompare(right.name)); } function extractPositionals( - commandArguments: readonly CommandArgumentDescriptor[], + commandArguments: readonly CommandArgumentDescriptor[] ): CompletionPositional[] { return commandArguments - .filter((argument) => argument.type === "positional") - .map((argument) => ({ + .filter(argument => argument.type === 'positional') + .map(argument => ({ name: argument.name, description: argument.description || undefined, required: argument.required, - completion: argument.positionalCompletion ?? "freeform", + completion: argument.positionalCompletion ?? 'freeform', values: argument.values, })); } @@ -92,17 +97,17 @@ function publicCommandNames(descriptor: CommandDescriptor): string[] { function buildCompletionNode( descriptor: CommandDescriptor, name: string, - depth: number, + depth: number ): CompletionNode { const metadata = descriptor.metadata; const subcommands = depth >= MAX_SUBCOMMAND_DEPTH ? [] : visibleCommandDescriptors(descriptor.subcommands) - .flatMap((subcommand) => - publicCommandNames(subcommand).map((subcommandName) => - buildCompletionNode(subcommand, subcommandName, depth + 1), - ), + .flatMap(subcommand => + publicCommandNames(subcommand).map(subcommandName => + buildCompletionNode(subcommand, subcommandName, depth + 1) + ) ) .sort((left, right) => left.name.localeCompare(right.name)); @@ -116,22 +121,34 @@ function buildCompletionNode( }; } -export function buildCompletionSpecFromDescriptor(descriptor: CommandDescriptor): CompletionNode { - return buildCompletionNode(descriptor, descriptor.metadata.name ?? "altertable", 0); +export function buildCompletionSpecFromDescriptor( + descriptor: CommandDescriptor +): CompletionNode { + return buildCompletionNode( + descriptor, + descriptor.metadata.name ?? 'altertable', + 0 + ); } -export async function buildCompletionSpec(root: Command): Promise { - return buildCompletionSpecFromDescriptor(await resolveCommandDescriptor(root)); +export async function buildCompletionSpec( + root: Command +): Promise { + return buildCompletionSpecFromDescriptor( + await resolveCommandDescriptor(root) + ); } -export function collectCompletionContexts(root: CompletionNode): CompletionContext[] { +export function collectCompletionContexts( + root: CompletionNode +): CompletionContext[] { const contexts: CompletionContext[] = []; function walk(node: CompletionNode, segments: string[]): void { contexts.push({ segments: [...segments], flags: node.flags, - subcommands: node.subcommands.map((child) => child.name), + subcommands: node.subcommands.map(child => child.name), positionals: node.positionals, soleDirectOperands: node.soleDirectOperands, }); diff --git a/cli/src/commands/doctor/index.test.ts b/cli/src/commands/doctor/index.test.ts index f234216b..9d165b94 100644 --- a/cli/src/commands/doctor/index.test.ts +++ b/cli/src/commands/doctor/index.test.ts @@ -1,48 +1,52 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { configFile, credentialsFile, kvSet } from "@/lib/config.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { configFile, credentialsFile, kvSet } from '@/lib/config.ts'; -let testHome = ""; -let mockFile = ""; +let testHome = ''; +let mockFile = ''; let stdinIsTty: PropertyDescriptor | undefined; const VALID_WHOAMI = { principal: { - id: "user-1", - type: "User", - name: "Jane", - email: "jane@example.com", + id: 'user-1', + type: 'User', + name: 'Jane', + email: 'jane@example.com', }, organization: { - id: "org-1", - name: "Acme", - slug: "acme", + id: 'org-1', + name: 'Acme', + slug: 'acme', }, - authentication_scope: "user", + authentication_scope: 'user', }; -const VALID_LAKEHOUSE_PROBE = ['{"statement":"SELECT 1"}', '["result"]', "[1]"].join("\n"); +const VALID_LAKEHOUSE_PROBE = [ + '{"statement":"SELECT 1"}', + '["result"]', + '[1]', +].join('\n'); beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-doctor-test-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-doctor-test-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); }); afterEach(() => { if (stdinIsTty) { - Object.defineProperty(process.stdin, "isTTY", stdinIsTty); + Object.defineProperty(process.stdin, 'isTTY', stdinIsTty); } else { - Reflect.deleteProperty(process.stdin, "isTTY"); + Reflect.deleteProperty(process.stdin, 'isTTY'); } rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; @@ -50,41 +54,47 @@ afterEach(() => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; }); -describe("doctor command", () => { - test("reports local findings without materializing the default profile", async () => { - const harness = await runCommandWithTestRuntime(["doctor", "--offline"]); - const report = JSON.parse(harness.stdout[0] ?? ""); +describe('doctor command', () => { + test('reports local findings without materializing the default profile', async () => { + const harness = await runCommandWithTestRuntime(['doctor', '--offline']); + const report = JSON.parse(harness.stdout[0] ?? ''); expect(report).toMatchObject({ healthy: false, - profile: "default", + profile: 'default', summary: { passed: 3, failed: 2, skipped: 2 }, }); expect(harness.exitCode).toBe(1); - expect(await Bun.file(join(testHome, "config")).exists()).toBe(false); - expect(await Bun.file(join(testHome, "profiles", "default", "config")).exists()).toBe(false); + expect(await Bun.file(join(testHome, 'config')).exists()).toBe(false); + expect( + await Bun.file(join(testHome, 'profiles', 'default', 'config')).exists() + ).toBe(false); }); - test("runs both read-only API probes for a configured profile", async () => { - const runtime = createCliRuntime({ debug: false, json: true, agent: false }); + test('runs both read-only API probes for a configured profile', async () => { + const runtime = createCliRuntime({ + debug: false, + json: true, + agent: false, + }); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - await configureRunSet({ user: "alice", password: "secret" }); + await configureRunSet({ apiKey: 'atm_test', env: 'production' }); + await configureRunSet({ user: 'alice', password: 'secret' }); }); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', body: JSON.stringify(VALID_WHOAMI), }, - { urlPattern: "/query", method: "POST", body: VALID_LAKEHOUSE_PROBE }, - ]), + { urlPattern: '/query', method: 'POST', body: VALID_LAKEHOUSE_PROBE }, + ]) ); - const harness = await runCommandWithTestRuntime(["doctor"]); - const report = JSON.parse(harness.stdout[0] ?? ""); + const harness = await runCommandWithTestRuntime(['doctor']); + const report = JSON.parse(harness.stdout[0] ?? ''); expect(report).toMatchObject({ healthy: true, @@ -94,21 +104,23 @@ describe("doctor command", () => { expect(report.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "management.api", - status: "pass", - message: expect.stringContaining("Jane "), + id: 'management.api', + status: 'pass', + message: expect.stringContaining('Jane '), }), - expect.objectContaining({ id: "lakehouse.api", status: "pass" }), - ]), + expect.objectContaining({ id: 'lakehouse.api', status: 'pass' }), + ]) ); }); - test("attributes credential backend failures to the secret store", async () => { - writeFileSync(credentialsFile(), "profile/default/api-key=atm_test\n", { mode: 0o644 }); + test('attributes credential backend failures to the secret store', async () => { + writeFileSync(credentialsFile(), 'profile/default/api-key=atm_test\n', { + mode: 0o644, + }); chmodSync(credentialsFile(), 0o644); - const harness = await runCommandWithTestRuntime(["doctor", "--offline"]); - const report = JSON.parse(harness.stdout[0] ?? ""); + const harness = await runCommandWithTestRuntime(['doctor', '--offline']); + const report = JSON.parse(harness.stdout[0] ?? ''); expect(report).toMatchObject({ healthy: false, @@ -116,64 +128,71 @@ describe("doctor command", () => { }); expect(report.checks).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: "profile.configuration", status: "pass" }), expect.objectContaining({ - id: "credentials.store", - status: "fail", - message: expect.stringContaining("permissions 644 are too open"), + id: 'profile.configuration', + status: 'pass', }), expect.objectContaining({ - id: "management.credentials", - status: "skipped", - message: "Blocked by secret store.", + id: 'credentials.store', + status: 'fail', + message: expect.stringContaining('permissions 644 are too open'), }), - ]), + expect.objectContaining({ + id: 'management.credentials', + status: 'skipped', + message: 'Blocked by secret store.', + }), + ]) ); }); - test("reports a stale active profile instead of selecting the default", async () => { - kvSet(configFile(), "active_profile", "missing"); + test('reports a stale active profile instead of selecting the default', async () => { + kvSet(configFile(), 'active_profile', 'missing'); - const harness = await runCommandWithTestRuntime(["doctor", "--offline"]); - const report = JSON.parse(harness.stdout[0] ?? ""); + const harness = await runCommandWithTestRuntime(['doctor', '--offline']); + const report = JSON.parse(harness.stdout[0] ?? ''); expect(report).toMatchObject({ healthy: false, - profile: "missing", + profile: 'missing', summary: { passed: 1, failed: 1, skipped: 5 }, }); expect(report.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "profile.configuration", - status: "fail", - message: "Profile not found: missing", + id: 'profile.configuration', + status: 'fail', + message: 'Profile not found: missing', }), expect.objectContaining({ - id: "credentials.store", - status: "skipped", - message: "Blocked by profile.", + id: 'credentials.store', + status: 'skipped', + message: 'Blocked by profile.', }), - ]), + ]) ); }); - test("rejects malformed successful API responses", async () => { - const runtime = createCliRuntime({ debug: false, json: true, agent: false }); + test('rejects malformed successful API responses', async () => { + const runtime = createCliRuntime({ + debug: false, + json: true, + agent: false, + }); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - await configureRunSet({ user: "alice", password: "secret" }); + await configureRunSet({ apiKey: 'atm_test', env: 'production' }); + await configureRunSet({ user: 'alice', password: 'secret' }); }); writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/whoami", method: "GET", body: "{}" }, - { urlPattern: "/query", method: "POST", body: "{}" }, - ]), + { urlPattern: '/whoami', method: 'GET', body: '{}' }, + { urlPattern: '/query', method: 'POST', body: '{}' }, + ]) ); - const harness = await runCommandWithTestRuntime(["doctor"]); - const report = JSON.parse(harness.stdout[0] ?? ""); + const harness = await runCommandWithTestRuntime(['doctor']); + const report = JSON.parse(harness.stdout[0] ?? ''); expect(report).toMatchObject({ healthy: false, @@ -182,75 +201,96 @@ describe("doctor command", () => { expect(report.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "management.api", - status: "fail", - code: "parse_error", - details: "Expected principal and organization objects.", + id: 'management.api', + status: 'fail', + code: 'parse_error', + details: 'Expected principal and organization objects.', }), expect.objectContaining({ - id: "lakehouse.api", - status: "fail", - code: "parse_error", - details: "Expected SELECT 1 to return the numeric value 1.", + id: 'lakehouse.api', + status: 'fail', + code: 'parse_error', + details: 'Expected SELECT 1 to return the numeric value 1.', }), - ]), + ]) ); const remediation = report.checks.flatMap( - (check: { remediation?: string[] }) => check.remediation ?? [], + (check: { remediation?: string[] }) => check.remediation ?? [] ); expect(remediation).toContain( - `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env `, + `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env ` ); expect(remediation).toContain( - `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin`, + `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin` + ); + expect(remediation).not.toContain('Run: altertable login'); + expect(remediation.every((line: string) => !line.includes('--scope'))).toBe( + true ); - expect(remediation).not.toContain("Run: altertable login"); - expect(remediation.every((line: string) => !line.includes("--scope"))).toBe(true); }); - test("renders non-interactive remediation when stdin is not a TTY", async () => { - Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); - const harness = await runCommandWithTestRuntime(["doctor", "--offline"], { + test('renders non-interactive remediation when stdin is not a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + const harness = await runCommandWithTestRuntime(['doctor', '--offline'], { debug: false, json: false, agent: false, noColor: true, }); - expect(harness.stdout[0]).toContain("ALTERTABLE CLI DOCTOR"); - expect(harness.stdout[0]).toContain("--api-key-stdin --env "); - expect(harness.stdout[0]).toContain("--user --password-stdin"); - expect(harness.stdout[0]).toContain("ALTERTABLE_API_KEY and ALTERTABLE_ENV"); - expect(harness.stdout[0]).not.toContain("altertable login"); - expect(harness.stdout[0]).not.toContain("--scope"); - expect(harness.stdout[0]).toContain("Result: unhealthy"); + expect(harness.stdout[0]).toContain('ALTERTABLE CLI DOCTOR'); + expect(harness.stdout[0]).toContain('--api-key-stdin --env '); + expect(harness.stdout[0]).toContain('--user --password-stdin'); + expect(harness.stdout[0]).toContain( + 'ALTERTABLE_API_KEY and ALTERTABLE_ENV' + ); + expect(harness.stdout[0]).not.toContain('altertable login'); + expect(harness.stdout[0]).not.toContain('--scope'); + expect(harness.stdout[0]).toContain('Result: unhealthy'); }); - test("preserves login-first remediation for an interactive human terminal", async () => { - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - const harness = await runCommandWithTestRuntime(["doctor", "--offline"], { + test('preserves login-first remediation for an interactive human terminal', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + const harness = await runCommandWithTestRuntime(['doctor', '--offline'], { debug: false, json: false, agent: false, noColor: true, }); - expect(harness.stdout[0]).toContain("Run: altertable login"); - expect(harness.stdout[0]).toContain("altertable profile configure --scope management"); - expect(harness.stdout[0]).toContain("altertable profile configure --scope lakehouse"); + expect(harness.stdout[0]).toContain('Run: altertable login'); + expect(harness.stdout[0]).toContain( + 'altertable profile configure --scope management' + ); + expect(harness.stdout[0]).toContain( + 'altertable profile configure --scope lakehouse' + ); }); - test("uses non-interactive remediation for agent output even with a TTY", async () => { - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - const harness = await runCommandWithTestRuntime(["--agent", "doctor", "--offline"]); - const report = JSON.parse(harness.stdout[0] ?? ""); + test('uses non-interactive remediation for agent output even with a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + const harness = await runCommandWithTestRuntime([ + '--agent', + 'doctor', + '--offline', + ]); + const report = JSON.parse(harness.stdout[0] ?? ''); const remediation = report.checks.flatMap( - (check: { remediation?: string[] }) => check.remediation ?? [], + (check: { remediation?: string[] }) => check.remediation ?? [] ); expect(remediation).toContain( - `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env `, + `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env ` ); - expect(remediation).not.toContain("Run: altertable login"); + expect(remediation).not.toContain('Run: altertable login'); }); }); diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index d50584e4..9ed092fe 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -1,37 +1,45 @@ -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { createDoctorChecks } from "@/commands/doctor/lib/checks.ts"; -import { formatDoctorReport } from "@/commands/doctor/lib/render.ts"; -import { runDoctorChecks } from "@/commands/doctor/lib/runner.ts"; -import { createDiagnosticExecutionContext } from "@/lib/execution-context.ts"; -import { EXIT_GENERIC, EXIT_SUCCESS } from "@/lib/errors.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { createDoctorChecks } from '@/commands/doctor/lib/checks.ts'; +import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; +import { runDoctorChecks } from '@/commands/doctor/lib/runner.ts'; +import { createDiagnosticExecutionContext } from '@/lib/execution-context.ts'; +import { EXIT_GENERIC, EXIT_SUCCESS } from '@/lib/errors.ts'; export const doctorCommand = defineCommand({ metadata: { - name: "doctor", - commandGroup: "platform", - description: "Diagnose local configuration and Altertable connectivity.", - examples: ["altertable doctor", "altertable doctor --offline", "altertable --json doctor"], + name: 'doctor', + commandGroup: 'platform', + description: 'Diagnose local configuration and Altertable connectivity.', + examples: [ + 'altertable doctor', + 'altertable doctor --offline', + 'altertable --json doctor', + ], }, args: { offline: { - type: "boolean", - description: "Inspect local configuration without contacting Altertable APIs.", + type: 'boolean', + description: + 'Inspect local configuration without contacting Altertable APIs.', }, }, async run({ args, runtime, sink }) { const report = await runDoctorChecks(createDoctorChecks(), { execution: createDiagnosticExecutionContext(runtime), offline: args.offline === true, - interactive: !runtime.context.json && !runtime.context.agent && process.stdin.isTTY === true, + interactive: + !runtime.context.json && + !runtime.context.agent && + process.stdin.isTTY === true, }); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: report, humanText: formatDoctorReport(report), }, - sink, + sink ); return { exitCode: report.healthy ? EXIT_SUCCESS : EXIT_GENERIC }; }, diff --git a/cli/src/commands/doctor/lib/checks.ts b/cli/src/commands/doctor/lib/checks.ts index 46a70490..81e88ca1 100644 --- a/cli/src/commands/doctor/lib/checks.ts +++ b/cli/src/commands/doctor/lib/checks.ts @@ -1,43 +1,55 @@ -import { VERSION } from "@/version.ts"; +import { VERSION } from '@/version.ts'; import { detectConfiguredProfileAuth, readProfileConfiguration, type ProfileAuth, -} from "@/lib/profile/model.ts"; -import { envConfigMode } from "@/lib/profile-store.ts"; -import { readEnv } from "@/lib/env.ts"; -import { configGetGlobal, resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; -import { secretStoreDisplay } from "@/lib/secrets.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { buildLakehouseVerifyRequest } from "@/lib/lakehouse/query.ts"; -import { parseWhoamiResponse } from "@/lib/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/lib/management/render.ts"; -import { parseLakehouseQueryResponse, type LakehouseRow } from "@/lib/lakehouse-ndjson.ts"; -import { ParseError } from "@/lib/errors.ts"; +} from '@/lib/profile/model.ts'; +import { envConfigMode } from '@/lib/profile-store.ts'; +import { readEnv } from '@/lib/env.ts'; +import { + configGetGlobal, + resolveApiBase, + resolveManagementApiBase, +} from '@/lib/config.ts'; +import { secretStoreDisplay } from '@/lib/secrets.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { buildLakehouseVerifyRequest } from '@/lib/lakehouse/query.ts'; +import { parseWhoamiResponse } from '@/lib/management/model.ts'; +import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; +import { + parseLakehouseQueryResponse, + type LakehouseRow, +} from '@/lib/lakehouse-ndjson.ts'; +import { ParseError } from '@/lib/errors.ts'; import type { DoctorCheck, DoctorCheckContext, DoctorCheckOutcome, -} from "@/commands/doctor/lib/model.ts"; +} from '@/commands/doctor/lib/model.ts'; -function passOutcome(message: string, details?: Record): DoctorCheckOutcome { - return { status: "pass", message, details }; +function passOutcome( + message: string, + details?: Record +): DoctorCheckOutcome { + return { status: 'pass', message, details }; } function failOutcome( message: string, code: string, remediation: string[], - details?: Record, + details?: Record ): DoctorCheckOutcome { - return { status: "fail", message, code, remediation, details }; + return { status: 'fail', message, code, remediation, details }; } function profileSource(context: DoctorCheckContext): string { - if (envConfigMode()) return "environment configuration"; - if (context.execution.cli.profile) return "--profile"; - if (readEnv("ALTERTABLE_PROFILE")) return "ALTERTABLE_PROFILE"; - return configGetGlobal("active_profile") ? "active profile" : "default profile"; + if (envConfigMode()) return 'environment configuration'; + if (context.execution.cli.profile) return '--profile'; + if (readEnv('ALTERTABLE_PROFILE')) return 'ALTERTABLE_PROFILE'; + return configGetGlobal('active_profile') + ? 'active profile' + : 'default profile'; } function formatManagementIdentity(body: string): string { @@ -51,62 +63,74 @@ function rowContainsProbeValue(row: LakehouseRow): boolean { function validateLakehouseProbeResponse(body: string): void { const result = parseLakehouseQueryResponse(body); if (!result.rows.some(rowContainsProbeValue)) { - throw new ParseError("Lakehouse probe returned an unexpected result.", { - details: "Expected SELECT 1 to return the numeric value 1.", + throw new ParseError('Lakehouse probe returned an unexpected result.', { + details: 'Expected SELECT 1 to return the numeric value 1.', }); } } -function managementCredentialRemediation(context: DoctorCheckContext): string[] { +function managementCredentialRemediation( + context: DoctorCheckContext +): string[] { if (context.interactive) { - return ["Run: altertable login", "Or run: altertable profile configure --scope management"]; + return [ + 'Run: altertable login', + 'Or run: altertable profile configure --scope management', + ]; } return [ `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env `, - "Or set: ALTERTABLE_API_KEY and ALTERTABLE_ENV", + 'Or set: ALTERTABLE_API_KEY and ALTERTABLE_ENV', ]; } function lakehouseCredentialRemediation(context: DoctorCheckContext): string[] { if (context.interactive) { - return ["Run: altertable login", "Or run: altertable profile configure --scope lakehouse"]; + return [ + 'Run: altertable login', + 'Or run: altertable profile configure --scope lakehouse', + ]; } return [ `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin`, - "Or set: ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME and ALTERTABLE_LAKEHOUSE_PASSWORD", + 'Or set: ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME and ALTERTABLE_LAKEHOUSE_PASSWORD', ]; } function checkManagementCredentialPresence( auth: ProfileAuth, - context: DoctorCheckContext, + context: DoctorCheckContext ): DoctorCheckOutcome { - if (auth.management === "none") { + if (auth.management === 'none') { return failOutcome( - "No management credentials configured.", - "configuration_error", - managementCredentialRemediation(context), + 'No management credentials configured.', + 'configuration_error', + managementCredentialRemediation(context) ); } - return passOutcome(`Configured (${auth.management}).`, { auth: auth.management }); + return passOutcome(`Configured (${auth.management}).`, { + auth: auth.management, + }); } function checkLakehouseCredentialPresence( auth: ProfileAuth, - context: DoctorCheckContext, + context: DoctorCheckContext ): DoctorCheckOutcome { - if (auth.lakehouse === "none") { + if (auth.lakehouse === 'none') { return failOutcome( - "No lakehouse credentials configured.", - "configuration_error", - lakehouseCredentialRemediation(context), + 'No lakehouse credentials configured.', + 'configuration_error', + lakehouseCredentialRemediation(context) ); } - return passOutcome(`Configured (${auth.lakehouse}).`, { auth: auth.lakehouse }); + return passOutcome(`Configured (${auth.lakehouse}).`, { + auth: auth.lakehouse, + }); } function offlineSkipReason(context: DoctorCheckContext): string | undefined { - return context.offline ? "Offline mode." : undefined; + return context.offline ? 'Offline mode.' : undefined; } export function createDoctorChecks(): DoctorCheck[] { @@ -114,15 +138,17 @@ export function createDoctorChecks(): DoctorCheck[] { function requireProfileAuth(): ProfileAuth { if (!profileAuth) { - throw new Error("Secret store check did not inspect profile authentication."); + throw new Error( + 'Secret store check did not inspect profile authentication.' + ); } return profileAuth; } return [ { - id: "cli.runtime", - label: "CLI", + id: 'cli.runtime', + label: 'CLI', run: () => passOutcome(`v${VERSION} · ${process.platform} ${process.arch}`, { version: VERSION, @@ -132,9 +158,9 @@ export function createDoctorChecks(): DoctorCheck[] { }), }, { - id: "profile.configuration", - label: "Profile", - run: (context) => { + id: 'profile.configuration', + label: 'Profile', + run: context => { const current = readProfileConfiguration(context.execution.profile); const source = profileSource(context); return passOutcome(`${current.name} (${source})`, { @@ -149,74 +175,76 @@ export function createDoctorChecks(): DoctorCheck[] { }, }); }, - remediation: () => ["Run: altertable profile show"], + remediation: () => ['Run: altertable profile show'], }, { - id: "credentials.store", - label: "Secret store", - requires: ["profile.configuration"], - run: (context) => { + id: 'credentials.store', + label: 'Secret store', + requires: ['profile.configuration'], + run: context => { profileAuth = detectConfiguredProfileAuth(context.execution.profile); if (envConfigMode()) { - return passOutcome("Credentials supplied by environment variables."); + return passOutcome('Credentials supplied by environment variables.'); } const store = secretStoreDisplay(); return passOutcome(`${store} accessible.`, { store }); }, - remediation: () => ["Run: altertable profile show"], + remediation: () => ['Run: altertable profile show'], }, { - id: "management.credentials", - label: "Management auth", - requires: ["credentials.store"], - run: (context) => checkManagementCredentialPresence(requireProfileAuth(), context), + id: 'management.credentials', + label: 'Management auth', + requires: ['credentials.store'], + run: context => + checkManagementCredentialPresence(requireProfileAuth(), context), }, { - id: "management.api", - label: "Management API", - requires: ["management.credentials"], + id: 'management.api', + label: 'Management API', + requires: ['management.credentials'], skip: offlineSkipReason, async run(context) { const endpoint = resolveManagementApiBase(context.execution.profile); const body = await sendHttp( { - plane: "management", - method: "GET", - endpoint: "/whoami", + plane: 'management', + method: 'GET', + endpoint: '/whoami', authRecovery: false, }, - context.execution, + context.execution ); const identity = formatManagementIdentity(body); return passOutcome(`${endpoint} · ${identity}`, { endpoint, identity }); }, - remediation: (context) => [ - "Check the control-plane URL and management credentials.", + remediation: context => [ + 'Check the control-plane URL and management credentials.', ...managementCredentialRemediation(context), ], }, { - id: "lakehouse.credentials", - label: "Lakehouse auth", - requires: ["credentials.store"], - run: (context) => checkLakehouseCredentialPresence(requireProfileAuth(), context), + id: 'lakehouse.credentials', + label: 'Lakehouse auth', + requires: ['credentials.store'], + run: context => + checkLakehouseCredentialPresence(requireProfileAuth(), context), }, { - id: "lakehouse.api", - label: "Lakehouse API", - requires: ["lakehouse.credentials"], + id: 'lakehouse.api', + label: 'Lakehouse API', + requires: ['lakehouse.credentials'], skip: offlineSkipReason, async run(context) { const endpoint = resolveApiBase(context.execution.profile); const body = await sendHttp( { ...buildLakehouseVerifyRequest(), authRecovery: false }, - context.execution, + context.execution ); validateLakehouseProbeResponse(body); return passOutcome(`${endpoint} · SELECT 1 succeeded.`, { endpoint }); }, - remediation: (context) => [ - "Check the data-plane URL and lakehouse credentials.", + remediation: context => [ + 'Check the data-plane URL and lakehouse credentials.', ...lakehouseCredentialRemediation(context), ], }, diff --git a/cli/src/commands/doctor/lib/model.ts b/cli/src/commands/doctor/lib/model.ts index 42462879..7b350de5 100644 --- a/cli/src/commands/doctor/lib/model.ts +++ b/cli/src/commands/doctor/lib/model.ts @@ -1,6 +1,6 @@ -import type { ExecutionContext } from "@/lib/execution-context.ts"; +import type { ExecutionContext } from '@/lib/execution-context.ts'; -export type DoctorCheckStatus = "pass" | "warn" | "fail" | "skipped"; +export type DoctorCheckStatus = 'pass' | 'warn' | 'fail' | 'skipped'; export type DoctorCheckResult = { id: string; @@ -34,13 +34,15 @@ export type DoctorCheckContext = { interactive: boolean; }; -export type DoctorCheckOutcome = Omit; +export type DoctorCheckOutcome = Omit; export type DoctorCheck = { id: string; label: string; requires?: readonly string[]; skip?: (context: DoctorCheckContext) => string | undefined; - run: (context: DoctorCheckContext) => DoctorCheckOutcome | Promise; + run: ( + context: DoctorCheckContext + ) => DoctorCheckOutcome | Promise; remediation?: (context: DoctorCheckContext) => string[]; }; diff --git a/cli/src/commands/doctor/lib/render.test.ts b/cli/src/commands/doctor/lib/render.test.ts index 3ea0b999..7c6758ef 100644 --- a/cli/src/commands/doctor/lib/render.test.ts +++ b/cli/src/commands/doctor/lib/render.test.ts @@ -1,26 +1,26 @@ -import { describe, expect, test } from "bun:test"; -import { formatDoctorReport } from "@/commands/doctor/lib/render.ts"; +import { describe, expect, test } from 'bun:test'; +import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; -describe("formatDoctorReport", () => { - test("renders structured failure status and details", () => { +describe('formatDoctorReport', () => { + test('renders structured failure status and details', () => { const output = formatDoctorReport({ healthy: false, - profile: "default", + profile: 'default', summary: { passed: 0, warnings: 0, failed: 1, skipped: 0 }, checks: [ { - id: "management.api", - label: "Management API", - status: "fail", - message: "Authentication failed.", - code: "auth_failed", + id: 'management.api', + label: 'Management API', + status: 'fail', + message: 'Authentication failed.', + code: 'auth_failed', http_status: 401, - details: "Token expired", + details: 'Token expired', }, ], }); - expect(output).toContain("HTTP status: 401"); - expect(output).toContain("Details: Token expired"); + expect(output).toContain('HTTP status: 401'); + expect(output).toContain('Details: Token expired'); }); }); diff --git a/cli/src/commands/doctor/lib/render.ts b/cli/src/commands/doctor/lib/render.ts index 35eea9f7..5a0d3494 100644 --- a/cli/src/commands/doctor/lib/render.ts +++ b/cli/src/commands/doctor/lib/render.ts @@ -2,40 +2,55 @@ import type { DoctorCheckResult, DoctorCheckStatus, DoctorReport, -} from "@/commands/doctor/lib/model.ts"; -import { span, type DisplayTextStyle } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +} from '@/commands/doctor/lib/model.ts'; +import { span, type DisplayTextStyle } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; -const STATUS_DISPLAY: Record = { - pass: { icon: "✓", style: "success" }, - warn: { icon: "!", style: "warning" }, - fail: { icon: "✗", style: "error" }, - skipped: { icon: "-", style: "muted" }, +const STATUS_DISPLAY: Record< + DoctorCheckStatus, + { icon: string; style: DisplayTextStyle } +> = { + pass: { icon: '✓', style: 'success' }, + warn: { icon: '!', style: 'warning' }, + fail: { icon: '✗', style: 'error' }, + skipped: { icon: '-', style: 'muted' }, }; -function formatCheckResult(check: DoctorCheckResult, labelWidth: number): string[] { +function formatCheckResult( + check: DoctorCheckResult, + labelWidth: number +): string[] { const display = STATUS_DISPLAY[check.status]; const lines = [ renderDisplayText([ span(display.icon, display.style), - span(` ${check.label.padEnd(labelWidth)} `, "strong"), - span(check.message, check.status === "skipped" ? "muted" : undefined), + span(` ${check.label.padEnd(labelWidth)} `, 'strong'), + span(check.message, check.status === 'skipped' ? 'muted' : undefined), ]), ]; if (check.http_status !== undefined) { lines.push( renderDisplayText([ - span(`${" ".repeat(labelWidth + 4)}HTTP status: ${check.http_status}`, "muted"), - ]), + span( + `${' '.repeat(labelWidth + 4)}HTTP status: ${check.http_status}`, + 'muted' + ), + ]) ); } - if (typeof check.details === "string") { + if (typeof check.details === 'string') { lines.push( - renderDisplayText([span(`${" ".repeat(labelWidth + 4)}Details: ${check.details}`, "muted")]), + renderDisplayText([ + span(`${' '.repeat(labelWidth + 4)}Details: ${check.details}`, 'muted'), + ]) ); } for (const remediation of check.remediation ?? []) { - lines.push(renderDisplayText([span(`${" ".repeat(labelWidth + 4)}${remediation}`, "muted")])); + lines.push( + renderDisplayText([ + span(`${' '.repeat(labelWidth + 4)}${remediation}`, 'muted'), + ]) + ); } return lines; } @@ -43,25 +58,31 @@ function formatCheckResult(check: DoctorCheckResult, labelWidth: number): string function formatSummary(report: DoctorReport): string { const values = [ `${report.summary.passed} passed`, - report.summary.warnings > 0 ? `${report.summary.warnings} warned` : undefined, + report.summary.warnings > 0 + ? `${report.summary.warnings} warned` + : undefined, report.summary.failed > 0 ? `${report.summary.failed} failed` : undefined, - report.summary.skipped > 0 ? `${report.summary.skipped} skipped` : undefined, + report.summary.skipped > 0 + ? `${report.summary.skipped} skipped` + : undefined, ].filter((value): value is string => value !== undefined); - const style: DisplayTextStyle = report.healthy ? "success" : "error"; + const style: DisplayTextStyle = report.healthy ? 'success' : 'error'; return renderDisplayText([ - span("Result: ", "strong"), - span(report.healthy ? "healthy" : "unhealthy", style), - span(` · ${values.join(", ")}`, "muted"), + span('Result: ', 'strong'), + span(report.healthy ? 'healthy' : 'unhealthy', style), + span(` · ${values.join(', ')}`, 'muted'), ]); } export function formatDoctorReport(report: DoctorReport): string { - const labelWidth = Math.max(...report.checks.map((check) => check.label.length)); + const labelWidth = Math.max( + ...report.checks.map(check => check.label.length) + ); return [ - renderDisplayText([span("Altertable CLI doctor", "heading")]), - "", - ...report.checks.flatMap((check) => formatCheckResult(check, labelWidth)), - "", + renderDisplayText([span('Altertable CLI doctor', 'heading')]), + '', + ...report.checks.flatMap(check => formatCheckResult(check, labelWidth)), + '', formatSummary(report), - ].join("\n"); + ].join('\n'); } diff --git a/cli/src/commands/doctor/lib/runner.test.ts b/cli/src/commands/doctor/lib/runner.test.ts index 1e0d6a32..fb0502c6 100644 --- a/cli/src/commands/doctor/lib/runner.test.ts +++ b/cli/src/commands/doctor/lib/runner.test.ts @@ -1,14 +1,17 @@ -import { describe, expect, test } from "bun:test"; -import { ConfigurationError, HttpError } from "@/lib/errors.ts"; -import type { DoctorCheck, DoctorCheckContext } from "@/commands/doctor/lib/model.ts"; -import { runDoctorChecks } from "@/commands/doctor/lib/runner.ts"; +import { describe, expect, test } from 'bun:test'; +import { ConfigurationError, HttpError } from '@/lib/errors.ts'; +import type { + DoctorCheck, + DoctorCheckContext, +} from '@/commands/doctor/lib/model.ts'; +import { runDoctorChecks } from '@/commands/doctor/lib/runner.ts'; function createDoctorContext(offline = false): DoctorCheckContext { return { offline, interactive: false, execution: { - profile: "test", + profile: 'test', cli: { debug: false, json: true, agent: false }, output: { json: true, @@ -24,218 +27,238 @@ function createDoctorContext(offline = false): DoctorCheckContext { }; } -async function expectRejection(promise: Promise, message: string): Promise { +async function expectRejection( + promise: Promise, + message: string +): Promise { const error = await promise.then( () => undefined, - (thrown: unknown) => thrown, + (thrown: unknown) => thrown ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain(message); } -describe("runDoctorChecks", () => { - test("aggregates outcomes and isolates thrown check errors", async () => { +describe('runDoctorChecks', () => { + test('aggregates outcomes and isolates thrown check errors', async () => { const checks: DoctorCheck[] = [ { - id: "passing", - label: "Passing", - run: () => ({ status: "pass", message: "Ready." }), + id: 'passing', + label: 'Passing', + run: () => ({ status: 'pass', message: 'Ready.' }), }, { - id: "failing", - label: "Failing", + id: 'failing', + label: 'Failing', run() { - throw new ConfigurationError("Missing."); + throw new ConfigurationError('Missing.'); }, - remediation: (context) => { - expect(context.execution.profile).toBe("test"); - return ["Configure it."]; + remediation: context => { + expect(context.execution.profile).toBe('test'); + return ['Configure it.']; }, }, { - id: "dependent", - label: "Dependent", - requires: ["failing"], - run: () => ({ status: "pass", message: "Should not run." }), + id: 'dependent', + label: 'Dependent', + requires: ['failing'], + run: () => ({ status: 'pass', message: 'Should not run.' }), }, ]; const report = await runDoctorChecks(checks, createDoctorContext()); expect(report.healthy).toBe(false); - expect(report.summary).toEqual({ passed: 1, warnings: 0, failed: 1, skipped: 1 }); + expect(report.summary).toEqual({ + passed: 1, + warnings: 0, + failed: 1, + skipped: 1, + }); expect(report.checks[1]).toMatchObject({ - id: "failing", - status: "fail", - code: "configuration_error", - message: "Missing.", - remediation: ["Configure it."], + id: 'failing', + status: 'fail', + code: 'configuration_error', + message: 'Missing.', + remediation: ['Configure it.'], }); expect(report.checks[2]).toMatchObject({ - status: "skipped", - message: "Blocked by failing.", + status: 'skipped', + message: 'Blocked by failing.', }); }); - test("supports intentional skips without making the report unhealthy", async () => { + test('supports intentional skips without making the report unhealthy', async () => { const report = await runDoctorChecks( [ { - id: "network", - label: "Network", - skip: ({ offline }) => (offline ? "Offline mode." : undefined), - run: () => ({ status: "pass", message: "Connected." }), + id: 'network', + label: 'Network', + skip: ({ offline }) => (offline ? 'Offline mode.' : undefined), + run: () => ({ status: 'pass', message: 'Connected.' }), }, ], - createDoctorContext(true), + createDoctorContext(true) ); expect(report.healthy).toBe(true); expect(report.summary.skipped).toBe(1); - expect(report.checks[0]).toMatchObject({ status: "skipped", message: "Offline mode." }); + expect(report.checks[0]).toMatchObject({ + status: 'skipped', + message: 'Offline mode.', + }); }); - test("keeps warning-only reports healthy", async () => { + test('keeps warning-only reports healthy', async () => { const report = await runDoctorChecks( [ { - id: "warning", - label: "Warning", - run: () => ({ status: "warn", message: "Degraded but usable." }), + id: 'warning', + label: 'Warning', + run: () => ({ status: 'warn', message: 'Degraded but usable.' }), }, ], - createDoctorContext(), + createDoctorContext() ); expect(report.healthy).toBe(true); - expect(report.summary).toEqual({ passed: 0, warnings: 1, failed: 0, skipped: 0 }); + expect(report.summary).toEqual({ + passed: 0, + warnings: 1, + failed: 0, + skipped: 0, + }); }); - test("runs independent checks concurrently and preserves report order", async () => { + test('runs independent checks concurrently and preserves report order', async () => { const started: string[] = []; let finishFirst!: () => void; let finishSecond!: () => void; - const firstFinished = new Promise((resolve) => { + const firstFinished = new Promise(resolve => { finishFirst = resolve; }); - const secondFinished = new Promise((resolve) => { + const secondFinished = new Promise(resolve => { finishSecond = resolve; }); const reportPromise = runDoctorChecks( [ { - id: "first", - label: "First", + id: 'first', + label: 'First', async run() { - started.push("first"); + started.push('first'); await firstFinished; - return { status: "pass", message: "First done." }; + return { status: 'pass', message: 'First done.' }; }, }, { - id: "second", - label: "Second", + id: 'second', + label: 'Second', async run() { - started.push("second"); + started.push('second'); await secondFinished; - return { status: "pass", message: "Second done." }; + return { status: 'pass', message: 'Second done.' }; }, }, { - id: "dependent", - label: "Dependent", - requires: ["first", "second"], + id: 'dependent', + label: 'Dependent', + requires: ['first', 'second'], run() { - started.push("dependent"); - return { status: "pass", message: "Dependent done." }; + started.push('dependent'); + return { status: 'pass', message: 'Dependent done.' }; }, }, ], - createDoctorContext(), + createDoctorContext() ); await Promise.resolve(); - expect(started).toEqual(["first", "second"]); + expect(started).toEqual(['first', 'second']); finishSecond(); await Promise.resolve(); - expect(started).toEqual(["first", "second"]); + expect(started).toEqual(['first', 'second']); finishFirst(); const report = await reportPromise; - expect(started).toEqual(["first", "second", "dependent"]); - expect(report.checks.map((check) => check.id)).toEqual(["first", "second", "dependent"]); + expect(started).toEqual(['first', 'second', 'dependent']); + expect(report.checks.map(check => check.id)).toEqual([ + 'first', + 'second', + 'dependent', + ]); }); - test("preserves HTTP status and CLI error details", async () => { + test('preserves HTTP status and CLI error details', async () => { const report = await runDoctorChecks( [ { - id: "http", - label: "HTTP", + id: 'http', + label: 'HTTP', run() { throw new HttpError({ status: 401, body: '{"message":"Token expired"}', - method: "GET", - url: "https://example.com/whoami", - parsedDetail: "Token expired", - authPlane: "management", + method: 'GET', + url: 'https://example.com/whoami', + parsedDetail: 'Token expired', + authPlane: 'management', }); }, }, { - id: "config", - label: "Config", + id: 'config', + label: 'Config', run() { - throw new ConfigurationError("Invalid configuration.", { - details: "The profile file is unreadable.", + throw new ConfigurationError('Invalid configuration.', { + details: 'The profile file is unreadable.', }); }, }, ], - createDoctorContext(), + createDoctorContext() ); expect(report.checks[0]).toMatchObject({ - status: "fail", - code: "auth_failed", + status: 'fail', + code: 'auth_failed', http_status: 401, - details: "Token expired", + details: 'Token expired', }); expect(report.checks[1]).toMatchObject({ - status: "fail", - code: "configuration_error", - details: "The profile file is unreadable.", + status: 'fail', + code: 'configuration_error', + details: 'The profile file is unreadable.', }); }); - test("rejects duplicate and forward dependency ids", async () => { + test('rejects duplicate and forward dependency ids', async () => { const duplicate: DoctorCheck = { - id: "same", - label: "Same", - run: () => ({ status: "pass", message: "Ready." }), + id: 'same', + label: 'Same', + run: () => ({ status: 'pass', message: 'Ready.' }), }; await expectRejection( runDoctorChecks([duplicate, duplicate], createDoctorContext()), - "Duplicate doctor check id", + 'Duplicate doctor check id' ); await expectRejection( runDoctorChecks( [ { - id: "first", - label: "First", - requires: ["later"], - run: () => ({ status: "pass", message: "Ready." }), + id: 'first', + label: 'First', + requires: ['later'], + run: () => ({ status: 'pass', message: 'Ready.' }), }, - { ...duplicate, id: "later" }, + { ...duplicate, id: 'later' }, ], - createDoctorContext(), + createDoctorContext() ), - "requires unknown or later check", + 'requires unknown or later check' ); }); }); diff --git a/cli/src/commands/doctor/lib/runner.ts b/cli/src/commands/doctor/lib/runner.ts index 629250ae..c01fe76f 100644 --- a/cli/src/commands/doctor/lib/runner.ts +++ b/cli/src/commands/doctor/lib/runner.ts @@ -1,18 +1,20 @@ -import { serializeCliError } from "@/lib/errors.ts"; +import { serializeCliError } from '@/lib/errors.ts'; import type { DoctorCheck, DoctorCheckContext, DoctorCheckResult, DoctorReport, DoctorSummary, -} from "@/commands/doctor/lib/model.ts"; +} from '@/commands/doctor/lib/model.ts'; -function summarizeCheckResults(checks: readonly DoctorCheckResult[]): DoctorSummary { +function summarizeCheckResults( + checks: readonly DoctorCheckResult[] +): DoctorSummary { return { - passed: checks.filter((check) => check.status === "pass").length, - warnings: checks.filter((check) => check.status === "warn").length, - failed: checks.filter((check) => check.status === "fail").length, - skipped: checks.filter((check) => check.status === "skipped").length, + passed: checks.filter(check => check.status === 'pass').length, + warnings: checks.filter(check => check.status === 'warn').length, + failed: checks.filter(check => check.status === 'fail').length, + skipped: checks.filter(check => check.status === 'skipped').length, }; } @@ -25,7 +27,7 @@ function validateCheckSequence(checks: readonly DoctorCheck[]): void { for (const dependencyId of check.requires ?? []) { if (!precedingIds.has(dependencyId)) { throw new Error( - `Doctor check ${check.id} requires unknown or later check ${dependencyId}.`, + `Doctor check ${check.id} requires unknown or later check ${dependencyId}.` ); } } @@ -36,16 +38,16 @@ function validateCheckSequence(checks: readonly DoctorCheck[]): void { async function runDoctorCheck( check: DoctorCheck, context: DoctorCheckContext, - dependencies: readonly DoctorCheckResult[], + dependencies: readonly DoctorCheckResult[] ): Promise { const blocker = dependencies.find( - (dependency) => dependency.status !== "pass" && dependency.status !== "warn", + dependency => dependency.status !== 'pass' && dependency.status !== 'warn' ); if (blocker) { return { id: check.id, label: check.label, - status: "skipped", + status: 'skipped', message: `Blocked by ${blocker.label.toLowerCase()}.`, }; } @@ -55,7 +57,7 @@ async function runDoctorCheck( return { id: check.id, label: check.label, - status: "skipped", + status: 'skipped', message: skipReason, }; } @@ -74,7 +76,7 @@ async function runDoctorCheck( return { id: check.id, label: check.label, - status: "fail", + status: 'fail', message: serialized.message, code: serialized.code, http_status: serialized.status, @@ -87,16 +89,20 @@ async function runDoctorCheck( export async function runDoctorChecks( checks: readonly DoctorCheck[], - context: DoctorCheckContext, + context: DoctorCheckContext ): Promise { validateCheckSequence(checks); const pendingResults = new Map>(); for (const check of checks) { - const dependencies = (check.requires ?? []).map((id) => pendingResults.get(id)!); + const dependencies = (check.requires ?? []).map(id => + pendingResults.get(id)! + ); pendingResults.set( check.id, - Promise.all(dependencies).then((results) => runDoctorCheck(check, context, results)), + Promise.all(dependencies).then(results => + runDoctorCheck(check, context, results) + ) ); } diff --git a/cli/src/commands/duckdb/index.test.ts b/cli/src/commands/duckdb/index.test.ts index d60d677f..adab58a6 100644 --- a/cli/src/commands/duckdb/index.test.ts +++ b/cli/src/commands/duckdb/index.test.ts @@ -1,29 +1,29 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { configSet } from "@/lib/config.ts"; -import { secretSet } from "@/lib/secrets.ts"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { chmodSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { configSet } from '@/lib/config.ts'; +import { secretSet } from '@/lib/secrets.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; let originalPath: string | undefined; beforeEach(() => { - workspace = createLakehouseTestWorkspace("duckdb-command"); + workspace = createLakehouseTestWorkspace('duckdb-command'); process.env.ALTERTABLE_CONFIG_HOME = workspace.home; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; const executable = workspace.writeFile( - "duckdb", - `#!/bin/sh\nprintf '%s' "$2" > "${join(workspace.home, "duckdb.sql")}"\n`, + 'duckdb', + `#!/bin/sh\nprintf '%s' "$2" > "${join(workspace.home, 'duckdb.sql')}"\n` ); chmodSync(executable, 0o755); originalPath = process.env.PATH; - process.env.PATH = `${workspace.home}:${originalPath ?? ""}`; + process.env.PATH = `${workspace.home}:${originalPath ?? ''}`; }); afterEach(() => { @@ -40,165 +40,177 @@ function useStoredProfile(): void { delete process.env.ALTERTABLE_API_BASE; delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_test", "default"); + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_test', 'default'); } function useLakehouseOnlyProfile(): void { delete process.env.ALTERTABLE_API_BASE; delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - configSet("user", "alice", "default"); - secretSet("lakehouse/password", "s3cret", "default"); + configSet('user', 'alice', 'default'); + secretSet('lakehouse/password', 's3cret', 'default'); } function writeAttachAllMocks(): void { workspace.writeMocks([ { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', body: JSON.stringify({ - principal: { type: "User", name: "Jane", email: "j@x.io" }, - organization: { name: "Acme", slug: "acme" }, + principal: { type: 'User', name: 'Jane', email: 'j@x.io' }, + organization: { name: 'Acme', slug: 'acme' }, }), }, - { urlPattern: "/query", method: "POST", body: '{"ok":true}' }, + { urlPattern: '/query', method: 'POST', body: '{"ok":true}' }, { - urlPattern: "/environments/production/databases", - method: "GET", + urlPattern: '/environments/production/databases', + method: 'GET', body: JSON.stringify({ databases: [ - { name: "Sales", slug: "sales", catalog: "sales" }, - { name: "Ops", slug: "ops", catalog: 'ops-"quoted' }, + { name: 'Sales', slug: 'sales', catalog: 'sales' }, + { name: 'Ops', slug: 'ops', catalog: 'ops-"quoted' }, ], }), }, { - urlPattern: "/environments/production/connections", - method: "GET", + urlPattern: '/environments/production/connections', + method: 'GET', body: '{"connections":[]}', }, ]); } function writeLakehouseOnlyMocks(): void { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: '{"ok":true}' }]); + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: '{"ok":true}' }, + ]); } function readGeneratedSql(): string { - return readFileSync(join(workspace.home, "duckdb.sql"), "utf8"); + return readFileSync(join(workspace.home, 'duckdb.sql'), 'utf8'); } -describe("duckdb attach-all", () => { - test("launches DuckDB with verified credentials and every catalog", async () => { +describe('duckdb attach-all', () => { + test('launches DuckDB with verified credentials and every catalog', async () => { useStoredProfile(); - secretSet("lakehouse/basic-token", Buffer.from("alice:s3cret").toString("base64"), "default"); + secretSet( + 'lakehouse/basic-token', + Buffer.from('alice:s3cret').toString('base64'), + 'default' + ); writeAttachAllMocks(); - await runCommandWithTestRuntime(["duckdb"]); + await runCommandWithTestRuntime(['duckdb']); const sql = readGeneratedSql(); - expect(sql).toContain("INSTALL altertable FROM community;"); - expect(sql).toContain("LOAD altertable;"); - expect(sql).toContain("user=alice password=s3cret catalog=sales"); + expect(sql).toContain('INSTALL altertable FROM community;'); + expect(sql).toContain('LOAD altertable;'); + expect(sql).toContain('user=alice password=s3cret catalog=sales'); expect(sql).toContain('AS "sales" (TYPE ALTERTABLE);'); expect(sql).toContain('AS "ops-""quoted" (TYPE ALTERTABLE);'); expect(sql.match(/ATTACH/g)).toHaveLength(2); }); - test("launches DuckDB for a username/password profile", async () => { + test('launches DuckDB for a username/password profile', async () => { useStoredProfile(); - configSet("user", "alice", "default"); - secretSet("lakehouse/password", "s3cret", "default"); + configSet('user', 'alice', 'default'); + secretSet('lakehouse/password', 's3cret', 'default'); writeAttachAllMocks(); - await runCommandWithTestRuntime(["duckdb"]); + await runCommandWithTestRuntime(['duckdb']); - expect(readGeneratedSql()).toContain("user=alice password=s3cret catalog=sales"); + expect(readGeneratedSql()).toContain( + 'user=alice password=s3cret catalog=sales' + ); }); - test("launches DuckDB with environment credentials", async () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "production"; + test('launches DuckDB with environment credentials', async () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'production'; writeAttachAllMocks(); - await runCommandWithTestRuntime(["duckdb"]); + await runCommandWithTestRuntime(['duckdb']); - expect(readGeneratedSql()).toContain("user=testuser password=testpass catalog=sales"); + expect(readGeneratedSql()).toContain( + 'user=testuser password=testpass catalog=sales' + ); }); - test("explains the missing management plane for a lakehouse-only env configuration", async () => { + test('explains the missing management plane for a lakehouse-only env configuration', async () => { delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-token"; + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-token'; writeAttachAllMocks(); - expect(runCommandWithTestRuntime(["duckdb"])).rejects.toThrow( - "Attaching all catalogs requires the management API to list them, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV, or attach a single catalog directly: altertable duckdb .", + expect(runCommandWithTestRuntime(['duckdb'])).rejects.toThrow( + 'Attaching all catalogs requires the management API to list them, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV, or attach a single catalog directly: altertable duckdb .' ); }); - test("explains the missing management credentials even when the environment is set", async () => { + test('explains the missing management credentials even when the environment is set', async () => { delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-token"; - process.env.ALTERTABLE_ENV = "production"; + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-token'; + process.env.ALTERTABLE_ENV = 'production'; writeAttachAllMocks(); - expect(runCommandWithTestRuntime(["duckdb"])).rejects.toThrow( - "Attaching all catalogs requires the management API to list them, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV, or attach a single catalog directly: altertable duckdb .", + expect(runCommandWithTestRuntime(['duckdb'])).rejects.toThrow( + 'Attaching all catalogs requires the management API to list them, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV, or attach a single catalog directly: altertable duckdb .' ); }); - test("explains the missing management plane for a lakehouse-only profile", async () => { + test('explains the missing management plane for a lakehouse-only profile', async () => { useLakehouseOnlyProfile(); writeAttachAllMocks(); - expect(runCommandWithTestRuntime(["duckdb"])).rejects.toThrow( - "Attaching all catalogs requires the management API to list them, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env ', or attach a single catalog directly: altertable duckdb .", + expect(runCommandWithTestRuntime(['duckdb'])).rejects.toThrow( + "Attaching all catalogs requires the management API to list them, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env ', or attach a single catalog directly: altertable duckdb ." ); }); - test("does not auto-provision lakehouse credentials in environment configuration mode", async () => { + test('does not auto-provision lakehouse credentials in environment configuration mode', async () => { delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "production"; + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'production'; writeAttachAllMocks(); - expect(runCommandWithTestRuntime(["duckdb"])).rejects.toThrow( - "Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.", + expect(runCommandWithTestRuntime(['duckdb'])).rejects.toThrow( + 'Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.' ); }); }); -describe("duckdb attach-single-catalog", () => { - test("attaches the named catalog for a lakehouse-only profile", async () => { +describe('duckdb attach-single-catalog', () => { + test('attaches the named catalog for a lakehouse-only profile', async () => { useLakehouseOnlyProfile(); writeLakehouseOnlyMocks(); - await runCommandWithTestRuntime(["duckdb", "sales"]); + await runCommandWithTestRuntime(['duckdb', 'sales']); const sql = readGeneratedSql(); - expect(sql).toContain("user=alice password=s3cret catalog=sales"); + expect(sql).toContain('user=alice password=s3cret catalog=sales'); expect(sql.match(/ATTACH/g)).toHaveLength(1); }); - test("attaches the named catalog with lakehouse-only env configuration", async () => { + test('attaches the named catalog with lakehouse-only env configuration', async () => { writeLakehouseOnlyMocks(); - await runCommandWithTestRuntime(["duckdb", "sales"]); + await runCommandWithTestRuntime(['duckdb', 'sales']); - expect(readGeneratedSql()).toContain("user=testuser password=testpass catalog=sales"); + expect(readGeneratedSql()).toContain( + 'user=testuser password=testpass catalog=sales' + ); }); - test("does not consult the management catalog list", async () => { + test('does not consult the management catalog list', async () => { useLakehouseOnlyProfile(); writeLakehouseOnlyMocks(); - await runCommandWithTestRuntime(["duckdb", "not-in-any-listing"]); + await runCommandWithTestRuntime(['duckdb', 'not-in-any-listing']); - expect(readGeneratedSql()).toContain("catalog=not-in-any-listing"); - expect(workspace.readHttpLog()).not.toContain("/environments/"); + expect(readGeneratedSql()).toContain('catalog=not-in-any-listing'); + expect(workspace.readHttpLog()).not.toContain('/environments/'); }); }); diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index f91aa491..c82e8ac1 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -1,34 +1,38 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync } from 'node:child_process'; import { getLakehouseCredentialPair, requireManagementPlane, type LakehouseBasicAuthPair, -} from "@/lib/auth.ts"; -import { configureVerify, type ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { optionalStringArg } from "@/lib/args.ts"; -import { fetchManagementCatalogRows } from "@/lib/management/catalogs.ts"; -import type { CatalogRow } from "@/lib/management/model.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { readEnv } from "@/lib/env.ts"; +} from '@/lib/auth.ts'; +import { + configureVerify, + type ConfigureAuthPlane, +} from '@/lib/profile-status.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; +import type { CatalogRow } from '@/lib/management/model.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { readEnv } from '@/lib/env.ts'; export const duckdbCommand = defineCommand({ metadata: { - name: "duckdb", - commandGroup: "query", - description: "Open a DuckDB shell attached to lakehouse catalogs (all of them by default).", - examples: ["altertable duckdb", "altertable duckdb analytics"], + name: 'duckdb', + commandGroup: 'query', + description: + 'Open a DuckDB shell attached to lakehouse catalogs (all of them by default).', + examples: ['altertable duckdb', 'altertable duckdb analytics'], }, args: { catalog: { - type: "positional", - description: "Catalog to attach (defaults to all catalogs)", + type: 'positional', + description: 'Catalog to attach (defaults to all catalogs)', required: false, }, }, run: ({ args, execution }) => - runDuckdb({ catalog: optionalStringArg(args, "catalog") }, execution), + runDuckdb({ catalog: optionalStringArg(args, 'catalog') }, execution), }); function escapeSql(value: string): string { @@ -39,61 +43,83 @@ function quoteIdentifier(value: string): string { return `"${value.replaceAll('"', '""')}"`; } -function attachStatement(credentials: LakehouseBasicAuthPair, catalog: string): string { +function attachStatement( + credentials: LakehouseBasicAuthPair, + catalog: string +): string { const connection = `user=${escapeSql(credentials.user)} password=${escapeSql(credentials.password)} catalog=${escapeSql(catalog)}`; return `ATTACH '${connection}' AS ${quoteIdentifier(catalog)} (TYPE ALTERTABLE);`; } -function buildDuckdbAttachSnippet(credentials: LakehouseBasicAuthPair, catalogs: string[]): string { +function buildDuckdbAttachSnippet( + credentials: LakehouseBasicAuthPair, + catalogs: string[] +): string { return [ - "INSTALL altertable FROM community;", - "LOAD altertable;", - ...catalogs.map((catalog) => attachStatement(credentials, catalog)), - ].join("\n"); + 'INSTALL altertable FROM community;', + 'LOAD altertable;', + ...catalogs.map(catalog => attachStatement(credentials, catalog)), + ].join('\n'); } function availableCatalogs(rows: CatalogRow[]): string[] { const available = [ - ...new Set(rows.map((row) => row.catalog).filter((catalog) => catalog.length > 0)), + ...new Set( + rows.map(row => row.catalog).filter(catalog => catalog.length > 0) + ), ]; if (available.length === 0) { - throw new ConfigurationError("No catalogs found in this environment."); + throw new ConfigurationError('No catalogs found in this environment.'); } return available; } async function verifyConfiguredPlanes( planes: ConfigureAuthPlane[], - execution: ExecutionContext, + execution: ExecutionContext ): Promise { const verify = await configureVerify(planes, execution); - const failedPlane = verify.configured.find((plane) => !verify.verified[plane]); + const failedPlane = verify.configured.find(plane => !verify.verified[plane]); if (failedPlane) { - const detail = verify.errors.find((error) => error.plane === failedPlane)?.message; - throw new ConfigurationError(detail ?? `${failedPlane} credentials verification failed.`); + const detail = verify.errors.find( + error => error.plane === failedPlane + )?.message; + throw new ConfigurationError( + detail ?? `${failedPlane} credentials verification failed.` + ); } } -type DuckdbAttachPlan = { credentials: LakehouseBasicAuthPair; catalogs: string[] }; +type DuckdbAttachPlan = { + credentials: LakehouseBasicAuthPair; + catalogs: string[]; +}; // Attaching one named catalog needs only lakehouse credentials; the catalog is // not validated against the management API and a typo surfaces from DuckDB. async function duckdbAttachSingleCatalog( catalog: string, - execution: ExecutionContext, + execution: ExecutionContext ): Promise { - await verifyConfiguredPlanes(["lakehouse"], execution); - return { credentials: getLakehouseCredentialPair(execution.profile), catalogs: [catalog] }; + await verifyConfiguredPlanes(['lakehouse'], execution); + return { + credentials: getLakehouseCredentialPair(execution.profile), + catalogs: [catalog], + }; } -async function duckdbAttachAll(execution: ExecutionContext): Promise { +async function duckdbAttachAll( + execution: ExecutionContext +): Promise { const managementEnv = requireManagementPlane(execution.profile, { - requirement: "Attaching all catalogs requires the management API to list them", - alternative: "attach a single catalog directly: altertable duckdb ", + requirement: + 'Attaching all catalogs requires the management API to list them', + alternative: + 'attach a single catalog directly: altertable duckdb ', }); - await verifyConfiguredPlanes(["management", "lakehouse"], execution); + await verifyConfiguredPlanes(['management', 'lakehouse'], execution); const credentials = getLakehouseCredentialPair(execution.profile); const rows = await fetchManagementCatalogRows(managementEnv, execution); return { credentials, catalogs: availableCatalogs(rows) }; @@ -101,11 +127,14 @@ async function duckdbAttachAll(execution: ExecutionContext): Promise { - const duckdb = Bun.which("duckdb", { PATH: readEnv("PATH") }); +async function runDuckdb( + input: DuckdbInput, + execution: ExecutionContext +): Promise { + const duckdb = Bun.which('duckdb', { PATH: readEnv('PATH') }); if (!duckdb) { throw new ConfigurationError( - "duckdb is not installed. Install it from https://duckdb.org/install/ and try again.", + 'duckdb is not installed. Install it from https://duckdb.org/install/ and try again.' ); } @@ -115,8 +144,10 @@ async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promi : await duckdbAttachSingleCatalog(input.catalog, execution); const snippet = buildDuckdbAttachSnippet(plan.credentials, plan.catalogs); - const result = spawnSync(duckdb, ["-cmd", snippet], { stdio: "inherit" }); + const result = spawnSync(duckdb, ['-cmd', snippet], { stdio: 'inherit' }); if (result.error) { - throw new ConfigurationError(`Failed to launch duckdb: ${result.error.message}`); + throw new ConfigurationError( + `Failed to launch duckdb: ${result.error.message}` + ); } } diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 89ffabb0..0fb53842 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,21 +1,23 @@ -import type { Command } from "@/lib/command.ts"; -import { loginCommand } from "@/commands/login/index.ts"; -import { logoutCommand } from "@/commands/logout/index.ts"; -import { profileCommand } from "@/commands/profile/index.ts"; -import { catalogCommand, catalogsCommand } from "@/commands/catalogs/index.ts"; -import { duckdbCommand } from "@/commands/duckdb/index.ts"; -import { appendCommand } from "@/commands/append/index.ts"; -import { queryCommand } from "@/commands/query/index.ts"; -import { schemaCommand } from "@/commands/schema/index.ts"; -import { uploadCommand } from "@/commands/upload/index.ts"; -import { upsertCommand } from "@/commands/upsert/index.ts"; -import { apiCommand } from "@/commands/api/index.ts"; -import { createCompletionCommand } from "@/commands/completion/index.ts"; -import { updateCommand } from "@/commands/update/index.ts"; -import { doctorCommand } from "@/commands/doctor/index.ts"; -import { appCommand } from "@/commands/app/index.ts"; +import type { Command } from '@/lib/command.ts'; +import { loginCommand } from '@/commands/login/index.ts'; +import { logoutCommand } from '@/commands/logout/index.ts'; +import { profileCommand } from '@/commands/profile/index.ts'; +import { catalogCommand, catalogsCommand } from '@/commands/catalogs/index.ts'; +import { duckdbCommand } from '@/commands/duckdb/index.ts'; +import { appendCommand } from '@/commands/append/index.ts'; +import { queryCommand } from '@/commands/query/index.ts'; +import { schemaCommand } from '@/commands/schema/index.ts'; +import { uploadCommand } from '@/commands/upload/index.ts'; +import { upsertCommand } from '@/commands/upsert/index.ts'; +import { apiCommand } from '@/commands/api/index.ts'; +import { createCompletionCommand } from '@/commands/completion/index.ts'; +import { updateCommand } from '@/commands/update/index.ts'; +import { doctorCommand } from '@/commands/doctor/index.ts'; +import { appCommand } from '@/commands/app/index.ts'; -export function buildTopLevelCommands(getMainCommand: () => Command): Record { +export function buildTopLevelCommands( + getMainCommand: () => Command +): Record { return { login: loginCommand, logout: logoutCommand, diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts index 1cdc5919..06958eee 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -1,70 +1,87 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configGet } from "@/lib/config.ts"; -import { HttpError } from "@/lib/errors.ts"; -import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/profile-store.ts"; -import { createEmptyProfile, updateProfile } from "@/lib/profile/model.ts"; -import { createCliTestHarness, runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -import { delay } from "@/test-utils/time.ts"; -import { forceNoTerminalColorForTests } from "@/test-utils/terminal.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configGet } from '@/lib/config.ts'; +import { HttpError } from '@/lib/errors.ts'; +import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { + getActiveProfileName, + profileExists, + setActiveProfile, +} from '@/lib/profile-store.ts'; +import { createEmptyProfile, updateProfile } from '@/lib/profile/model.ts'; +import { + createCliTestHarness, + runCommandWithTestRuntime, +} from '@/test-utils/cli.ts'; +import { delay } from '@/test-utils/time.ts'; +import { forceNoTerminalColorForTests } from '@/test-utils/terminal.ts'; const DEFAULT_WHOAMI = { principal: { - type: "User", - name: "Test User", - email: "test.user@altertable.test", - slug: "test-user", + type: 'User', + name: 'Test User', + email: 'test.user@altertable.test', + slug: 'test-user', }, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", + organization: { name: 'Altertable', slug: 'altertable' }, + authentication_scope: 'environment', + environment_slug: 'production', }; -let testHome = ""; -let mockFile = ""; +let testHome = ''; +let mockFile = ''; let originalPath: string | undefined; let stdinIsTty: PropertyDescriptor | undefined; function storedAccessToken(profileName: string): string { - return secretGet("oauth/access-token", profileName); + return secretGet('oauth/access-token', profileName); } // bun-types declare rejects.toThrow() as void, so awaiting it trips the // await-thenable lint rule; capture the rejection explicitly instead. -async function expectRejection(promise: Promise, message: string): Promise { +async function expectRejection( + promise: Promise, + message: string +): Promise { const error = await promise.then( () => undefined, - (thrown: unknown) => thrown, + (thrown: unknown) => thrown ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain(message); } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-login-test-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-login-test-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - process.env.OSC_HYPERLINK = "0"; + process.env.OSC_HYPERLINK = '0'; originalPath = process.env.PATH; - stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); forceNoTerminalColorForTests(); - for (const browserCommand of ["open", "xdg-open"]) { + for (const browserCommand of ['open', 'xdg-open']) { const browserPath = join(testHome, browserCommand); - writeFileSync(browserPath, "#!/bin/sh\nexit 0\n"); + writeFileSync(browserPath, '#!/bin/sh\nexit 0\n'); chmodSync(browserPath, 0o755); } - process.env.PATH = `${testHome}:${originalPath ?? ""}`; + process.env.PATH = `${testHome}:${originalPath ?? ''}`; }); afterEach(() => { - if (stdinIsTty) Object.defineProperty(process.stdin, "isTTY", stdinIsTty); + if (stdinIsTty) Object.defineProperty(process.stdin, 'isTTY', stdinIsTty); rmSync(testHome, { recursive: true, force: true }); process.env.PATH = originalPath; delete process.env.ALTERTABLE_CONFIG_HOME; @@ -76,245 +93,299 @@ afterEach(() => { }); const SERVICE_ACCOUNT_RESPONSE = { - service_account: { id: "sa-1", label: "My Service", slug: "my-service" }, - role_assignments: [{ role: "catalog:reader", resource_kind: "catalog", resource_id: "cat-1" }], - service_oauth_token: "svc_access_token", + service_account: { id: 'sa-1', label: 'My Service', slug: 'my-service' }, + role_assignments: [ + { role: 'catalog:reader', resource_kind: 'catalog', resource_id: 'cat-1' }, + ], + service_oauth_token: 'svc_access_token', }; const CATALOG_LIST_MOCKS = [ { - urlPattern: "/environments/production/databases", - method: "GET", - body: JSON.stringify({ databases: [{ id: "cat-1", name: "Analytics", slug: "analytics" }] }), + urlPattern: '/environments/production/databases', + method: 'GET', + body: JSON.stringify({ + databases: [{ id: 'cat-1', name: 'Analytics', slug: 'analytics' }], + }), }, { - urlPattern: "/environments/production/connections", - method: "GET", - body: JSON.stringify({ connections: [{ id: "cat-2", name: "Events", slug: "events" }] }), + urlPattern: '/environments/production/connections', + method: 'GET', + body: JSON.stringify({ + connections: [{ id: 'cat-2', name: 'Events', slug: 'events' }], + }), }, ]; function httpLogJsonPayload(logPath: string, urlSubstring: string): unknown { - const entry = readFileSync(logPath, "utf8") - .split("---\n") - .find((block) => block.includes(`URL=`) && block.includes(urlSubstring)); + const entry = readFileSync(logPath, 'utf8') + .split('---\n') + .find(block => block.includes(`URL=`) && block.includes(urlSubstring)); if (!entry) { throw new Error(`No HTTP log entry for ${urlSubstring}`); } - const payloadLine = entry.split("\n").find((line) => line.startsWith("PAYLOAD=")); + const payloadLine = entry + .split('\n') + .find(line => line.startsWith('PAYLOAD=')); if (!payloadLine) { throw new Error(`No PAYLOAD= entry for ${urlSubstring}`); } - return JSON.parse(payloadLine.slice("PAYLOAD=".length)); + return JSON.parse(payloadLine.slice('PAYLOAD='.length)); } async function completeBrowserLogin( - rawArgs: string[] = ["login"], + rawArgs: string[] = ['login'], whoami: object = DEFAULT_WHOAMI, - accessToken = "access_token", - extraMocks: object[] = [], + accessToken = 'access_token', + extraMocks: object[] = [] ) { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', body: JSON.stringify({ access_token: accessToken, - token_type: "Bearer", - refresh_token: "refresh_token", + token_type: 'Bearer', + refresh_token: 'refresh_token', expires_in: 3600, }), }, { - urlPattern: "/whoami", - method: "GET", + urlPattern: '/whoami', + method: 'GET', authPattern: accessToken, body: JSON.stringify(whoami), }, ...extraMocks, - ]), + ]) ); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); const cli = createCliTestHarness(); const completion = cli.run(rawArgs); let authorizeUrl: URL | undefined; for (let attempt = 0; attempt < 100 && !authorizeUrl; attempt += 1) { - const match = cli.stdout.join("\n").match(/https?:\/\/[^\s]+\/oauth\/authorize\?[^\s]+/); + const match = cli.stdout + .join('\n') + .match(/https?:\/\/[^\s]+\/oauth\/authorize\?[^\s]+/); if (match?.[0]) authorizeUrl = new URL(match[0]); if (!authorizeUrl) await delay(5); } - if (!authorizeUrl) throw new Error("login command did not print an authorize URL"); + if (!authorizeUrl) + throw new Error('login command did not print an authorize URL'); - const redirectUri = authorizeUrl.searchParams.get("redirect_uri"); - const state = authorizeUrl.searchParams.get("state"); - if (!redirectUri || !state) throw new Error("authorize URL is missing callback parameters"); + const redirectUri = authorizeUrl.searchParams.get('redirect_uri'); + const state = authorizeUrl.searchParams.get('state'); + if (!redirectUri || !state) + throw new Error('authorize URL is missing callback parameters'); const callback = new URL(redirectUri); - callback.searchParams.set("code", "code"); - callback.searchParams.set("state", state); + callback.searchParams.set('code', 'code'); + callback.searchParams.set('state', state); expect((await fetch(callback)).status).toBe(200); await completion; return cli; } -describe("login command", () => { - test("refuses non-interactive and JSON invocations before opening a browser", () => { +describe('login command', () => { + test('refuses non-interactive and JSON invocations before opening a browser', () => { return expect( - runCommandWithTestRuntime(["login"], { debug: false, json: true, agent: false }), - ).rejects.toThrow("needs an interactive terminal"); + runCommandWithTestRuntime(['login'], { + debug: false, + json: true, + agent: false, + }) + ).rejects.toThrow('needs an interactive terminal'); }); - test("signs a fresh installation into the current default profile", async () => { + test('signs a fresh installation into the current default profile', async () => { const cli = await completeBrowserLogin(); - expect(getActiveProfileName()).toBe("default"); - expect(profileExists("altertable_production")).toBe(false); - expect(configGet("api_key_env", "default")).toBe("production"); - expect(configGet("organization_slug", "default")).toBe("altertable"); - expect(configGet("principal_email", "default")).toBe("test.user@altertable.test"); - expect(storedAccessToken("default")).toBe("access_token"); - expect(cli.stdout.join("\n")).toContain('using profile "default"'); + expect(getActiveProfileName()).toBe('default'); + expect(profileExists('altertable_production')).toBe(false); + expect(configGet('api_key_env', 'default')).toBe('production'); + expect(configGet('organization_slug', 'default')).toBe('altertable'); + expect(configGet('principal_email', 'default')).toBe( + 'test.user@altertable.test' + ); + expect(storedAccessToken('default')).toBe('access_token'); + expect(cli.stdout.join('\n')).toContain('using profile "default"'); expect(cli.stderr).toEqual([]); }); - test("passes requested organization and environment to browser authorization", async () => { - const cli = await completeBrowserLogin(["login", "--org", "acme", "--env", "staging"]); - const match = cli.stdout.join("\n").match(/https?:\/\/[^\s]+\/oauth\/authorize\?[^\s]+/); + test('passes requested organization and environment to browser authorization', async () => { + const cli = await completeBrowserLogin([ + 'login', + '--org', + 'acme', + '--env', + 'staging', + ]); + const match = cli.stdout + .join('\n') + .match(/https?:\/\/[^\s]+\/oauth\/authorize\?[^\s]+/); expect(match).not.toBeNull(); const url = new URL(match![0]); - expect(url.searchParams.get("organization")).toBe("acme"); - expect(url.searchParams.get("environment")).toBe("staging"); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(storedAccessToken("default")).toBe("access_token"); + expect(url.searchParams.get('organization')).toBe('acme'); + expect(url.searchParams.get('environment')).toBe('staging'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(storedAccessToken('default')).toBe('access_token'); }); - test("rejects empty organization or environment slugs before starting OAuth", async () => { - for (const flag of ["--org", "--env"]) { + test('rejects empty organization or environment slugs before starting OAuth', async () => { + for (const flag of ['--org', '--env']) { await expectRejection( - runCommandWithTestRuntime(["login", flag, " "]), - `${flag} requires a non-empty slug`, + runCommandWithTestRuntime(['login', flag, ' ']), + `${flag} requires a non-empty slug` ); } }); - test("preserves an authenticated profile when signing into another organization", async () => { + test('preserves an authenticated profile when signing into another organization', async () => { storeOAuthTokens( - { access_token: "org_a_token", refresh_token: "org_a_refresh", expires_in: 3600 }, - "default", + { + access_token: 'org_a_token', + refresh_token: 'org_a_refresh', + expires_in: 3600, + }, + 'default' ); await completeBrowserLogin( - ["login"], + ['login'], { ...DEFAULT_WHOAMI, - organization: { name: "Org B", slug: "org-b" }, + organization: { name: 'Org B', slug: 'org-b' }, }, - "org_b_token", + 'org_b_token' ); - expect(storedAccessToken("default")).toBe("org_a_token"); - expect(getActiveProfileName()).toBe("org-b_production"); - expect(storedAccessToken("org-b_production")).toBe("org_b_token"); + expect(storedAccessToken('default')).toBe('org_a_token'); + expect(getActiveProfileName()).toBe('org-b_production'); + expect(storedAccessToken('org-b_production')).toBe('org_b_token'); }); - test("signs into a fresh empty profile without deriving another profile", async () => { - createEmptyProfile("new"); - setActiveProfile("new"); + test('signs into a fresh empty profile without deriving another profile', async () => { + createEmptyProfile('new'); + setActiveProfile('new'); await completeBrowserLogin(); - expect(getActiveProfileName()).toBe("new"); - expect(profileExists("altertable_production")).toBe(false); - expect(storedAccessToken("new")).toBe("access_token"); + expect(getActiveProfileName()).toBe('new'); + expect(profileExists('altertable_production')).toBe(false); + expect(storedAccessToken('new')).toBe('access_token'); }); - test("preserves lakehouse-only authentication by creating a login profile", async () => { - secretSet("lakehouse/password", "lakehouse-secret", "default"); + test('preserves lakehouse-only authentication by creating a login profile', async () => { + secretSet('lakehouse/password', 'lakehouse-secret', 'default'); await completeBrowserLogin(); - expect(getActiveProfileName()).toBe("altertable_production"); - expect(secretGet("lakehouse/password", "default")).toBe("lakehouse-secret"); - expect(storedAccessToken("altertable_production")).toBe("access_token"); + expect(getActiveProfileName()).toBe('altertable_production'); + expect(secretGet('lakehouse/password', 'default')).toBe('lakehouse-secret'); + expect(storedAccessToken('altertable_production')).toBe('access_token'); }); - test("--replace-profile replaces the current login without creating a profile", async () => { + test('--replace-profile replaces the current login without creating a profile', async () => { storeOAuthTokens( - { access_token: "old_token", refresh_token: "old_refresh", expires_in: 3600 }, - "default", + { + access_token: 'old_token', + refresh_token: 'old_refresh', + expires_in: 3600, + }, + 'default' ); await completeBrowserLogin( - ["login", "--replace-profile"], + ['login', '--replace-profile'], { ...DEFAULT_WHOAMI, - organization: { name: "Org B", slug: "org-b" }, + organization: { name: 'Org B', slug: 'org-b' }, }, - "replacement_token", + 'replacement_token' ); - expect(getActiveProfileName()).toBe("default"); - expect(profileExists("org-b_production")).toBe(false); - expect(configGet("organization_slug", "default")).toBe("org-b"); - expect(storedAccessToken("default")).toBe("replacement_token"); + expect(getActiveProfileName()).toBe('default'); + expect(profileExists('org-b_production')).toBe(false); + expect(configGet('organization_slug', 'default')).toBe('org-b'); + expect(storedAccessToken('default')).toBe('replacement_token'); }); - test("reused profiles inherit the control plane that authenticated the session", async () => { + test('reused profiles inherit the control plane that authenticated the session', async () => { storeOAuthTokens( - { access_token: "org_a_token", refresh_token: "org_a_refresh", expires_in: 3600 }, - "default", + { + access_token: 'org_a_token', + refresh_token: 'org_a_refresh', + expires_in: 3600, + }, + 'default' ); - updateProfile("default", { controlPlane: "https://login.altertable.test" }); - createEmptyProfile("org-b_production"); - updateProfile("org-b_production", { controlPlane: "https://stale.altertable.test" }); + updateProfile('default', { controlPlane: 'https://login.altertable.test' }); + createEmptyProfile('org-b_production'); + updateProfile('org-b_production', { + controlPlane: 'https://stale.altertable.test', + }); await completeBrowserLogin( - ["login"], + ['login'], { ...DEFAULT_WHOAMI, - organization: { name: "Org B", slug: "org-b" }, + organization: { name: 'Org B', slug: 'org-b' }, }, - "org_b_token", + 'org_b_token' ); - expect(getActiveProfileName()).toBe("org-b_production"); - expect(configGet("management_api_base", "org-b_production")).toBe( - "https://login.altertable.test", + expect(getActiveProfileName()).toBe('org-b_production'); + expect(configGet('management_api_base', 'org-b_production')).toBe( + 'https://login.altertable.test' ); - expect(storedAccessToken("org-b_production")).toBe("org_b_token"); + expect(storedAccessToken('org-b_production')).toBe('org_b_token'); }); - test("stores endpoint overrides only after a successful login", async () => { + test('stores endpoint overrides only after a successful login', async () => { await completeBrowserLogin([ - "login", - "--control-plane-url", - "https://app.altertable.test", - "--data-plane-url", - "https://api.altertable.test", + 'login', + '--control-plane-url', + 'https://app.altertable.test', + '--data-plane-url', + 'https://api.altertable.test', ]); - expect(configGet("management_api_base", "default")).toBe("https://app.altertable.test"); - expect(configGet("api_base", "default")).toBe("https://api.altertable.test"); + expect(configGet('management_api_base', 'default')).toBe( + 'https://app.altertable.test' + ); + expect(configGet('api_base', 'default')).toBe( + 'https://api.altertable.test' + ); }); - test("rejects insecure control-plane URLs before starting OAuth", () => { - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + test('rejects insecure control-plane URLs before starting OAuth', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); return expect( - runCommandWithTestRuntime(["login", "--control-plane-url", "http://app.altertable.test"]), + runCommandWithTestRuntime([ + 'login', + '--control-plane-url', + 'http://app.altertable.test', + ]) ).rejects.toThrow(); }); }); -describe("login --service-account", () => { - const serviceAccountProfile = "altertable_production_my-service"; - let logFile = ""; +describe('login --service-account', () => { + const serviceAccountProfile = 'altertable_production_my-service'; + let logFile = ''; beforeEach(() => { - logFile = join(testHome, "http.log"); + logFile = join(testHome, 'http.log'); process.env.ALTERTABLE_HTTP_LOG = logFile; }); @@ -324,256 +395,307 @@ describe("login --service-account", () => { function serviceAccountMock( body: object | string = SERVICE_ACCOUNT_RESPONSE, - status = 201, + status = 201 ): object { return { - urlPattern: "/environments/production/service_accounts", - method: "POST", + urlPattern: '/environments/production/service_accounts', + method: 'POST', status, - body: typeof body === "string" ? body : JSON.stringify(body), + body: typeof body === 'string' ? body : JSON.stringify(body), }; } - function serviceAccountMocks(body: object | string = SERVICE_ACCOUNT_RESPONSE): object[] { + function serviceAccountMocks( + body: object | string = SERVICE_ACCOUNT_RESPONSE + ): object[] { return [serviceAccountMock(body), ...CATALOG_LIST_MOCKS]; } - test("provisions a service account and stores its service OAuth token", async () => { + test('provisions a service account and stores its service OAuth token', async () => { const cli = await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "catalog1:ro"], + ['login', '--service-account', 'My Service', '--scope', 'catalog1:ro'], DEFAULT_WHOAMI, - "access_token", - serviceAccountMocks(), + 'access_token', + serviceAccountMocks() ); expect(getActiveProfileName()).toBe(serviceAccountProfile); expect(profileExists(serviceAccountProfile)).toBe(true); - expect(configGet("principal_type", serviceAccountProfile)).toBe("ServiceAccount"); - expect(configGet("principal_slug", serviceAccountProfile)).toBe("my-service"); - expect(storedAccessToken(serviceAccountProfile)).toBe("svc_access_token"); - expect(secretGet("oauth/refresh-token", serviceAccountProfile)).toBe(""); - expect(configGet("oauth_expiry", serviceAccountProfile)).toBe(""); - expect(secretGet("lakehouse/basic-token", serviceAccountProfile)).toBe(""); - expect(cli.stdout.join("\n")).toContain("My Service"); - expect(cli.stdout.join("\n")).toContain(serviceAccountProfile); - expect(cli.stdout.join("\n")).toMatch(/CATALOG\s+SLUG\s+ACCESS/); - expect(cli.stdout.join("\n")).toMatch(/Analytics\s+analytics\s+ro/); + expect(configGet('principal_type', serviceAccountProfile)).toBe( + 'ServiceAccount' + ); + expect(configGet('principal_slug', serviceAccountProfile)).toBe( + 'my-service' + ); + expect(storedAccessToken(serviceAccountProfile)).toBe('svc_access_token'); + expect(secretGet('oauth/refresh-token', serviceAccountProfile)).toBe(''); + expect(configGet('oauth_expiry', serviceAccountProfile)).toBe(''); + expect(secretGet('lakehouse/basic-token', serviceAccountProfile)).toBe(''); + expect(cli.stdout.join('\n')).toContain('My Service'); + expect(cli.stdout.join('\n')).toContain(serviceAccountProfile); + expect(cli.stdout.join('\n')).toMatch(/CATALOG\s+SLUG\s+ACCESS/); + expect(cli.stdout.join('\n')).toMatch(/Analytics\s+analytics\s+ro/); expect(cli.stderr).toEqual([]); - const payload = httpLogJsonPayload(logFile, "/environments/production/service_accounts") as { + const payload = httpLogJsonPayload( + logFile, + '/environments/production/service_accounts' + ) as { label: string; caveats: Record; }; - expect(payload.label).toBe("My Service"); - expect(payload.caveats).toEqual({ catalog1: "ro" }); - expect(payload).toHaveProperty("with_service_oauth_token"); + expect(payload.label).toBe('My Service'); + expect(payload.caveats).toEqual({ catalog1: 'ro' }); + expect(payload).toHaveProperty('with_service_oauth_token'); }); test("leaves the signing-in user's own session unstored", async () => { await completeBrowserLogin( - ["login", "--service-account", "My Service"], + ['login', '--service-account', 'My Service'], DEFAULT_WHOAMI, - "access_token", - serviceAccountMocks(), + 'access_token', + serviceAccountMocks() ); - expect(storedAccessToken("default")).toBe(""); - expect(secretGet("oauth/refresh-token", "default")).toBe(""); - expect(storedAccessToken(serviceAccountProfile)).toBe("svc_access_token"); + expect(storedAccessToken('default')).toBe(''); + expect(secretGet('oauth/refresh-token', 'default')).toBe(''); + expect(storedAccessToken(serviceAccountProfile)).toBe('svc_access_token'); }); - test("reports the catalogs the server granted, not the ones --scope requested", async () => { + test('reports the catalogs the server granted, not the ones --scope requested', async () => { const cli = await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "analytics:rw"], + ['login', '--service-account', 'My Service', '--scope', 'analytics:rw'], DEFAULT_WHOAMI, - "access_token", + 'access_token', serviceAccountMocks({ ...SERVICE_ACCOUNT_RESPONSE, role_assignments: [ - { role: "catalog:reader", resource_kind: "catalog", resource_id: "cat-1" }, - { role: "catalog:writer", resource_kind: "catalog", resource_id: "cat-2" }, + { + role: 'catalog:reader', + resource_kind: 'catalog', + resource_id: 'cat-1', + }, + { + role: 'catalog:writer', + resource_kind: 'catalog', + resource_id: 'cat-2', + }, ], - }), + }) ); - const stdout = cli.stdout.join("\n"); - expect(stdout).toContain("Catalog access"); + const stdout = cli.stdout.join('\n'); + expect(stdout).toContain('Catalog access'); expect(stdout).toMatch(/Analytics\s+analytics\s+ro/); expect(stdout).toMatch(/Events\s+events\s+rw/); - expect(stdout).not.toContain("analytics:rw"); + expect(stdout).not.toContain('analytics:rw'); }); - test("omits assignments that are not catalogs or cannot be resolved to a catalog", async () => { + test('omits assignments that are not catalogs or cannot be resolved to a catalog', async () => { const cli = await completeBrowserLogin( - ["login", "--service-account", "My Service"], + ['login', '--service-account', 'My Service'], DEFAULT_WHOAMI, - "access_token", + 'access_token', serviceAccountMocks({ ...SERVICE_ACCOUNT_RESPONSE, role_assignments: [ - { role: "organization:member", resource_kind: "organization", resource_id: "org-1" }, - { role: "catalog:reader", resource_kind: "catalog", resource_id: "cat-unknown" }, + { + role: 'organization:member', + resource_kind: 'organization', + resource_id: 'org-1', + }, + { + role: 'catalog:reader', + resource_kind: 'catalog', + resource_id: 'cat-unknown', + }, ], - }), + }) ); - const stdout = cli.stdout.join("\n"); - expect(stdout).not.toContain("Catalog access"); - expect(stdout).not.toContain("org-1"); - expect(stdout).not.toContain("cat-unknown"); + const stdout = cli.stdout.join('\n'); + expect(stdout).not.toContain('Catalog access'); + expect(stdout).not.toContain('org-1'); + expect(stdout).not.toContain('cat-unknown'); }); - test("warns when --scope was requested but the server reported no role assignments", async () => { + test('warns when --scope was requested but the server reported no role assignments', async () => { const cli = await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "analytics:ro"], + ['login', '--service-account', 'My Service', '--scope', 'analytics:ro'], DEFAULT_WHOAMI, - "access_token", - serviceAccountMocks({ ...SERVICE_ACCOUNT_RESPONSE, role_assignments: [] }), + 'access_token', + serviceAccountMocks({ ...SERVICE_ACCOUNT_RESPONSE, role_assignments: [] }) ); - const stdout = cli.stdout.join("\n"); - expect(stdout).not.toContain("Catalog access"); - expect(stdout).toContain("could not be confirmed"); + const stdout = cli.stdout.join('\n'); + expect(stdout).not.toContain('Catalog access'); + expect(stdout).toContain('could not be confirmed'); }); - test("fails when the response omits the service OAuth token", async () => { + test('fails when the response omits the service OAuth token', async () => { await expectRejection( completeBrowserLogin( - ["login", "--service-account", "My Service"], + ['login', '--service-account', 'My Service'], DEFAULT_WHOAMI, - "access_token", - [serviceAccountMock({ ...SERVICE_ACCOUNT_RESPONSE, service_oauth_token: undefined })], + 'access_token', + [ + serviceAccountMock({ + ...SERVICE_ACCOUNT_RESPONSE, + service_oauth_token: undefined, + }), + ] ), - "assertion failed retrieving service_account.service_oauth_token", + 'assertion failed retrieving service_account.service_oauth_token' ); }); - test("omits the scope when --scope is not set", async () => { + test('omits the scope when --scope is not set', async () => { await completeBrowserLogin( - ["login", "--service-account", "My Service"], + ['login', '--service-account', 'My Service'], DEFAULT_WHOAMI, - "access_token", - [serviceAccountMock()], + 'access_token', + [serviceAccountMock()] ); - const payload = httpLogJsonPayload(logFile, "/environments/production/service_accounts") as { + const payload = httpLogJsonPayload( + logFile, + '/environments/production/service_accounts' + ) as { caveats?: unknown; }; - expect(payload).not.toHaveProperty("caveats"); + expect(payload).not.toHaveProperty('caveats'); }); - test("--scope ro sends a blanket read-only scope", async () => { + test('--scope ro sends a blanket read-only scope', async () => { await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "ro"], + ['login', '--service-account', 'My Service', '--scope', 'ro'], DEFAULT_WHOAMI, - "access_token", - serviceAccountMocks(), + 'access_token', + serviceAccountMocks() ); - const payload = httpLogJsonPayload(logFile, "/environments/production/service_accounts") as { + const payload = httpLogJsonPayload( + logFile, + '/environments/production/service_accounts' + ) as { caveats: unknown; }; - expect(payload.caveats).toBe("ro"); + expect(payload.caveats).toBe('ro'); }); - test("--scope without --service-account fails before any HTTP call", async () => { + test('--scope without --service-account fails before any HTTP call', async () => { await expectRejection( - runCommandWithTestRuntime(["login", "--scope", "catalog1:ro"]), - "--scope requires --service-account", + runCommandWithTestRuntime(['login', '--scope', 'catalog1:ro']), + '--scope requires --service-account' ); expect(existsSync(logFile)).toBe(false); }); - test("--service-account with --replace-profile fails before any HTTP call", async () => { + test('--service-account with --replace-profile fails before any HTTP call', async () => { await expectRejection( - runCommandWithTestRuntime(["login", "--service-account", "My Service", "--replace-profile"]), - "--replace-profile", + runCommandWithTestRuntime([ + 'login', + '--service-account', + 'My Service', + '--replace-profile', + ]), + '--replace-profile' ); expect(existsSync(logFile)).toBe(false); }); - test("an empty --service-account label fails before any HTTP call", async () => { + test('an empty --service-account label fails before any HTTP call', async () => { await expectRejection( - runCommandWithTestRuntime(["login", "--service-account", " "]), - "non-empty label", + runCommandWithTestRuntime(['login', '--service-account', ' ']), + 'non-empty label' ); expect(existsSync(logFile)).toBe(false); }); - test("malformed --scope fails before any HTTP call", async () => { + test('malformed --scope fails before any HTTP call', async () => { await expectRejection( runCommandWithTestRuntime([ - "login", - "--service-account", - "My Service", - "--scope", - "catalog1:read", + 'login', + '--service-account', + 'My Service', + '--scope', + 'catalog1:read', ]), - "catalog1:read", + 'catalog1:read' ); expect(existsSync(logFile)).toBe(false); }); // A URL rejected late would leave a service account minted server-side whose // token was never stored, and the CLI switched to the empty profile. - test("a rejected --data-plane-url fails before the browser flow and mints nothing", async () => { - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + test('a rejected --data-plane-url fails before the browser flow and mints nothing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); await expectRejection( runCommandWithTestRuntime([ - "login", - "--service-account", - "My Service", - "--data-plane-url", - "http://evil.example.com", + 'login', + '--service-account', + 'My Service', + '--data-plane-url', + 'http://evil.example.com', ]), - "Insecure HTTP URL", + 'Insecure HTTP URL' ); expect(existsSync(logFile)).toBe(false); expect(profileExists(serviceAccountProfile)).toBe(false); - expect(getActiveProfileName()).toBe("default"); + expect(getActiveProfileName()).toBe('default'); }); - test("surfaces a 403 from the service account endpoint", async () => { + test('surfaces a 403 from the service account endpoint', async () => { try { await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "catalog1:ro"], + ['login', '--service-account', 'My Service', '--scope', 'catalog1:ro'], DEFAULT_WHOAMI, - "access_token", + 'access_token', [ serviceAccountMock( { - error: { code: "forbidden", message: "Only org admins can create service accounts" }, + error: { + code: 'forbidden', + message: 'Only org admins can create service accounts', + }, }, - 403, + 403 ), - ], + ] ); - expect.unreachable("login --service-account should have failed"); + expect.unreachable('login --service-account should have failed'); } catch (error) { expect(error).toBeInstanceOf(HttpError); const httpError = error as HttpError; - expect(httpError.message).toContain("service account"); - expect(httpError.parsedDetail).toBe("Only org admins can create service accounts"); + expect(httpError.message).toContain('service account'); + expect(httpError.parsedDetail).toBe( + 'Only org admins can create service accounts' + ); } }); - test("names the missing service account endpoint on 404", async () => { + test('names the missing service account endpoint on 404', async () => { try { await completeBrowserLogin( - ["login", "--service-account", "My Service", "--scope", "catalog1:ro"], + ['login', '--service-account', 'My Service', '--scope', 'catalog1:ro'], DEFAULT_WHOAMI, - "access_token", - [serviceAccountMock("Not Found", 404)], + 'access_token', + [serviceAccountMock('Not Found', 404)] ); - expect.unreachable("login --service-account should have failed"); + expect.unreachable('login --service-account should have failed'); } catch (error) { expect(error).toBeInstanceOf(HttpError); const httpError = error as HttpError; - expect(httpError.message).toContain("service account"); - expect(httpError.message).toContain("Not found (404)"); - expect(httpError.details).toContain("/environments/production/service_accounts"); + expect(httpError.message).toContain('service account'); + expect(httpError.message).toContain('Not found (404)'); + expect(httpError.details).toContain( + '/environments/production/service_accounts' + ); } }); }); diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index e17eddb7..a5c51ed6 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -1,15 +1,15 @@ -import { defineCommand } from "@/lib/command.ts"; -import { assertRetrieved, ConfigurationError } from "@/lib/errors.ts"; -import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; -import { refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts"; -import { httpSend } from "@/lib/http.ts"; -import { resolveManagementApiRoot } from "@/lib/config.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { runLoginFlow, type TokenResponse } from "@/lib/oauth-flow.ts"; -import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import type { WhoamiResponse } from "@/lib/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/lib/management/render.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { assertRetrieved, ConfigurationError } from '@/lib/errors.ts'; +import { getCliContext, isJsonOutput, setCliContext } from '@/context.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; +import { refreshCliRuntimeContext, type OutputSink } from '@/lib/runtime.ts'; +import { httpSend } from '@/lib/http.ts'; +import { resolveManagementApiRoot } from '@/lib/config.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { runLoginFlow, type TokenResponse } from '@/lib/oauth-flow.ts'; +import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; +import type { WhoamiResponse } from '@/lib/management/model.ts'; +import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; import { assertNoEnvConfigMode, createEmptyProfile, @@ -17,9 +17,13 @@ import { deriveServiceAccountProfileName, profileHasAnyAuthConfigured, updateProfile, -} from "@/lib/profile/model.ts"; -import { profileExists, resolveWorkingProfile, setActiveProfile } from "@/lib/profile-store.ts"; -import { secretSet } from "@/lib/secrets.ts"; +} from '@/lib/profile/model.ts'; +import { + profileExists, + resolveWorkingProfile, + setActiveProfile, +} from '@/lib/profile-store.ts'; +import { secretSet } from '@/lib/secrets.ts'; import { fetchEnvironmentCatalogs, parseServiceAccountScope, @@ -29,65 +33,68 @@ import { type ServiceAccountAccessMode, type ServiceAccountRoleAssignment, type ServiceAccountScope, -} from "@/lib/service-account-provision.ts"; -import { document, section, span, table } from "@/ui/document.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +} from '@/lib/service-account-provision.ts'; +import { document, section, span, table } from '@/ui/document.ts'; +import { renderDocumentText } from '@/ui/renderers/terminal.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export const loginCommand = defineCommand({ metadata: { - name: "login", - commandGroup: "platform", - description: "Sign in with your browser (OAuth) and store the session.", + name: 'login', + commandGroup: 'platform', + description: 'Sign in with your browser (OAuth) and store the session.', examples: [ - "altertable login", - "altertable login --org acme --env staging", - "altertable login --replace-profile", + 'altertable login', + 'altertable login --org acme --env staging', + 'altertable login --replace-profile', 'altertable login --service-account "CI Bot" --scope analytics:ro', 'altertable login --service-account "CI Bot" --scope ro', ], }, args: { - "control-plane-url": { - type: "string", + 'control-plane-url': { + type: 'string', description: - "Control-plane server root to log in against; saved to the profile only on success (the CLI appends /oauth and /rest/v1). Default: https://app.altertable.ai", + 'Control-plane server root to log in against; saved to the profile only on success (the CLI appends /oauth and /rest/v1). Default: https://app.altertable.ai', }, - "data-plane-url": { - type: "string", + 'data-plane-url': { + type: 'string', description: - "Data-plane base URL saved to the profile only on successful login. Default: https://api.altertable.ai", + 'Data-plane base URL saved to the profile only on successful login. Default: https://api.altertable.ai', }, - "allow-insecure-http": { - type: "boolean", + 'allow-insecure-http': { + type: 'boolean', description: - "Allow http:// URLs other than localhost for --control-plane-url (for development only)", + 'Allow http:// URLs other than localhost for --control-plane-url (for development only)', }, - "replace-profile": { - type: "boolean", - description: "Store the login session in the current profile instead of switching profiles", + 'replace-profile': { + type: 'boolean', + description: + 'Store the login session in the current profile instead of switching profiles', }, org: { - type: "string", - valueHint: "SLUG", - description: "Preselect this organization in the browser authorization page", + type: 'string', + valueHint: 'SLUG', + description: + 'Preselect this organization in the browser authorization page', }, env: { - type: "string", - valueHint: "SLUG", - description: "Preselect this environment in the browser authorization page", + type: 'string', + valueHint: 'SLUG', + description: + 'Preselect this environment in the browser authorization page', }, - "service-account": { - type: "string", - valueHint: "LABEL", + 'service-account': { + type: 'string', + valueHint: 'LABEL', description: - "Create an environment-scoped service account with this label and store its access token instead of your login session", + 'Create an environment-scoped service account with this label and store its access token instead of your login session', }, scope: { - type: "string", - valueHint: "MODE|CATALOG:MODE,...", + type: 'string', + valueHint: 'MODE|CATALOG:MODE,...', description: - "Request a service account limited to `ro` or `rw` on every catalog, or to specific catalogs, e.g. `analytics:ro,staging:rw` (requires --service-account). The catalog access the server actually grants is reported on success.", + 'Request a service account limited to `ro` or `rw` on every catalog, or to specific catalogs, e.g. `analytics:ro,staging:rw` (requires --service-account). The catalog access the server actually grants is reported on success.', }, }, run: ({ args, sink }) => runLogin(args as LoginArgs, sink), @@ -100,10 +107,10 @@ function isInteractiveTerminal(): boolean { function assertInteractiveLogin(): void { if (isJsonOutput(getCliContext()) || !isInteractiveTerminal()) { throw new ConfigurationError( - "altertable login needs an interactive terminal with a browser and does not support --json or --agent.\n" + - "For headless setups, pipe a management key:\n" + + 'altertable login needs an interactive terminal with a browser and does not support --json or --agent.\n' + + 'For headless setups, pipe a management key:\n' + ` printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env \n` + - "Or set ALTERTABLE_API_KEY and ALTERTABLE_ENV.", + 'Or set ALTERTABLE_API_KEY and ALTERTABLE_ENV.' ); } } @@ -111,31 +118,37 @@ function assertInteractiveLogin(): void { type LoginProfileMetadata = { environment: string; profileName: string; - profileAction: "created" | "reused" | "replaced" | "unchanged"; + profileAction: 'created' | 'reused' | 'replaced' | 'unchanged'; }; -type LoginProfileAction = LoginProfileMetadata["profileAction"]; +type LoginProfileAction = LoginProfileMetadata['profileAction']; const LOGIN_PROFILE_MESSAGES = { - created: "created profile", - reused: "using existing profile", - replaced: "replaced current profile with", - unchanged: "using profile", + created: 'created profile', + reused: 'using existing profile', + replaced: 'replaced current profile with', + unchanged: 'using profile', } satisfies Record; -function loginProfileName(whoami: WhoamiResponse, environment: string, fallback: string): string { +function loginProfileName( + whoami: WhoamiResponse, + environment: string, + fallback: string +): string { const organizationSlug = whoami.organization?.slug; - return organizationSlug ? deriveProfileName(organizationSlug, environment) : fallback; + return organizationSlug + ? deriveProfileName(organizationSlug, environment) + : fallback; } function selectLoginProfile( whoami: WhoamiResponse, environment: string, - replaceCurrentProfile: boolean, -): Pick { + replaceCurrentProfile: boolean +): Pick { const currentProfile = resolveWorkingProfile(getCliContext().profile); if (replaceCurrentProfile) { - return { profileName: currentProfile, profileAction: "replaced" }; + return { profileName: currentProfile, profileAction: 'replaced' }; } // Sign into the current profile while it has no credentials of its own yet — a @@ -144,20 +157,20 @@ function selectLoginProfile( // clobber an existing session. (Env credentials never reach here — login is // refused up front while they are set.) if (!profileHasAnyAuthConfigured(currentProfile)) { - return { profileName: currentProfile, profileAction: "unchanged" }; + return { profileName: currentProfile, profileAction: 'unchanged' }; } const targetProfile = loginProfileName(whoami, environment, currentProfile); if (targetProfile === currentProfile) { - return { profileName: targetProfile, profileAction: "unchanged" }; + return { profileName: targetProfile, profileAction: 'unchanged' }; } let profileAction: LoginProfileAction; if (profileExists(targetProfile)) { - profileAction = "reused"; + profileAction = 'reused'; } else { createEmptyProfile(targetProfile); - profileAction = "created"; + profileAction = 'created'; } setActiveProfile(targetProfile); setCliContext({ ...getCliContext(), profile: targetProfile }); @@ -166,19 +179,23 @@ function selectLoginProfile( } function requireWhoamiEnvironment(whoami: WhoamiResponse): string { - return assertRetrieved(whoami.environment_slug, "login", "whoami.environment_slug"); + return assertRetrieved( + whoami.environment_slug, + 'login', + 'whoami.environment_slug' + ); } function storeLoginProfileMetadata( whoami: WhoamiResponse, args: LoginArgs, - controlPlane: string, + controlPlane: string ): LoginProfileMetadata { const environment = requireWhoamiEnvironment(whoami); const { profileName, profileAction } = selectLoginProfile( whoami, environment, - Boolean(args["replace-profile"]), + Boolean(args['replace-profile']) ); updateProfile(profileName, { environment, @@ -188,7 +205,7 @@ function storeLoginProfileMetadata( principalName: whoami.principal?.name, principalEmail: whoami.principal?.email ?? undefined, principalSlug: whoami.principal?.slug ?? undefined, - ...(args["data-plane-url"] ? { dataPlane: args["data-plane-url"] } : {}), + ...(args['data-plane-url'] ? { dataPlane: args['data-plane-url'] } : {}), controlPlane, }); @@ -196,13 +213,13 @@ function storeLoginProfileMetadata( } type LoginArgs = { - "data-plane-url"?: string; - "control-plane-url"?: string; - "allow-insecure-http"?: boolean; - "replace-profile"?: boolean; + 'data-plane-url'?: string; + 'control-plane-url'?: string; + 'allow-insecure-http'?: boolean; + 'replace-profile'?: boolean; org?: string; env?: string; - "service-account"?: string; + 'service-account'?: string; scope?: string; }; @@ -213,14 +230,14 @@ type ServiceAccountLoginRequest = { function resolveLoginEndpoints( args: LoginArgs, - profileName: string, + profileName: string ): { controlPlane: string; oauthBase: string; managementApiBase: string } { - const override = args["control-plane-url"]; + const override = args['control-plane-url']; const controlPlane = override - ? override.replace(/\/$/, "") + ? override.replace(/\/$/, '') : resolveManagementApiRoot(profileName); assertAllowedApiBase(controlPlane, { - allowInsecureHttp: Boolean(args["allow-insecure-http"]), + allowInsecureHttp: Boolean(args['allow-insecure-http']), }); return { controlPlane, @@ -233,44 +250,50 @@ function resolveLoginEndpoints( // auth, which may still be a different org's session — decides who we are. async function fetchLoginWhoami( oauthResponse: TokenResponse, - managementApiBase: string, + managementApiBase: string ): Promise { const body = await httpSend({ - method: "GET", - url: `${managementApiBase}${encodeManagementEndpoint("/whoami")}`, + method: 'GET', + url: `${managementApiBase}${encodeManagementEndpoint('/whoami')}`, authHeader: `Authorization: Bearer ${oauthResponse.access_token}`, - authPlane: "management", + authPlane: 'management', }); return JSON.parse(body) as WhoamiResponse; } function applyLoginDataPlaneUrl(args: LoginArgs): void { - if (args["data-plane-url"]) { - assertAllowedApiBase(args["data-plane-url"], { - allowInsecureHttp: Boolean(args["allow-insecure-http"]), + if (args['data-plane-url']) { + assertAllowedApiBase(args['data-plane-url'], { + allowInsecureHttp: Boolean(args['allow-insecure-http']), }); } } -function parseServiceAccountLoginArgs(args: LoginArgs): ServiceAccountLoginRequest | undefined { - const label = args["service-account"]; +function parseServiceAccountLoginArgs( + args: LoginArgs +): ServiceAccountLoginRequest | undefined { + const label = args['service-account']; if (args.scope !== undefined && label === undefined) { - throw new ConfigurationError("--scope requires --service-account."); + throw new ConfigurationError('--scope requires --service-account.'); } if (label === undefined) { return undefined; } - if (args["replace-profile"]) { + if (args['replace-profile']) { throw new ConfigurationError( - "--service-account cannot be combined with --replace-profile; a service account always gets its own profile.", + '--service-account cannot be combined with --replace-profile; a service account always gets its own profile.' ); } if (label.trim().length === 0) { - throw new ConfigurationError("--service-account requires a non-empty label."); + throw new ConfigurationError( + '--service-account requires a non-empty label.' + ); } return { label: label.trim(), - ...(args.scope === undefined ? {} : { scope: parseServiceAccountScope(args.scope) }), + ...(args.scope === undefined + ? {} + : { scope: parseServiceAccountScope(args.scope) }), }; } @@ -284,14 +307,18 @@ function activateDerivedProfile(profileName: string): void { } function catalogAccessMode( - assignment: ServiceAccountRoleAssignment, + assignment: ServiceAccountRoleAssignment ): ServiceAccountAccessMode | null { - if (assignment.role === "catalog:writer") return "rw"; - if (assignment.role === "catalog:reader") return "ro"; + if (assignment.role === 'catalog:writer') return 'rw'; + if (assignment.role === 'catalog:reader') return 'ro'; return null; } -type CatalogAccessRow = { name: string; slug: string; access: ServiceAccountAccessMode }; +type CatalogAccessRow = { + name: string; + slug: string; + access: ServiceAccountAccessMode; +}; /** * Lists the catalogs the service account can reach, by name. Role assignments @@ -300,7 +327,7 @@ type CatalogAccessRow = { name: string; slug: string; access: ServiceAccountAcce */ function summarizeCatalogAccess( created: EnvironmentServiceAccountResponse, - catalogs: Map, + catalogs: Map ): CatalogAccessRow[] { const rows = new Map(); for (const assignment of created.role_assignments ?? []) { @@ -308,7 +335,7 @@ function summarizeCatalogAccess( const catalog = catalogs.get(assignment.resource_id); if (!access || !catalog) continue; const existing = rows.get(assignment.resource_id); - if (!existing || access === "rw") { + if (!existing || access === 'rw') { rows.set(assignment.resource_id, { ...catalog, access }); } } @@ -322,14 +349,18 @@ function formatCatalogAccessTable(rows: CatalogAccessRow[]): string { table({ rows, columns: [ - { header: "CATALOG", cell: (row) => [span(row.name, "strong")], flex: true }, - { header: "SLUG", cell: (row) => [span(row.slug, "accent")] }, - { header: "ACCESS", cell: (row) => [span(row.access, "subtle")] }, + { + header: 'CATALOG', + cell: row => [span(row.name, 'strong')], + flex: true, + }, + { header: 'SLUG', cell: row => [span(row.slug, 'accent')] }, + { header: 'ACCESS', cell: row => [span(row.access, 'subtle')] }, ], - emptyMessage: "", - }), - ), - ), + emptyMessage: '', + }) + ) + ) ); } @@ -340,13 +371,13 @@ async function completeServiceAccountLogin( controlPlane: string, managementApiBase: string, request: ServiceAccountLoginRequest, - sink: OutputSink, + sink: OutputSink ): Promise { const environment = requireWhoamiEnvironment(whoami); const organizationSlug = assertRetrieved( whoami.organization?.slug, - "login", - "whoami.organization.slug", + 'login', + 'whoami.organization.slug' ); const created = await provisionEnvironmentServiceAccount({ @@ -360,21 +391,21 @@ async function completeServiceAccountLogin( const profileName = deriveServiceAccountProfileName( organizationSlug, environment, - created.service_account.slug, + created.service_account.slug ); activateDerivedProfile(profileName); updateProfile(profileName, { environment, organizationSlug, organizationName: whoami.organization?.name, - principalType: "ServiceAccount", + principalType: 'ServiceAccount', principalName: created.service_account.label, principalSlug: created.service_account.slug, - ...(args["data-plane-url"] ? { dataPlane: args["data-plane-url"] } : {}), + ...(args['data-plane-url'] ? { dataPlane: args['data-plane-url'] } : {}), controlPlane, }); - secretSet("oauth/access-token", created.service_oauth_token, profileName); + secretSet('oauth/access-token', created.service_oauth_token, profileName); const catalogs = await fetchEnvironmentCatalogs({ managementApiBase, @@ -384,14 +415,14 @@ async function completeServiceAccountLogin( const catalogAccess = summarizeCatalogAccess(created, catalogs); const lines = [ renderDisplayText([ - span("✓", "success"), + span('✓', 'success'), span( - ` Created service account "${created.service_account.label}" — profile "${profileName}" is now active; environment "${environment}".`, + ` Created service account "${created.service_account.label}" — profile "${profileName}" is now active; environment "${environment}".` ), ]), ]; if (catalogAccess.length > 0) { - lines.push("", renderDisplayText([span("Catalog access", "subtle")])); + lines.push('', renderDisplayText([span('Catalog access', 'subtle')])); lines.push(formatCatalogAccessTable(catalogAccess)); } if (request.scope && (created.role_assignments ?? []).length === 0) { @@ -399,9 +430,9 @@ async function completeServiceAccountLogin( renderDisplayText([ span( "! The server reported no role assignments, so the requested restriction could not be confirmed. Check the service account's access before using this token.", - "warning", + 'warning' ), - ]), + ]) ); } for (const line of lines) { @@ -411,7 +442,7 @@ async function completeServiceAccountLogin( async function runLogin(args: LoginArgs, sink: OutputSink): Promise { const serviceAccountRequest = parseServiceAccountLoginArgs(args); - for (const flag of ["org", "env"] as const) { + for (const flag of ['org', 'env'] as const) { if (args[flag] !== undefined && args[flag].trim().length === 0) { throw new ConfigurationError(`--${flag} requires a non-empty slug.`); } @@ -423,7 +454,7 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { const currentProfile = resolveWorkingProfile(getCliContext().profile); const { controlPlane, oauthBase, managementApiBase } = resolveLoginEndpoints( args, - currentProfile, + currentProfile ); // Past this point the flow is profile-free so it can't accidentally read another org's stored session. @@ -441,7 +472,7 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { controlPlane, managementApiBase, serviceAccountRequest, - sink, + sink ); return; } @@ -450,7 +481,7 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { const { environment, profileName, profileAction } = storeLoginProfileMetadata( whoami, args, - controlPlane, + controlPlane ); storeOAuthTokens(oauthResponse, profileName); refreshCliRuntimeContext(getCliContext()); @@ -459,8 +490,10 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { const profileMessage = `${LOGIN_PROFILE_MESSAGES[profileAction]} "${profileName}"`; sink.writeHuman( renderDisplayText([ - span("✓", "success"), - span(` Logged in (${identity}) — ${profileMessage}; environment "${environment}".`), - ]), + span('✓', 'success'), + span( + ` Logged in (${identity}) — ${profileMessage}; environment "${environment}".` + ), + ]) ); } diff --git a/cli/src/commands/logout/index.test.ts b/cli/src/commands/logout/index.test.ts index 4814e2b1..7c89497b 100644 --- a/cli/src/commands/logout/index.test.ts +++ b/cli/src/commands/logout/index.test.ts @@ -1,18 +1,22 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configGet, configSet } from "@/lib/config.ts"; -import { getActiveProfileName, listProfileNames, setActiveProfile } from "@/lib/profile-store.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; - -let testHome = ""; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configGet, configSet } from '@/lib/config.ts'; +import { + getActiveProfileName, + listProfileNames, + setActiveProfile, +} from '@/lib/profile-store.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; + +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-logout-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-logout-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; }); afterEach(() => { @@ -23,99 +27,108 @@ afterEach(() => { delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; }); -describe("logout command", () => { - test("clears stored configuration for all profiles", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); +describe('logout command', () => { + test('clears stored configuration for all profiles', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); - await runCommandWithTestRuntime(["logout"]); + await runCommandWithTestRuntime(['logout']); - expect(secretGet("api-key", "default")).toBe(""); - expect(existsSync(join(testHome, "profiles"))).toBe(false); + expect(secretGet('api-key', 'default')).toBe(''); + expect(existsSync(join(testHome, 'profiles'))).toBe(false); }); - test("--except-current keeps the active profile and removes the others", async () => { - configSet("api_key_env", "production", "keeper"); - secretSet("api-key", "atm_keeper", "keeper"); - configSet("api_key_env", "staging", "other"); - secretSet("api-key", "atm_other", "other"); - setActiveProfile("keeper"); + test('--except-current keeps the active profile and removes the others', async () => { + configSet('api_key_env', 'production', 'keeper'); + secretSet('api-key', 'atm_keeper', 'keeper'); + configSet('api_key_env', 'staging', 'other'); + secretSet('api-key', 'atm_other', 'other'); + setActiveProfile('keeper'); - await runCommandWithTestRuntime(["logout", "--except-current"]); + await runCommandWithTestRuntime(['logout', '--except-current']); - expect(getActiveProfileName()).toBe("keeper"); - expect(listProfileNames()).toEqual(["keeper"]); - expect(secretGet("api-key", "keeper")).toBe("atm_keeper"); - expect(configGet("api_key_env", "keeper")).toBe("production"); - expect(secretGet("api-key", "other")).toBe(""); + expect(getActiveProfileName()).toBe('keeper'); + expect(listProfileNames()).toEqual(['keeper']); + expect(secretGet('api-key', 'keeper')).toBe('atm_keeper'); + expect(configGet('api_key_env', 'keeper')).toBe('production'); + expect(secretGet('api-key', 'other')).toBe(''); }); // secretGet returns "" for any unknown profile, so assert on the store itself: // a logout that only unlinked directories would still pass the check above. test("--except-current erases the removed profiles' secrets from the store", async () => { - secretSet("api-key", "atm_keeper", "keeper"); - secretSet("api-key", "atm_other", "other"); - secretSet("oauth/access-token", "oauth_other", "other"); - setActiveProfile("keeper"); - expect(readFileSync(join(testHome, "credentials"), "utf8")).toContain("atm_other"); - - await runCommandWithTestRuntime(["logout", "--except-current"]); - - const credentials = readFileSync(join(testHome, "credentials"), "utf8"); - expect(credentials).not.toContain("atm_other"); - expect(credentials).not.toContain("oauth_other"); - expect(credentials).toContain("atm_keeper"); - }); + secretSet('api-key', 'atm_keeper', 'keeper'); + secretSet('api-key', 'atm_other', 'other'); + secretSet('oauth/access-token', 'oauth_other', 'other'); + setActiveProfile('keeper'); + expect(readFileSync(join(testHome, 'credentials'), 'utf8')).toContain( + 'atm_other' + ); - test("--except-current keeps the profile selected with --profile", async () => { - configSet("api_key_env", "production", "keeper"); - secretSet("api-key", "atm_keeper", "keeper"); - configSet("api_key_env", "staging", "other"); - secretSet("api-key", "atm_other", "other"); - setActiveProfile("other"); + await runCommandWithTestRuntime(['logout', '--except-current']); - await runCommandWithTestRuntime(["--profile", "keeper", "logout", "--except-current"]); + const credentials = readFileSync(join(testHome, 'credentials'), 'utf8'); + expect(credentials).not.toContain('atm_other'); + expect(credentials).not.toContain('oauth_other'); + expect(credentials).toContain('atm_keeper'); + }); - expect(getActiveProfileName()).toBe("keeper"); - expect(listProfileNames()).toEqual(["keeper"]); - expect(secretGet("api-key", "keeper")).toBe("atm_keeper"); + test('--except-current keeps the profile selected with --profile', async () => { + configSet('api_key_env', 'production', 'keeper'); + secretSet('api-key', 'atm_keeper', 'keeper'); + configSet('api_key_env', 'staging', 'other'); + secretSet('api-key', 'atm_other', 'other'); + setActiveProfile('other'); + + await runCommandWithTestRuntime([ + '--profile', + 'keeper', + 'logout', + '--except-current', + ]); + + expect(getActiveProfileName()).toBe('keeper'); + expect(listProfileNames()).toEqual(['keeper']); + expect(secretGet('api-key', 'keeper')).toBe('atm_keeper'); }); - test("--except-current succeeds when there is nothing else to clear", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); + test('--except-current succeeds when there is nothing else to clear', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); - await runCommandWithTestRuntime(["logout", "--except-current"]); + await runCommandWithTestRuntime(['logout', '--except-current']); - expect(listProfileNames()).toEqual(["default"]); - expect(secretGet("api-key", "default")).toBe("atm_stored"); + expect(listProfileNames()).toEqual(['default']); + expect(secretGet('api-key', 'default')).toBe('atm_stored'); }); - test("refuses to run while environment configuration is active", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('refuses to run while environment configuration is active', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); + process.env.ALTERTABLE_API_KEY = 'atm_env'; - expect(runCommandWithTestRuntime(["logout"])).rejects.toThrow( - "Profile management commands aren't available when configuring through environment variables.", + expect(runCommandWithTestRuntime(['logout'])).rejects.toThrow( + "Profile management commands aren't available when configuring through environment variables." + ); + expect(runCommandWithTestRuntime(['logout'])).rejects.toThrow( + 'ALTERTABLE_API_KEY' ); - expect(runCommandWithTestRuntime(["logout"])).rejects.toThrow("ALTERTABLE_API_KEY"); delete process.env.ALTERTABLE_API_KEY; - expect(secretGet("api-key", "default")).toBe("atm_stored"); - expect(existsSync(join(testHome, "profiles", "default"))).toBe(true); + expect(secretGet('api-key', 'default')).toBe('atm_stored'); + expect(existsSync(join(testHome, 'profiles', 'default'))).toBe(true); }); - test("refuses for lakehouse-only environment configuration", async () => { - configSet("user", "alice", "default"); - secretSet("lakehouse/password", "s3cret", "default"); - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-token"; + test('refuses for lakehouse-only environment configuration', async () => { + configSet('user', 'alice', 'default'); + secretSet('lakehouse/password', 's3cret', 'default'); + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-token'; - expect(runCommandWithTestRuntime(["logout"])).rejects.toThrow( - "Profile management commands aren't available when configuring through environment variables.", + expect(runCommandWithTestRuntime(['logout'])).rejects.toThrow( + "Profile management commands aren't available when configuring through environment variables." ); delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; - expect(secretGet("lakehouse/password", "default")).toBe("s3cret"); + expect(secretGet('lakehouse/password', 'default')).toBe('s3cret'); }); }); diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts index 1bf0e611..627465fd 100644 --- a/cli/src/commands/logout/index.ts +++ b/cli/src/commands/logout/index.ts @@ -1,22 +1,22 @@ -import { defineCommand } from "@/lib/command.ts"; -import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; -import { configureRunClear } from "@/lib/profile-configure-core.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; +import { configureRunClear } from '@/lib/profile-configure-core.ts'; export const logoutCommand = defineCommand({ metadata: { - name: "logout", - commandGroup: "platform", - description: "Remove stored credentials and settings for all profiles.", - examples: ["altertable logout", "altertable logout --except-current"], + name: 'logout', + commandGroup: 'platform', + description: 'Remove stored credentials and settings for all profiles.', + examples: ['altertable logout', 'altertable logout --except-current'], }, args: { - "except-current": { - type: "boolean", - description: "Keep the current profile and remove every other profile", + 'except-current': { + type: 'boolean', + description: 'Keep the current profile and remove every other profile', }, }, run({ args, sink }) { assertNoEnvConfigMode(); - configureRunClear(sink, { exceptCurrent: Boolean(args["except-current"]) }); + configureRunClear(sink, { exceptCurrent: Boolean(args['except-current']) }); }, }); diff --git a/cli/src/commands/profile/configure.ts b/cli/src/commands/profile/configure.ts index ba482008..c0bd88e3 100644 --- a/cli/src/commands/profile/configure.ts +++ b/cli/src/commands/profile/configure.ts @@ -1,29 +1,29 @@ -import { getCliContext } from "@/context.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; -import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; +import { getCliContext } from '@/context.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; import { configureArgs, runProfileConfigure, type ConfigureCommandArgs, -} from "@/lib/profile-configure.ts"; -import { optionalArg } from "@/commands/profile/lib/profile.ts"; +} from '@/lib/profile-configure.ts'; +import { optionalArg } from '@/commands/profile/lib/profile.ts'; export const profileConfigureCommand = defineCommand({ metadata: { - name: "configure", + name: 'configure', description: "Create or update a profile's credentials and settings", examples: [ - "altertable profile configure", - "altertable profile configure acme_production --api-key atm_xxx --env production", - "altertable profile configure acme_staging --user alice --password-stdin", - "altertable profile configure --scope lakehouse", + 'altertable profile configure', + 'altertable profile configure acme_production --api-key atm_xxx --env production', + 'altertable profile configure acme_staging --user alice --password-stdin', + 'altertable profile configure --scope lakehouse', ], }, args: { name: { - type: "positional", - description: "Profile name (default: selected or active profile)", + type: 'positional', + description: 'Profile name (default: selected or active profile)', required: false, }, ...configureArgs, @@ -32,15 +32,19 @@ export const profileConfigureCommand = defineCommand({ assertNoEnvConfigMode(); const explicitProfile = optionalArg(args.name); const selectedProfile = getCliContext().profile; - if (explicitProfile && selectedProfile && explicitProfile !== selectedProfile) { + if ( + explicitProfile && + selectedProfile && + explicitProfile !== selectedProfile + ) { throw new CliError( - `Profile name "${explicitProfile}" does not match global --profile "${selectedProfile}".`, + `Profile name "${explicitProfile}" does not match global --profile "${selectedProfile}".` ); } await runProfileConfigure( args as ConfigureCommandArgs, sink, - explicitProfile ?? selectedProfile, + explicitProfile ?? selectedProfile ); }, }); diff --git a/cli/src/commands/profile/current.ts b/cli/src/commands/profile/current.ts index 0bed5576..0e6e5ab0 100644 --- a/cli/src/commands/profile/current.ts +++ b/cli/src/commands/profile/current.ts @@ -1,14 +1,18 @@ -import { getActiveProfileName } from "@/lib/profile-store.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { getActiveProfileName } from '@/lib/profile-store.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileCurrentCommand = defineCommand({ - metadata: { name: "current", description: "Show the active profile name" }, + metadata: { name: 'current', description: 'Show the active profile name' }, async run({ sink }) { const profileName = getActiveProfileName(); await writeCommandOutput( - { kind: "normalized", data: { active_profile: profileName }, humanText: profileName }, - sink, + { + kind: 'normalized', + data: { active_profile: profileName }, + humanText: profileName, + }, + sink ); }, }); diff --git a/cli/src/commands/profile/delete.ts b/cli/src/commands/profile/delete.ts index d89440e8..a338febe 100644 --- a/cli/src/commands/profile/delete.ts +++ b/cli/src/commands/profile/delete.ts @@ -1,27 +1,27 @@ -import { assertNoEnvConfigMode, deleteProfile } from "@/lib/profile/model.ts"; -import { CliError } from "@/lib/errors.ts"; -import { requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { assertNoEnvConfigMode, deleteProfile } from '@/lib/profile/model.ts'; +import { CliError } from '@/lib/errors.ts'; +import { requireProfileName } from '@/commands/profile/lib/profile.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileDeleteCommand = defineCommand({ - metadata: { name: "delete", description: "Delete a profile" }, + metadata: { name: 'delete', description: 'Delete a profile' }, args: { - name: { type: "positional", description: "Profile name", required: true }, - yes: { type: "boolean", description: "Confirm deletion" }, + name: { type: 'positional', description: 'Profile name', required: true }, + yes: { type: 'boolean', description: 'Confirm deletion' }, }, async run({ args, sink }) { - if (!args.yes) throw new CliError("Pass --yes to delete a profile."); + if (!args.yes) throw new CliError('Pass --yes to delete a profile.'); const profileName = requireProfileName(args.name); assertNoEnvConfigMode(); deleteProfile(profileName); await writeCommandOutput( { - kind: "ack", + kind: 'ack', data: { deleted: profileName }, metadataMessage: `Deleted profile ${profileName}.`, }, - sink, + sink ); }, }); diff --git a/cli/src/commands/profile/env.ts b/cli/src/commands/profile/env.ts index 1dc1bf95..fb15d2be 100644 --- a/cli/src/commands/profile/env.ts +++ b/cli/src/commands/profile/env.ts @@ -1,31 +1,33 @@ -import { buildProfileShellExportView } from "@/lib/profile/views.ts"; +import { buildProfileShellExportView } from '@/lib/profile/views.ts'; import { profileNameArgOrActive, requireStoredProfileForExport, -} from "@/commands/profile/lib/profile.ts"; -import { renderShellExportView } from "@/ui/shell/render.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +} from '@/commands/profile/lib/profile.ts'; +import { renderShellExportView } from '@/ui/shell/render.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileEnvCommand = defineCommand({ - metadata: { name: "env", description: "Print shell exports for a profile" }, + metadata: { name: 'env', description: 'Print shell exports for a profile' }, args: { name: { - type: "positional", - description: "Profile name (default: active profile)", + type: 'positional', + description: 'Profile name (default: active profile)', required: false, }, }, async run({ args, sink }) { - const profileName = requireStoredProfileForExport(profileNameArgOrActive(args)); + const profileName = requireStoredProfileForExport( + profileNameArgOrActive(args) + ); const view = buildProfileShellExportView(profileName); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: { profile: profileName, env: view.env }, humanText: renderShellExportView(view), }, - sink, + sink ); }, }); diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts index 078995fb..68a57277 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -1,29 +1,29 @@ -import { defineCommand } from "@/lib/command.ts"; -import { profileConfigureCommand } from "@/commands/profile/configure.ts"; -import { profileCurrentCommand } from "@/commands/profile/current.ts"; -import { profileDeleteCommand } from "@/commands/profile/delete.ts"; -import { profileEnvCommand } from "@/commands/profile/env.ts"; -import { profileListCommand } from "@/commands/profile/list.ts"; -import { profileRenameCommand } from "@/commands/profile/rename.ts"; -import { profileShowCommand } from "@/commands/profile/show.ts"; -import { profileStatusCommand } from "@/commands/profile/status.ts"; -import { profileSwitchCommand } from "@/commands/profile/switch.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { profileConfigureCommand } from '@/commands/profile/configure.ts'; +import { profileCurrentCommand } from '@/commands/profile/current.ts'; +import { profileDeleteCommand } from '@/commands/profile/delete.ts'; +import { profileEnvCommand } from '@/commands/profile/env.ts'; +import { profileListCommand } from '@/commands/profile/list.ts'; +import { profileRenameCommand } from '@/commands/profile/rename.ts'; +import { profileShowCommand } from '@/commands/profile/show.ts'; +import { profileStatusCommand } from '@/commands/profile/status.ts'; +import { profileSwitchCommand } from '@/commands/profile/switch.ts'; export const profileCommand = defineCommand({ metadata: { - name: "profile", - commandGroup: "platform", - description: "Manage named profiles and stored credentials.", + name: 'profile', + commandGroup: 'platform', + description: 'Manage named profiles and stored credentials.', examples: [ - "altertable profile show", - "altertable profile configure", - "altertable profile configure acme_production --api-key atm_xxx --env production", - "altertable profile configure --scope lakehouse", - "altertable profile list", - "altertable profile switch", - "altertable profile status", + 'altertable profile show', + 'altertable profile configure', + 'altertable profile configure acme_production --api-key atm_xxx --env production', + 'altertable profile configure --scope lakehouse', + 'altertable profile list', + 'altertable profile switch', + 'altertable profile status', 'eval "$(altertable profile env acme_staging)"', - "altertable --profile acme_staging profile show", + 'altertable --profile acme_staging profile show', ], }, subcommands: { @@ -43,8 +43,8 @@ export const profileCommand = defineCommand({ }); function throwNoProfileCommand(): never { - const error = new Error("No command specified.") as Error & { code: string }; - error.name = "CLIError"; - error.code = "E_NO_COMMAND"; + const error = new Error('No command specified.') as Error & { code: string }; + error.name = 'CLIError'; + error.code = 'E_NO_COMMAND'; throw error; } diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts index 66348be3..890ba8a8 100644 --- a/cli/src/commands/profile/lib/profile.ts +++ b/cli/src/commands/profile/lib/profile.ts @@ -1,21 +1,21 @@ -import { asCliArgString } from "@/lib/cli-args.ts"; -import { getCliContext } from "@/context.ts"; -import { CliError, ConfigurationError } from "@/lib/errors.ts"; -import type { ProfileInspect } from "@/lib/profile/model.ts"; -import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { defaultPrompts, type Prompts } from "@/ui/prompts.ts"; -import { profileSwitchOption } from "@/lib/profile/views.ts"; +import { asCliArgString } from '@/lib/cli-args.ts'; +import { getCliContext } from '@/context.ts'; +import { CliError, ConfigurationError } from '@/lib/errors.ts'; +import type { ProfileInspect } from '@/lib/profile/model.ts'; +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { defaultPrompts, type Prompts } from '@/ui/prompts.ts'; +import { profileSwitchOption } from '@/lib/profile/views.ts'; import { envConfigMode, getActiveProfileName, isFromEnvProfile, profileExists, -} from "@/lib/profile-store.ts"; -import { listProfiles } from "@/lib/profile/model.ts"; +} from '@/lib/profile-store.ts'; +import { listProfiles } from '@/lib/profile/model.ts'; export function requireProfileName(name: unknown): string { const trimmed = asCliArgString(name).trim(); - if (trimmed === "") throw new CliError("A profile name is required."); + if (trimmed === '') throw new CliError('A profile name is required.'); return trimmed; } @@ -25,7 +25,9 @@ export function optionalArg(value: unknown): string | undefined { } export function existingProfileName(name: string): string { - const resolvable = isFromEnvProfile(name) ? envConfigMode() : profileExists(name); + const resolvable = isFromEnvProfile(name) + ? envConfigMode() + : profileExists(name); if (!resolvable) throw new ConfigurationError(`Profile not found: ${name}`); return name; } @@ -33,7 +35,7 @@ export function existingProfileName(name: string): string { export function requireStoredProfileForExport(name: string): string { if (isFromEnvProfile(name)) { throw new CliError( - "The active identity is configured through environment variables; there is no stored profile to export. Pass a profile name.", + 'The active identity is configured through environment variables; there is no stored profile to export. Pass a profile name.' ); } return existingProfileName(name); @@ -46,21 +48,27 @@ export function profileNameArgOrActive(args: Record): string { } export function profileShowTargetName(args: Record): string { - return optionalArg(args.name) ?? getCliContext().profile ?? getActiveProfileName(); + return ( + optionalArg(args.name) ?? getCliContext().profile ?? getActiveProfileName() + ); } -export function configuredVerificationPlanes(profile: ProfileInspect): ConfigureAuthPlane[] { +export function configuredVerificationPlanes( + profile: ProfileInspect +): ConfigureAuthPlane[] { const planes: ConfigureAuthPlane[] = []; - if (profile.auth.management !== "none") planes.push("management"); - if (profile.auth.lakehouse !== "none") planes.push("lakehouse"); + if (profile.auth.management !== 'none') planes.push('management'); + if (profile.auth.lakehouse !== 'none') planes.push('lakehouse'); return planes; } -export async function promptProfileSwitch(prompts: Prompts = defaultPrompts): Promise { +export async function promptProfileSwitch( + prompts: Prompts = defaultPrompts +): Promise { const profiles = listProfiles(); return prompts.readSelect( - "Switch profile", + 'Switch profile', profiles.map(profileSwitchOption), - getActiveProfileName(), + getActiveProfileName() ); } diff --git a/cli/src/commands/profile/list.ts b/cli/src/commands/profile/list.ts index f4eaf96c..afe16137 100644 --- a/cli/src/commands/profile/list.ts +++ b/cli/src/commands/profile/list.ts @@ -1,15 +1,19 @@ -import { listProfiles } from "@/lib/profile/model.ts"; -import { formatProfileList } from "@/lib/profile/render.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { listProfiles } from '@/lib/profile/model.ts'; +import { formatProfileList } from '@/lib/profile/render.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileListCommand = defineCommand({ - metadata: { name: "list", description: "List configured profiles" }, + metadata: { name: 'list', description: 'List configured profiles' }, async run({ sink }) { const profiles = listProfiles(); await writeCommandOutput( - { kind: "normalized", data: { profiles }, humanText: formatProfileList(profiles) }, - sink, + { + kind: 'normalized', + data: { profiles }, + humanText: formatProfileList(profiles), + }, + sink ); }, }); diff --git a/cli/src/commands/profile/rename.ts b/cli/src/commands/profile/rename.ts index 442470ee..b4a536ef 100644 --- a/cli/src/commands/profile/rename.ts +++ b/cli/src/commands/profile/rename.ts @@ -1,13 +1,17 @@ -import { assertNoEnvConfigMode, renameProfile } from "@/lib/profile/model.ts"; -import { requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { assertNoEnvConfigMode, renameProfile } from '@/lib/profile/model.ts'; +import { requireProfileName } from '@/commands/profile/lib/profile.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileRenameCommand = defineCommand({ - metadata: { name: "rename", description: "Rename a profile", hidden: true }, + metadata: { name: 'rename', description: 'Rename a profile', hidden: true }, args: { - from: { type: "positional", description: "Current profile name", required: true }, - to: { type: "positional", description: "New profile name", required: true }, + from: { + type: 'positional', + description: 'Current profile name', + required: true, + }, + to: { type: 'positional', description: 'New profile name', required: true }, }, async run({ args, sink }) { const from = requireProfileName(args.from); @@ -16,11 +20,11 @@ export const profileRenameCommand = defineCommand({ renameProfile(from, to); await writeCommandOutput( { - kind: "ack", + kind: 'ack', data: { renamed: true, from, to }, metadataMessage: `Renamed profile ${from} to ${to}.`, }, - sink, + sink ); }, }); diff --git a/cli/src/commands/profile/show.ts b/cli/src/commands/profile/show.ts index 394ef618..5db41e4a 100644 --- a/cli/src/commands/profile/show.ts +++ b/cli/src/commands/profile/show.ts @@ -1,31 +1,39 @@ -import { inspectProfile } from "@/lib/profile/model.ts"; -import { formatProfileInspectResult } from "@/lib/profile/render.ts"; -import { profileInspectToJson } from "@/lib/profile/views.ts"; -import { existingProfileName, profileShowTargetName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { inspectProfile } from '@/lib/profile/model.ts'; +import { formatProfileInspectResult } from '@/lib/profile/render.ts'; +import { profileInspectToJson } from '@/lib/profile/views.ts'; +import { + existingProfileName, + profileShowTargetName, +} from '@/commands/profile/lib/profile.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileShowCommand = defineCommand({ - metadata: { name: "show", description: "Show a profile's stored identity, auth, and endpoints" }, + metadata: { + name: 'show', + description: "Show a profile's stored identity, auth, and endpoints", + }, args: { name: { - type: "positional", - description: "Profile name (default: selected or active profile)", + type: 'positional', + description: 'Profile name (default: selected or active profile)', required: false, }, }, async run({ args, runtime, sink }) { - const profile = inspectProfile(existingProfileName(profileShowTargetName(args))); + const profile = inspectProfile( + existingProfileName(profileShowTargetName(args)) + ); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: profileInspectToJson(profile), humanText: formatProfileInspectResult( profile, - !sink.json && !runtime.context.agent && process.stdin.isTTY === true, + !sink.json && !runtime.context.agent && process.stdin.isTTY === true ), }, - sink, + sink ); }, }); diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts index 33185125..1c05d408 100644 --- a/cli/src/commands/profile/status.ts +++ b/cli/src/commands/profile/status.ts @@ -1,44 +1,50 @@ -import { inspectProfile } from "@/lib/profile/model.ts"; -import { formatProfileStatus } from "@/lib/profile/render.ts"; -import { profileStatusToJson } from "@/lib/profile/views.ts"; -import { configureVerify } from "@/lib/profile-status.ts"; +import { inspectProfile } from '@/lib/profile/model.ts'; +import { formatProfileStatus } from '@/lib/profile/render.ts'; +import { profileStatusToJson } from '@/lib/profile/views.ts'; +import { configureVerify } from '@/lib/profile-status.ts'; import { configuredVerificationPlanes, existingProfileName, profileShowTargetName, -} from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { EXIT_GENERIC, EXIT_SUCCESS } from "@/lib/errors.ts"; +} from '@/commands/profile/lib/profile.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { EXIT_GENERIC, EXIT_SUCCESS } from '@/lib/errors.ts'; export const profileStatusCommand = defineCommand({ - metadata: { name: "status", description: "Verify stored credentials and show the profile" }, + metadata: { + name: 'status', + description: 'Verify stored credentials and show the profile', + }, args: { name: { - type: "positional", - description: "Profile name (default: selected or active profile)", + type: 'positional', + description: 'Profile name (default: selected or active profile)', required: false, }, }, async run({ args, execution, sink }) { const profileName = existingProfileName(profileShowTargetName(args)); const profile = inspectProfile(profileName); - const verification = await configureVerify(configuredVerificationPlanes(profile), { - ...execution, - profile: profileName, - }); + const verification = await configureVerify( + configuredVerificationPlanes(profile), + { + ...execution, + profile: profileName, + } + ); const result = { profile, verification }; await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: profileStatusToJson(result), humanText: formatProfileStatus(result), }, - sink, + sink ); const healthy = verification.configured.length > 0 && - verification.configured.every((plane) => verification.verified[plane]); + verification.configured.every(plane => verification.verified[plane]); return { exitCode: healthy ? EXIT_SUCCESS : EXIT_GENERIC }; }, }); diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts index 0b58f97b..eef81eaa 100644 --- a/cli/src/commands/profile/switch.ts +++ b/cli/src/commands/profile/switch.ts @@ -1,31 +1,45 @@ -import { getCliContext, isJsonOutput } from "@/context.ts"; -import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; -import { setActiveProfile } from "@/lib/profile-store.ts"; -import { CliError } from "@/lib/errors.ts"; -import { promptProfileSwitch, requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; +import { getCliContext, isJsonOutput } from '@/context.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; +import { setActiveProfile } from '@/lib/profile-store.ts'; +import { CliError } from '@/lib/errors.ts'; +import { + promptProfileSwitch, + requireProfileName, +} from '@/commands/profile/lib/profile.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileSwitchCommand = defineCommand({ - metadata: { name: "switch", description: "Interactively switch the active profile" }, - args: { name: { type: "positional", description: "Profile name", required: false } }, + metadata: { + name: 'switch', + description: 'Interactively switch the active profile', + }, + args: { + name: { type: 'positional', description: 'Profile name', required: false }, + }, async run({ args, sink }) { assertNoEnvConfigMode(); let profileName = args.name ? requireProfileName(args.name) : undefined; if (!profileName) { - if (isJsonOutput(getCliContext()) || getCliContext().agent || process.stdin.isTTY !== true) { - throw new CliError("Interactive profile switch requires a TTY. Pass a profile name."); + if ( + isJsonOutput(getCliContext()) || + getCliContext().agent || + process.stdin.isTTY !== true + ) { + throw new CliError( + 'Interactive profile switch requires a TTY. Pass a profile name.' + ); } profileName = await promptProfileSwitch(); } setActiveProfile(profileName); await writeCommandOutput( { - kind: "ack", + kind: 'ack', data: { active_profile: profileName }, metadataMessage: `Active profile set to ${profileName}.`, }, - sink, + sink ); }, }); diff --git a/cli/src/commands/query/cancel.ts b/cli/src/commands/query/cancel.ts index 364f0da5..cc19a091 100644 --- a/cli/src/commands/query/cancel.ts +++ b/cli/src/commands/query/cancel.ts @@ -1,26 +1,34 @@ -import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { buildLakehouseQueryCancelRequest } from "@/lib/lakehouse/query.ts"; -import { sendHttp } from "@/lib/http-request.ts"; +import { stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { buildLakehouseQueryCancelRequest } from '@/lib/lakehouse/query.ts'; +import { sendHttp } from '@/lib/http-request.ts'; export const queryCancelCommand = defineCommand({ metadata: { - name: "cancel", - description: "Cancel a running query.", + name: 'cancel', + description: 'Cancel a running query.', }, args: { - "query-id": { type: "positional", description: "Query id to cancel", required: true }, - "session-id": { type: "string", description: "Session id that owns the query", required: true }, + 'query-id': { + type: 'positional', + description: 'Query id to cancel', + required: true, + }, + 'session-id': { + type: 'string', + description: 'Session id that owns the query', + required: true, + }, }, async run({ args, execution, sink }) { const response = await sendHttp( buildLakehouseQueryCancelRequest({ - queryId: stringArg(args, "query-id"), - sessionId: stringArg(args, "session-id"), + queryId: stringArg(args, 'query-id'), + sessionId: stringArg(args, 'session-id'), }), - execution, + execution ); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); }, }); diff --git a/cli/src/commands/query/index.test.ts b/cli/src/commands/query/index.test.ts index eeafc451..0bdfc0a6 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -1,189 +1,236 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; -const QUERY_RESPONSE = ['{"statement":"SELECT 1"}', '["id"]', "[1]"].join("\n"); +const QUERY_RESPONSE = ['{"statement":"SELECT 1"}', '["id"]', '[1]'].join('\n'); let workspace: LakehouseTestWorkspace; beforeEach(() => { - workspace = createLakehouseTestWorkspace("query-command"); + workspace = createLakehouseTestWorkspace('query-command'); }); afterEach(() => workspace.cleanup()); -describe("query command", () => { - test("sends statement identifiers and default compute size", async () => { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]); +describe('query command', () => { + test('sends statement identifiers and default compute size', async () => { + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, + ]); await runCommandWithTestRuntime([ - "query", - "SELECT 1", - "--query-id", - "query-1", - "--dialect", - "snowflake", - "--catalog", - "analytics", - "--schema", - "main", + 'query', + 'SELECT 1', + '--query-id', + 'query-1', + '--dialect', + 'snowflake', + '--catalog', + 'analytics', + '--schema', + 'main', ]); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - query_id: "query-1", - compute_size: "AUTO", - dialect: "snowflake", - catalog: "analytics", - schema: "main", + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT 1', + query_id: 'query-1', + compute_size: 'AUTO', + dialect: 'snowflake', + catalog: 'analytics', + schema: 'main', }); }); - test("omits compute size with session unless an explicit size is set", async () => { + test('omits compute size with session unless an explicit size is set', async () => { workspace.writeMocks([ - { urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }, - { urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }, + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, ]); - await runCommandWithTestRuntime(["query", "SELECT 1", "--session-id", "session-1"]); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - session_id: "session-1", + await runCommandWithTestRuntime([ + 'query', + 'SELECT 1', + '--session-id', + 'session-1', + ]); + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT 1', + session_id: 'session-1', }); await runCommandWithTestRuntime([ - "query", - "SELECT 1", - "--session-id", - "session-1", - "--compute-size", - "S", + 'query', + 'SELECT 1', + '--session-id', + 'session-1', + '--compute-size', + 'S', ]); - expect(JSON.parse(workspace.readPayloads()[1] ?? "")).toEqual({ - statement: "SELECT 1", - session_id: "session-1", - compute_size: "S", + expect(JSON.parse(workspace.readPayloads()[1] ?? '')).toEqual({ + statement: 'SELECT 1', + session_id: 'session-1', + compute_size: 'S', }); }); - test("sends explicit AUTO compute size with a session to the API", async () => { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]); + test('sends explicit AUTO compute size with a session to the API', async () => { + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, + ]); await runCommandWithTestRuntime([ - "query", - "SELECT 1", - "--session-id", - "session-1", - "--compute-size", - "AUTO", + 'query', + 'SELECT 1', + '--session-id', + 'session-1', + '--compute-size', + 'AUTO', ]); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - session_id: "session-1", - compute_size: "AUTO", + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT 1', + session_id: 'session-1', + compute_size: 'AUTO', }); }); - test("streams API-native csv bytes instead of client-rendered CSV", async () => { - const apiCsv = "id,name\n9,from-api\n"; - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: apiCsv }]); + test('streams API-native csv bytes instead of client-rendered CSV', async () => { + const apiCsv = 'id,name\n9,from-api\n'; + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: apiCsv }, + ]); - const harness = await runCommandWithTestRuntime(["query", "SELECT 1", "--format", "csv"], { - debug: false, - json: false, - agent: false, - }); + const harness = await runCommandWithTestRuntime( + ['query', 'SELECT 1', '--format', 'csv'], + { + debug: false, + json: false, + agent: false, + } + ); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - compute_size: "AUTO", - format: "csv", + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT 1', + compute_size: 'AUTO', + format: 'csv', }); - expect(harness.stdout.join("")).toContain("9,from-api"); + expect(harness.stdout.join('')).toContain('9,from-api'); }); - test("writes API-native output to --output", async () => { - const outputDir = mkdtempSync(join(tmpdir(), "altertable-query-output-")); - const outputPath = join(outputDir, "result.csv"); + test('writes API-native output to --output', async () => { + const outputDir = mkdtempSync(join(tmpdir(), 'altertable-query-output-')); + const outputPath = join(outputDir, 'result.csv'); try { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: "id\n42\n" }]); + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: 'id\n42\n' }, + ]); await runCommandWithTestRuntime( - ["query", "SELECT 1", "--format", "csv", "--output", outputPath], - { debug: false, json: false, agent: false }, + ['query', 'SELECT 1', '--format', 'csv', '--output', outputPath], + { debug: false, json: false, agent: false } ); - expect(readFileSync(outputPath, "utf8")).toContain("42"); + expect(readFileSync(outputPath, 'utf8')).toContain('42'); } finally { rmSync(outputDir, { recursive: true, force: true }); } }); - test("keeps markdown as client-rendered NDJSON output", async () => { - workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]); + test('keeps markdown as client-rendered NDJSON output', async () => { + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, + ]); - const harness = await runCommandWithTestRuntime(["query", "SELECT 1", "--format", "markdown"], { - debug: false, - json: false, - agent: false, - }); + const harness = await runCommandWithTestRuntime( + ['query', 'SELECT 1', '--format', 'markdown'], + { + debug: false, + json: false, + agent: false, + } + ); - expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - compute_size: "AUTO", + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT 1', + compute_size: 'AUTO', }); - expect(harness.stdout.join("")).toContain("|"); + expect(harness.stdout.join('')).toContain('|'); }); - test("rejects API-native format with --json", async () => { + test('rejects API-native format with --json', async () => { expect( - runCommandWithTestRuntime(["query", "SELECT 1", "--format", "parquet"], { + runCommandWithTestRuntime(['query', 'SELECT 1', '--format', 'parquet'], { debug: false, json: true, agent: false, - }), - ).rejects.toThrow("cannot be combined with --json or --agent"); + }) + ).rejects.toThrow('cannot be combined with --json or --agent'); }); - test("dispatches show and cancel without run arguments", async () => { - const queryId = "11111111-2222-3333-4444-555555555555"; + test('dispatches show and cancel without run arguments', async () => { + const queryId = '11111111-2222-3333-4444-555555555555'; workspace.writeMocks([ - { urlPattern: `/query/${queryId}`, method: "GET", body: `{"uuid":"${queryId}"}` }, - { urlPattern: `/query/${queryId}`, method: "DELETE", body: '{"cancelled":true}' }, + { + urlPattern: `/query/${queryId}`, + method: 'GET', + body: `{"uuid":"${queryId}"}`, + }, + { + urlPattern: `/query/${queryId}`, + method: 'DELETE', + body: '{"cancelled":true}', + }, ]); - await runCommandWithTestRuntime(["query", "show", queryId]); - await runCommandWithTestRuntime(["query", "cancel", queryId, "--session-id", "session-1"]); + await runCommandWithTestRuntime(['query', 'show', queryId]); + await runCommandWithTestRuntime([ + 'query', + 'cancel', + queryId, + '--session-id', + 'session-1', + ]); const log = workspace.readHttpLog(); - expect(log).toContain(`METHOD=GET\nURL=https://example.com/query/${queryId}`); expect(log).toContain( - `METHOD=DELETE\nURL=https://example.com/query/${queryId}?session_id=session-1`, + `METHOD=GET\nURL=https://example.com/query/${queryId}` + ); + expect(log).toContain( + `METHOD=DELETE\nURL=https://example.com/query/${queryId}?session_id=session-1` ); }); - test("requires a query id for show instead of running bare show as SQL", async () => { - expect(runCommandWithTestRuntime(["query", "show"])).rejects.toThrow( - "Missing required argument: query-id.", + test('requires a query id for show instead of running bare show as SQL', async () => { + expect(runCommandWithTestRuntime(['query', 'show'])).rejects.toThrow( + 'Missing required argument: query-id.' ); }); - test("URL-encodes query identifiers", async () => { - const queryId = "query/id+special"; + test('URL-encodes query identifiers', async () => { + const queryId = 'query/id+special'; const encodedQueryId = encodeURIComponent(queryId); workspace.writeMocks([ - { urlPattern: `/query/${encodedQueryId}`, method: "DELETE", body: '{"cancelled":true}' }, + { + urlPattern: `/query/${encodedQueryId}`, + method: 'DELETE', + body: '{"cancelled":true}', + }, ]); - await runCommandWithTestRuntime(["query", "cancel", queryId, "--session-id", "session-1"]); + await runCommandWithTestRuntime([ + 'query', + 'cancel', + queryId, + '--session-id', + 'session-1', + ]); expect(workspace.readHttpLog()).toContain( - `URL=https://example.com/query/${encodedQueryId}?session_id=session-1`, + `URL=https://example.com/query/${encodedQueryId}?session_id=session-1` ); }); }); diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index e5f731d2..7abb18c9 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,28 +1,31 @@ -import { defineCommand } from "@/lib/command.ts"; -import { queryRunArgs } from "@/commands/query/lib/args.ts"; -import { queryShowCommand } from "@/commands/query/show.ts"; -import { queryCancelCommand } from "@/commands/query/cancel.ts"; -import { CliError } from "@/lib/errors.ts"; -import { optionalStringArg } from "@/lib/args.ts"; -import { writeQueryDestination } from "@/lib/query-destination.ts"; -import { isApiNativeQueryFormat, writeQueryOutput } from "@/lib/query-output.ts"; -import { parseQueryOutputOptions } from "@/lib/query-output-args.ts"; +import { defineCommand } from '@/lib/command.ts'; +import { queryRunArgs } from '@/commands/query/lib/args.ts'; +import { queryShowCommand } from '@/commands/query/show.ts'; +import { queryCancelCommand } from '@/commands/query/cancel.ts'; +import { CliError } from '@/lib/errors.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { writeQueryDestination } from '@/lib/query-destination.ts'; +import { + isApiNativeQueryFormat, + writeQueryOutput, +} from '@/lib/query-output.ts'; +import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; import { executeLakehouseQuery, executeLakehouseQueryBytes, type LakehouseQueryInput, -} from "@/lib/lakehouse/query.ts"; +} from '@/lib/lakehouse/query.ts'; export const queryCommand = defineCommand({ metadata: { - name: "query", - commandGroup: "query", - description: "Run SQL queries against the lakehouse.", + name: 'query', + commandGroup: 'query', + description: 'Run SQL queries against the lakehouse.', examples: [ 'altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 10"', 'altertable query "SELECT event, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --json', 'altertable query "SELECT 1" --format csv --output results.csv', - "altertable query show ", + 'altertable query show ', ], }, args: queryRunArgs, @@ -31,9 +34,11 @@ export const queryCommand = defineCommand({ cancel: queryCancelCommand, }, async run({ args, rawArgs, execution, sink }) { - const statement = optionalStringArg(args, "statement"); + const statement = optionalStringArg(args, 'statement'); if (statement === undefined) { - throw new CliError('Provide a SQL statement, e.g. altertable query "SELECT 1".'); + throw new CliError( + 'Provide a SQL statement, e.g. altertable query "SELECT 1".' + ); } const { format, @@ -52,8 +57,8 @@ export const queryCommand = defineCommand({ const input: LakehouseQueryInput = { statement, - queryId: optionalStringArg(args, "query-id"), - sessionId: optionalStringArg(args, "session-id"), + queryId: optionalStringArg(args, 'query-id'), + sessionId: optionalStringArg(args, 'session-id'), computeSize, dialect, catalog, @@ -61,12 +66,22 @@ export const queryCommand = defineCommand({ }; if (isApiNativeQueryFormat(format)) { - const stream = await executeLakehouseQueryBytes({ ...input, format }, execution); + const stream = await executeLakehouseQueryBytes( + { ...input, format }, + execution + ); await writeQueryDestination(stream, { outputPath, sink }); return; } const result = await executeLakehouseQuery(input, execution, !sink.json); - await writeQueryOutput(result, format, sink, displayOptions, pagerOptions, outputPath); + await writeQueryOutput( + result, + format, + sink, + displayOptions, + pagerOptions, + outputPath + ); }, }); diff --git a/cli/src/commands/query/lib/args.ts b/cli/src/commands/query/lib/args.ts index a7a9bba1..3254d309 100644 --- a/cli/src/commands/query/lib/args.ts +++ b/cli/src/commands/query/lib/args.ts @@ -1,22 +1,22 @@ -import { defineArguments } from "@/lib/command.ts"; +import { defineArguments } from '@/lib/command.ts'; import { queryApiResultFormatArgs, queryDisplayArgs, queryPagerArgs, queryRequestArgs, -} from "@/lib/query-output-args.ts"; +} from '@/lib/query-output-args.ts'; export const queryRunArgs = defineArguments({ statement: { - type: "positional", - description: "SQL statement to run", + type: 'positional', + description: 'SQL statement to run', required: false, directRequired: true, }, ...queryApiResultFormatArgs, ...queryDisplayArgs, ...queryRequestArgs, - "query-id": { type: "string", description: "Optional stable query id" }, - "session-id": { type: "string", description: "Optional session id" }, + 'query-id': { type: 'string', description: 'Optional stable query id' }, + 'session-id': { type: 'string', description: 'Optional session id' }, ...queryPagerArgs, }); diff --git a/cli/src/commands/query/show.ts b/cli/src/commands/query/show.ts index 1142a69e..3657c56a 100644 --- a/cli/src/commands/query/show.ts +++ b/cli/src/commands/query/show.ts @@ -1,22 +1,26 @@ -import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { buildLakehouseQueryShowRequest } from "@/lib/lakehouse/query.ts"; -import { sendHttp } from "@/lib/http-request.ts"; +import { stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { buildLakehouseQueryShowRequest } from '@/lib/lakehouse/query.ts'; +import { sendHttp } from '@/lib/http-request.ts'; export const queryShowCommand = defineCommand({ metadata: { - name: "show", - description: "Fetch metadata for a completed or running query.", + name: 'show', + description: 'Fetch metadata for a completed or running query.', }, args: { - "query-id": { type: "positional", description: "Query id returned by the API", required: true }, + 'query-id': { + type: 'positional', + description: 'Query id returned by the API', + required: true, + }, }, async run({ args, execution, sink }) { const response = await sendHttp( - buildLakehouseQueryShowRequest(stringArg(args, "query-id")), - execution, + buildLakehouseQueryShowRequest(stringArg(args, 'query-id')), + execution ); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); }, }); diff --git a/cli/src/commands/schema/index.test.ts b/cli/src/commands/schema/index.test.ts index dbf4fd31..aaf49f77 100644 --- a/cli/src/commands/schema/index.test.ts +++ b/cli/src/commands/schema/index.test.ts @@ -1,34 +1,34 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; const SCHEMA_COLUMNS = [ - "schema_name", - "table_name", - "table_comment", - "column_name", - "data_type", - "is_nullable", - "table_type", - "comment", - "ordinal_position", + 'schema_name', + 'table_name', + 'table_comment', + 'column_name', + 'data_type', + 'is_nullable', + 'table_type', + 'comment', + 'ordinal_position', ]; -let testHome = ""; -let mockFile = ""; -let logFile = ""; +let testHome = ''; +let mockFile = ''; +let logFile = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-schema-test-")); - mockFile = join(testHome, "mocks.json"); - logFile = join(testHome, "http.log"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-schema-test-')); + mockFile = join(testHome, 'mocks.json'); + logFile = join(testHome, 'http.log'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; - process.env.ALTERTABLE_API_BASE = "https://example.com"; - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "testuser"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "testpass"; + process.env.ALTERTABLE_API_BASE = 'https://example.com'; + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'testuser'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'testpass'; }); afterEach(() => { @@ -45,66 +45,88 @@ function writeSchemaResponse(rows: unknown[][]): void { mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", - body: [{ statement: "schema" }, SCHEMA_COLUMNS, ...rows] - .map((line) => JSON.stringify(line)) - .join("\n"), + urlPattern: '/query', + method: 'POST', + body: [{ statement: 'schema' }, SCHEMA_COLUMNS, ...rows] + .map(line => JSON.stringify(line)) + .join('\n'), }, - ]), + ]) ); } -describe("schema command", () => { - test("sends one escaped catalog-scoped query", async () => { +describe('schema command', () => { + test('sends one escaped catalog-scoped query', async () => { writeSchemaResponse([]); - await runCommandWithTestRuntime(["schema", "o'brien"]); + await runCommandWithTestRuntime(['schema', "o'brien"]); - const payloadLine = readFileSync(logFile, "utf8") - .split("\n") - .find((line) => line.startsWith("PAYLOAD=")); - const payload = JSON.parse(payloadLine?.slice("PAYLOAD=".length) ?? "") as { + const payloadLine = readFileSync(logFile, 'utf8') + .split('\n') + .find(line => line.startsWith('PAYLOAD=')); + const payload = JSON.parse(payloadLine?.slice('PAYLOAD='.length) ?? '') as { statement: string; }; - expect(payload.statement.match(/database_name = 'o''brien'/g)).toHaveLength(3); - expect(payload.statement).toContain("duckdb_schemas()"); - expect(payload.statement).toContain("duckdb_tables()"); - expect(payload.statement).toContain("duckdb_views()"); + expect(payload.statement.match(/database_name = 'o''brien'/g)).toHaveLength( + 3 + ); + expect(payload.statement).toContain('duckdb_schemas()'); + expect(payload.statement).toContain('duckdb_tables()'); + expect(payload.statement).toContain('duckdb_views()'); }); - test("renders schemas, tables, columns, types, and comments", async () => { + test('renders schemas, tables, columns, types, and comments', async () => { writeSchemaResponse([ - ["main", null, null, null, null, null, null, null, 0], - ["main", "users", "user table", "id", "INTEGER", "NO", "BASE TABLE", "primary key", 1], - ["main", "users", "user table", "name", "VARCHAR", "YES", "BASE TABLE", null, 2], + ['main', null, null, null, null, null, null, null, 0], + [ + 'main', + 'users', + 'user table', + 'id', + 'INTEGER', + 'NO', + 'BASE TABLE', + 'primary key', + 1, + ], + [ + 'main', + 'users', + 'user table', + 'name', + 'VARCHAR', + 'YES', + 'BASE TABLE', + null, + 2, + ], ]); - const result = await runCommandWithTestRuntime(["schema", "analytics"], { + const result = await runCommandWithTestRuntime(['schema', 'analytics'], { debug: false, json: false, agent: false, }); - expect(result.stdout.join("\n")).toBe( + expect(result.stdout.join('\n')).toBe( [ - "Schemas and tables for analytics", - "└── main", - " └── users — user table", - " ├── id INTEGER NOT NULL — primary key", - " └── name VARCHAR", - ].join("\n"), + 'Schemas and tables for analytics', + '└── main', + ' └── users — user table', + ' ├── id INTEGER NOT NULL — primary key', + ' └── name VARCHAR', + ].join('\n') ); }); - test("rejects query-only presentation flags because human output is always the tree", async () => { + test('rejects query-only presentation flags because human output is always the tree', async () => { for (const args of [ - ["--layout", "line"], - ["--columns", "id"], - ["--max-width", "40"], + ['--layout', 'line'], + ['--columns', 'id'], + ['--max-width', '40'], ]) { try { - await runCommandWithTestRuntime(["schema", "analytics", ...args]); + await runCommandWithTestRuntime(['schema', 'analytics', ...args]); throw new Error(`Expected ${args[0]} to be rejected`); } catch (error) { expect(error).toBeInstanceOf(Error); diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index de450575..14855d5b 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,44 +1,56 @@ -import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { parseQueryOutputOptions } from "@/lib/query-output-args.ts"; -import { writePagedOutput } from "@/lib/pager.ts"; -import { writeQueryOutput } from "@/lib/query-output.ts"; -import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; -import { buildSchemaTreeView } from "@/commands/schema/lib/views.ts"; -import { schemaArgs } from "@/commands/schema/lib/args.ts"; -import { renderTreeText } from "@/ui/renderers/terminal.ts"; +import { stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; +import { writePagedOutput } from '@/lib/pager.ts'; +import { writeQueryOutput } from '@/lib/query-output.ts'; +import { executeLakehouseQuery } from '@/lib/lakehouse/query.ts'; +import { buildSchemaTreeView } from '@/commands/schema/lib/views.ts'; +import { schemaArgs } from '@/commands/schema/lib/args.ts'; +import { renderTreeText } from '@/ui/renderers/terminal.ts'; export const schemaCommand = defineCommand({ metadata: { - name: "schema", - commandGroup: "query", - description: "List schemas, tables, and columns for a catalog.", - examples: ["altertable schema analytics", "altertable schema analytics --json"], + name: 'schema', + commandGroup: 'query', + description: 'List schemas, tables, and columns for a catalog.', + examples: [ + 'altertable schema analytics', + 'altertable schema analytics --json', + ], }, args: schemaArgs, async run({ args, rawArgs, execution, sink }) { - const catalog = stringArg(args, "catalog"); - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, { - agent: execution.cli.agent, - json: sink.json, - rawArgs, - }); + const catalog = stringArg(args, 'catalog'); + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions( + args, + { + agent: execution.cli.agent, + json: sink.json, + rawArgs, + } + ); const queryInput = { statement: buildSchemaStatement(catalog), }; const result = await executeLakehouseQuery( queryInput, execution, - format !== "json" && !sink.json, + format !== 'json' && !sink.json ); - if (format !== "human" || sink.json) { - await writeQueryOutput(result, format, sink, displayOptions, pagerOptions); + if (format !== 'human' || sink.json) { + await writeQueryOutput( + result, + format, + sink, + displayOptions, + pagerOptions + ); return; } await writePagedOutput( renderTreeText(buildSchemaTreeView(result, catalog)), pagerOptions, - sink, + sink ); }, }); diff --git a/cli/src/commands/schema/lib/args.ts b/cli/src/commands/schema/lib/args.ts index a47293dc..d0cb4ad5 100644 --- a/cli/src/commands/schema/lib/args.ts +++ b/cli/src/commands/schema/lib/args.ts @@ -1,8 +1,11 @@ -import { defineArguments } from "@/lib/command.ts"; -import { queryPagerArgs, queryResultFormatArgs } from "@/lib/query-output-args.ts"; +import { defineArguments } from '@/lib/command.ts'; +import { + queryPagerArgs, + queryResultFormatArgs, +} from '@/lib/query-output-args.ts'; export const schemaArgs = defineArguments({ - catalog: { type: "positional", description: "Catalog name", required: true }, + catalog: { type: 'positional', description: 'Catalog name', required: true }, ...queryResultFormatArgs, ...queryPagerArgs, }); diff --git a/cli/src/commands/schema/lib/views.ts b/cli/src/commands/schema/lib/views.ts index 696e2ad5..0d3dc130 100644 --- a/cli/src/commands/schema/lib/views.ts +++ b/cli/src/commands/schema/lib/views.ts @@ -1,7 +1,10 @@ -import { getQueryColumnNames } from "@/lib/query-format.ts"; -import type { LakehouseQueryResult, LakehouseRow } from "@/lib/lakehouse-ndjson.ts"; -import type { TreeNode, TreeView } from "@/ui/layouts/tree.ts"; -import { span, type DisplaySpan } from "@/ui/document.ts"; +import { getQueryColumnNames } from '@/lib/query-format.ts'; +import type { + LakehouseQueryResult, + LakehouseRow, +} from '@/lib/lakehouse-ndjson.ts'; +import type { TreeNode, TreeView } from '@/ui/layouts/tree.ts'; +import { span, type DisplaySpan } from '@/ui/document.ts'; type SchemaTreeColumn = { name: string; @@ -19,7 +22,7 @@ type SchemaTreeTable = { function resultCell( row: LakehouseRow, name: string, - columnIndex: ReadonlyMap, + columnIndex: ReadonlyMap ): unknown { return Array.isArray(row) ? row[columnIndex.get(name) ?? -1] : row[name]; } @@ -27,18 +30,22 @@ function resultCell( function textCell( row: LakehouseRow, name: string, - columnIndex: ReadonlyMap, + columnIndex: ReadonlyMap ): string | null { const value = resultCell(row, name, columnIndex); - return typeof value === "string" ? value : null; + return typeof value === 'string' ? value : null; } -function buildSchemaMap(result: LakehouseQueryResult): Map> { - const columnIndex = new Map(getQueryColumnNames(result).map((name, index) => [name, index])); +function buildSchemaMap( + result: LakehouseQueryResult +): Map> { + const columnIndex = new Map( + getQueryColumnNames(result).map((name, index) => [name, index]) + ); const schemas = new Map>(); for (const row of result.rows) { - const schemaName = textCell(row, "schema_name", columnIndex); + const schemaName = textCell(row, 'schema_name', columnIndex); if (schemaName === null) { continue; } @@ -49,7 +56,7 @@ function buildSchemaMap(result: LakehouseQueryResult): Map column.name.length)); - const label: DisplaySpan[] = [span(tableName, "strong")]; - if (table.type && table.type !== "BASE TABLE") { - label.push(span(" "), span(`(${table.type})`, "warning")); + const nameWidth = Math.max( + 0, + ...table.columns.map(column => column.name.length) + ); + const label: DisplaySpan[] = [span(tableName, 'strong')]; + if (table.type && table.type !== 'BASE TABLE') { + label.push(span(' '), span(`(${table.type})`, 'warning')); } if (table.comment) { - label.push(span(" "), span(`— ${table.comment}`, "subtle")); + label.push(span(' '), span(`— ${table.comment}`, 'subtle')); } return { label, - children: table.columns.map((column) => schemaColumnNode(column, nameWidth)), + children: table.columns.map(column => schemaColumnNode(column, nameWidth)), }; } function schemaNode( schemaName: string, - tables: ReadonlyMap | undefined, + tables: ReadonlyMap | undefined ): TreeNode { const tableEntries = tables ? [...tables] : []; return { - label: [span(schemaName, "accent")], - emptyLabel: [span("", "subtle")], - children: tableEntries.map(([tableName, table]) => schemaTableNode(tableName, table)), + label: [span(schemaName, 'accent')], + emptyLabel: [span('', 'subtle')], + children: tableEntries.map(([tableName, table]) => + schemaTableNode(tableName, table) + ), }; } -export function buildSchemaTreeView(result: LakehouseQueryResult, catalog: string): TreeView { +export function buildSchemaTreeView( + result: LakehouseQueryResult, + catalog: string +): TreeView { const schemas = buildSchemaMap(result); return { - title: [span(`Schemas and tables for ${catalog}`, "strong")], - emptyLabel: [span("", "subtle")], + title: [span(`Schemas and tables for ${catalog}`, 'strong')], + emptyLabel: [span('', 'subtle')], children: [...schemas.keys()] .sort() - .map((schemaName) => schemaNode(schemaName, schemas.get(schemaName))), + .map(schemaName => schemaNode(schemaName, schemas.get(schemaName))), }; } diff --git a/cli/src/commands/update/index.ts b/cli/src/commands/update/index.ts index 48e2df75..bb5d6551 100644 --- a/cli/src/commands/update/index.ts +++ b/cli/src/commands/update/index.ts @@ -1,12 +1,12 @@ -import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import { VERSION } from "@/version.ts"; -import { asCliArgString } from "@/lib/cli-args.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; +import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { VERSION } from '@/version.ts'; +import { asCliArgString } from '@/lib/cli-args.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; import { checkForUpdate, compareVersions, @@ -18,29 +18,33 @@ import { releaseUrlForSource, resolveUpdateSource, type UpdateCheckResult, -} from "@/lib/updater.ts"; +} from '@/lib/updater.ts'; export const updateCommand = defineCommand({ metadata: { - name: "update", - alias: ["upgrade"], - commandGroup: "platform", - description: "Update Altertable CLI to the latest release.", - examples: ["altertable update", "altertable update --check", "altertable update 1.2.0 --force"], + name: 'update', + alias: ['upgrade'], + commandGroup: 'platform', + description: 'Update Altertable CLI to the latest release.', + examples: [ + 'altertable update', + 'altertable update --check', + 'altertable update 1.2.0 --force', + ], }, args: { version: { - type: "positional", - description: "Specific version to install (default: latest).", + type: 'positional', + description: 'Specific version to install (default: latest).', required: false, }, check: { - type: "boolean", - description: "Check for an update without installing it.", + type: 'boolean', + description: 'Check for an update without installing it.', }, force: { - type: "boolean", - description: "Reinstall or downgrade to the selected release.", + type: 'boolean', + description: 'Reinstall or downgrade to the selected release.', }, }, async run({ args, sink }) { @@ -59,13 +63,16 @@ export type UpdateCommandDependencies = { installCliUpdate: typeof installCliUpdate; }; -const DEFAULT_DEPENDENCIES: UpdateCommandDependencies = { checkForUpdate, installCliUpdate }; +const DEFAULT_DEPENDENCIES: UpdateCommandDependencies = { + checkForUpdate, + installCliUpdate, +}; function buildTargetResult(targetVersion: string): UpdateCheckResult { const version = normalizeVersion(targetVersion); if (!isValidVersion(version)) { throw new CliError(`Invalid update version: ${targetVersion}.`, { - details: "Expected a semantic version such as 1.2.0 or 1.3.0-rc.1.", + details: 'Expected a semantic version such as 1.2.0 or 1.3.0-rc.1.', }); } const source = resolveUpdateSource(); @@ -83,22 +90,22 @@ function buildTargetResult(targetVersion: string): UpdateCheckResult { async function writeUpdateCheck( result: UpdateCheckResult, targetVersion: string, - sink: OutputSink, + sink: OutputSink ): Promise { await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: result, humanText: formatUpdateResult(result, targetVersion), }, - sink, + sink ); } export async function executeUpdateCommand( args: UpdateCommandArgs, sink: OutputSink, - dependencies: UpdateCommandDependencies = DEFAULT_DEPENDENCIES, + dependencies: UpdateCommandDependencies = DEFAULT_DEPENDENCIES ): Promise { const targetVersion = asCliArgString(args.version); const result = targetVersion @@ -115,7 +122,9 @@ export async function executeUpdateCommand( targetVersion && compareVersions(result.latest_version, result.current_version) < 0 ) { - throw new CliError(`Target version v${result.latest_version} is older than v${VERSION}.`); + throw new CliError( + `Target version v${result.latest_version} is older than v${VERSION}.` + ); } if (!args.force && !result.update_available) { await writeUpdateCheck(result, targetVersion, sink); @@ -126,68 +135,78 @@ export async function executeUpdateCommand( sink.writeMetadata([formatUpdateAnnouncement(result)]); } const installed = await dependencies.installCliUpdate(result.latest_version, { - stdio: sink.json ? "pipe" : "inherit", + stdio: sink.json ? 'pipe' : 'inherit', }); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: installed, humanText: formatInstalledUpdate(result, installed.verified_version), }, - sink, + sink ); } function formatUpdateAnnouncement(result: UpdateCheckResult): string { if (result.update_available) { return renderDisplayText([ - span("Altertable CLI ", "strong"), - span(`v${result.latest_version}`, "accent"), + span('Altertable CLI ', 'strong'), + span(`v${result.latest_version}`, 'accent'), span(" is out! You're on "), - span(`v${result.current_version}`, "string"), + span(`v${result.current_version}`, 'string'), ]); } if (compareVersions(result.latest_version, result.current_version) === 0) { return renderDisplayText([ - span("Reinstalling Altertable CLI ", "strong"), - span(`v${result.current_version}`, "accent"), - span("."), + span('Reinstalling Altertable CLI ', 'strong'), + span(`v${result.current_version}`, 'accent'), + span('.'), ]); } return renderDisplayText([ - span("Downgrading Altertable CLI to ", "strong"), - span(`v${result.latest_version}`, "accent"), + span('Downgrading Altertable CLI to ', 'strong'), + span(`v${result.latest_version}`, 'accent'), span(". You're on "), - span(`v${result.current_version}`, "string"), + span(`v${result.current_version}`, 'string'), ]); } -function formatInstalledUpdate(result: UpdateCheckResult, installedVersion: string): string { +function formatInstalledUpdate( + result: UpdateCheckResult, + installedVersion: string +): string { const repositoryUrl = `https://github.com/${CLI_PACKAGE_METADATA.repositorySlug}`; const version = `v${normalizeVersion(installedVersion)}`; const currentVersion = `v${normalizeVersion(result.current_version)}`; const comparison = compareVersions(installedVersion, result.current_version); - let outcome = "Reinstalled."; + let outcome = 'Reinstalled.'; if (comparison > 0) { - outcome = "Upgraded."; + outcome = 'Upgraded.'; } else if (comparison < 0) { - outcome = "Downgraded."; + outcome = 'Downgraded.'; } const lines = [ outcome, - "", + '', `Release notes for Altertable CLI ${version}:`, - "", - renderDisplayText([span(` ${repositoryUrl}/releases/tag/${version}`, "accent")]), - "", - "Report any bugs:", - "", + '', + renderDisplayText([ + span(` ${repositoryUrl}/releases/tag/${version}`, 'accent'), + ]), + '', + 'Report any bugs:', + '', ` ${repositoryUrl}/issues`, ]; if (comparison !== 0) { - lines.push("", "Commit log:", "", ` ${repositoryUrl}/compare/${currentVersion}...${version}`); + lines.push( + '', + 'Commit log:', + '', + ` ${repositoryUrl}/compare/${currentVersion}...${version}` + ); } - return lines.join("\n"); + return lines.join('\n'); } diff --git a/cli/src/commands/upload/index.test.ts b/cli/src/commands/upload/index.test.ts index 428e9470..66a45f32 100644 --- a/cli/src/commands/upload/index.test.ts +++ b/cli/src/commands/upload/index.test.ts @@ -1,50 +1,57 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; beforeEach(() => { - workspace = createLakehouseTestWorkspace("upload-command"); + workspace = createLakehouseTestWorkspace('upload-command'); }); afterEach(() => workspace.cleanup()); -describe("upload command", () => { - test("sends the mode without logging file contents", async () => { - const uploadFile = workspace.writeFile("data.csv", "id,name\n1,Alice"); - workspace.writeMocks([{ urlPattern: "/upload", method: "POST", body: '{"ok":true}' }]); +describe('upload command', () => { + test('sends the mode without logging file contents', async () => { + const uploadFile = workspace.writeFile('data.csv', 'id,name\n1,Alice'); + workspace.writeMocks([ + { urlPattern: '/upload', method: 'POST', body: '{"ok":true}' }, + ]); await runCommandWithTestRuntime([ - "upload", + 'upload', uploadFile, - "--to", - "memory.main.users", - "--mode", - "overwrite", + '--to', + 'memory.main.users', + '--mode', + 'overwrite', ]); const log = workspace.readHttpLog(); - expect(log).toContain("URL=https://example.com/upload?"); - expect(log).toContain("mode=overwrite"); - expect(log).toContain("PAYLOAD=@blob"); - expect(log).not.toContain("id,name"); + expect(log).toContain('URL=https://example.com/upload?'); + expect(log).toContain('mode=overwrite'); + expect(log).toContain('PAYLOAD=@blob'); + expect(log).not.toContain('id,name'); }); - test("rejects missing files and directories", async () => { - const directory = workspace.createDirectory("directory"); - const baseArgs = ["upload"]; + test('rejects missing files and directories', async () => { + const directory = workspace.createDirectory('directory'); + const baseArgs = ['upload']; for (const file of [`${workspace.home}/missing.csv`, directory]) { try { - await runCommandWithTestRuntime([...baseArgs, file, "--to", "memory.main.users"]); - throw new Error("Expected upload to reject an invalid file"); + await runCommandWithTestRuntime([ + ...baseArgs, + file, + '--to', + 'memory.main.users', + ]); + throw new Error('Expected upload to reject an invalid file'); } catch (error) { expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("File not found"); + expect((error as Error).message).toContain('File not found'); } } }); diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts index bea97c98..b27f5616 100644 --- a/cli/src/commands/upload/index.ts +++ b/cli/src/commands/upload/index.ts @@ -1,39 +1,45 @@ -import { enumArg, optionalStringArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { parseLakehouseFileContentType, parseLakehouseTarget } from "@/lib/lakehouse/args.ts"; -import { createLakehouseUploadRequest } from "@/lib/lakehouse-transport.ts"; -import { getFileSizeBytes } from "@/lib/lakehouse/file.ts"; -import { uploadArgs, UPLOAD_MODE_OPTIONS } from "@/commands/upload/lib/args.ts"; +import { enumArg, optionalStringArg, stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { + parseLakehouseFileContentType, + parseLakehouseTarget, +} from '@/lib/lakehouse/args.ts'; +import { createLakehouseUploadRequest } from '@/lib/lakehouse-transport.ts'; +import { getFileSizeBytes } from '@/lib/lakehouse/file.ts'; +import { uploadArgs, UPLOAD_MODE_OPTIONS } from '@/commands/upload/lib/args.ts'; export const uploadCommand = defineCommand({ metadata: { - name: "upload", - commandGroup: "ingest", - description: "Upload a file to create, append to, or overwrite a table.", + name: 'upload', + commandGroup: 'ingest', + description: 'Upload a file to create, append to, or overwrite a table.', examples: [ - "altertable upload users.csv --to analytics.main.users", - "altertable upload orders.parquet --to analytics.main.orders --mode overwrite", + 'altertable upload users.csv --to analytics.main.users', + 'altertable upload orders.parquet --to analytics.main.orders --mode overwrite', ], }, args: uploadArgs, async run({ args, execution, sink }) { - const mode = enumArg(args, "mode", UPLOAD_MODE_OPTIONS); - const filePath = stringArg(args, "file"); + const mode = enumArg(args, 'mode', UPLOAD_MODE_OPTIONS); + const filePath = stringArg(args, 'file'); const fileSizeBytes = getFileSizeBytes(filePath); - const target = parseLakehouseTarget(stringArg(args, "to")); + const target = parseLakehouseTarget(stringArg(args, 'to')); const scope = createLakehouseUploadRequest({ ...target, mode, filePath, fileSizeBytes, - contentType: parseLakehouseFileContentType(optionalStringArg(args, "format"), filePath), + contentType: parseLakehouseFileContentType( + optionalStringArg(args, 'format'), + filePath + ), }); try { const response = await sendHttp(scope.request, execution); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); } finally { scope.release(); } diff --git a/cli/src/commands/upload/lib/args.ts b/cli/src/commands/upload/lib/args.ts index 63395fe8..dfc073f1 100644 --- a/cli/src/commands/upload/lib/args.ts +++ b/cli/src/commands/upload/lib/args.ts @@ -1,14 +1,14 @@ -import { defineArguments } from "@/lib/command.ts"; -import { lakehouseFileArgs } from "@/lib/lakehouse/args.ts"; +import { defineArguments } from '@/lib/command.ts'; +import { lakehouseFileArgs } from '@/lib/lakehouse/args.ts'; -export const UPLOAD_MODE_OPTIONS = ["create", "append", "overwrite"] as const; +export const UPLOAD_MODE_OPTIONS = ['create', 'append', 'overwrite'] as const; export const uploadArgs = defineArguments({ ...lakehouseFileArgs, mode: { - type: "enum", - description: "create, append, or overwrite", - default: "create", + type: 'enum', + description: 'create, append, or overwrite', + default: 'create', options: [...UPLOAD_MODE_OPTIONS], }, }); diff --git a/cli/src/commands/upsert/index.test.ts b/cli/src/commands/upsert/index.test.ts index 96c99c1a..8837d440 100644 --- a/cli/src/commands/upsert/index.test.ts +++ b/cli/src/commands/upsert/index.test.ts @@ -1,36 +1,38 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, type LakehouseTestWorkspace, -} from "@/test-utils/lakehouse.ts"; +} from '@/test-utils/lakehouse.ts'; let workspace: LakehouseTestWorkspace; beforeEach(() => { - workspace = createLakehouseTestWorkspace("upsert-command"); + workspace = createLakehouseTestWorkspace('upsert-command'); }); afterEach(() => workspace.cleanup()); -describe("upsert command", () => { - test("sends the primary key without an upload mode", async () => { - const uploadFile = workspace.writeFile("data.csv", "id,name\n1,Alice"); - workspace.writeMocks([{ urlPattern: "/upsert", method: "POST", body: '{"ok":true}' }]); +describe('upsert command', () => { + test('sends the primary key without an upload mode', async () => { + const uploadFile = workspace.writeFile('data.csv', 'id,name\n1,Alice'); + workspace.writeMocks([ + { urlPattern: '/upsert', method: 'POST', body: '{"ok":true}' }, + ]); await runCommandWithTestRuntime([ - "upsert", + 'upsert', uploadFile, - "--to", - "memory.main.users", - "--key", - "id", + '--to', + 'memory.main.users', + '--key', + 'id', ]); const log = workspace.readHttpLog(); - expect(log).toContain("URL=https://example.com/upsert?"); - expect(log).toContain("primary_key=id"); - expect(log).not.toContain("mode=upsert"); + expect(log).toContain('URL=https://example.com/upsert?'); + expect(log).toContain('primary_key=id'); + expect(log).not.toContain('mode=upsert'); expect(log).toMatch(/PAYLOAD=@(?:blob|stream)/); }); }); diff --git a/cli/src/commands/upsert/index.ts b/cli/src/commands/upsert/index.ts index 2f2ee87b..1de18b12 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -1,35 +1,43 @@ -import { optionalStringArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { parseLakehouseFileContentType, parseLakehouseTarget } from "@/lib/lakehouse/args.ts"; -import { getFileSizeBytes } from "@/lib/lakehouse/file.ts"; -import { createLakehouseUpsertRequest } from "@/lib/lakehouse-transport.ts"; -import { upsertArgs } from "@/commands/upsert/lib/args.ts"; +import { optionalStringArg, stringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { + parseLakehouseFileContentType, + parseLakehouseTarget, +} from '@/lib/lakehouse/args.ts'; +import { getFileSizeBytes } from '@/lib/lakehouse/file.ts'; +import { createLakehouseUpsertRequest } from '@/lib/lakehouse-transport.ts'; +import { upsertArgs } from '@/commands/upsert/lib/args.ts'; export const upsertCommand = defineCommand({ metadata: { - name: "upsert", - commandGroup: "ingest", - description: "Upload a file and match existing rows by primary key.", - examples: ["altertable upsert users.csv --to analytics.main.users --key id"], + name: 'upsert', + commandGroup: 'ingest', + description: 'Upload a file and match existing rows by primary key.', + examples: [ + 'altertable upsert users.csv --to analytics.main.users --key id', + ], }, args: upsertArgs, async run({ args, execution, sink }) { - const filePath = stringArg(args, "file"); + const filePath = stringArg(args, 'file'); const fileSizeBytes = getFileSizeBytes(filePath); - const target = parseLakehouseTarget(stringArg(args, "to")); + const target = parseLakehouseTarget(stringArg(args, 'to')); const scope = createLakehouseUpsertRequest({ ...target, - primaryKey: stringArg(args, "key"), + primaryKey: stringArg(args, 'key'), filePath, fileSizeBytes, - contentType: parseLakehouseFileContentType(optionalStringArg(args, "format"), filePath), + contentType: parseLakehouseFileContentType( + optionalStringArg(args, 'format'), + filePath + ), }); try { const response = await sendHttp(scope.request, execution); - await writeCommandOutput({ kind: "raw_api", body: response }, sink); + await writeCommandOutput({ kind: 'raw_api', body: response }, sink); } finally { scope.release(); } diff --git a/cli/src/commands/upsert/lib/args.ts b/cli/src/commands/upsert/lib/args.ts index 77c3ca90..394c66b6 100644 --- a/cli/src/commands/upsert/lib/args.ts +++ b/cli/src/commands/upsert/lib/args.ts @@ -1,11 +1,11 @@ -import { defineArguments } from "@/lib/command.ts"; -import { lakehouseFileArgs } from "@/lib/lakehouse/args.ts"; +import { defineArguments } from '@/lib/command.ts'; +import { lakehouseFileArgs } from '@/lib/lakehouse/args.ts'; export const upsertArgs = defineArguments({ ...lakehouseFileArgs, key: { - type: "string", - description: "Column name used to match existing rows", + type: 'string', + description: 'Column name used to match existing rows', required: true, }, }); diff --git a/cli/src/context.ts b/cli/src/context.ts index f6319a34..8fd20dbc 100644 --- a/cli/src/context.ts +++ b/cli/src/context.ts @@ -1,13 +1,17 @@ -import { DEFAULT_CONNECT_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; -import { getCliRuntime, isCliRuntimeReady, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { DEFAULT_CONNECT_TIMEOUT_MS } from '@/lib/transport-defaults.ts'; +import { + getCliRuntime, + isCliRuntimeReady, + refreshCliRuntimeContext, +} from '@/lib/runtime.ts'; import { getBootstrapCliContextState, isJsonOutput as contextUsesJsonOutput, setBootstrapCliContext, type CliContext, -} from "@/lib/cli-context.ts"; +} from '@/lib/cli-context.ts'; -export type { CliContext } from "@/lib/cli-context.ts"; +export type { CliContext } from '@/lib/cli-context.ts'; export function isJsonOutput(context: CliContext = getCliContext()): boolean { return contextUsesJsonOutput(context); diff --git a/cli/src/lib/args.ts b/cli/src/lib/args.ts index d14052c0..e2ff6553 100644 --- a/cli/src/lib/args.ts +++ b/cli/src/lib/args.ts @@ -1,18 +1,21 @@ -import { CliError } from "@/lib/errors.ts"; +import { CliError } from '@/lib/errors.ts'; export type ParsedArgsRecord = Record; export function stringArg(args: ParsedArgsRecord, key: string): string { const value = args[key]; - if (typeof value === "string") { + if (typeof value === 'string') { return value; } throw new CliError(`Missing required argument: ${key}.`); } -export function optionalStringArg(args: ParsedArgsRecord, key: string): string | undefined { +export function optionalStringArg( + args: ParsedArgsRecord, + key: string +): string | undefined { const value = args[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; + return typeof value === 'string' && value.length > 0 ? value : undefined; } export function booleanArg(args: ParsedArgsRecord, key: string): boolean { @@ -22,11 +25,11 @@ export function booleanArg(args: ParsedArgsRecord, key: string): boolean { export function enumArg( args: ParsedArgsRecord, key: string, - values: T, + values: T ): T[number] { const value = stringArg(args, key); if (values.includes(value)) { return value as T[number]; } - throw new CliError(`--${key} must be one of: ${values.join(", ")}.`); + throw new CliError(`--${key} must be one of: ${values.join(', ')}.`); } diff --git a/cli/src/lib/auth.test.ts b/cli/src/lib/auth.test.ts index 74ba5966..64b0285b 100644 --- a/cli/src/lib/auth.test.ts +++ b/cli/src/lib/auth.test.ts @@ -1,25 +1,25 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { getLakehouseAuthHeader, getLakehouseCredentialPair, getManagementAuthHeader, requireManagementEnv, -} from "@/lib/auth.ts"; -import { configSet } from "@/lib/config.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; -import { secretSet } from "@/lib/secrets.ts"; +} from '@/lib/auth.ts'; +import { configSet } from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { FROM_ENV_PSEUDOPROFILE_NAME } from '@/lib/profile-store.ts'; +import { secretSet } from '@/lib/secrets.ts'; -let testHome = ""; -const profileName = "default"; +let testHome = ''; +const profileName = 'default'; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-auth-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-auth-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; }); afterEach(() => { @@ -33,128 +33,156 @@ afterEach(() => { delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; }); -describe("auth", () => { - test("getLakehouseAuthHeader throws ConfigurationError when credentials are missing", () => { - expect(() => getLakehouseAuthHeader(profileName)).toThrow(ConfigurationError); +describe('auth', () => { + test('getLakehouseAuthHeader throws ConfigurationError when credentials are missing', () => { expect(() => getLakehouseAuthHeader(profileName)).toThrow( - "No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).", + ConfigurationError + ); + expect(() => getLakehouseAuthHeader(profileName)).toThrow( + "No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN)." ); }); - test("getManagementAuthHeader throws ConfigurationError when API key is missing", () => { - expect(() => getManagementAuthHeader(profileName)).toThrow(ConfigurationError); + test('getManagementAuthHeader throws ConfigurationError when API key is missing', () => { expect(() => getManagementAuthHeader(profileName)).toThrow( - "No management credentials. Run 'altertable login', 'altertable profile configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY.", + ConfigurationError + ); + expect(() => getManagementAuthHeader(profileName)).toThrow( + "No management credentials. Run 'altertable login', 'altertable profile configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY." ); }); - test("requireManagementEnv throws ConfigurationError when environment is missing", () => { + test('requireManagementEnv throws ConfigurationError when environment is missing', () => { expect(() => requireManagementEnv(profileName)).toThrow(ConfigurationError); expect(() => requireManagementEnv(profileName)).toThrow( - "No environment set. Run 'altertable profile configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV.", + "No environment set. Run 'altertable profile configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV." ); }); - test("resolves lakehouse and management credentials independently", () => { - configSet("user", "alice", profileName); - secretSet("lakehouse/password", "lakehouse-secret", profileName); - secretSet("api-key", "atm_test", profileName); + test('resolves lakehouse and management credentials independently', () => { + configSet('user', 'alice', profileName); + secretSet('lakehouse/password', 'lakehouse-secret', profileName); + secretSet('api-key', 'atm_test', profileName); - const lakehouseToken = Buffer.from("alice:lakehouse-secret").toString("base64"); - expect(getLakehouseAuthHeader(profileName)).toBe(`Authorization: Basic ${lakehouseToken}`); - expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer atm_test"); + const lakehouseToken = Buffer.from('alice:lakehouse-secret').toString( + 'base64' + ); + expect(getLakehouseAuthHeader(profileName)).toBe( + `Authorization: Basic ${lakehouseToken}` + ); + expect(getManagementAuthHeader(profileName)).toBe( + 'Authorization: Bearer atm_test' + ); }); - test("resolves environment credentials through the env pseudo-profile", () => { - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "staging"; + test('resolves environment credentials through the env pseudo-profile', () => { + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'staging'; - const lakehouseToken = Buffer.from("env-user:env-pass").toString("base64"); + const lakehouseToken = Buffer.from('env-user:env-pass').toString('base64'); expect(getLakehouseAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toBe( - `Authorization: Basic ${lakehouseToken}`, + `Authorization: Basic ${lakehouseToken}` ); expect(getManagementAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toBe( - "Authorization: Bearer atm_env", + 'Authorization: Bearer atm_env' ); - expect(requireManagementEnv(FROM_ENV_PSEUDOPROFILE_NAME)).toBe("staging"); + expect(requireManagementEnv(FROM_ENV_PSEUDOPROFILE_NAME)).toBe('staging'); }); - test("ignores environment credentials for stored profiles", () => { - configSet("user", "alice", profileName); - secretSet("lakehouse/password", "lakehouse-secret", profileName); - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-basic-token"; + test('ignores environment credentials for stored profiles', () => { + configSet('user', 'alice', profileName); + secretSet('lakehouse/password', 'lakehouse-secret', profileName); + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-basic-token'; - const lakehouseToken = Buffer.from("alice:lakehouse-secret").toString("base64"); - expect(getLakehouseAuthHeader(profileName)).toBe(`Authorization: Basic ${lakehouseToken}`); + const lakehouseToken = Buffer.from('alice:lakehouse-secret').toString( + 'base64' + ); + expect(getLakehouseAuthHeader(profileName)).toBe( + `Authorization: Basic ${lakehouseToken}` + ); }); - test("prefers the environment Basic token over environment username/password", () => { - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-basic-token"; - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; + test('prefers the environment Basic token over environment username/password', () => { + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-basic-token'; + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; expect(getLakehouseAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toBe( - "Authorization: Basic env-basic-token", + 'Authorization: Basic env-basic-token' ); }); - test("env pseudo-profile errors name the missing environment variables", () => { + test('env pseudo-profile errors name the missing environment variables', () => { expect(() => getLakehouseAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toThrow( - "No lakehouse credentials in the environment configuration. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD, or unset the ALTERTABLE_* configuration variables to use a stored profile.", + 'No lakehouse credentials in the environment configuration. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD, or unset the ALTERTABLE_* configuration variables to use a stored profile.' ); expect(() => getManagementAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toThrow( - "No management credentials in the environment configuration. Set ALTERTABLE_API_KEY, or unset the ALTERTABLE_* configuration variables to use a stored profile.", + 'No management credentials in the environment configuration. Set ALTERTABLE_API_KEY, or unset the ALTERTABLE_* configuration variables to use a stored profile.' ); expect(() => requireManagementEnv(FROM_ENV_PSEUDOPROFILE_NAME)).toThrow( - "No environment set in the environment configuration. Set ALTERTABLE_ENV.", + 'No environment set in the environment configuration. Set ALTERTABLE_ENV.' ); }); - test("getLakehouseCredentialPair returns stored username/password credentials", () => { - configSet("user", "alice", profileName); - secretSet("lakehouse/password", "s3cret", profileName); + test('getLakehouseCredentialPair returns stored username/password credentials', () => { + configSet('user', 'alice', profileName); + secretSet('lakehouse/password', 's3cret', profileName); - expect(getLakehouseCredentialPair(profileName)).toEqual({ user: "alice", password: "s3cret" }); + expect(getLakehouseCredentialPair(profileName)).toEqual({ + user: 'alice', + password: 's3cret', + }); }); - test("getLakehouseCredentialPair decodes a stored basic token", () => { - secretSet("lakehouse/basic-token", Buffer.from("alice:s3cret").toString("base64"), profileName); + test('getLakehouseCredentialPair decodes a stored basic token', () => { + secretSet( + 'lakehouse/basic-token', + Buffer.from('alice:s3cret').toString('base64'), + profileName + ); - expect(getLakehouseCredentialPair(profileName)).toEqual({ user: "alice", password: "s3cret" }); + expect(getLakehouseCredentialPair(profileName)).toEqual({ + user: 'alice', + password: 's3cret', + }); }); - test("getLakehouseCredentialPair preserves colons in decoded passwords", () => { + test('getLakehouseCredentialPair preserves colons in decoded passwords', () => { secretSet( - "lakehouse/basic-token", - Buffer.from("alice:pa:ss:word").toString("base64"), - profileName, + 'lakehouse/basic-token', + Buffer.from('alice:pa:ss:word').toString('base64'), + profileName ); expect(getLakehouseCredentialPair(profileName)).toEqual({ - user: "alice", - password: "pa:ss:word", + user: 'alice', + password: 'pa:ss:word', }); }); - test("getLakehouseCredentialPair resolves environment credentials", () => { - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; + test('getLakehouseCredentialPair resolves environment credentials', () => { + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; expect(getLakehouseCredentialPair(FROM_ENV_PSEUDOPROFILE_NAME)).toEqual({ - user: "env-user", - password: "env-pass", + user: 'env-user', + password: 'env-pass', }); }); - test("getLakehouseCredentialPair rejects a token without decodable credentials", () => { - secretSet("lakehouse/basic-token", Buffer.from("no-separator").toString("base64"), profileName); + test('getLakehouseCredentialPair rejects a token without decodable credentials', () => { + secretSet( + 'lakehouse/basic-token', + Buffer.from('no-separator').toString('base64'), + profileName + ); expect(() => getLakehouseCredentialPair(profileName)).toThrow( - "The configured lakehouse basic token does not decode to user:password credentials.", + 'The configured lakehouse basic token does not decode to user:password credentials.' ); }); }); diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index bc858c69..fa79599a 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -1,12 +1,12 @@ -import { configGet } from "@/lib/config.ts"; -import { CliError, ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; -import { optionalAuth } from "@/lib/execution-context.ts"; -import { secretGet } from "@/lib/secrets.ts"; -import { readEnv } from "@/lib/env.ts"; -import { isFromEnvProfile } from "@/lib/profile-store.ts"; +import { configGet } from '@/lib/config.ts'; +import { CliError, ConfigurationError, EXIT_CONFIG } from '@/lib/errors.ts'; +import { optionalAuth } from '@/lib/execution-context.ts'; +import { secretGet } from '@/lib/secrets.ts'; +import { readEnv } from '@/lib/env.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; export function basicAuthToken(user: string, password: string): string { - return Buffer.from(`${user}:${password}`).toString("base64"); + return Buffer.from(`${user}:${password}`).toString('base64'); } export function basicAuthHeader(token: string): string { @@ -14,7 +14,7 @@ export function basicAuthHeader(token: string): string { } function storedLakehouseCredentialsExpired(profileName: string): boolean { - const raw = configGet("lakehouse_credential_expiry", profileName); + const raw = configGet('lakehouse_credential_expiry', profileName); if (!raw) { return false; } @@ -24,7 +24,7 @@ function storedLakehouseCredentialsExpired(profileName: string): boolean { if (Number.isNaN(expiry)) { throw new CliError( "Stored lakehouse credential expiry is corrupted. Run 'altertable logout' and try again.", - { exitCode: EXIT_CONFIG }, + { exitCode: EXIT_CONFIG } ); } // Leave time to renew before a long-running data app starts a query. @@ -32,40 +32,42 @@ function storedLakehouseCredentialsExpired(profileName: string): boolean { } export type LakehouseCredential = - | { kind: "basic-token"; token: string } - | { kind: "user-password"; user: string; password: string }; + | { kind: 'basic-token'; token: string } + | { kind: 'user-password'; user: string; password: string }; export type LakehouseBasicAuthPair = { user: string; password: string }; function envLakehouseCredential(): LakehouseCredential | undefined { - const envToken = readEnv("ALTERTABLE_BASIC_AUTH_TOKEN"); + const envToken = readEnv('ALTERTABLE_BASIC_AUTH_TOKEN'); if (envToken) { - return { kind: "basic-token", token: envToken }; + return { kind: 'basic-token', token: envToken }; } - const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); - const envPassword = readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"); + const envUser = readEnv('ALTERTABLE_LAKEHOUSE_USERNAME'); + const envPassword = readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD'); if (envUser && envPassword) { - return { kind: "user-password", user: envUser, password: envPassword }; + return { kind: 'user-password', user: envUser, password: envPassword }; } return undefined; } -function storedLakehouseCredential(profileName: string): LakehouseCredential | undefined { +function storedLakehouseCredential( + profileName: string +): LakehouseCredential | undefined { if (storedLakehouseCredentialsExpired(profileName)) { return undefined; } - const storedToken = secretGet("lakehouse/basic-token", profileName); + const storedToken = secretGet('lakehouse/basic-token', profileName); if (storedToken) { - return { kind: "basic-token", token: storedToken }; + return { kind: 'basic-token', token: storedToken }; } - const user = configGet("user", profileName); - const password = secretGet("lakehouse/password", profileName); + const user = configGet('user', profileName); + const password = secretGet('lakehouse/password', profileName); if (user && password) { - return { kind: "user-password", user, password }; + return { kind: 'user-password', user, password }; } return undefined; @@ -75,14 +77,16 @@ export function hasLakehouseEnvCredentials(): boolean { return envLakehouseCredential() !== undefined; } -export function resolveLakehouseCredential(profileName: string): LakehouseCredential { +export function resolveLakehouseCredential( + profileName: string +): LakehouseCredential { if (isFromEnvProfile(profileName)) { const credential = envLakehouseCredential(); if (credential) { return credential; } throw new ConfigurationError( - "No lakehouse credentials in the environment configuration. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD, or unset the ALTERTABLE_* configuration variables to use a stored profile.", + 'No lakehouse credentials in the environment configuration. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD, or unset the ALTERTABLE_* configuration variables to use a stored profile.' ); } @@ -91,35 +95,40 @@ export function resolveLakehouseCredential(profileName: string): LakehouseCreden return credential; } throw new ConfigurationError( - "No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).", + "No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN)." ); } export function getLakehouseAuthHeader(profileName: string): string { const credential = resolveLakehouseCredential(profileName); - return credential.kind === "basic-token" + return credential.kind === 'basic-token' ? basicAuthHeader(credential.token) : basicAuthHeader(basicAuthToken(credential.user, credential.password)); } function decodeBasicToken(token: string): LakehouseBasicAuthPair | undefined { - const decoded = Buffer.from(token, "base64").toString("utf8"); - const separator = decoded.indexOf(":"); + const decoded = Buffer.from(token, 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); if (separator <= 0 || separator === decoded.length - 1) { return undefined; } - return { user: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; + return { + user: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; } -export function getLakehouseCredentialPair(profileName: string): LakehouseBasicAuthPair { +export function getLakehouseCredentialPair( + profileName: string +): LakehouseBasicAuthPair { const credential = resolveLakehouseCredential(profileName); - if (credential.kind === "user-password") { + if (credential.kind === 'user-password') { return { user: credential.user, password: credential.password }; } const decoded = decodeBasicToken(credential.token); if (!decoded) { throw new ConfigurationError( - "The configured lakehouse basic token does not decode to user:password credentials. Provide a username and password instead (ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD or 'altertable profile configure --user --password

').", + "The configured lakehouse basic token does not decode to user:password credentials. Provide a username and password instead (ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD or 'altertable profile configure --user --password

')." ); } return decoded; @@ -127,24 +136,24 @@ export function getLakehouseCredentialPair(profileName: string): LakehouseBasicA export function getManagementAuthHeader(profileName: string): string { if (isFromEnvProfile(profileName)) { - const envKey = readEnv("ALTERTABLE_API_KEY") ?? ""; + const envKey = readEnv('ALTERTABLE_API_KEY') ?? ''; if (envKey) { return `Authorization: Bearer ${envKey}`; } throw new ConfigurationError( - "No management credentials in the environment configuration. Set ALTERTABLE_API_KEY, or unset the ALTERTABLE_* configuration variables to use a stored profile.", + 'No management credentials in the environment configuration. Set ALTERTABLE_API_KEY, or unset the ALTERTABLE_* configuration variables to use a stored profile.' ); } - const oauthToken = secretGet("oauth/access-token", profileName); + const oauthToken = secretGet('oauth/access-token', profileName); if (oauthToken) { return `Authorization: Bearer ${oauthToken}`; } - const key = secretGet("api-key", profileName); + const key = secretGet('api-key', profileName); if (!key) { throw new ConfigurationError( - "No management credentials. Run 'altertable login', 'altertable profile configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY.", + "No management credentials. Run 'altertable login', 'altertable profile configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY." ); } return `Authorization: Bearer ${key}`; @@ -152,19 +161,19 @@ export function getManagementAuthHeader(profileName: string): string { export function requireManagementEnv(profileName: string): string { if (isFromEnvProfile(profileName)) { - const env = readEnv("ALTERTABLE_ENV") ?? ""; + const env = readEnv('ALTERTABLE_ENV') ?? ''; if (!env) { throw new ConfigurationError( - "No environment set in the environment configuration. Set ALTERTABLE_ENV.", + 'No environment set in the environment configuration. Set ALTERTABLE_ENV.' ); } return env; } - const env = configGet("api_key_env", profileName); + const env = configGet('api_key_env', profileName); if (!env) { throw new ConfigurationError( - "No environment set. Run 'altertable profile configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV.", + "No environment set. Run 'altertable profile configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV." ); } return env; @@ -177,18 +186,20 @@ export type ManagementPlaneRequirement = { export function requireManagementPlane( profileName: string, - options: ManagementPlaneRequirement, + options: ManagementPlaneRequirement ): string { const env = optionalAuth(() => requireManagementEnv(profileName)); - const credentialed = optionalAuth(() => getManagementAuthHeader(profileName)) !== undefined; + const credentialed = + optionalAuth(() => getManagementAuthHeader(profileName)) !== undefined; if (env !== undefined && credentialed) { return env; } const remediation = isFromEnvProfile(profileName) - ? "Set ALTERTABLE_API_KEY and ALTERTABLE_ENV" + ? 'Set ALTERTABLE_API_KEY and ALTERTABLE_ENV' : "Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env '"; - const alternative = options.alternative === undefined ? "" : `, or ${options.alternative}`; + const alternative = + options.alternative === undefined ? '' : `, or ${options.alternative}`; throw new ConfigurationError( - `${options.requirement}, but the management plane is not configured. ${remediation}${alternative}.`, + `${options.requirement}, but the management plane is not configured. ${remediation}${alternative}.` ); } diff --git a/cli/src/lib/cli-args.ts b/cli/src/lib/cli-args.ts index 85376385..a20bb72e 100644 --- a/cli/src/lib/cli-args.ts +++ b/cli/src/lib/cli-args.ts @@ -1,10 +1,14 @@ /** Coerce a parsed CLI argument to string without unsafe object stringification. */ export function asCliArgString(value: unknown): string { - if (typeof value === "string") { + if (typeof value === 'string') { return value; } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { return String(value); } - return ""; + return ''; } diff --git a/cli/src/lib/cli-session.test.ts b/cli/src/lib/cli-session.test.ts index a3dfd734..79a091d5 100644 --- a/cli/src/lib/cli-session.test.ts +++ b/cli/src/lib/cli-session.test.ts @@ -1,19 +1,19 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { createCliSession } from "@/lib/cli-session.ts"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { setCliContext } from "@/context.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createCliSession } from '@/lib/cli-session.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { setCliContext } from '@/context.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -let testHome = ""; +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-cli-session-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-cli-session-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; setCliContext({ debug: false, json: false, agent: false }); }); @@ -25,18 +25,24 @@ afterEach(() => { delete process.env.ALTERTABLE_ENV; }); -describe("createCliSession", () => { - test("pins the resolved working profile", async () => { +describe('createCliSession', () => { + test('pins the resolved working profile', async () => { await configureRunSet({ - apiKey: "atm_test", - env: "staging", - dataPlaneUrl: "http://localhost:15000", - controlPlaneUrl: "http://localhost:13000", + apiKey: 'atm_test', + env: 'staging', + dataPlaneUrl: 'http://localhost:15000', + controlPlaneUrl: 'http://localhost:13000', }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - const session = runWithCliRuntime(runtime, () => createCliSession(runtime.context)); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + const session = runWithCliRuntime(runtime, () => + createCliSession(runtime.context) + ); - expect(session.profile).toBe("default"); + expect(session.profile).toBe('default'); }); }); diff --git a/cli/src/lib/cli-session.ts b/cli/src/lib/cli-session.ts index 2a62ca35..55aea56a 100644 --- a/cli/src/lib/cli-session.ts +++ b/cli/src/lib/cli-session.ts @@ -1,5 +1,5 @@ -import type { CliContext } from "@/context.ts"; -import { resolveWorkingProfile } from "@/lib/profile-store.ts"; +import type { CliContext } from '@/context.ts'; +import { resolveWorkingProfile } from '@/lib/profile-store.ts'; // Endpoints and auth are intentionally NOT cached here — they resolve from this // profile at each request, so they can never desync from it. diff --git a/cli/src/lib/command-descriptor.test.ts b/cli/src/lib/command-descriptor.test.ts index 4b7e5361..ab9ea292 100644 --- a/cli/src/lib/command-descriptor.test.ts +++ b/cli/src/lib/command-descriptor.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, test } from "bun:test"; -import { buildMainCommand } from "@/cli.ts"; -import { buildCompletionSpecFromDescriptor } from "@/commands/completion/lib/spec.ts"; -import { defineCommand } from "@/lib/command.ts"; +import { describe, expect, test } from 'bun:test'; +import { buildMainCommand } from '@/cli.ts'; +import { buildCompletionSpecFromDescriptor } from '@/commands/completion/lib/spec.ts'; +import { defineCommand } from '@/lib/command.ts'; import { resolveCommandDescriptor, validateCommandDescriptor, @@ -9,11 +9,11 @@ import { type CommandArgumentDescriptor, type CommandDescriptor, type ResolvedCommandMetadata, -} from "@/lib/command-descriptor.ts"; +} from '@/lib/command-descriptor.ts'; import { buildStructuredHelpFromDescriptor, renderAltertableUsageFromDescriptor, -} from "@/lib/usage.ts"; +} from '@/lib/usage.ts'; function structuredArgumentContract(argument: CommandArgumentDescriptor) { return { @@ -31,17 +31,17 @@ function structuredArgumentContract(argument: CommandArgumentDescriptor) { function completionContract( descriptor: CommandDescriptor, name: string, - depth = 0, + depth = 0 ): ReturnType { const subcommands = depth >= 3 ? [] : visibleCommandDescriptors(descriptor.subcommands) - .flatMap((child) => { + .flatMap(child => { if (!child.metadata.name) return []; - return [...new Set([child.metadata.name, ...child.metadata.aliases])].map((childName) => - completionContract(child, childName, depth + 1), - ); + return [ + ...new Set([child.metadata.name, ...child.metadata.aliases]), + ].map(childName => completionContract(child, childName, depth + 1)); }) .sort((left, right) => left.name.localeCompare(right.name)); @@ -50,23 +50,23 @@ function completionContract( description: descriptor.metadata.description || undefined, subcommands, flags: descriptor.arguments - .filter((argument) => ["boolean", "string", "enum"].includes(argument.type)) - .map((argument) => ({ + .filter(argument => ['boolean', 'string', 'enum'].includes(argument.type)) + .map(argument => ({ name: argument.name, alias: argument.aliases[0], description: argument.description || undefined, values: argument.values.length > 0 ? argument.values : undefined, - takesValue: argument.type === "string" || argument.type === "enum", + takesValue: argument.type === 'string' || argument.type === 'enum', scope: argument.scope, })) .sort((left, right) => left.name.localeCompare(right.name)), positionals: descriptor.arguments - .filter((argument) => argument.type === "positional") - .map((argument) => ({ + .filter(argument => argument.type === 'positional') + .map(argument => ({ name: argument.name, description: argument.description || undefined, required: argument.required, - completion: argument.positionalCompletion ?? "freeform", + completion: argument.positionalCompletion ?? 'freeform', values: argument.values, })), soleDirectOperands: descriptor.soleDirectOperands, @@ -76,11 +76,11 @@ function completionContract( async function expectStructuredHelpMatchesDescriptor( descriptor: CommandDescriptor, root: CommandDescriptor, - parent?: CommandDescriptor, + parent?: CommandDescriptor ): Promise { const help = buildStructuredHelpFromDescriptor(descriptor, parent, root); const humanHelp = renderAltertableUsageFromDescriptor(descriptor, parent); - const compactHumanHelp = humanHelp.replace(/\s+/g, ""); + const compactHumanHelp = humanHelp.replace(/\s+/g, ''); const commandArguments = descriptor.arguments.map(structuredArgumentContract); const visibleSubcommands = visibleCommandDescriptors(descriptor.subcommands); @@ -88,23 +88,29 @@ async function expectStructuredHelpMatchesDescriptor( description: descriptor.metadata.description, aliases: descriptor.metadata.aliases, examples: descriptor.metadata.examples, - arguments: commandArguments.filter((argument) => argument.type === "positional"), - options: parent ? commandArguments.filter((argument) => argument.type !== "positional") : [], + arguments: commandArguments.filter( + argument => argument.type === 'positional' + ), + options: parent + ? commandArguments.filter(argument => argument.type !== 'positional') + : [], global_options: (parent - ? root.arguments.filter((argument) => argument.scope === "global") + ? root.arguments.filter(argument => argument.scope === 'global') : root.arguments ).map(structuredArgumentContract), - subcommands: visibleSubcommands.map((child) => ({ - name: child.key ?? child.metadata.name ?? "command", + subcommands: visibleSubcommands.map(child => ({ + name: child.key ?? child.metadata.name ?? 'command', aliases: child.metadata.aliases, description: child.metadata.description, })), }); for (const argument of descriptor.arguments) { - if (argument.type === "positional") { + if (argument.type === 'positional') { const values = - argument.values.length > 0 ? argument.values : [argument.valueHint ?? argument.name]; + argument.values.length > 0 + ? argument.values + : [argument.valueHint ?? argument.name]; for (const value of values) { expect(humanHelp).toContain(value.toUpperCase()); } @@ -114,11 +120,13 @@ async function expectStructuredHelpMatchesDescriptor( } for (const child of visibleSubcommands) { if (parent || child.metadata.commandGroup) { - expect(humanHelp).toContain(child.key ?? child.metadata.name ?? "command"); + expect(humanHelp).toContain( + child.key ?? child.metadata.name ?? 'command' + ); } } for (const example of descriptor.metadata.examples) { - expect(compactHumanHelp).toContain(example.replace(/\s+/g, "")); + expect(compactHumanHelp).toContain(example.replace(/\s+/g, '')); } for (const child of visibleSubcommands) { @@ -126,63 +134,71 @@ async function expectStructuredHelpMatchesDescriptor( } } -describe("command descriptor", () => { - test("normalizes the shared command presentation contract", async () => { +describe('command descriptor', () => { + test('normalizes the shared command presentation contract', async () => { const command = defineCommand({ metadata: { - name: "inspect", - alias: ["show", "get"], - description: "Inspect a resource.", - examples: ["altertable inspect resource"], + name: 'inspect', + alias: ['show', 'get'], + description: 'Inspect a resource.', + examples: ['altertable inspect resource'], hidden: true, - commandGroup: "platform", + commandGroup: 'platform', }, }); const expected = { - name: "inspect", - aliases: ["show", "get"], - description: "Inspect a resource.", - examples: ["altertable inspect resource"], + name: 'inspect', + aliases: ['show', 'get'], + description: 'Inspect a resource.', + examples: ['altertable inspect resource'], hidden: true, - commandGroup: "platform", - invocations: ["direct"], + commandGroup: 'platform', + invocations: ['direct'], } satisfies ResolvedCommandMetadata; - expect((await resolveCommandDescriptor(command)).metadata).toEqual(expected); + expect((await resolveCommandDescriptor(command)).metadata).toEqual( + expected + ); }); - test("supports resolvable metadata for asynchronous consumers", async () => { + test('supports resolvable metadata for asynchronous consumers', async () => { const command = defineCommand({ - metadata: async () => ({ name: "generated", description: "Generated metadata." }), + metadata: async () => ({ + name: 'generated', + description: 'Generated metadata.', + }), }); expect((await resolveCommandDescriptor(command)).metadata).toMatchObject({ - name: "generated", - description: "Generated metadata.", + name: 'generated', + description: 'Generated metadata.', }); }); - test("normalizes metadata, arguments, and child commands together", async () => { + test('normalizes metadata, arguments, and child commands together', async () => { const command = defineCommand({ - metadata: async () => ({ name: "completion", description: "Manage completion." }), + metadata: async () => ({ + name: 'completion', + description: 'Manage completion.', + }), args: { shell: { - type: "positional", - description: "Shell name", - values: ["bash", "fish", "zsh"], + type: 'positional', + description: 'Shell name', + values: ['bash', 'fish', 'zsh'], required: true, }, output: { - type: "enum", - alias: ["o"], - description: "Output mode", - options: ["script", "path"], - default: "script", + type: 'enum', + alias: ['o'], + description: 'Output mode', + options: ['script', 'path'], + default: 'script', }, }, subcommands: { install: defineCommand({ - metadata: async () => ({ name: "install", alias: "add" }), + metadata: async () => ({ name: 'install', alias: 'add' }), }), }, }); @@ -190,129 +206,148 @@ describe("command descriptor", () => { const descriptor = await resolveCommandDescriptor(command); expect(descriptor.metadata).toMatchObject({ - name: "completion", - description: "Manage completion.", + name: 'completion', + description: 'Manage completion.', }); expect(descriptor.arguments).toEqual([ { - name: "shell", + name: 'shell', aliases: [], - type: "positional", - description: "Shell name", + type: 'positional', + description: 'Shell name', required: true, parserRequired: true, requiredExplicitly: true, repeatable: false, - scope: "command", - values: ["bash", "fish", "zsh"], - positionalCompletion: "finite", + scope: 'command', + values: ['bash', 'fish', 'zsh'], + positionalCompletion: 'finite', }, { - name: "output", - aliases: ["o"], - type: "enum", - description: "Output mode", + name: 'output', + aliases: ['o'], + type: 'enum', + description: 'Output mode', required: false, parserRequired: false, requiredExplicitly: true, repeatable: false, - scope: "command", - values: ["script", "path"], - default: "script", + scope: 'command', + values: ['script', 'path'], + default: 'script', }, ]); expect(descriptor.subcommands).toHaveLength(1); expect(descriptor.subcommands[0]).toMatchObject({ - key: "install", - metadata: { name: "install", aliases: ["add"] }, + key: 'install', + metadata: { name: 'install', aliases: ['add'] }, arguments: [], subcommands: [], }); expect(JSON.parse(JSON.stringify(descriptor))).toEqual(descriptor); }); - test("keeps human help, structured help, and completion aligned with the descriptor", async () => { + test('keeps human help, structured help, and completion aligned with the descriptor', async () => { const root = await resolveCommandDescriptor(buildMainCommand()); expect(() => validateCommandDescriptor(root)).not.toThrow(); await expectStructuredHelpMatchesDescriptor(root, root); expect(buildCompletionSpecFromDescriptor(root)).toEqual( - completionContract(root, root.metadata.name ?? "altertable"), + completionContract(root, root.metadata.name ?? 'altertable') ); }); - test("marks profile env name as an optional runtime operand", async () => { + test('marks profile env name as an optional runtime operand', async () => { const root = await resolveCommandDescriptor(buildMainCommand()); - const profile = root.subcommands.find((command) => command.key === "profile"); - const env = profile?.subcommands.find((command) => command.key === "env"); + const profile = root.subcommands.find(command => command.key === 'profile'); + const env = profile?.subcommands.find(command => command.key === 'env'); expect(env?.arguments).toEqual([ - expect.objectContaining({ name: "name", required: false, requiredExplicitly: true }), - ]); - expect(renderAltertableUsageFromDescriptor(env!, profile)).toContain("[NAME]"); - expect(buildStructuredHelpFromDescriptor(env!, profile, root).arguments).toEqual([ - expect.objectContaining({ name: "name", required: false }), + expect.objectContaining({ + name: 'name', + required: false, + requiredExplicitly: true, + }), ]); + expect(renderAltertableUsageFromDescriptor(env!, profile)).toContain( + '[NAME]' + ); + expect( + buildStructuredHelpFromDescriptor(env!, profile, root).arguments + ).toEqual([expect.objectContaining({ name: 'name', required: false })]); }); - test("separates parser optionality from direct invocation requirements", async () => { + test('separates parser optionality from direct invocation requirements', async () => { const root = await resolveCommandDescriptor(buildMainCommand()); - const query = root.subcommands.find((command) => command.key === "query"); - const append = root.subcommands.find((command) => command.key === "append"); + const query = root.subcommands.find(command => command.key === 'query'); + const append = root.subcommands.find(command => command.key === 'append'); expect(query?.arguments).toContainEqual( expect.objectContaining({ - name: "statement", + name: 'statement', parserRequired: false, required: true, - }), + }) ); expect(append?.arguments).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: "data", parserRequired: false, required: true }), - expect.objectContaining({ name: "to", parserRequired: false, required: true }), - ]), + expect.objectContaining({ + name: 'data', + parserRequired: false, + required: true, + }), + expect.objectContaining({ + name: 'to', + parserRequired: false, + required: true, + }), + ]) ); }); - test("describes query show as a subcommand without sole-direct collisions", async () => { + test('describes query show as a subcommand without sole-direct collisions', async () => { const root = await resolveCommandDescriptor(buildMainCommand()); - const query = root.subcommands.find((command) => command.key === "query"); + const query = root.subcommands.find(command => command.key === 'query'); expect(query?.soleDirectOperands).toEqual([]); - expect(query?.subcommands.map((command) => command.metadata.name)).toContain("show"); + expect(query?.subcommands.map(command => command.metadata.name)).toContain( + 'show' + ); }); - test("reports descriptor invariant violations together", async () => { + test('reports descriptor invariant violations together', async () => { const descriptor = await resolveCommandDescriptor( defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, subcommands: { registered: defineCommand({ - metadata: { name: "canonical" }, - soleDirectOperands: ["missing", "missing"], + metadata: { name: 'canonical' }, + soleDirectOperands: ['missing', 'missing'], args: { - value: { type: "positional", description: "Runtime-optional value" }, + value: { + type: 'positional', + description: 'Runtime-optional value', + }, }, }), }, - }), + }) ); expect(() => validateCommandDescriptor(descriptor)).toThrow( - /registry key "registered" must match canonical name "canonical"/, + /registry key "registered" must match canonical name "canonical"/ ); expect(() => validateCommandDescriptor(descriptor)).toThrow( - /visible root command must declare a help group/, + /visible root command must declare a help group/ ); expect(() => validateCommandDescriptor(descriptor)).toThrow( - /positional requiredness must be explicit/, + /positional requiredness must be explicit/ ); expect(() => validateCommandDescriptor(descriptor)).toThrow( - /sole direct operand "missing" must match a subcommand name or alias/, + /sole direct operand "missing" must match a subcommand name or alias/ ); expect(() => validateCommandDescriptor(descriptor)).toThrow( - /sole direct operands must be unique/, + /sole direct operands must be unique/ ); }); }); diff --git a/cli/src/lib/command-descriptor.ts b/cli/src/lib/command-descriptor.ts index bb60eda7..b7aa1041 100644 --- a/cli/src/lib/command-descriptor.ts +++ b/cli/src/lib/command-descriptor.ts @@ -9,7 +9,7 @@ import { type CommandInvocationKind, type CommandMetadata, type PositionalCompletionKind, -} from "@/lib/command.ts"; +} from '@/lib/command.ts'; export type ResolvedCommandMetadata = { name?: string; @@ -50,7 +50,9 @@ export type CommandDescriptor = { subcommands: CommandDescriptor[]; }; -function asStringArray(value: string | readonly string[] | undefined): string[] { +function asStringArray( + value: string | readonly string[] | undefined +): string[] { if (Array.isArray(value)) return value.map(String); return value === undefined ? [] : [String(value)]; } @@ -64,10 +66,12 @@ export function aliasFlagToken(alias: string): string { return alias.length === 1 ? `-${alias}` : `--${alias}`; } -function normalizeCommandMetadata(metadata: CommandMetadata | undefined): ResolvedCommandMetadata { +function normalizeCommandMetadata( + metadata: CommandMetadata | undefined +): ResolvedCommandMetadata { return { ...(metadata?.name ? { name: String(metadata.name) } : {}), - description: metadata?.description ?? "", + description: metadata?.description ?? '', aliases: asStringArray(metadata?.alias), examples: asStringArray(metadata?.examples), hidden: metadata?.hidden === true, @@ -78,20 +82,22 @@ function normalizeCommandMetadata(metadata: CommandMetadata | undefined): Resolv function normalizeCommandArgument( name: string, - argument: CommandArgument, + argument: CommandArgument ): CommandArgumentDescriptor { const aliases = asStringArray(argument.alias); const values = commandArgumentValues(argument).map(String); const parserRequired = argument.default === undefined && - (argument.type === "positional" ? argument.required !== false : argument.required === true); + (argument.type === 'positional' + ? argument.required !== false + : argument.required === true); const required = argument.directRequired ?? parserRequired; return { name, aliases, - type: argument.type ?? "string", - description: argument.description ?? "", + type: argument.type ?? 'string', + description: argument.description ?? '', required, parserRequired, requiredExplicitly: @@ -99,12 +105,14 @@ function normalizeCommandArgument( argument.directRequired !== undefined || argument.default !== undefined, repeatable: argument.repeatable === true, - scope: argument.flagScope ?? "command", + scope: argument.flagScope ?? 'command', values, - ...(argument.type === "positional" + ...(argument.type === 'positional' ? { positionalCompletion: - values.length > 0 ? ("finite" as const) : (argument.completion ?? "freeform"), + values.length > 0 + ? ('finite' as const) + : (argument.completion ?? 'freeform'), } : {}), ...(argument.valueHint ? { valueHint: argument.valueHint } : {}), @@ -112,32 +120,40 @@ function normalizeCommandArgument( }; } -async function resolveCommandMetadata(command: Command): Promise { +async function resolveCommandMetadata( + command: Command +): Promise { const metadata = await resolveCommandValue( - command.metadata ?? undefined, + command.metadata ?? undefined ); return normalizeCommandMetadata(metadata); } -async function resolveCommandArguments(command: Command): Promise { - const commandArguments = (await resolveCommandValue(command.args ?? {})) as CommandArguments; +async function resolveCommandArguments( + command: Command +): Promise { + const commandArguments = (await resolveCommandValue( + command.args ?? {} + )) as CommandArguments; return Object.entries(commandArguments).map(([name, argument]) => - normalizeCommandArgument(name, argument), + normalizeCommandArgument(name, argument) ); } -async function resolveSubcommands(command: Command): Promise { +async function resolveSubcommands( + command: Command +): Promise { const subcommands = await resolveCommandValue(command.subcommands ?? {}); return await Promise.all( Object.entries(subcommands).map(async ([key, child]) => - resolveCommandDescriptor(await resolveCommandValue(child), key), - ), + resolveCommandDescriptor(await resolveCommandValue(child), key) + ) ); } export async function resolveCommandDescriptor( command: Command, - key?: string, + key?: string ): Promise { const [metadata, commandArguments, subcommands] = await Promise.all([ resolveCommandMetadata(command), @@ -150,10 +166,10 @@ export async function resolveCommandDescriptor( ? metadata.invocations : [ ...(subcommands.length === 0 || - commandArguments.some((argument) => argument.type === "positional") - ? (["direct"] as const) + commandArguments.some(argument => argument.type === 'positional') + ? (['direct'] as const) : []), - ...(subcommands.length > 0 ? (["subcommand"] as const) : []), + ...(subcommands.length > 0 ? (['subcommand'] as const) : []), ]; return { @@ -166,44 +182,56 @@ export async function resolveCommandDescriptor( } export function visibleCommandDescriptors( - descriptors: readonly CommandDescriptor[], + descriptors: readonly CommandDescriptor[] ): CommandDescriptor[] { - return descriptors.filter((descriptor) => !descriptor.metadata.hidden); + return descriptors.filter(descriptor => !descriptor.metadata.hidden); } export function validateCommandDescriptor(root: CommandDescriptor): void { const errors: string[] = []; - function visit(descriptor: CommandDescriptor, path: string[], depth: number): void { + function visit( + descriptor: CommandDescriptor, + path: string[], + depth: number + ): void { const canonicalName = descriptor.metadata.name; - const name = canonicalName ?? descriptor.key ?? ""; + const name = canonicalName ?? descriptor.key ?? ''; const commandPath = [...path, name]; - const displayPath = commandPath.join(" "); + const displayPath = commandPath.join(' '); if (descriptor.key && descriptor.key !== canonicalName) { errors.push( - `${displayPath}: registry key "${descriptor.key}" must match canonical name "${canonicalName ?? ""}"`, + `${displayPath}: registry key "${descriptor.key}" must match canonical name "${canonicalName ?? ''}"` ); } - if (depth === 1 && !descriptor.metadata.hidden && !descriptor.metadata.commandGroup) { - errors.push(`${displayPath}: visible root command must declare a help group`); + if ( + depth === 1 && + !descriptor.metadata.hidden && + !descriptor.metadata.commandGroup + ) { + errors.push( + `${displayPath}: visible root command must declare a help group` + ); } for (const argument of descriptor.arguments) { - if (argument.type === "positional" && !argument.requiredExplicitly) { - errors.push(`${displayPath} <${argument.name}>: positional requiredness must be explicit`); + if (argument.type === 'positional' && !argument.requiredExplicitly) { + errors.push( + `${displayPath} <${argument.name}>: positional requiredness must be explicit` + ); } } const subcommandNames = new Set( - descriptor.subcommands.flatMap((subcommand) => [ + descriptor.subcommands.flatMap(subcommand => [ ...(subcommand.metadata.name ? [subcommand.metadata.name] : []), ...subcommand.metadata.aliases, - ]), + ]) ); const soleDirectOperands = new Set(descriptor.soleDirectOperands); for (const operand of soleDirectOperands) { if (!subcommandNames.has(operand)) { errors.push( - `${displayPath}: sole direct operand "${operand}" must match a subcommand name or alias`, + `${displayPath}: sole direct operand "${operand}" must match a subcommand name or alias` ); } } @@ -218,7 +246,7 @@ export function validateCommandDescriptor(root: CommandDescriptor): void { visit(root, [], 0); if (errors.length > 0) { throw new TypeError( - `Invalid command descriptor:\n${errors.map((error) => `- ${error}`).join("\n")}`, + `Invalid command descriptor:\n${errors.map(error => `- ${error}`).join('\n')}` ); } } diff --git a/cli/src/lib/command-output.test.ts b/cli/src/lib/command-output.test.ts index a46437be..67f63a18 100644 --- a/cli/src/lib/command-output.test.ts +++ b/cli/src/lib/command-output.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { createCliRuntime, type OutputSink } from "@/lib/runtime.ts"; +import { describe, expect, test } from 'bun:test'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { createCliRuntime, type OutputSink } from '@/lib/runtime.ts'; function captureOutput(json: boolean): { stdout: string[]; @@ -10,109 +10,125 @@ function captureOutput(json: boolean): { const stdout: string[] = []; const stderr: string[] = []; const sink = createCliRuntime({ debug: false, json, agent: false }).output; - sink.writeJson = (data) => stdout.push(JSON.stringify(data, null, 2)); - sink.writeRaw = (body) => stdout.push(body); - sink.writeHuman = (text) => stdout.push(text); - sink.writeMetadata = (lines) => stderr.push(...lines); + sink.writeJson = data => stdout.push(JSON.stringify(data, null, 2)); + sink.writeRaw = body => stdout.push(body); + sink.writeHuman = text => stdout.push(text); + sink.writeMetadata = lines => stderr.push(...lines); return { stdout, stderr, sink }; } -describe("writeCommandOutput", () => { - test("raw_api emits verbatim body in json mode", async () => { +describe('writeCommandOutput', () => { + test('raw_api emits verbatim body in json mode', async () => { const { stdout, sink } = captureOutput(true); - await writeCommandOutput({ kind: "raw_api", body: '{"ok":true}' }, sink); + await writeCommandOutput({ kind: 'raw_api', body: '{"ok":true}' }, sink); expect(stdout).toEqual(['{"ok":true}']); }); - test("normalized emits envelope in json mode", async () => { + test('normalized emits envelope in json mode', async () => { const { stdout, sink } = captureOutput(true); await writeCommandOutput( { - kind: "normalized", - data: { catalogs: [{ name: "db" }] }, - humanText: "table output", + kind: 'normalized', + data: { catalogs: [{ name: 'db' }] }, + humanText: 'table output', }, - sink, + sink ); - expect(stdout).toEqual([JSON.stringify({ catalogs: [{ name: "db" }] }, null, 2)]); + expect(stdout).toEqual([ + JSON.stringify({ catalogs: [{ name: 'db' }] }, null, 2), + ]); }); - test("human emits text envelope in json mode", async () => { + test('human emits text envelope in json mode', async () => { const { stdout, sink } = captureOutput(true); - await writeCommandOutput({ kind: "human", text: "configure show text" }, sink); - expect(stdout).toEqual([JSON.stringify({ text: "configure show text" }, null, 2)]); + await writeCommandOutput( + { kind: 'human', text: 'configure show text' }, + sink + ); + expect(stdout).toEqual([ + JSON.stringify({ text: 'configure show text' }, null, 2), + ]); }); - test("deleted emits json success object", async () => { + test('deleted emits json success object', async () => { const { stdout, sink } = captureOutput(true); - await writeCommandOutput({ kind: "deleted", message: "Deleted item.", id: "item_1" }, sink); - expect(stdout).toEqual([JSON.stringify({ deleted: true, id: "item_1" }, null, 2)]); + await writeCommandOutput( + { kind: 'deleted', message: 'Deleted item.', id: 'item_1' }, + sink + ); + expect(stdout).toEqual([ + JSON.stringify({ deleted: true, id: 'item_1' }, null, 2), + ]); }); - test("tabular defaults to table in human mode", async () => { - const body = JSON.stringify({ databases: [{ id: "db_1", name: "Analytics" }] }); + test('tabular defaults to table in human mode', async () => { + const body = JSON.stringify({ + databases: [{ id: 'db_1', name: 'Analytics' }], + }); const { stdout, sink } = captureOutput(false); - await writeCommandOutput({ kind: "tabular", body }, sink); - expect(stdout[0]).toContain("id"); - expect(stdout[0]).toContain("Analytics"); + await writeCommandOutput({ kind: 'tabular', body }, sink); + expect(stdout[0]).toContain('id'); + expect(stdout[0]).toContain('Analytics'); }); - test("normalized writes metadata lines in human mode", async () => { + test('normalized writes metadata lines in human mode', async () => { const { stdout, stderr, sink } = captureOutput(false); await writeCommandOutput( { - kind: "normalized", - data: { catalogs: [], summary: "0 catalogs" }, - humanText: "table output", - metadataLines: ["", "0 catalogs"], + kind: 'normalized', + data: { catalogs: [], summary: '0 catalogs' }, + humanText: 'table output', + metadataLines: ['', '0 catalogs'], }, - sink, + sink ); - expect(stdout).toEqual(["table output"]); - expect(stderr).toEqual(["", "0 catalogs"]); + expect(stdout).toEqual(['table output']); + expect(stderr).toEqual(['', '0 catalogs']); }); - test("normalized can opt human output into pager handling", async () => { + test('normalized can opt human output into pager handling', async () => { const { stdout, sink } = captureOutput(false); await writeCommandOutput( { - kind: "normalized", + kind: 'normalized', data: { routes: [] }, - humanText: "wide route table", + humanText: 'wide route table', pageHumanText: true, }, - sink, + sink ); - expect(stdout).toEqual(["wide route table"]); + expect(stdout).toEqual(['wide route table']); }); - test("ack emits json data in json mode", async () => { + test('ack emits json data in json mode', async () => { const { stdout, sink } = captureOutput(true); await writeCommandOutput( { - kind: "ack", - data: { active_profile: "staging" }, - metadataMessage: "Active profile set to staging.", + kind: 'ack', + data: { active_profile: 'staging' }, + metadataMessage: 'Active profile set to staging.', }, - sink, + sink ); - expect(stdout).toEqual([JSON.stringify({ active_profile: "staging" }, null, 2)]); + expect(stdout).toEqual([ + JSON.stringify({ active_profile: 'staging' }, null, 2), + ]); }); - test("ack writes metadata in human mode", async () => { + test('ack writes metadata in human mode', async () => { const previousNoColor = process.env.NO_COLOR; - process.env.NO_COLOR = "1"; + process.env.NO_COLOR = '1'; const { stderr, sink } = captureOutput(false); await writeCommandOutput( { - kind: "ack", - data: { active_profile: "staging" }, - metadataMessage: "Active profile set to staging.", + kind: 'ack', + data: { active_profile: 'staging' }, + metadataMessage: 'Active profile set to staging.', }, - sink, + sink ); if (previousNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = previousNoColor; - expect(stderr).toEqual(["Active profile set to staging."]); + expect(stderr).toEqual(['Active profile set to staging.']); }); }); diff --git a/cli/src/lib/command-output.ts b/cli/src/lib/command-output.ts index 5c5ef4cd..5215dcbb 100644 --- a/cli/src/lib/command-output.ts +++ b/cli/src/lib/command-output.ts @@ -1,34 +1,41 @@ -import type { ManagementOutputFormat } from "@/lib/tabular-result.ts"; -import { renderManagementOutput } from "@/lib/management-output.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import { resolvePagerOptions, writePagedOutput } from "@/lib/pager.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import type { ManagementOutputFormat } from '@/lib/tabular-result.ts'; +import { renderManagementOutput } from '@/lib/management-output.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; +import { resolvePagerOptions, writePagedOutput } from '@/lib/pager.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export type CommandOutputMode = - | { kind: "raw_api"; body: string; humanFormatter?: (data: unknown) => string } | { - kind: "normalized"; + kind: 'raw_api'; + body: string; + humanFormatter?: (data: unknown) => string; + } + | { + kind: 'normalized'; data: unknown; humanText: string; metadataLines?: string[]; pageHumanText?: boolean; } - | { kind: "human"; text: string } + | { kind: 'human'; text: string } | { - kind: "tabular"; + kind: 'tabular'; body: string; format?: ManagementOutputFormat; humanFormatter?: (data: unknown) => string; } - | { kind: "deleted"; message: string; id?: string } - | { kind: "ack"; data: Record; metadataMessage: string }; + | { kind: 'deleted'; message: string; id?: string } + | { kind: 'ack'; data: Record; metadataMessage: string }; -export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSink): Promise { +export async function writeCommandOutput( + mode: CommandOutputMode, + sink: OutputSink +): Promise { const json = sink.json; - if (mode.kind === "deleted") { + if (mode.kind === 'deleted') { if (json) { sink.writeJson({ deleted: true, @@ -40,7 +47,7 @@ export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSi return; } - if (mode.kind === "human") { + if (mode.kind === 'human') { if (json) { sink.writeJson({ text: mode.text }); return; @@ -49,7 +56,7 @@ export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSi return; } - if (mode.kind === "normalized") { + if (mode.kind === 'normalized') { if (json) { sink.writeJson(mode.data); return; @@ -65,16 +72,18 @@ export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSi return; } - if (mode.kind === "ack") { + if (mode.kind === 'ack') { if (json) { sink.writeJson(mode.data); return; } - sink.writeMetadata([renderDisplayText([span(mode.metadataMessage, "subtle")])]); + sink.writeMetadata([ + renderDisplayText([span(mode.metadataMessage, 'subtle')]), + ]); return; } - if (mode.kind === "raw_api") { + if (mode.kind === 'raw_api') { if (json) { sink.writeRaw(mode.body); return; @@ -87,7 +96,7 @@ export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSi return; } - if (mode.kind === "tabular") { + if (mode.kind === 'tabular') { if (json) { sink.writeRaw(mode.body); return; @@ -96,9 +105,9 @@ export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSi sink.writeHuman(mode.humanFormatter(parseApiJson(mode.body))); return; } - const format = mode.format ?? "table"; + const format = mode.format ?? 'table'; const output = renderManagementOutput(mode.body, format); - if (format === "table") { + if (format === 'table') { await writePagedOutput(output, resolvePagerOptions(), sink); return; } diff --git a/cli/src/lib/command-parser.ts b/cli/src/lib/command-parser.ts index 7fbb74e0..f302fa1d 100644 --- a/cli/src/lib/command-parser.ts +++ b/cli/src/lib/command-parser.ts @@ -6,11 +6,14 @@ import { type CommandArguments, type CommandMetadata, type CommandRunResult, -} from "@/lib/command.ts"; -import { aliasFlagToken } from "@/lib/command-descriptor.ts"; -import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; -import { EXIT_SUCCESS } from "@/lib/errors.ts"; -import { getCliRuntime } from "@/lib/runtime.ts"; +} from '@/lib/command.ts'; +import { aliasFlagToken } from '@/lib/command-descriptor.ts'; +import { + createExecutionContext, + type ExecutionContext, +} from '@/lib/execution-context.ts'; +import { EXIT_SUCCESS } from '@/lib/errors.ts'; +import { getCliRuntime } from '@/lib/runtime.ts'; type ParsedArgumentValue = string | boolean | string[] | undefined; type ParsedArguments = Record; @@ -39,7 +42,7 @@ type SelectedInvocation = { commandTokenIndexes: ReadonlySet; }; -type SelectionValidation = "strict" | "lenient"; +type SelectionValidation = 'strict' | 'lenient'; export type CommandSelection = { command: Command; @@ -52,9 +55,9 @@ export type CommandExecutionResult = CommandSelection & CommandRunResult; export class CommandParseError extends Error { code: string; - constructor(message: string, code = "E_PARSE") { + constructor(message: string, code = 'E_PARSE') { super(message); - this.name = "CLIError"; + this.name = 'CLIError'; this.code = code; } } @@ -73,10 +76,10 @@ async function resolveCommand(command: Command): Promise { Object.entries(subcommands).map(async ([name, resolvableCommand]) => { const child = await resolveCommandValue(resolvableCommand); const metadata = await resolveCommandValue( - child.metadata ?? undefined, + child.metadata ?? undefined ); return { name, child, aliases: stringList(metadata?.alias) }; - }), + }) ); const subcommandsByName = new Map(); for (const { name, child, aliases } of entries) { @@ -84,13 +87,19 @@ async function resolveCommand(command: Command): Promise { subcommandsByName.set(name, subcommand); for (const alias of aliases) subcommandsByName.set(alias, subcommand); } - return { definition: command, arguments: commandArguments, subcommandsByName }; + return { + definition: command, + arguments: commandArguments, + subcommandsByName, + }; } -function optionBindings(commandArguments: CommandArguments): Map { +function optionBindings( + commandArguments: CommandArguments +): Map { const bindings = new Map(); for (const [name, argument] of Object.entries(commandArguments)) { - if (argument.type === "positional") continue; + if (argument.type === 'positional') continue; const binding = { name, argument }; bindings.set(`--${name}`, binding); for (const alias of stringList(argument.alias)) { @@ -102,40 +111,44 @@ function optionBindings(commandArguments: CommandArguments): Map { - return new Map([...optionBindings(rootArguments), ...optionBindings(commandArguments)]); + return new Map([ + ...optionBindings(rootArguments), + ...optionBindings(commandArguments), + ]); } function optionName(token: string): string { - const equalsIndex = token.indexOf("="); + const equalsIndex = token.indexOf('='); return equalsIndex === -1 ? token : token.slice(0, equalsIndex); } function inlineOptionValue(token: string): string | undefined { - const equalsIndex = token.indexOf("="); + const equalsIndex = token.indexOf('='); return equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1); } function takesValue(argument: CommandArgument): boolean { - return argument.type === "string" || argument.type === "enum"; + return argument.type === 'string' || argument.type === 'enum'; } function nextOptionValue( tokens: readonly string[], optionIndex: number, - options: ReadonlyMap, + options: ReadonlyMap ): string | undefined { const candidate = tokens[optionIndex + 1]; - if (candidate === undefined || candidate === "--") return undefined; - if (candidate.startsWith("-") && options.has(optionName(candidate))) return undefined; + if (candidate === undefined || candidate === '--') return undefined; + if (candidate.startsWith('-') && options.has(optionName(candidate))) + return undefined; return candidate; } function inspectFollowingTokens( tokens: readonly string[], startIndex: number, - options: ReadonlyMap, + options: ReadonlyMap ): { hasOperand: boolean; helpRequested: boolean } { let afterSeparator = false; let hasOperand = false; @@ -144,13 +157,13 @@ function inspectFollowingTokens( for (let index = startIndex; index < tokens.length; index += 1) { const token = tokens[index]; if (token === undefined) continue; - if (!afterSeparator && token === "--") { + if (!afterSeparator && token === '--') { afterSeparator = true; continue; } - if (!afterSeparator && token.startsWith("-")) { + if (!afterSeparator && token.startsWith('-')) { const name = optionName(token); - if (name === "--help" || name === "-h") helpRequested = true; + if (name === '--help' || name === '-h') helpRequested = true; const binding = options.get(name); if ( binding && @@ -173,7 +186,7 @@ function prefersDirectInvocation( operand: string, tokens: readonly string[], operandIndex: number, - options: ReadonlyMap, + options: ReadonlyMap ): boolean { if (!command.definition.soleDirectOperands?.includes(operand)) return false; const following = inspectFollowingTokens(tokens, operandIndex + 1, options); @@ -183,7 +196,7 @@ function prefersDirectInvocation( async function selectInvocation( root: Command, tokens: readonly string[], - validation: SelectionValidation, + validation: SelectionValidation ): Promise { const resolvedRoot = await resolveCommand(root); let command = resolvedRoot; @@ -198,28 +211,34 @@ async function selectInvocation( const token = tokens[index]; if (token === undefined) continue; - if (!afterSeparator && token === "--") { + if (!afterSeparator && token === '--') { afterSeparator = true; hasDirectOperand = true; continue; } - if (!afterSeparator && token.startsWith("-")) { + if (!afterSeparator && token.startsWith('-')) { const name = optionName(token); const binding = options.get(name); if (!binding) { - if (validation === "strict") throw new CommandParseError(`Unknown option ${name}.`); + if (validation === 'strict') + throw new CommandParseError(`Unknown option ${name}.`); continue; } - if (binding.argument.flagScope === "root-only" && tokens.length !== 1) { - if (validation === "strict") { - throw new CommandParseError(`${name} is only valid at the command root.`); + if (binding.argument.flagScope === 'root-only' && tokens.length !== 1) { + if (validation === 'strict') { + throw new CommandParseError( + `${name} is only valid at the command root.` + ); } continue; } - if (takesValue(binding.argument) && inlineOptionValue(token) === undefined) { + if ( + takesValue(binding.argument) && + inlineOptionValue(token) === undefined + ) { if (nextOptionValue(tokens, index, options) === undefined) { - if (validation === "strict") { + if (validation === 'strict') { throw new CommandParseError(`Missing value for ${name}.`); } continue; @@ -233,7 +252,8 @@ async function selectInvocation( const canSelectSubcommand = !afterSeparator && (command === resolvedRoot || - (!hasDirectOperand && !prefersDirectInvocation(command, token, tokens, index, options))); + (!hasDirectOperand && + !prefersDirectInvocation(command, token, tokens, index, options))); if (subcommand && canSelectSubcommand) { parent = command.definition; @@ -246,7 +266,8 @@ async function selectInvocation( } if (command === resolvedRoot) { - if (validation === "strict") throw new CommandParseError(`Unknown command ${token}.`); + if (validation === 'strict') + throw new CommandParseError(`Unknown command ${token}.`); continue; } hasDirectOperand = true; @@ -263,9 +284,9 @@ async function selectInvocation( export async function resolveCommandSelection( root: Command, - tokens: readonly string[], + tokens: readonly string[] ): Promise { - const invocation = await selectInvocation(root, tokens, "lenient"); + const invocation = await selectInvocation(root, tokens, 'lenient'); return { command: invocation.command.definition, ...(invocation.parent ? { parent: invocation.parent } : {}), @@ -277,33 +298,48 @@ function assignParsedValue( parsed: ParsedArguments, name: string, argument: CommandArgument, - value: string | boolean, + value: string | boolean ): void { if (argument.repeatable) { const previous = parsed[name]; - parsed[name] = [...(Array.isArray(previous) ? previous : []), String(value)]; + parsed[name] = [ + ...(Array.isArray(previous) ? previous : []), + String(value), + ]; return; } parsed[name] = value; } -function validateArgumentValue(name: string, argument: CommandArgument, value: string): void { +function validateArgumentValue( + name: string, + argument: CommandArgument, + value: string +): void { const values = commandArgumentValues(argument); - if (argument.type === "enum" && values.length > 0 && !values.includes(value)) { - throw new CommandParseError(`--${name} must be one of: ${values.join(", ")}.`); + if ( + argument.type === 'enum' && + values.length > 0 && + !values.includes(value) + ) { + throw new CommandParseError( + `--${name} must be one of: ${values.join(', ')}.` + ); } } function isParserRequired(argument: CommandArgument): boolean { return ( argument.default === undefined && - (argument.type === "positional" ? argument.required !== false : argument.required === true) + (argument.type === 'positional' + ? argument.required !== false + : argument.required === true) ); } function applyDefaultsAndValidate( parsed: ParsedArguments, - commandArguments: CommandArguments, + commandArguments: CommandArguments ): void { for (const [name, argument] of Object.entries(commandArguments)) { if (parsed[name] === undefined && argument.default !== undefined) { @@ -311,7 +347,7 @@ function applyDefaultsAndValidate( } const required = argument.directRequired ?? isParserRequired(argument); if (required && parsed[name] === undefined) { - const label = argument.type === "positional" ? name : `--${name}`; + const label = argument.type === 'positional' ? name : `--${name}`; throw new CommandParseError(`Missing required argument: ${label}.`); } } @@ -319,11 +355,14 @@ function applyDefaultsAndValidate( function parseInvocationArguments( invocation: SelectedInvocation, - tokens: readonly string[], + tokens: readonly string[] ): ParsedArguments { - const options = availableOptions(invocation.root.arguments, invocation.command.arguments); + const options = availableOptions( + invocation.root.arguments, + invocation.command.arguments + ); const positionals = Object.entries(invocation.command.arguments).filter( - ([, argument]) => argument.type === "positional", + ([, argument]) => argument.type === 'positional' ); const parsed: ParsedArguments = {}; let positionalIndex = 0; @@ -333,22 +372,25 @@ function parseInvocationArguments( if (invocation.commandTokenIndexes.has(index)) continue; const token = tokens[index]; if (token === undefined) continue; - if (!afterSeparator && token === "--") { + if (!afterSeparator && token === '--') { afterSeparator = true; continue; } - if (!afterSeparator && token.startsWith("-")) { + if (!afterSeparator && token.startsWith('-')) { const name = optionName(token); const binding = options.get(name); if (!binding) throw new CommandParseError(`Unknown option ${name}.`); - if (binding.argument.flagScope === "root-only" && tokens.length !== 1) { - throw new CommandParseError(`${name} is only valid at the command root.`); + if (binding.argument.flagScope === 'root-only' && tokens.length !== 1) { + throw new CommandParseError( + `${name} is only valid at the command root.` + ); } if (takesValue(binding.argument)) { const inlineValue = inlineOptionValue(token); const value = inlineValue ?? nextOptionValue(tokens, index, options); - if (value === undefined) throw new CommandParseError(`Missing value for ${name}.`); + if (value === undefined) + throw new CommandParseError(`Missing value for ${name}.`); validateArgumentValue(binding.name, binding.argument, value); assignParsedValue(parsed, binding.name, binding.argument, value); if (inlineValue === undefined) index += 1; @@ -362,7 +404,8 @@ function parseInvocationArguments( } const positional = positionals[positionalIndex]; - if (!positional) throw new CommandParseError(`Unexpected argument: ${token}.`); + if (!positional) + throw new CommandParseError(`Unexpected argument: ${token}.`); const [name, argument] = positional; validateArgumentValue(name, argument, token); assignParsedValue(parsed, name, argument, token); @@ -376,7 +419,11 @@ function parseInvocationArguments( return parsed; } -function createRunContext(command: Command, args: ParsedArguments, rawArgs: readonly string[]) { +function createRunContext( + command: Command, + args: ParsedArguments, + rawArgs: readonly string[] +) { const runtime = getCliRuntime(); let execution: ExecutionContext | undefined; return { @@ -394,9 +441,9 @@ function createRunContext(command: Command, args: ParsedArguments, rawArgs: read export async function executeCommand( root: Command, - rawArgs: readonly string[], + rawArgs: readonly string[] ): Promise { - const invocation = await selectInvocation(root, rawArgs, "strict"); + const invocation = await selectInvocation(root, rawArgs, 'strict'); const parsed = parseInvocationArguments(invocation, rawArgs); let runResult: void | CommandRunResult = undefined; if (root.run) { diff --git a/cli/src/lib/command.test.ts b/cli/src/lib/command.test.ts index 368db57a..637828c8 100644 --- a/cli/src/lib/command.test.ts +++ b/cli/src/lib/command.test.ts @@ -1,39 +1,47 @@ -import { describe, expect, test } from "bun:test"; -import { defineCommand } from "@/lib/command.ts"; -import { CommandParseError, executeCommand } from "@/lib/command-parser.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { describe, expect, test } from 'bun:test'; +import { defineCommand } from '@/lib/command.ts'; +import { CommandParseError, executeCommand } from '@/lib/command-parser.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -describe("command composition", () => { - test("defineCommand preserves the declarative command object", () => { - const definition = { metadata: { name: "leaf" } }; +describe('command composition', () => { + test('defineCommand preserves the declarative command object', () => { + const definition = { metadata: { name: 'leaf' } }; expect(defineCommand(definition)).toBe(definition); }); - test("dispatches a declarative command with injected runtime", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('dispatches a declarative command with injected runtime', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); let receivedRuntime: unknown; let executionIsStable = false; const leaf = defineCommand({ - metadata: { name: "leaf" }, + metadata: { name: 'leaf' }, run(context) { receivedRuntime = context.runtime; executionIsStable = context.execution === context.execution; }, }); - const definition = { metadata: { name: "root" }, subcommands: { leaf } }; + const definition = { metadata: { name: 'root' }, subcommands: { leaf } }; const root = defineCommand(definition); expect(root).toBe(definition); - await runWithCliRuntime(runtime, () => executeCommand(root, ["leaf"])); + await runWithCliRuntime(runtime, () => executeCommand(root, ['leaf'])); expect(receivedRuntime).toBe(runtime); expect(executionIsStable).toBe(true); }); test("returns a completed command's requested exit code", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const root = defineCommand({ subcommands: { check: defineCommand({ @@ -44,20 +52,31 @@ describe("command composition", () => { }, }); - const result = await runWithCliRuntime(runtime, () => executeCommand(root, ["check"])); + const result = await runWithCliRuntime(runtime, () => + executeCommand(root, ['check']) + ); expect(result.exitCode).toBe(1); }); - test("parses global and command flags in every position", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('parses global and command flags in every position', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const received: Array> = []; const leaf = defineCommand({ - metadata: { name: "leaf" }, + metadata: { name: 'leaf' }, args: { - mode: { type: "enum", alias: "m", options: ["fast", "safe"], default: "safe" }, - tag: { type: "string", alias: "t", repeatable: true }, - value: { type: "positional", required: true }, + mode: { + type: 'enum', + alias: 'm', + options: ['fast', 'safe'], + default: 'safe', + }, + tag: { type: 'string', alias: 't', repeatable: true }, + value: { type: 'positional', required: true }, }, run({ args }) { received.push(args); @@ -65,108 +84,124 @@ describe("command composition", () => { }); const root = defineCommand({ args: { - json: { type: "boolean", flagScope: "global" }, - profile: { type: "string", flagScope: "global" }, + json: { type: 'boolean', flagScope: 'global' }, + profile: { type: 'string', flagScope: 'global' }, }, subcommands: { leaf }, }); await runWithCliRuntime(runtime, () => executeCommand(root, [ - "--profile", - "staging", - "leaf", - "payload", - "-t", - "one", - "--json", - "--tag=two", - "-m=fast", - ]), + '--profile', + 'staging', + 'leaf', + 'payload', + '-t', + 'one', + '--json', + '--tag=two', + '-m=fast', + ]) ); expect(received).toEqual([ expect.objectContaining({ - profile: "staging", + profile: 'staging', json: true, - mode: "fast", - tag: ["one", "two"], - value: "payload", + mode: 'fast', + tag: ['one', 'two'], + value: 'payload', }), ]); }); - test("does not consume recognized flags as option values", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('does not consume recognized flags as option values', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const leaf = defineCommand({ - metadata: { name: "leaf" }, + metadata: { name: 'leaf' }, args: { - columns: { type: "string", alias: "c" }, - force: { type: "boolean" }, - value: { type: "positional", required: true }, + columns: { type: 'string', alias: 'c' }, + force: { type: 'boolean' }, + value: { type: 'positional', required: true }, }, }); const root = defineCommand({ args: { - json: { type: "boolean", flagScope: "global" }, - profile: { type: "string", flagScope: "global" }, + json: { type: 'boolean', flagScope: 'global' }, + profile: { type: 'string', flagScope: 'global' }, }, subcommands: { leaf }, }); for (const rawArgs of [ - ["leaf", "value", "--columns", "--json"], - ["leaf", "value", "-c", "--json"], - ["leaf", "--profile", "--force", "value"], + ['leaf', 'value', '--columns', '--json'], + ['leaf', 'value', '-c', '--json'], + ['leaf', '--profile', '--force', 'value'], ]) { - expect(runWithCliRuntime(runtime, () => executeCommand(root, rawArgs))).rejects.toThrow( - /^Missing value for/, - ); + expect( + runWithCliRuntime(runtime, () => executeCommand(root, rawArgs)) + ).rejects.toThrow(/^Missing value for/); } }); - test("binds single-character aliases as short flags and longer ones as long flags", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('binds single-character aliases as short flags and longer ones as long flags', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const received: string[] = []; const root = defineCommand({ subcommands: { leaf: defineCommand({ args: { - "service-account": { type: "string", alias: "svc" }, - columns: { type: "string", alias: "c" }, + 'service-account': { type: 'string', alias: 'svc' }, + columns: { type: 'string', alias: 'c' }, }, run({ args }) { - received.push(`${String(args["service-account"])}/${String(args.columns)}`); + received.push( + `${String(args['service-account'])}/${String(args.columns)}` + ); }, }), }, }); for (const rawArgs of [ - ["leaf", "--service-account", "bot", "--columns", "a"], - ["leaf", "--svc", "bot", "-c", "a"], - ["leaf", "--svc=bot", "-c=a"], + ['leaf', '--service-account', 'bot', '--columns', 'a'], + ['leaf', '--svc', 'bot', '-c', 'a'], + ['leaf', '--svc=bot', '-c=a'], ]) { await runWithCliRuntime(runtime, () => executeCommand(root, rawArgs)); } - expect(received).toEqual(["bot/a", "bot/a", "bot/a"]); + expect(received).toEqual(['bot/a', 'bot/a', 'bot/a']); // A long alias is not also bound as a single-dash flag. expect( - runWithCliRuntime(runtime, () => executeCommand(root, ["leaf", "-svc", "bot"])), - ).rejects.toThrow("Unknown option -svc"); + runWithCliRuntime(runtime, () => + executeCommand(root, ['leaf', '-svc', 'bot']) + ) + ).rejects.toThrow('Unknown option -svc'); }); - test("accepts an option-like value when it is explicit", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - let received = ""; + test('accepts an option-like value when it is explicit', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + let received = ''; const root = defineCommand({ - args: { json: { type: "boolean", flagScope: "global" } }, + args: { json: { type: 'boolean', flagScope: 'global' } }, subcommands: { leaf: defineCommand({ args: { - columns: { type: "string" }, - value: { type: "positional", required: true }, + columns: { type: 'string' }, + value: { type: 'positional', required: true }, }, run({ args }) { received = String(args.columns); @@ -176,25 +211,33 @@ describe("command composition", () => { }); await runWithCliRuntime(runtime, () => - executeCommand(root, ["leaf", "value", "--columns=--json"]), + executeCommand(root, ['leaf', 'value', '--columns=--json']) ); - expect(received).toBe("--json"); + expect(received).toBe('--json'); }); - test("resolves aliases, subcommands, and intentional direct operands", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('resolves aliases, subcommands, and intentional direct operands', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const calls: string[] = []; const parent = defineCommand({ - metadata: { name: "query", alias: "q" }, - soleDirectOperands: ["show"], + metadata: { name: 'query', alias: 'q' }, + soleDirectOperands: ['show'], args: { - statement: { type: "positional", required: false, directRequired: true }, + statement: { + type: 'positional', + required: false, + directRequired: true, + }, }, subcommands: { show: defineCommand({ - metadata: { name: "show" }, - args: { id: { type: "positional", required: true } }, + metadata: { name: 'show' }, + args: { id: { type: 'positional', required: true } }, run({ args }) { calls.push(`subcommand:${String(args.id)}`); }, @@ -207,24 +250,28 @@ describe("command composition", () => { const root = defineCommand({ subcommands: { query: parent } }); await runWithCliRuntime(runtime, async () => { - await executeCommand(root, ["q", "show"]); - await executeCommand(root, ["query", "show", "query-1"]); + await executeCommand(root, ['q', 'show']); + await executeCommand(root, ['query', 'show', 'query-1']); }); - expect(calls).toEqual(["direct:show", "subcommand:query-1"]); + expect(calls).toEqual(['direct:show', 'subcommand:query-1']); }); - test("honors the option separator and validates the invocation contract", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - let received = ""; + test('honors the option separator and validates the invocation contract', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + let received = ''; const root = defineCommand({ args: { - version: { type: "boolean", alias: "v", flagScope: "root-only" }, + version: { type: 'boolean', alias: 'v', flagScope: 'root-only' }, }, subcommands: { leaf: defineCommand({ - metadata: { name: "leaf" }, - args: { value: { type: "positional", required: true } }, + metadata: { name: 'leaf' }, + args: { value: { type: 'positional', required: true } }, run({ args }) { received = String(args.value); }, @@ -232,17 +279,23 @@ describe("command composition", () => { }, }); - await runWithCliRuntime(runtime, () => executeCommand(root, ["leaf", "--", "--literal"])); - expect(received).toBe("--literal"); - - expect(runWithCliRuntime(runtime, () => executeCommand(root, ["--", "leaf"]))).rejects.toThrow( - "Unknown command leaf.", + await runWithCliRuntime(runtime, () => + executeCommand(root, ['leaf', '--', '--literal']) ); + expect(received).toBe('--literal'); + + expect( + runWithCliRuntime(runtime, () => executeCommand(root, ['--', 'leaf'])) + ).rejects.toThrow('Unknown command leaf.'); expect( - runWithCliRuntime(runtime, () => executeCommand(root, ["leaf", "value", "--version"])), + runWithCliRuntime(runtime, () => + executeCommand(root, ['leaf', 'value', '--version']) + ) ).rejects.toBeInstanceOf(CommandParseError); expect( - runWithCliRuntime(runtime, () => executeCommand(root, ["leaf", "value", "extra"])), - ).rejects.toThrow("Unexpected argument: extra."); + runWithCliRuntime(runtime, () => + executeCommand(root, ['leaf', 'value', 'extra']) + ) + ).rejects.toThrow('Unexpected argument: extra.'); }); }); diff --git a/cli/src/lib/command.ts b/cli/src/lib/command.ts index 43ae644d..02f09d60 100644 --- a/cli/src/lib/command.ts +++ b/cli/src/lib/command.ts @@ -1,13 +1,23 @@ -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import type { CliRuntime, OutputSink } from "@/lib/runtime.ts"; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import type { CliRuntime, OutputSink } from '@/lib/runtime.ts'; export type Resolvable = T | Promise | (() => T | Promise); -export const POSITIONAL_COMPLETION_KINDS = ["finite", "file", "freeform"] as const; -export type PositionalCompletionKind = (typeof POSITIONAL_COMPLETION_KINDS)[number]; -export const COMMAND_FLAG_SCOPES = ["root-only", "global", "command"] as const; +export const POSITIONAL_COMPLETION_KINDS = [ + 'finite', + 'file', + 'freeform', +] as const; +export type PositionalCompletionKind = + (typeof POSITIONAL_COMPLETION_KINDS)[number]; +export const COMMAND_FLAG_SCOPES = ['root-only', 'global', 'command'] as const; export type CommandFlagScope = (typeof COMMAND_FLAG_SCOPES)[number]; -export type CommandInvocationKind = "direct" | "subcommand"; -export const COMMAND_ARGUMENT_TYPES = ["boolean", "string", "enum", "positional"] as const; +export type CommandInvocationKind = 'direct' | 'subcommand'; +export const COMMAND_ARGUMENT_TYPES = [ + 'boolean', + 'string', + 'enum', + 'positional', +] as const; export type CommandArgumentType = (typeof COMMAND_ARGUMENT_TYPES)[number]; export type CommandArgument = { @@ -18,7 +28,7 @@ export type CommandArgument = { default?: unknown; options?: readonly string[]; values?: readonly string[]; - completion?: Exclude; + completion?: Exclude; directRequired?: boolean; flagScope?: CommandFlagScope; valueHint?: string; @@ -27,11 +37,12 @@ export type CommandArgument = { export type CommandArguments = Record; export const ALTERTABLE_COMMAND_GROUPS = [ - { id: "platform", title: "Platform" }, - { id: "ingest", title: "Ingest" }, - { id: "query", title: "Query" }, + { id: 'platform', title: 'Platform' }, + { id: 'ingest', title: 'Ingest' }, + { id: 'query', title: 'Query' }, ] as const; -export type AltertableCommandGroup = (typeof ALTERTABLE_COMMAND_GROUPS)[number]["id"]; +export type AltertableCommandGroup = + (typeof ALTERTABLE_COMMAND_GROUPS)[number]['id']; export type CommandMetadata = { name?: string; @@ -44,8 +55,12 @@ export type CommandMetadata = { }; type ParsedArgumentValue = string | boolean | string[] | undefined; -export type ParsedCommandArguments = { - [K in keyof T]: T[K] extends { type: "boolean" } ? boolean | undefined : ParsedArgumentValue; +export type ParsedCommandArguments< + T extends CommandArguments = CommandArguments, +> = { + [K in keyof T]: T[K] extends { type: 'boolean' } + ? boolean | undefined + : ParsedArgumentValue; } & Record; export type CommandRunContext = { @@ -72,30 +87,34 @@ export type CommandDefinition = { */ soleDirectOperands?: readonly string[]; run?: ( - context: CommandRunContext, + context: CommandRunContext ) => void | CommandRunResult | Promise; }; export type Command = CommandDefinition; -export function defineArguments(commandArguments: T): T { +export function defineArguments( + commandArguments: T +): T { return commandArguments; } -export function defineCommand( - definition: CommandDefinition, -): CommandDefinition { +export function defineCommand< + const T extends CommandArguments = CommandArguments, +>(definition: CommandDefinition): CommandDefinition { return definition; } -export function commandArgumentValues(argument: CommandArgument): readonly string[] { +export function commandArgumentValues( + argument: CommandArgument +): readonly string[] { if (argument.values) return argument.values; - if (argument.type === "enum" && argument.options) return argument.options; + if (argument.type === 'enum' && argument.options) return argument.options; return []; } export async function resolveCommandValue(value: Resolvable): Promise { - if (typeof value === "function") { + if (typeof value === 'function') { return await (value as () => T | Promise)(); } return await value; diff --git a/cli/src/lib/compiled-executable-path.test.ts b/cli/src/lib/compiled-executable-path.test.ts index 06f73f08..e1b3480b 100644 --- a/cli/src/lib/compiled-executable-path.test.ts +++ b/cli/src/lib/compiled-executable-path.test.ts @@ -1,53 +1,65 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; -const cliRoot = resolve(import.meta.dir, "../.."); -const fixture = join(import.meta.dir, "fixtures", "compiled-executable-path.ts"); -let testDirectory = ""; -let executable = ""; -let invocationDirectory = ""; +const cliRoot = resolve(import.meta.dir, '../..'); +const fixture = join( + import.meta.dir, + 'fixtures', + 'compiled-executable-path.ts' +); +let testDirectory = ''; +let executable = ''; +let invocationDirectory = ''; beforeAll(() => { - testDirectory = mkdtempSync(join(tmpdir(), "altertable-compiled-path-test-")); - const releaseDirectory = join(testDirectory, "release"); - const binDirectory = join(testDirectory, "bin"); - invocationDirectory = join(testDirectory, "work"); - executable = join(releaseDirectory, "altertable"); + testDirectory = mkdtempSync(join(tmpdir(), 'altertable-compiled-path-test-')); + const releaseDirectory = join(testDirectory, 'release'); + const binDirectory = join(testDirectory, 'bin'); + invocationDirectory = join(testDirectory, 'work'); + executable = join(releaseDirectory, 'altertable'); mkdirSync(releaseDirectory, { recursive: true }); mkdirSync(binDirectory, { recursive: true }); mkdirSync(invocationDirectory, { recursive: true }); const build = Bun.spawnSync( - [process.execPath, "build", "--compile", fixture, "--outfile", executable], - { cwd: cliRoot, stdout: "pipe", stderr: "pipe" }, + [process.execPath, 'build', '--compile', fixture, '--outfile', executable], + { cwd: cliRoot, stdout: 'pipe', stderr: 'pipe' } ); if (build.exitCode !== 0) { throw new Error(new TextDecoder().decode(build.stderr)); } - symlinkSync(executable, join(binDirectory, "altertable")); + symlinkSync(executable, join(binDirectory, 'altertable')); }); afterAll(() => { rmSync(testDirectory, { recursive: true, force: true }); }); -describe("compiled executable path", () => { - test("resolves the canonical release binary when invoked through PATH and a symlink", () => { - const result = Bun.spawnSync(["altertable"], { +describe('compiled executable path', () => { + test('resolves the canonical release binary when invoked through PATH and a symlink', () => { + const result = Bun.spawnSync(['altertable'], { cwd: invocationDirectory, env: { ...process.env, - PATH: `${join(testDirectory, "bin")}:${process.env.PATH ?? ""}`, + PATH: `${join(testDirectory, 'bin')}:${process.env.PATH ?? ''}`, }, - stdout: "pipe", - stderr: "pipe", + stdout: 'pipe', + stderr: 'pipe', }); expect(result.exitCode).toBe(0); - expect(new TextDecoder().decode(result.stderr)).toBe(""); - expect(new TextDecoder().decode(result.stdout).trim()).toBe(realpathSync(executable)); + expect(new TextDecoder().decode(result.stderr)).toBe(''); + expect(new TextDecoder().decode(result.stdout).trim()).toBe( + realpathSync(executable) + ); expect(dirname(realpathSync(executable))).not.toBe(invocationDirectory); }); }); diff --git a/cli/src/lib/config-files.ts b/cli/src/lib/config-files.ts index 95e92536..217a2831 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -1,45 +1,67 @@ -import { randomBytes } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { configDir } from "@/lib/user-directories.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; +import { randomBytes } from 'node:crypto'; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { configDir } from '@/lib/user-directories.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; -export { configDir } from "@/lib/user-directories.ts"; +export { configDir } from '@/lib/user-directories.ts'; function tempSiblingPath(filePath: string): string { - return join(dirname(filePath), `.altertable-kv-${randomBytes(8).toString("hex")}`); + return join( + dirname(filePath), + `.altertable-kv-${randomBytes(8).toString('hex')}` + ); } function isFileNotFound(error: unknown): boolean { return ( - typeof error === "object" && + typeof error === 'object' && error !== null && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' ); } -function configFileError(action: string, filePath: string, cause: unknown): ConfigurationError { - return new ConfigurationError(`Unable to ${action} configuration file: ${filePath}`, { cause }); +function configFileError( + action: string, + filePath: string, + cause: unknown +): ConfigurationError { + return new ConfigurationError( + `Unable to ${action} configuration file: ${filePath}`, + { cause } + ); } function configKey(line: string): string { - const separatorIndex = line.indexOf("="); - return line.slice(0, separatorIndex === -1 ? undefined : separatorIndex).trim(); + const separatorIndex = line.indexOf('='); + return line + .slice(0, separatorIndex === -1 ? undefined : separatorIndex) + .trim(); } function readConfigFileIfPresent(filePath: string): string | undefined { try { - return readFileSync(filePath, "utf8"); + return readFileSync(filePath, 'utf8'); } catch (error) { if (isFileNotFound(error)) { return undefined; } - throw configFileError("read", filePath, error); + throw configFileError('read', filePath, error); } } -function writeConfigFileAtomically(filePath: string, content: string, action: string): void { +function writeConfigFileAtomically( + filePath: string, + content: string, + action: string +): void { const temporaryPath = tempSiblingPath(filePath); let failure: unknown; try { @@ -59,23 +81,23 @@ function writeConfigFileAtomically(filePath: string, content: string, action: st } export function configFile(): string { - return join(configDir(), "config"); + return join(configDir(), 'config'); } export function credentialsFile(): string { - return join(configDir(), "credentials"); + return join(configDir(), 'credentials'); } export function kvGet(filePath: string, key: string): string { const content = readConfigFileIfPresent(filePath); if (content === undefined) { - return ""; + return ''; } - for (const line of content.split("\n")) { - if (line === "" || line.startsWith("#")) { + for (const line of content.split('\n')) { + if (line === '' || line.startsWith('#')) { continue; } - const eqIndex = line.indexOf("="); + const eqIndex = line.indexOf('='); if (eqIndex === -1) { continue; } @@ -84,21 +106,21 @@ export function kvGet(filePath: string, key: string): string { return line.slice(eqIndex + 1).trim(); } } - return ""; + return ''; } export function kvSet(filePath: string, key: string, value: string): void { try { mkdirSync(dirname(filePath), { recursive: true }); } catch (error) { - throw configFileError("write", filePath, error); + throw configFileError('write', filePath, error); } - const lines = readConfigFileIfPresent(filePath)?.split("\n") ?? []; + const lines = readConfigFileIfPresent(filePath)?.split('\n') ?? []; let found = false; const output = lines - .filter((line) => line !== "" || lines.length > 1) - .map((line) => { + .filter(line => line !== '' || lines.length > 1) + .map(line => { if (configKey(line) !== key) { return line; } @@ -109,10 +131,10 @@ export function kvSet(filePath: string, key: string, value: string): void { output.push(`${key}=${value}`); } - while (output.at(-1) === "") { + while (output.at(-1) === '') { output.pop(); } - writeConfigFileAtomically(filePath, `${output.join("\n")}\n`, "write"); + writeConfigFileAtomically(filePath, `${output.join('\n')}\n`, 'write'); } export function kvUnset(filePath: string, key: string): void { @@ -122,8 +144,8 @@ export function kvUnset(filePath: string, key: string): void { } const output = content - .split("\n") - .filter((line) => configKey(line) !== key) - .join("\n"); - writeConfigFileAtomically(filePath, output, "update"); + .split('\n') + .filter(line => configKey(line) !== key) + .join('\n'); + writeConfigFileAtomically(filePath, output, 'update'); } diff --git a/cli/src/lib/config.test.ts b/cli/src/lib/config.test.ts index 6a1e163f..926313bf 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -1,7 +1,14 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; import { configDir, configFile, @@ -15,15 +22,15 @@ import { kvUnset, resolveApiBase, resolveManagementApiBase, -} from "@/lib/config.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { profileConfigFile } from "@/lib/profile-store.ts"; +} from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { profileConfigFile } from '@/lib/profile-store.ts'; -let testHome = ""; -const profileName = "default"; +let testHome = ''; +const profileName = 'default'; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-config-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-config-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; }); @@ -35,85 +42,91 @@ afterEach(() => { delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; }); -describe("config", () => { - test("stores key-value config in protected files", () => { - const filePath = join(testHome, "sample"); +describe('config', () => { + test('stores key-value config in protected files', () => { + const filePath = join(testHome, 'sample'); - kvSet(filePath, "user", "alice"); - kvSet(filePath, "user", "bob"); + kvSet(filePath, 'user', 'alice'); + kvSet(filePath, 'user', 'bob'); - expect(kvGet(filePath, "user")).toBe("bob"); + expect(kvGet(filePath, 'user')).toBe('bob'); expect(statSync(filePath).mode & 0o777).toBe(0o600); - expect(configGet("missing", profileName)).toBe(""); + expect(configGet('missing', profileName)).toBe(''); }); - test("reports configuration I/O failures instead of treating them as missing files", () => { - const directoryPath = join(testHome, "not-a-file"); + test('reports configuration I/O failures instead of treating them as missing files', () => { + const directoryPath = join(testHome, 'not-a-file'); mkdirSync(directoryPath); - expect(() => kvGet(directoryPath, "user")).toThrow(ConfigurationError); - expect(() => kvSet(directoryPath, "user", "alice")).toThrow(ConfigurationError); - expect(() => kvUnset(directoryPath, "user")).toThrow(ConfigurationError); + expect(() => kvGet(directoryPath, 'user')).toThrow(ConfigurationError); + expect(() => kvSet(directoryPath, 'user', 'alice')).toThrow( + ConfigurationError + ); + expect(() => kvUnset(directoryPath, 'user')).toThrow(ConfigurationError); }); - test("resolves API bases from defaults, stored config, and environment overrides", () => { - expect(resolveApiBase(profileName)).toBe("https://api.altertable.ai"); - expect(resolveManagementApiBase(profileName)).toBe("https://app.altertable.ai/rest/v1"); - - configSet("api_base", "http://localhost:1111/", profileName); - configSet("management_api_base", "http://localhost:13000/", profileName); - expect(resolveApiBase(profileName)).toBe("http://localhost:1111"); - expect(resolveManagementApiBase(profileName)).toBe("http://localhost:13000/rest/v1"); - - process.env.ALTERTABLE_API_BASE = "http://localhost:2222"; - expect(resolveApiBase(profileName)).toBe("http://localhost:2222"); + test('resolves API bases from defaults, stored config, and environment overrides', () => { + expect(resolveApiBase(profileName)).toBe('https://api.altertable.ai'); + expect(resolveManagementApiBase(profileName)).toBe( + 'https://app.altertable.ai/rest/v1' + ); + + configSet('api_base', 'http://localhost:1111/', profileName); + configSet('management_api_base', 'http://localhost:13000/', profileName); + expect(resolveApiBase(profileName)).toBe('http://localhost:1111'); + expect(resolveManagementApiBase(profileName)).toBe( + 'http://localhost:13000/rest/v1' + ); + + process.env.ALTERTABLE_API_BASE = 'http://localhost:2222'; + expect(resolveApiBase(profileName)).toBe('http://localhost:2222'); }); - test("requires an opt-in for insecure non-local API bases", () => { - process.env.ALTERTABLE_API_BASE = "http://192.168.1.5"; - expect(() => resolveApiBase(profileName)).toThrow("Insecure HTTP URL"); + test('requires an opt-in for insecure non-local API bases', () => { + process.env.ALTERTABLE_API_BASE = 'http://192.168.1.5'; + expect(() => resolveApiBase(profileName)).toThrow('Insecure HTTP URL'); - process.env.ALTERTABLE_ALLOW_INSECURE_HTTP = "true"; - expect(resolveApiBase(profileName)).toBe("http://192.168.1.5"); + process.env.ALTERTABLE_ALLOW_INSECURE_HTTP = 'true'; + expect(resolveApiBase(profileName)).toBe('http://192.168.1.5'); }); - test("reads valid query display defaults and ignores invalid values", () => { - kvSet(configFile(), "query_max_width", "48"); - kvSet(configFile(), "query_layout", "line"); - kvSet(configFile(), "query_pager", "always"); + test('reads valid query display defaults and ignores invalid values', () => { + kvSet(configFile(), 'query_max_width', '48'); + kvSet(configFile(), 'query_layout', 'line'); + kvSet(configFile(), 'query_pager', 'always'); expect(getQueryDefaultMaxColumnWidth()).toBe(48); - expect(getQueryDefaultLayout()).toBe("line"); - expect(getQueryDefaultPager()).toBe("always"); + expect(getQueryDefaultLayout()).toBe('line'); + expect(getQueryDefaultPager()).toBe('always'); - kvSet(configFile(), "query_max_width", "4"); - kvSet(configFile(), "query_layout", "invalid"); - kvSet(configFile(), "query_pager", "sometimes"); + kvSet(configFile(), 'query_max_width', '4'); + kvSet(configFile(), 'query_layout', 'invalid'); + kvSet(configFile(), 'query_pager', 'sometimes'); expect(getQueryDefaultMaxColumnWidth()).toBeUndefined(); expect(getQueryDefaultLayout()).toBeUndefined(); expect(getQueryDefaultPager()).toBeUndefined(); }); - test("keeps global query defaults outside profile config", () => { - kvSet(configFile(), "query_layout", "line"); - configSet("api_key_env", "staging", "staging"); + test('keeps global query defaults outside profile config', () => { + kvSet(configFile(), 'query_layout', 'line'); + configSet('api_key_env', 'staging', 'staging'); - expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); - expect(kvGet(profileConfigFile("staging"), "query_layout")).toBe(""); + expect(kvGet(join(testHome, 'config'), 'query_layout')).toBe('line'); + expect(kvGet(profileConfigFile('staging'), 'query_layout')).toBe(''); }); - test("uses ALTERTABLE_CONFIG_HOME for profile config", () => { + test('uses ALTERTABLE_CONFIG_HOME for profile config', () => { expect(configDir()).toBe(testHome); - configSet("user", "x", profileName); + configSet('user', 'x', profileName); - const configFile = join(testHome, "profiles", "default", "config"); + const configFile = join(testHome, 'profiles', 'default', 'config'); expect(existsSync(configFile)).toBe(true); - expect(readFileSync(configFile, "utf8")).toContain("user=x"); + expect(readFileSync(configFile, 'utf8')).toContain('user=x'); }); }); for (const { name, value } of [ - { name: "unset", value: undefined }, - { name: "empty", value: "" }, + { name: 'unset', value: undefined }, + { name: 'empty', value: '' }, ]) { test(`uses the OS home directory when HOME is ${name}`, () => { const previous = { @@ -126,7 +139,7 @@ for (const { name, value } of [ delete process.env.XDG_CONFIG_HOME; if (value === undefined) delete process.env.HOME; else process.env.HOME = value; - expect(configDir()).toBe(join(homedir(), ".config", "altertable")); + expect(configDir()).toBe(join(homedir(), '.config', 'altertable')); } finally { for (const [name, value] of Object.entries(previous)) { if (value === undefined) delete process.env[name]; diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index ec52eda9..37a986de 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,4 +1,4 @@ -import { configGet, configSet } from "@/lib/profile-store.ts"; +import { configGet, configSet } from '@/lib/profile-store.ts'; import { configDir, configFile, @@ -6,10 +6,10 @@ import { kvGet, kvSet, kvUnset, -} from "@/lib/config-files.ts"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; -import { readEnv } from "@/lib/env.ts"; -import { isQueryLayout, type QueryLayout } from "@/ui/layouts/query.ts"; +} from '@/lib/config-files.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; +import { readEnv } from '@/lib/env.ts'; +import { isQueryLayout, type QueryLayout } from '@/ui/layouts/query.ts'; export { configDir, configFile, credentialsFile, kvGet, kvSet, kvUnset }; export { configGet, configSet }; @@ -19,32 +19,36 @@ export function configGetGlobal(key: string): string { } function resolveAllowInsecureHttp(): boolean { - return readEnv("ALTERTABLE_ALLOW_INSECURE_HTTP") ?? false; + return readEnv('ALTERTABLE_ALLOW_INSECURE_HTTP') ?? false; } export function resolveApiBase(profileName: string): string { - let base = readEnv("ALTERTABLE_API_BASE") ?? ""; + let base = readEnv('ALTERTABLE_API_BASE') ?? ''; if (!base) { - base = configGet("api_base", profileName); + base = configGet('api_base', profileName); } if (!base) { - base = "https://api.altertable.ai"; + base = 'https://api.altertable.ai'; } - const normalized = base.replace(/\/$/, ""); - assertAllowedApiBase(normalized, { allowInsecureHttp: resolveAllowInsecureHttp() }); + const normalized = base.replace(/\/$/, ''); + assertAllowedApiBase(normalized, { + allowInsecureHttp: resolveAllowInsecureHttp(), + }); return normalized; } export function resolveManagementApiRoot(profileName: string): string { - let root = readEnv("ALTERTABLE_MANAGEMENT_API_BASE") ?? ""; + let root = readEnv('ALTERTABLE_MANAGEMENT_API_BASE') ?? ''; if (!root) { - root = configGet("management_api_base", profileName); + root = configGet('management_api_base', profileName); } if (!root) { - root = "https://app.altertable.ai"; + root = 'https://app.altertable.ai'; } - const normalized = root.replace(/\/$/, ""); - assertAllowedApiBase(normalized, { allowInsecureHttp: resolveAllowInsecureHttp() }); + const normalized = root.replace(/\/$/, ''); + assertAllowedApiBase(normalized, { + allowInsecureHttp: resolveAllowInsecureHttp(), + }); return normalized; } @@ -56,11 +60,11 @@ export function resolveOAuthBase(profileName: string): string { return `${resolveManagementApiRoot(profileName)}/oauth`; } -const QUERY_PAGER_VALUES = new Set(["auto", "always", "never"]); +const QUERY_PAGER_VALUES = new Set(['auto', 'always', 'never']); const MIN_QUERY_MAX_COL_WIDTH = 8; export function getQueryDefaultMaxColumnWidth(): number | undefined { - const value = configGetGlobal("query_max_width"); + const value = configGetGlobal('query_max_width'); if (value.length === 0) { return undefined; } @@ -72,7 +76,7 @@ export function getQueryDefaultMaxColumnWidth(): number | undefined { } export function getQueryDefaultLayout(): QueryLayout | undefined { - const value = configGetGlobal("query_layout"); + const value = configGetGlobal('query_layout'); if (value.length === 0) { return undefined; } @@ -82,13 +86,17 @@ export function getQueryDefaultLayout(): QueryLayout | undefined { return undefined; } -export function getQueryDefaultPager(): "auto" | "always" | "never" | undefined { - const value = configGetGlobal("query_pager"); +export function getQueryDefaultPager(): + | 'auto' + | 'always' + | 'never' + | undefined { + const value = configGetGlobal('query_pager'); if (value.length === 0) { return undefined; } if (QUERY_PAGER_VALUES.has(value)) { - return value as "auto" | "always" | "never"; + return value as 'auto' | 'always' | 'never'; } return undefined; } diff --git a/cli/src/lib/early-bootstrap.test.ts b/cli/src/lib/early-bootstrap.test.ts index ee3e8403..be1beb90 100644 --- a/cli/src/lib/early-bootstrap.test.ts +++ b/cli/src/lib/early-bootstrap.test.ts @@ -1,33 +1,38 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test'; import { EARLY_BOOTSTRAP_EXITS, HELP_FLAGS, VERSION_FLAGS, findEarlyBootstrapExit, -} from "@/lib/early-bootstrap.ts"; +} from '@/lib/early-bootstrap.ts'; -describe("early-bootstrap", () => { - test("help flags match empty argv and explicit help", () => { - expect(findEarlyBootstrapExit([])?.id).toBe("help"); - expect(findEarlyBootstrapExit(["--help"])?.id).toBe("help"); - expect(findEarlyBootstrapExit(["query", "-h"])?.id).toBe("help"); +describe('early-bootstrap', () => { + test('help flags match empty argv and explicit help', () => { + expect(findEarlyBootstrapExit([])?.id).toBe('help'); + expect(findEarlyBootstrapExit(['--help'])?.id).toBe('help'); + expect(findEarlyBootstrapExit(['query', '-h'])?.id).toBe('help'); }); - test("help flags are literal operands after the option separator", () => { - expect(findEarlyBootstrapExit(["query", "--", "--help"])).toBeUndefined(); - expect(findEarlyBootstrapExit(["query", "--", "-h"])).toBeUndefined(); - expect(findEarlyBootstrapExit(["query", "--help", "--", "SELECT 1"])?.id).toBe("help"); + test('help flags are literal operands after the option separator', () => { + expect(findEarlyBootstrapExit(['query', '--', '--help'])).toBeUndefined(); + expect(findEarlyBootstrapExit(['query', '--', '-h'])).toBeUndefined(); + expect( + findEarlyBootstrapExit(['query', '--help', '--', 'SELECT 1'])?.id + ).toBe('help'); }); - test("version flags match only when argv is a single version flag", () => { - expect(findEarlyBootstrapExit(["--version"])?.id).toBe("version"); - expect(findEarlyBootstrapExit(["-v"])?.id).toBe("version"); - expect(findEarlyBootstrapExit(["query", "--version"])).toBeUndefined(); + test('version flags match only when argv is a single version flag', () => { + expect(findEarlyBootstrapExit(['--version'])?.id).toBe('version'); + expect(findEarlyBootstrapExit(['-v'])?.id).toBe('version'); + expect(findEarlyBootstrapExit(['query', '--version'])).toBeUndefined(); }); - test("declares help and version flag aliases", () => { - expect(EARLY_BOOTSTRAP_EXITS.map((exit) => exit.id)).toEqual(["help", "version"]); - expect(HELP_FLAGS).toEqual(["--help", "-h"]); - expect(VERSION_FLAGS).toEqual(["--version", "-v"]); + test('declares help and version flag aliases', () => { + expect(EARLY_BOOTSTRAP_EXITS.map(exit => exit.id)).toEqual([ + 'help', + 'version', + ]); + expect(HELP_FLAGS).toEqual(['--help', '-h']); + expect(VERSION_FLAGS).toEqual(['--version', '-v']); }); }); diff --git a/cli/src/lib/early-bootstrap.ts b/cli/src/lib/early-bootstrap.ts index 7e52a603..0d45330b 100644 --- a/cli/src/lib/early-bootstrap.ts +++ b/cli/src/lib/early-bootstrap.ts @@ -1,7 +1,7 @@ -export const HELP_FLAGS = ["--help", "-h"] as const; -export const VERSION_FLAGS = ["--version", "-v"] as const; +export const HELP_FLAGS = ['--help', '-h'] as const; +export const VERSION_FLAGS = ['--version', '-v'] as const; -export type EarlyBootstrapExitId = "help" | "version"; +export type EarlyBootstrapExitId = 'help' | 'version'; export type EarlyBootstrapExit = { id: EarlyBootstrapExitId; @@ -9,31 +9,38 @@ export type EarlyBootstrapExit = { matches(rawArgs: readonly string[]): boolean; }; -function argvIncludesFlag(rawArgs: readonly string[], flags: readonly string[]): boolean { - const separatorIndex = rawArgs.indexOf("--"); - const optionArgs = separatorIndex === -1 ? rawArgs : rawArgs.slice(0, separatorIndex); - return optionArgs.some((arg) => flags.includes(arg)); +function argvIncludesFlag( + rawArgs: readonly string[], + flags: readonly string[] +): boolean { + const separatorIndex = rawArgs.indexOf('--'); + const optionArgs = + separatorIndex === -1 ? rawArgs : rawArgs.slice(0, separatorIndex); + return optionArgs.some(arg => flags.includes(arg)); } export const EARLY_BOOTSTRAP_EXITS: readonly EarlyBootstrapExit[] = [ { - id: "help", + id: 'help', flags: HELP_FLAGS, matches(rawArgs) { return rawArgs.length === 0 || argvIncludesFlag(rawArgs, HELP_FLAGS); }, }, { - id: "version", + id: 'version', flags: VERSION_FLAGS, matches(rawArgs) { return ( - rawArgs.length === 1 && VERSION_FLAGS.includes(rawArgs[0] as (typeof VERSION_FLAGS)[number]) + rawArgs.length === 1 && + VERSION_FLAGS.includes(rawArgs[0] as (typeof VERSION_FLAGS)[number]) ); }, }, ]; -export function findEarlyBootstrapExit(rawArgs: readonly string[]): EarlyBootstrapExit | undefined { - return EARLY_BOOTSTRAP_EXITS.find((exit) => exit.matches(rawArgs)); +export function findEarlyBootstrapExit( + rawArgs: readonly string[] +): EarlyBootstrapExit | undefined { + return EARLY_BOOTSTRAP_EXITS.find(exit => exit.matches(rawArgs)); } diff --git a/cli/src/lib/env.test.ts b/cli/src/lib/env.test.ts index 28afcd57..a7d5e569 100644 --- a/cli/src/lib/env.test.ts +++ b/cli/src/lib/env.test.ts @@ -1,44 +1,55 @@ -import { describe, expect, test } from "bun:test"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { assertSecretEnvMetadata, CONFIG_ENV_NAMES, isSecretEnv, readEnvFrom, validateEnvironment, -} from "@/lib/env.ts"; -import { ConfigurationError, EXIT_CONFIG } from "@/lib/errors.ts"; +} from '@/lib/env.ts'; +import { ConfigurationError, EXIT_CONFIG } from '@/lib/errors.ts'; -describe("environment schema", () => { - test("uses canonical environment names as typed keys", () => { - expect(CONFIG_ENV_NAMES).toContain("ALTERTABLE_API_KEY"); - expect(CONFIG_ENV_NAMES).toContain("ALTERTABLE_MANAGEMENT_API_BASE"); +describe('environment schema', () => { + test('uses canonical environment names as typed keys', () => { + expect(CONFIG_ENV_NAMES).toContain('ALTERTABLE_API_KEY'); + expect(CONFIG_ENV_NAMES).toContain('ALTERTABLE_MANAGEMENT_API_BASE'); expect(new Set(CONFIG_ENV_NAMES).size).toBe(CONFIG_ENV_NAMES.length); }); - test("returns undefined for absent and empty optional values", () => { - expect(readEnvFrom({}, "ALTERTABLE_API_KEY")).toBeUndefined(); - expect(readEnvFrom({ ALTERTABLE_API_KEY: "" }, "ALTERTABLE_API_KEY")).toBeUndefined(); + test('returns undefined for absent and empty optional values', () => { + expect(readEnvFrom({}, 'ALTERTABLE_API_KEY')).toBeUndefined(); + expect( + readEnvFrom({ ALTERTABLE_API_KEY: '' }, 'ALTERTABLE_API_KEY') + ).toBeUndefined(); }); - test("parses booleans without truthy-string ambiguity", () => { + test('parses booleans without truthy-string ambiguity', () => { expect( - readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "true" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"), + readEnvFrom( + { ALTERTABLE_ALLOW_INSECURE_HTTP: 'true' }, + 'ALTERTABLE_ALLOW_INSECURE_HTTP' + ) ).toBe(true); expect( - readEnvFrom({ ALTERTABLE_ALLOW_INSECURE_HTTP: "0" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"), + readEnvFrom( + { ALTERTABLE_ALLOW_INSECURE_HTTP: '0' }, + 'ALTERTABLE_ALLOW_INSECURE_HTTP' + ) ).toBe(false); }); - test("rejects invalid enum and boolean values as configuration errors", () => { + test('rejects invalid enum and boolean values as configuration errors', () => { for (const [source, key] of [ - [{ ALTERTABLE_UPDATE_INSTALLER: "nmp" }, "ALTERTABLE_UPDATE_INSTALLER"], - [{ ALTERTABLE_ALLOW_INSECURE_HTTP: "yes" }, "ALTERTABLE_ALLOW_INSECURE_HTTP"], + [{ ALTERTABLE_UPDATE_INSTALLER: 'nmp' }, 'ALTERTABLE_UPDATE_INSTALLER'], + [ + { ALTERTABLE_ALLOW_INSECURE_HTTP: 'yes' }, + 'ALTERTABLE_ALLOW_INSECURE_HTTP', + ], ] as const) { try { readEnvFrom(source, key); - throw new Error("expected environment validation to fail"); + throw new Error('expected environment validation to fail'); } catch (error) { expect(error).toBeInstanceOf(ConfigurationError); expect((error as ConfigurationError).exitCode).toBe(EXIT_CONFIG); @@ -47,107 +58,134 @@ describe("environment schema", () => { } }); - test("validates the OAuth redirect port exactly", () => { + test('validates the OAuth redirect port exactly', () => { expect( - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "0" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + readEnvFrom( + { ALTERTABLE_OAUTH_REDIRECT_PORT: '0' }, + 'ALTERTABLE_OAUTH_REDIRECT_PORT' + ) ).toBe(0); expect( - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65535" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + readEnvFrom( + { ALTERTABLE_OAUTH_REDIRECT_PORT: '65535' }, + 'ALTERTABLE_OAUTH_REDIRECT_PORT' + ) ).toBe(65_535); expect(() => - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "12px" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + readEnvFrom( + { ALTERTABLE_OAUTH_REDIRECT_PORT: '12px' }, + 'ALTERTABLE_OAUTH_REDIRECT_PORT' + ) ).toThrow(ConfigurationError); expect(() => - readEnvFrom({ ALTERTABLE_OAUTH_REDIRECT_PORT: "65536" }, "ALTERTABLE_OAUTH_REDIRECT_PORT"), + readEnvFrom( + { ALTERTABLE_OAUTH_REDIRECT_PORT: '65536' }, + 'ALTERTABLE_OAUTH_REDIRECT_PORT' + ) ).toThrow(ConfigurationError); }); - test("classifies credential values for safe diagnostics", () => { - expect(isSecretEnv("ALTERTABLE_API_KEY")).toBe(true); - expect(isSecretEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")).toBe(true); - expect(isSecretEnv("ALTERTABLE_LAKEHOUSE_USERNAME")).toBe(false); + test('classifies credential values for safe diagnostics', () => { + expect(isSecretEnv('ALTERTABLE_API_KEY')).toBe(true); + expect(isSecretEnv('ALTERTABLE_LAKEHOUSE_PASSWORD')).toBe(true); + expect(isSecretEnv('ALTERTABLE_LAKEHOUSE_USERNAME')).toBe(false); }); - test("rejects credential-looking names without secret metadata", () => { + test('rejects credential-looking names without secret metadata', () => { expect(() => assertSecretEnvMetadata({ ALTERTABLE_NEW_TOKEN: { secret: false }, ALTERTABLE_SAFE_VALUE: {}, - }), - ).toThrow("Secret environment variables must declare secret: true: ALTERTABLE_NEW_TOKEN"); + }) + ).toThrow( + 'Secret environment variables must declare secret: true: ALTERTABLE_NEW_TOKEN' + ); }); - test("ignores unknown Altertable variable names", () => { + test('ignores unknown Altertable variable names', () => { expect(() => validateEnvironment({ - ALTERTABLE_CATALOG: "analytics", - ALTERTABLE_SCHEMA: "reporting", - ALTERTABLE_TABLE: "events", - }), + ALTERTABLE_CATALOG: 'analytics', + ALTERTABLE_SCHEMA: 'reporting', + ALTERTABLE_TABLE: 'events', + }) ).not.toThrow(); }); - test("validates every configured Altertable value at startup", () => { - expect(() => validateEnvironment({ ALTERTABLE_UPDATE_SOURCE: "gitlab" })).toThrow( - ConfigurationError, - ); + test('validates every configured Altertable value at startup', () => { + expect(() => + validateEnvironment({ ALTERTABLE_UPDATE_SOURCE: 'gitlab' }) + ).toThrow(ConfigurationError); }); - test("rejects a lone lakehouse username or password", () => { - expect(() => validateEnvironment({ ALTERTABLE_LAKEHOUSE_USERNAME: "u" })).toThrow( - "ALTERTABLE_LAKEHOUSE_USERNAME is set but ALTERTABLE_LAKEHOUSE_PASSWORD is not; set both or neither.", + test('rejects a lone lakehouse username or password', () => { + expect(() => + validateEnvironment({ ALTERTABLE_LAKEHOUSE_USERNAME: 'u' }) + ).toThrow( + 'ALTERTABLE_LAKEHOUSE_USERNAME is set but ALTERTABLE_LAKEHOUSE_PASSWORD is not; set both or neither.' ); - expect(() => validateEnvironment({ ALTERTABLE_LAKEHOUSE_PASSWORD: "p" })).toThrow( - "ALTERTABLE_LAKEHOUSE_PASSWORD is set but ALTERTABLE_LAKEHOUSE_USERNAME is not; set both or neither.", + expect(() => + validateEnvironment({ ALTERTABLE_LAKEHOUSE_PASSWORD: 'p' }) + ).toThrow( + 'ALTERTABLE_LAKEHOUSE_PASSWORD is set but ALTERTABLE_LAKEHOUSE_USERNAME is not; set both or neither.' ); }); - test("rejects endpoint or environment variables without env credentials", () => { - expect(() => validateEnvironment({ ALTERTABLE_ENV: "production" })).toThrow( - "Incomplete environment configuration: ALTERTABLE_ENV is set without credentials", + test('rejects endpoint or environment variables without env credentials', () => { + expect(() => validateEnvironment({ ALTERTABLE_ENV: 'production' })).toThrow( + 'Incomplete environment configuration: ALTERTABLE_ENV is set without credentials' ); expect(() => validateEnvironment({ - ALTERTABLE_API_BASE: "https://api.example.com", - ALTERTABLE_ENV: "production", - }), - ).toThrow("ALTERTABLE_ENV, ALTERTABLE_API_BASE are set without credentials"); + ALTERTABLE_API_BASE: 'https://api.example.com', + ALTERTABLE_ENV: 'production', + }) + ).toThrow( + 'ALTERTABLE_ENV, ALTERTABLE_API_BASE are set without credentials' + ); expect(() => - validateEnvironment({ ALTERTABLE_MANAGEMENT_API_BASE: "https://app.example.com" }), + validateEnvironment({ + ALTERTABLE_MANAGEMENT_API_BASE: 'https://app.example.com', + }) ).toThrow(ConfigurationError); }); - test("accepts complete environment configurations", () => { + test('accepts complete environment configurations', () => { expect(() => validateEnvironment({})).not.toThrow(); expect(() => - validateEnvironment({ ALTERTABLE_API_KEY: "atm_x", ALTERTABLE_ENV: "production" }), + validateEnvironment({ + ALTERTABLE_API_KEY: 'atm_x', + ALTERTABLE_ENV: 'production', + }) + ).not.toThrow(); + expect(() => + validateEnvironment({ ALTERTABLE_BASIC_AUTH_TOKEN: 'token' }) ).not.toThrow(); - expect(() => validateEnvironment({ ALTERTABLE_BASIC_AUTH_TOKEN: "token" })).not.toThrow(); expect(() => validateEnvironment({ - ALTERTABLE_LAKEHOUSE_USERNAME: "u", - ALTERTABLE_LAKEHOUSE_PASSWORD: "p", - ALTERTABLE_API_BASE: "https://api.example.com", - }), + ALTERTABLE_LAKEHOUSE_USERNAME: 'u', + ALTERTABLE_LAKEHOUSE_PASSWORD: 'p', + ALTERTABLE_API_BASE: 'https://api.example.com', + }) ).not.toThrow(); }); }); -test("production modules access the environment only through env.ts", async () => { - const sourceRoot = join(import.meta.dir, ".."); +test('production modules access the environment only through env.ts', async () => { + const sourceRoot = join(import.meta.dir, '..'); const directAccessPattern = /(?:process|globalThis\.process\?)\.env/; const violations: string[] = []; - for await (const relativePath of new Bun.Glob("**/*.ts").scan(sourceRoot)) { + for await (const relativePath of new Bun.Glob('**/*.ts').scan(sourceRoot)) { if ( - relativePath === "lib/env.ts" || - relativePath.endsWith(".test.ts") || - relativePath.startsWith("generated/") || - relativePath.startsWith("test-utils/") + relativePath === 'lib/env.ts' || + relativePath.endsWith('.test.ts') || + relativePath.startsWith('generated/') || + relativePath.startsWith('test-utils/') ) { continue; } - const source = await readFile(join(sourceRoot, relativePath), "utf8"); + const source = await readFile(join(sourceRoot, relativePath), 'utf8'); if (directAccessPattern.test(source)) { violations.push(relativePath); } diff --git a/cli/src/lib/env.ts b/cli/src/lib/env.ts index 99407d5f..78d095a3 100644 --- a/cli/src/lib/env.ts +++ b/cli/src/lib/env.ts @@ -1,4 +1,4 @@ -import { ConfigurationError } from "@/lib/errors.ts"; +import { ConfigurationError } from '@/lib/errors.ts'; type EnvSource = Readonly>; @@ -7,7 +7,7 @@ function optional(value: string | undefined): string | undefined { } function oneOf( - values: readonly TValue[], + values: readonly TValue[] ): (value: string | undefined, name: string) => TValue | undefined { return (value, name) => { const present = optional(value); @@ -17,22 +17,25 @@ function oneOf( if (values.includes(present as TValue)) { return present as TValue; } - throw invalidValue(name, present, `one of: ${values.join(", ")}`); + throw invalidValue(name, present, `one of: ${values.join(', ')}`); }; } -function booleanValue(value: string | undefined, name: string): boolean | undefined { +function booleanValue( + value: string | undefined, + name: string +): boolean | undefined { const present = optional(value)?.toLowerCase(); if (present === undefined) { return undefined; } - if (present === "1" || present === "true") { + if (present === '1' || present === 'true') { return true; } - if (present === "0" || present === "false") { + if (present === '0' || present === 'false') { return false; } - throw invalidValue(name, value ?? "", "true, false, 1, or 0"); + throw invalidValue(name, value ?? '', 'true, false, 1, or 0'); } function port(value: string | undefined, name: string): number | undefined { @@ -41,18 +44,22 @@ function port(value: string | undefined, name: string): number | undefined { return undefined; } if (!/^\d+$/.test(present)) { - throw invalidValue(name, present, "an integer from 0 to 65535"); + throw invalidValue(name, present, 'an integer from 0 to 65535'); } const parsed = Number(present); if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65_535) { - throw invalidValue(name, present, "an integer from 0 to 65535"); + throw invalidValue(name, present, 'an integer from 0 to 65535'); } return parsed; } -function invalidValue(name: string, value: string, expectation: string): ConfigurationError { +function invalidValue( + name: string, + value: string, + expectation: string +): ConfigurationError { return new ConfigurationError( - `Invalid ${name} value ${JSON.stringify(value)}; expected ${expectation}.`, + `Invalid ${name} value ${JSON.stringify(value)}; expected ${expectation}.` ); } @@ -62,7 +69,7 @@ const ENV_SCHEMA = { ALTERTABLE_API_BASE: { parse: optional }, ALTERTABLE_API_KEY: { parse: optional, secret: true }, ALTERTABLE_BASIC_AUTH_TOKEN: { parse: optional, secret: true }, - ALTERTABLE_COLOR: { parse: oneOf(["auto", "always", "never"] as const) }, + ALTERTABLE_COLOR: { parse: oneOf(['auto', 'always', 'never'] as const) }, ALTERTABLE_CONFIG_HOME: { parse: optional }, ALTERTABLE_DOCKER_PASSWORD: { parse: optional, secret: true }, ALTERTABLE_DOCKER_USER: { parse: optional }, @@ -79,17 +86,19 @@ const ENV_SCHEMA = { ALTERTABLE_OAUTH_REDIRECT_PORT: { parse: port }, ALTERTABLE_OAUTH_SCOPE: { parse: optional }, ALTERTABLE_PROFILE: { parse: optional }, - ALTERTABLE_SECRET_BACKEND: { parse: oneOf(["file", "keychain"] as const) }, + ALTERTABLE_SECRET_BACKEND: { parse: oneOf(['file', 'keychain'] as const) }, ALTERTABLE_UPDATE_CHECK: { - parse: oneOf(["0", "1", "false", "true", "never"] as const), + parse: oneOf(['0', '1', 'false', 'true', 'never'] as const), }, ALTERTABLE_UPDATE_GITHUB_REPO: { parse: optional }, ALTERTABLE_UPDATE_INSTALL_METHOD: { - parse: oneOf(["auto", "package-manager", "github-binary"] as const), + parse: oneOf(['auto', 'package-manager', 'github-binary'] as const), + }, + ALTERTABLE_UPDATE_INSTALLER: { + parse: oneOf(['bun', 'npm', 'pnpm', 'yarn'] as const), }, - ALTERTABLE_UPDATE_INSTALLER: { parse: oneOf(["bun", "npm", "pnpm", "yarn"] as const) }, ALTERTABLE_UPDATE_REGISTRY_URL: { parse: optional }, - ALTERTABLE_UPDATE_SOURCE: { parse: oneOf(["npm", "github"] as const) }, + ALTERTABLE_UPDATE_SOURCE: { parse: oneOf(['npm', 'github'] as const) }, BUN_INSTALL: { parse: optional }, CI: { parse: optional }, COLUMNS: { parse: optional }, @@ -112,20 +121,20 @@ const ENV_SCHEMA = { const SECRET_NAME_PATTERN = /_(?:KEY|PASSWORD|SECRET|TOKEN)$/; -export function assertSecretEnvMetadata>>( - schema: TSchema, -): void { +export function assertSecretEnvMetadata< + TSchema extends Readonly>, +>(schema: TSchema): void { const missing = Object.entries(schema) .filter( ([name, definition]) => SECRET_NAME_PATTERN.test(name) && - (definition as { readonly secret?: boolean }).secret !== true, + (definition as { readonly secret?: boolean }).secret !== true ) .map(([name]) => name) .sort(); if (missing.length > 0) { throw new Error( - `Secret environment variables must declare secret: true: ${missing.join(", ")}`, + `Secret environment variables must declare secret: true: ${missing.join(', ')}` ); } } @@ -133,7 +142,9 @@ export function assertSecretEnvMetadata = ReturnType<(typeof ENV_SCHEMA)[TName]["parse"]>; +export type EnvValue = ReturnType< + (typeof ENV_SCHEMA)[TName]['parse'] +>; export function readEnv(name: TName): EnvValue { return readEnvFrom(process.env, name); @@ -141,12 +152,15 @@ export function readEnv(name: TName): EnvValue { export function readEnvFrom( source: EnvSource, - name: TName, + name: TName ): EnvValue { return ENV_SCHEMA[name].parse(source[name], name) as EnvValue; } -export function setEnv(name: TName, value: string): void { +export function setEnv( + name: TName, + value: string +): void { ENV_SCHEMA[name].parse(value, name); process.env[name] = value; } @@ -160,38 +174,48 @@ export function copyProcessEnv(): NodeJS.ProcessEnv { } function assertCoherentEnvConfig(source: EnvSource): void { - const user = readEnvFrom(source, "ALTERTABLE_LAKEHOUSE_USERNAME"); - const password = readEnvFrom(source, "ALTERTABLE_LAKEHOUSE_PASSWORD"); + const user = readEnvFrom(source, 'ALTERTABLE_LAKEHOUSE_USERNAME'); + const password = readEnvFrom(source, 'ALTERTABLE_LAKEHOUSE_PASSWORD'); if (Boolean(user) !== Boolean(password)) { - const set = user ? "ALTERTABLE_LAKEHOUSE_USERNAME" : "ALTERTABLE_LAKEHOUSE_PASSWORD"; - const missing = user ? "ALTERTABLE_LAKEHOUSE_PASSWORD" : "ALTERTABLE_LAKEHOUSE_USERNAME"; - throw new ConfigurationError(`${set} is set but ${missing} is not; set both or neither.`); + const set = user + ? 'ALTERTABLE_LAKEHOUSE_USERNAME' + : 'ALTERTABLE_LAKEHOUSE_PASSWORD'; + const missing = user + ? 'ALTERTABLE_LAKEHOUSE_PASSWORD' + : 'ALTERTABLE_LAKEHOUSE_USERNAME'; + throw new ConfigurationError( + `${set} is set but ${missing} is not; set both or neither.` + ); } const hasCredential = Boolean( - readEnvFrom(source, "ALTERTABLE_API_KEY") || - readEnvFrom(source, "ALTERTABLE_BASIC_AUTH_TOKEN") || - (user && password), + readEnvFrom(source, 'ALTERTABLE_API_KEY') || + readEnvFrom(source, 'ALTERTABLE_BASIC_AUTH_TOKEN') || + (user && password) ); if (hasCredential) { return; } const credentialless = ( - ["ALTERTABLE_ENV", "ALTERTABLE_API_BASE", "ALTERTABLE_MANAGEMENT_API_BASE"] as const - ).filter((name) => readEnvFrom(source, name)); + [ + 'ALTERTABLE_ENV', + 'ALTERTABLE_API_BASE', + 'ALTERTABLE_MANAGEMENT_API_BASE', + ] as const + ).filter(name => readEnvFrom(source, name)); if (credentialless.length > 0) { throw new ConfigurationError( [ - `Incomplete environment configuration: ${credentialless.join(", ")} ${credentialless.length === 1 ? "is" : "are"} set without credentials, and environment configuration replaces stored profiles entirely.`, - "Set ALTERTABLE_API_KEY, ALTERTABLE_BASIC_AUTH_TOKEN, or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD — or unset these variables to use stored profiles.", - ].join("\n"), + `Incomplete environment configuration: ${credentialless.join(', ')} ${credentialless.length === 1 ? 'is' : 'are'} set without credentials, and environment configuration replaces stored profiles entirely.`, + 'Set ALTERTABLE_API_KEY, ALTERTABLE_BASIC_AUTH_TOKEN, or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD — or unset these variables to use stored profiles.', + ].join('\n') ); } } export function validateEnvironment(source: EnvSource = process.env): void { for (const name of Object.keys(ENV_SCHEMA) as EnvName[]) { - if (name.startsWith("ALTERTABLE_")) { + if (name.startsWith('ALTERTABLE_')) { readEnvFrom(source, name); } } @@ -199,36 +223,38 @@ export function validateEnvironment(source: EnvSource = process.env): void { } export type ConfigEnvName = - | "ALTERTABLE_API_KEY" - | "ALTERTABLE_BASIC_AUTH_TOKEN" - | "ALTERTABLE_LAKEHOUSE_USERNAME" - | "ALTERTABLE_LAKEHOUSE_PASSWORD" - | "ALTERTABLE_ENV" - | "ALTERTABLE_API_BASE" - | "ALTERTABLE_MANAGEMENT_API_BASE"; + | 'ALTERTABLE_API_KEY' + | 'ALTERTABLE_BASIC_AUTH_TOKEN' + | 'ALTERTABLE_LAKEHOUSE_USERNAME' + | 'ALTERTABLE_LAKEHOUSE_PASSWORD' + | 'ALTERTABLE_ENV' + | 'ALTERTABLE_API_BASE' + | 'ALTERTABLE_MANAGEMENT_API_BASE'; export const CONFIG_ENV_NAMES = [ - "ALTERTABLE_API_KEY", - "ALTERTABLE_BASIC_AUTH_TOKEN", - "ALTERTABLE_LAKEHOUSE_USERNAME", - "ALTERTABLE_LAKEHOUSE_PASSWORD", - "ALTERTABLE_ENV", - "ALTERTABLE_API_BASE", - "ALTERTABLE_MANAGEMENT_API_BASE", + 'ALTERTABLE_API_KEY', + 'ALTERTABLE_BASIC_AUTH_TOKEN', + 'ALTERTABLE_LAKEHOUSE_USERNAME', + 'ALTERTABLE_LAKEHOUSE_PASSWORD', + 'ALTERTABLE_ENV', + 'ALTERTABLE_API_BASE', + 'ALTERTABLE_MANAGEMENT_API_BASE', ] as const satisfies readonly ConfigEnvName[]; export function isSecretEnv(name: EnvName): boolean { - return "secret" in ENV_SCHEMA[name] && ENV_SCHEMA[name].secret === true; + return 'secret' in ENV_SCHEMA[name] && ENV_SCHEMA[name].secret === true; } export function configuredEnvConfig( - source: EnvSource = process.env, + source: EnvSource = process.env ): Array<{ name: ConfigEnvName; display: string }> { - return CONFIG_ENV_NAMES.flatMap((name) => { + return CONFIG_ENV_NAMES.flatMap(name => { const value = readEnvFrom(source, name); if (!value) { return []; } - return [{ name, display: isSecretEnv(name) ? "set (hidden)" : String(value) }]; + return [ + { name, display: isSecretEnv(name) ? 'set (hidden)' : String(value) }, + ]; }); } diff --git a/cli/src/lib/errors.test.ts b/cli/src/lib/errors.test.ts index 43f74316..354d462f 100644 --- a/cli/src/lib/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { CliError, ConfigurationError, @@ -18,14 +18,18 @@ import { httpStatusMessage, serializeCliError, shouldShowCommandExamplesOnError, -} from "@/lib/errors.ts"; -import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; +} from '@/lib/errors.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; import { forceNoTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} from '@/test-utils/terminal.ts'; let terminalState: TerminalTestState; @@ -38,177 +42,207 @@ afterEach(() => { restoreTerminalState(terminalState); }); -describe("errors", () => { - test("renderCliError formats CliError", () => { - expect(renderCliError(new CliError("x"))).toBe("[ERROR] x"); +describe('errors', () => { + test('renderCliError formats CliError', () => { + expect(renderCliError(new CliError('x'))).toBe('[ERROR] x'); }); - test("renderCliError preserves trusted line structure and sanitizes each line", () => { + test('renderCliError preserves trusted line structure and sanitizes each line', () => { const rendered = renderCliError( - new ConfigurationError("First line\nRun this\u001b]0;spoofed\u0007"), + new ConfigurationError('First line\nRun this\u001b]0;spoofed\u0007') ); - expect(rendered).toBe("[ERROR] First line\nRun this\\x1b]0;spoofed\\x07"); - expect(rendered).not.toContain("\\x0a"); - expect(rendered).not.toContain("\u001b"); - expect(rendered).not.toContain("\u0007"); + expect(rendered).toBe('[ERROR] First line\nRun this\\x1b]0;spoofed\\x07'); + expect(rendered).not.toContain('\\x0a'); + expect(rendered).not.toContain('\u001b'); + expect(rendered).not.toContain('\u0007'); }); - test("renderCliErrorDetails prefixes the first line with the documented marker", () => { - expect(renderCliErrorDetails("First detail\nSecond detail")).toBe( - "[ERROR] First detail\nSecond detail", + test('renderCliErrorDetails prefixes the first line with the documented marker', () => { + expect(renderCliErrorDetails('First detail\nSecond detail')).toBe( + '[ERROR] First detail\nSecond detail' ); }); - test("ConfigurationError uses EXIT_CONFIG", () => { - const error = new ConfigurationError("missing config"); + test('ConfigurationError uses EXIT_CONFIG', () => { + const error = new ConfigurationError('missing config'); expect(error.exitCode).toBe(EXIT_CONFIG); - expect(error.name).toBe("ConfigurationError"); + expect(error.name).toBe('ConfigurationError'); }); - test("unknown errors render without stack traces", () => { - const rendered = renderCliError(new TypeError("secret internal detail")); - expect(rendered).toBe("[ERROR] Unexpected error."); - expect(rendered).not.toContain("secret internal detail"); - expect(rendered).not.toContain("TypeError"); + test('unknown errors render without stack traces', () => { + const rendered = renderCliError(new TypeError('secret internal detail')); + expect(rendered).toBe('[ERROR] Unexpected error.'); + expect(rendered).not.toContain('secret internal detail'); + expect(rendered).not.toContain('TypeError'); }); - test("shouldShowCommandExamplesOnError is true only for usage CliErrors", () => { - expect(shouldShowCommandExamplesOnError(new CliError("Endpoint path is required."))).toBe(true); - expect(shouldShowCommandExamplesOnError(new ConfigurationError("Not configured."))).toBe(false); + test('shouldShowCommandExamplesOnError is true only for usage CliErrors', () => { + expect( + shouldShowCommandExamplesOnError( + new CliError('Endpoint path is required.') + ) + ).toBe(true); + expect( + shouldShowCommandExamplesOnError( + new ConfigurationError('Not configured.') + ) + ).toBe(false); }); - test("shouldShowCommandExamplesOnError is false for transport and HTTP errors", () => { + test('shouldShowCommandExamplesOnError is false for transport and HTTP errors', () => { expect( shouldShowCommandExamplesOnError( - new HttpError({ status: 404, body: "", method: "GET", url: "/x" }), - ), + new HttpError({ status: 404, body: '', method: 'GET', url: '/x' }) + ) ).toBe(false); expect(shouldShowCommandExamplesOnError(new NetworkError())).toBe(false); expect(shouldShowCommandExamplesOnError(new TimeoutError())).toBe(false); - expect(shouldShowCommandExamplesOnError(new ParseError("bad json"))).toBe(false); + expect(shouldShowCommandExamplesOnError(new ParseError('bad json'))).toBe( + false + ); }); }); -describe("HttpError exit codes", () => { - test("maps HTTP status to semantic exit codes", () => { - expect(new HttpError({ status: 401, body: "", method: "GET", url: "/x" }).exitCode).toBe( - EXIT_AUTH, - ); - expect(new HttpError({ status: 404, body: "", method: "GET", url: "/x" }).exitCode).toBe( - EXIT_NOT_FOUND, - ); - expect(new HttpError({ status: 403, body: "", method: "GET", url: "/x" }).exitCode).toBe(3); - expect(new HttpError({ status: 409, body: "", method: "POST", url: "/x" }).exitCode).toBe(5); - expect(new HttpError({ status: 429, body: "", method: "GET", url: "/x" }).exitCode).toBe(7); - expect(new HttpError({ status: 422, body: "", method: "GET", url: "/x" }).exitCode).toBe( - EXIT_VALIDATION, - ); - expect(new HttpError({ status: 500, body: "", method: "GET", url: "/x" }).exitCode).toBe( - EXIT_SERVER, - ); - expect(new HttpError({ status: 418, body: "", method: "GET", url: "/x" }).exitCode).toBe( - EXIT_GENERIC, - ); +describe('HttpError exit codes', () => { + test('maps HTTP status to semantic exit codes', () => { + expect( + new HttpError({ status: 401, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(EXIT_AUTH); + expect( + new HttpError({ status: 404, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(EXIT_NOT_FOUND); + expect( + new HttpError({ status: 403, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(3); + expect( + new HttpError({ status: 409, body: '', method: 'POST', url: '/x' }) + .exitCode + ).toBe(5); + expect( + new HttpError({ status: 429, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(7); + expect( + new HttpError({ status: 422, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(EXIT_VALIDATION); + expect( + new HttpError({ status: 500, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(EXIT_SERVER); + expect( + new HttpError({ status: 418, body: '', method: 'GET', url: '/x' }) + .exitCode + ).toBe(EXIT_GENERIC); }); }); -describe("transport error exit codes", () => { - test("NetworkError and TimeoutError use EXIT_NETWORK", () => { +describe('transport error exit codes', () => { + test('NetworkError and TimeoutError use EXIT_NETWORK', () => { expect(new NetworkError().exitCode).toBe(EXIT_NETWORK); expect(new TimeoutError().exitCode).toBe(EXIT_NETWORK); }); - test("ParseError uses EXIT_VALIDATION", () => { - expect(new ParseError("bad json").exitCode).toBe(EXIT_VALIDATION); + test('ParseError uses EXIT_VALIDATION', () => { + expect(new ParseError('bad json').exitCode).toBe(EXIT_VALIDATION); }); }); -describe("HttpError messages", () => { - test("401 with lakehouse plane mentions lakehouse credentials", () => { +describe('HttpError messages', () => { + test('401 with lakehouse plane mentions lakehouse credentials', () => { const error = new HttpError({ status: 401, - body: "", - method: "POST", - url: "/query", - authPlane: "lakehouse", + body: '', + method: 'POST', + url: '/query', + authPlane: 'lakehouse', }); - expect(error.message).toContain("lakehouse credentials"); + expect(error.message).toContain('lakehouse credentials'); }); - test("401 with management plane mentions management API key", () => { + test('401 with management plane mentions management API key', () => { const error = new HttpError({ status: 401, - body: "", - method: "GET", - url: "/whoami", - authPlane: "management", + body: '', + method: 'GET', + url: '/whoami', + authPlane: 'management', }); - expect(error.message).toContain("management API key"); + expect(error.message).toContain('management API key'); }); - test("401 without plane uses generic credentials message", () => { + test('401 without plane uses generic credentials message', () => { const error = new HttpError({ status: 401, - body: "", - method: "GET", - url: "/x", + body: '', + method: 'GET', + url: '/x', }); - expect(error.message).toContain("Check your credentials."); + expect(error.message).toContain('Check your credentials.'); }); - test("httpStatusMessage matches HttpError for each plane", () => { - expect(httpStatusMessage(401, "lakehouse")).toContain("lakehouse credentials"); - expect(httpStatusMessage(401, "management")).toContain("management API key"); - expect(httpStatusMessage(401)).toContain("Check your credentials."); + test('httpStatusMessage matches HttpError for each plane', () => { + expect(httpStatusMessage(401, 'lakehouse')).toContain( + 'lakehouse credentials' + ); + expect(httpStatusMessage(401, 'management')).toContain( + 'management API key' + ); + expect(httpStatusMessage(401)).toContain('Check your credentials.'); }); }); -describe("JSON error envelope", () => { - test("HttpError serializes with status and stable code", () => { +describe('JSON error envelope', () => { + test('HttpError serializes with status and stable code', () => { const error = new HttpError({ status: 401, body: '{"error":"invalid key"}', - method: "GET", - url: "https://api.example.com/whoami", - parsedDetail: "invalid key", - authPlane: "management", + method: 'GET', + url: 'https://api.example.com/whoami', + parsedDetail: 'invalid key', + authPlane: 'management', }); const json = serializeCliError(error); expect(json).toEqual({ error: true, - code: "auth_failed", - message: "Authentication failed (401). Check your management API key.", + code: 'auth_failed', + message: 'Authentication failed (401). Check your management API key.', exit_code: EXIT_AUTH, - details: "invalid key", + details: 'invalid key', status: 401, }); expect(JSON.parse(renderCliErrorJson(error))).toEqual(json); }); - test("ConfigurationError serializes with configuration_error code", () => { - const json = serializeCliError(new ConfigurationError("No API key configured.")); - expect(json.code).toBe("configuration_error"); + test('ConfigurationError serializes with configuration_error code', () => { + const json = serializeCliError( + new ConfigurationError('No API key configured.') + ); + expect(json.code).toBe('configuration_error'); expect(json.exit_code).toBe(EXIT_CONFIG); expect(json.error).toBe(true); }); - test("command parser errors serialize as usage_error", () => { - const usageError = new Error("Missing required argument: statement"); - usageError.name = "CLIError"; - Object.assign(usageError, { code: "ERR_MISSING_ARGS" }); + test('command parser errors serialize as usage_error', () => { + const usageError = new Error('Missing required argument: statement'); + usageError.name = 'CLIError'; + Object.assign(usageError, { code: 'ERR_MISSING_ARGS' }); - expect(errorCodeFromError(usageError)).toBe("usage_error"); + expect(errorCodeFromError(usageError)).toBe('usage_error'); expect(getCliExitCode(usageError)).toBe(EXIT_GENERIC); const json = serializeCliError(usageError); - expect(json.code).toBe("usage_error"); + expect(json.code).toBe('usage_error'); expect(json.exit_code).toBe(EXIT_GENERIC); }); - test("unexpected errors serialize without leaking internals", () => { - const json = serializeCliError(new TypeError("secret stack detail")); - expect(json.code).toBe("unexpected_error"); - expect(json.message).toBe("Unexpected error."); - expect(JSON.stringify(json)).not.toContain("secret"); + test('unexpected errors serialize without leaking internals', () => { + const json = serializeCliError(new TypeError('secret stack detail')); + expect(json.code).toBe('unexpected_error'); + expect(json.message).toBe('Unexpected error.'); + expect(JSON.stringify(json)).not.toContain('secret'); }); }); diff --git a/cli/src/lib/errors.ts b/cli/src/lib/errors.ts index 2a0cb207..4bd37868 100644 --- a/cli/src/lib/errors.ts +++ b/cli/src/lib/errors.ts @@ -40,7 +40,7 @@ export class CliError extends Error { constructor(message: string, options: CliErrorOptions = {}) { super(message); - this.name = "CliError"; + this.name = 'CliError'; this.exitCode = options.exitCode ?? EXIT_GENERIC; if (options.cause !== undefined) { this.cause = options.cause; @@ -54,10 +54,12 @@ export class CliError extends Error { export function assertRetrieved( value: T | null | undefined, operation: string, - path: string, + path: string ): T { - if (value === undefined || value === null || value === "") { - throw new CliError(`${operation} failed: assertion failed retrieving ${path}`); + if (value === undefined || value === null || value === '') { + throw new CliError( + `${operation} failed: assertion failed retrieving ${path}` + ); } return value; } @@ -65,7 +67,7 @@ export function assertRetrieved( export class ConfigurationError extends CliError { constructor(message: string, options: CliErrorOptions = {}) { super(message, { ...options, exitCode: options.exitCode ?? EXIT_CONFIG }); - this.name = "ConfigurationError"; + this.name = 'ConfigurationError'; } } @@ -121,65 +123,76 @@ export class HttpError extends CliError { exitCode: options.exitCode ?? httpStatusExitCode(options.status), details: options.detailsOverride ?? options.parsedDetail, }); - this.name = "HttpError"; + this.name = 'HttpError'; this.status = options.status; this.body = options.body; this.method = options.method; this.url = options.url; this.parsedDetail = options.parsedDetail; this.retryAfterHeader = options.retryAfterHeader; - this.message = options.messageOverride ?? httpStatusMessage(options.status, options.authPlane); + this.message = + options.messageOverride ?? + httpStatusMessage(options.status, options.authPlane); } } export class NetworkError extends CliError { - constructor(message = "Request failed (network error).", options: CliErrorOptions = {}) { + constructor( + message = 'Request failed (network error).', + options: CliErrorOptions = {} + ) { super(message, { ...options, exitCode: options.exitCode ?? EXIT_NETWORK }); - this.name = "NetworkError"; + this.name = 'NetworkError'; } } export class TimeoutError extends NetworkError { - constructor(message = "Request timed out.", options: CliErrorOptions = {}) { + constructor(message = 'Request timed out.', options: CliErrorOptions = {}) { super(message, options); - this.name = "TimeoutError"; + this.name = 'TimeoutError'; } } export class ParseError extends CliError { constructor(message: string, options: CliErrorOptions = {}) { - super(message, { ...options, exitCode: options.exitCode ?? EXIT_VALIDATION }); - this.name = "ParseError"; + super(message, { + ...options, + exitCode: options.exitCode ?? EXIT_VALIDATION, + }); + this.name = 'ParseError'; } } export class QueryError extends CliError { constructor(message: string) { super(`Query failed: ${message}`); - this.name = "QueryError"; + this.name = 'QueryError'; } } -export type AuthPlane = "lakehouse" | "management"; +export type AuthPlane = 'lakehouse' | 'management'; -export function httpStatusMessage(status: number, authPlane?: AuthPlane): string { +export function httpStatusMessage( + status: number, + authPlane?: AuthPlane +): string { if (status === 401) { - if (authPlane === "lakehouse") { - return "Authentication failed (401). Check lakehouse credentials (username/password or Basic token)."; + if (authPlane === 'lakehouse') { + return 'Authentication failed (401). Check lakehouse credentials (username/password or Basic token).'; } - if (authPlane === "management") { - return "Authentication failed (401). Check your management API key."; + if (authPlane === 'management') { + return 'Authentication failed (401). Check your management API key.'; } - return "Authentication failed (401). Check your credentials."; + return 'Authentication failed (401). Check your credentials.'; } if (status === 403) { - return "Permission denied (403)."; + return 'Permission denied (403).'; } if (status === 404) { - return "Not found (404)."; + return 'Not found (404).'; } if (status === 429) { - return "Rate limited (429). Try again later."; + return 'Rate limited (429). Try again later.'; } if (status >= 500) { return `Server error (${status}). Try again later.`; @@ -199,50 +212,50 @@ export type CliErrorJson = { export function errorCodeFromError(error: unknown): string { if (error instanceof HttpError) { if (error.status === 401) { - return "auth_failed"; + return 'auth_failed'; } if (error.status === 403) { - return "forbidden"; + return 'forbidden'; } if (error.status === 404) { - return "not_found"; + return 'not_found'; } if (error.status === 409) { - return "conflict"; + return 'conflict'; } if (error.status === 422) { - return "validation_error"; + return 'validation_error'; } if (error.status === 429) { - return "rate_limited"; + return 'rate_limited'; } if (error.status >= 500) { - return "server_error"; + return 'server_error'; } - return "http_error"; + return 'http_error'; } if (error instanceof QueryError) { - return "query_error"; + return 'query_error'; } if (error instanceof ConfigurationError) { - return "configuration_error"; + return 'configuration_error'; } if (error instanceof ParseError) { - return "parse_error"; + return 'parse_error'; } if (error instanceof TimeoutError) { - return "timeout_error"; + return 'timeout_error'; } if (error instanceof NetworkError) { - return "network_error"; + return 'network_error'; } if (error instanceof CliError) { - return "cli_error"; + return 'cli_error'; } - if (error instanceof Error && error.name === "CLIError") { - return "usage_error"; + if (error instanceof Error && error.name === 'CLIError') { + return 'usage_error'; } - return "unexpected_error"; + return 'unexpected_error'; } export function serializeCliError(error: unknown): CliErrorJson { @@ -270,10 +283,10 @@ export function serializeCliError(error: unknown): CliErrorJson { }; } - if (error instanceof Error && error.name === "CLIError") { + if (error instanceof Error && error.name === 'CLIError') { return { error: true, - code: "usage_error", + code: 'usage_error', message: error.message, exit_code: EXIT_GENERIC, }; @@ -281,8 +294,8 @@ export function serializeCliError(error: unknown): CliErrorJson { return { error: true, - code: "unexpected_error", - message: "Unexpected error.", + code: 'unexpected_error', + message: 'Unexpected error.', exit_code: EXIT_GENERIC, }; } @@ -291,14 +304,16 @@ export function getCliExitCode(error: unknown): number { if (error instanceof CliError) { return error.exitCode; } - if (error instanceof Error && error.name === "CLIError") { + if (error instanceof Error && error.name === 'CLIError') { return EXIT_GENERIC; } return EXIT_GENERIC; } -export function isCommandParseError(error: unknown): error is Error & { code: string } { - return error instanceof Error && error.name === "CLIError" && "code" in error; +export function isCommandParseError( + error: unknown +): error is Error & { code: string } { + return error instanceof Error && error.name === 'CLIError' && 'code' in error; } export function shouldShowCommandExamplesOnError(error: unknown): boolean { diff --git a/cli/src/lib/executable-path.ts b/cli/src/lib/executable-path.ts index 18f95ae0..d144af73 100644 --- a/cli/src/lib/executable-path.ts +++ b/cli/src/lib/executable-path.ts @@ -1,5 +1,5 @@ -import { accessSync, constants, realpathSync, statSync } from "node:fs"; -import { delimiter, isAbsolute, resolve } from "node:path"; +import { accessSync, constants, realpathSync, statSync } from 'node:fs'; +import { delimiter, isAbsolute, resolve } from 'node:path'; export type ExecutablePathOptions = { execPath?: string; @@ -28,11 +28,15 @@ function isExecutableFile(path: string): boolean { } } -function invocationPath(value: string, cwd: string, pathValue: string): string | undefined { +function invocationPath( + value: string, + cwd: string, + pathValue: string +): string | undefined { if (isAbsolute(value)) { return isExecutableFile(value) ? value : undefined; } - if (value.includes("/") || value.includes("\\")) { + if (value.includes('/') || value.includes('\\')) { const candidate = resolve(cwd, value); return isExecutableFile(candidate) ? candidate : undefined; } @@ -46,12 +50,15 @@ function invocationPath(value: string, cwd: string, pathValue: string): string | } /** Resolve the file that should be replaced when a compiled CLI updates itself. */ -export function resolveProcessExecutablePath(options: ExecutablePathOptions = {}): string { +export function resolveProcessExecutablePath( + options: ExecutablePathOptions = {} +): string { const usesCurrentProcess = options.execPath === undefined; const execPath = options.execPath ?? process.execPath; - const argv0 = options.argv0 ?? (usesCurrentProcess ? process.argv0 : execPath); + const argv0 = + options.argv0 ?? (usesCurrentProcess ? process.argv0 : execPath); const cwd = options.cwd ?? process.cwd(); - const pathValue = options.path ?? ""; + const pathValue = options.path ?? ''; if (isAbsolute(execPath)) { return canonicalPath(execPath); @@ -62,6 +69,7 @@ export function resolveProcessExecutablePath(options: ExecutablePathOptions = {} return canonicalPath(invokedPath); } - const resolvedExecPath = invocationPath(execPath, cwd, pathValue) ?? resolve(cwd, execPath); + const resolvedExecPath = + invocationPath(execPath, cwd, pathValue) ?? resolve(cwd, execPath); return canonicalPath(resolvedExecPath); } diff --git a/cli/src/lib/execution-context.ts b/cli/src/lib/execution-context.ts index e2d3aaee..60532788 100644 --- a/cli/src/lib/execution-context.ts +++ b/cli/src/lib/execution-context.ts @@ -1,6 +1,9 @@ -import type { CliContext } from "@/context.ts"; -import { resolveProfileReference, resolveWorkingProfile } from "@/lib/profile-store.ts"; -import type { CliRuntime, OutputSink } from "@/lib/runtime.ts"; +import type { CliContext } from '@/context.ts'; +import { + resolveProfileReference, + resolveWorkingProfile, +} from '@/lib/profile-store.ts'; +import type { CliRuntime, OutputSink } from '@/lib/runtime.ts'; export type ExecutionContext = { cli: CliContext; @@ -12,7 +15,7 @@ export function optionalAuth(resolve: () => string): string | undefined { try { return resolve(); } catch (error) { - if (error instanceof Error && error.name === "ConfigurationError") { + if (error instanceof Error && error.name === 'ConfigurationError') { return undefined; } throw error; @@ -23,11 +26,15 @@ export function createExecutionContext(runtime: CliRuntime): ExecutionContext { return { cli: runtime.context, output: runtime.output, - profile: runtime.session?.profile ?? resolveWorkingProfile(runtime.context.profile), + profile: + runtime.session?.profile ?? + resolveWorkingProfile(runtime.context.profile), }; } -export function createDiagnosticExecutionContext(runtime: CliRuntime): ExecutionContext { +export function createDiagnosticExecutionContext( + runtime: CliRuntime +): ExecutionContext { return { cli: runtime.context, output: runtime.output, diff --git a/cli/src/lib/fixtures/compiled-executable-path.ts b/cli/src/lib/fixtures/compiled-executable-path.ts index 7a956368..5316851a 100644 --- a/cli/src/lib/fixtures/compiled-executable-path.ts +++ b/cli/src/lib/fixtures/compiled-executable-path.ts @@ -1,3 +1,3 @@ -import { resolveProcessExecutablePath } from "@/lib/executable-path.ts"; +import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; console.log(resolveProcessExecutablePath()); diff --git a/cli/src/lib/global-flags.test.ts b/cli/src/lib/global-flags.test.ts index 5be94171..15c697a3 100644 --- a/cli/src/lib/global-flags.test.ts +++ b/cli/src/lib/global-flags.test.ts @@ -1,42 +1,42 @@ -import { describe, expect, test } from "bun:test"; -import { isJsonOutput, setCliContext } from "@/context.ts"; +import { describe, expect, test } from 'bun:test'; +import { isJsonOutput, setCliContext } from '@/context.ts'; import { AGENT_ENV_VARIABLES, mergeGlobalFlagsFromArgs, parseGlobalOutputFlags, -} from "@/lib/global-flags.ts"; +} from '@/lib/global-flags.ts'; import { createCliRuntime, getOutputSink, refreshCliRuntimeContext, setCliRuntime, -} from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { CliError } from "@/lib/errors.ts"; -import { renderCliErrorJson } from "@/ui/error.ts"; +} from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { CliError } from '@/lib/errors.ts'; +import { renderCliErrorJson } from '@/ui/error.ts'; -describe("parseGlobalOutputFlags", () => { - test("parses --agent", () => { - const context = parseGlobalOutputFlags(["--agent", "query", "SELECT 1"]); +describe('parseGlobalOutputFlags', () => { + test('parses --agent', () => { + const context = parseGlobalOutputFlags(['--agent', 'query', 'SELECT 1']); expect(context.agent).toBe(true); expect(context.json).toBe(false); }); - test("parses --no-color", () => { - const context = parseGlobalOutputFlags(["--no-color", "catalog"]); + test('parses --no-color', () => { + const context = parseGlobalOutputFlags(['--no-color', 'catalog']); expect(context.noColor).toBe(true); }); - test("parses --json and --agent independently", () => { - const both = parseGlobalOutputFlags(["--json", "--agent", "context"]); + test('parses --json and --agent independently', () => { + const both = parseGlobalOutputFlags(['--json', '--agent', 'context']); expect(both.json).toBe(true); expect(both.agent).toBe(true); }); - test("isolates non-throwing output flags for early error rendering", () => { + test('isolates non-throwing output flags for early error rendering', () => { const context = parseGlobalOutputFlags( - ["query", "SELECT 1", "--connect-timeout", "nope", "--json"], - { env: {}, stdoutIsTTY: false }, + ['query', 'SELECT 1', '--connect-timeout', 'nope', '--json'], + { env: {}, stdoutIsTTY: false } ); expect(context).toEqual({ @@ -48,46 +48,63 @@ describe("parseGlobalOutputFlags", () => { }); }); -describe("mergeGlobalFlagsFromArgs", () => { - test("carries --ignore-ssl-errors into the context", () => { +describe('mergeGlobalFlagsFromArgs', () => { + test('carries --ignore-ssl-errors into the context', () => { const merged = mergeGlobalFlagsFromArgs( { debug: false, json: false, agent: false }, - { "ignore-ssl-errors": true }, + { 'ignore-ssl-errors': true } ); expect(merged.ignoreSslErrors).toBe(true); }); - test("leaves TLS verification untouched when the flag is absent", () => { - const merged = mergeGlobalFlagsFromArgs({ debug: false, json: false, agent: false }, {}); + test('leaves TLS verification untouched when the flag is absent', () => { + const merged = mergeGlobalFlagsFromArgs( + { debug: false, json: false, agent: false }, + {} + ); expect(merged.ignoreSslErrors).toBeUndefined(); }); }); -describe("agent output preset", () => { - test("isJsonOutput is true when agent is set", () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: true }); +describe('agent output preset', () => { + test('isJsonOutput is true when agent is set', () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: true, + }); runWithCliRuntime(runtime, () => { expect(isJsonOutput()).toBe(true); }); }); - test("runtime sink treats agent as JSON-capable", () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: true }); + test('runtime sink treats agent as JSON-capable', () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: true, + }); expect(runtime.output.json).toBe(true); }); - test("agent mode uses JSON error envelopes", () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: true }); + test('agent mode uses JSON error envelopes', () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: true, + }); runWithCliRuntime(runtime, () => { - const envelope = renderCliErrorJson(new CliError("agent failure")); + const envelope = renderCliErrorJson(new CliError('agent failure')); expect(envelope).toContain('"error"'); - expect(envelope).toContain("agent failure"); + expect(envelope).toContain('agent failure'); }); }); - test("refreshCliRuntimeContext enables sink json for --agent", () => { - setCliRuntime(createCliRuntime({ debug: false, json: false, agent: false })); - const context = parseGlobalOutputFlags(["--agent", "context"]); + test('refreshCliRuntimeContext enables sink json for --agent', () => { + setCliRuntime( + createCliRuntime({ debug: false, json: false, agent: false }) + ); + const context = parseGlobalOutputFlags(['--agent', 'context']); setCliContext(context); expect(isJsonOutput()).toBe(true); expect(getOutputSink().json).toBe(true); @@ -95,53 +112,74 @@ describe("agent output preset", () => { }); }); -describe("automatic agent mode", () => { +describe('automatic agent mode', () => { for (const marker of AGENT_ENV_VARIABLES) { test(`detects ${marker} only outside a terminal`, () => { - const env = { [marker]: "1" }; - expect(parseGlobalOutputFlags([], { env, stdoutIsTTY: false }).agent).toBe(true); - expect(parseGlobalOutputFlags([], { env, stdoutIsTTY: true }).agent).toBe(false); + const env = { [marker]: '1' }; + expect( + parseGlobalOutputFlags([], { env, stdoutIsTTY: false }).agent + ).toBe(true); + expect(parseGlobalOutputFlags([], { env, stdoutIsTTY: true }).agent).toBe( + false + ); }); } - test("leaves ordinary pipes and empty markers alone", () => { - expect(parseGlobalOutputFlags([], { env: {}, stdoutIsTTY: false }).agent).toBe(false); - expect(parseGlobalOutputFlags([], { env: { AI_AGENT: "" }, stdoutIsTTY: false }).agent).toBe( - false, - ); + test('leaves ordinary pipes and empty markers alone', () => { + expect( + parseGlobalOutputFlags([], { env: {}, stdoutIsTTY: false }).agent + ).toBe(false); + expect( + parseGlobalOutputFlags([], { env: { AI_AGENT: '' }, stdoutIsTTY: false }) + .agent + ).toBe(false); }); - test("preserves explicit output formats", () => { - for (const flags of [["--json"], ["--format", "csv"], ["--format=markdown"]]) { + test('preserves explicit output formats', () => { + for (const flags of [ + ['--json'], + ['--format', 'csv'], + ['--format=markdown'], + ]) { expect( - parseGlobalOutputFlags(flags, { env: { AI_AGENT: "1" }, stdoutIsTTY: false }).agent, + parseGlobalOutputFlags(flags, { + env: { AI_AGENT: '1' }, + stdoutIsTTY: false, + }).agent ).toBe(false); } }); - test("applies flags before environment overrides and detection", () => { - for (const value of ["1", "true", "0", "false"]) { + test('applies flags before environment overrides and detection', () => { + for (const value of ['1', 'true', '0', 'false']) { const environment = { - env: { AI_AGENT: "1", ALTERTABLE_AGENT_MODE: value }, + env: { AI_AGENT: '1', ALTERTABLE_AGENT_MODE: value }, stdoutIsTTY: true, }; - expect(parseGlobalOutputFlags([], environment).agent).toBe(value === "1" || value === "true"); - expect(parseGlobalOutputFlags(["--agent"], environment).agent).toBe(true); - expect(parseGlobalOutputFlags(["--agent", "--no-agent"], environment).agent).toBe(false); + expect(parseGlobalOutputFlags([], environment).agent).toBe( + value === '1' || value === 'true' + ); + expect(parseGlobalOutputFlags(['--agent'], environment).agent).toBe(true); + expect( + parseGlobalOutputFlags(['--agent', '--no-agent'], environment).agent + ).toBe(false); } }); - test("ignores flags after the option separator", () => { + test('ignores flags after the option separator', () => { expect( - parseGlobalOutputFlags(["--", "--no-agent"], { env: { AI_AGENT: "1" }, stdoutIsTTY: false }) - .agent, + parseGlobalOutputFlags(['--', '--no-agent'], { + env: { AI_AGENT: '1' }, + stdoutIsTTY: false, + }).agent ).toBe(true); }); - test("preserves detection when merging parsed flags", () => { - expect(mergeGlobalFlagsFromArgs({ debug: false, json: false, agent: true }, {}).agent).toBe( - true, - ); + test('preserves detection when merging parsed flags', () => { + expect( + mergeGlobalFlagsFromArgs({ debug: false, json: false, agent: true }, {}) + .agent + ).toBe(true); expect( mergeGlobalFlagsFromArgs( { debug: false, json: false, agent: true }, - { agent: true, "no-agent": true }, - ).agent, + { agent: true, 'no-agent': true } + ).agent ).toBe(false); }); }); diff --git a/cli/src/lib/global-flags.ts b/cli/src/lib/global-flags.ts index 6941b94b..09273991 100644 --- a/cli/src/lib/global-flags.ts +++ b/cli/src/lib/global-flags.ts @@ -1,20 +1,20 @@ -import { copyProcessEnv } from "@/lib/env.ts"; -import type { CliContext } from "@/context.ts"; -import { asCliArgString } from "@/lib/cli-args.ts"; -import { parseTimeoutSeconds } from "@/lib/timeout-args.ts"; +import { copyProcessEnv } from '@/lib/env.ts'; +import type { CliContext } from '@/context.ts'; +import { asCliArgString } from '@/lib/cli-args.ts'; +import { parseTimeoutSeconds } from '@/lib/timeout-args.ts'; export const AGENT_ENV_VARIABLES = [ - "AI_AGENT", - "AGENT", - "CLAUDECODE", - "CODEX_CI", - "CODEX_SANDBOX", - "CODEX_THREAD_ID", - "CURSOR_AGENT", - "GEMINI_CLI", - "COPILOT_AGENT", - "COPILOT_CLI", - "COPILOT_AGENT_SESSION_ID", + 'AI_AGENT', + 'AGENT', + 'CLAUDECODE', + 'CODEX_CI', + 'CODEX_SANDBOX', + 'CODEX_THREAD_ID', + 'CURSOR_AGENT', + 'GEMINI_CLI', + 'COPILOT_AGENT', + 'COPILOT_CLI', + 'COPILOT_AGENT_SESSION_ID', ] as const; type AgentEnvironment = { @@ -24,18 +24,22 @@ type AgentEnvironment = { function resolveAgentMode( argv: readonly string[], - { env, stdoutIsTTY }: AgentEnvironment, + { env, stdoutIsTTY }: AgentEnvironment ): boolean { - if (argv.includes("--no-agent")) return false; - if (argv.includes("--agent")) return true; + if (argv.includes('--no-agent')) return false; + if (argv.includes('--agent')) return true; // Keep early output selection non-throwing; validateEnvironment reports invalid values later. const override = env.ALTERTABLE_AGENT_MODE?.toLowerCase(); - if (override === "1" || override === "true") return true; - if (override === "0" || override === "false") return false; + if (override === '1' || override === 'true') return true; + if (override === '0' || override === 'false') return false; const explicitFormat = argv.some( - (arg) => arg === "--json" || arg === "--format" || arg.startsWith("--format="), + arg => arg === '--json' || arg === '--format' || arg.startsWith('--format=') + ); + return ( + !stdoutIsTTY && + !explicitFormat && + AGENT_ENV_VARIABLES.some(name => Boolean(env[name])) ); - return !stdoutIsTTY && !explicitFormat && AGENT_ENV_VARIABLES.some((name) => Boolean(env[name])); } export function parseGlobalOutputFlags( @@ -43,39 +47,47 @@ export function parseGlobalOutputFlags( environment: AgentEnvironment = { env: copyProcessEnv(), stdoutIsTTY: Boolean(process.stdout.isTTY), - }, + } ): CliContext { - const separatorIndex = argv.indexOf("--"); - const globalArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex); + const separatorIndex = argv.indexOf('--'); + const globalArgs = + separatorIndex === -1 ? argv : argv.slice(0, separatorIndex); return { - debug: globalArgs.includes("--debug") || globalArgs.includes("-d"), - json: globalArgs.includes("--json"), + debug: globalArgs.includes('--debug') || globalArgs.includes('-d'), + json: globalArgs.includes('--json'), agent: resolveAgentMode(globalArgs, environment), - noColor: globalArgs.includes("--no-color"), + noColor: globalArgs.includes('--no-color'), }; } export function mergeGlobalFlagsFromArgs( context: CliContext, - args: Record, + args: Record ): CliContext { const merged = { ...context }; if (args.debug !== undefined) merged.debug = Boolean(args.debug); if (args.json !== undefined) merged.json = Boolean(args.json); if (args.agent !== undefined) merged.agent = Boolean(args.agent); - if (args["no-agent"]) merged.agent = false; - if (args["no-color"] !== undefined) merged.noColor = Boolean(args["no-color"]); + if (args['no-agent']) merged.agent = false; + if (args['no-color'] !== undefined) + merged.noColor = Boolean(args['no-color']); const profile = asCliArgString(args.profile); if (profile.length > 0) merged.profile = profile; - if (args["connect-timeout"] !== undefined) { - merged.connectTimeoutMs = parseTimeoutSeconds(args["connect-timeout"], "--connect-timeout"); + if (args['connect-timeout'] !== undefined) { + merged.connectTimeoutMs = parseTimeoutSeconds( + args['connect-timeout'], + '--connect-timeout' + ); } - if (args["read-timeout"] !== undefined) { - merged.readTimeoutMs = parseTimeoutSeconds(args["read-timeout"], "--read-timeout"); + if (args['read-timeout'] !== undefined) { + merged.readTimeoutMs = parseTimeoutSeconds( + args['read-timeout'], + '--read-timeout' + ); } - if (args["ignore-ssl-errors"] !== undefined) { - merged.ignoreSslErrors = Boolean(args["ignore-ssl-errors"]); + if (args['ignore-ssl-errors'] !== undefined) { + merged.ignoreSslErrors = Boolean(args['ignore-ssl-errors']); } return merged; } diff --git a/cli/src/lib/http-headers.test.ts b/cli/src/lib/http-headers.test.ts index 39fac724..0bd43a81 100644 --- a/cli/src/lib/http-headers.test.ts +++ b/cli/src/lib/http-headers.test.ts @@ -1,26 +1,26 @@ -import { describe, expect, test } from "bun:test"; -import { buildRequestHeaders } from "@/lib/http.ts"; +import { describe, expect, test } from 'bun:test'; +import { buildRequestHeaders } from '@/lib/http.ts'; -describe("buildRequestHeaders", () => { - test("omits Authorization when authHeader is empty", () => { +describe('buildRequestHeaders', () => { + test('omits Authorization when authHeader is empty', () => { const headers = buildRequestHeaders({ - method: "POST", - url: "http://x/oauth/token", - authHeader: "", - body: "a=1", - contentType: "application/x-www-form-urlencoded", + method: 'POST', + url: 'http://x/oauth/token', + authHeader: '', + body: 'a=1', + contentType: 'application/x-www-form-urlencoded', }); expect(headers.Authorization).toBeUndefined(); - expect(headers[""]).toBeUndefined(); - expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(headers['']).toBeUndefined(); + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded'); }); - test("includes Authorization when present", () => { + test('includes Authorization when present', () => { const headers = buildRequestHeaders({ - method: "GET", - url: "http://x", - authHeader: "Authorization: Bearer t", + method: 'GET', + url: 'http://x', + authHeader: 'Authorization: Bearer t', }); - expect(headers.Authorization).toBe("Bearer t"); + expect(headers.Authorization).toBe('Bearer t'); }); }); diff --git a/cli/src/lib/http-request.test.ts b/cli/src/lib/http-request.test.ts index 88eb723d..d7cfd717 100644 --- a/cli/src/lib/http-request.test.ts +++ b/cli/src/lib/http-request.test.ts @@ -1,23 +1,23 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { basicAuthToken } from "@/lib/auth.ts"; -import { configGet, configSet } from "@/lib/config.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; -import { provisionLakehouseCredential } from "@/lib/lakehouse-provision.ts"; -import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; -import { createCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { basicAuthToken } from '@/lib/auth.ts'; +import { configGet, configSet } from '@/lib/config.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; +import { provisionLakehouseCredential } from '@/lib/lakehouse-provision.ts'; +import { FROM_ENV_PSEUDOPROFILE_NAME } from '@/lib/profile-store.ts'; +import { createCliRuntime, type CliRuntime } from '@/lib/runtime.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -let testHome = ""; +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-http-request-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-http-request-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; }); afterEach(() => { @@ -30,187 +30,223 @@ afterEach(() => { }); function lakehouseQueryRequest(): HttpRequest { - return { plane: "lakehouse", method: "POST", endpoint: "/query", body: "{}" }; + return { plane: 'lakehouse', method: 'POST', endpoint: '/query', body: '{}' }; } -function createTestExecution(profile: string): { runtime: CliRuntime; context: ExecutionContext } { +function createTestExecution(profile: string): { + runtime: CliRuntime; + context: ExecutionContext; +} { const runtime = createCliRuntime({ debug: false, json: true, agent: false }); runtime.output.writeMetadata = () => {}; - return { runtime, context: { cli: runtime.context, output: runtime.output, profile } }; + return { + runtime, + context: { cli: runtime.context, output: runtime.output, profile }, + }; } -describe("lakehouse credential provisioning", () => { - test("refuses to mint credentials in env configuration mode", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "production"; - const { runtime, context } = createTestExecution(FROM_ENV_PSEUDOPROFILE_NAME); +describe('lakehouse credential provisioning', () => { + test('refuses to mint credentials in env configuration mode', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'production'; + const { runtime, context } = createTestExecution( + FROM_ENV_PSEUDOPROFILE_NAME + ); expect( - runWithCliRuntime(runtime, () => sendHttp(lakehouseQueryRequest(), context)), + runWithCliRuntime(runtime, () => + sendHttp(lakehouseQueryRequest(), context) + ) ).rejects.toThrow( - "Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.", + 'Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.' ); }); - test("read-only diagnostics report missing credentials without provisioning", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "production"; - const { runtime, context } = createTestExecution(FROM_ENV_PSEUDOPROFILE_NAME); + test('read-only diagnostics report missing credentials without provisioning', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'production'; + const { runtime, context } = createTestExecution( + FROM_ENV_PSEUDOPROFILE_NAME + ); expect( runWithCliRuntime(runtime, () => - sendHttp({ ...lakehouseQueryRequest(), authRecovery: false }, context), - ), - ).rejects.toThrow("No lakehouse credentials in the environment configuration."); + sendHttp({ ...lakehouseQueryRequest(), authRecovery: false }, context) + ) + ).rejects.toThrow( + 'No lakehouse credentials in the environment configuration.' + ); }); - test("mints and stores a credential for stored profiles", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); + test('mints and stores a credential for stored profiles', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); - const mockFile = join(testHome, "mocks.json"); + const mockFile = join(testHome, 'mocks.json'); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", - body: JSON.stringify({ principal: { id: "user-1", type: "User" } }), + urlPattern: '/whoami', + method: 'GET', + body: JSON.stringify({ principal: { id: 'user-1', type: 'User' } }), }, { - urlPattern: "/users/user-1/environments/production/credentials", - method: "POST", + urlPattern: '/users/user-1/environments/production/credentials', + method: 'POST', body: JSON.stringify({ - credential: { username: "prov-user", expires_at: expiresAt }, - password: "prov-pass", + credential: { username: 'prov-user', expires_at: expiresAt }, + password: 'prov-pass', }), }, - { urlPattern: "/query", method: "POST", body: '{"ok":true}' }, - ]), + { urlPattern: '/query', method: 'POST', body: '{"ok":true}' }, + ]) ); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - const { runtime, context } = createTestExecution("default"); + const { runtime, context } = createTestExecution('default'); - const body = await runWithCliRuntime(runtime, () => sendHttp(lakehouseQueryRequest(), context)); + const body = await runWithCliRuntime(runtime, () => + sendHttp(lakehouseQueryRequest(), context) + ); expect(body).toBe('{"ok":true}'); - expect(secretGet("lakehouse/basic-token", "default")).toBe( - basicAuthToken("prov-user", "prov-pass"), + expect(secretGet('lakehouse/basic-token', 'default')).toBe( + basicAuthToken('prov-user', 'prov-pass') + ); + expect(Number(configGet('lakehouse_credential_expiry', 'default'))).toBe( + Date.parse(expiresAt) ); - expect(Number(configGet("lakehouse_credential_expiry", "default"))).toBe(Date.parse(expiresAt)); }); - test("renews a credential before expiry and retries a revoked credential once", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); - secretSet("lakehouse/basic-token", basicAuthToken("old-user", "old-pass"), "default"); - configSet("lakehouse_credential_expiry", String(Date.now() + 4 * 60_000), "default"); + test('renews a credential before expiry and retries a revoked credential once', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); + secretSet( + 'lakehouse/basic-token', + basicAuthToken('old-user', 'old-pass'), + 'default' + ); + configSet( + 'lakehouse_credential_expiry', + String(Date.now() + 4 * 60_000), + 'default' + ); const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); - const mockFile = join(testHome, "renewal-mocks.json"); + const mockFile = join(testHome, 'renewal-mocks.json'); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", - body: JSON.stringify({ principal: { id: "user-1", type: "User" } }), + urlPattern: '/whoami', + method: 'GET', + body: JSON.stringify({ principal: { id: 'user-1', type: 'User' } }), }, { - urlPattern: "/users/user-1/environments/production/credentials", - method: "POST", + urlPattern: '/users/user-1/environments/production/credentials', + method: 'POST', body: JSON.stringify({ - credential: { username: "new-user", expires_at: expiresAt }, - password: "new-pass", + credential: { username: 'new-user', expires_at: expiresAt }, + password: 'new-pass', }), }, { - urlPattern: "/query", - method: "POST", - authPattern: basicAuthToken("new-user", "new-pass"), + urlPattern: '/query', + method: 'POST', + authPattern: basicAuthToken('new-user', 'new-pass'), body: '{"ok":true}', }, - ]), + ]) ); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - const { runtime, context } = createTestExecution("default"); + const { runtime, context } = createTestExecution('default'); const result = await runWithCliRuntime(runtime, () => - sendHttp(lakehouseQueryRequest(), context), + sendHttp(lakehouseQueryRequest(), context) ); expect(result).toBe('{"ok":true}'); - expect(secretGet("lakehouse/basic-token", "default")).toBe( - basicAuthToken("new-user", "new-pass"), + expect(secretGet('lakehouse/basic-token', 'default')).toBe( + basicAuthToken('new-user', 'new-pass') ); - secretSet("lakehouse/basic-token", basicAuthToken("revoked-user", "revoked-pass"), "default"); - configSet("lakehouse_credential_expiry", String(Date.now() + 3_600_000), "default"); + secretSet( + 'lakehouse/basic-token', + basicAuthToken('revoked-user', 'revoked-pass'), + 'default' + ); + configSet( + 'lakehouse_credential_expiry', + String(Date.now() + 3_600_000), + 'default' + ); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", - authPattern: basicAuthToken("revoked-user", "revoked-pass"), + urlPattern: '/query', + method: 'POST', + authPattern: basicAuthToken('revoked-user', 'revoked-pass'), status: 401, - body: "rejected", + body: 'rejected', }, { - urlPattern: "/query", - method: "POST", - authPattern: basicAuthToken("new-user", "new-pass"), + urlPattern: '/query', + method: 'POST', + authPattern: basicAuthToken('new-user', 'new-pass'), body: '{"ok":true}', }, { - urlPattern: "/whoami", - method: "GET", - body: JSON.stringify({ principal: { id: "user-1", type: "User" } }), + urlPattern: '/whoami', + method: 'GET', + body: JSON.stringify({ principal: { id: 'user-1', type: 'User' } }), }, { - urlPattern: "/users/user-1/environments/production/credentials", - method: "POST", + urlPattern: '/users/user-1/environments/production/credentials', + method: 'POST', body: JSON.stringify({ - credential: { username: "new-user", expires_at: expiresAt }, - password: "new-pass", + credential: { username: 'new-user', expires_at: expiresAt }, + password: 'new-pass', }), }, - ]), - ); - expect(await runWithCliRuntime(runtime, () => sendHttp(lakehouseQueryRequest(), context))).toBe( - '{"ok":true}', + ]) ); + expect( + await runWithCliRuntime(runtime, () => + sendHttp(lakehouseQueryRequest(), context) + ) + ).toBe('{"ok":true}'); }); - test("shares an in-flight credential renewal for the same profile", async () => { - configSet("api_key_env", "production", "default"); - secretSet("api-key", "atm_stored", "default"); - const mockFile = join(testHome, "concurrent-mocks.json"); + test('shares an in-flight credential renewal for the same profile', async () => { + configSet('api_key_env', 'production', 'default'); + secretSet('api-key', 'atm_stored', 'default'); + const mockFile = join(testHome, 'concurrent-mocks.json'); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", - body: JSON.stringify({ principal: { id: "user-1", type: "User" } }), + urlPattern: '/whoami', + method: 'GET', + body: JSON.stringify({ principal: { id: 'user-1', type: 'User' } }), }, { - urlPattern: "/users/user-1/environments/production/credentials", - method: "POST", + urlPattern: '/users/user-1/environments/production/credentials', + method: 'POST', body: JSON.stringify({ credential: { - username: "new-user", + username: 'new-user', expires_at: new Date(Date.now() + 3_600_000).toISOString(), }, - password: "new-pass", + password: 'new-pass', }), }, - ]), + ]) ); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - const { runtime, context } = createTestExecution("default"); + const { runtime, context } = createTestExecution('default'); await runWithCliRuntime(runtime, async () => { const first = provisionLakehouseCredential(context); const second = provisionLakehouseCredential(context); expect(second).toBe(first); - expect(await first).toContain("Basic"); + expect(await first).toContain('Basic'); }); }); }); diff --git a/cli/src/lib/http-request.ts b/cli/src/lib/http-request.ts index ba0ddbc1..7879dfd8 100644 --- a/cli/src/lib/http-request.ts +++ b/cli/src/lib/http-request.ts @@ -1,15 +1,21 @@ -import { HttpError, type AuthPlane } from "@/lib/errors.ts"; -import { httpSend, httpSendStream, type HttpSendOptions } from "@/lib/http.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { optionalAuth, type ExecutionContext } from "@/lib/execution-context.ts"; -import { getLakehouseAuthHeader, getManagementAuthHeader } from "@/lib/auth.ts"; -import { resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; -import { ensureFreshAccessToken, hasOAuthSession } from "@/lib/oauth-profile.ts"; +import { HttpError, type AuthPlane } from '@/lib/errors.ts'; +import { httpSend, httpSendStream, type HttpSendOptions } from '@/lib/http.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { + optionalAuth, + type ExecutionContext, +} from '@/lib/execution-context.ts'; +import { getLakehouseAuthHeader, getManagementAuthHeader } from '@/lib/auth.ts'; +import { resolveApiBase, resolveManagementApiBase } from '@/lib/config.ts'; +import { + ensureFreshAccessToken, + hasOAuthSession, +} from '@/lib/oauth-profile.ts'; import { canRecoverLakehouseAuth, hasManagementCredentials, provisionLakehouseCredential, -} from "@/lib/lakehouse-provision.ts"; +} from '@/lib/lakehouse-provision.ts'; type PlaneUrlBuilder = (endpoint: string, context: ExecutionContext) => string; @@ -32,20 +38,24 @@ export type HttpRequest = { }; const PLANE_URL_BUILDERS = { - lakehouse: (endpoint, context) => `${resolveApiBase(context.profile)}${endpoint}`, + lakehouse: (endpoint, context) => + `${resolveApiBase(context.profile)}${endpoint}`, management: (endpoint, context) => `${resolveManagementApiBase(context.profile)}${encodeManagementEndpoint(endpoint)}`, } satisfies Record; -function resolvePlaneUrl(request: HttpRequest, context: ExecutionContext): string { +function resolvePlaneUrl( + request: HttpRequest, + context: ExecutionContext +): string { return PLANE_URL_BUILDERS[request.plane](request.endpoint, context); } async function resolveRequestAuthHeader( request: HttpRequest, - context: ExecutionContext, + context: ExecutionContext ): Promise { - if (request.plane === "management") { + if (request.plane === 'management') { if (request.authRecovery !== false && hasOAuthSession(context.profile)) { await ensureFreshAccessToken(context.profile); } @@ -55,7 +65,10 @@ async function resolveRequestAuthHeader( if (existing) { return existing; } - if (request.authRecovery !== false && hasManagementCredentials(context.profile)) { + if ( + request.authRecovery !== false && + hasManagementCredentials(context.profile) + ) { // In env config mode this throws instead of minting a credential. return provisionLakehouseCredential(context); } @@ -64,7 +77,7 @@ async function resolveRequestAuthHeader( async function toHttpSendOptions( request: HttpRequest, - context: ExecutionContext, + context: ExecutionContext ): Promise { return { method: request.method, @@ -84,12 +97,12 @@ async function toHttpSendOptions( function isRecoverableLakehouseAuthFailure( request: HttpRequest, error: unknown, - profileName: string, + profileName: string ): boolean { return ( error instanceof HttpError && error.status === 401 && - request.plane === "lakehouse" && + request.plane === 'lakehouse' && request.authRecovery !== false && // A stream body was consumed by the failed attempt and cannot be resent. !(request.body instanceof ReadableStream) && @@ -100,7 +113,7 @@ function isRecoverableLakehouseAuthFailure( async function sendWithAuthRecovery( request: HttpRequest, context: ExecutionContext, - send: (options: HttpSendOptions) => Promise, + send: (options: HttpSendOptions) => Promise ): Promise { const options = await toHttpSendOptions(request, context); try { @@ -120,13 +133,16 @@ async function sendWithAuthRecovery( } } -export async function sendHttp(request: HttpRequest, context: ExecutionContext): Promise { +export async function sendHttp( + request: HttpRequest, + context: ExecutionContext +): Promise { return sendWithAuthRecovery(request, context, httpSend); } export async function sendHttpStream( request: HttpRequest, - context: ExecutionContext, + context: ExecutionContext ): Promise> { return sendWithAuthRecovery(request, context, httpSendStream); } diff --git a/cli/src/lib/http-retry.ts b/cli/src/lib/http-retry.ts index 620f08c5..421966b2 100644 --- a/cli/src/lib/http-retry.ts +++ b/cli/src/lib/http-retry.ts @@ -1,4 +1,4 @@ -import { sleep } from "@/lib/sleep.ts"; +import { sleep } from '@/lib/sleep.ts'; export const MAX_RETRY_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; @@ -6,10 +6,16 @@ export const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); export function isRetryableMethod(method: string): boolean { const normalizedMethod = method.toUpperCase(); - return normalizedMethod === "GET" || normalizedMethod === "HEAD" || normalizedMethod === "DELETE"; + return ( + normalizedMethod === 'GET' || + normalizedMethod === 'HEAD' || + normalizedMethod === 'DELETE' + ); } -export function parseRetryAfterMs(headerValue: string | null): number | undefined { +export function parseRetryAfterMs( + headerValue: string | null +): number | undefined { if (!headerValue) { return undefined; } @@ -29,7 +35,10 @@ export function parseRetryAfterMs(headerValue: string | null): number | undefine return undefined; } -export function computeRetryDelayMs(attemptIndex: number, retryAfterHeader: string | null): number { +export function computeRetryDelayMs( + attemptIndex: number, + retryAfterHeader: string | null +): number { const retryAfterMs = parseRetryAfterMs(retryAfterHeader); if (retryAfterMs !== undefined) { return retryAfterMs; @@ -47,7 +56,7 @@ export function shouldRetryHttpRequest( options: HttpRetryOptions, status: number, attemptIndex: number, - maxAttempts: number, + maxAttempts: number ): boolean { if (attemptIndex >= maxAttempts - 1) { return false; @@ -66,7 +75,7 @@ export function shouldRetryHttpRequest( export async function delayBeforeHttpRetry( attemptIndex: number, - retryAfterHeader: string | null, + retryAfterHeader: string | null ): Promise { const delayMs = computeRetryDelayMs(attemptIndex, retryAfterHeader); await sleep(delayMs); diff --git a/cli/src/lib/http.test.ts b/cli/src/lib/http.test.ts index 3a1b40bb..06a05b21 100644 --- a/cli/src/lib/http.test.ts +++ b/cli/src/lib/http.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { CliError, HttpError, NetworkError, TimeoutError } from "@/lib/errors.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setCliContext } from '@/context.ts'; +import { + CliError, + HttpError, + NetworkError, + TimeoutError, +} from '@/lib/errors.ts'; import { buildFetchInit, getSharedDispatcher, @@ -14,22 +19,26 @@ import { resolveFetchTimeoutMs, resolveReadTimeoutMs, shouldIgnoreSslErrors, -} from "@/lib/http.ts"; -import { computeRetryDelayMs, isRetryableMethod, parseRetryAfterMs } from "@/lib/http-retry.ts"; -import { redactResponseBodyForDebug } from "@/lib/redact.ts"; -import { delay } from "@/test-utils/time.ts"; +} from '@/lib/http.ts'; +import { + computeRetryDelayMs, + isRetryableMethod, + parseRetryAfterMs, +} from '@/lib/http-retry.ts'; +import { redactResponseBodyForDebug } from '@/lib/redact.ts'; +import { delay } from '@/test-utils/time.ts'; -const fakeBearerKey = "atm_fake_test_key_for_redaction"; -const fakeBasicToken = "fake_basic_token_value"; +const fakeBearerKey = 'atm_fake_test_key_for_redaction'; +const fakeBasicToken = 'fake_basic_token_value'; -let testHome = ""; -let mockFile = ""; -let logFile = ""; +let testHome = ''; +let mockFile = ''; +let logFile = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-http-test-")); - mockFile = join(testHome, "mocks.json"); - logFile = join(testHome, "http.log"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-http-test-')); + mockFile = join(testHome, 'mocks.json'); + logFile = join(testHome, 'http.log'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; setCliContext({ debug: false, json: false, agent: false }); @@ -41,222 +50,228 @@ afterEach(() => { delete process.env.ALTERTABLE_HTTP_LOG; }); -describe("http redaction", () => { - test("redactHeaderValue redacts Authorization", () => { - expect(redactHeaderValue("Authorization", `Bearer ${fakeBearerKey}`)).toBe("[REDACTED]"); - expect(redactHeaderValue("authorization", fakeBasicToken)).toBe("[REDACTED]"); - expect(redactHeaderValue("Content-Type", "application/json")).toBe("application/json"); +describe('http redaction', () => { + test('redactHeaderValue redacts Authorization', () => { + expect(redactHeaderValue('Authorization', `Bearer ${fakeBearerKey}`)).toBe( + '[REDACTED]' + ); + expect(redactHeaderValue('authorization', fakeBasicToken)).toBe( + '[REDACTED]' + ); + expect(redactHeaderValue('Content-Type', 'application/json')).toBe( + 'application/json' + ); }); - test("redactAuthHeader redacts bearer and basic auth headers", () => { + test('redactAuthHeader redacts bearer and basic auth headers', () => { expect(redactAuthHeader(`Authorization: Bearer ${fakeBearerKey}`)).toBe( - "Authorization: [REDACTED]", + 'Authorization: [REDACTED]' ); expect(redactAuthHeader(`Authorization: Basic ${fakeBasicToken}`)).toBe( - "Authorization: [REDACTED]", + 'Authorization: [REDACTED]' ); }); - test("logHttpRequest redacts auth values in ALTERTABLE_HTTP_LOG", async () => { + test('logHttpRequest redacts auth values in ALTERTABLE_HTTP_LOG', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/test-redact", - method: "GET", - body: "{}", + urlPattern: '/test-redact', + method: 'GET', + body: '{}', }, - ]), + ]) ); await httpSend({ - method: "GET", - url: "https://example.com/test-redact", + method: 'GET', + url: 'https://example.com/test-redact', authHeader: `Authorization: Bearer ${fakeBearerKey}`, }); - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("AUTH=Authorization: [REDACTED]"); + const logContent = readFileSync(logFile, 'utf8'); + expect(logContent).toContain('AUTH=Authorization: [REDACTED]'); expect(logContent).not.toContain(fakeBearerKey); expect(logContent).not.toContain(fakeBasicToken); }); - test("logHttpRequest redacts sensitive fields from JSON payloads", async () => { - const secretPassword = "super-secret-post-password"; + test('logHttpRequest redacts sensitive fields from JSON payloads', async () => { + const secretPassword = 'super-secret-post-password'; writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/post-secret", - method: "POST", - body: "{}", + urlPattern: '/post-secret', + method: 'POST', + body: '{}', }, - ]), + ]) ); await httpSend({ - method: "POST", - url: "https://example.com/post-secret", - authHeader: "Authorization: Bearer test", - body: JSON.stringify({ password: secretPassword, label: "credential" }), + method: 'POST', + url: 'https://example.com/post-secret', + authHeader: 'Authorization: Bearer test', + body: JSON.stringify({ password: secretPassword, label: 'credential' }), }); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); expect(logContent).not.toContain(secretPassword); - expect(logContent).toContain("[REDACTED]"); + expect(logContent).toContain('[REDACTED]'); }); }); -describe("httpSend errors", () => { - test("missing mock response rejects with CliError", async () => { +describe('httpSend errors', () => { + test('missing mock response rejects with CliError', async () => { writeFileSync(mockFile, JSON.stringify([])); return expect( httpSend({ - method: "GET", - url: "https://example.com/missing", - authHeader: "Authorization: Bearer test", - }), + method: 'GET', + url: 'https://example.com/missing', + authHeader: 'Authorization: Bearer test', + }) ).rejects.toBeInstanceOf(CliError); }); - test("non-2xx mock response rejects with HttpError", async () => { + test('non-2xx mock response rejects with HttpError', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/server-error", - method: "GET", + urlPattern: '/server-error', + method: 'GET', status: 500, - body: JSON.stringify({ message: "internal failure" }), + body: JSON.stringify({ message: 'internal failure' }), }, - ]), + ]) ); try { await httpSend({ - method: "GET", - url: "https://example.com/server-error", - authHeader: "Authorization: Bearer test", + method: 'GET', + url: 'https://example.com/server-error', + authHeader: 'Authorization: Bearer test', retry: false, }); - expect.unreachable("httpSend should have rejected"); + expect.unreachable('httpSend should have rejected'); } catch (error) { expect(error).toBeInstanceOf(HttpError); const httpError = error as HttpError; expect(httpError.status).toBe(500); - expect(httpError.parsedDetail).toBe("internal failure"); + expect(httpError.parsedDetail).toBe('internal failure'); } }); }); -describe("http retry policy", () => { - test("isRetryableMethod allows GET HEAD DELETE only", () => { - expect(isRetryableMethod("GET")).toBe(true); - expect(isRetryableMethod("HEAD")).toBe(true); - expect(isRetryableMethod("DELETE")).toBe(true); - expect(isRetryableMethod("POST")).toBe(false); - expect(isRetryableMethod("PUT")).toBe(false); +describe('http retry policy', () => { + test('isRetryableMethod allows GET HEAD DELETE only', () => { + expect(isRetryableMethod('GET')).toBe(true); + expect(isRetryableMethod('HEAD')).toBe(true); + expect(isRetryableMethod('DELETE')).toBe(true); + expect(isRetryableMethod('POST')).toBe(false); + expect(isRetryableMethod('PUT')).toBe(false); }); - test("parseRetryAfterMs parses delay seconds", () => { - expect(parseRetryAfterMs("2")).toBe(2_000); + test('parseRetryAfterMs parses delay seconds', () => { + expect(parseRetryAfterMs('2')).toBe(2_000); expect(parseRetryAfterMs(null)).toBeUndefined(); }); - test("computeRetryDelayMs uses exponential backoff", () => { + test('computeRetryDelayMs uses exponential backoff', () => { expect(computeRetryDelayMs(0, null)).toBe(500); expect(computeRetryDelayMs(1, null)).toBe(1_000); }); - test("GET retries retriable status codes until success", async () => { + test('GET retries retriable status codes until success', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/retry-get", - method: "GET", + urlPattern: '/retry-get', + method: 'GET', status: 503, - body: "temporary", + body: 'temporary', }, { - urlPattern: "/retry-get", - method: "GET", + urlPattern: '/retry-get', + method: 'GET', status: 503, - body: "temporary", + body: 'temporary', }, { - urlPattern: "/retry-get", - method: "GET", + urlPattern: '/retry-get', + method: 'GET', status: 200, - body: "ok", + body: 'ok', }, - ]), + ]) ); const response = await httpSend({ - method: "GET", - url: "https://example.com/retry-get", - authHeader: "Authorization: Bearer test", + method: 'GET', + url: 'https://example.com/retry-get', + authHeader: 'Authorization: Bearer test', }); - expect(response).toBe("ok"); + expect(response).toBe('ok'); }); - test("GET does not retry non-retriable 400 responses", async () => { + test('GET does not retry non-retriable 400 responses', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/bad-request", - method: "GET", + urlPattern: '/bad-request', + method: 'GET', status: 400, - body: JSON.stringify({ message: "bad input" }), + body: JSON.stringify({ message: 'bad input' }), }, { - urlPattern: "/bad-request", - method: "GET", + urlPattern: '/bad-request', + method: 'GET', status: 200, - body: "ok", + body: 'ok', }, - ]), + ]) ); return expect( httpSend({ - method: "GET", - url: "https://example.com/bad-request", - authHeader: "Authorization: Bearer test", - }), + method: 'GET', + url: 'https://example.com/bad-request', + authHeader: 'Authorization: Bearer test', + }) ).rejects.toBeInstanceOf(HttpError); }); - test("POST does not retry by default", async () => { + test('POST does not retry by default', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/post-mutation", - method: "POST", + urlPattern: '/post-mutation', + method: 'POST', status: 503, - body: "temporary", + body: 'temporary', }, { - urlPattern: "/post-mutation", - method: "POST", + urlPattern: '/post-mutation', + method: 'POST', status: 200, - body: "ok", + body: 'ok', }, - ]), + ]) ); try { await httpSend({ - method: "POST", - url: "https://example.com/post-mutation", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/post-mutation', + authHeader: 'Authorization: Bearer test', + body: '{}', }); - expect.unreachable("httpSend should have rejected"); + expect.unreachable('httpSend should have rejected'); } catch (error) { expect(error).toBeInstanceOf(HttpError); expect((error as HttpError).status).toBe(503); @@ -264,27 +279,27 @@ describe("http retry policy", () => { }); }); -describe("TimeoutError", () => { - test("TimeoutError extends NetworkError", () => { +describe('TimeoutError', () => { + test('TimeoutError extends NetworkError', () => { const error = new TimeoutError(); expect(error).toBeInstanceOf(TimeoutError); - expect(error.name).toBe("TimeoutError"); - expect(error.message).toBe("Request timed out."); + expect(error.name).toBe('TimeoutError'); + expect(error.message).toBe('Request timed out.'); }); }); -describe("network error context", () => { - test("includes the method and URL of the failed request", async () => { +describe('network error context', () => { + test('includes the method and URL of the failed request', async () => { // Bypass the mock harness so we hit the real fetch path against a closed port. delete process.env.ALTERTABLE_MOCK_HTTP_FILE; let caught: unknown; try { await httpSend({ - method: "POST", - url: "http://127.0.0.1:1/oauth/token", - authHeader: "", - body: "grant_type=authorization_code", - contentType: "application/x-www-form-urlencoded", + method: 'POST', + url: 'http://127.0.0.1:1/oauth/token', + authHeader: '', + body: 'grant_type=authorization_code', + contentType: 'application/x-www-form-urlencoded', maxAttempts: 1, connectTimeoutMs: 2000, }); @@ -292,58 +307,65 @@ describe("network error context", () => { caught = error; } expect(caught).toBeInstanceOf(NetworkError); - expect((caught as Error).message).toContain("POST http://127.0.0.1:1/oauth/token"); + expect((caught as Error).message).toContain( + 'POST http://127.0.0.1:1/oauth/token' + ); }); }); -describe("context timeout resolution", () => { - test("resolveFetchTimeoutMs uses context read timeout override", () => { - setCliContext({ debug: false, json: false, agent: false, readTimeoutMs: 30_000 }); +describe('context timeout resolution', () => { + test('resolveFetchTimeoutMs uses context read timeout override', () => { + setCliContext({ + debug: false, + json: false, + agent: false, + readTimeoutMs: 30_000, + }); expect( resolveFetchTimeoutMs({ - method: "GET", - url: "https://example.com", - authHeader: "Authorization: Bearer test", - }), + method: 'GET', + url: 'https://example.com', + authHeader: 'Authorization: Bearer test', + }) ).toBe(5_000 + 30_000); }); - test("stream read timeout 0 uses connect-only abort when context unset", () => { + test('stream read timeout 0 uses connect-only abort when context unset', () => { setCliContext({ debug: false, json: false, agent: false }); expect( resolveReadTimeoutMs( { - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', }, - 0, - ), + 0 + ) ).toBe(0); expect( resolveFetchTimeoutMs({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', readTimeoutMs: 0, - }), + }) ).toBe(5_000); }); }); -describe("shared dispatcher", () => { - test("getSharedDispatcher returns the same instance", () => { +describe('shared dispatcher', () => { + test('getSharedDispatcher returns the same instance', () => { const first = getSharedDispatcher(); const second = getSharedDispatcher(); expect(first).toBe(second); }); }); -describe("ignore ssl errors", () => { +describe('ignore ssl errors', () => { const request = { - method: "GET", - url: "https://app.altertable.test/rest/v1/whoami", - authHeader: "Authorization: Bearer test", + method: 'GET', + url: 'https://app.altertable.test/rest/v1/whoami', + authHeader: 'Authorization: Bearer test', }; afterEach(() => { @@ -351,7 +373,7 @@ describe("ignore ssl errors", () => { setCliContext({ debug: false, json: false, agent: false }); }); - test("verification stays on by default", () => { + test('verification stays on by default', () => { expect(shouldIgnoreSslErrors()).toBe(false); const init = buildFetchInit(request, {}, AbortSignal.timeout(1_000)) as { tls?: unknown; @@ -361,8 +383,13 @@ describe("ignore ssl errors", () => { expect(init.dispatcher).toBe(getSharedDispatcher()); }); - test("the global flag disables verification for Bun and undici transports", () => { - setCliContext({ debug: false, json: false, agent: false, ignoreSslErrors: true }); + test('the global flag disables verification for Bun and undici transports', () => { + setCliContext({ + debug: false, + json: false, + agent: false, + ignoreSslErrors: true, + }); expect(shouldIgnoreSslErrors()).toBe(true); const init = buildFetchInit(request, {}, AbortSignal.timeout(1_000)) as { tls?: { rejectUnauthorized: boolean }; @@ -372,36 +399,36 @@ describe("ignore ssl errors", () => { expect(init.dispatcher).not.toBe(getSharedDispatcher()); }); - test("ALTERTABLE_IGNORE_SSL_ERRORS disables verification without the flag", () => { - process.env.ALTERTABLE_IGNORE_SSL_ERRORS = "true"; + test('ALTERTABLE_IGNORE_SSL_ERRORS disables verification without the flag', () => { + process.env.ALTERTABLE_IGNORE_SSL_ERRORS = 'true'; expect(shouldIgnoreSslErrors()).toBe(true); }); }); -describe("httpSendStream mocks", () => { - test("chunked mock stream splits body by newline", async () => { +describe('httpSendStream mocks', () => { + test('chunked mock stream splits body by newline', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", + urlPattern: '/query', + method: 'POST', chunked: true, body: '{"statement":"SELECT 1"}\n["id"]\n[1]', }, - ]), + ]) ); const stream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', + body: '{}', }); const reader = stream.getReader(); const decoder = new TextDecoder(); - let combined = ""; + let combined = ''; while (true) { const { done, value } = await reader.read(); if (done) { @@ -414,39 +441,42 @@ describe("httpSendStream mocks", () => { }); }); -describe("httpSendStream live timeouts", () => { - test("readTimeoutMs 0 does not abort body after connect timeout once response is returned", async () => { +describe('httpSendStream live timeouts', () => { + test('readTimeoutMs 0 does not abort body after connect timeout once response is returned', async () => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; const originalFetch = globalThis.fetch; const encoder = new TextEncoder(); globalThis.fetch = Object.assign( - async (_url: Parameters[0], init?: Parameters[1]) => { + async ( + _url: Parameters[0], + init?: Parameters[1] + ) => { const signal = init?.signal; return new Response( new ReadableStream({ async start(controller) { await delay(20); if (signal?.aborted) { - controller.error(new Error("stream was aborted")); + controller.error(new Error('stream was aborted')); return; } - controller.enqueue(encoder.encode("ok")); + controller.enqueue(encoder.encode('ok')); controller.close(); }, }), - { status: 200 }, + { status: 200 } ); }, - { preconnect: originalFetch.preconnect }, + { preconnect: originalFetch.preconnect } ); try { const stream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', + body: '{}', connectTimeoutMs: 5, readTimeoutMs: 0, }); @@ -455,41 +485,46 @@ describe("httpSendStream live timeouts", () => { const { done, value } = await reader.read(); expect(done).toBe(false); - expect(new TextDecoder().decode(value)).toBe("ok"); + expect(new TextDecoder().decode(value)).toBe('ok'); } finally { globalThis.fetch = originalFetch; } }); - test("positive readTimeoutMs aborts when stream body takes too long", async () => { + test('positive readTimeoutMs aborts when stream body takes too long', async () => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; const originalFetch = globalThis.fetch; let abortObserved = false; globalThis.fetch = Object.assign( - async (_url: Parameters[0], init?: Parameters[1]) => { + async ( + _url: Parameters[0], + init?: Parameters[1] + ) => { const signal = init?.signal; return new Response( new ReadableStream({ start(controller) { - signal?.addEventListener("abort", () => { + signal?.addEventListener('abort', () => { abortObserved = true; - controller.error(new DOMException("stream timed out", "AbortError")); + controller.error( + new DOMException('stream timed out', 'AbortError') + ); }); }, }), - { status: 200 }, + { status: 200 } ); }, - { preconnect: originalFetch.preconnect }, + { preconnect: originalFetch.preconnect } ); try { const stream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', + body: '{}', connectTimeoutMs: 50, readTimeoutMs: 5, }); @@ -497,12 +532,12 @@ describe("httpSendStream live timeouts", () => { const reader = stream.getReader(); try { await reader.read(); - expect.unreachable("stream read should have timed out"); + expect.unreachable('stream read should have timed out'); } catch (error) { expect(error).toBeInstanceOf(TimeoutError); expect((error as TimeoutError).exitCode).toBe(9); expect((error as TimeoutError).message).toBe( - "Request timed out: POST https://example.com/query", + 'Request timed out: POST https://example.com/query' ); } expect(abortObserved).toBe(true); @@ -511,44 +546,49 @@ describe("httpSendStream live timeouts", () => { } }); - test("positive readTimeoutMs also aborts stalled error response bodies", async () => { + test('positive readTimeoutMs also aborts stalled error response bodies', async () => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; const originalFetch = globalThis.fetch; globalThis.fetch = Object.assign( - async (_url: Parameters[0], init?: Parameters[1]) => { + async ( + _url: Parameters[0], + init?: Parameters[1] + ) => { const signal = init?.signal; return new Response( new ReadableStream({ start(controller) { - signal?.addEventListener("abort", () => { - controller.error(new DOMException("error body timed out", "AbortError")); + signal?.addEventListener('abort', () => { + controller.error( + new DOMException('error body timed out', 'AbortError') + ); }); }, }), - { status: 500 }, + { status: 500 } ); }, - { preconnect: originalFetch.preconnect }, + { preconnect: originalFetch.preconnect } ); try { expect( httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', + body: '{}', connectTimeoutMs: 50, readTimeoutMs: 5, - }), + }) ).rejects.toBeInstanceOf(TimeoutError); } finally { globalThis.fetch = originalFetch; } }); - test("reports peer-aborted streams as network errors instead of timeouts", async () => { + test('reports peer-aborted streams as network errors instead of timeouts', async () => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; const originalFetch = globalThis.fetch; @@ -557,27 +597,27 @@ describe("httpSendStream live timeouts", () => { new Response( new ReadableStream({ start(controller) { - controller.error(new DOMException("peer aborted", "AbortError")); + controller.error(new DOMException('peer aborted', 'AbortError')); }, }), - { status: 200 }, + { status: 200 } ), - { preconnect: originalFetch.preconnect }, + { preconnect: originalFetch.preconnect } ); try { const stream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Bearer test", - body: "{}", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Bearer test', + body: '{}', connectTimeoutMs: 50, readTimeoutMs: 50, }); try { await stream.getReader().read(); - expect.unreachable("stream read should have failed"); + expect.unreachable('stream read should have failed'); } catch (error) { expect(error).toBeInstanceOf(NetworkError); expect(error).not.toBeInstanceOf(TimeoutError); @@ -588,57 +628,60 @@ describe("httpSendStream live timeouts", () => { }); }); -describe("debug response redaction", () => { - test("redactResponseBodyForDebug removes password fields from JSON", () => { - const body = JSON.stringify({ credential: { id: "c1" }, password: "leaked-secret" }); +describe('debug response redaction', () => { + test('redactResponseBodyForDebug removes password fields from JSON', () => { + const body = JSON.stringify({ + credential: { id: 'c1' }, + password: 'leaked-secret', + }); const redacted = redactResponseBodyForDebug(body); - expect(redacted).not.toContain("leaked-secret"); - expect(redacted).toContain("[REDACTED]"); + expect(redacted).not.toContain('leaked-secret'); + expect(redacted).toContain('[REDACTED]'); }); - test("redactResponseBodyForDebug redacts OAuth access and refresh tokens", () => { + test('redactResponseBodyForDebug redacts OAuth access and refresh tokens', () => { const body = JSON.stringify({ - access_token: "acc-leak", - refresh_token: "ref-leak", - token_type: "Bearer", + access_token: 'acc-leak', + refresh_token: 'ref-leak', + token_type: 'Bearer', }); const redacted = redactResponseBodyForDebug(body); - expect(redacted).not.toContain("acc-leak"); - expect(redacted).not.toContain("ref-leak"); - expect(redacted).toContain("[REDACTED]"); + expect(redacted).not.toContain('acc-leak'); + expect(redacted).not.toContain('ref-leak'); + expect(redacted).toContain('[REDACTED]'); }); }); -describe("retry-after header", () => { - test("honors Retry-After on retriable responses", async () => { +describe('retry-after header', () => { + test('honors Retry-After on retriable responses', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/retry-after", - method: "GET", + urlPattern: '/retry-after', + method: 'GET', status: 503, - body: "temporary", - retryAfter: "0", + body: 'temporary', + retryAfter: '0', }, { - urlPattern: "/retry-after", - method: "GET", + urlPattern: '/retry-after', + method: 'GET', status: 200, - body: "ok", + body: 'ok', }, - ]), + ]) ); const startMs = Date.now(); const response = await httpSend({ - method: "GET", - url: "https://example.com/retry-after", - authHeader: "Authorization: Bearer test", + method: 'GET', + url: 'https://example.com/retry-after', + authHeader: 'Authorization: Bearer test', }); const elapsedMs = Date.now() - startMs; - expect(response).toBe("ok"); + expect(response).toBe('ok'); expect(elapsedMs).toBeLessThan(500); }); }); diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts index af8de7a6..392821fe 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -1,22 +1,31 @@ -import { appendFileSync } from "node:fs"; -import { getCliContext, getConnectTimeoutMs } from "@/context.ts"; -import { USER_AGENT } from "@/version.ts"; -import { CliError, HttpError, NetworkError, TimeoutError, type AuthPlane } from "@/lib/errors.ts"; -import { logDebug, logWarn } from "@/lib/log.ts"; -import { getOutputSink } from "@/lib/runtime.ts"; +import { appendFileSync } from 'node:fs'; +import { getCliContext, getConnectTimeoutMs } from '@/context.ts'; +import { USER_AGENT } from '@/version.ts'; +import { + CliError, + HttpError, + NetworkError, + TimeoutError, + type AuthPlane, +} from '@/lib/errors.ts'; +import { logDebug, logWarn } from '@/lib/log.ts'; +import { getOutputSink } from '@/lib/runtime.ts'; import { redactResponseBodyForDebug, redactSensitiveJsonString, redactSensitiveJsonValue, truncateBodySnippet, -} from "@/lib/redact.ts"; +} from '@/lib/redact.ts'; import { delayBeforeHttpRetry, MAX_RETRY_ATTEMPTS, shouldRetryHttpRequest, -} from "@/lib/http-retry.ts"; -import { DEFAULT_READ_TIMEOUT_MS, STREAM_READ_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; -import { readEnv } from "@/lib/env.ts"; +} from '@/lib/http-retry.ts'; +import { + DEFAULT_READ_TIMEOUT_MS, + STREAM_READ_TIMEOUT_MS, +} from '@/lib/transport-defaults.ts'; +import { readEnv } from '@/lib/env.ts'; export type HttpSendOptions = { method: string; @@ -54,7 +63,7 @@ type UndiciAgentOptions = { function createDispatcher(options: UndiciAgentOptions): unknown { try { - const undici = require("undici") as { + const undici = require('undici') as { Agent: new (options: UndiciAgentOptions) => unknown; }; return new undici.Agent(options); @@ -102,7 +111,8 @@ function getInsecureDispatcher(): unknown { export function shouldIgnoreSslErrors(): boolean { return ( - getCliContext().ignoreSslErrors === true || readEnv("ALTERTABLE_IGNORE_SSL_ERRORS") === true + getCliContext().ignoreSslErrors === true || + readEnv('ALTERTABLE_IGNORE_SSL_ERRORS') === true ); } @@ -114,7 +124,7 @@ function warnInsecureTlsOnce(): void { } insecureTlsWarned = true; logWarn( - "TLS certificate verification is disabled (--ignore-ssl-errors); connections are not protected against interception.", + 'TLS certificate verification is disabled (--ignore-ssl-errors); connections are not protected against interception.' ); } @@ -125,7 +135,7 @@ function warnInsecureTlsOnce(): void { export function buildFetchInit( options: HttpSendOptions, headers: Record, - signal: AbortSignal, + signal: AbortSignal ): RequestInit { const ignoreSslErrors = shouldIgnoreSslErrors(); if (ignoreSslErrors) { @@ -137,7 +147,9 @@ export function buildFetchInit( headers, body: options.body, signal, - dispatcher: ignoreSslErrors ? getInsecureDispatcher() : getSharedDispatcher(), + dispatcher: ignoreSslErrors + ? getInsecureDispatcher() + : getSharedDispatcher(), ...(ignoreSslErrors ? { tls: { rejectUnauthorized: false } } : {}), } as RequestInit; } @@ -148,7 +160,7 @@ function resolveConnectTimeoutMs(options: HttpSendOptions): number { export function resolveReadTimeoutMs( options: HttpSendOptions, - streamDefault = DEFAULT_READ_TIMEOUT_MS, + streamDefault = DEFAULT_READ_TIMEOUT_MS ): number { if (options.readTimeoutMs !== undefined) { return options.readTimeoutMs; @@ -179,35 +191,43 @@ function unwrapErrorDetail(error: unknown): string { } current = (current as { cause?: unknown }).cause; } - return messages.join(": "); + return messages.join(': '); } function timeoutError(options: HttpSendOptions, cause: unknown): TimeoutError { - return new TimeoutError(`Request timed out: ${options.method} ${options.url}`, { cause }); + return new TimeoutError( + `Request timed out: ${options.method} ${options.url}`, + { cause } + ); } -function connectionError(options: HttpSendOptions, cause: unknown): NetworkError { +function connectionError( + options: HttpSendOptions, + cause: unknown +): NetworkError { const detail = unwrapErrorDetail(cause); - const suffix = detail ? ` (${detail})` : ""; + const suffix = detail ? ` (${detail})` : ''; return new NetworkError( `Request failed (network error): ${options.method} ${options.url}${suffix}`, - { cause }, + { cause } ); } function transportError( options: HttpSendOptions, signal: AbortSignal, - cause: unknown, + cause: unknown ): NetworkError { - return signal.aborted ? timeoutError(options, cause) : connectionError(options, cause); + return signal.aborted + ? timeoutError(options, cause) + : connectionError(options, cause); } function wrapResponseStream( stream: ReadableStream, clearTimeoutAfterRead: () => void, signal: AbortSignal, - options: HttpStreamOptions, + options: HttpStreamOptions ): ReadableStream { const reader = stream.getReader(); return new ReadableStream({ @@ -241,12 +261,13 @@ function wrapResponseStream( function findMatchingMock( mocks: MockHttpEntry[], options: HttpSendOptions, - attemptIndex: number, + attemptIndex: number ): MockHttpEntry | undefined { - const matchingMocks = mocks.filter((mock) => { + const matchingMocks = mocks.filter(mock => { const urlMatches = options.url.includes(mock.urlPattern); const methodMatches = !mock.method || mock.method === options.method; - const authMatches = !mock.authPattern || options.authHeader.includes(mock.authPattern); + const authMatches = + !mock.authPattern || options.authHeader.includes(mock.authPattern); return urlMatches && methodMatches && authMatches; }); @@ -259,7 +280,7 @@ function findMatchingMock( function createChunkedMockStream(body: string): ReadableStream { const encoder = new TextEncoder(); - const lines = body.split("\n"); + const lines = body.split('\n'); return new ReadableStream({ start(controller) { @@ -268,7 +289,8 @@ function createChunkedMockStream(body: string): ReadableStream { if (line === undefined) { continue; } - const suffix = lineIndex < lines.length - 1 ? "\n" : body.endsWith("\n") ? "\n" : ""; + const suffix = + lineIndex < lines.length - 1 ? '\n' : body.endsWith('\n') ? '\n' : ''; controller.enqueue(encoder.encode(`${line}${suffix}`)); } controller.close(); @@ -276,10 +298,13 @@ function createChunkedMockStream(body: string): ReadableStream { }); } -async function executeMockRequest(options: HttpSendOptions, attemptIndex: number): Promise { - const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); +async function executeMockRequest( + options: HttpSendOptions, + attemptIndex: number +): Promise { + const mockFile = readEnv('ALTERTABLE_MOCK_HTTP_FILE'); if (!mockFile) { - throw new CliError("Mock HTTP file is not configured."); + throw new CliError('Mock HTTP file is not configured.'); } const mocks = JSON.parse(await Bun.file(mockFile).text()) as MockHttpEntry[]; @@ -287,7 +312,9 @@ async function executeMockRequest(options: HttpSendOptions, attemptIndex: number const match = findMatchingMock(mocks, options, attemptIndex); if (!match) { - throw new CliError(`No mock HTTP response for ${options.method} ${options.url}`); + throw new CliError( + `No mock HTTP response for ${options.method} ${options.url}` + ); } const status = match.status ?? 200; @@ -301,17 +328,17 @@ async function executeMockRequest(options: HttpSendOptions, attemptIndex: number options.method, options.url, options.authPlane, - match.retryAfter ?? null, + match.retryAfter ?? null ); } async function executeMockStream( options: HttpStreamOptions, - attemptIndex: number, + attemptIndex: number ): Promise> { - const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); + const mockFile = readEnv('ALTERTABLE_MOCK_HTTP_FILE'); if (!mockFile) { - throw new CliError("Mock HTTP file is not configured."); + throw new CliError('Mock HTTP file is not configured.'); } const mocks = JSON.parse(await Bun.file(mockFile).text()) as MockHttpEntry[]; @@ -319,7 +346,9 @@ async function executeMockStream( const match = findMatchingMock(mocks, options, attemptIndex); if (!match) { - throw new CliError(`No mock HTTP response for ${options.method} ${options.url}`); + throw new CliError( + `No mock HTTP response for ${options.method} ${options.url}` + ); } const status = match.status ?? 200; @@ -330,7 +359,7 @@ async function executeMockStream( options.method, options.url, options.authPlane, - match.retryAfter ?? null, + match.retryAfter ?? null ); } @@ -347,15 +376,18 @@ async function executeMockStream( }); } -export function redactHeaderValue(headerName: string, headerValue: string): string { - if (headerName.toLowerCase() === "authorization") { - return "[REDACTED]"; +export function redactHeaderValue( + headerName: string, + headerValue: string +): string { + if (headerName.toLowerCase() === 'authorization') { + return '[REDACTED]'; } return headerValue; } export function redactAuthHeader(authHeader: string): string { - const colonIndex = authHeader.indexOf(": "); + const colonIndex = authHeader.indexOf(': '); if (colonIndex === -1) { return authHeader; } @@ -365,38 +397,38 @@ export function redactAuthHeader(authHeader: string): string { } function logHttpRequest(options: HttpSendOptions): void { - const logPath = readEnv("ALTERTABLE_HTTP_LOG"); + const logPath = readEnv('ALTERTABLE_HTTP_LOG'); if (!logPath) { return; } - let payload = ""; - if (typeof options.body === "string") { + let payload = ''; + if (typeof options.body === 'string') { payload = redactSensitiveJsonString(options.body); } else if (options.body instanceof Blob) { - payload = "@blob"; + payload = '@blob'; } else if (options.body !== undefined) { - payload = "@stream"; + payload = '@stream'; } appendFileSync( logPath, - `METHOD=${options.method}\nURL=${options.url}\nAUTH=${redactAuthHeader(options.authHeader)}\nPAYLOAD=${payload}\n---\n`, + `METHOD=${options.method}\nURL=${options.url}\nAUTH=${redactAuthHeader(options.authHeader)}\nPAYLOAD=${payload}\n---\n` ); } function httpExtractBodyDetail(body: string): string | undefined { const trimmed = body.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { const parsed = JSON.parse(body) as { error?: { message?: string } | string; message?: string; }; const detail = - (typeof parsed.error === "object" && parsed.error?.message) || - (typeof parsed.message === "string" ? parsed.message : "") || - (typeof parsed.error === "string" ? parsed.error : ""); + (typeof parsed.error === 'object' && parsed.error?.message) || + (typeof parsed.message === 'string' ? parsed.message : '') || + (typeof parsed.error === 'string' ? parsed.error : ''); if (detail) { return detail; } @@ -419,7 +451,7 @@ function throwHttpError( method: string, url: string, authPlane?: AuthPlane, - retryAfterHeader?: string | null, + retryAfterHeader?: string | null ): never { throw new HttpError({ status, @@ -428,29 +460,33 @@ function throwHttpError( url, parsedDetail: httpExtractBodyDetail(body) ?? - (authPlane === "lakehouse" && body.trim() ? truncateBodySnippet(body) : undefined), + (authPlane === 'lakehouse' && body.trim() + ? truncateBodySnippet(body) + : undefined), authPlane, retryAfterHeader, }); } -export function buildRequestHeaders(options: HttpSendOptions): Record { +export function buildRequestHeaders( + options: HttpSendOptions +): Record { const headers: Record = {}; - const colonIndex = options.authHeader.indexOf(": "); + const colonIndex = options.authHeader.indexOf(': '); if (colonIndex !== -1) { const authName = options.authHeader.slice(0, colonIndex); const authValue = options.authHeader.slice(colonIndex + 2); headers[authName] = authValue; } - headers["User-Agent"] = USER_AGENT; + headers['User-Agent'] = USER_AGENT; Object.assign(headers, options.extraHeaders); if (options.contentType) { - headers["Content-Type"] = options.contentType; - } else if (typeof options.body === "string" && options.body.length > 0) { - headers["Content-Type"] = "application/json"; + headers['Content-Type'] = options.contentType; + } else if (typeof options.body === 'string' && options.body.length > 0) { + headers['Content-Type'] = 'application/json'; } return headers; @@ -476,12 +512,17 @@ async function executeLiveRequest(options: HttpSendOptions): Promise { logHttpDebug([ `> ${options.method} ${options.url}`, - ...Object.entries(headers).map(([key, value]) => `> ${key}: ${redactHeaderValue(key, value)}`), + ...Object.entries(headers).map( + ([key, value]) => `> ${key}: ${redactHeaderValue(key, value)}` + ), ]); let response: Response; try { - response = await fetch(options.url, buildFetchInit(options, headers, signal)); + response = await fetch( + options.url, + buildFetchInit(options, headers, signal) + ); } catch (error) { throw transportError(options, signal, error); } @@ -508,11 +549,13 @@ async function executeLiveRequest(options: HttpSendOptions): Promise { options.method, options.url, options.authPlane, - response.headers.get("Retry-After"), + response.headers.get('Retry-After') ); } -async function executeLiveStream(options: HttpStreamOptions): Promise> { +async function executeLiveStream( + options: HttpStreamOptions +): Promise> { const headers = buildRequestHeaders(options); const connectTimeoutMs = resolveConnectTimeoutMs(options); const readTimeoutMs = resolveReadTimeoutMs(options, STREAM_READ_TIMEOUT_MS); @@ -536,7 +579,10 @@ async function executeLiveStream(options: HttpStreamOptions): Promise= 200 && response.status < 300) { if (!response.body) { - throw new NetworkError("Response body is missing."); + throw new NetworkError('Response body is missing.'); } if (readTimeoutMs > 0) { startTimeout(readTimeoutMs); } - return wrapResponseStream(response.body, clearActiveTimeout, abortController.signal, options); + return wrapResponseStream( + response.body, + clearActiveTimeout, + abortController.signal, + options + ); } if (readTimeoutMs > 0) { @@ -571,13 +622,13 @@ async function executeLiveStream(options: HttpStreamOptions): Promise { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); + const mockFile = readEnv('ALTERTABLE_MOCK_HTTP_FILE'); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { @@ -587,8 +638,18 @@ export async function httpSend(options: HttpSendOptions): Promise { return await executeLiveRequest(options); } catch (error) { if (error instanceof HttpError) { - if (shouldRetryHttpRequest(options, error.status, attemptIndex, maxAttempts)) { - await delayBeforeHttpRetry(attemptIndex, error.retryAfterHeader ?? null); + if ( + shouldRetryHttpRequest( + options, + error.status, + attemptIndex, + maxAttempts + ) + ) { + await delayBeforeHttpRetry( + attemptIndex, + error.retryAfterHeader ?? null + ); continue; } } @@ -597,14 +658,14 @@ export async function httpSend(options: HttpSendOptions): Promise { } } - throw new NetworkError("Request failed after retries."); + throw new NetworkError('Request failed after retries.'); } export async function httpSendStream( - options: HttpStreamOptions, + options: HttpStreamOptions ): Promise> { const maxAttempts = options.maxAttempts ?? MAX_RETRY_ATTEMPTS; - const mockFile = readEnv("ALTERTABLE_MOCK_HTTP_FILE"); + const mockFile = readEnv('ALTERTABLE_MOCK_HTTP_FILE'); for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) { try { @@ -614,8 +675,18 @@ export async function httpSendStream( return await executeLiveStream(options); } catch (error) { if (error instanceof HttpError) { - if (shouldRetryHttpRequest(options, error.status, attemptIndex, maxAttempts)) { - await delayBeforeHttpRetry(attemptIndex, error.retryAfterHeader ?? null); + if ( + shouldRetryHttpRequest( + options, + error.status, + attemptIndex, + maxAttempts + ) + ) { + await delayBeforeHttpRetry( + attemptIndex, + error.retryAfterHeader ?? null + ); continue; } } @@ -624,5 +695,5 @@ export async function httpSendStream( } } - throw new NetworkError("Request failed after retries."); + throw new NetworkError('Request failed after retries.'); } diff --git a/cli/src/lib/lakehouse-ndjson.ts b/cli/src/lib/lakehouse-ndjson.ts index f52c97e8..14da1ef4 100644 --- a/cli/src/lib/lakehouse-ndjson.ts +++ b/cli/src/lib/lakehouse-ndjson.ts @@ -1,5 +1,5 @@ -import { ParseError, QueryError } from "@/lib/errors.ts"; -import { readTextStreamLines } from "@/lib/stream-lines.ts"; +import { ParseError, QueryError } from '@/lib/errors.ts'; +import { readTextStreamLines } from '@/lib/stream-lines.ts'; export type LakehouseQueryMetadata = { statement?: string; @@ -35,7 +35,9 @@ export type LakehouseQueryStreamResult = { function isColumnNameArray(value: unknown): value is string[] { return ( - Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string") + Array.isArray(value) && + value.length > 0 && + value.every(entry => typeof entry === 'string') ); } @@ -43,18 +45,24 @@ function isColumnObjectArray(value: unknown): value is LakehouseColumn[] { return ( Array.isArray(value) && value.length > 0 && - value.every((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)) + value.every( + entry => + typeof entry === 'object' && entry !== null && !Array.isArray(entry) + ) ); } -function parseMetadataLine(line: string, lineNumber: number): LakehouseQueryMetadata { +function parseMetadataLine( + line: string, + lineNumber: number +): LakehouseQueryMetadata { const parsedMetadata = parseProtocolLine(line, lineNumber); if ( - typeof parsedMetadata !== "object" || + typeof parsedMetadata !== 'object' || parsedMetadata === null || Array.isArray(parsedMetadata) ) { - throw new ParseError("Query response metadata must be a JSON object.", { + throw new ParseError('Query response metadata must be a JSON object.', { details: `Line ${lineNumber}: ${line}`, }); } @@ -63,7 +71,7 @@ function parseMetadataLine(line: string, lineNumber: number): LakehouseQueryMeta function parseOptionalColumnsLine( line: string, - lineNumber: number, + lineNumber: number ): string[] | LakehouseColumn[] | undefined { const parsedLine = parseProtocolLine(line, lineNumber); if (isColumnNameArray(parsedLine) || isColumnObjectArray(parsedLine)) { @@ -79,18 +87,21 @@ function parseProtocolLine(line: string, lineNumber: number): unknown { try { value = JSON.parse(line); } catch (error) { - throw new ParseError(`Failed to parse query response at line ${lineNumber}.`, { - details: line, - cause: error, - }); + throw new ParseError( + `Failed to parse query response at line ${lineNumber}.`, + { + details: line, + cause: error, + } + ); } if ( - typeof value === "object" && + typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && - "error" in value && - typeof value.error === "string" + 'error' in value && + typeof value.error === 'string' ) { throw new QueryError(value.error); } @@ -113,7 +124,9 @@ function trimTrailingEmptyLines(lines: string[]): string[] { return trimmedLines; } -export async function parseLakehouseQueryHeader(lineSource: AsyncIterable): Promise<{ +export async function parseLakehouseQueryHeader( + lineSource: AsyncIterable +): Promise<{ metadata: LakehouseQueryMetadata; columns: string[] | LakehouseColumn[]; lineIterator: AsyncIterator; @@ -124,7 +137,7 @@ export async function parseLakehouseQueryHeader(lineSource: AsyncIterable, + stream: ReadableStream ): AsyncGenerator { const header = await parseLakehouseQueryHeader(readTextStreamLines(stream)); const rows: LakehouseRow[] = []; diff --git a/cli/src/lib/lakehouse-provision.test.ts b/cli/src/lib/lakehouse-provision.test.ts index 3dceeb7b..360d42a2 100644 --- a/cli/src/lib/lakehouse-provision.test.ts +++ b/cli/src/lib/lakehouse-provision.test.ts @@ -1,33 +1,43 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { configGet, configSet } from "@/lib/config.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { USER_AGENT } from "@/version.ts"; - -const profileName = "default"; - -let testHome = ""; -let mockFile = ""; -let logFile = ""; - -const WHOAMI_BODY = JSON.stringify({ principal: { id: "user-1", type: "User", name: "Leo" } }); +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setCliContext } from '@/context.ts'; +import { configGet, configSet } from '@/lib/config.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { USER_AGENT } from '@/version.ts'; + +const profileName = 'default'; + +let testHome = ''; +let mockFile = ''; +let logFile = ''; + +const WHOAMI_BODY = JSON.stringify({ + principal: { id: 'user-1', type: 'User', name: 'Leo' }, +}); const CREDENTIAL_BODY = JSON.stringify({ - credential: { username: "cli-user", expires_at: "2100-01-01T00:00:00Z" }, - password: "cli-pass", + credential: { username: 'cli-user', expires_at: '2100-01-01T00:00:00Z' }, + password: 'cli-pass', }); beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-lakehouse-provision-test-")); - mockFile = join(testHome, "mocks.json"); - logFile = join(testHome, "http.log"); + testHome = mkdtempSync( + join(tmpdir(), 'altertable-lakehouse-provision-test-') + ); + mockFile = join(testHome, 'mocks.json'); + logFile = join(testHome, 'http.log'); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; setCliContext({ debug: false, json: false, agent: false }); @@ -35,10 +45,10 @@ beforeEach(() => { // profile rather than env vars: setting those vars would trigger env-config // isolation (`_from_env`), which has no store to cache provisioned lakehouse // credentials in. This exercises the stored-profile provisioning path. - configSet("api_base", "https://api.example.com", profileName); - configSet("management_api_base", "https://app.example.com", profileName); - configSet("api_key_env", "env-1", profileName); - secretSet("api-key", "atm_test", profileName); + configSet('api_base', 'https://api.example.com', profileName); + configSet('management_api_base', 'https://app.example.com', profileName); + configSet('api_key_env', 'env-1', profileName); + secretSet('api-key', 'atm_test', profileName); refreshCliRuntimeContext(getCliRuntime().context); }); @@ -52,250 +62,305 @@ afterEach(() => { function writeMocks( credentialBody: string = CREDENTIAL_BODY, - options: { whoamiBody?: string; credentialUrlPattern?: string } = {}, + options: { whoamiBody?: string; credentialUrlPattern?: string } = {} ): void { writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/whoami", method: "GET", body: options.whoamiBody ?? WHOAMI_BODY }, { - urlPattern: options.credentialUrlPattern ?? "/users/user-1/environments/env-1/credentials", - method: "POST", + urlPattern: '/whoami', + method: 'GET', + body: options.whoamiBody ?? WHOAMI_BODY, + }, + { + urlPattern: + options.credentialUrlPattern ?? + '/users/user-1/environments/env-1/credentials', + method: 'POST', body: credentialBody, }, - { urlPattern: "api.example.com/tables", method: "GET", body: "ok" }, - ]), + { urlPattern: 'api.example.com/tables', method: 'GET', body: 'ok' }, + ]) ); } async function sendLakehouseRequest(authRecovery?: boolean): Promise { return sendHttp( - { plane: "lakehouse", method: "GET", endpoint: "/tables", authRecovery }, - createExecutionContext(getCliRuntime()), + { plane: 'lakehouse', method: 'GET', endpoint: '/tables', authRecovery }, + createExecutionContext(getCliRuntime()) ); } // bun-types declare rejects.toThrow() as void, so awaiting it trips the // await-thenable lint rule; capture the rejection explicitly instead. -async function expectRejection(promise: Promise, message: string): Promise { +async function expectRejection( + promise: Promise, + message: string +): Promise { const error = await promise.then( () => undefined, - (thrown: unknown) => thrown, + (thrown: unknown) => thrown ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain(message); } -describe("lakehouse credential auto-provisioning", () => { - test("provisions a credential when none exists and management auth is available", async () => { +describe('lakehouse credential auto-provisioning', () => { + test('provisions a credential when none exists and management auth is available', async () => { writeMocks(); const response = await sendLakehouseRequest(); - expect(response).toBe("ok"); - const expectedToken = Buffer.from("cli-user:cli-pass").toString("base64"); - expect(secretGet("lakehouse/basic-token", profileName)).toBe(expectedToken); - expect(configGet("lakehouse_credential_expiry", profileName)).toBe( - String(Date.parse("2100-01-01T00:00:00Z")), + expect(response).toBe('ok'); + const expectedToken = Buffer.from('cli-user:cli-pass').toString('base64'); + expect(secretGet('lakehouse/basic-token', profileName)).toBe(expectedToken); + expect(configGet('lakehouse_credential_expiry', profileName)).toBe( + String(Date.parse('2100-01-01T00:00:00Z')) ); - const logContent = readFileSync(logFile, "utf8"); + const logContent = readFileSync(logFile, 'utf8'); expect(logContent).toContain( - "URL=https://app.example.com/rest/v1/users/user-1/environments/env-1/credentials", + 'URL=https://app.example.com/rest/v1/users/user-1/environments/env-1/credentials' ); expect(logContent).toContain(`"label":"${USER_AGENT}"`); }); - test("provisions through /service_accounts for a service account principal", async () => { + test('provisions through /service_accounts for a service account principal', async () => { writeMocks(CREDENTIAL_BODY, { whoamiBody: JSON.stringify({ - principal: { id: "svc-1", type: "ServiceAccount", name: "CI Bot" }, + principal: { id: 'svc-1', type: 'ServiceAccount', name: 'CI Bot' }, }), - credentialUrlPattern: "/service_accounts/svc-1/environments/env-1/credentials", + credentialUrlPattern: + '/service_accounts/svc-1/environments/env-1/credentials', }); const response = await sendLakehouseRequest(); - expect(response).toBe("ok"); - expect(readFileSync(logFile, "utf8")).toContain( - "URL=https://app.example.com/rest/v1/service_accounts/svc-1/environments/env-1/credentials", + expect(response).toBe('ok'); + expect(readFileSync(logFile, 'utf8')).toContain( + 'URL=https://app.example.com/rest/v1/service_accounts/svc-1/environments/env-1/credentials' ); }); // A principal type like "constructor" resolves to a truthy Object.prototype // member under a bare lookup, which would build a nonsense credentials URL. - test("rejects a principal type that only exists on the object prototype", async () => { + test('rejects a principal type that only exists on the object prototype', async () => { writeMocks(CREDENTIAL_BODY, { whoamiBody: JSON.stringify({ - principal: { id: "weird-1", type: "constructor", name: "Weird" }, + principal: { id: 'weird-1', type: 'constructor', name: 'Weird' }, }), }); await expectRejection( sendLakehouseRequest(), - "Cannot auto-create lakehouse credentials for this identity", + 'Cannot auto-create lakehouse credentials for this identity' ); }); - test("re-provisions when the stored credential is expired", async () => { + test('re-provisions when the stored credential is expired', async () => { writeMocks(); secretSet( - "lakehouse/basic-token", - Buffer.from("old-user:old-pass").toString("base64"), - profileName, + 'lakehouse/basic-token', + Buffer.from('old-user:old-pass').toString('base64'), + profileName + ); + configSet( + 'lakehouse_credential_expiry', + String(Date.now() - 1000), + profileName ); - configSet("lakehouse_credential_expiry", String(Date.now() - 1000), profileName); const response = await sendLakehouseRequest(); - expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token", profileName)).toBe( - Buffer.from("cli-user:cli-pass").toString("base64"), + expect(response).toBe('ok'); + expect(secretGet('lakehouse/basic-token', profileName)).toBe( + Buffer.from('cli-user:cli-pass').toString('base64') ); }); - test("aborts on a corrupt stored expiry without provisioning or sending anything", async () => { + test('aborts on a corrupt stored expiry without provisioning or sending anything', async () => { writeMocks(); secretSet( - "lakehouse/basic-token", - Buffer.from("old-user:old-pass").toString("base64"), - profileName, + 'lakehouse/basic-token', + Buffer.from('old-user:old-pass').toString('base64'), + profileName ); - configSet("lakehouse_credential_expiry", "not-a-number", profileName); + configSet('lakehouse_credential_expiry', 'not-a-number', profileName); await expectRejection( sendLakehouseRequest(), - "Stored lakehouse credential expiry is corrupted. Run 'altertable logout' and try again.", + "Stored lakehouse credential expiry is corrupted. Run 'altertable logout' and try again." ); expect(existsSync(logFile)).toBe(false); }); - test("aborts when the created credential has no expiry", () => { - writeMocks(JSON.stringify({ credential: { username: "cli-user" }, password: "cli-pass" })); + test('aborts when the created credential has no expiry', () => { + writeMocks( + JSON.stringify({ + credential: { username: 'cli-user' }, + password: 'cli-pass', + }) + ); return expect(sendLakehouseRequest()).rejects.toThrow( - "Credential creation response was missing an expiry.", + 'Credential creation response was missing an expiry.' ); }); - test("uses valid stored credentials without touching the management plane", async () => { + test('uses valid stored credentials without touching the management plane', async () => { writeMocks(); secretSet( - "lakehouse/basic-token", - Buffer.from("old-user:old-pass").toString("base64"), - profileName, + 'lakehouse/basic-token', + Buffer.from('old-user:old-pass').toString('base64'), + profileName ); const response = await sendLakehouseRequest(); - expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token", profileName)).toBe( - Buffer.from("old-user:old-pass").toString("base64"), + expect(response).toBe('ok'); + expect(secretGet('lakehouse/basic-token', profileName)).toBe( + Buffer.from('old-user:old-pass').toString('base64') ); - expect(readFileSync(logFile, "utf8")).not.toContain("/whoami"); + expect(readFileSync(logFile, 'utf8')).not.toContain('/whoami'); }); }); -describe("lakehouse credential recovery after 401", () => { - const OLD_TOKEN = Buffer.from("old-user:old-pass").toString("base64"); +describe('lakehouse credential recovery after 401', () => { + const OLD_TOKEN = Buffer.from('old-user:old-pass').toString('base64'); function storeProvisionedCredential(): void { - secretSet("lakehouse/basic-token", OLD_TOKEN, profileName); - configSet("lakehouse_credential_expiry", String(Date.now() + 60 * 60 * 1000), profileName); + secretSet('lakehouse/basic-token', OLD_TOKEN, profileName); + configSet( + 'lakehouse_credential_expiry', + String(Date.now() + 60 * 60 * 1000), + profileName + ); } - test("re-provisions and retries when the server rejects a provisioned credential", async () => { + test('re-provisions and retries when the server rejects a provisioned credential', async () => { storeProvisionedCredential(); writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "api.example.com/tables", - method: "GET", + urlPattern: 'api.example.com/tables', + method: 'GET', authPattern: OLD_TOKEN, status: 401, - body: "", + body: '', }, - { urlPattern: "/whoami", method: "GET", body: WHOAMI_BODY }, + { urlPattern: '/whoami', method: 'GET', body: WHOAMI_BODY }, { - urlPattern: "/users/user-1/environments/env-1/credentials", - method: "POST", + urlPattern: '/users/user-1/environments/env-1/credentials', + method: 'POST', body: CREDENTIAL_BODY, }, - { urlPattern: "api.example.com/tables", method: "GET", body: "ok" }, - ]), + { urlPattern: 'api.example.com/tables', method: 'GET', body: 'ok' }, + ]) ); const response = await sendLakehouseRequest(); - expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token", profileName)).toBe( - Buffer.from("cli-user:cli-pass").toString("base64"), + expect(response).toBe('ok'); + expect(secretGet('lakehouse/basic-token', profileName)).toBe( + Buffer.from('cli-user:cli-pass').toString('base64') ); }); - test("gives up after one re-provision attempt", async () => { + test('gives up after one re-provision attempt', async () => { storeProvisionedCredential(); writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "api.example.com/tables", method: "GET", status: 401, body: "" }, - { urlPattern: "/whoami", method: "GET", body: WHOAMI_BODY }, { - urlPattern: "/users/user-1/environments/env-1/credentials", - method: "POST", + urlPattern: 'api.example.com/tables', + method: 'GET', + status: 401, + body: '', + }, + { urlPattern: '/whoami', method: 'GET', body: WHOAMI_BODY }, + { + urlPattern: '/users/user-1/environments/env-1/credentials', + method: 'POST', body: CREDENTIAL_BODY, }, - ]), + ]) ); - await expectRejection(sendLakehouseRequest(), "Authentication failed (401)"); - const whoamiCalls = readFileSync(logFile, "utf8").match(/\/whoami/g) ?? []; + await expectRejection( + sendLakehouseRequest(), + 'Authentication failed (401)' + ); + const whoamiCalls = readFileSync(logFile, 'utf8').match(/\/whoami/g) ?? []; expect(whoamiCalls).toHaveLength(1); }); - test("does not re-provision on 401 when env credentials are in use", async () => { + test('does not re-provision on 401 when env credentials are in use', async () => { storeProvisionedCredential(); - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-token"; + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'env-token'; try { writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "api.example.com/tables", method: "GET", status: 401, body: "" }, - ]), + { + urlPattern: 'api.example.com/tables', + method: 'GET', + status: 401, + body: '', + }, + ]) ); - await expectRejection(sendLakehouseRequest(), "Authentication failed (401)"); - expect(readFileSync(logFile, "utf8")).not.toContain("/whoami"); + await expectRejection( + sendLakehouseRequest(), + 'Authentication failed (401)' + ); + expect(readFileSync(logFile, 'utf8')).not.toContain('/whoami'); } finally { delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; } }); - test("does not re-provision on 401 for manually configured credentials", async () => { - secretSet("lakehouse/basic-token", OLD_TOKEN, profileName); + test('does not re-provision on 401 for manually configured credentials', async () => { + secretSet('lakehouse/basic-token', OLD_TOKEN, profileName); writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "api.example.com/tables", method: "GET", status: 401, body: "" }, - ]), + { + urlPattern: 'api.example.com/tables', + method: 'GET', + status: 401, + body: '', + }, + ]) ); - await expectRejection(sendLakehouseRequest(), "Authentication failed (401)"); - expect(readFileSync(logFile, "utf8")).not.toContain("/whoami"); + await expectRejection( + sendLakehouseRequest(), + 'Authentication failed (401)' + ); + expect(readFileSync(logFile, 'utf8')).not.toContain('/whoami'); }); - test("does not re-provision a managed credential when auth recovery is disabled", async () => { + test('does not re-provision a managed credential when auth recovery is disabled', async () => { storeProvisionedCredential(); writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "api.example.com/tables", method: "GET", status: 401, body: "" }, - ]), + { + urlPattern: 'api.example.com/tables', + method: 'GET', + status: 401, + body: '', + }, + ]) ); - await expectRejection(sendLakehouseRequest(false), "Authentication failed (401)"); - expect(secretGet("lakehouse/basic-token", profileName)).toBe(OLD_TOKEN); - expect(readFileSync(logFile, "utf8")).not.toContain("/whoami"); + await expectRejection( + sendLakehouseRequest(false), + 'Authentication failed (401)' + ); + expect(secretGet('lakehouse/basic-token', profileName)).toBe(OLD_TOKEN); + expect(readFileSync(logFile, 'utf8')).not.toContain('/whoami'); }); }); diff --git a/cli/src/lib/lakehouse-provision.ts b/cli/src/lib/lakehouse-provision.ts index 67bcb3a9..76eba269 100644 --- a/cli/src/lib/lakehouse-provision.ts +++ b/cli/src/lib/lakehouse-provision.ts @@ -1,34 +1,46 @@ -import type { components } from "@/generated/openapi-types.ts"; +import type { components } from '@/generated/openapi-types.ts'; import { basicAuthHeader, basicAuthToken, getManagementAuthHeader, hasLakehouseEnvCredentials, requireManagementEnv, -} from "@/lib/auth.ts"; -import { configGet, configSet, resolveManagementApiBase } from "@/lib/config.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { optionalAuth, type ExecutionContext } from "@/lib/execution-context.ts"; -import { isFromEnvProfile } from "@/lib/profile-store.ts"; -import { httpSend } from "@/lib/http.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { ensureFreshAccessToken, hasOAuthSession } from "@/lib/oauth-profile.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import { secretSet } from "@/lib/secrets.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import { USER_AGENT } from "@/version.ts"; +} from '@/lib/auth.ts'; +import { + configGet, + configSet, + resolveManagementApiBase, +} from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { + optionalAuth, + type ExecutionContext, +} from '@/lib/execution-context.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import { httpSend } from '@/lib/http.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { + ensureFreshAccessToken, + hasOAuthSession, +} from '@/lib/oauth-profile.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; +import { secretSet } from '@/lib/secrets.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { USER_AGENT } from '@/version.ts'; const CREDENTIAL_LABEL = USER_AGENT; const CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; const PRINCIPAL_CREDENTIAL_COLLECTIONS: Record = { - User: "users", - ServiceAccount: "service_accounts", + User: 'users', + ServiceAccount: 'service_accounts', }; // hasOwn, not a bare lookup: `principal.type` is server-supplied, and keys like // "constructor" would otherwise resolve to a truthy Object.prototype member. -function principalCredentialCollection(principalType: string | undefined): string | undefined { +function principalCredentialCollection( + principalType: string | undefined +): string | undefined { if ( principalType === undefined || !Object.hasOwn(PRINCIPAL_CREDENTIAL_COLLECTIONS, principalType) @@ -52,7 +64,7 @@ export function canRecoverLakehouseAuth(profileName: string): boolean { return ( !isFromEnvProfile(profileName) && !hasLakehouseEnvCredentials() && - configGet("lakehouse_credential_expiry", profileName) !== "" && + configGet('lakehouse_credential_expiry', profileName) !== '' && hasManagementCredentials(profileName) ); } @@ -61,7 +73,7 @@ async function sendManagementRequest( context: ExecutionContext, method: string, endpoint: string, - body?: string, + body?: string ): Promise { if (hasOAuthSession(context.profile)) { await ensureFreshAccessToken(context.profile); @@ -71,15 +83,17 @@ async function sendManagementRequest( url: `${resolveManagementApiBase(context.profile)}${encodeManagementEndpoint(endpoint)}`, authHeader: getManagementAuthHeader(context.profile), body, - contentType: body === undefined ? undefined : "application/json", - authPlane: "management", + contentType: body === undefined ? undefined : 'application/json', + authPlane: 'management', }); return parseApiJson(response); } const pendingCredentials = new Map>(); -export function provisionLakehouseCredential(context: ExecutionContext): Promise { +export function provisionLakehouseCredential( + context: ExecutionContext +): Promise { const pending = pendingCredentials.get(context.profile); if (pending) return pending; const renewal = createLakehouseCredential(context).finally(() => { @@ -89,52 +103,56 @@ export function provisionLakehouseCredential(context: ExecutionContext): Promise return renewal; } -async function createLakehouseCredential(context: ExecutionContext): Promise { +async function createLakehouseCredential( + context: ExecutionContext +): Promise { if (isFromEnvProfile(context.profile)) { throw new ConfigurationError( - "Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.", + 'Lakehouse credentials are not auto-provisioned while environment configuration is active. Set ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD.' ); } context.output.writeMetadata([ - renderDisplayText([span("Refreshing lakehouse credentials...", "muted")]), + renderDisplayText([span('Refreshing lakehouse credentials...', 'muted')]), ]); const env = requireManagementEnv(context.profile); const whoami = (await sendManagementRequest( context, - "GET", - "/whoami", - )) as components["schemas"]["WhoamiResponse"]; + 'GET', + '/whoami' + )) as components['schemas']['WhoamiResponse']; const principal = whoami.principal; const principalCollection = principalCredentialCollection(principal?.type); if (!principal?.id || !principalCollection) { throw new ConfigurationError( - "Cannot auto-create lakehouse credentials for this identity. Run 'altertable profile configure'.", + "Cannot auto-create lakehouse credentials for this identity. Run 'altertable profile configure'." ); } const expiresAt = new Date(Date.now() + CREDENTIAL_TTL_MS).toISOString(); const created = (await sendManagementRequest( context, - "POST", + 'POST', `/${principalCollection}/${principal.id}/environments/${env}/credentials`, - JSON.stringify({ label: CREDENTIAL_LABEL, expires_at: expiresAt }), - )) as components["schemas"]["CreateCredentialResponse"]; + JSON.stringify({ label: CREDENTIAL_LABEL, expires_at: expiresAt }) + )) as components['schemas']['CreateCredentialResponse']; const username = created.credential?.username; const password = created.password; if (!username || !password) { throw new ConfigurationError( - "Credential creation response was missing a username or password.", + 'Credential creation response was missing a username or password.' ); } - const expiryMs = Date.parse(created.credential?.expires_at ?? ""); + const expiryMs = Date.parse(created.credential?.expires_at ?? ''); if (Number.isNaN(expiryMs)) { - throw new ConfigurationError("Credential creation response was missing an expiry."); + throw new ConfigurationError( + 'Credential creation response was missing an expiry.' + ); } const token = basicAuthToken(username, password); - secretSet("lakehouse/basic-token", token, context.profile); - configSet("lakehouse_credential_expiry", String(expiryMs), context.profile); + secretSet('lakehouse/basic-token', token, context.profile); + configSet('lakehouse_credential_expiry', String(expiryMs), context.profile); return basicAuthHeader(token); } diff --git a/cli/src/lib/lakehouse-transport.ts b/cli/src/lib/lakehouse-transport.ts index 69e1f891..3fcbaf1c 100644 --- a/cli/src/lib/lakehouse-transport.ts +++ b/cli/src/lib/lakehouse-transport.ts @@ -1,6 +1,9 @@ -import { type HttpSendOptions } from "@/lib/http.ts"; -import { createUploadProgressReporter, shouldShowProgress } from "@/lib/progress.ts"; -import type { HttpRequest } from "@/lib/http-request.ts"; +import { type HttpSendOptions } from '@/lib/http.ts'; +import { + createUploadProgressReporter, + shouldShowProgress, +} from '@/lib/progress.ts'; +import type { HttpRequest } from '@/lib/http-request.ts'; export type LakehouseAppendOptions = { sync?: boolean; @@ -41,25 +44,27 @@ export type LakehouseUploadRequestScope = { release: () => void; }; -export function buildLakehouseAppendRequest(input: LakehouseAppendRequestInput): HttpRequest { +export function buildLakehouseAppendRequest( + input: LakehouseAppendRequestInput +): HttpRequest { const params = new URLSearchParams({ catalog: input.catalog, schema: input.schema, table: input.table, }); if (input.options?.sync) { - params.set("sync", "true"); + params.set('sync', 'true'); } return { - plane: "lakehouse", - method: "POST", + plane: 'lakehouse', + method: 'POST', endpoint: `/append?${params.toString()}`, body: input.jsonContent, }; } export function createLakehouseUploadRequest( - input: LakehouseUploadRequestInput, + input: LakehouseUploadRequestInput ): LakehouseUploadRequestScope { const params = new URLSearchParams({ catalog: input.catalog, @@ -71,7 +76,7 @@ export function createLakehouseUploadRequest( } export function createLakehouseUpsertRequest( - input: LakehouseUpsertRequestInput, + input: LakehouseUpsertRequestInput ): LakehouseUploadRequestScope { const params = new URLSearchParams({ catalog: input.catalog, @@ -84,7 +89,7 @@ export function createLakehouseUpsertRequest( function createLakehouseFileRequest( input: LakehouseUploadRequestInput | LakehouseUpsertRequestInput, - endpoint: string, + endpoint: string ): LakehouseUploadRequestScope { const file = Bun.file(input.filePath); let body: Blob | ReadableStream = input.contentType ? file : file.stream(); @@ -96,7 +101,7 @@ function createLakehouseFileRequest( input.fileSizeBytes, (sentBytes, totalBytes) => { uploadProgress.report(sentBytes, totalBytes); - }, + } ); } else { uploadProgress = createUploadProgressReporter(0); @@ -104,8 +109,8 @@ function createLakehouseFileRequest( return { request: { - plane: "lakehouse", - method: "POST", + plane: 'lakehouse', + method: 'POST', endpoint, body, contentType: input.contentType, @@ -118,7 +123,7 @@ function createLakehouseFileRequest( function wrapStreamWithByteProgress( source: ReadableStream, totalBytes: number, - onBytesSent: (sentBytes: number, totalBytes: number) => void, + onBytesSent: (sentBytes: number, totalBytes: number) => void ): ReadableStream { const reader = source.getReader(); let sentBytes = 0; diff --git a/cli/src/lib/lakehouse/args.test.ts b/cli/src/lib/lakehouse/args.test.ts index 5ab3360b..17be6aaa 100644 --- a/cli/src/lib/lakehouse/args.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -1,41 +1,49 @@ -import { describe, expect, test } from "bun:test"; -import { CliError } from "@/lib/errors.ts"; +import { describe, expect, test } from 'bun:test'; +import { CliError } from '@/lib/errors.ts'; import { inferLakehouseFileFormat, parseLakehouseFileContentType, parseLakehouseTarget, -} from "@/lib/lakehouse/args.ts"; +} from '@/lib/lakehouse/args.ts'; -describe("parseLakehouseFileContentType", () => { - test("maps supported lakehouse file formats to content types", () => { - expect(parseLakehouseFileContentType(undefined, "data.CSV")).toBe("text/csv"); - expect(parseLakehouseFileContentType("json", "data")).toBe("application/json"); - expect(parseLakehouseFileContentType("parquet", "data.csv")).toBe( - "application/vnd.apache.parquet", +describe('parseLakehouseFileContentType', () => { + test('maps supported lakehouse file formats to content types', () => { + expect(parseLakehouseFileContentType(undefined, 'data.CSV')).toBe( + 'text/csv' ); - expect(inferLakehouseFileFormat("data.JSON")).toBe("json"); + expect(parseLakehouseFileContentType('json', 'data')).toBe( + 'application/json' + ); + expect(parseLakehouseFileContentType('parquet', 'data.csv')).toBe( + 'application/vnd.apache.parquet' + ); + expect(inferLakehouseFileFormat('data.JSON')).toBe('json'); }); - test("rejects unknown lakehouse file formats", () => { - expect(() => parseLakehouseFileContentType("xml", "data.xml")).toThrow(CliError); - expect(() => parseLakehouseFileContentType(undefined, "data.xml")).toThrow( - "Could not infer the input format", + test('rejects unknown lakehouse file formats', () => { + expect(() => parseLakehouseFileContentType('xml', 'data.xml')).toThrow( + CliError + ); + expect(() => parseLakehouseFileContentType(undefined, 'data.xml')).toThrow( + 'Could not infer the input format' ); }); }); -describe("parseLakehouseTarget", () => { - test("parses and percent-decodes exactly three components", () => { - expect(parseLakehouseTarget("my%2Ecatalog.public.users")).toEqual({ - catalog: "my.catalog", - schema: "public", - table: "users", +describe('parseLakehouseTarget', () => { + test('parses and percent-decodes exactly three components', () => { + expect(parseLakehouseTarget('my%2Ecatalog.public.users')).toEqual({ + catalog: 'my.catalog', + schema: 'public', + table: 'users', }); }); - test("rejects shorthand, empty components, and malformed encoding", () => { - expect(() => parseLakehouseTarget("public.users")).toThrow(CliError); - expect(() => parseLakehouseTarget("catalog..users")).toThrow(CliError); - expect(() => parseLakehouseTarget("catalog.public.%ZZ")).toThrow("Invalid percent encoding"); + test('rejects shorthand, empty components, and malformed encoding', () => { + expect(() => parseLakehouseTarget('public.users')).toThrow(CliError); + expect(() => parseLakehouseTarget('catalog..users')).toThrow(CliError); + expect(() => parseLakehouseTarget('catalog.public.%ZZ')).toThrow( + 'Invalid percent encoding' + ); }); }); diff --git a/cli/src/lib/lakehouse/args.ts b/cli/src/lib/lakehouse/args.ts index 8287c846..060329a1 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,24 +1,28 @@ -import { extname } from "node:path"; -import { defineArguments } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; +import { extname } from 'node:path'; +import { defineArguments } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; -export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; +export const LAKEHOUSE_FILE_FORMAT_OPTIONS = [ + 'csv', + 'json', + 'parquet', +] as const; export const lakehouseFileArgs = defineArguments({ file: { - type: "positional", - description: "Local file to upload", + type: 'positional', + description: 'Local file to upload', required: true, - completion: "file", + completion: 'file', }, to: { - type: "string", - description: "Destination as catalog.schema.table", + type: 'string', + description: 'Destination as catalog.schema.table', required: true, }, format: { - type: "enum", - description: "Input format; inferred from the filename when omitted", + type: 'enum', + description: 'Input format; inferred from the filename when omitted', options: [...LAKEHOUSE_FILE_FORMAT_OPTIONS], }, }); @@ -30,26 +34,31 @@ export type LakehouseTarget = { }; export function parseLakehouseTarget(target: string): LakehouseTarget { - const components = target.split("."); - if (components.length !== 3 || components.some((component) => component.length === 0)) { - throw new CliError("--to must use catalog.schema.table notation."); + const components = target.split('.'); + if ( + components.length !== 3 || + components.some(component => component.length === 0) + ) { + throw new CliError('--to must use catalog.schema.table notation.'); } try { - const [catalog, schema, table] = components.map((component) => decodeURIComponent(component)); + const [catalog, schema, table] = components.map(component => + decodeURIComponent(component) + ); if (!catalog || !schema || !table) { - throw new CliError("--to must use catalog.schema.table notation."); + throw new CliError('--to must use catalog.schema.table notation.'); } return { catalog, schema, table }; } catch (error) { if (error instanceof CliError) throw error; - throw new CliError("Invalid percent encoding in --to target."); + throw new CliError('Invalid percent encoding in --to target.'); } } export function inferLakehouseFileFormat(filePath: string): string | undefined { const extension = extname(filePath).slice(1).toLowerCase(); return LAKEHOUSE_FILE_FORMAT_OPTIONS.includes( - extension as (typeof LAKEHOUSE_FILE_FORMAT_OPTIONS)[number], + extension as (typeof LAKEHOUSE_FILE_FORMAT_OPTIONS)[number] ) ? extension : undefined; @@ -57,19 +66,20 @@ export function inferLakehouseFileFormat(filePath: string): string | undefined { export function parseLakehouseFileContentType( format: string | undefined, - filePath: string, + filePath: string ): string { - const resolvedFormat = format?.toLowerCase() ?? inferLakehouseFileFormat(filePath); + const resolvedFormat = + format?.toLowerCase() ?? inferLakehouseFileFormat(filePath); switch (resolvedFormat) { - case "csv": - return "text/csv"; - case "json": - return "application/json"; - case "parquet": - return "application/vnd.apache.parquet"; + case 'csv': + return 'text/csv'; + case 'json': + return 'application/json'; + case 'parquet': + return 'application/vnd.apache.parquet'; default: throw new CliError( - "Could not infer the input format. Use a .csv, .json, or .parquet filename, or pass --format.", + 'Could not infer the input format. Use a .csv, .json, or .parquet filename, or pass --format.' ); } } diff --git a/cli/src/lib/lakehouse/file.ts b/cli/src/lib/lakehouse/file.ts index 18f698cd..649935f0 100644 --- a/cli/src/lib/lakehouse/file.ts +++ b/cli/src/lib/lakehouse/file.ts @@ -1,5 +1,5 @@ -import { statSync } from "node:fs"; -import { CliError } from "@/lib/errors.ts"; +import { statSync } from 'node:fs'; +import { CliError } from '@/lib/errors.ts'; export function getFileSizeBytes(filePath: string): number { try { diff --git a/cli/src/lib/lakehouse/query.test.ts b/cli/src/lib/lakehouse/query.test.ts index 8e7c9eb4..1eabcf58 100644 --- a/cli/src/lib/lakehouse/query.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -1,47 +1,47 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { ParseError, QueryError } from "@/lib/errors.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setCliContext } from '@/context.ts'; +import { ParseError, QueryError } from '@/lib/errors.ts'; import { csvEscapeCell, renderQueryCsv, renderQueryJson, renderQueryOutputText, -} from "@/lib/query-output.ts"; +} from '@/lib/query-output.ts'; import { parseLakehouseQueryResponse, parseLakehouseQueryStream, type LakehouseRow, -} from "@/lib/lakehouse-ndjson.ts"; -import { getQueryColumnNames } from "@/lib/query-format.ts"; -import { httpSendStream, resolveReadTimeoutMs } from "@/lib/http.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; +} from '@/lib/lakehouse-ndjson.ts'; +import { getQueryColumnNames } from '@/lib/query-format.ts'; +import { httpSendStream, resolveReadTimeoutMs } from '@/lib/http.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; import { buildLakehouseQueryRequest, executeLakehouseQuery, executeLakehouseQueryBytes, -} from "@/lib/lakehouse/query.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +} from '@/lib/lakehouse/query.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; const SAMPLE_NDJSON = [ '{"statement":"SELECT 1","session_id":"abc","query_id":"def"}', '["id","name"]', '[1,"Alice"]', '[2,"Bob"]', -].join("\n"); +].join('\n'); -let testHome = ""; -let mockFile = ""; +let testHome = ''; +let mockFile = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-lakehouse-test-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-lakehouse-test-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - process.env.ALTERTABLE_API_BASE = "https://example.com"; - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "testuser"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "testpass"; + process.env.ALTERTABLE_API_BASE = 'https://example.com'; + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'testuser'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'testpass'; setCliContext({ debug: false, json: false, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); }); @@ -54,84 +54,86 @@ afterEach(() => { delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; }); -describe("parseLakehouseQueryResponse", () => { - test("parses metadata, columns, and rows", () => { +describe('parseLakehouseQueryResponse', () => { + test('parses metadata, columns, and rows', () => { const result = parseLakehouseQueryResponse(SAMPLE_NDJSON); - expect(result.metadata.statement).toBe("SELECT 1"); - expect(result.metadata.session_id).toBe("abc"); - expect(result.columns).toEqual(["id", "name"]); + expect(result.metadata.statement).toBe('SELECT 1'); + expect(result.metadata.session_id).toBe('abc'); + expect(result.columns).toEqual(['id', 'name']); expect(result.rows).toEqual([ - [1, "Alice"], - [2, "Bob"], + [1, 'Alice'], + [2, 'Bob'], ]); }); - test("parses object column metadata and object rows", () => { + test('parses object column metadata and object rows', () => { const result = parseLakehouseQueryResponse( [ '{"statement":"SELECT * FROM users"}', '[{"name":"id","type":"integer"},{"name":"name","type":"varchar"}]', '{"id":1,"name":"Alice"}', - "", - ].join("\n"), + '', + ].join('\n') ); expect(result.columns).toEqual([ - { name: "id", type: "integer" }, - { name: "name", type: "varchar" }, + { name: 'id', type: 'integer' }, + { name: 'name', type: 'varchar' }, ]); - expect(result.rows).toEqual([{ id: 1, name: "Alice" }]); + expect(result.rows).toEqual([{ id: 1, name: 'Alice' }]); }); - test("treats a non-column second line as the first row", () => { + test('treats a non-column second line as the first row', () => { const result = parseLakehouseQueryResponse( - ['{"statement":"SELECT 1"}', '{"id":1}', "", '{"id":2}'].join("\n"), + ['{"statement":"SELECT 1"}', '{"id":1}', '', '{"id":2}'].join('\n') ); expect(result.columns).toEqual([]); expect(result.rows).toEqual([{ id: 1 }, { id: 2 }]); }); - test("throws ParseError when metadata is not an object", () => { - expect(() => parseLakehouseQueryResponse("[]\n")).toThrow("metadata must be a JSON object"); + test('throws ParseError when metadata is not an object', () => { + expect(() => parseLakehouseQueryResponse('[]\n')).toThrow( + 'metadata must be a JSON object' + ); }); - test("throws ParseError with line index for malformed JSON", () => { + test('throws ParseError with line index for malformed JSON', () => { const malformed = `${SAMPLE_NDJSON}\nnot-json`; try { parseLakehouseQueryResponse(malformed); - throw new Error("expected parse failure"); + throw new Error('expected parse failure'); } catch (error) { expect(error).toBeInstanceOf(ParseError); - expect((error as ParseError).message).toContain("line 5"); + expect((error as ParseError).message).toContain('line 5'); } }); - test("throws ParseError for empty response", () => { - expect(() => parseLakehouseQueryResponse("")).toThrow(ParseError); - expect(() => parseLakehouseQueryResponse(" \n ")).toThrow(ParseError); + test('throws ParseError for empty response', () => { + expect(() => parseLakehouseQueryResponse('')).toThrow(ParseError); + expect(() => parseLakehouseQueryResponse(' \n ')).toThrow(ParseError); }); }); -describe("parseLakehouseQueryStream", () => { - test("parses rows split across stream chunks", async () => { +describe('parseLakehouseQueryStream', () => { + test('parses rows split across stream chunks', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", + urlPattern: '/query', + method: 'POST', chunked: true, body: SAMPLE_NDJSON, }, - ]), + ]) ); const byteStream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Basic test", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Basic test', body: '{"statement":"SELECT 1"}', }); @@ -141,8 +143,8 @@ describe("parseLakehouseQueryStream", () => { const next = await streamParser.next(); if (next.done) { expect(next.value.rows).toEqual([ - [1, "Alice"], - [2, "Bob"], + [1, 'Alice'], + [2, 'Bob'], ]); break; } @@ -150,12 +152,12 @@ describe("parseLakehouseQueryStream", () => { } expect(rowValues).toEqual([ - [1, "Alice"], - [2, "Bob"], + [1, 'Alice'], + [2, 'Bob'], ]); }); - test("throws ParseError when metadata line is incomplete across first chunk", async () => { + test('throws ParseError when metadata line is incomplete across first chunk', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { @@ -167,13 +169,13 @@ describe("parseLakehouseQueryStream", () => { try { const streamParser = parseLakehouseQueryStream(stream); await streamParser.next(); - throw new Error("expected parse failure"); + throw new Error('expected parse failure'); } catch (error) { expect(error).toBeInstanceOf(ParseError); } }); - test("throws ParseError for empty stream", async () => { + test('throws ParseError for empty stream', async () => { const stream = new ReadableStream({ start(controller) { controller.close(); @@ -183,18 +185,20 @@ describe("parseLakehouseQueryStream", () => { try { const streamParser = parseLakehouseQueryStream(stream); await streamParser.next(); - throw new Error("expected parse failure"); + throw new Error('expected parse failure'); } catch (error) { expect(error).toBeInstanceOf(ParseError); } }); - test("streams a pending first row when no columns line is present", async () => { + test('streams a pending first row when no columns line is present', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue( - encoder.encode(['{"statement":"SELECT 1"}', '{"id":1}', '{"id":2}'].join("\n")), + encoder.encode( + ['{"statement":"SELECT 1"}', '{"id":1}', '{"id":2}'].join('\n') + ) ); controller.close(); }, @@ -215,24 +219,24 @@ describe("parseLakehouseQueryStream", () => { expect(rowValues).toEqual([{ id: 1 }, { id: 2 }]); }); - test("malformed row line includes line index", async () => { + test('malformed row line includes line index', async () => { const malformedBody = `${SAMPLE_NDJSON}\nnot-json`; writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", + urlPattern: '/query', + method: 'POST', chunked: true, body: malformedBody, }, - ]), + ]) ); const byteStream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Basic test", + method: 'POST', + url: 'https://example.com/query', + authHeader: 'Authorization: Basic test', body: '{"statement":"SELECT 1"}', }); @@ -241,92 +245,105 @@ describe("parseLakehouseQueryStream", () => { await streamParser.next(); try { await streamParser.next(); - throw new Error("expected parse failure"); + throw new Error('expected parse failure'); } catch (error) { expect(error).toBeInstanceOf(ParseError); - expect((error as ParseError).message).toContain("line 5"); + expect((error as ParseError).message).toContain('line 5'); } }); }); -describe("buildLakehouseQueryRequest", () => { +describe('buildLakehouseQueryRequest', () => { test.each([ [undefined, 5], [0, 0], [25, 25], ] as const)( - "streaming timeout precedence: request %s resolves to %s ms", + 'streaming timeout precedence: request %s resolves to %s ms', (readTimeoutMs, expected) => { - setCliContext({ debug: false, json: false, agent: false, readTimeoutMs: 5 }); + setCliContext({ + debug: false, + json: false, + agent: false, + readTimeoutMs: 5, + }); const request = buildLakehouseQueryRequest( { - statement: "SELECT 1", - ...(readTimeoutMs === undefined ? {} : { httpOptions: { readTimeoutMs } }), + statement: 'SELECT 1', + ...(readTimeoutMs === undefined + ? {} + : { httpOptions: { readTimeoutMs } }), }, - true, + true ); expect( - resolveReadTimeoutMs({ ...request, url: "https://example.com/query", authHeader: "" }, 0), + resolveReadTimeoutMs( + { ...request, url: 'https://example.com/query', authHeader: '' }, + 0 + ) ).toBe(expected); - }, + } ); - test("streaming queries retain an unlimited default without a timeout override", () => { - const request = buildLakehouseQueryRequest({ statement: "SELECT 1" }, true); + test('streaming queries retain an unlimited default without a timeout override', () => { + const request = buildLakehouseQueryRequest({ statement: 'SELECT 1' }, true); expect( - resolveReadTimeoutMs({ ...request, url: "https://example.com/query", authHeader: "" }, 0), + resolveReadTimeoutMs( + { ...request, url: 'https://example.com/query', authHeader: '' }, + 0 + ) ).toBe(0); expect(request.retry).toBe(false); }); - test("includes optional QueryRequest fields in the JSON body", () => { + test('includes optional QueryRequest fields in the JSON body', () => { const request = buildLakehouseQueryRequest( { - statement: "SELECT 1", - queryId: "query-1", - sessionId: "session-1", - computeSize: "M", - format: "csv", - dialect: "snowflake", - catalog: "analytics", - schema: "main", + statement: 'SELECT 1', + queryId: 'query-1', + sessionId: 'session-1', + computeSize: 'M', + format: 'csv', + dialect: 'snowflake', + catalog: 'analytics', + schema: 'main', }, - false, + false ); - expect(typeof request.body).toBe("string"); + expect(typeof request.body).toBe('string'); expect(JSON.parse(request.body as string)).toEqual({ - statement: "SELECT 1", - query_id: "query-1", - session_id: "session-1", - compute_size: "M", - format: "csv", - dialect: "snowflake", - catalog: "analytics", - schema: "main", + statement: 'SELECT 1', + query_id: 'query-1', + session_id: 'session-1', + compute_size: 'M', + format: 'csv', + dialect: 'snowflake', + catalog: 'analytics', + schema: 'main', }); }); }); -describe("executeLakehouseQuery", () => { - test("returns the same result as parseLakehouseQueryResponse", async () => { +describe('executeLakehouseQuery', () => { + test('returns the same result as parseLakehouseQueryResponse', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", + urlPattern: '/query', + method: 'POST', chunked: true, body: SAMPLE_NDJSON, }, - ]), + ]) ); const runtime = getCliRuntime(); const streamedResult = await executeLakehouseQuery( - { statement: "SELECT 1" }, + { statement: 'SELECT 1' }, createExecutionContext(runtime), - true, + true ); const bufferedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); @@ -334,80 +351,80 @@ describe("executeLakehouseQuery", () => { }); }); -describe("executeLakehouseQueryBytes", () => { - test("streams raw response bytes for API-native formats", async () => { - const csvBody = "id,name\n1,Alice\n"; +describe('executeLakehouseQueryBytes', () => { + test('streams raw response bytes for API-native formats', async () => { + const csvBody = 'id,name\n1,Alice\n'; writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/query", - method: "POST", + urlPattern: '/query', + method: 'POST', chunked: true, body: csvBody, }, - ]), + ]) ); const runtime = getCliRuntime(); const stream = await executeLakehouseQueryBytes( - { statement: "SELECT 1", format: "csv", computeSize: "AUTO" }, - createExecutionContext(runtime), + { statement: 'SELECT 1', format: 'csv', computeSize: 'AUTO' }, + createExecutionContext(runtime) ); const chunks: Uint8Array[] = []; for await (const chunk of stream) { chunks.push(chunk); } - const body = Buffer.concat(chunks).toString("utf8"); - expect(body).toContain("id,name"); - expect(body).toContain("1,Alice"); + const body = Buffer.concat(chunks).toString('utf8'); + expect(body).toContain('id,name'); + expect(body).toContain('1,Alice'); }); }); -describe("query renderers", () => { +describe('query renderers', () => { const parsedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); - test("human query output prints a padded table", () => { - const table = renderQueryOutputText(parsedResult, "human"); - expect(table).toContain("id"); - expect(table).toContain("name"); - expect(table).toContain("Alice"); - expect(table).toContain("Bob"); + test('human query output prints a padded table', () => { + const table = renderQueryOutputText(parsedResult, 'human'); + expect(table).toContain('id'); + expect(table).toContain('name'); + expect(table).toContain('Alice'); + expect(table).toContain('Bob'); }); - test("renderQueryCsv outputs header and quoted values", () => { + test('renderQueryCsv outputs header and quoted values', () => { const csv = renderQueryCsv(parsedResult); - expect(csv).toBe("id,name\n1,Alice\n2,Bob"); + expect(csv).toBe('id,name\n1,Alice\n2,Bob'); }); - test("csvEscapeCell quotes commas, quotes, and newlines", () => { + test('csvEscapeCell quotes commas, quotes, and newlines', () => { expect(csvEscapeCell('say "hi"')).toBe('"say ""hi"""'); - expect(csvEscapeCell("a,b")).toBe('"a,b"'); - expect(csvEscapeCell("line\nbreak")).toBe('"line\nbreak"'); + expect(csvEscapeCell('a,b')).toBe('"a,b"'); + expect(csvEscapeCell('line\nbreak')).toBe('"line\nbreak"'); }); - test("renderQueryJson pretty-prints structured output", () => { + test('renderQueryJson pretty-prints structured output', () => { const json = renderQueryJson(parsedResult); const parsed = JSON.parse(json) as { metadata: { statement: string }; rows: unknown[][]; }; - expect(parsed.metadata.statement).toBe("SELECT 1"); + expect(parsed.metadata.statement).toBe('SELECT 1'); expect(parsed.rows).toHaveLength(2); }); - test("getQueryColumnNames derives names from object rows", () => { + test('getQueryColumnNames derives names from object rows', () => { const names = getQueryColumnNames({ metadata: {}, columns: [], - rows: [{ id: 1, name: "Alice" }], + rows: [{ id: 1, name: 'Alice' }], }); - expect(names).toEqual(["id", "name"]); + expect(names).toEqual(['id', 'name']); }); }); -describe("NDJSON engine error records", () => { - const message = "Conversion Error: invalid integer"; +describe('NDJSON engine error records', () => { + const message = 'Conversion Error: invalid integer'; const errorLine = JSON.stringify({ error: message }); test.each([ @@ -415,47 +432,56 @@ describe("NDJSON engine error records", () => { `{}\n${errorLine}`, `{}\n["v"]\n${errorLine}`, `{}\n["v"]\n[1]\n${errorLine}`, - ])("buffered and chunked parsers reject the same engine frame: %s", async (body) => { - expect(() => parseLakehouseQueryResponse(body)).toThrow(message); - const bytes = new TextEncoder().encode(`${body.replaceAll("\n", "\r\n")}\r\n`); - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); - }, - cancel() { - canceled = true; - }, - }); - const rows: LakehouseRow[] = []; - async function consume() { - for await (const row of parseLakehouseQueryStream(stream)) rows.push(row); - } - let failure: unknown; - try { - await consume(); - } catch (error) { - failure = error; + ])( + 'buffered and chunked parsers reject the same engine frame: %s', + async body => { + expect(() => parseLakehouseQueryResponse(body)).toThrow(message); + const bytes = new TextEncoder().encode( + `${body.replaceAll('\n', '\r\n')}\r\n` + ); + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); + }, + cancel() { + canceled = true; + }, + }); + const rows: LakehouseRow[] = []; + async function consume() { + for await (const row of parseLakehouseQueryStream(stream)) + rows.push(row); + } + let failure: unknown; + try { + await consume(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(QueryError); + expect((failure as QueryError).message).toContain(message); + expect(rows).toEqual(body.includes('[1]') ? [[1]] : []); + expect(stream.locked).toBe(false); + expect(canceled).toBe(true); } - expect(failure).toBeInstanceOf(QueryError); - expect((failure as QueryError).message).toContain(message); - expect(rows).toEqual(body.includes("[1]") ? [[1]] : []); - expect(stream.locked).toBe(false); - expect(canceled).toBe(true); - }); + ); - test("preserves rows with error values and additional object fields", () => { + test('preserves rows with error values and additional object fields', () => { const result = parseLakehouseQueryResponse( - '{}\n["error","value"]\n["valid data",1]\n{"error":"valid data","value":2}\n', + '{}\n["error","value"]\n["valid data",1]\n{"error":"valid data","value":2}\n' ); - expect(result.rows).toEqual([["valid data", 1], { error: "valid data", value: 2 }]); + expect(result.rows).toEqual([ + ['valid data', 1], + { error: 'valid data', value: 2 }, + ]); }); }); -describe("streamed parse line numbers", () => { +describe('streamed parse line numbers', () => { test.each(['{}\n["v"]\n[1]\n\nnot-json', '{}\n{"v":1}\n\n \nnot-json'])( - "counts empty and whitespace-only lines: %s", - async (body) => { + 'counts empty and whitespace-only lines: %s', + async body => { let bufferedFailure: unknown; try { parseLakehouseQueryResponse(body); @@ -473,8 +499,10 @@ describe("streamed parse line numbers", () => { } expect(bufferedFailure).toBeInstanceOf(ParseError); expect(streamedFailure).toBeInstanceOf(ParseError); - expect((bufferedFailure as ParseError).message).toContain("line 5"); - expect((streamedFailure as ParseError).message).toBe((bufferedFailure as ParseError).message); - }, + expect((bufferedFailure as ParseError).message).toContain('line 5'); + expect((streamedFailure as ParseError).message).toBe( + (bufferedFailure as ParseError).message + ); + } ); }); diff --git a/cli/src/lib/lakehouse/query.ts b/cli/src/lib/lakehouse/query.ts index 1bb9e9bf..a78e6010 100644 --- a/cli/src/lib/lakehouse/query.ts +++ b/cli/src/lib/lakehouse/query.ts @@ -1,12 +1,16 @@ -import type { ExecutionContext } from "@/lib/execution-context.ts"; +import type { ExecutionContext } from '@/lib/execution-context.ts'; import { parseLakehouseQueryResponse, parseLakehouseQueryStream, type LakehouseQueryResult, -} from "@/lib/lakehouse-ndjson.ts"; -import { sendHttp, sendHttpStream, type HttpRequest } from "@/lib/http-request.ts"; +} from '@/lib/lakehouse-ndjson.ts'; +import { + sendHttp, + sendHttpStream, + type HttpRequest, +} from '@/lib/http-request.ts'; -export type LakehouseApiQueryFormat = "csv" | "jsonl" | "parquet"; +export type LakehouseApiQueryFormat = 'csv' | 'jsonl' | 'parquet'; export type LakehouseQueryInput = { statement: string; @@ -39,21 +43,21 @@ function buildQueryPayload(input: LakehouseQueryInput): Record { export function buildLakehouseQueryRequest( input: LakehouseQueryInput, - streaming: boolean, + streaming: boolean ): HttpRequest { return { - plane: "lakehouse", - method: "POST", - endpoint: "/query", + plane: 'lakehouse', + method: 'POST', + endpoint: '/query', body: JSON.stringify(buildQueryPayload(input)), - contentType: "application/json", + contentType: 'application/json', ...(streaming ? { retry: false } : {}), ...input.httpOptions, }; } async function collectQueryStream( - stream: ReadableStream, + stream: ReadableStream ): Promise { const parser = parseLakehouseQueryStream(stream); while (true) { @@ -65,7 +69,7 @@ async function collectQueryStream( export async function executeLakehouseQuery( input: LakehouseQueryInput, execution: ExecutionContext, - streaming: boolean, + streaming: boolean ): Promise { const request = buildLakehouseQueryRequest(input, streaming); if (streaming) { @@ -76,29 +80,34 @@ export async function executeLakehouseQuery( export async function executeLakehouseQueryBytes( input: LakehouseQueryInput, - execution: ExecutionContext, + execution: ExecutionContext ): Promise> { return sendHttpStream(buildLakehouseQueryRequest(input, true), execution); } export function buildLakehouseQueryShowRequest(queryId: string): HttpRequest { return { - plane: "lakehouse", - method: "GET", + plane: 'lakehouse', + method: 'GET', endpoint: `/query/${encodeURIComponent(queryId)}`, retry: true, }; } -export function buildLakehouseQueryCancelRequest(input: LakehouseCancelInput): HttpRequest { +export function buildLakehouseQueryCancelRequest( + input: LakehouseCancelInput +): HttpRequest { const params = new URLSearchParams({ session_id: input.sessionId }); return { - plane: "lakehouse", - method: "DELETE", + plane: 'lakehouse', + method: 'DELETE', endpoint: `/query/${encodeURIComponent(input.queryId)}?${params.toString()}`, }; } export function buildLakehouseVerifyRequest(): HttpRequest { - return { ...buildLakehouseQueryRequest({ statement: "SELECT 1" }, false), retry: true }; + return { + ...buildLakehouseQueryRequest({ statement: 'SELECT 1' }, false), + retry: true, + }; } diff --git a/cli/src/lib/log.ts b/cli/src/lib/log.ts index 7242bd89..cf363024 100644 --- a/cli/src/lib/log.ts +++ b/cli/src/lib/log.ts @@ -1,4 +1,4 @@ -import { getCliContext } from "@/context.ts"; +import { getCliContext } from '@/context.ts'; export function logDebug(message: string): void { if (getCliContext().debug) { diff --git a/cli/src/lib/management-endpoint.ts b/cli/src/lib/management-endpoint.ts index c2e0be0f..b3cd234a 100644 --- a/cli/src/lib/management-endpoint.ts +++ b/cli/src/lib/management-endpoint.ts @@ -1,12 +1,12 @@ function encodeManagementPath(path: string): string { - const segments = path.split("/"); + const segments = path.split('/'); return segments - .map((segment) => (segment.length > 0 ? encodeURIComponent(segment) : "")) - .join("/"); + .map(segment => (segment.length > 0 ? encodeURIComponent(segment) : '')) + .join('/'); } export function encodeManagementEndpoint(endpoint: string): string { - const queryStartIndex = endpoint.indexOf("?"); + const queryStartIndex = endpoint.indexOf('?'); if (queryStartIndex === -1) { return encodeManagementPath(endpoint); } diff --git a/cli/src/lib/management-output.test.ts b/cli/src/lib/management-output.test.ts index 2ab60945..a24b8dce 100644 --- a/cli/src/lib/management-output.test.ts +++ b/cli/src/lib/management-output.test.ts @@ -1,89 +1,94 @@ -import { describe, expect, test } from "bun:test"; -import { extractManagementRows, renderManagementOutput } from "@/lib/management-output.ts"; +import { describe, expect, test } from 'bun:test'; +import { + extractManagementRows, + renderManagementOutput, +} from '@/lib/management-output.ts'; -describe("extractManagementRows", () => { - test("extracts rows from list responses", () => { +describe('extractManagementRows', () => { + test('extracts rows from list responses', () => { const rows = extractManagementRows({ databases: [ - { id: "db_1", name: "Analytics" }, - { id: "db_2", name: "Prod DB" }, + { id: 'db_1', name: 'Analytics' }, + { id: 'db_2', name: 'Prod DB' }, ], }); expect(rows).toHaveLength(2); - expect(rows[0]?.name).toBe("Analytics"); + expect(rows[0]?.name).toBe('Analytics'); }); - test("extracts a single row from resource wrappers", () => { + test('extracts a single row from resource wrappers', () => { const rows = extractManagementRows({ - database: { id: "db_1", name: "Analytics" }, + database: { id: 'db_1', name: 'Analytics' }, }); - expect(rows).toEqual([{ id: "db_1", name: "Analytics" }]); + expect(rows).toEqual([{ id: 'db_1', name: 'Analytics' }]); }); - test("flattens credential create responses without exposing password", () => { + test('flattens credential create responses without exposing password', () => { const rows = extractManagementRows({ - credential: { id: "cred_1", label: "default" }, - password: "secret", + credential: { id: 'cred_1', label: 'default' }, + password: 'secret', }); - expect(rows).toEqual([{ id: "cred_1", label: "default", password: "[REDACTED]" }]); + expect(rows).toEqual([ + { id: 'cred_1', label: 'default', password: '[REDACTED]' }, + ]); }); }); -describe("renderManagementOutput", () => { +describe('renderManagementOutput', () => { const listBody = JSON.stringify({ databases: [ { - id: "db_1", - name: "Analytics", - slug: "ALT-1", + id: 'db_1', + name: 'Analytics', + slug: 'ALT-1', read_only: false, tags: [], }, { - id: "db_2", - name: "Prod DB", - slug: "ALT-2", + id: 'db_2', + name: 'Prod DB', + slug: 'ALT-2', read_only: true, - tags: ["prod"], + tags: ['prod'], }, ], }); - test("renders csv with header row", () => { - const output = renderManagementOutput(listBody, "csv"); - const lines = output.split("\n"); - expect(lines[0]).toBe("id,name,read_only,slug,tags"); - expect(lines[1]).toContain("db_1"); - expect(lines[1]).toContain("Analytics"); - expect(lines[2]).toContain("Prod DB"); - expect(lines[2]).toContain("prod"); + test('renders csv with header row', () => { + const output = renderManagementOutput(listBody, 'csv'); + const lines = output.split('\n'); + expect(lines[0]).toBe('id,name,read_only,slug,tags'); + expect(lines[1]).toContain('db_1'); + expect(lines[1]).toContain('Analytics'); + expect(lines[2]).toContain('Prod DB'); + expect(lines[2]).toContain('prod'); }); - test("renders markdown table", () => { - const output = renderManagementOutput(listBody, "markdown"); - expect(output).toContain("| id | name | read_only | slug | tags |"); - expect(output).toContain("| db_1 | Analytics | false | ALT-1 | [] |"); + test('renders markdown table', () => { + const output = renderManagementOutput(listBody, 'markdown'); + expect(output).toContain('| id | name | read_only | slug | tags |'); + expect(output).toContain('| db_1 | Analytics | false | ALT-1 | [] |'); }); - test("renders table output", () => { - const output = renderManagementOutput(listBody, "table"); - expect(output).toContain("id"); - expect(output).toContain("Analytics"); - expect(output).toContain("Prod DB"); + test('renders table output', () => { + const output = renderManagementOutput(listBody, 'table'); + expect(output).toContain('id'); + expect(output).toContain('Analytics'); + expect(output).toContain('Prod DB'); }); - test("pretty-prints json", () => { - const output = renderManagementOutput(listBody, "json"); + test('pretty-prints json', () => { + const output = renderManagementOutput(listBody, 'json'); expect(JSON.parse(output)).toEqual(JSON.parse(listBody)); }); - test("table format redacts credential password values", () => { + test('table format redacts credential password values', () => { const credentialBody = JSON.stringify({ - credential: { id: "cred_1", label: "default", username: "user_1" }, - password: "super-secret-password", + credential: { id: 'cred_1', label: 'default', username: 'user_1' }, + password: 'super-secret-password', }); - const output = renderManagementOutput(credentialBody, "table"); - expect(output).not.toContain("super-secret-password"); - expect(output).toContain("[REDACTED]"); + const output = renderManagementOutput(credentialBody, 'table'); + expect(output).not.toContain('super-secret-password'); + expect(output).toContain('[REDACTED]'); }); }); diff --git a/cli/src/lib/management-output.ts b/cli/src/lib/management-output.ts index 14bdde98..a93a6ba9 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,13 +1,15 @@ -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import { isRecord } from "@/lib/object.ts"; -import { redactSensitiveJsonValue } from "@/lib/redact.ts"; +import { parseApiJson } from '@/lib/parse-api-json.ts'; +import { isRecord } from '@/lib/object.ts'; +import { redactSensitiveJsonValue } from '@/lib/redact.ts'; import { renderTabularOutput, type ManagementOutputFormat, type TabularResult, -} from "@/lib/tabular-result.ts"; +} from '@/lib/tabular-result.ts'; -export function extractManagementRows(data: unknown): Record[] { +export function extractManagementRows( + data: unknown +): Record[] { if (!isRecord(data)) { return []; } @@ -22,7 +24,9 @@ export function extractManagementRows(data: unknown): Record[] return plainRows.map(redactSensitiveRow); } - const nestedObjects = Object.entries(data).filter(([, value]) => isRecord(value)); + const nestedObjects = Object.entries(data).filter(([, value]) => + isRecord(value) + ); if (nestedObjects.length === 1 && Object.keys(data).length === 1) { const nestedObject = nestedObjects[0]?.[1]; if (!isRecord(nestedObject)) { @@ -45,12 +49,17 @@ export function extractManagementRows(data: unknown): Record[] return Object.keys(row).length > 0 ? [row] : []; } -function redactSensitiveRow(row: Record): Record { +function redactSensitiveRow( + row: Record +): Record { return redactSensitiveJsonValue(row) as Record; } function redactSensitiveRowValue(key: string, value: unknown): unknown { - const redacted = redactSensitiveJsonValue({ [key]: value }) as Record; + const redacted = redactSensitiveJsonValue({ [key]: value }) as Record< + string, + unknown + >; return redacted[key]; } @@ -72,10 +81,13 @@ function managementDataToTabularResult(data: unknown): TabularResult { }; } -export function renderManagementOutput(body: string, format: ManagementOutputFormat): string { +export function renderManagementOutput( + body: string, + format: ManagementOutputFormat +): string { const data = parseApiJson(body); - if (format === "json") { + if (format === 'json') { return JSON.stringify(data, null, 2); } diff --git a/cli/src/lib/management/auth.test.ts b/cli/src/lib/management/auth.test.ts index 43aaaa85..338f6d10 100644 --- a/cli/src/lib/management/auth.test.ts +++ b/cli/src/lib/management/auth.test.ts @@ -1,23 +1,23 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { getManagementAuthHeader, requireManagementPlane } from "@/lib/auth.ts"; -import { configSet } from "@/lib/config.ts"; -import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; -import { secretSet } from "@/lib/secrets.ts"; -import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import { setCliContext } from "@/context.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; - -const profileName = "default"; - -let testHome = ""; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getManagementAuthHeader, requireManagementPlane } from '@/lib/auth.ts'; +import { configSet } from '@/lib/config.ts'; +import { FROM_ENV_PSEUDOPROFILE_NAME } from '@/lib/profile-store.ts'; +import { secretSet } from '@/lib/secrets.ts'; +import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; +import { setCliContext } from '@/context.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; + +const profileName = 'default'; + +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-mgmt-auth-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-mgmt-auth-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; delete process.env.ALTERTABLE_API_KEY; setCliContext({ debug: false, json: false, agent: false }); }); @@ -30,73 +30,95 @@ afterEach(() => { delete process.env.ALTERTABLE_ENV; }); -describe("getManagementAuthHeader precedence", () => { - test("throws when nothing is configured", () => { - expect(() => getManagementAuthHeader(profileName)).toThrow(ConfigurationError); +describe('getManagementAuthHeader precedence', () => { + test('throws when nothing is configured', () => { + expect(() => getManagementAuthHeader(profileName)).toThrow( + ConfigurationError + ); }); - test("uses the stored api-key when it is the only credential", () => { - secretSet("api-key", "atm_stored", profileName); - expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer atm_stored"); + test('uses the stored api-key when it is the only credential', () => { + secretSet('api-key', 'atm_stored', profileName); + expect(getManagementAuthHeader(profileName)).toBe( + 'Authorization: Bearer atm_stored' + ); }); - test("OAuth access token beats the stored api-key", () => { - secretSet("api-key", "atm_stored", profileName); + test('OAuth access token beats the stored api-key', () => { + secretSet('api-key', 'atm_stored', profileName); storeOAuthTokens( - { access_token: "oauth_tok", refresh_token: "r", expires_in: 3600 }, - profileName, + { access_token: 'oauth_tok', refresh_token: 'r', expires_in: 3600 }, + profileName + ); + expect(getManagementAuthHeader(profileName)).toBe( + 'Authorization: Bearer oauth_tok' ); - expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer oauth_tok"); }); - test("ALTERTABLE_API_KEY applies only to the env pseudo-profile", () => { - secretSet("api-key", "atm_stored", profileName); - storeOAuthTokens({ access_token: "oauth_tok", expires_in: 3600 }, profileName); - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('ALTERTABLE_API_KEY applies only to the env pseudo-profile', () => { + secretSet('api-key', 'atm_stored', profileName); + storeOAuthTokens( + { access_token: 'oauth_tok', expires_in: 3600 }, + profileName + ); + process.env.ALTERTABLE_API_KEY = 'atm_env'; expect(getManagementAuthHeader(FROM_ENV_PSEUDOPROFILE_NAME)).toBe( - "Authorization: Bearer atm_env", + 'Authorization: Bearer atm_env' + ); + expect(getManagementAuthHeader(profileName)).toBe( + 'Authorization: Bearer oauth_tok' ); - expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer oauth_tok"); }); }); -describe("requireManagementPlane", () => { - test("returns the environment for a fully configured profile", () => { - secretSet("api-key", "atm_stored", profileName); - configSet("api_key_env", "production", profileName); +describe('requireManagementPlane', () => { + test('returns the environment for a fully configured profile', () => { + secretSet('api-key', 'atm_stored', profileName); + configSet('api_key_env', 'production', profileName); - expect(requireManagementPlane(profileName, { requirement: "Doing X" })).toBe("production"); + expect( + requireManagementPlane(profileName, { requirement: 'Doing X' }) + ).toBe('production'); }); - test("reports the unconfigured management plane for a lakehouse-only profile", () => { - configSet("user", "alice", profileName); - secretSet("lakehouse/password", "s3cret", profileName); + test('reports the unconfigured management plane for a lakehouse-only profile', () => { + configSet('user', 'alice', profileName); + secretSet('lakehouse/password', 's3cret', profileName); - expect(() => requireManagementPlane(profileName, { requirement: "Doing X" })).toThrow( - "Doing X, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env '.", + expect(() => + requireManagementPlane(profileName, { requirement: 'Doing X' }) + ).toThrow( + "Doing X, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env '." ); }); - test("appends the alternative to the remediation", () => { + test('appends the alternative to the remediation', () => { expect(() => - requireManagementPlane(profileName, { requirement: "Doing X", alternative: "do Y instead" }), + requireManagementPlane(profileName, { + requirement: 'Doing X', + alternative: 'do Y instead', + }) ).toThrow( - "Doing X, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env ', or do Y instead.", + "Doing X, but the management plane is not configured. Run 'altertable login' or 'altertable profile configure --api-key atm_xxx --env ', or do Y instead." ); }); - test("requires both the env API key and environment in env configuration mode", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('requires both the env API key and environment in env configuration mode', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; expect(() => - requireManagementPlane(FROM_ENV_PSEUDOPROFILE_NAME, { requirement: "Doing X" }), + requireManagementPlane(FROM_ENV_PSEUDOPROFILE_NAME, { + requirement: 'Doing X', + }) ).toThrow( - "Doing X, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV.", + 'Doing X, but the management plane is not configured. Set ALTERTABLE_API_KEY and ALTERTABLE_ENV.' ); - process.env.ALTERTABLE_ENV = "staging"; - expect(requireManagementPlane(FROM_ENV_PSEUDOPROFILE_NAME, { requirement: "Doing X" })).toBe( - "staging", - ); + process.env.ALTERTABLE_ENV = 'staging'; + expect( + requireManagementPlane(FROM_ENV_PSEUDOPROFILE_NAME, { + requirement: 'Doing X', + }) + ).toBe('staging'); }); }); diff --git a/cli/src/lib/management/catalogs.ts b/cli/src/lib/management/catalogs.ts index b28bddf8..9bad7ffa 100644 --- a/cli/src/lib/management/catalogs.ts +++ b/cli/src/lib/management/catalogs.ts @@ -1,7 +1,7 @@ -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import type { CatalogRow } from "@/lib/management/model.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import type { CatalogRow } from '@/lib/management/model.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; type CatalogSummary = { name?: string; @@ -10,44 +10,57 @@ type CatalogSummary = { catalog?: string; }; -function catalogListRequest(environment: string, kind: "databases" | "connections") { +function catalogListRequest( + environment: string, + kind: 'databases' | 'connections' +) { return { - plane: "management" as const, - method: "GET", + plane: 'management' as const, + method: 'GET', endpoint: `/environments/${environment}/${kind}`, }; } -function parseCatalogRows(databasesResponse: string, connectionsResponse: string): CatalogRow[] { - const databases = parseApiJson(databasesResponse) as { databases?: CatalogSummary[] }; - const connections = parseApiJson(connectionsResponse) as { connections?: CatalogSummary[] }; +function parseCatalogRows( + databasesResponse: string, + connectionsResponse: string +): CatalogRow[] { + const databases = parseApiJson(databasesResponse) as { + databases?: CatalogSummary[]; + }; + const connections = parseApiJson(connectionsResponse) as { + connections?: CatalogSummary[]; + }; return [ - ...(databases.databases ?? []).map((database) => ({ - type: "database" as const, - name: database.name ?? "", - slug: database.slug ?? "", - engine: "altertable", - catalog: database.catalog ?? "", + ...(databases.databases ?? []).map(database => ({ + type: 'database' as const, + name: database.name ?? '', + slug: database.slug ?? '', + engine: 'altertable', + catalog: database.catalog ?? '', })), - ...(connections.connections ?? []).map((connection) => ({ - type: "connection" as const, - name: connection.name ?? "", - slug: connection.slug ?? "", - engine: connection.engine ?? "", - catalog: connection.catalog ?? "", + ...(connections.connections ?? []).map(connection => ({ + type: 'connection' as const, + name: connection.name ?? '', + slug: connection.slug ?? '', + engine: connection.engine ?? '', + catalog: connection.catalog ?? '', })), ]; } export async function fetchManagementCatalogRows( environment: string, - execution: ExecutionContext, + execution: ExecutionContext ): Promise { - const databasesResponse = await sendHttp(catalogListRequest(environment, "databases"), execution); + const databasesResponse = await sendHttp( + catalogListRequest(environment, 'databases'), + execution + ); const connectionsResponse = await sendHttp( - catalogListRequest(environment, "connections"), - execution, + catalogListRequest(environment, 'connections'), + execution ); return parseCatalogRows(databasesResponse, connectionsResponse); } diff --git a/cli/src/lib/management/model.test.ts b/cli/src/lib/management/model.test.ts index be4fc9bc..a205f9a8 100644 --- a/cli/src/lib/management/model.test.ts +++ b/cli/src/lib/management/model.test.ts @@ -1,40 +1,42 @@ -import { describe, expect, test } from "bun:test"; -import { ParseError } from "@/lib/errors.ts"; -import { parseWhoamiResponse } from "@/lib/management/model.ts"; +import { describe, expect, test } from 'bun:test'; +import { ParseError } from '@/lib/errors.ts'; +import { parseWhoamiResponse } from '@/lib/management/model.ts'; const VALID_WHOAMI = { principal: { - id: "user-1", - type: "User" as const, - name: "Jane", - email: "jane@example.com", + id: 'user-1', + type: 'User' as const, + name: 'Jane', + email: 'jane@example.com', }, organization: { - id: "org-1", - name: "Acme", - slug: "acme", + id: 'org-1', + name: 'Acme', + slug: 'acme', }, - authentication_scope: "user", - environment_slug: "production", + authentication_scope: 'user', + environment_slug: 'production', }; -describe("parseWhoamiResponse", () => { - test("parses the required management identity shape", () => { - expect(parseWhoamiResponse(JSON.stringify(VALID_WHOAMI))).toEqual(VALID_WHOAMI); +describe('parseWhoamiResponse', () => { + test('parses the required management identity shape', () => { + expect(parseWhoamiResponse(JSON.stringify(VALID_WHOAMI))).toEqual( + VALID_WHOAMI + ); }); - test("rejects invalid JSON and missing required fields", () => { - expect(() => parseWhoamiResponse("not-json")).toThrow(ParseError); - expect(() => parseWhoamiResponse("{}")).toThrow( - "Management identity response has an invalid shape", + test('rejects invalid JSON and missing required fields', () => { + expect(() => parseWhoamiResponse('not-json')).toThrow(ParseError); + expect(() => parseWhoamiResponse('{}')).toThrow( + 'Management identity response has an invalid shape' ); expect(() => parseWhoamiResponse( JSON.stringify({ ...VALID_WHOAMI, - principal: { type: "User", name: "Jane" }, - }), - ), - ).toThrow("Management identity response has an invalid shape"); + principal: { type: 'User', name: 'Jane' }, + }) + ) + ).toThrow('Management identity response has an invalid shape'); }); }); diff --git a/cli/src/lib/management/model.ts b/cli/src/lib/management/model.ts index 309ea31a..e4df2671 100644 --- a/cli/src/lib/management/model.ts +++ b/cli/src/lib/management/model.ts @@ -1,47 +1,57 @@ -import type { components } from "@/generated/openapi-types.ts"; -import { ParseError } from "@/lib/errors.ts"; -import { isRecord } from "@/lib/object.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; +import type { components } from '@/generated/openapi-types.ts'; +import { ParseError } from '@/lib/errors.ts'; +import { isRecord } from '@/lib/object.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; -export type WhoamiResponse = components["schemas"]["WhoamiResponse"]; +export type WhoamiResponse = components['schemas']['WhoamiResponse']; -function hasRequiredString(record: Record, key: string): boolean { - return typeof record[key] === "string" && record[key].length > 0; +function hasRequiredString( + record: Record, + key: string +): boolean { + return typeof record[key] === 'string' && record[key].length > 0; } -function hasOptionalString(record: Record, key: string): boolean { - return record[key] === undefined || typeof record[key] === "string"; +function hasOptionalString( + record: Record, + key: string +): boolean { + return record[key] === undefined || typeof record[key] === 'string'; } export function parseWhoamiResponse(body: string): WhoamiResponse { const data = parseApiJson(body); - if (!isRecord(data) || !isRecord(data.principal) || !isRecord(data.organization)) { - throw new ParseError("Management identity response has an invalid shape.", { - details: "Expected principal and organization objects.", + if ( + !isRecord(data) || + !isRecord(data.principal) || + !isRecord(data.organization) + ) { + throw new ParseError('Management identity response has an invalid shape.', { + details: 'Expected principal and organization objects.', }); } const principal = data.principal; const organization = data.organization; const validPrincipal = - hasRequiredString(principal, "id") && - (principal.type === "User" || principal.type === "ServiceAccount") && - hasRequiredString(principal, "name") && - hasOptionalString(principal, "email") && - hasOptionalString(principal, "slug"); + hasRequiredString(principal, 'id') && + (principal.type === 'User' || principal.type === 'ServiceAccount') && + hasRequiredString(principal, 'name') && + hasOptionalString(principal, 'email') && + hasOptionalString(principal, 'slug'); const validOrganization = - hasRequiredString(organization, "id") && - hasRequiredString(organization, "name") && - hasRequiredString(organization, "slug"); + hasRequiredString(organization, 'id') && + hasRequiredString(organization, 'name') && + hasRequiredString(organization, 'slug'); const validResponse = validPrincipal && validOrganization && - hasRequiredString(data, "authentication_scope") && - hasOptionalString(data, "environment_slug"); + hasRequiredString(data, 'authentication_scope') && + hasOptionalString(data, 'environment_slug'); if (!validResponse) { - throw new ParseError("Management identity response has an invalid shape.", { - details: "Response does not match the required WhoamiResponse fields.", + throw new ParseError('Management identity response has an invalid shape.', { + details: 'Response does not match the required WhoamiResponse fields.', }); } return data as WhoamiResponse; diff --git a/cli/src/lib/management/render.test.ts b/cli/src/lib/management/render.test.ts index 98e2cadb..4b5454b9 100644 --- a/cli/src/lib/management/render.test.ts +++ b/cli/src/lib/management/render.test.ts @@ -1,47 +1,54 @@ -import { describe, expect, test } from "bun:test"; -import { formatCatalogsSummary, formatCatalogsTable } from "@/lib/management/render.ts"; -import type { CatalogRow } from "@/lib/management/model.ts"; +import { describe, expect, test } from 'bun:test'; +import { + formatCatalogsSummary, + formatCatalogsTable, +} from '@/lib/management/render.ts'; +import type { CatalogRow } from '@/lib/management/model.ts'; const sampleRows: CatalogRow[] = [ { - type: "database", - name: "ClickBench", - slug: "ALT-1", - engine: "altertable", - catalog: "clickbench", + type: 'database', + name: 'ClickBench', + slug: 'ALT-1', + engine: 'altertable', + catalog: 'clickbench', }, { - type: "connection", - name: "Prod PG", - slug: "EXT-12", - engine: "postgres", - catalog: "prod_pg", + type: 'connection', + name: 'Prod PG', + slug: 'EXT-12', + engine: 'postgres', + catalog: 'prod_pg', }, ]; -describe("formatCatalogsTable", () => { - test("shows full type labels and groups databases before connections", () => { +describe('formatCatalogsTable', () => { + test('shows full type labels and groups databases before connections', () => { const output = formatCatalogsTable(sampleRows); - expect(output).toContain("SLUG"); - expect(output).toContain("ClickBench"); - expect(output).toContain("database"); - expect(output).toContain("connection"); - expect(output.indexOf("ClickBench")).toBeLessThan(output.indexOf("Prod PG")); + expect(output).toContain('SLUG'); + expect(output).toContain('ClickBench'); + expect(output).toContain('database'); + expect(output).toContain('connection'); + expect(output.indexOf('ClickBench')).toBeLessThan( + output.indexOf('Prod PG') + ); - const lines = output.split("\n"); - const blankLineIndex = lines.findIndex((line) => line.trim() === ""); + const lines = output.split('\n'); + const blankLineIndex = lines.findIndex(line => line.trim() === ''); expect(blankLineIndex).toBeGreaterThan(0); - expect(lines[blankLineIndex - 1]).toContain("ClickBench"); - expect(lines[blankLineIndex + 1]).toContain("Prod PG"); + expect(lines[blankLineIndex - 1]).toContain('ClickBench'); + expect(lines[blankLineIndex + 1]).toContain('Prod PG'); }); }); -describe("formatCatalogsSummary", () => { - test("returns null for an empty list", () => { +describe('formatCatalogsSummary', () => { + test('returns null for an empty list', () => { expect(formatCatalogsSummary([])).toBeNull(); }); - test("summarizes database and connection counts", () => { - expect(formatCatalogsSummary(sampleRows)).toBe("2 catalogs · 1 database · 1 connection"); + test('summarizes database and connection counts', () => { + expect(formatCatalogsSummary(sampleRows)).toBe( + '2 catalogs · 1 database · 1 connection' + ); }); }); diff --git a/cli/src/lib/management/render.ts b/cli/src/lib/management/render.ts index 3b7e6401..6413b218 100644 --- a/cli/src/lib/management/render.ts +++ b/cli/src/lib/management/render.ts @@ -1,16 +1,16 @@ -import type { CatalogRow, WhoamiResponse } from "@/lib/management/model.ts"; -import { buildCatalogsTableView } from "@/lib/management/views.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; +import type { CatalogRow, WhoamiResponse } from '@/lib/management/model.ts'; +import { buildCatalogsTableView } from '@/lib/management/views.ts'; +import { renderDocumentText } from '@/ui/renderers/terminal.ts'; export function formatWhoamiPrincipalLine(data: WhoamiResponse): string { const principal = data.principal ?? {}; - if (principal.type === "ServiceAccount") { - return `Service account: ${principal.name ?? ""} (${principal.slug ?? ""})`; + if (principal.type === 'ServiceAccount') { + return `Service account: ${principal.name ?? ''} (${principal.slug ?? ''})`; } if (principal.email) { - return `User: ${principal.name ?? ""} <${principal.email}>`; + return `User: ${principal.name ?? ''} <${principal.email}>`; } - return `User: ${principal.name ?? ""}`; + return `User: ${principal.name ?? ''}`; } export function formatCatalogsSummary(rows: CatalogRow[]): string | null { @@ -18,21 +18,22 @@ export function formatCatalogsSummary(rows: CatalogRow[]): string | null { return null; } - const databaseCount = rows.filter((row) => row.type === "database").length; - const connectionCount = rows.filter((row) => row.type === "connection").length; - const catalogLabel = rows.length === 1 ? "catalog" : "catalogs"; + const databaseCount = rows.filter(row => row.type === 'database').length; + const connectionCount = rows.filter(row => row.type === 'connection').length; + const catalogLabel = rows.length === 1 ? 'catalog' : 'catalogs'; const summaryParts = [`${rows.length} ${catalogLabel}`]; if (databaseCount > 0) { - const databaseLabel = databaseCount === 1 ? "database" : "databases"; + const databaseLabel = databaseCount === 1 ? 'database' : 'databases'; summaryParts.push(`${databaseCount} ${databaseLabel}`); } if (connectionCount > 0) { - const connectionLabel = connectionCount === 1 ? "connection" : "connections"; + const connectionLabel = + connectionCount === 1 ? 'connection' : 'connections'; summaryParts.push(`${connectionCount} ${connectionLabel}`); } - return summaryParts.join(" · "); + return summaryParts.join(' · '); } export function formatCatalogsTable(rows: CatalogRow[]): string { diff --git a/cli/src/lib/management/views.ts b/cli/src/lib/management/views.ts index fb8c7e8f..7a9a1f10 100644 --- a/cli/src/lib/management/views.ts +++ b/cli/src/lib/management/views.ts @@ -1,21 +1,37 @@ -import type { CatalogRow } from "@/lib/management/model.ts"; -import { document, section, span, table, type DisplayDocument } from "@/ui/document.ts"; +import type { CatalogRow } from '@/lib/management/model.ts'; +import { + document, + section, + span, + table, + type DisplayDocument, +} from '@/ui/document.ts'; -export function buildCatalogsTableView(rows: readonly CatalogRow[]): DisplayDocument { +export function buildCatalogsTableView( + rows: readonly CatalogRow[] +): DisplayDocument { return document( section( table({ rows, columns: [ - { header: "SLUG", cell: (row) => [span(row.slug, "accent")] }, - { header: "NAME", cell: (row) => [span(row.name, "strong")], flex: true }, - { header: "ENGINE", cell: (row) => [span(row.engine, "muted")] }, - { header: "CATALOG", cell: (row) => [span(row.catalog, "string")], flex: true }, - { header: "TYPE", cell: (row) => [span(row.type, "subtle")] }, + { header: 'SLUG', cell: row => [span(row.slug, 'accent')] }, + { + header: 'NAME', + cell: row => [span(row.name, 'strong')], + flex: true, + }, + { header: 'ENGINE', cell: row => [span(row.engine, 'muted')] }, + { + header: 'CATALOG', + cell: row => [span(row.catalog, 'string')], + flex: true, + }, + { header: 'TYPE', cell: row => [span(row.type, 'subtle')] }, ], - emptyMessage: "No catalogs found.", - options: { groupBy: (row) => row.type }, - }), - ), + emptyMessage: 'No catalogs found.', + options: { groupBy: row => row.type }, + }) + ) ); } diff --git a/cli/src/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts index 70a3a6aa..ca92ff57 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -1,24 +1,28 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configGet, resolveOAuthBase } from "@/lib/config.ts"; -import { setCliContext } from "@/context.ts"; -import { buildAuthorizeUrl, parseCallback, startLoopbackServer } from "@/lib/oauth-flow.ts"; -import { clearOAuthTokens, storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import { secretGet } from "@/lib/secrets.ts"; - -const profileName = "default"; -let testHome = ""; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configGet, resolveOAuthBase } from '@/lib/config.ts'; +import { setCliContext } from '@/context.ts'; +import { + buildAuthorizeUrl, + parseCallback, + startLoopbackServer, +} from '@/lib/oauth-flow.ts'; +import { clearOAuthTokens, storeOAuthTokens } from '@/lib/oauth-profile.ts'; +import { secretGet } from '@/lib/secrets.ts'; + +const profileName = 'default'; +let testHome = ''; function storedAccessToken(): string { - return secretGet("oauth/access-token", profileName); + return secretGet('oauth/access-token', profileName); } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-oauth-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-oauth-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; setCliContext({ debug: false, json: false, agent: false }); }); @@ -29,145 +33,169 @@ afterEach(() => { delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; }); -describe("resolveOAuthBase", () => { - test("defaults to the public app root + /oauth", () => { - expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.ai/oauth"); +describe('resolveOAuthBase', () => { + test('defaults to the public app root + /oauth', () => { + expect(resolveOAuthBase(profileName)).toBe( + 'https://app.altertable.ai/oauth' + ); }); - test("respects ALTERTABLE_MANAGEMENT_API_BASE", () => { - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - expect(resolveOAuthBase(profileName)).toBe("https://app.example.com/oauth"); + test('respects ALTERTABLE_MANAGEMENT_API_BASE', () => { + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; + expect(resolveOAuthBase(profileName)).toBe('https://app.example.com/oauth'); }); }); -describe("parseCallback", () => { - test("returns the code on success", () => { - expect(parseCallback("/callback?code=abc&state=st", "st")).toEqual({ ok: true, code: "abc" }); +describe('parseCallback', () => { + test('returns the code on success', () => { + expect(parseCallback('/callback?code=abc&state=st', 'st')).toEqual({ + ok: true, + code: 'abc', + }); }); - test("rejects a state mismatch", () => { - expect(parseCallback("/callback?code=abc&state=other", "st").ok).toBe(false); + test('rejects a state mismatch', () => { + expect(parseCallback('/callback?code=abc&state=other', 'st').ok).toBe( + false + ); }); - test("surfaces provider errors", () => { - expect(parseCallback("/callback?error=access_denied&error_description=nope", "st")).toEqual({ + test('surfaces provider errors', () => { + expect( + parseCallback( + '/callback?error=access_denied&error_description=nope', + 'st' + ) + ).toEqual({ ok: false, - message: "Authorization failed: access_denied — nope", + message: 'Authorization failed: access_denied — nope', }); }); - test("rejects a missing code", () => { - expect(parseCallback("/callback?state=st", "st").ok).toBe(false); + test('rejects a missing code', () => { + expect(parseCallback('/callback?state=st', 'st').ok).toBe(false); }); }); -describe("buildAuthorizeUrl", () => { - test("includes PKCE, state, scope, and client id", () => { - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; +describe('buildAuthorizeUrl', () => { + test('includes PKCE, state, scope, and client id', () => { + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'https://app.example.com'; const url = new URL( buildAuthorizeUrl( { - redirectUri: "http://127.0.0.1:5000/callback", - challenge: "chal", - state: "st", + redirectUri: 'http://127.0.0.1:5000/callback', + challenge: 'chal', + state: 'st', }, - resolveOAuthBase(profileName), - ), + resolveOAuthBase(profileName) + ) + ); + expect(url.origin + url.pathname).toBe( + 'https://app.example.com/oauth/authorize' ); - expect(url.origin + url.pathname).toBe("https://app.example.com/oauth/authorize"); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("code_challenge")).toBe("chal"); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("state")).toBe("st"); - expect(url.searchParams.get("client_id")).toBe("altertable_cli"); - expect(url.searchParams.get("scope")).toBe("management"); - expect(url.searchParams.has("organization")).toBe(false); - expect(url.searchParams.has("environment")).toBe(false); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('code_challenge')).toBe('chal'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('state')).toBe('st'); + expect(url.searchParams.get('client_id')).toBe('altertable_cli'); + expect(url.searchParams.get('scope')).toBe('management'); + expect(url.searchParams.has('organization')).toBe(false); + expect(url.searchParams.has('environment')).toBe(false); }); - test("adds organization and environment preselection without changing OAuth parameters", () => { + test('adds organization and environment preselection without changing OAuth parameters', () => { const url = new URL( buildAuthorizeUrl( { - redirectUri: "http://127.0.0.1:5000/callback", - challenge: "chal", - state: "st", - organization: "acme & co", - environment: "stage/blue", + redirectUri: 'http://127.0.0.1:5000/callback', + challenge: 'chal', + state: 'st', + organization: 'acme & co', + environment: 'stage/blue', }, - "https://app.example.com/oauth", - ), + 'https://app.example.com/oauth' + ) ); - expect(url.searchParams.get("organization")).toBe("acme & co"); - expect(url.searchParams.get("environment")).toBe("stage/blue"); - expect(url.searchParams.has("organization_slug")).toBe(false); - expect(url.searchParams.has("environment_slug")).toBe(false); - expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:5000/callback"); - expect(url.searchParams.get("code_challenge")).toBe("chal"); - expect(url.searchParams.get("state")).toBe("st"); + expect(url.searchParams.get('organization')).toBe('acme & co'); + expect(url.searchParams.get('environment')).toBe('stage/blue'); + expect(url.searchParams.has('organization_slug')).toBe(false); + expect(url.searchParams.has('environment_slug')).toBe(false); + expect(url.searchParams.get('redirect_uri')).toBe( + 'http://127.0.0.1:5000/callback' + ); + expect(url.searchParams.get('code_challenge')).toBe('chal'); + expect(url.searchParams.get('state')).toBe('st'); }); }); -describe("loopback server", () => { - test("resolves the code from a valid callback", async () => { - const server = await startLoopbackServer("st"); +describe('loopback server', () => { + test('resolves the code from a valid callback', async () => { + const server = await startLoopbackServer('st'); try { const response = await fetch(`${server.redirectUri}?code=abc&state=st`); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/plain"); - expect(await server.waitForCode()).toBe("abc"); + expect(response.headers.get('content-type')).toContain('text/plain'); + expect(await server.waitForCode()).toBe('abc'); } finally { server.close(); } }); - test("serves callback errors as plain text", async () => { - const server = await startLoopbackServer("st"); + test('serves callback errors as plain text', async () => { + const server = await startLoopbackServer('st'); const settled = server.waitForCode().then( - () => "resolved", - () => "rejected", + () => 'resolved', + () => 'rejected' ); try { const response = await fetch( - `${server.redirectUri}?error=access_denied&error_description=${encodeURIComponent("")}`, + `${server.redirectUri}?error=access_denied&error_description=${encodeURIComponent('')}` ); expect(response.status).toBe(400); - expect(response.headers.get("content-type")).toContain("text/plain"); + expect(response.headers.get('content-type')).toContain('text/plain'); expect(await response.text()).toBe( - "Authorization failed: access_denied — ", + 'Authorization failed: access_denied — ' ); - expect(await settled).toBe("rejected"); + expect(await settled).toBe('rejected'); } finally { server.close(); } }); - test("ignores non-callback paths", async () => { - const server = await startLoopbackServer("st"); + test('ignores non-callback paths', async () => { + const server = await startLoopbackServer('st'); try { const port = new URL(server.redirectUri).port; - expect((await fetch(`http://127.0.0.1:${port}/favicon.ico`)).status).toBe(404); + expect((await fetch(`http://127.0.0.1:${port}/favicon.ico`)).status).toBe( + 404 + ); } finally { server.close(); } }); }); -describe("token storage", () => { - test("round-trips tokens and stamps expiry", () => { +describe('token storage', () => { + test('round-trips tokens and stamps expiry', () => { const before = Date.now(); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - expect(storedAccessToken()).toBe("acc"); - expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThanOrEqual( - before + 3600 * 1000, + storeOAuthTokens( + { access_token: 'acc', refresh_token: 'ref', expires_in: 3600 }, + profileName ); + expect(storedAccessToken()).toBe('acc'); + expect( + Number.parseInt(configGet('oauth_expiry', profileName), 10) + ).toBeGreaterThanOrEqual(before + 3600 * 1000); }); - test("clear removes tokens and expiry", () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + test('clear removes tokens and expiry', () => { + storeOAuthTokens( + { access_token: 'acc', refresh_token: 'ref', expires_in: 3600 }, + profileName + ); clearOAuthTokens(profileName); - expect(storedAccessToken()).toBe(""); - expect(secretGet("oauth/refresh-token", profileName)).toBe(""); - expect(configGet("oauth_expiry", profileName)).toBe(""); + expect(storedAccessToken()).toBe(''); + expect(secretGet('oauth/refresh-token', profileName)).toBe(''); + expect(configGet('oauth_expiry', profileName)).toBe(''); }); }); diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index 3ad214b9..112e082d 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -1,12 +1,12 @@ -import * as oauth from "oauth4webapi"; -import { createServer } from "node:http"; -import { spawn } from "node:child_process"; -import { httpSend } from "@/lib/http.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { readEnv } from "@/lib/env.ts"; +import * as oauth from 'oauth4webapi'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { httpSend } from '@/lib/http.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { readEnv } from '@/lib/env.ts'; const LOGIN_TIMEOUT_MS = 180_000; @@ -18,31 +18,42 @@ export type TokenResponse = { }; export function oauthClientId(): string { - return readEnv("ALTERTABLE_OAUTH_CLIENT_ID") ?? "altertable_cli"; + return readEnv('ALTERTABLE_OAUTH_CLIENT_ID') ?? 'altertable_cli'; } export function oauthScope(): string { - return readEnv("ALTERTABLE_OAUTH_SCOPE") ?? "management"; + return readEnv('ALTERTABLE_OAUTH_SCOPE') ?? 'management'; } -export type CallbackOutcome = { ok: true; code: string } | { ok: false; message: string }; +export type CallbackOutcome = + | { ok: true; code: string } + | { ok: false; message: string }; -export function parseCallback(requestUrl: string, expectedState: string): CallbackOutcome { - const url = new URL(requestUrl, "http://127.0.0.1"); - const error = url.searchParams.get("error"); +export function parseCallback( + requestUrl: string, + expectedState: string +): CallbackOutcome { + const url = new URL(requestUrl, 'http://127.0.0.1'); + const error = url.searchParams.get('error'); if (error) { - const description = url.searchParams.get("error_description"); + const description = url.searchParams.get('error_description'); return { ok: false, - message: `Authorization failed: ${error}${description ? ` — ${description}` : ""}`, + message: `Authorization failed: ${error}${description ? ` — ${description}` : ''}`, }; } - if (url.searchParams.get("state") !== expectedState) { - return { ok: false, message: "OAuth state mismatch — possible CSRF. Aborting." }; + if (url.searchParams.get('state') !== expectedState) { + return { + ok: false, + message: 'OAuth state mismatch — possible CSRF. Aborting.', + }; } - const code = url.searchParams.get("code"); + const code = url.searchParams.get('code'); if (!code) { - return { ok: false, message: "Authorization response was missing the code parameter." }; + return { + ok: false, + message: 'Authorization response was missing the code parameter.', + }; } return { ok: true, code }; } @@ -55,35 +66,35 @@ export function buildAuthorizeUrl( organization?: string; environment?: string; }, - oauthBase: string, + oauthBase: string ): string { const query = new URLSearchParams({ - response_type: "code", + response_type: 'code', client_id: oauthClientId(), redirect_uri: params.redirectUri, code_challenge: params.challenge, - code_challenge_method: "S256", + code_challenge_method: 'S256', state: params.state, }); const scope = oauthScope(); if (scope) { - query.set("scope", scope); + query.set('scope', scope); } if (params.organization !== undefined) { - query.set("organization", params.organization); + query.set('organization', params.organization); } if (params.environment !== undefined) { - query.set("environment", params.environment); + query.set('environment', params.environment); } return `${oauthBase}/authorize?${query.toString()}`; } -type OAuthFetchOptions = oauth.CustomFetchOptions<"POST", URLSearchParams>; +type OAuthFetchOptions = oauth.CustomFetchOptions<'POST', URLSearchParams>; function resolveAuthServer(oauthBase: string): oauth.AuthorizationServer { return { // Discovery advertises the issuer as the server root (base without the /oauth suffix). - issuer: oauthBase.replace(/\/oauth$/, ""), + issuer: oauthBase.replace(/\/oauth$/, ''), authorization_endpoint: `${oauthBase}/authorize`, token_endpoint: `${oauthBase}/token`, }; @@ -95,29 +106,37 @@ function resolveOauthClient(): oauth.Client { // Route oauth4webapi's token requests through httpSend so the credential path keeps // our secret redaction in debug logs, retry policy, and assertAllowedApiBase URL policy. -async function httpSendFetch(url: string, options: OAuthFetchOptions): Promise { +async function httpSendFetch( + url: string, + options: OAuthFetchOptions +): Promise { const headers = new Headers(options.headers); - const accept = headers.get("accept"); + const accept = headers.get('accept'); const body = await httpSend({ method: options.method, url, - authHeader: headers.get("authorization") ?? "", + authHeader: headers.get('authorization') ?? '', body: options.body.toString(), - contentType: headers.get("content-type") ?? "application/x-www-form-urlencoded", + contentType: + headers.get('content-type') ?? 'application/x-www-form-urlencoded', extraHeaders: accept ? { accept } : undefined, - authPlane: "management", + authPlane: 'management', maxAttempts: 1, }); // httpSend already threw on any non-2xx, so this is a success body. return new Response(body, { status: 200, - headers: { "content-type": "application/json" }, + headers: { 'content-type': 'application/json' }, }); } -function tokenRequestOptions(oauthBase: string): oauth.TokenEndpointRequestOptions { - const options: oauth.TokenEndpointRequestOptions = { [oauth.customFetch]: httpSendFetch }; - if (oauthBase.startsWith("http://")) { +function tokenRequestOptions( + oauthBase: string +): oauth.TokenEndpointRequestOptions { + const options: oauth.TokenEndpointRequestOptions = { + [oauth.customFetch]: httpSendFetch, + }; + if (oauthBase.startsWith('http://')) { // Endpoint protocol is gated by our own url-policy; let oauth4webapi allow the // http:// loopback/localhost control-plane instead of hard-failing on it. options[oauth.allowInsecureRequests] = true; @@ -140,7 +159,7 @@ export async function exchangeCode( redirectUri: string; verifier: string; }, - oauthBase: string, + oauthBase: string ): Promise { const authServer = resolveAuthServer(oauthBase); const client = resolveOauthClient(); @@ -151,7 +170,7 @@ export async function exchangeCode( authServer, client, new URLSearchParams({ code: params.code }), - oauth.skipStateCheck, + oauth.skipStateCheck ); const response = await oauth.authorizationCodeGrantRequest( authServer, @@ -160,16 +179,16 @@ export async function exchangeCode( callbackParameters, params.redirectUri, params.verifier, - tokenRequestOptions(oauthBase), + tokenRequestOptions(oauthBase) ); return toTokenResponse( - await oauth.processAuthorizationCodeResponse(authServer, client, response), + await oauth.processAuthorizationCodeResponse(authServer, client, response) ); } export async function refreshAccessToken( refreshToken: string, - oauthBase: string, + oauthBase: string ): Promise { const authServer = resolveAuthServer(oauthBase); const client = resolveOauthClient(); @@ -178,13 +197,15 @@ export async function refreshAccessToken( client, oauth.None(), refreshToken, - tokenRequestOptions(oauthBase), + tokenRequestOptions(oauthBase) + ); + return toTokenResponse( + await oauth.processRefreshTokenResponse(authServer, client, response) ); - return toTokenResponse(await oauth.processRefreshTokenResponse(authServer, client, response)); } function redirectPort(): number { - return readEnv("ALTERTABLE_OAUTH_REDIRECT_PORT") ?? 0; + return readEnv('ALTERTABLE_OAUTH_REDIRECT_PORT') ?? 0; } export type LoopbackServer = { @@ -193,7 +214,9 @@ export type LoopbackServer = { close: () => void; }; -export function startLoopbackServer(expectedState: string): Promise { +export function startLoopbackServer( + expectedState: string +): Promise { return new Promise((resolveServer, rejectServer) => { let resolveCode: (code: string) => void = () => {}; let rejectCode: (error: Error) => void = () => {}; @@ -203,20 +226,22 @@ export function startLoopbackServer(expectedState: string): Promise { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (url.pathname !== "/callback") { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return; } - const outcome = parseCallback(req.url ?? "", expectedState); + const outcome = parseCallback(req.url ?? '', expectedState); // Plain text: the message can echo provider-supplied error_description, so // never serve it as HTML (would reflect untrusted input into the page). - res.writeHead(outcome.ok ? 200 : 400, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(outcome.ok ? 200 : 400, { + 'Content-Type': 'text/plain; charset=utf-8', + }); res.end( outcome.ok - ? "Signed in. You can close this tab and return to the terminal." - : outcome.message, + ? 'Signed in. You can close this tab and return to the terminal.' + : outcome.message ); if (outcome.ok) { resolveCode(outcome.code); @@ -225,12 +250,16 @@ export function startLoopbackServer(expectedState: string): Promise { + server.on('error', rejectServer); + server.listen(redirectPort(), '127.0.0.1', () => { const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; + const port = typeof address === 'object' && address ? address.port : 0; const timer = setTimeout(() => { - rejectCode(new ConfigurationError("Timed out waiting for browser sign-in (3 minutes).")); + rejectCode( + new ConfigurationError( + 'Timed out waiting for browser sign-in (3 minutes).' + ) + ); }, LOGIN_TIMEOUT_MS); resolveServer({ redirectUri: `http://127.0.0.1:${port}/callback`, @@ -246,11 +275,12 @@ export function startLoopbackServer(expectedState: string): Promise {}); // the printed URL is the fallback + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => {}); // the printed URL is the fallback child.unref(); } catch { // ignore — the authorize URL was printed for manual use @@ -260,7 +290,7 @@ export function openBrowser(url: string): void { export async function runLoginFlow( sink: OutputSink, oauthBase: string, - selection: { organization?: string; environment?: string } = {}, + selection: { organization?: string; environment?: string } = {} ): Promise { const verifier = oauth.generateRandomCodeVerifier(); const challenge = await oauth.calculatePKCECodeChallenge(verifier); @@ -269,20 +299,25 @@ export async function runLoginFlow( try { const authorizeUrl = buildAuthorizeUrl( { redirectUri: server.redirectUri, challenge, state, ...selection }, - oauthBase, + oauthBase ); // Login is interactive-only (no --json, no pipes), so its progress belongs on // stdout; stderr stays reserved for failures. - sink.writeHuman(renderDisplayText([span("Opening your browser to sign in…", "subtle")])); + sink.writeHuman( + renderDisplayText([span('Opening your browser to sign in…', 'subtle')]) + ); sink.writeHuman( renderDisplayText([ - span("If it doesn't open, visit: ", "subtle"), - span(authorizeUrl, "accent", authorizeUrl), - ]), + span("If it doesn't open, visit: ", 'subtle'), + span(authorizeUrl, 'accent', authorizeUrl), + ]) ); openBrowser(authorizeUrl); const code = await server.waitForCode(); - return await exchangeCode({ code, redirectUri: server.redirectUri, verifier }, oauthBase); + return await exchangeCode( + { code, redirectUri: server.redirectUri, verifier }, + oauthBase + ); } finally { server.close(); } diff --git a/cli/src/lib/oauth-profile.test.ts b/cli/src/lib/oauth-profile.test.ts index 61841dab..2580932d 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -1,32 +1,35 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { exchangeCode } from "@/lib/oauth-flow.ts"; -import { ensureFreshAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import { getManagementAuthHeader } from "@/lib/auth.ts"; -import { configGet, configSet, resolveOAuthBase } from "@/lib/config.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { setCliContext, getCliContext } from "@/context.ts"; -import { ConfigurationError, HttpError, NetworkError } from "@/lib/errors.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp } from "@/lib/http-request.ts"; -import { createCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { exchangeCode } from '@/lib/oauth-flow.ts'; +import { + ensureFreshAccessToken, + storeOAuthTokens, +} from '@/lib/oauth-profile.ts'; +import { getManagementAuthHeader } from '@/lib/auth.ts'; +import { configGet, configSet, resolveOAuthBase } from '@/lib/config.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { setCliContext, getCliContext } from '@/context.ts'; +import { ConfigurationError, HttpError, NetworkError } from '@/lib/errors.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { createCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -const profileName = "default"; -let testHome = ""; -let mockFile = ""; +const profileName = 'default'; +let testHome = ''; +let mockFile = ''; function storedAccessToken(): string { - return secretGet("oauth/access-token", profileName); + return secretGet('oauth/access-token', profileName); } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-oauth-refresh-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-oauth-refresh-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; // No ALTERTABLE_MANAGEMENT_API_BASE: it would trigger env-config isolation // (`_from_env`) and bypass the stored-profile OAuth session these tests set up. @@ -45,88 +48,93 @@ afterEach(() => { delete process.env.ALTERTABLE_API_KEY; }); -describe("exchangeCode", () => { - test("posts the authorization_code grant and returns tokens", async () => { +describe('exchangeCode', () => { + test('posts the authorization_code grant and returns tokens', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', body: '{"access_token":"acc","token_type":"Bearer","refresh_token":"ref","expires_in":3600}', }, - ]), + ]) ); const tokens = await exchangeCode( { - code: "c", - redirectUri: "http://127.0.0.1:5000/callback", - verifier: "v", + code: 'c', + redirectUri: 'http://127.0.0.1:5000/callback', + verifier: 'v', }, - resolveOAuthBase(profileName), + resolveOAuthBase(profileName) ); - expect(tokens.access_token).toBe("acc"); - expect(tokens.refresh_token).toBe("ref"); + expect(tokens.access_token).toBe('acc'); + expect(tokens.refresh_token).toBe('ref'); }); }); -describe("ensureFreshAccessToken", () => { - test("no-op when not logged in via OAuth", async () => { +describe('ensureFreshAccessToken', () => { + test('no-op when not logged in via OAuth', async () => { await ensureFreshAccessToken(profileName); - expect(storedAccessToken()).toBe(""); // nothing stored, nothing refreshed + expect(storedAccessToken()).toBe(''); // nothing stored, nothing refreshed }); - test("no-op when the token is still fresh", async () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + test('no-op when the token is still fresh', async () => { + storeOAuthTokens( + { access_token: 'acc', refresh_token: 'ref', expires_in: 3600 }, + profileName + ); await ensureFreshAccessToken(profileName); - expect(storedAccessToken()).toBe("acc"); // unchanged — no refresh + expect(storedAccessToken()).toBe('acc'); // unchanged — no refresh }); - test("no-op when ALTERTABLE_API_KEY is set (env key short-circuits refresh)", async () => { + test('no-op when ALTERTABLE_API_KEY is set (env key short-circuits refresh)', async () => { // Expired OAuth session, but no /oauth/token mock — a refresh attempt would throw. - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); const tokenBefore = storedAccessToken(); - process.env.ALTERTABLE_API_KEY = "atm_env"; + process.env.ALTERTABLE_API_KEY = 'atm_env'; await ensureFreshAccessToken(profileName); expect(storedAccessToken()).toBe(tokenBefore); // tokens must not be cleared }); - test("refreshes and persists when expired", async () => { + test('refreshes and persists when expired', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', body: '{"access_token":"acc2","token_type":"Bearer","refresh_token":"ref2","expires_in":3600}', }, - ]), + ]) ); - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); await ensureFreshAccessToken(profileName); - expect(storedAccessToken()).toBe("acc2"); - expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); + expect(storedAccessToken()).toBe('acc2'); + expect( + Number.parseInt(configGet('oauth_expiry', profileName), 10) + ).toBeGreaterThan(Date.now()); }); - test("clears tokens and throws when refresh fails", async () => { + test('clears tokens and throws when refresh fails', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', status: 400, body: '{"error":"invalid_grant"}', }, - ]), + ]) ); - secretSet("oauth/access-token", "acc", profileName); - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + secretSet('oauth/access-token', 'acc', profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); let caught: unknown; try { @@ -135,24 +143,24 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(ConfigurationError); - expect(storedAccessToken()).toBe(""); + expect(storedAccessToken()).toBe(''); }); - test("keeps tokens and rethrows when refresh returns 404 (wrong URL, not a dead session)", async () => { + test('keeps tokens and rethrows when refresh returns 404 (wrong URL, not a dead session)', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', status: 404, - body: "not found", + body: 'not found', }, - ]), + ]) ); - secretSet("oauth/access-token", "acc", profileName); - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + secretSet('oauth/access-token', 'acc', profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); let caught: unknown; try { @@ -161,17 +169,17 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(HttpError); - expect(storedAccessToken()).toBe("acc"); // session preserved - expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); + expect(storedAccessToken()).toBe('acc'); // session preserved + expect(secretGet('oauth/refresh-token', profileName)).toBe('ref'); }); - test("keeps tokens and rethrows when the refresh fails with a network error", async () => { + test('keeps tokens and rethrows when the refresh fails with a network error', async () => { // Bypass the mock harness so the refresh hits a real closed port. delete process.env.ALTERTABLE_MOCK_HTTP_FILE; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "http://127.0.0.1:1"; - secretSet("oauth/access-token", "acc", profileName); - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + process.env.ALTERTABLE_MANAGEMENT_API_BASE = 'http://127.0.0.1:1'; + secretSet('oauth/access-token', 'acc', profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); let caught: unknown; try { @@ -180,98 +188,125 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(NetworkError); - expect(storedAccessToken()).toBe("acc"); - expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); + expect(storedAccessToken()).toBe('acc'); + expect(secretGet('oauth/refresh-token', profileName)).toBe('ref'); }); }); -describe("management request auth resolution", () => { - test("refreshes an expired OAuth token before sending", async () => { +describe('management request auth resolution', () => { + test('refreshes an expired OAuth token before sending', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/oauth/token", - method: "POST", + urlPattern: '/oauth/token', + method: 'POST', body: '{"access_token":"acc3","token_type":"Bearer","refresh_token":"ref3","expires_in":3600}', }, - { urlPattern: "/whoami", method: "GET", body: '{"principal":{},"organization":{}}' }, - ]), + { + urlPattern: '/whoami', + method: 'GET', + body: '{"principal":{},"organization":{}}', + }, + ]) ); - secretSet("oauth/access-token", "stale", profileName); - secretSet("oauth/refresh-token", "ref", profileName); - configSet("oauth_expiry", String(Date.now() - 1000), profileName); + secretSet('oauth/access-token', 'stale', profileName); + secretSet('oauth/refresh-token', 'ref', profileName); + configSet('oauth_expiry', String(Date.now() - 1000), profileName); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); await sendHttp( - { plane: "management", method: "GET", endpoint: "/whoami" }, - createExecutionContext(runtime), + { plane: 'management', method: 'GET', endpoint: '/whoami' }, + createExecutionContext(runtime) ); }); - expect(storedAccessToken()).toBe("acc3"); - expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); + expect(storedAccessToken()).toBe('acc3'); + expect( + Number.parseInt(configGet('oauth_expiry', profileName), 10) + ).toBeGreaterThan(Date.now()); }); - test("does not refresh an expired OAuth token when auth recovery is disabled", async () => { + test('does not refresh an expired OAuth token when auth recovery is disabled', async () => { writeFileSync( mockFile, JSON.stringify([ { - urlPattern: "/whoami", - method: "GET", - authPattern: "stale", + urlPattern: '/whoami', + method: 'GET', + authPattern: 'stale', body: '{"principal":{},"organization":{}}', }, - ]), + ]) ); - secretSet("oauth/access-token", "stale", profileName); - secretSet("oauth/refresh-token", "ref", profileName); + secretSet('oauth/access-token', 'stale', profileName); + secretSet('oauth/refresh-token', 'ref', profileName); const expiredAt = String(Date.now() - 1000); - configSet("oauth_expiry", expiredAt, profileName); + configSet('oauth_expiry', expiredAt, profileName); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); await sendHttp( { - plane: "management", - method: "GET", - endpoint: "/whoami", + plane: 'management', + method: 'GET', + endpoint: '/whoami', authRecovery: false, }, - createExecutionContext(runtime), + createExecutionContext(runtime) ); }); - expect(storedAccessToken()).toBe("stale"); - expect(configGet("oauth_expiry", profileName)).toBe(expiredAt); + expect(storedAccessToken()).toBe('stale'); + expect(configGet('oauth_expiry', profileName)).toBe(expiredAt); }); - test("resolves the header live from the store, not a bootstrap cache", async () => { - storeOAuthTokens({ access_token: "tok_a", refresh_token: "r", expires_in: 3600 }, profileName); + test('resolves the header live from the store, not a bootstrap cache', async () => { + storeOAuthTokens( + { access_token: 'tok_a', refresh_token: 'r', expires_in: 3600 }, + profileName + ); writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/whoami", method: "GET", body: '{"principal":{},"organization":{}}' }, - ]), + { + urlPattern: '/whoami', + method: 'GET', + body: '{"principal":{},"organization":{}}', + }, + ]) ); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); // Simulate an out-of-band token rotation (e.g. a prior refresh in this process). - secretSet("oauth/access-token", "tok_b", profileName); + secretSet('oauth/access-token', 'tok_b', profileName); await sendHttp( - { plane: "management", method: "GET", endpoint: "/whoami" }, - createExecutionContext(runtime), + { plane: 'management', method: 'GET', endpoint: '/whoami' }, + createExecutionContext(runtime) ); }); // The header reflects the rotated token, resolved live at send time. - expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer tok_b"); + expect(getManagementAuthHeader(profileName)).toBe( + 'Authorization: Bearer tok_b' + ); }); }); diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index 8fc6567c..ab234211 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -1,15 +1,18 @@ -import { configGet, configSet, resolveOAuthBase } from "@/lib/config.ts"; -import { secretDelete, secretGet, secretSet } from "@/lib/secrets.ts"; -import { ConfigurationError, HttpError } from "@/lib/errors.ts"; -import { refreshAccessToken, type TokenResponse } from "@/lib/oauth-flow.ts"; -import { readEnv } from "@/lib/env.ts"; +import { configGet, configSet, resolveOAuthBase } from '@/lib/config.ts'; +import { secretDelete, secretGet, secretSet } from '@/lib/secrets.ts'; +import { ConfigurationError, HttpError } from '@/lib/errors.ts'; +import { refreshAccessToken, type TokenResponse } from '@/lib/oauth-flow.ts'; +import { readEnv } from '@/lib/env.ts'; -const ACCESS_TOKEN_ACCOUNT = "oauth/access-token"; -const REFRESH_TOKEN_ACCOUNT = "oauth/refresh-token"; -const OAUTH_EXPIRY_KEY = "oauth_expiry"; +const ACCESS_TOKEN_ACCOUNT = 'oauth/access-token'; +const REFRESH_TOKEN_ACCOUNT = 'oauth/refresh-token'; +const OAUTH_EXPIRY_KEY = 'oauth_expiry'; const REFRESH_SKEW_MS = 60_000; -export function storeOAuthTokens(oauthResponse: TokenResponse, profileName: string): void { +export function storeOAuthTokens( + oauthResponse: TokenResponse, + profileName: string +): void { secretSet(ACCESS_TOKEN_ACCOUNT, oauthResponse.access_token, profileName); if (oauthResponse.refresh_token) { secretSet(REFRESH_TOKEN_ACCOUNT, oauthResponse.refresh_token, profileName); @@ -21,15 +24,17 @@ export function storeOAuthTokens(oauthResponse: TokenResponse, profileName: stri export function clearOAuthTokens(profileName: string): void { secretDelete(ACCESS_TOKEN_ACCOUNT, profileName); secretDelete(REFRESH_TOKEN_ACCOUNT, profileName); - configSet(OAUTH_EXPIRY_KEY, "", profileName); + configSet(OAUTH_EXPIRY_KEY, '', profileName); } export function hasOAuthSession(profileName: string): boolean { - return configGet(OAUTH_EXPIRY_KEY, profileName) !== ""; + return configGet(OAUTH_EXPIRY_KEY, profileName) !== ''; } -export async function ensureFreshAccessToken(profileName: string): Promise { - if (readEnv("ALTERTABLE_API_KEY")) { +export async function ensureFreshAccessToken( + profileName: string +): Promise { + if (readEnv('ALTERTABLE_API_KEY')) { return; // env API key takes precedence; don't refresh or clear the OAuth session } const expiryRaw = configGet(OAUTH_EXPIRY_KEY, profileName); @@ -46,7 +51,10 @@ export async function ensureFreshAccessToken(profileName: string): Promise } let oauthResponse: TokenResponse; try { - oauthResponse = await refreshAccessToken(refreshToken, resolveOAuthBase(profileName)); + oauthResponse = await refreshAccessToken( + refreshToken, + resolveOAuthBase(profileName) + ); } catch (error) { // Only a rejected grant means the session is gone: the token endpoint returns // 400 (invalid_grant — expired/revoked refresh token, RFC 6749 §5.2) or 401 @@ -54,10 +62,13 @@ export async function ensureFreshAccessToken(profileName: string): Promise // account), 404 (wrong control-plane URL), 429 (rate limited), 5xx, // network/timeout — keeps the tokens and surfaces as-is so we don't wipe a // valid session and print a misleading "expired" message. - if (error instanceof HttpError && (error.status === 400 || error.status === 401)) { + if ( + error instanceof HttpError && + (error.status === 400 || error.status === 401) + ) { clearOAuthTokens(profileName); throw new ConfigurationError( - "Your login session expired or was revoked. Run 'altertable login' to sign in again.", + "Your login session expired or was revoked. Run 'altertable login' to sign in again." ); } throw error; diff --git a/cli/src/lib/object.ts b/cli/src/lib/object.ts index 25af3ae7..00b87495 100644 --- a/cli/src/lib/object.ts +++ b/cli/src/lib/object.ts @@ -1,16 +1,16 @@ export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value); } export function objectKeys>( - value: TValue, + value: TValue ): Array> { return Object.keys(value) as Array>; } export function hasObjectKey>( value: TValue, - key: string, + key: string ): key is Extract { return key in value; } diff --git a/cli/src/lib/openapi-spec.test.ts b/cli/src/lib/openapi-spec.test.ts index 6a7fd2e6..6c900685 100644 --- a/cli/src/lib/openapi-spec.test.ts +++ b/cli/src/lib/openapi-spec.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { getOpenapiSpecYaml, stripOpenapiSourceHeader } from "@/lib/openapi-spec.ts"; +import { describe, expect, test } from 'bun:test'; +import { + getOpenapiSpecYaml, + stripOpenapiSourceHeader, +} from '@/lib/openapi-spec.ts'; -describe("openapi-spec", () => { - test("stripOpenapiSourceHeader removes bundled source comments", () => { +describe('openapi-spec', () => { + test('stripOpenapiSourceHeader removes bundled source comments', () => { const yaml = `# AUTO-GENERATED by example # Edit source instead. openapi: 3.1.0 @@ -16,14 +19,14 @@ info: `); }); - test("stripOpenapiSourceHeader leaves yaml without a source header unchanged", () => { - const yaml = "openapi: 3.1.0\n"; + test('stripOpenapiSourceHeader leaves yaml without a source header unchanged', () => { + const yaml = 'openapi: 3.1.0\n'; expect(stripOpenapiSourceHeader(yaml)).toBe(yaml); }); - test("getOpenapiSpecYaml omits AUTO-GENERATED header comments", () => { + test('getOpenapiSpecYaml omits AUTO-GENERATED header comments', () => { const yaml = getOpenapiSpecYaml(); - expect(yaml.startsWith("openapi: 3.1.0")).toBe(true); - expect(yaml).not.toContain("AUTO-GENERATED"); + expect(yaml.startsWith('openapi: 3.1.0')).toBe(true); + expect(yaml).not.toContain('AUTO-GENERATED'); }); }); diff --git a/cli/src/lib/openapi-spec.ts b/cli/src/lib/openapi-spec.ts index 079d8b0f..52dc9859 100644 --- a/cli/src/lib/openapi-spec.ts +++ b/cli/src/lib/openapi-spec.ts @@ -1,15 +1,16 @@ // The bundled asset lives outside src/, so the @ alias cannot resolve it. // oxlint-disable-next-line no-relative-import-paths/no-relative-import-paths -import openapiYaml from "../../openapi/openapi.yaml" with { type: "text" }; -import { parse } from "yaml"; +import openapiYaml from '../../openapi/openapi.yaml' with { type: 'text' }; +import { parse } from 'yaml'; -const OPENAPI_SOURCE_HEADER_PATTERN = /^# AUTO-GENERATED[^\n]*\n(?:# [^\n]*\n)*/; +const OPENAPI_SOURCE_HEADER_PATTERN = + /^# AUTO-GENERATED[^\n]*\n(?:# [^\n]*\n)*/; export function stripOpenapiSourceHeader(yaml: string): string { - if (!yaml.startsWith("# AUTO-GENERATED")) { + if (!yaml.startsWith('# AUTO-GENERATED')) { return yaml; } - return yaml.replace(OPENAPI_SOURCE_HEADER_PATTERN, ""); + return yaml.replace(OPENAPI_SOURCE_HEADER_PATTERN, ''); } export function getOpenapiSpecYaml(): string { diff --git a/cli/src/lib/pager.test.ts b/cli/src/lib/pager.test.ts index 2150d703..63c6cfef 100644 --- a/cli/src/lib/pager.test.ts +++ b/cli/src/lib/pager.test.ts @@ -1,15 +1,19 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configFile, kvSet } from "@/lib/config.ts"; -import { buildPagerEnv, resolvePagerOptions, shouldUsePager } from "@/lib/pager.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configFile, kvSet } from '@/lib/config.ts'; +import { + buildPagerEnv, + resolvePagerOptions, + shouldUsePager, +} from '@/lib/pager.ts'; -describe("resolvePagerOptions", () => { - let testHome = ""; +describe('resolvePagerOptions', () => { + let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-pager-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-pager-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; }); @@ -18,90 +22,156 @@ describe("resolvePagerOptions", () => { delete process.env.ALTERTABLE_CONFIG_HOME; }); - test("config always forces pager when CLI flags unset", () => { - kvSet(configFile(), "query_pager", "always"); - expect(resolvePagerOptions()).toEqual({ mode: "always" }); + test('config always forces pager when CLI flags unset', () => { + kvSet(configFile(), 'query_pager', 'always'); + expect(resolvePagerOptions()).toEqual({ mode: 'always' }); }); - test("CLI pager mode wins over config always", () => { - kvSet(configFile(), "query_pager", "always"); - expect(resolvePagerOptions("never")).toEqual({ mode: "never" }); + test('CLI pager mode wins over config always', () => { + kvSet(configFile(), 'query_pager', 'always'); + expect(resolvePagerOptions('never')).toEqual({ mode: 'never' }); }); - test("config always triggers pager for short text on TTY", () => { - kvSet(configFile(), "query_pager", "always"); + test('config always triggers pager for short text on TTY', () => { + kvSet(configFile(), 'query_pager', 'always'); const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - expect(shouldUsePager("short", resolvePagerOptions())).toBe(true); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + expect(shouldUsePager('short', resolvePagerOptions())).toBe(true); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); }); }); -describe("buildPagerEnv", () => { - test("defaults less to UTF-8 so box drawing characters render correctly", () => { - expect(buildPagerEnv({ PATH: "/bin" })).toMatchObject({ - LESS: "FRX", - LESSCHARSET: "utf-8", - PATH: "/bin", +describe('buildPagerEnv', () => { + test('defaults less to UTF-8 so box drawing characters render correctly', () => { + expect(buildPagerEnv({ PATH: '/bin' })).toMatchObject({ + LESS: 'FRX', + LESSCHARSET: 'utf-8', + PATH: '/bin', }); }); - test("preserves explicit LESSCHARSET overrides", () => { - expect(buildPagerEnv({ LESSCHARSET: "latin1" }).LESSCHARSET).toBe("latin1"); + test('preserves explicit LESSCHARSET overrides', () => { + expect(buildPagerEnv({ LESSCHARSET: 'latin1' }).LESSCHARSET).toBe('latin1'); }); }); -describe("shouldUsePager", () => { +describe('shouldUsePager', () => { const originalIsTTY = process.stdout.isTTY; const originalRows = process.stdout.rows; const originalColumns = process.stdout.columns; - test("returns false when disabled", () => { - expect(shouldUsePager("line1\nline2\nline3", { mode: "never" })).toBe(false); + test('returns false when disabled', () => { + expect(shouldUsePager('line1\nline2\nline3', { mode: 'never' })).toBe( + false + ); }); - test("returns false when stdout is not a TTY", () => { - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - expect(shouldUsePager("line1\nline2\nline3", { mode: "always" })).toBe(false); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); + test('returns false when stdout is not a TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + expect(shouldUsePager('line1\nline2\nline3', { mode: 'always' })).toBe( + false + ); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); }); - test("returns true when forced on TTY", () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: 24, configurable: true }); - expect(shouldUsePager("short", { mode: "always" })).toBe(true); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: originalRows, configurable: true }); + test('returns true when forced on TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: 24, + configurable: true, + }); + expect(shouldUsePager('short', { mode: 'always' })).toBe(true); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: originalRows, + configurable: true, + }); }); - test("returns true when output exceeds terminal rows", () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: 3, configurable: true }); - const text = "line1\nline2\nline3\nline4"; - expect(shouldUsePager(text, { mode: "auto" })).toBe(true); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: originalRows, configurable: true }); + test('returns true when output exceeds terminal rows', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: 3, + configurable: true, + }); + const text = 'line1\nline2\nline3\nline4'; + expect(shouldUsePager(text, { mode: 'auto' })).toBe(true); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: originalRows, + configurable: true, + }); }); - test("returns true when output exceeds terminal columns", () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "columns", { value: 10, configurable: true }); - expect(shouldUsePager("short\nthis line is too wide", { mode: "auto" })).toBe(true); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); - Object.defineProperty(process.stdout, "columns", { + test('returns true when output exceeds terminal columns', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'columns', { + value: 10, + configurable: true, + }); + expect( + shouldUsePager('short\nthis line is too wide', { mode: 'auto' }) + ).toBe(true); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true, }); }); - test("returns false when output fits terminal", () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: 24, configurable: true }); - Object.defineProperty(process.stdout, "columns", { value: 80, configurable: true }); - expect(shouldUsePager("line1\nline2", { mode: "auto" })).toBe(false); - Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); - Object.defineProperty(process.stdout, "rows", { value: originalRows, configurable: true }); - Object.defineProperty(process.stdout, "columns", { + test('returns false when output fits terminal', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: 24, + configurable: true, + }); + Object.defineProperty(process.stdout, 'columns', { + value: 80, + configurable: true, + }); + expect(shouldUsePager('line1\nline2', { mode: 'auto' })).toBe(false); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: originalRows, + configurable: true, + }); + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true, }); diff --git a/cli/src/lib/pager.ts b/cli/src/lib/pager.ts index dca1d50d..05502e81 100644 --- a/cli/src/lib/pager.ts +++ b/cli/src/lib/pager.ts @@ -1,10 +1,10 @@ -import { spawnSync } from "node:child_process"; -import { getQueryDefaultPager } from "@/lib/config.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { getVisibleTextWidth } from "@/ui/terminal/styles.ts"; -import { copyProcessEnv } from "@/lib/env.ts"; +import { spawnSync } from 'node:child_process'; +import { getQueryDefaultPager } from '@/lib/config.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; -export type PagerMode = "auto" | "always" | "never"; +export type PagerMode = 'auto' | 'always' | 'never'; export type PagerOptions = { mode: PagerMode; @@ -16,43 +16,47 @@ export function resolvePagerOptions(cliMode?: PagerMode): PagerOptions { } const defaultPager = getQueryDefaultPager(); - return { mode: defaultPager ?? "auto" }; + return { mode: defaultPager ?? 'auto' }; } export function shouldUsePager(text: string, options: PagerOptions): boolean { - if (options.mode === "never") { + if (options.mode === 'never') { return false; } if (!process.stdout.isTTY) { return false; } - if (options.mode === "always") { + if (options.mode === 'always') { return true; } - const lineCount = text.split("\n").length; + const lineCount = text.split('\n').length; const rows = process.stdout.rows ?? 24; const columns = process.stdout.columns ?? 80; - const hasWideLine = text.split("\n").some((line) => getVisibleTextWidth(line) > columns); + const hasWideLine = text + .split('\n') + .some(line => getVisibleTextWidth(line) > columns); return lineCount > rows || hasWideLine; } -export function buildPagerEnv(env: NodeJS.ProcessEnv = copyProcessEnv()): NodeJS.ProcessEnv { - return { ...env, LESS: "FRX", LESSCHARSET: env.LESSCHARSET ?? "utf-8" }; +export function buildPagerEnv( + env: NodeJS.ProcessEnv = copyProcessEnv() +): NodeJS.ProcessEnv { + return { ...env, LESS: 'FRX', LESSCHARSET: env.LESSCHARSET ?? 'utf-8' }; } export async function writePagedOutput( text: string, options: PagerOptions, - sink: OutputSink, + sink: OutputSink ): Promise { if (!shouldUsePager(text, options)) { sink.writeHuman(text); return; } - const result = spawnSync("less", ["-SR"], { + const result = spawnSync('less', ['-SR'], { input: text, - stdio: ["pipe", "inherit", "inherit"], + stdio: ['pipe', 'inherit', 'inherit'], env: buildPagerEnv(), }); diff --git a/cli/src/lib/parse-api-json.ts b/cli/src/lib/parse-api-json.ts index e048bf31..ae80a26b 100644 --- a/cli/src/lib/parse-api-json.ts +++ b/cli/src/lib/parse-api-json.ts @@ -1,11 +1,11 @@ -import { ParseError } from "@/lib/errors.ts"; -import { truncateBodySnippet } from "@/lib/redact.ts"; +import { ParseError } from '@/lib/errors.ts'; +import { truncateBodySnippet } from '@/lib/redact.ts'; export function parseApiJson(body: string): unknown { try { return JSON.parse(body) as unknown; } catch { - throw new ParseError("Response body is not valid JSON.", { + throw new ParseError('Response body is not valid JSON.', { details: truncateBodySnippet(body), }); } diff --git a/cli/src/lib/pluralize.test.ts b/cli/src/lib/pluralize.test.ts index 249abdc7..c4ad3077 100644 --- a/cli/src/lib/pluralize.test.ts +++ b/cli/src/lib/pluralize.test.ts @@ -1,26 +1,26 @@ -import { describe, expect, test } from "bun:test"; -import { pluralize, pluralizeLabel } from "@/lib/pluralize.ts"; +import { describe, expect, test } from 'bun:test'; +import { pluralize, pluralizeLabel } from '@/lib/pluralize.ts'; -describe("pluralize", () => { - test("returns singular for count of 1", () => { - expect(pluralize(1, "row")).toBe("row"); - expect(pluralize(1, "minute")).toBe("minute"); +describe('pluralize', () => { + test('returns singular for count of 1', () => { + expect(pluralize(1, 'row')).toBe('row'); + expect(pluralize(1, 'minute')).toBe('minute'); }); - test("returns plural for other counts", () => { - expect(pluralize(0, "row")).toBe("rows"); - expect(pluralize(2, "minute")).toBe("minutes"); - expect(pluralize(5, "day")).toBe("days"); + test('returns plural for other counts', () => { + expect(pluralize(0, 'row')).toBe('rows'); + expect(pluralize(2, 'minute')).toBe('minutes'); + expect(pluralize(5, 'day')).toBe('days'); }); - test("accepts an explicit plural form", () => { - expect(pluralize(2, "child", "children")).toBe("children"); + test('accepts an explicit plural form', () => { + expect(pluralize(2, 'child', 'children')).toBe('children'); }); }); -describe("pluralizeLabel", () => { - test("combines count and pluralized noun", () => { - expect(pluralizeLabel(1, "row")).toBe("1 row"); - expect(pluralizeLabel(10, "row")).toBe("10 rows"); +describe('pluralizeLabel', () => { + test('combines count and pluralized noun', () => { + expect(pluralizeLabel(1, 'row')).toBe('1 row'); + expect(pluralizeLabel(10, 'row')).toBe('10 rows'); }); }); diff --git a/cli/src/lib/pluralize.ts b/cli/src/lib/pluralize.ts index 6e390246..0242b60f 100644 --- a/cli/src/lib/pluralize.ts +++ b/cli/src/lib/pluralize.ts @@ -1,10 +1,18 @@ -export function pluralize(count: number, singular: string, plural?: string): string { +export function pluralize( + count: number, + singular: string, + plural?: string +): string { if (count === 1) { return singular; } return plural ?? `${singular}s`; } -export function pluralizeLabel(count: number, singular: string, plural?: string): string { +export function pluralizeLabel( + count: number, + singular: string, + plural?: string +): string { return `${count} ${pluralize(count, singular, plural)}`; } diff --git a/cli/src/lib/profile-configure-core.test.ts b/cli/src/lib/profile-configure-core.test.ts index 24a5eabd..fe68e53a 100644 --- a/cli/src/lib/profile-configure-core.test.ts +++ b/cli/src/lib/profile-configure-core.test.ts @@ -1,30 +1,35 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { configGet } from "@/lib/config.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { configureRunClear, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { profileExists } from "@/lib/profile-store.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { secretGet, secretSet } from "@/lib/secrets.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; - -let testHome = ""; - -async function captureErrorMessage(operation: () => Promise): Promise { +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { configGet } from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { + configureRunClear, + configureRunSet, +} from '@/lib/profile-configure-core.ts'; +import { profileExists } from '@/lib/profile-store.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; + +let testHome = ''; + +async function captureErrorMessage( + operation: () => Promise +): Promise { try { await operation(); } catch (error) { return error instanceof Error ? error.message : String(error); } - throw new Error("Expected operation to fail"); + throw new Error('Expected operation to fail'); } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-configure-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-configure-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; }); afterEach(() => { @@ -33,68 +38,89 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); -describe("configureRunSet", () => { - test("accumulates management and lakehouse credentials across invocations", async () => { - await configureRunSet({ apiKey: "atm_test", env: "development" }); - await configureRunSet({ user: "alice", password: "lakehouse-secret" }); +describe('configureRunSet', () => { + test('accumulates management and lakehouse credentials across invocations', async () => { + await configureRunSet({ apiKey: 'atm_test', env: 'development' }); + await configureRunSet({ user: 'alice', password: 'lakehouse-secret' }); - expect(secretGet("api-key", "default")).toBe("atm_test"); - expect(configGet("api_key_env", "default")).toBe("development"); - expect(secretGet("lakehouse/password", "default")).toBe("lakehouse-secret"); - expect(configGet("user", "default")).toBe("alice"); + expect(secretGet('api-key', 'default')).toBe('atm_test'); + expect(configGet('api_key_env', 'default')).toBe('development'); + expect(secretGet('lakehouse/password', 'default')).toBe('lakehouse-secret'); + expect(configGet('user', 'default')).toBe('alice'); }); - test("writes configuration and credentials to a named profile", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); + test('writes configuration and credentials to a named profile', async () => { + await configureRunSet({ + profile: 'staging', + apiKey: 'atm_staging', + env: 'staging', + }); - expect(profileExists("staging")).toBe(true); - expect(configGet("api_key_env", "staging")).toBe("staging"); - expect(secretGet("api-key", "staging")).toBe("atm_staging"); + expect(profileExists('staging')).toBe(true); + expect(configGet('api_key_env', 'staging')).toBe('staging'); + expect(secretGet('api-key', 'staging')).toBe('atm_staging'); }); - test("rejects mixed authentication mechanisms with actionable messages", async () => { + test('rejects mixed authentication mechanisms with actionable messages', async () => { const mixedPlanes = await captureErrorMessage(() => - configureRunSet({ user: "u", password: "p", apiKey: "atm_x", env: "prod" }), + configureRunSet({ + user: 'u', + password: 'p', + apiKey: 'atm_x', + env: 'prod', + }) ); const mixedLakehouseCredentials = await captureErrorMessage(() => - configureRunSet({ user: "u", password: "p", basicToken: "dG9rZW4=" }), + configureRunSet({ user: 'u', password: 'p', basicToken: 'dG9rZW4=' }) ); - expect(mixedPlanes).toContain("single authentication mechanism per configure invocation"); - expect(mixedLakehouseCredentials).toContain("single lakehouse authentication mechanism"); + expect(mixedPlanes).toContain( + 'single authentication mechanism per configure invocation' + ); + expect(mixedLakehouseCredentials).toContain( + 'single lakehouse authentication mechanism' + ); }); - test("warns when a password is passed on argv", async () => { + test('warns when a password is passed on argv', async () => { const printed: string[] = []; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeHuman = (line) => printed.push(line); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + runtime.output.writeHuman = line => printed.push(line); await runWithCliRuntime(runtime, () => - configureRunSet({ user: "alice", password: "test-password-value" }), + configureRunSet({ user: 'alice', password: 'test-password-value' }) ); - expect(printed.some((line) => line.includes("--password-stdin"))).toBe(true); + expect(printed.some(line => line.includes('--password-stdin'))).toBe(true); }); }); -describe("configureRunClear", () => { - test("removes root files, profile directories, and profile secrets", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); - await configureRunSet({ profile: "prod", apiKey: "atm_prod", env: "prod" }); - secretSet("lakehouse/password", "lake-secret", "staging"); +describe('configureRunClear', () => { + test('removes root files, profile directories, and profile secrets', async () => { + await configureRunSet({ + profile: 'staging', + apiKey: 'atm_staging', + env: 'staging', + }); + await configureRunSet({ profile: 'prod', apiKey: 'atm_prod', env: 'prod' }); + secretSet('lakehouse/password', 'lake-secret', 'staging'); configureRunClear(); - expect(existsSync(join(testHome, "config"))).toBe(false); - expect(existsSync(join(testHome, "credentials"))).toBe(false); - expect(existsSync(join(testHome, "profiles"))).toBe(false); - expect(secretGet("api-key", "staging")).toBe(""); - expect(secretGet("api-key", "prod")).toBe(""); - expect(secretGet("lakehouse/password", "staging")).toBe(""); + expect(existsSync(join(testHome, 'config'))).toBe(false); + expect(existsSync(join(testHome, 'credentials'))).toBe(false); + expect(existsSync(join(testHome, 'profiles'))).toBe(false); + expect(secretGet('api-key', 'staging')).toBe(''); + expect(secretGet('api-key', 'prod')).toBe(''); + expect(secretGet('lakehouse/password', 'staging')).toBe(''); }); - test("reports failures while removing configuration", () => { - mkdirSync(join(testHome, "config")); + test('reports failures while removing configuration', () => { + mkdirSync(join(testHome, 'config')); expect(() => configureRunClear()).toThrow(ConfigurationError); }); diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index e0cac1c8..82778f22 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -1,7 +1,17 @@ -import { unlinkSync, rmSync, existsSync, readFileSync } from "node:fs"; -import { configFile, configGet, configSet, credentialsFile, kvUnset } from "@/lib/config.ts"; -import { CliError, ConfigurationError } from "@/lib/errors.ts"; -import { deleteProfile, deriveProfileName, listProfiles } from "@/lib/profile/model.ts"; +import { unlinkSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { + configFile, + configGet, + configSet, + credentialsFile, + kvUnset, +} from '@/lib/config.ts'; +import { CliError, ConfigurationError } from '@/lib/errors.ts'; +import { + deleteProfile, + deriveProfileName, + listProfiles, +} from '@/lib/profile/model.ts'; import { ensureProfileExists, getActiveProfileName, @@ -10,17 +20,21 @@ import { profilesDir, resolveWorkingProfile, setActiveProfile, -} from "@/lib/profile-store.ts"; -import { pluralizeLabel } from "@/lib/pluralize.ts"; -import { getCliContext } from "@/context.ts"; -import { secretDelete, secretSet } from "@/lib/secrets.ts"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; -import { getCliRuntime, getOutputSink, type OutputSink } from "@/lib/runtime.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +} from '@/lib/profile-store.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; +import { getCliContext } from '@/context.ts'; +import { secretDelete, secretSet } from '@/lib/secrets.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; +import { + getCliRuntime, + getOutputSink, + type OutputSink, +} from '@/lib/runtime.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; const ARGV_SECRET_WARNING = - "Warning: passing secrets on the command line is visible in process listings. Prefer --password-stdin / --api-key-stdin."; + 'Warning: passing secrets on the command line is visible in process listings. Prefer --password-stdin / --api-key-stdin.'; export type ConfigureOptions = { user?: string; @@ -40,186 +54,222 @@ export type ConfigureOptions = { interactive?: boolean; }; -const AUTO_PROFILE_NAME = "auto"; +const AUTO_PROFILE_NAME = 'auto'; function markCurrentProfileUpdated(profileName: string): void { const timestamp = new Date().toISOString(); - if (!configGet("created_at", profileName)) { - configSet("created_at", timestamp, profileName); + if (!configGet('created_at', profileName)) { + configSet('created_at', timestamp, profileName); } - configSet("updated_at", timestamp, profileName); + configSet('updated_at', timestamp, profileName); } -function clearProfileEndpoint(key: "api_base" | "management_api_base", profileName: string): void { +function clearProfileEndpoint( + key: 'api_base' | 'management_api_base', + profileName: string +): void { kvUnset(profileConfigFile(profileName), key); } -function resolveConfigureProfile(options: ConfigureOptions, org: string): string { +function resolveConfigureProfile( + options: ConfigureOptions, + org: string +): string { const explicitProfile = options.profile; - const env = options.env ?? ""; + const env = options.env ?? ''; if (explicitProfile && explicitProfile !== AUTO_PROFILE_NAME) { return explicitProfile; } - if (explicitProfile === AUTO_PROFILE_NAME && options.interactive && org && env) { + if ( + explicitProfile === AUTO_PROFILE_NAME && + options.interactive && + org && + env + ) { return deriveProfileName(org, env); } if (explicitProfile === AUTO_PROFILE_NAME) { - throw new CliError("--profile auto is only supported by interactive configure."); + throw new CliError( + '--profile auto is only supported by interactive configure.' + ); } return resolveWorkingProfile(getCliContext().profile); } export async function withConfigureProfileContext( profileName: string, - run: (profileName: string) => Promise | T, + run: (profileName: string) => Promise | T ): Promise { ensureProfileExists(profileName); return await run(profileName); } function configureClearLakehouseCredentials(profileName: string): void { - secretDelete("lakehouse/password", profileName); - secretDelete("lakehouse/basic-token", profileName); - configSet("user", "", profileName); - configSet("lakehouse_credential_expiry", "", profileName); + secretDelete('lakehouse/password', profileName); + secretDelete('lakehouse/basic-token', profileName); + configSet('user', '', profileName); + configSet('lakehouse_credential_expiry', '', profileName); } function configureClearManagementCredentials(profileName: string): void { - secretDelete("api-key", profileName); - secretDelete("oauth/access-token", profileName); - secretDelete("oauth/refresh-token", profileName); - configSet("api_key_env", "", profileName); - configSet("oauth_expiry", "", profileName); + secretDelete('api-key', profileName); + secretDelete('oauth/access-token', profileName); + secretDelete('oauth/refresh-token', profileName); + configSet('api_key_env', '', profileName); + configSet('oauth_expiry', '', profileName); } export async function configureRunSet( options: ConfigureOptions, sink: OutputSink = getOutputSink(), - org = "", + org = '' ): Promise { - return withConfigureProfileContext(resolveConfigureProfile(options, org), async (profileName) => { - let user = options.user ?? ""; - let password = options.password ?? ""; - let apiKey = options.apiKey ?? ""; - const basicToken = options.basicToken ?? ""; - const env = options.env ?? ""; - const dataPlaneUrl = options.dataPlaneUrl ?? ""; - const controlPlaneUrl = options.controlPlaneUrl ?? ""; - const allowInsecureHttp = options.allowInsecureHttp ?? false; + return withConfigureProfileContext( + resolveConfigureProfile(options, org), + async profileName => { + let user = options.user ?? ''; + let password = options.password ?? ''; + let apiKey = options.apiKey ?? ''; + const basicToken = options.basicToken ?? ''; + const env = options.env ?? ''; + const dataPlaneUrl = options.dataPlaneUrl ?? ''; + const controlPlaneUrl = options.controlPlaneUrl ?? ''; + const allowInsecureHttp = options.allowInsecureHttp ?? false; - const passwordFromArgv = - Boolean(options.password) && !options.passwordStdin && !options.interactive; - const apiKeyFromArgv = Boolean(options.apiKey) && !options.apiKeyStdin && !options.interactive; - if (passwordFromArgv || apiKeyFromArgv) { - sink.writeHuman(renderDisplayText([span(ARGV_SECRET_WARNING, "subtle")])); - } + const passwordFromArgv = + Boolean(options.password) && + !options.passwordStdin && + !options.interactive; + const apiKeyFromArgv = + Boolean(options.apiKey) && !options.apiKeyStdin && !options.interactive; + if (passwordFromArgv || apiKeyFromArgv) { + sink.writeHuman( + renderDisplayText([span(ARGV_SECRET_WARNING, 'subtle')]) + ); + } - const hasAnyInput = - user || - password || - basicToken || - apiKey || - env || - options.passwordStdin || - options.apiKeyStdin || - dataPlaneUrl || - controlPlaneUrl; + const hasAnyInput = + user || + password || + basicToken || + apiKey || + env || + options.passwordStdin || + options.apiKeyStdin || + dataPlaneUrl || + controlPlaneUrl; - if (!hasAnyInput) { - throw new CliError( - "Nothing to configure. Use --user/--password, --basic-token, or --api-key --env .", - ); - } + if (!hasAnyInput) { + throw new CliError( + 'Nothing to configure. Use --user/--password, --basic-token, or --api-key --env .' + ); + } - // Bun's asynchronous stdin read can let Windows exit before credentials are saved. - if (options.passwordStdin) { - password = readFileSync(0, "utf8").replace(/\n$/, ""); - } - if (options.apiKeyStdin) { - apiKey = readFileSync(0, "utf8").replace(/\n$/, ""); - } + // Bun's asynchronous stdin read can let Windows exit before credentials are saved. + if (options.passwordStdin) { + password = readFileSync(0, 'utf8').replace(/\n$/, ''); + } + if (options.apiKeyStdin) { + apiKey = readFileSync(0, 'utf8').replace(/\n$/, ''); + } - const hasLakehouseCredentials = Boolean(user || password); - const hasLakehouseBasicToken = Boolean(basicToken); - const hasApiKey = Boolean(apiKey); - const lakehouseMechanismCount = - Number(hasLakehouseCredentials) + Number(hasLakehouseBasicToken); - const hasAnyCredential = hasLakehouseCredentials || hasLakehouseBasicToken || hasApiKey; + const hasLakehouseCredentials = Boolean(user || password); + const hasLakehouseBasicToken = Boolean(basicToken); + const hasApiKey = Boolean(apiKey); + const lakehouseMechanismCount = + Number(hasLakehouseCredentials) + Number(hasLakehouseBasicToken); + const hasAnyCredential = + hasLakehouseCredentials || hasLakehouseBasicToken || hasApiKey; - if (lakehouseMechanismCount > 1) { - throw new CliError( - "Choose a single lakehouse authentication mechanism: --user/--password or --basic-token.", - ); - } - if (hasApiKey && (hasLakehouseCredentials || hasLakehouseBasicToken)) { - throw new CliError( - "Choose a single authentication mechanism per configure invocation: lakehouse credentials or --api-key.", - ); - } - if (controlPlaneUrl && !hasAnyCredential) { - throw new CliError("--control-plane-url must be set together with a credential."); - } - if (env && !hasApiKey) { - throw new CliError("--env applies only to --api-key."); - } - if (!hasAnyCredential && !dataPlaneUrl) { - throw new CliError( - "Nothing to configure. Use --user/--password, --basic-token, or --api-key --env .", - ); - } - if (hasLakehouseCredentials && (!user || !password)) { - throw new CliError("--user and --password must be provided together."); - } - if (hasApiKey && !env) { - throw new CliError("--api-key requires --env ."); - } + if (lakehouseMechanismCount > 1) { + throw new CliError( + 'Choose a single lakehouse authentication mechanism: --user/--password or --basic-token.' + ); + } + if (hasApiKey && (hasLakehouseCredentials || hasLakehouseBasicToken)) { + throw new CliError( + 'Choose a single authentication mechanism per configure invocation: lakehouse credentials or --api-key.' + ); + } + if (controlPlaneUrl && !hasAnyCredential) { + throw new CliError( + '--control-plane-url must be set together with a credential.' + ); + } + if (env && !hasApiKey) { + throw new CliError('--env applies only to --api-key.'); + } + if (!hasAnyCredential && !dataPlaneUrl) { + throw new CliError( + 'Nothing to configure. Use --user/--password, --basic-token, or --api-key --env .' + ); + } + if (hasLakehouseCredentials && (!user || !password)) { + throw new CliError('--user and --password must be provided together.'); + } + if (hasApiKey && !env) { + throw new CliError('--api-key requires --env .'); + } - if (hasApiKey) { - configureClearManagementCredentials(profileName); - secretSet("api-key", apiKey, profileName, { fromArgv: apiKeyFromArgv }); - configSet("api_key_env", env, profileName); - if (org) { - configSet("organization_slug", org, profileName); + if (hasApiKey) { + configureClearManagementCredentials(profileName); + secretSet('api-key', apiKey, profileName, { fromArgv: apiKeyFromArgv }); + configSet('api_key_env', env, profileName); + if (org) { + configSet('organization_slug', org, profileName); + } + } + if (hasLakehouseBasicToken) { + configureClearLakehouseCredentials(profileName); + secretSet('lakehouse/basic-token', basicToken, profileName); + } else if (hasLakehouseCredentials) { + configureClearLakehouseCredentials(profileName); + configSet('user', user, profileName); + secretSet('lakehouse/password', password, profileName, { + fromArgv: passwordFromArgv, + }); } - } - if (hasLakehouseBasicToken) { - configureClearLakehouseCredentials(profileName); - secretSet("lakehouse/basic-token", basicToken, profileName); - } else if (hasLakehouseCredentials) { - configureClearLakehouseCredentials(profileName); - configSet("user", user, profileName); - secretSet("lakehouse/password", password, profileName, { fromArgv: passwordFromArgv }); - } - if (dataPlaneUrl) { - assertAllowedApiBase(dataPlaneUrl, { allowInsecureHttp }); - configSet("api_base", dataPlaneUrl, profileName); - } else if (hasAnyCredential) { - clearProfileEndpoint("api_base", profileName); - } - if (controlPlaneUrl) { - assertAllowedApiBase(controlPlaneUrl, { allowInsecureHttp }); - configSet("management_api_base", controlPlaneUrl, profileName); - } else if (hasAnyCredential) { - clearProfileEndpoint("management_api_base", profileName); - } + if (dataPlaneUrl) { + assertAllowedApiBase(dataPlaneUrl, { allowInsecureHttp }); + configSet('api_base', dataPlaneUrl, profileName); + } else if (hasAnyCredential) { + clearProfileEndpoint('api_base', profileName); + } + if (controlPlaneUrl) { + assertAllowedApiBase(controlPlaneUrl, { allowInsecureHttp }); + configSet('management_api_base', controlPlaneUrl, profileName); + } else if (hasAnyCredential) { + clearProfileEndpoint('management_api_base', profileName); + } - if (hasApiKey) { - sink.writeHuman( - renderDisplayText([span(`Saved management API key for environment ${env}.`, "subtle")]), - ); - } else if (hasLakehouseBasicToken) { - sink.writeHuman(renderDisplayText([span("Saved lakehouse Basic token.", "subtle")])); - } else if (hasLakehouseCredentials) { - sink.writeHuman( - renderDisplayText([span(`Saved lakehouse credentials for user ${user}.`, "subtle")]), - ); - } else if (dataPlaneUrl) { - sink.writeHuman(renderDisplayText([span(`Saved data plane URL ${dataPlaneUrl}.`, "subtle")])); + if (hasApiKey) { + sink.writeHuman( + renderDisplayText([ + span(`Saved management API key for environment ${env}.`, 'subtle'), + ]) + ); + } else if (hasLakehouseBasicToken) { + sink.writeHuman( + renderDisplayText([span('Saved lakehouse Basic token.', 'subtle')]) + ); + } else if (hasLakehouseCredentials) { + sink.writeHuman( + renderDisplayText([ + span(`Saved lakehouse credentials for user ${user}.`, 'subtle'), + ]) + ); + } else if (dataPlaneUrl) { + sink.writeHuman( + renderDisplayText([ + span(`Saved data plane URL ${dataPlaneUrl}.`, 'subtle'), + ]) + ); + } + markCurrentProfileUpdated(profileName); } - markCurrentProfileUpdated(profileName); - }); + ); } export type ConfigureClearOptions = { @@ -233,7 +283,7 @@ function configureClearExceptCurrent(sink: OutputSink): void { setActiveProfile(keptProfile); } - const removed = listProfileNames().filter((name) => name !== keptProfile); + const removed = listProfileNames().filter(name => name !== keptProfile); for (const name of removed) { deleteProfile(name); } @@ -242,13 +292,13 @@ function configureClearExceptCurrent(sink: OutputSink): void { const summary = removed.length === 0 ? `No other profiles to clear; kept "${keptProfile}".` - : `Cleared ${pluralizeLabel(removed.length, "profile")}; kept "${keptProfile}".`; - sink.writeHuman(renderDisplayText([span(summary, "subtle")])); + : `Cleared ${pluralizeLabel(removed.length, 'profile')}; kept "${keptProfile}".`; + sink.writeHuman(renderDisplayText([span(summary, 'subtle')])); } export function configureRunClear( sink: OutputSink = getOutputSink(), - options: ConfigureClearOptions = {}, + options: ConfigureClearOptions = {} ): void { if (options.exceptCurrent) { configureClearExceptCurrent(sink); @@ -265,24 +315,32 @@ export function configureRunClear( unlinkSync(filePath); } catch (error) { if ( - typeof error !== "object" || + typeof error !== 'object' || error === null || - !("code" in error) || - (error as { code?: unknown }).code !== "ENOENT" + !('code' in error) || + (error as { code?: unknown }).code !== 'ENOENT' ) { - throw new ConfigurationError(`Unable to remove configuration file: ${filePath}`, { - cause: error, - }); + throw new ConfigurationError( + `Unable to remove configuration file: ${filePath}`, + { + cause: error, + } + ); } } } try { rmSync(profilesDir(), { recursive: true, force: true }); } catch (error) { - throw new ConfigurationError(`Unable to remove profiles directory: ${profilesDir()}`, { - cause: error, - }); + throw new ConfigurationError( + `Unable to remove profiles directory: ${profilesDir()}`, + { + cause: error, + } + ); } getCliRuntime().session = undefined; - sink.writeHuman(renderDisplayText([span("Cleared all altertable configuration.", "subtle")])); + sink.writeHuman( + renderDisplayText([span('Cleared all altertable configuration.', 'subtle')]) + ); } diff --git a/cli/src/lib/profile-configure-credential-status.test.ts b/cli/src/lib/profile-configure-credential-status.test.ts index f4e7e10d..f236d40b 100644 --- a/cli/src/lib/profile-configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -1,28 +1,28 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { buildConfigureShowData, configureCredentialStatus, lakehousePlaneStatusDetail, managementPlaneStatusDetail, -} from "@/lib/profile/model.ts"; +} from '@/lib/profile/model.ts'; import { formatConfigureAuthenticationLines, formatConfigureSessionSummary, -} from "@/lib/profile/render.ts"; -import { secretSet } from "@/lib/secrets.ts"; -import { configSet } from "@/lib/config.ts"; +} from '@/lib/profile/render.ts'; +import { secretSet } from '@/lib/secrets.ts'; +import { configSet } from '@/lib/config.ts'; -const profileName = "default"; +const profileName = 'default'; -let testHome = ""; +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-credential-status-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-credential-status-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; }); afterEach(() => { @@ -36,161 +36,173 @@ afterEach(() => { delete process.env.ALTERTABLE_ENV; }); -describe("configureCredentialStatus", () => { - test("detects stored and environment credentials", () => { +describe('configureCredentialStatus', () => { + test('detects stored and environment credentials', () => { expect(configureCredentialStatus(profileName)).toEqual({ hasManagement: false, hasLakehouse: false, }); - secretSet("api-key", "atm_test", profileName); + secretSet('api-key', 'atm_test', profileName); expect(configureCredentialStatus(profileName).hasManagement).toBe(true); - process.env.ALTERTABLE_API_KEY = "atm_env"; + process.env.ALTERTABLE_API_KEY = 'atm_env'; expect(configureCredentialStatus(profileName).hasManagement).toBe(true); - secretSet("lakehouse/password", "secret", profileName); - configSet("user", "alice", profileName); + secretSet('lakehouse/password', 'secret', profileName); + configSet('user', 'alice', profileName); expect(configureCredentialStatus(profileName).hasLakehouse).toBe(true); }); - test("detects environment lakehouse credentials without stored secrets", () => { - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; + test('detects environment lakehouse credentials without stored secrets', () => { + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; expect(configureCredentialStatus(profileName)).toEqual({ hasManagement: false, hasLakehouse: true, }); }); - test("plane status detail reflects environment credentials", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(managementPlaneStatusDetail(profileName)).toBe("via ALTERTABLE_API_KEY"); + test('plane status detail reflects environment credentials', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; + expect(managementPlaneStatusDetail(profileName)).toBe( + 'via ALTERTABLE_API_KEY' + ); - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "dG9rZW4="; - expect(lakehousePlaneStatusDetail(profileName)).toBe("via ALTERTABLE_BASIC_AUTH_TOKEN"); + process.env.ALTERTABLE_BASIC_AUTH_TOKEN = 'dG9rZW4='; + expect(lakehousePlaneStatusDetail(profileName)).toBe( + 'via ALTERTABLE_BASIC_AUTH_TOKEN' + ); delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - expect(lakehousePlaneStatusDetail(profileName)).toBe("env-user"); + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'env-user'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'env-pass'; + expect(lakehousePlaneStatusDetail(profileName)).toBe('env-user'); }); }); -describe("formatConfigureAuthenticationLines", () => { - test("includes environment-only management credentials", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; - process.env.ALTERTABLE_ENV = "staging"; +describe('formatConfigureAuthenticationLines', () => { + test('includes environment-only management credentials', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; + process.env.ALTERTABLE_ENV = 'staging'; - const lines = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }); - expect(lines.join("\n")).toContain("ALTERTABLE_API_KEY"); - expect(lines.join("\n")).toContain("staging"); + const lines = formatConfigureAuthenticationLines(profileName, { + planes: ['management'], + }); + expect(lines.join('\n')).toContain('ALTERTABLE_API_KEY'); + expect(lines.join('\n')).toContain('staging'); }); - test("session summary includes only configured planes", () => { - secretSet("api-key", "atm_test", profileName); - configSet("api_key_env", "production", profileName); - secretSet("lakehouse/password", "secret", profileName); - configSet("user", "alice", profileName); + test('session summary includes only configured planes', () => { + secretSet('api-key', 'atm_test', profileName); + configSet('api_key_env', 'production', profileName); + secretSet('lakehouse/password', 'secret', profileName); + configSet('user', 'alice', profileName); - const summary = formatConfigureSessionSummary(profileName, ["management"]); - expect(summary.join("\n")).toContain("management API key"); - expect(summary.join("\n")).not.toContain("lakehouse"); + const summary = formatConfigureSessionSummary(profileName, ['management']); + expect(summary.join('\n')).toContain('management API key'); + expect(summary.join('\n')).not.toContain('lakehouse'); }); }); -describe("OAuth login status", () => { +describe('OAuth login status', () => { function loginViaOAuth(): void { - secretSet("oauth/access-token", "tok", profileName); - configSet("oauth_expiry", String(Date.now() + 3_600_000), profileName); - configSet("api_key_env", "production", profileName); + secretSet('oauth/access-token', 'tok', profileName); + configSet('oauth_expiry', String(Date.now() + 3_600_000), profileName); + configSet('api_key_env', 'production', profileName); } - test("counts as a management credential", () => { + test('counts as a management credential', () => { loginViaOAuth(); expect(configureCredentialStatus(profileName).hasManagement).toBe(true); }); - test("shows browser login (OAuth), not the api-key lines", () => { + test('shows browser login (OAuth), not the api-key lines', () => { loginViaOAuth(); - configSet("organization_slug", "altertable", profileName); - configSet("organization_name", "Altertable", profileName); - const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( - "\n", - ); - expect(text).toContain("browser login (OAuth)"); - expect(text).toContain("production"); - expect(text).toContain("Organization:"); - expect(text).toContain("Altertable (altertable)"); - expect(text).not.toContain("api key:"); - expect(text).not.toContain("management API key"); + configSet('organization_slug', 'altertable', profileName); + configSet('organization_name', 'Altertable', profileName); + const text = formatConfigureAuthenticationLines(profileName, { + planes: ['management'], + }).join('\n'); + expect(text).toContain('browser login (OAuth)'); + expect(text).toContain('production'); + expect(text).toContain('Organization:'); + expect(text).toContain('Altertable (altertable)'); + expect(text).not.toContain('api key:'); + expect(text).not.toContain('management API key'); }); - test("OAuth login shadows a leftover stored api key", () => { - secretSet("api-key", "atm_leftover", profileName); + test('OAuth login shadows a leftover stored api key', () => { + secretSet('api-key', 'atm_leftover', profileName); loginViaOAuth(); - const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( - "\n", + const text = formatConfigureAuthenticationLines(profileName, { + planes: ['management'], + }).join('\n'); + expect(text).toContain('browser login (OAuth)'); + expect(text).not.toContain('api key:'); + expect(managementPlaneStatusDetail(profileName)).toBe( + 'OAuth login (production)' ); - expect(text).toContain("browser login (OAuth)"); - expect(text).not.toContain("api key:"); - expect(managementPlaneStatusDetail(profileName)).toBe("OAuth login (production)"); - expect(buildConfigureShowData(profileName).credentials.management).toMatchObject({ - mechanism: "management_oauth", - oauth: "set", + expect( + buildConfigureShowData(profileName).credentials.management + ).toMatchObject({ + mechanism: 'management_oauth', + oauth: 'set', }); }); - test("ALTERTABLE_API_KEY env still takes precedence over OAuth", () => { + test('ALTERTABLE_API_KEY env still takes precedence over OAuth', () => { loginViaOAuth(); - process.env.ALTERTABLE_API_KEY = "atm_env"; - const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( - "\n", + process.env.ALTERTABLE_API_KEY = 'atm_env'; + const text = formatConfigureAuthenticationLines(profileName, { + planes: ['management'], + }).join('\n'); + expect(text).toContain('ALTERTABLE_API_KEY'); + expect(text).not.toContain('browser login (OAuth)'); + expect(managementPlaneStatusDetail(profileName)).toBe( + 'via ALTERTABLE_API_KEY' ); - expect(text).toContain("ALTERTABLE_API_KEY"); - expect(text).not.toContain("browser login (OAuth)"); - expect(managementPlaneStatusDetail(profileName)).toBe("via ALTERTABLE_API_KEY"); }); }); -describe("buildConfigureShowData", () => { - test("returns structured credential status without secrets", () => { - secretSet("api-key", "atm_test", profileName); - configSet("api_key_env", "production", profileName); - secretSet("lakehouse/password", "lakehouse-pass", profileName); - configSet("user", "alice", profileName); +describe('buildConfigureShowData', () => { + test('returns structured credential status without secrets', () => { + secretSet('api-key', 'atm_test', profileName); + configSet('api_key_env', 'production', profileName); + secretSet('lakehouse/password', 'lakehouse-pass', profileName); + configSet('user', 'alice', profileName); const data = buildConfigureShowData(profileName); expect(data.credentials.management).toEqual({ configured: true, - mechanism: "management_api_key", - source: "stored", - environment: "production", - api_key: "set", + mechanism: 'management_api_key', + source: 'stored', + environment: 'production', + api_key: 'set', }); expect(data.credentials.lakehouse).toEqual({ configured: true, - mechanism: "lakehouse_username_password", - source: "stored", - user: "alice", - password: "set", + mechanism: 'lakehouse_username_password', + source: 'stored', + user: 'alice', + password: 'set', }); - expect(JSON.stringify(data)).not.toContain("atm_test"); - expect(JSON.stringify(data)).not.toContain("lakehouse-pass"); + expect(JSON.stringify(data)).not.toContain('atm_test'); + expect(JSON.stringify(data)).not.toContain('lakehouse-pass'); }); - test("includes environment overrides", () => { - secretSet("api-key", "atm_test", profileName); - configSet("api_key_env", "production", profileName); - process.env.ALTERTABLE_ENV = "staging"; - process.env.ALTERTABLE_API_KEY = "atm_override"; + test('includes environment overrides', () => { + secretSet('api-key', 'atm_test', profileName); + configSet('api_key_env', 'production', profileName); + process.env.ALTERTABLE_ENV = 'staging'; + process.env.ALTERTABLE_API_KEY = 'atm_override'; const data = buildConfigureShowData(profileName); expect(data.overrides).toEqual({ - environment: "staging", - stored_environment: "production", + environment: 'staging', + stored_environment: 'production', api_key: true, }); - expect(JSON.stringify(data)).not.toContain("atm_override"); + expect(JSON.stringify(data)).not.toContain('atm_override'); }); }); diff --git a/cli/src/lib/profile-configure-data-plane.test.ts b/cli/src/lib/profile-configure-data-plane.test.ts index b98135a0..59dc4ec2 100644 --- a/cli/src/lib/profile-configure-data-plane.test.ts +++ b/cli/src/lib/profile-configure-data-plane.test.ts @@ -1,26 +1,26 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { getBootstrapCliContext } from "@/context.ts"; -import { configGet } from "@/lib/config.ts"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { secretGet } from "@/lib/secrets.ts"; - -let testHome = ""; -const profileName = "default"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getBootstrapCliContext } from '@/context.ts'; +import { configGet } from '@/lib/config.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { secretGet } from '@/lib/secrets.ts'; + +let testHome = ''; +const profileName = 'default'; function runInTestHome(run: () => T | Promise): T | Promise { process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; const runtime = createCliRuntime(getBootstrapCliContext()); return runWithCliRuntime(runtime, run); } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-configure-url-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-configure-url-test-')); }); afterEach(() => { @@ -29,25 +29,29 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); -describe("profile configure --data-plane-url alone", () => { - test("sets api_base and nothing else", async () => { +describe('profile configure --data-plane-url alone', () => { + test('sets api_base and nothing else', async () => { await runInTestHome(async () => { - await configureRunSet({ apiKey: "atm_test", env: "staging" }); + await configureRunSet({ apiKey: 'atm_test', env: 'staging' }); - await configureRunSet({ dataPlaneUrl: "https://data.example.com" }); + await configureRunSet({ dataPlaneUrl: 'https://data.example.com' }); - expect(configGet("api_base", profileName)).toBe("https://data.example.com"); - expect(secretGet("api-key", profileName)).toBe("atm_test"); - expect(configGet("api_key_env", profileName)).toBe("staging"); - expect(configGet("management_api_base", profileName)).toBe(""); + expect(configGet('api_base', profileName)).toBe( + 'https://data.example.com' + ); + expect(secretGet('api-key', profileName)).toBe('atm_test'); + expect(configGet('api_key_env', profileName)).toBe('staging'); + expect(configGet('management_api_base', profileName)).toBe(''); }); }); - test("--control-plane-url alone is still rejected", async () => { + test('--control-plane-url alone is still rejected', async () => { await runInTestHome(() => { return expect( - configureRunSet({ controlPlaneUrl: "https://app.example.com" }), - ).rejects.toThrow("--control-plane-url must be set together with a credential."); + configureRunSet({ controlPlaneUrl: 'https://app.example.com' }) + ).rejects.toThrow( + '--control-plane-url must be set together with a credential.' + ); }); }); }); diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index 0f9df1e8..d5c4055b 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -1,18 +1,18 @@ -import { configGet } from "@/lib/config.ts"; -import { CliError } from "@/lib/errors.ts"; -import { secretExists, secretGet } from "@/lib/secrets.ts"; -import { type ConfigureOptions } from "@/lib/profile-configure-core.ts"; +import { configGet } from '@/lib/config.ts'; +import { CliError } from '@/lib/errors.ts'; +import { secretExists, secretGet } from '@/lib/secrets.ts'; +import { type ConfigureOptions } from '@/lib/profile-configure-core.ts'; import { configureCredentialStatus, lakehousePlaneStatusDetail, managementPlaneStatusDetail, -} from "@/lib/profile/model.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import type { Prompts, SelectOption } from "@/ui/prompts.ts"; +} from '@/lib/profile/model.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import type { Prompts, SelectOption } from '@/ui/prompts.ts'; -export type ConfigureWizardScope = "both" | "management" | "lakehouse"; +export type ConfigureWizardScope = 'both' | 'management' | 'lakehouse'; export type ConfigureWizardOptions = { scope?: ConfigureWizardScope; @@ -23,40 +23,49 @@ export type ConfigureWizardOptions = { sink?: OutputSink; }; -const DEFAULT_CONTROL_PLANE_URL = "https://app.altertable.ai"; -const DEFAULT_DATA_PLANE_URL = "https://api.altertable.ai"; -const DEFAULT_SCOPE: ConfigureWizardScope = "both"; -const AUTO_PROFILE_NAME = "auto"; +const DEFAULT_CONTROL_PLANE_URL = 'https://app.altertable.ai'; +const DEFAULT_DATA_PLANE_URL = 'https://api.altertable.ai'; +const DEFAULT_SCOPE: ConfigureWizardScope = 'both'; +const AUTO_PROFILE_NAME = 'auto'; -const HIDDEN_PROMPT_HINT = "(hidden)"; -const SCOPE_SELECT_TITLE = "What to configure?"; +const HIDDEN_PROMPT_HINT = '(hidden)'; +const SCOPE_SELECT_TITLE = 'What to configure?'; const CONFIGURE_SCOPE_SELECT_OPTIONS = { leadingNewline: false, }; -type PromptScopeSelection = "management" | "lakehouse" | "both"; +type PromptScopeSelection = 'management' | 'lakehouse' | 'both'; type ScopePromptModel = { defaultValue: PromptScopeSelection; options: SelectOption[]; }; -function formatScopeSelectLabel(plane: "management" | "lakehouse", profileName: string): string { - const name = plane === "management" ? "Management" : "Lakehouse"; +function formatScopeSelectLabel( + plane: 'management' | 'lakehouse', + profileName: string +): string { + const name = plane === 'management' ? 'Management' : 'Lakehouse'; const detail = - plane === "management" + plane === 'management' ? managementPlaneStatusDetail(profileName) : lakehousePlaneStatusDetail(profileName); if (!detail) { - return renderDisplayText([span(name, "strong"), span(" not set", "muted")]); + return renderDisplayText([span(name, 'strong'), span(' not set', 'muted')]); } - return renderDisplayText([span(name, "strong"), span(` ${detail}`, "subtle")]); + return renderDisplayText([ + span(name, 'strong'), + span(` ${detail}`, 'subtle'), + ]); } -function toScopePromptOption(value: PromptScopeSelection, profileName: string): SelectOption { - if (value === "both") { - return { value, label: renderDisplayText([span("Both", "strong")]) }; +function toScopePromptOption( + value: PromptScopeSelection, + profileName: string +): SelectOption { + if (value === 'both') { + return { value, label: renderDisplayText([span('Both', 'strong')]) }; } return { value, label: formatScopeSelectLabel(value, profileName) }; } @@ -64,51 +73,55 @@ function toScopePromptOption(value: PromptScopeSelection, profileName: string): function buildScopePromptModel( hasManagement: boolean, hasLakehouse: boolean, - profileName: string, + profileName: string ): ScopePromptModel { if (hasManagement && hasLakehouse) { return { - defaultValue: "both", + defaultValue: 'both', options: [ - toScopePromptOption("management", profileName), - toScopePromptOption("lakehouse", profileName), - toScopePromptOption("both", profileName), + toScopePromptOption('management', profileName), + toScopePromptOption('lakehouse', profileName), + toScopePromptOption('both', profileName), ], }; } if (hasManagement) { return { - defaultValue: "lakehouse", + defaultValue: 'lakehouse', options: [ - toScopePromptOption("lakehouse", profileName), - toScopePromptOption("management", profileName), + toScopePromptOption('lakehouse', profileName), + toScopePromptOption('management', profileName), ], }; } if (hasLakehouse) { return { - defaultValue: "management", + defaultValue: 'management', options: [ - toScopePromptOption("management", profileName), - toScopePromptOption("lakehouse", profileName), + toScopePromptOption('management', profileName), + toScopePromptOption('lakehouse', profileName), ], }; } return { - defaultValue: "both", + defaultValue: 'both', options: [ - toScopePromptOption("both", profileName), - toScopePromptOption("management", profileName), - toScopePromptOption("lakehouse", profileName), + toScopePromptOption('both', profileName), + toScopePromptOption('management', profileName), + toScopePromptOption('lakehouse', profileName), ], }; } function parseScopeSelection(selection: string): ConfigureWizardScope { - if (selection === "management" || selection === "lakehouse" || selection === "both") { + if ( + selection === 'management' || + selection === 'lakehouse' || + selection === 'both' + ) { return selection; } throw new CliError(`Unexpected scope selection: ${selection}`); @@ -117,40 +130,45 @@ function parseScopeSelection(selection: string): ConfigureWizardScope { export async function promptConfigureScope( prompts: Prompts, requestedScope: ConfigureWizardScope, - profileName: string, + profileName: string ): Promise { - const { hasManagement, hasLakehouse } = configureCredentialStatus(profileName); + const { hasManagement, hasLakehouse } = + configureCredentialStatus(profileName); if (requestedScope !== DEFAULT_SCOPE) { return requestedScope; } - const scopePromptModel = buildScopePromptModel(hasManagement, hasLakehouse, profileName); + const scopePromptModel = buildScopePromptModel( + hasManagement, + hasLakehouse, + profileName + ); const selectedScope = await prompts.readSelect( SCOPE_SELECT_TITLE, scopePromptModel.options, scopePromptModel.defaultValue, - CONFIGURE_SCOPE_SELECT_OPTIONS, + CONFIGURE_SCOPE_SELECT_OPTIONS ); return parseScopeSelection(selectedScope); } export function planesForConfigureScope( - scope: ConfigureWizardScope, -): ("management" | "lakehouse")[] { - if (scope === "management") { - return ["management"]; + scope: ConfigureWizardScope +): ('management' | 'lakehouse')[] { + if (scope === 'management') { + return ['management']; } - if (scope === "lakehouse") { - return ["lakehouse"]; + if (scope === 'lakehouse') { + return ['lakehouse']; } - return ["management", "lakehouse"]; + return ['management', 'lakehouse']; } async function readLineWithDefault( prompts: Prompts, prompt: string, - defaultValue: string, + defaultValue: string ): Promise { const response = await prompts.readLine(prompt); return response || defaultValue; @@ -159,7 +177,7 @@ async function readLineWithDefault( async function readRequiredPassword( prompts: Prompts, prompt: string, - requiredMessage: string, + requiredMessage: string ): Promise { const password = await prompts.readPassword(prompt); if (!password) { @@ -178,9 +196,10 @@ type ReadSecretWithOptionalReuseArgs = { async function readSecretWithOptionalReuse( args: ReadSecretWithOptionalReuseArgs, - profileName: string, + profileName: string ): Promise { - const { prompts, secretKey, keepPrompt, passwordPrompt, requiredMessage } = args; + const { prompts, secretKey, keepPrompt, passwordPrompt, requiredMessage } = + args; if (secretExists(secretKey, profileName)) { const keepExistingValue = await prompts.readConfirm(keepPrompt, true); if (keepExistingValue) { @@ -194,57 +213,59 @@ async function readSecretWithOptionalReuse( export async function collectManagementCredentials( prompts: Prompts, options: ConfigureWizardOptions, - profileName: string, + profileName: string ): Promise<{ options: ConfigureOptions; org: string }> { - prompts.writePrompt(`\n${renderDisplayText([span("Management", "accent")])}\n`); + prompts.writePrompt( + `\n${renderDisplayText([span('Management', 'accent')])}\n` + ); - const currentEnv = configGet("api_key_env", profileName); - const envDefault = currentEnv || "production"; + const currentEnv = configGet('api_key_env', profileName); + const envDefault = currentEnv || 'production'; const envPrompt = renderDisplayText([ - span("Environment "), - span(`[${envDefault}]`, "subtle"), - span(": "), + span('Environment '), + span(`[${envDefault}]`, 'subtle'), + span(': '), ]); const env = await readLineWithDefault(prompts, envPrompt, envDefault); - let org = options.org ?? ""; + let org = options.org ?? ''; if (!org && options.profile === AUTO_PROFILE_NAME) { - const currentOrg = configGet("organization_slug", profileName); + const currentOrg = configGet('organization_slug', profileName); const orgPrompt = currentOrg ? renderDisplayText([ - span("Organization slug "), - span(`[${currentOrg}]`, "subtle"), - span(": "), + span('Organization slug '), + span(`[${currentOrg}]`, 'subtle'), + span(': '), ]) - : renderDisplayText([span("Organization slug", "accent"), span(": ")]); + : renderDisplayText([span('Organization slug', 'accent'), span(': ')]); org = currentOrg ? await readLineWithDefault(prompts, orgPrompt, currentOrg) : await prompts.readLine(orgPrompt); if (!org) { - throw new CliError("Organization slug is required for --profile auto."); + throw new CliError('Organization slug is required for --profile auto.'); } } const apiKey = await readSecretWithOptionalReuse( { prompts, - secretKey: "api-key", - keepPrompt: "Keep existing API key?", + secretKey: 'api-key', + keepPrompt: 'Keep existing API key?', passwordPrompt: renderDisplayText([ - span("API key", "accent"), - span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), - span(": "), + span('API key', 'accent'), + span(` ${HIDDEN_PROMPT_HINT}`, 'subtle'), + span(': '), ]), - requiredMessage: "API key is required.", + requiredMessage: 'API key is required.', }, - profileName, + profileName ); const useDefaultControlPlane = await prompts.readConfirm( renderDisplayText([ - span("Default control plane "), - span(DEFAULT_CONTROL_PLANE_URL, "accent", DEFAULT_CONTROL_PLANE_URL), - span("?"), + span('Default control plane '), + span(DEFAULT_CONTROL_PLANE_URL, 'accent', DEFAULT_CONTROL_PLANE_URL), + span('?'), ]), - true, + true ); const configureOptions: ConfigureOptions = { @@ -257,12 +278,13 @@ export async function collectManagementCredentials( if (!useDefaultControlPlane) { const controlPlaneUrl = await prompts.readLine( renderDisplayText([ - span("Control plane [", "subtle"), - span(DEFAULT_CONTROL_PLANE_URL, "accent", DEFAULT_CONTROL_PLANE_URL), - span("]: ", "subtle"), - ]), + span('Control plane [', 'subtle'), + span(DEFAULT_CONTROL_PLANE_URL, 'accent', DEFAULT_CONTROL_PLANE_URL), + span(']: ', 'subtle'), + ]) ); - configureOptions.controlPlaneUrl = controlPlaneUrl || DEFAULT_CONTROL_PLANE_URL; + configureOptions.controlPlaneUrl = + controlPlaneUrl || DEFAULT_CONTROL_PLANE_URL; } return { options: configureOptions, org }; @@ -271,15 +293,15 @@ export async function collectManagementCredentials( export async function collectLakehouseCredentials( prompts: Prompts, options: ConfigureWizardOptions, - profileName: string, + profileName: string ): Promise { const method = await prompts.readSelect( - renderDisplayText([span("Lakehouse", "accent"), span(" auth", "subtle")]), + renderDisplayText([span('Lakehouse', 'accent'), span(' auth', 'subtle')]), [ - { value: "user-password", label: "Username and password" }, - { value: "basic-token", label: "Basic token" }, + { value: 'user-password', label: 'Username and password' }, + { value: 'basic-token', label: 'Basic token' }, ], - "user-password", + 'user-password' ); const configureOptions: ConfigureOptions = { @@ -287,53 +309,57 @@ export async function collectLakehouseCredentials( allowInsecureHttp: options.allowInsecureHttp, }; - if (method === "basic-token") { + if (method === 'basic-token') { const basicToken = await readRequiredPassword( prompts, renderDisplayText([ - span("Basic token", "accent"), - span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), - span(": "), + span('Basic token', 'accent'), + span(` ${HIDDEN_PROMPT_HINT}`, 'subtle'), + span(': '), ]), - "Basic token is required.", + 'Basic token is required.' ); configureOptions.basicToken = basicToken; return configureOptions; } - const currentUser = configGet("user", profileName); + const currentUser = configGet('user', profileName); const userPrompt = currentUser - ? renderDisplayText([span("Username "), span(`[${currentUser}]`, "subtle"), span(": ")]) - : renderDisplayText([span("Username", "accent"), span(": ")]); + ? renderDisplayText([ + span('Username '), + span(`[${currentUser}]`, 'subtle'), + span(': '), + ]) + : renderDisplayText([span('Username', 'accent'), span(': ')]); const user = currentUser ? await readLineWithDefault(prompts, userPrompt, currentUser) : await prompts.readLine(userPrompt); if (!user) { - throw new CliError("Username is required."); + throw new CliError('Username is required.'); } const password = await readSecretWithOptionalReuse( { prompts, - secretKey: "lakehouse/password", - keepPrompt: "Keep existing password?", + secretKey: 'lakehouse/password', + keepPrompt: 'Keep existing password?', passwordPrompt: renderDisplayText([ - span("Password", "accent"), - span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), - span(": "), + span('Password', 'accent'), + span(` ${HIDDEN_PROMPT_HINT}`, 'subtle'), + span(': '), ]), - requiredMessage: "Password is required.", + requiredMessage: 'Password is required.', }, - profileName, + profileName ); const useDefaultDataPlane = await prompts.readConfirm( renderDisplayText([ - span("Default data plane "), - span(DEFAULT_DATA_PLANE_URL, "accent", DEFAULT_DATA_PLANE_URL), - span("?"), + span('Default data plane '), + span(DEFAULT_DATA_PLANE_URL, 'accent', DEFAULT_DATA_PLANE_URL), + span('?'), ]), - true, + true ); configureOptions.user = user; @@ -342,10 +368,10 @@ export async function collectLakehouseCredentials( if (!useDefaultDataPlane) { const dataPlaneUrl = await prompts.readLine( renderDisplayText([ - span("Data plane [", "subtle"), - span(DEFAULT_DATA_PLANE_URL, "accent", DEFAULT_DATA_PLANE_URL), - span("]: ", "subtle"), - ]), + span('Data plane [', 'subtle'), + span(DEFAULT_DATA_PLANE_URL, 'accent', DEFAULT_DATA_PLANE_URL), + span(']: ', 'subtle'), + ]) ); configureOptions.dataPlaneUrl = dataPlaneUrl || DEFAULT_DATA_PLANE_URL; } diff --git a/cli/src/lib/profile-configure.test.ts b/cli/src/lib/profile-configure.test.ts index fe000992..8825ebd3 100644 --- a/cli/src/lib/profile-configure.test.ts +++ b/cli/src/lib/profile-configure.test.ts @@ -1,20 +1,26 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import type { Prompts } from "@/ui/prompts.ts"; -import { hasConfigureCredentialFlags, runConfigureWizard } from "@/lib/profile-configure.ts"; -import { configureRunSet, withConfigureProfileContext } from "@/lib/profile-configure-core.ts"; -import { configGet } from "@/lib/config.ts"; -import { secretGet } from "@/lib/secrets.ts"; -import { setCliContext } from "@/context.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; - -const profileName = "default"; - -let testHome = ""; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { Prompts } from '@/ui/prompts.ts'; +import { + hasConfigureCredentialFlags, + runConfigureWizard, +} from '@/lib/profile-configure.ts'; +import { + configureRunSet, + withConfigureProfileContext, +} from '@/lib/profile-configure-core.ts'; +import { configGet } from '@/lib/config.ts'; +import { secretGet } from '@/lib/secrets.ts'; +import { setCliContext } from '@/context.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; + +const profileName = 'default'; + +let testHome = ''; function createMockPrompts(responses: { selects?: string[]; @@ -32,7 +38,7 @@ function createMockPrompts(responses: { readSelect(_title, _options, defaultValue, _selectOptions) { const value = responses.selects?.[selectIndex]; selectIndex += 1; - return Promise.resolve(value ?? defaultValue ?? _options[0]?.value ?? ""); + return Promise.resolve(value ?? defaultValue ?? _options[0]?.value ?? ''); }, readConfirm(_prompt, defaultYes) { const value = responses.confirms?.[confirmIndex]; @@ -40,12 +46,12 @@ function createMockPrompts(responses: { return Promise.resolve(value ?? defaultYes ?? true); }, readLine(_prompt) { - const value = responses.lines?.[lineIndex] ?? ""; + const value = responses.lines?.[lineIndex] ?? ''; lineIndex += 1; return Promise.resolve(value); }, readPassword(_prompt) { - const value = responses.passwords?.[passwordIndex] ?? ""; + const value = responses.passwords?.[passwordIndex] ?? ''; passwordIndex += 1; return Promise.resolve(value); }, @@ -53,9 +59,9 @@ function createMockPrompts(responses: { } beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-profile-configure-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-profile-configure-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; setCliContext({ debug: false, json: false, agent: false }); }); @@ -65,254 +71,350 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); -describe("configure wizard helpers", () => { - test("hasConfigureCredentialFlags detects credential flags", () => { - expect(hasConfigureCredentialFlags({ user: "u" })).toBe(true); +describe('configure wizard helpers', () => { + test('hasConfigureCredentialFlags detects credential flags', () => { + expect(hasConfigureCredentialFlags({ user: 'u' })).toBe(true); expect(hasConfigureCredentialFlags({ apiKeyStdin: true })).toBe(true); expect(hasConfigureCredentialFlags({})).toBe(false); }); }); -describe("runConfigureWizard", () => { - test("stores management credentials when scope is management", async () => { +describe('runConfigureWizard', () => { + test('stores management credentials when scope is management', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); const prompts = createMockPrompts({ - lines: ["staging"], - passwords: ["atm_test_key"], + lines: ['staging'], + passwords: ['atm_test_key'], confirms: [true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "management", + scope: 'management', prompts, sink: runtime.output, }); }); - expect(secretGet("api-key", profileName)).toBe("atm_test_key"); - expect(configGet("api_key_env", profileName)).toBe("staging"); + expect(secretGet('api-key', profileName)).toBe('atm_test_key'); + expect(configGet('api_key_env', profileName)).toBe('staging'); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("stores lakehouse credentials when scope is lakehouse", async () => { + test('stores lakehouse credentials when scope is lakehouse', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); const prompts = createMockPrompts({ - selects: ["user-password"], - lines: ["alice"], - passwords: ["lake-secret"], + selects: ['user-password'], + lines: ['alice'], + passwords: ['lake-secret'], confirms: [true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "lakehouse", + scope: 'lakehouse', prompts, sink: runtime.output, }); }); - expect(configGet("user", profileName)).toBe("alice"); - expect(secretGet("lakehouse/password", profileName)).toBe("lake-secret"); + expect(configGet('user', profileName)).toBe('alice'); + expect(secretGet('lakehouse/password', profileName)).toBe('lake-secret'); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("uses the requested profile for interactive defaults", async () => { + test('uses the requested profile for interactive defaults', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); - await configureRunSet({ user: "active-user", password: "active-secret" }); + await configureRunSet({ user: 'active-user', password: 'active-secret' }); await configureRunSet({ - profile: "target", - user: "target-user", - password: "target-secret", + profile: 'target', + user: 'target-user', + password: 'target-secret', }); const prompts = createMockPrompts({ - selects: ["user-password"], - lines: [""], + selects: ['user-password'], + lines: [''], confirms: [true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "lakehouse", - profile: "target", + scope: 'lakehouse', + profile: 'target', prompts, sink: runtime.output, }); }); - await withConfigureProfileContext("target", (profileName) => { - expect(configGet("user", profileName)).toBe("target-user"); - expect(secretGet("lakehouse/password", profileName)).toBe("target-secret"); + await withConfigureProfileContext('target', profileName => { + expect(configGet('user', profileName)).toBe('target-user'); + expect(secretGet('lakehouse/password', profileName)).toBe( + 'target-secret' + ); }); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("auto profile stores both planes in derived org_env profile", async () => { + test('auto profile stores both planes in derived org_env profile', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); const prompts = createMockPrompts({ - selects: ["both", "user-password"], - lines: ["production", "Acme", "alice"], - passwords: ["atm_test_key", "lake-secret"], + selects: ['both', 'user-password'], + lines: ['production', 'Acme', 'alice'], + passwords: ['atm_test_key', 'lake-secret'], confirms: [true, true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "both", - profile: "auto", + scope: 'both', + profile: 'auto', prompts, sink: runtime.output, }); }); - await withConfigureProfileContext("acme_production", (profileName) => { - expect(secretGet("api-key", profileName)).toBe("atm_test_key"); - expect(configGet("api_key_env", profileName)).toBe("production"); - expect(configGet("organization_slug", profileName)).toBe("Acme"); - expect(configGet("user", profileName)).toBe("alice"); - expect(secretGet("lakehouse/password", profileName)).toBe("lake-secret"); + await withConfigureProfileContext('acme_production', profileName => { + expect(secretGet('api-key', profileName)).toBe('atm_test_key'); + expect(configGet('api_key_env', profileName)).toBe('production'); + expect(configGet('organization_slug', profileName)).toBe('Acme'); + expect(configGet('user', profileName)).toBe('alice'); + expect(secretGet('lakehouse/password', profileName)).toBe( + 'lake-secret' + ); }); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("does not warn about argv secrets when saving interactively", async () => { + test('does not warn about argv secrets when saving interactively', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); const metadata: string[] = []; const prompts = createMockPrompts({ - selects: ["user-password"], - lines: ["alice"], - passwords: ["lake-secret"], + selects: ['user-password'], + lines: ['alice'], + passwords: ['lake-secret'], confirms: [true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeMetadata = (lines) => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + runtime.output.writeMetadata = lines => { metadata.push(...lines); }; try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "lakehouse", + scope: 'lakehouse', prompts, sink: runtime.output, }); }); - expect(metadata.some((line) => line.includes("--password-stdin"))).toBe(false); + expect(metadata.some(line => line.includes('--password-stdin'))).toBe( + false + ); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("keeps an existing management API key when requested", async () => { + test('keeps an existing management API key when requested', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); - await configureRunSet({ apiKey: "atm_existing", env: "production" }); + await configureRunSet({ apiKey: 'atm_existing', env: 'production' }); const prompts = createMockPrompts({ - lines: ["production"], + lines: ['production'], confirms: [true, true], }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { await runWithCliRuntime(runtime, async () => { await runConfigureWizard({ - scope: "management", + scope: 'management', prompts, sink: runtime.output, }); }); - expect(secretGet("api-key", profileName)).toBe("atm_existing"); - expect(configGet("api_key_env", profileName)).toBe("production"); + expect(secretGet('api-key', profileName)).toBe('atm_existing'); + expect(configGet('api_key_env', profileName)).toBe('production'); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("rejects non-TTY interactive configure", async () => { + test('rejects non-TTY interactive configure', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); try { return expect( runWithCliRuntime(runtime, async () => { await runConfigureWizard({ sink: runtime.output }); - }), + }) ).rejects.toThrow(ConfigurationError); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); } }); - test("rejects interactive configure with --json", async () => { + test('rejects interactive configure with --json', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); setCliContext({ debug: false, json: true, agent: false }); - const runtime = createCliRuntime({ debug: false, json: true, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: true, + agent: false, + }); try { return expect( runWithCliRuntime(runtime, async () => { await runConfigureWizard({ sink: runtime.output }); - }), + }) ).rejects.toThrow(/does not support --json or --agent/); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); setCliContext({ debug: false, json: false, agent: false }); } }); - test("rejects interactive configure with --agent", async () => { + test('rejects interactive configure with --agent', async () => { const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); setCliContext({ debug: false, json: false, agent: true }); - const runtime = createCliRuntime({ debug: false, json: false, agent: true }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: true, + }); try { return expect( runWithCliRuntime(runtime, async () => { await runConfigureWizard({ sink: runtime.output }); - }), + }) ).rejects.toThrow(/does not support --json or --agent/); } finally { - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); setCliContext({ debug: false, json: false, agent: false }); } }); - test("configureRunSet without flags throws", () => { + test('configureRunSet without flags throws', () => { return expect(configureRunSet({})).rejects.toThrow(/Nothing to configure/); }); }); diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index e76a0f79..59e26d86 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -1,15 +1,15 @@ -import { defineArguments } from "@/lib/command.ts"; +import { defineArguments } from '@/lib/command.ts'; import { configureRunSet, withConfigureProfileContext, type ConfigureOptions, -} from "@/lib/profile-configure-core.ts"; -import { formatConfigureSessionSummary } from "@/lib/profile/render.ts"; -import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { ConfigurationError, CliError } from "@/lib/errors.ts"; -import { getCliContext, isJsonOutput } from "@/context.ts"; -import { resolveWorkingProfile } from "@/lib/profile-store.ts"; -import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; +} from '@/lib/profile-configure-core.ts'; +import { formatConfigureSessionSummary } from '@/lib/profile/render.ts'; +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { ConfigurationError, CliError } from '@/lib/errors.ts'; +import { getCliContext, isJsonOutput } from '@/context.ts'; +import { resolveWorkingProfile } from '@/lib/profile-store.ts'; +import { getOutputSink, type OutputSink } from '@/lib/runtime.ts'; import { collectLakehouseCredentials, collectManagementCredentials, @@ -17,31 +17,41 @@ import { promptConfigureScope, type ConfigureWizardOptions, type ConfigureWizardScope, -} from "@/lib/profile-configure-interactive.ts"; -import { defaultPrompts, PromptCancelled } from "@/ui/prompts.ts"; -import { getTerminalWidth, getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; -import { deriveProfileName } from "@/lib/profile/model.ts"; -import { document, section, span, text, type DisplayText } from "@/ui/document.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; +} from '@/lib/profile-configure-interactive.ts'; +import { defaultPrompts, PromptCancelled } from '@/ui/prompts.ts'; +import { + getTerminalWidth, + getVisibleTextWidth, + renderDisplayText, +} from '@/ui/terminal/styles.ts'; +import { deriveProfileName } from '@/lib/profile/model.ts'; +import { + document, + section, + span, + text, + type DisplayText, +} from '@/ui/document.ts'; +import { renderDocumentText } from '@/ui/renderers/terminal.ts'; -const DEFAULT_SCOPE: ConfigureWizardScope = "both"; -const AUTO_PROFILE_NAME = "auto"; +const DEFAULT_SCOPE: ConfigureWizardScope = 'both'; +const AUTO_PROFILE_NAME = 'auto'; async function saveCredentials( configureOptions: ConfigureOptions, sink: OutputSink, - org = "", + org = '' ): Promise { await configureRunSet({ ...configureOptions, interactive: true }, sink, org); } function formatNextCommandsLine(commands: DisplayText[]): string { if (commands.length === 0) { - return ""; + return ''; } const terminalWidth = getTerminalWidth(); - const inlineLines: DisplayText[] = [[span("Next:", "muted")], ...commands]; + const inlineLines: DisplayText[] = [[span('Next:', 'muted')], ...commands]; const inlineLine = renderDocumentText(document(section(text(inlineLines)))); if (getVisibleTextWidth(inlineLine) <= terminalWidth) { return inlineLine; @@ -51,54 +61,57 @@ function formatNextCommandsLine(commands: DisplayText[]): string { document( section( text([ - [span("Next:", "muted")], - ...commands.map((command) => [ - span(" "), - ...(typeof command === "string" ? [span(command)] : command), + [span('Next:', 'muted')], + ...commands.map(command => [ + span(' '), + ...(typeof command === 'string' ? [span(command)] : command), ]), - ]), - ), - ), + ]) + ) + ) ); } function writeOutro( sink: OutputSink, configuredPlanes: ConfigureAuthPlane[], - profileName: string, + profileName: string ): void { - const summaryLines = formatConfigureSessionSummary(profileName, configuredPlanes); + const summaryLines = formatConfigureSessionSummary( + profileName, + configuredPlanes + ); const nextCommands: DisplayText[] = []; - if (configuredPlanes.includes("management")) { - nextCommands.push([span("altertable profile show", "accent")]); + if (configuredPlanes.includes('management')) { + nextCommands.push([span('altertable profile show', 'accent')]); } - if (configuredPlanes.includes("lakehouse")) { - nextCommands.push([span('altertable query "SELECT 1"', "accent")]); + if (configuredPlanes.includes('lakehouse')) { + nextCommands.push([span('altertable query "SELECT 1"', 'accent')]); } const nextLine = formatNextCommandsLine(nextCommands); - sink.writeMetadata(["", ...summaryLines, "", nextLine, ""]); + sink.writeMetadata(['', ...summaryLines, '', nextLine, '']); } export function configureNonTtyErrorMessage(): string { return ( - "Interactive configure requires a TTY. Examples:\n" + + 'Interactive configure requires a TTY. Examples:\n' + ` printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env production\n` + - " printf '%s' \"$PASS\" | altertable profile configure --user alice --password-stdin" + ' printf \'%s\' "$PASS" | altertable profile configure --user alice --password-stdin' ); } async function runConfigureWizardInCurrentProfile( options: ConfigureWizardOptions, - profileName: string, + profileName: string ): Promise { const sink = options.sink ?? getOutputSink(); const prompts = options.prompts ?? defaultPrompts; if (isJsonOutput(getCliContext())) { throw new ConfigurationError( - "Interactive configure does not support --json or --agent. Use credential flags instead.", + 'Interactive configure does not support --json or --agent. Use credential flags instead.' ); } @@ -107,7 +120,11 @@ async function runConfigureWizardInCurrentProfile( } try { - const scope = await promptConfigureScope(prompts, options.scope ?? DEFAULT_SCOPE, profileName); + const scope = await promptConfigureScope( + prompts, + options.scope ?? DEFAULT_SCOPE, + profileName + ); const planes = planesForConfigureScope(scope); const configuredPlanes: ConfigureAuthPlane[] = []; let activeProfile = options.profile; @@ -115,57 +132,64 @@ async function runConfigureWizardInCurrentProfile( // the org/env are collected, so it starts as the read profile and is updated. let targetProfile = profileName; - if (activeProfile === AUTO_PROFILE_NAME && !planes.includes("management")) { + if (activeProfile === AUTO_PROFILE_NAME && !planes.includes('management')) { throw new CliError( - "--profile auto requires management configuration so the environment is known.", + '--profile auto requires management configuration so the environment is known.' ); } - if (planes.includes("management")) { - const { options: managementOptions, org } = await collectManagementCredentials( - prompts, - { ...options, profile: activeProfile }, - targetProfile, - ); + if (planes.includes('management')) { + const { options: managementOptions, org } = + await collectManagementCredentials( + prompts, + { ...options, profile: activeProfile }, + targetProfile + ); await saveCredentials(managementOptions, sink, org); if (activeProfile === AUTO_PROFILE_NAME && org && managementOptions.env) { activeProfile = deriveProfileName(org, managementOptions.env); targetProfile = activeProfile; } - configuredPlanes.push("management"); + configuredPlanes.push('management'); } - if (planes.includes("lakehouse")) { + if (planes.includes('lakehouse')) { const lakehouseOptions = await collectLakehouseCredentials( prompts, { ...options, profile: activeProfile }, - targetProfile, + targetProfile ); await saveCredentials(lakehouseOptions, sink); - configuredPlanes.push("lakehouse"); + configuredPlanes.push('lakehouse'); } writeOutro(sink, configuredPlanes, targetProfile); } catch (error) { if (error instanceof PromptCancelled) { - sink.writeMetadata([renderDisplayText([span("Configuration cancelled.", "subtle")])]); + sink.writeMetadata([ + renderDisplayText([span('Configuration cancelled.', 'subtle')]), + ]); throw error; } throw error; } } -export async function runConfigureWizard(options: ConfigureWizardOptions = {}): Promise { +export async function runConfigureWizard( + options: ConfigureWizardOptions = {} +): Promise { const contextProfile = options.profile && options.profile !== AUTO_PROFILE_NAME ? options.profile : resolveWorkingProfile(getCliContext().profile); - await withConfigureProfileContext(contextProfile, async (profileName) => { + await withConfigureProfileContext(contextProfile, async profileName => { await runConfigureWizardInCurrentProfile(options, profileName); }); } -export function hasConfigureCredentialFlags(options: ConfigureOptions): boolean { +export function hasConfigureCredentialFlags( + options: ConfigureOptions +): boolean { return ( Boolean(options.user) || Boolean(options.password) || @@ -182,47 +206,59 @@ export function hasConfigureCredentialFlags(options: ConfigureOptions): boolean export type ConfigureCommandArgs = { user?: string; password?: string; - "basic-token"?: string; - "api-key"?: string; + 'basic-token'?: string; + 'api-key'?: string; env?: string; - "password-stdin"?: boolean; - "api-key-stdin"?: boolean; - "data-plane-url"?: string; - "control-plane-url"?: string; - "allow-insecure-http"?: boolean; + 'password-stdin'?: boolean; + 'api-key-stdin'?: boolean; + 'data-plane-url'?: string; + 'control-plane-url'?: string; + 'allow-insecure-http'?: boolean; scope?: string; }; /** Credential and endpoint flags owned by `profile configure`. */ export const configureArgs = defineArguments({ - user: { type: "string", description: "Lakehouse username (global)" }, - password: { type: "string", description: "Lakehouse password (global)" }, - "basic-token": { type: "string", description: "Pre-encoded HTTP Basic token" }, - "api-key": { type: "string", description: "Management API key for the --env environment" }, + user: { type: 'string', description: 'Lakehouse username (global)' }, + password: { type: 'string', description: 'Lakehouse password (global)' }, + 'basic-token': { + type: 'string', + description: 'Pre-encoded HTTP Basic token', + }, + 'api-key': { + type: 'string', + description: 'Management API key for the --env environment', + }, env: { - type: "string", - description: "Target environment (management API keys are per-environment)", + type: 'string', + description: 'Target environment (management API keys are per-environment)', }, - "password-stdin": { type: "boolean", description: "Read the lakehouse password from stdin" }, - "api-key-stdin": { type: "boolean", description: "Read the management API key from stdin" }, - "data-plane-url": { - type: "string", + 'password-stdin': { + type: 'boolean', + description: 'Read the lakehouse password from stdin', + }, + 'api-key-stdin': { + type: 'boolean', + description: 'Read the management API key from stdin', + }, + 'data-plane-url': { + type: 'string', description: - "Data-plane base URL; can be set alone or with a credential (default: https://api.altertable.ai)", + 'Data-plane base URL; can be set alone or with a credential (default: https://api.altertable.ai)', }, - "control-plane-url": { - type: "string", + 'control-plane-url': { + type: 'string', description: - "Control-plane server root; the CLI appends /rest/v1 (default: https://app.altertable.ai)", + 'Control-plane server root; the CLI appends /rest/v1 (default: https://app.altertable.ai)', }, - "allow-insecure-http": { - type: "boolean", - description: "Allow http:// URLs other than localhost (not recommended)", + 'allow-insecure-http': { + type: 'boolean', + description: 'Allow http:// URLs other than localhost (not recommended)', }, scope: { - type: "enum", - options: ["management", "lakehouse"], - description: "Limit the interactive wizard to one plane (default: both)", + type: 'enum', + options: ['management', 'lakehouse'], + description: 'Limit the interactive wizard to one plane (default: both)', }, }); @@ -230,25 +266,27 @@ function buildConfigureOptions(args: ConfigureCommandArgs): ConfigureOptions { return { user: args.user, password: args.password, - basicToken: args["basic-token"], - apiKey: args["api-key"], + basicToken: args['basic-token'], + apiKey: args['api-key'], env: args.env, - passwordStdin: args["password-stdin"], - apiKeyStdin: args["api-key-stdin"], - dataPlaneUrl: args["data-plane-url"], - controlPlaneUrl: args["control-plane-url"], - allowInsecureHttp: args["allow-insecure-http"], + passwordStdin: args['password-stdin'], + apiKeyStdin: args['api-key-stdin'], + dataPlaneUrl: args['data-plane-url'], + controlPlaneUrl: args['control-plane-url'], + allowInsecureHttp: args['allow-insecure-http'], }; } function resolveWizardScope(scope: string | undefined): ConfigureWizardScope { - if (scope === "management" || scope === "lakehouse" || scope === "both") { + if (scope === 'management' || scope === 'lakehouse' || scope === 'both') { return scope; } if (!scope) { - return "both"; + return 'both'; } - throw new CliError(`Invalid --scope "${scope}". Use management or lakehouse.`); + throw new CliError( + `Invalid --scope "${scope}". Use management or lakehouse.` + ); } /** @@ -259,7 +297,7 @@ function resolveWizardScope(scope: string | undefined): ConfigureWizardScope { export async function runProfileConfigure( args: ConfigureCommandArgs, sink: OutputSink, - profileName?: string, + profileName?: string ): Promise { const options = buildConfigureOptions(args); if (profileName) { @@ -269,7 +307,7 @@ export async function runProfileConfigure( if (hasConfigureCredentialFlags(options)) { if (args.scope) { throw new CliError( - "--scope only applies to the interactive wizard, not flag-based configure.", + '--scope only applies to the interactive wizard, not flag-based configure.' ); } await configureRunSet(options, sink); diff --git a/cli/src/lib/profile-status.test.ts b/cli/src/lib/profile-status.test.ts index 95dfa78e..852043df 100644 --- a/cli/src/lib/profile-status.test.ts +++ b/cli/src/lib/profile-status.test.ts @@ -1,25 +1,25 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { configureVerify } from "@/lib/profile-status.ts"; -import { setCliContext, getCliContext } from "@/context.ts"; -import { createCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { configureVerify } from '@/lib/profile-status.ts'; +import { setCliContext, getCliContext } from '@/context.ts'; +import { createCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; -let testHome = ""; -let mockFile = ""; +let testHome = ''; +let mockFile = ''; const WHOAMI_BODY = '{"principal":{"type":"User","name":"Jane","email":"j@x.io"},"organization":{"name":"Acme","slug":"acme"}}'; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-profile-status-test-")); - mockFile = join(testHome, "mocks.json"); + testHome = mkdtempSync(join(tmpdir(), 'altertable-profile-status-test-')); + mockFile = join(testHome, 'mocks.json'); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; // No ALTERTABLE_API_BASE / ALTERTABLE_MANAGEMENT_API_BASE: those trigger // env-config isolation (`_from_env`), which would ignore the stored profile @@ -35,25 +35,29 @@ afterEach(() => { delete process.env.ALTERTABLE_MOCK_HTTP_FILE; }); -describe("configureVerify", () => { - test("verifies management and lakehouse credentials via mock HTTP", async () => { +describe('configureVerify', () => { + test('verifies management and lakehouse credentials via mock HTTP', async () => { writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/whoami", method: "GET", body: WHOAMI_BODY }, - { urlPattern: "/query", method: "POST", body: "{}" }, - ]), + { urlPattern: '/whoami', method: 'GET', body: WHOAMI_BODY }, + { urlPattern: '/query', method: 'POST', body: '{}' }, + ]) ); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_test", env: "prod" }); - await configureRunSet({ user: "alice", password: "secret" }); + await configureRunSet({ apiKey: 'atm_test', env: 'prod' }); + await configureRunSet({ user: 'alice', password: 'secret' }); refreshCliRuntimeContext(runtime.context); const result = await configureVerify( - ["management", "lakehouse"], - createExecutionContext(runtime), + ['management', 'lakehouse'], + createExecutionContext(runtime) ); expect(result.verified.management).toBe(true); expect(result.verified.lakehouse).toBe(true); @@ -61,43 +65,67 @@ describe("configureVerify", () => { }); }); - test("retries a transient lakehouse error during the read-only verification query", async () => { + test('retries a transient lakehouse error during the read-only verification query', async () => { writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/query", method: "POST", status: 500, body: "temporary failure" }, - { urlPattern: "/query", method: "POST", body: "{}" }, - ]), + { + urlPattern: '/query', + method: 'POST', + status: 500, + body: 'temporary failure', + }, + { urlPattern: '/query', method: 'POST', body: '{}' }, + ]) ); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ user: "alice", password: "secret" }); + await configureRunSet({ user: 'alice', password: 'secret' }); refreshCliRuntimeContext(runtime.context); - const result = await configureVerify(["lakehouse"], createExecutionContext(runtime)); + const result = await configureVerify( + ['lakehouse'], + createExecutionContext(runtime) + ); expect(result.verified.lakehouse).toBe(true); expect(result.errors).toHaveLength(0); }); }); - test("records verification errors without clearing stored credentials", async () => { + test('records verification errors without clearing stored credentials', async () => { writeFileSync( mockFile, JSON.stringify([ - { urlPattern: "/whoami", method: "GET", status: 401, body: '{"error":"unauthorized"}' }, - ]), + { + urlPattern: '/whoami', + method: 'GET', + status: 401, + body: '{"error":"unauthorized"}', + }, + ]) ); - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_bad", env: "prod" }); + await configureRunSet({ apiKey: 'atm_bad', env: 'prod' }); refreshCliRuntimeContext(runtime.context); - const result = await configureVerify(["management"], createExecutionContext(runtime)); + const result = await configureVerify( + ['management'], + createExecutionContext(runtime) + ); expect(result.verified.management).toBe(false); expect(result.errors).toHaveLength(1); - expect(result.errors[0]?.plane).toBe("management"); + expect(result.errors[0]?.plane).toBe('management'); }); }); }); diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 83ae1b4b..7d851864 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -1,13 +1,13 @@ -import { configGet } from "@/lib/config.ts"; -import { CliError } from "@/lib/errors.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { buildLakehouseVerifyRequest } from "@/lib/lakehouse/query.ts"; -import type { WhoamiResponse } from "@/lib/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/lib/management/render.ts"; -import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; -import { formatProgressStatus, startProgress } from "@/lib/progress.ts"; +import { configGet } from '@/lib/config.ts'; +import { CliError } from '@/lib/errors.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { buildLakehouseVerifyRequest } from '@/lib/lakehouse/query.ts'; +import type { WhoamiResponse } from '@/lib/management/model.ts'; +import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; +import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; +import { formatProgressStatus, startProgress } from '@/lib/progress.ts'; -export type ConfigureAuthPlane = "management" | "lakehouse"; +export type ConfigureAuthPlane = 'management' | 'lakehouse'; export type ConfigureVerifyError = { plane: ConfigureAuthPlane; @@ -26,7 +26,7 @@ function parseWhoamiPrincipalName(body: string): string { const data = JSON.parse(body) as WhoamiResponse; return formatWhoamiPrincipalLine(data); } catch { - return "authenticated"; + return 'authenticated'; } } @@ -34,7 +34,7 @@ function getErrorMessage(error: unknown): string { if (error instanceof CliError || error instanceof Error) { return error.message; } - return "Verification failed."; + return 'Verification failed.'; } type ConfigureVerifier = { @@ -47,23 +47,28 @@ type ConfigureVerifier = { const CONFIGURE_VERIFY_PLANES = { management: { - progressLabel: "Verifying management API key", - failureLabel: "Management API key verification failed.", - request: () => ({ plane: "management", method: "GET", endpoint: "/whoami" }), + progressLabel: 'Verifying management API key', + failureLabel: 'Management API key verification failed.', + request: () => ({ + plane: 'management', + method: 'GET', + endpoint: '/whoami', + }), parse: parseWhoamiPrincipalName, - successMessage: (principalLine) => `Management API key verified (${principalLine}).`, + successMessage: principalLine => + `Management API key verified (${principalLine}).`, }, lakehouse: { - progressLabel: "Verifying lakehouse credentials", - failureLabel: "Lakehouse credentials verification failed.", + progressLabel: 'Verifying lakehouse credentials', + failureLabel: 'Lakehouse credentials verification failed.', request: buildLakehouseVerifyRequest, - successMessage: () => "Lakehouse credentials verified.", + successMessage: () => 'Lakehouse credentials verified.', }, } satisfies Record; async function verifyPlane( plane: ConfigureAuthPlane, - execution: ExecutionContext, + execution: ExecutionContext ): Promise { const verifier: ConfigureVerifier = CONFIGURE_VERIFY_PLANES[plane]; const response = await sendHttp(verifier.request(), execution); @@ -73,7 +78,7 @@ async function verifyPlane( export async function configureVerify( planes: ConfigureAuthPlane[], - execution: ExecutionContext, + execution: ExecutionContext ): Promise { const result: ConfigureVerifyResult = { profile: execution.profile, @@ -87,10 +92,10 @@ export async function configureVerify( const progress = startProgress(verifier.progressLabel); try { const successMessage = await verifyPlane(plane, execution); - progress.done(formatProgressStatus("success", successMessage)); + progress.done(formatProgressStatus('success', successMessage)); result.verified[plane] = true; } catch (error) { - progress.fail(formatProgressStatus("error", verifier.failureLabel)); + progress.fail(formatProgressStatus('error', verifier.failureLabel)); result.errors.push({ plane, message: getErrorMessage(error) }); } } @@ -100,11 +105,11 @@ export async function configureVerify( export function formatConfigureVerifyRemediation( plane: ConfigureAuthPlane, - profileName: string, + profileName: string ): string { - if (plane === "management") { - const env = configGet("api_key_env", profileName) || ""; + if (plane === 'management') { + const env = configGet('api_key_env', profileName) || ''; return `Check your API key and environment. Run: altertable profile configure --scope management or altertable profile configure --api-key atm_xxx --env ${env}`; } - return "Check your lakehouse username and password. Run: altertable profile configure --scope lakehouse or altertable profile configure --user --password

"; + return 'Check your lakehouse username and password. Run: altertable profile configure --scope lakehouse or altertable profile configure --user --password

'; } diff --git a/cli/src/lib/profile-store.test.ts b/cli/src/lib/profile-store.test.ts index 38eea5fb..87f9edf4 100644 --- a/cli/src/lib/profile-store.test.ts +++ b/cli/src/lib/profile-store.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { kvGet } from "@/lib/config.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { kvGet } from '@/lib/config.ts'; import { DEFAULT_PROFILE_NAME, ensureProfileExists, @@ -15,13 +15,13 @@ import { resolveWorkingProfile, resolveProfileReference, setActiveProfile, -} from "@/lib/profile-store.ts"; -import { kvSet } from "@/lib/config.ts"; +} from '@/lib/profile-store.ts'; +import { kvSet } from '@/lib/config.ts'; -let testHome = ""; +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-profile-store-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-profile-store-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; }); @@ -32,73 +32,79 @@ afterEach(() => { delete process.env.ALTERTABLE_API_KEY; }); -describe("profile store", () => { - test("creates the default profile layout and active selection", () => { +describe('profile store', () => { + test('creates the default profile layout and active selection', () => { ensureProfilesLayout(); expect(existsSync(profileConfigFile(DEFAULT_PROFILE_NAME))).toBe(true); expect(getProfileId(DEFAULT_PROFILE_NAME)).toMatch(/^[0-9a-f-]{36}$/); - expect(existsSync(join(testHome, "profiles", DEFAULT_PROFILE_NAME))).toBe(true); - expect(kvGet(join(testHome, "config"), "active_profile")).toBe(DEFAULT_PROFILE_NAME); + expect(existsSync(join(testHome, 'profiles', DEFAULT_PROFILE_NAME))).toBe( + true + ); + expect(kvGet(join(testHome, 'config'), 'active_profile')).toBe( + DEFAULT_PROFILE_NAME + ); }); - test("resolves an explicit override before the environment and active profile", () => { - ensureProfileExists("staging"); - ensureProfileExists("production"); - setActiveProfile("production"); - process.env.ALTERTABLE_PROFILE = "staging"; + test('resolves an explicit override before the environment and active profile', () => { + ensureProfileExists('staging'); + ensureProfileExists('production'); + setActiveProfile('production'); + process.env.ALTERTABLE_PROFILE = 'staging'; - expect(resolveWorkingProfile("production")).toBe("production"); - expect(resolveWorkingProfile()).toBe("staging"); + expect(resolveWorkingProfile('production')).toBe('production'); + expect(resolveWorkingProfile()).toBe('staging'); delete process.env.ALTERTABLE_PROFILE; - expect(resolveWorkingProfile()).toBe("production"); - expect(getActiveProfileName()).toBe("production"); + expect(resolveWorkingProfile()).toBe('production'); + expect(getActiveProfileName()).toBe('production'); }); - test("rejects unsafe names and accepts supported profile names", () => { - expect(() => resolveWorkingProfile("../../outside")).toThrow("Invalid profile name"); - expect(() => setActiveProfile("..")).toThrow("Invalid profile name"); + test('rejects unsafe names and accepts supported profile names', () => { + expect(() => resolveWorkingProfile('../../outside')).toThrow( + 'Invalid profile name' + ); + expect(() => setActiveProfile('..')).toThrow('Invalid profile name'); - ensureProfileExists("staging"); - ensureProfileExists("prod-eu"); - expect(profileExists("staging")).toBe(true); - expect(profileExists("prod-eu")).toBe(true); + ensureProfileExists('staging'); + ensureProfileExists('prod-eu'); + expect(profileExists('staging')).toBe(true); + expect(profileExists('prod-eu')).toBe(true); }); - test("preserves a stale active profile reference for read-only diagnostics", () => { - kvSet(join(testHome, "config"), "active_profile", "missing"); + test('preserves a stale active profile reference for read-only diagnostics', () => { + kvSet(join(testHome, 'config'), 'active_profile', 'missing'); - expect(resolveProfileReference()).toBe("missing"); + expect(resolveProfileReference()).toBe('missing'); }); - test("resolves the env pseudo-profile while environment configuration is active", () => { - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('resolves the env pseudo-profile while environment configuration is active', () => { + process.env.ALTERTABLE_API_KEY = 'atm_env'; expect(resolveWorkingProfile()).toBe(FROM_ENV_PSEUDOPROFILE_NAME); expect(resolveProfileReference()).toBe(FROM_ENV_PSEUDOPROFILE_NAME); }); - test("rejects --profile while environment configuration is active", () => { - ensureProfileExists("production"); - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('rejects --profile while environment configuration is active', () => { + ensureProfileExists('production'); + process.env.ALTERTABLE_API_KEY = 'atm_env'; - expect(() => resolveWorkingProfile("production")).toThrow( - 'Cannot select profile "production" through --profile: environment configuration is active, so stored profiles are ignored.', + expect(() => resolveWorkingProfile('production')).toThrow( + 'Cannot select profile "production" through --profile: environment configuration is active, so stored profiles are ignored.' ); - expect(() => resolveProfileReference("production")).toThrow( - 'Cannot select profile "production" through --profile', + expect(() => resolveProfileReference('production')).toThrow( + 'Cannot select profile "production" through --profile' ); }); - test("rejects ALTERTABLE_PROFILE while environment configuration is active", () => { - ensureProfileExists("staging"); - process.env.ALTERTABLE_PROFILE = "staging"; - process.env.ALTERTABLE_API_KEY = "atm_env"; + test('rejects ALTERTABLE_PROFILE while environment configuration is active', () => { + ensureProfileExists('staging'); + process.env.ALTERTABLE_PROFILE = 'staging'; + process.env.ALTERTABLE_API_KEY = 'atm_env'; expect(() => resolveWorkingProfile()).toThrow( - 'Cannot select profile "staging" through ALTERTABLE_PROFILE', + 'Cannot select profile "staging" through ALTERTABLE_PROFILE' ); - expect(() => resolveWorkingProfile()).toThrow("ALTERTABLE_API_KEY"); + expect(() => resolveWorkingProfile()).toThrow('ALTERTABLE_API_KEY'); }); }); diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index 3baa0a3a..2e890a13 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -1,17 +1,18 @@ -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync } from "node:fs"; -import { join, resolve, sep } from "node:path"; -import { configDir, configFile, kvGet, kvSet } from "@/lib/config-files.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { configuredEnvConfig, readEnv } from "@/lib/env.ts"; - -export const DEFAULT_PROFILE_NAME = "default"; -const PROFILE_ID_KEY = "profile_id"; -const PROFILE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; +import { configDir, configFile, kvGet, kvSet } from '@/lib/config-files.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { configuredEnvConfig, readEnv } from '@/lib/env.ts'; + +export const DEFAULT_PROFILE_NAME = 'default'; +const PROFILE_ID_KEY = 'profile_id'; +const PROFILE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; // Reserved pseudo-profile: the identity in effect when credentials come from // environment variables rather than a stored profile. Never a real directory. -export const FROM_ENV_PSEUDOPROFILE_NAME = "_from_env"; +export const FROM_ENV_PSEUDOPROFILE_NAME = '_from_env'; const RESERVED_PROFILE_NAMES = new Set([FROM_ENV_PSEUDOPROFILE_NAME]); @@ -26,31 +27,32 @@ export function isFromEnvProfile(name: string): boolean { // secret backend, OAuth, mock/log) are deliberately excluded. export function envConfigMode(): boolean { return Boolean( - readEnv("ALTERTABLE_API_KEY") || - readEnv("ALTERTABLE_BASIC_AUTH_TOKEN") || - (readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) || - readEnv("ALTERTABLE_ENV") || - readEnv("ALTERTABLE_API_BASE") || - readEnv("ALTERTABLE_MANAGEMENT_API_BASE"), + readEnv('ALTERTABLE_API_KEY') || + readEnv('ALTERTABLE_BASIC_AUTH_TOKEN') || + (readEnv('ALTERTABLE_LAKEHOUSE_USERNAME') && + readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD')) || + readEnv('ALTERTABLE_ENV') || + readEnv('ALTERTABLE_API_BASE') || + readEnv('ALTERTABLE_MANAGEMENT_API_BASE') ); } export const PROFILE_CONFIG_KEYS = [ - "user", - "api_key_env", - "api_base", - "management_api_base", - "organization_slug", - "organization_name", - "principal_type", - "principal_name", - "principal_email", - "principal_slug", - "created_at", - "updated_at", - "last_verified_at", - "oauth_expiry", - "lakehouse_credential_expiry", + 'user', + 'api_key_env', + 'api_base', + 'management_api_base', + 'organization_slug', + 'organization_name', + 'principal_type', + 'principal_name', + 'principal_email', + 'principal_slug', + 'created_at', + 'updated_at', + 'last_verified_at', + 'oauth_expiry', + 'lakehouse_credential_expiry', ] as const; export type ProfileConfigKey = (typeof PROFILE_CONFIG_KEYS)[number]; @@ -58,22 +60,22 @@ export type ProfileConfigKey = (typeof PROFILE_CONFIG_KEYS)[number]; const SAFE_PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; export function profilesDir(): string { - return join(configDir(), "profiles"); + return join(configDir(), 'profiles'); } export function assertSafeProfileName(name: string): void { if (name.length === 0) { - throw new ConfigurationError("Profile name cannot be empty."); + throw new ConfigurationError('Profile name cannot be empty.'); } - if (name === "." || name === "..") { + if (name === '.' || name === '..') { throw new ConfigurationError(`Invalid profile name: ${name}`); } - if (name.includes("/") || name.includes("\\") || name.includes(sep)) { + if (name.includes('/') || name.includes('\\') || name.includes(sep)) { throw new ConfigurationError(`Invalid profile name: ${name}`); } if (!SAFE_PROFILE_NAME_PATTERN.test(name)) { throw new ConfigurationError( - `Invalid profile name: ${name}. Use only letters, digits, dots, underscores, and hyphens.`, + `Invalid profile name: ${name}. Use only letters, digits, dots, underscores, and hyphens.` ); } if (RESERVED_PROFILE_NAMES.has(name)) { @@ -99,7 +101,7 @@ export function profileDir(name: string): string { } export function profileConfigFile(name: string): string { - return join(profileDir(name), "config"); + return join(profileDir(name), 'config'); } export function profileExists(name: string): boolean { @@ -135,8 +137,8 @@ export function ensureProfilesLayout(): void { mkdirSync(profileDir(DEFAULT_PROFILE_NAME), { recursive: true }); ensureProfileId(DEFAULT_PROFILE_NAME); } - if (kvGet(configFile(), "active_profile").length === 0) { - kvSet(configFile(), "active_profile", DEFAULT_PROFILE_NAME); + if (kvGet(configFile(), 'active_profile').length === 0) { + kvSet(configFile(), 'active_profile', DEFAULT_PROFILE_NAME); } } @@ -145,7 +147,7 @@ export function getActiveProfileName(): string { return FROM_ENV_PSEUDOPROFILE_NAME; } ensureProfilesLayout(); - const active = kvGet(configFile(), "active_profile"); + const active = kvGet(configFile(), 'active_profile'); if (active.length > 0 && profileExists(active)) { return active; } @@ -158,28 +160,30 @@ export function setActiveProfile(name: string): void { if (!profileExists(name)) { throw new ConfigurationError(`Profile not found: ${name}`); } - kvSet(configFile(), "active_profile", name); + kvSet(configFile(), 'active_profile', name); } // Env configuration isolates the active identity; an explicit --profile / // ALTERTABLE_PROFILE selection alongside it is a contradiction, so refuse it // rather than silently pick a side. function assertNoProfileSelectionInEnvMode(override: string | undefined): void { - const explicit = override ?? readEnv("ALTERTABLE_PROFILE") ?? ""; + const explicit = override ?? readEnv('ALTERTABLE_PROFILE') ?? ''; if (explicit.length === 0) { return; } - const selector = override ? "--profile" : "ALTERTABLE_PROFILE"; - const lines = configuredEnvConfig().map(({ name, display }) => ` ${name.padEnd(32)} ${display}`); + const selector = override ? '--profile' : 'ALTERTABLE_PROFILE'; + const lines = configuredEnvConfig().map( + ({ name, display }) => ` ${name.padEnd(32)} ${display}` + ); throw new ConfigurationError( [ `Cannot select profile "${explicit}" through ${selector}: environment configuration is active, so stored profiles are ignored.`, - "", - "Currently configured:", + '', + 'Currently configured:', ...lines, - "", - "Unset these variables to use stored profiles, or drop the profile selection.", - ].join("\n"), + '', + 'Unset these variables to use stored profiles, or drop the profile selection.', + ].join('\n') ); } @@ -191,7 +195,7 @@ export function resolveWorkingProfile(override?: string): string { ensureProfilesLayout(); - const explicit = override ?? readEnv("ALTERTABLE_PROFILE") ?? ""; + const explicit = override ?? readEnv('ALTERTABLE_PROFILE') ?? ''; if (explicit.length > 0) { assertSafeProfileName(explicit); @@ -210,7 +214,7 @@ export function resolveProfileReference(override?: string): string { return FROM_ENV_PSEUDOPROFILE_NAME; } - const explicit = override ?? readEnv("ALTERTABLE_PROFILE") ?? ""; + const explicit = override ?? readEnv('ALTERTABLE_PROFILE') ?? ''; if (explicit.length > 0) { assertSafeProfileName(explicit); if (explicit !== DEFAULT_PROFILE_NAME && !profileExists(explicit)) { @@ -219,7 +223,7 @@ export function resolveProfileReference(override?: string): string { return explicit; } - const active = kvGet(configFile(), "active_profile"); + const active = kvGet(configFile(), 'active_profile'); if (active.length > 0) { assertSafeProfileName(active); return active; @@ -233,8 +237,8 @@ export function listProfileNames(): string[] { return []; } return readdirSync(profilesDir(), { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) .sort(); } @@ -254,12 +258,16 @@ export function ensureProfileExists(name: string): void { export function configGet(key: string, profileName: string): string { // Env config never reads stored values — the whole point of the isolation. if (isFromEnvProfile(profileName)) { - return ""; + return ''; } return kvGet(profileConfigFile(profileName), key); } -export function configSet(key: string, value: string, profileName: string): void { +export function configSet( + key: string, + value: string, + profileName: string +): void { // The env pseudo-profile has no directory. Profile-mutating commands are // refused in env mode; the only writes that reach here are internal caches // (e.g. lakehouse credential expiry), which are simply ephemeral — drop them. @@ -272,12 +280,11 @@ export function configSet(key: string, value: string, profileName: string): void } export function readProfileConfigRecord( - name: string, + name: string ): Record { - const config = Object.fromEntries(PROFILE_CONFIG_KEYS.map((key) => [key, undefined])) as Record< - ProfileConfigKey, - string | undefined - >; + const config = Object.fromEntries( + PROFILE_CONFIG_KEYS.map(key => [key, undefined]) + ) as Record; if (isFromEnvProfile(name)) { return config; } diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index ac5f04e4..8e8d3851 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -1,11 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { setCliContext } from "@/context.ts"; -import { credentialsFile, kvGet, kvSet } from "@/lib/config.ts"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setCliContext } from '@/context.ts'; +import { credentialsFile, kvGet, kvSet } from '@/lib/config.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; import { createEmptyProfile, deleteProfile, @@ -15,7 +21,7 @@ import { listProfiles, renameProfile, updateProfile, -} from "@/lib/profile/model.ts"; +} from '@/lib/profile/model.ts'; import { getActiveProfileName, getProfileId, @@ -24,16 +30,16 @@ import { profileDir, profileExists, setActiveProfile, -} from "@/lib/profile-store.ts"; -import { secretGet } from "@/lib/secrets.ts"; -import { createFakeKeychain } from "@/test-utils/keychain.ts"; +} from '@/lib/profile-store.ts'; +import { secretGet } from '@/lib/secrets.ts'; +import { createFakeKeychain } from '@/test-utils/keychain.ts'; -let testHome = ""; +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-profile-model-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-profile-model-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; setCliContext({ debug: false, json: false, agent: false }); }); @@ -43,191 +49,226 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); -describe("profile model", () => { - test("derives safe profile names from organization and environment", () => { - expect(deriveProfileName("Acme Inc.", "Production")).toBe("acme-inc_production"); - expect(deriveProfileName("acme", "prod/eu")).toBe("acme_prod-eu"); +describe('profile model', () => { + test('derives safe profile names from organization and environment', () => { + expect(deriveProfileName('Acme Inc.', 'Production')).toBe( + 'acme-inc_production' + ); + expect(deriveProfileName('acme', 'prod/eu')).toBe('acme_prod-eu'); }); - test("derives service account profile names from org, environment, and slug", () => { - expect(deriveServiceAccountProfileName("Altertable", "production", "my-service")).toBe( - "altertable_production_my-service", - ); + test('derives service account profile names from org, environment, and slug', () => { + expect( + deriveServiceAccountProfileName('Altertable', 'production', 'my-service') + ).toBe('altertable_production_my-service'); }); - test("rejects service account profile names with an unusable part", () => { - expect(() => deriveServiceAccountProfileName("altertable", "production", "")).toThrow( - ConfigurationError, - ); - expect(() => deriveServiceAccountProfileName("altertable", "production", "---")).toThrow( - ConfigurationError, - ); - expect(() => deriveServiceAccountProfileName("", "production", "my-service")).toThrow( - ConfigurationError, - ); + test('rejects service account profile names with an unusable part', () => { + expect(() => + deriveServiceAccountProfileName('altertable', 'production', '') + ).toThrow(ConfigurationError); + expect(() => + deriveServiceAccountProfileName('altertable', 'production', '---') + ).toThrow(ConfigurationError); + expect(() => + deriveServiceAccountProfileName('', 'production', 'my-service') + ).toThrow(ConfigurationError); }); - test("normalizes a server-supplied slug instead of letting it escape the profiles directory", () => { - expect(deriveServiceAccountProfileName("altertable", "production", "../../etc")).toBe( - "altertable_production_etc", - ); + test('normalizes a server-supplied slug instead of letting it escape the profiles directory', () => { + expect( + deriveServiceAccountProfileName('altertable', 'production', '../../etc') + ).toBe('altertable_production_etc'); }); - test("creates and updates profile metadata without credentials", () => { - createEmptyProfile("acme_prod"); - const profile = updateProfile("acme_prod", { - organizationSlug: "acme", - organizationName: "Acme Inc", - environment: "production", - dataPlane: "https://api.example.com", - controlPlane: "https://app.example.com", + test('creates and updates profile metadata without credentials', () => { + createEmptyProfile('acme_prod'); + const profile = updateProfile('acme_prod', { + organizationSlug: 'acme', + organizationName: 'Acme Inc', + environment: 'production', + dataPlane: 'https://api.example.com', + controlPlane: 'https://app.example.com', }); expect(profile).toMatchObject({ - name: "acme_prod", - status: "partial", - organization: { slug: "acme", name: "Acme Inc" }, - environment: "production", + name: 'acme_prod', + status: 'partial', + organization: { slug: 'acme', name: 'Acme Inc' }, + environment: 'production', endpoints: { - data_plane: "https://api.example.com", - control_plane: "https://app.example.com", + data_plane: 'https://api.example.com', + control_plane: 'https://app.example.com', }, }); }); - test("inspects profile-scoped credentials and expiry metadata", async () => { - await configureRunSet({ profile: "acme_prod", apiKey: "atm_prod", env: "production" }); - await configureRunSet({ profile: "acme_prod", user: "alice", password: "secret" }); + test('inspects profile-scoped credentials and expiry metadata', async () => { + await configureRunSet({ + profile: 'acme_prod', + apiKey: 'atm_prod', + env: 'production', + }); + await configureRunSet({ + profile: 'acme_prod', + user: 'alice', + password: 'secret', + }); - const profile = inspectProfile("acme_prod"); - expect(profile.status).toBe("configured"); - expect(profile.auth).toEqual({ management: "api_key", lakehouse: "username_password" }); + const profile = inspectProfile('acme_prod'); + expect(profile.status).toBe('configured'); + expect(profile.auth).toEqual({ + management: 'api_key', + lakehouse: 'username_password', + }); expect(profile.timestamps.created_at).toBeTruthy(); }); - test("lists profiles with the active profile marked", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - setActiveProfile("staging"); + test('lists profiles with the active profile marked', async () => { + await configureRunSet({ apiKey: 'atm_a', env: 'prod' }); + await configureRunSet({ + profile: 'staging', + apiKey: 'atm_b', + env: 'staging', + }); + setActiveProfile('staging'); const profiles = listProfiles(); - expect(profiles.find((profile) => profile.name === "staging")?.active).toBe(true); - expect(profiles.find((profile) => profile.name === "default")?.active).toBe(false); + expect(profiles.find(profile => profile.name === 'staging')?.active).toBe( + true + ); + expect(profiles.find(profile => profile.name === 'default')?.active).toBe( + false + ); }); - test("refuses to delete the active profile but deletes an inactive profile", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - setActiveProfile("staging"); + test('refuses to delete the active profile but deletes an inactive profile', async () => { + await configureRunSet({ apiKey: 'atm_a', env: 'prod' }); + await configureRunSet({ + profile: 'staging', + apiKey: 'atm_b', + env: 'staging', + }); + setActiveProfile('staging'); - expect(() => deleteProfile("staging")).toThrow("Cannot delete the active profile"); - deleteProfile("default"); + expect(() => deleteProfile('staging')).toThrow( + 'Cannot delete the active profile' + ); + deleteProfile('default'); - expect(profileExists("default")).toBe(false); - expect(profileExists("staging")).toBe(true); + expect(profileExists('default')).toBe(false); + expect(profileExists('staging')).toBe(true); }); - test("deletes stored Keychain secrets before removing a profile", () => { - createEmptyProfile("staging"); - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + test('deletes stored Keychain secrets before removing a profile', () => { + createEmptyProfile('staging'); + process.env.ALTERTABLE_SECRET_BACKEND = 'keychain'; const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); - keychain.store.secretSet("lakehouse/password", "lakehouse-secret", "staging"); - const profileId = getProfileId("staging"); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); + keychain.store.secretSet( + 'lakehouse/password', + 'lakehouse-secret', + 'staging' + ); + const profileId = getProfileId('staging'); - deleteProfile("staging", keychain.store); + deleteProfile('staging', keychain.store); - expect(profileExists("staging")).toBe(false); - expect(keychain.store.secretGet("api-key", "staging")).toBe(""); - expect(keychain.store.secretGet("lakehouse/password", "staging")).toBe(""); + expect(profileExists('staging')).toBe(false); + expect(keychain.store.secretGet('api-key', 'staging')).toBe(''); + expect(keychain.store.secretGet('lakehouse/password', 'staging')).toBe(''); expect( keychain.calls.some( - (args) => - args.includes("delete-generic-password") && args.includes(`profile/${profileId}/api-key`), - ), + args => + args.includes('delete-generic-password') && + args.includes(`profile/${profileId}/api-key`) + ) ).toBe(true); }); - test("keeps the profile when Keychain secret deletion fails", () => { - createEmptyProfile("staging"); - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + test('keeps the profile when Keychain secret deletion fails', () => { + createEmptyProfile('staging'); + process.env.ALTERTABLE_SECRET_BACKEND = 'keychain'; const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); - keychain.failingDeletes.add(`profile/${getProfileId("staging")}/api-key`); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); + keychain.failingDeletes.add(`profile/${getProfileId('staging')}/api-key`); - expect(() => deleteProfile("staging", keychain.store)).toThrow( - "Failed to delete secret from macOS keychain", + expect(() => deleteProfile('staging', keychain.store)).toThrow( + 'Failed to delete secret from macOS keychain' ); - expect(profileExists("staging")).toBe(true); - expect(keychain.store.secretGet("api-key", "staging")).toBe("atm_staging"); + expect(profileExists('staging')).toBe(true); + expect(keychain.store.secretGet('api-key', 'staging')).toBe('atm_staging'); }); - test("restores profile config and secrets when directory deletion fails", () => { - createEmptyProfile("staging"); - updateProfile("staging", { organizationSlug: "acme", environment: "staging" }); - const originalConfig = readFileSync(profileConfigFile("staging")); + test('restores profile config and secrets when directory deletion fails', () => { + createEmptyProfile('staging'); + updateProfile('staging', { + organizationSlug: 'acme', + environment: 'staging', + }); + const originalConfig = readFileSync(profileConfigFile('staging')); const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); expect(() => - deleteProfile("staging", keychain.store, { + deleteProfile('staging', keychain.store, { renameDirectory(source, target) { renameSync(profileDir(source), profileDir(target)); }, removeDirectory(name) { rmSync(profileConfigFile(name)); - throw new Error("simulated partial remove failure"); + throw new Error('simulated partial remove failure'); }, setActive: setActiveProfile, - }), - ).toThrow("simulated partial remove failure"); + }) + ).toThrow('simulated partial remove failure'); - expect(profileExists("staging")).toBe(true); - expect(readFileSync(profileConfigFile("staging"))).toEqual(originalConfig); - expect(keychain.store.secretGet("api-key", "staging")).toBe("atm_staging"); + expect(profileExists('staging')).toBe(true); + expect(readFileSync(profileConfigFile('staging'))).toEqual(originalConfig); + expect(keychain.store.secretGet('api-key', 'staging')).toBe('atm_staging'); }); - test("restores a legacy configless profile with a stable identity after deletion fails", () => { - mkdirSync(profileDir("configless"), { recursive: true }); + test('restores a legacy configless profile with a stable identity after deletion fails', () => { + mkdirSync(profileDir('configless'), { recursive: true }); const keychain = createFakeKeychain(); expect(() => - deleteProfile("configless", keychain.store, { + deleteProfile('configless', keychain.store, { renameDirectory(source, target) { renameSync(profileDir(source), profileDir(target)); }, removeDirectory(name) { rmSync(profileDir(name), { recursive: true }); - throw new Error("simulated remove-then-fail"); + throw new Error('simulated remove-then-fail'); }, setActive: setActiveProfile, - }), - ).toThrow("simulated remove-then-fail"); + }) + ).toThrow('simulated remove-then-fail'); - expect(profileExists("configless")).toBe(true); - expect(getProfileId("configless")).toMatch(/^[0-9a-f-]{36}$/); + expect(profileExists('configless')).toBe(true); + expect(getProfileId('configless')).toMatch(/^[0-9a-f-]{36}$/); }); - test("reports incomplete recovery after profile deletion fails", () => { - createEmptyProfile("staging"); + test('reports incomplete recovery after profile deletion fails', () => { + createEmptyProfile('staging'); const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); const secrets = { ...keychain.store, secretSet() { - throw new Error("simulated secret restore failure"); + throw new Error('simulated secret restore failure'); }, }; let thrown: unknown; try { - deleteProfile("staging", secrets, { + deleteProfile('staging', secrets, { renameDirectory(source, target) { renameSync(profileDir(source), profileDir(target)); }, removeDirectory() { - throw new Error("simulated deletion failure"); + throw new Error('simulated deletion failure'); }, setActive: setActiveProfile, }); @@ -236,72 +277,92 @@ describe("profile model", () => { } expect(thrown).toBeInstanceOf(ConfigurationError); - expect((thrown as ConfigurationError).message).toContain("rollback was incomplete"); + expect((thrown as ConfigurationError).message).toContain( + 'rollback was incomplete' + ); expect((thrown as ConfigurationError).cause).toBeInstanceOf(Error); expect(((thrown as ConfigurationError).cause as Error).message).toBe( - "simulated deletion failure", + 'simulated deletion failure' ); expect((thrown as ConfigurationError).details).toContain( - "Rollback failure (restore secret api-key): simulated secret restore failure", + 'Rollback failure (restore secret api-key): simulated secret restore failure' ); }); - test("renames profile config, active selection, and secrets", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); - setActiveProfile("staging"); - const profileId = getProfileId("staging"); + test('renames profile config, active selection, and secrets', async () => { + await configureRunSet({ + profile: 'staging', + apiKey: 'atm_staging', + env: 'staging', + }); + setActiveProfile('staging'); + const profileId = getProfileId('staging'); - renameProfile("staging", "acme_staging"); + renameProfile('staging', 'acme_staging'); - expect(profileExists("staging")).toBe(false); - expect(profileExists("acme_staging")).toBe(true); - expect(getActiveProfileName()).toBe("acme_staging"); - expect(getProfileId("acme_staging")).toBe(profileId); - expect(secretGet("api-key", "acme_staging")).toBe("atm_staging"); - expect(secretGet("api-key", "staging")).toBe(""); + expect(profileExists('staging')).toBe(false); + expect(profileExists('acme_staging')).toBe(true); + expect(getActiveProfileName()).toBe('acme_staging'); + expect(getProfileId('acme_staging')).toBe(profileId); + expect(secretGet('api-key', 'acme_staging')).toBe('atm_staging'); + expect(secretGet('api-key', 'staging')).toBe(''); }); - test("renames a profile without rewriting Keychain secrets", () => { - createEmptyProfile("staging"); - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + test('renames a profile without rewriting Keychain secrets', () => { + createEmptyProfile('staging'); + process.env.ALTERTABLE_SECRET_BACKEND = 'keychain'; const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); - keychain.store.secretSet("lakehouse/password", "lakehouse-secret", "staging"); - const profileId = getProfileId("staging"); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); + keychain.store.secretSet( + 'lakehouse/password', + 'lakehouse-secret', + 'staging' + ); + const profileId = getProfileId('staging'); const callsBeforeRename = keychain.calls.length; - renameProfile("staging", "acme_staging", keychain.store); + renameProfile('staging', 'acme_staging', keychain.store); - expect(getProfileId("acme_staging")).toBe(profileId); - expect(keychain.store.secretGet("api-key", "acme_staging")).toBe("atm_staging"); - expect(keychain.store.secretGet("lakehouse/password", "acme_staging")).toBe("lakehouse-secret"); + expect(getProfileId('acme_staging')).toBe(profileId); + expect(keychain.store.secretGet('api-key', 'acme_staging')).toBe( + 'atm_staging' + ); + expect(keychain.store.secretGet('lakehouse/password', 'acme_staging')).toBe( + 'lakehouse-secret' + ); const renameCalls = keychain.calls.slice(callsBeforeRename); - expect(renameCalls.some((args) => args.includes("add-generic-password"))).toBe(false); - expect(renameCalls.some((args) => args.includes("delete-generic-password"))).toBe(false); + expect( + renameCalls.some(args => args.includes('add-generic-password')) + ).toBe(false); + expect( + renameCalls.some(args => args.includes('delete-generic-password')) + ).toBe(false); }); - test("migrates legacy name-keyed secrets before renaming a profile", () => { + test('migrates legacy name-keyed secrets before renaming a profile', () => { ensureProfilesLayout(); - mkdirSync(profileDir("legacy"), { recursive: true }); - kvSet(credentialsFile(), "profile/legacy/api-key", "atm_legacy"); + mkdirSync(profileDir('legacy'), { recursive: true }); + kvSet(credentialsFile(), 'profile/legacy/api-key', 'atm_legacy'); - renameProfile("legacy", "renamed"); + renameProfile('legacy', 'renamed'); - const profileId = getProfileId("renamed"); + const profileId = getProfileId('renamed'); expect(profileId).toBeTruthy(); - expect(secretGet("api-key", "renamed")).toBe("atm_legacy"); - expect(kvGet(credentialsFile(), "profile/legacy/api-key")).toBe(""); - expect(kvGet(credentialsFile(), `profile/${profileId}/api-key`)).toBe("atm_legacy"); + expect(secretGet('api-key', 'renamed')).toBe('atm_legacy'); + expect(kvGet(credentialsFile(), 'profile/legacy/api-key')).toBe(''); + expect(kvGet(credentialsFile(), `profile/${profileId}/api-key`)).toBe( + 'atm_legacy' + ); }); - test("rolls back config and secrets when switching the renamed active profile fails", () => { - createEmptyProfile("staging"); - setActiveProfile("staging"); + test('rolls back config and secrets when switching the renamed active profile fails', () => { + createEmptyProfile('staging'); + setActiveProfile('staging'); const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_staging", "staging"); + keychain.store.secretSet('api-key', 'atm_staging', 'staging'); expect(() => - renameProfile("staging", "acme_staging", keychain.store, { + renameProfile('staging', 'acme_staging', keychain.store, { renameDirectory(source, target) { renameSync(profileDir(source), profileDir(target)); }, @@ -309,29 +370,29 @@ describe("profile model", () => { rmSync(profileDir(name), { recursive: true, force: true }); }, setActive(name) { - if (name === "acme_staging") { - throw new Error("simulated active-profile failure"); + if (name === 'acme_staging') { + throw new Error('simulated active-profile failure'); } setActiveProfile(name); }, - }), - ).toThrow("simulated active-profile failure"); - - expect(profileExists("staging")).toBe(true); - expect(profileExists("acme_staging")).toBe(false); - expect(getActiveProfileName()).toBe("staging"); - expect(keychain.store.secretGet("api-key", "staging")).toBe("atm_staging"); - expect(keychain.store.secretGet("api-key", "acme_staging")).toBe(""); + }) + ).toThrow('simulated active-profile failure'); + + expect(profileExists('staging')).toBe(true); + expect(profileExists('acme_staging')).toBe(false); + expect(getActiveProfileName()).toBe('staging'); + expect(keychain.store.secretGet('api-key', 'staging')).toBe('atm_staging'); + expect(keychain.store.secretGet('api-key', 'acme_staging')).toBe(''); }); - test("reports incomplete recovery after profile rename fails", () => { - createEmptyProfile("staging"); - setActiveProfile("staging"); + test('reports incomplete recovery after profile rename fails', () => { + createEmptyProfile('staging'); + setActiveProfile('staging'); const keychain = createFakeKeychain(); let thrown: unknown; try { - renameProfile("staging", "acme_staging", keychain.store, { + renameProfile('staging', 'acme_staging', keychain.store, { renameDirectory(source, target) { renameSync(profileDir(source), profileDir(target)); }, @@ -340,9 +401,9 @@ describe("profile model", () => { }, setActive(name) { throw new Error( - name === "acme_staging" - ? "simulated rename completion failure" - : "simulated active restore failure", + name === 'acme_staging' + ? 'simulated rename completion failure' + : 'simulated active restore failure' ); }, }); @@ -351,16 +412,20 @@ describe("profile model", () => { } expect(thrown).toBeInstanceOf(ConfigurationError); - expect((thrown as ConfigurationError).message).toContain("rollback was incomplete"); + expect((thrown as ConfigurationError).message).toContain( + 'rollback was incomplete' + ); expect(((thrown as ConfigurationError).cause as Error).message).toBe( - "simulated rename completion failure", + 'simulated rename completion failure' ); expect((thrown as ConfigurationError).details).toContain( - "Rollback failure (restore active profile): simulated active restore failure", + 'Rollback failure (restore active profile): simulated active restore failure' ); }); - test("rejects a no-op rename when the source profile does not exist", () => { - expect(() => renameProfile("missing", "missing")).toThrow("Profile not found: missing"); + test('rejects a no-op rename when the source profile does not exist', () => { + expect(() => renameProfile('missing', 'missing')).toThrow( + 'Profile not found: missing' + ); }); }); diff --git a/cli/src/lib/profile/model.ts b/cli/src/lib/profile/model.ts index 1f8340ae..394260dc 100644 --- a/cli/src/lib/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, @@ -7,7 +7,7 @@ import { rmSync, statSync, writeFileSync, -} from "node:fs"; +} from 'node:fs'; import { configDir, configFile, @@ -16,9 +16,9 @@ import { kvGet, resolveApiBase, resolveManagementApiBase, -} from "@/lib/config.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { configuredEnvConfig, readEnv } from "@/lib/env.ts"; +} from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { configuredEnvConfig, readEnv } from '@/lib/env.ts'; import { migrateProfileSecrets, secretDelete, @@ -27,7 +27,7 @@ import { secretSet, secretStoreDisplay, type SecretStore, -} from "@/lib/secrets.ts"; +} from '@/lib/secrets.ts'; import { assertSafeProfileName, DEFAULT_PROFILE_NAME, @@ -44,19 +44,19 @@ import { profileExists, readProfileConfigRecord, setActiveProfile, -} from "@/lib/profile-store.ts"; +} from '@/lib/profile-store.ts'; const PROFILE_SECRET_ACCOUNTS = [ - "api-key", - "lakehouse/password", - "lakehouse/basic-token", - "oauth/access-token", - "oauth/refresh-token", + 'api-key', + 'lakehouse/password', + 'lakehouse/basic-token', + 'oauth/access-token', + 'oauth/refresh-token', ] as const; type ProfileSecretStore = Pick< SecretStore, - "migrateProfileSecrets" | "secretDelete" | "secretGet" | "secretSet" + 'migrateProfileSecrets' | 'secretDelete' | 'secretGet' | 'secretSet' >; const PROFILE_SECRET_STORE: ProfileSecretStore = { @@ -94,7 +94,7 @@ function profileMutationErrorDetail(error: unknown): string { function runProfileRollbackStep( failures: ProfileRollbackFailure[], step: string, - operation: () => void, + operation: () => void ): void { try { operation(); @@ -107,7 +107,7 @@ function rethrowProfileMutationFailure( originalError: unknown, rollbackFailures: ProfileRollbackFailure[], operation: string, - recoveryAdvice: string, + recoveryAdvice: string ): never { if (rollbackFailures.length === 0) { throw originalError; @@ -119,15 +119,16 @@ function rethrowProfileMutationFailure( details: [ `Original failure: ${profileMutationErrorDetail(originalError)}`, ...rollbackFailures.map( - ({ step, error }) => `Rollback failure (${step}): ${profileMutationErrorDetail(error)}`, + ({ step, error }) => + `Rollback failure (${step}): ${profileMutationErrorDetail(error)}` ), - ].join("\n"), - }, + ].join('\n'), + } ); } -type ProfileManagementAuth = "oauth" | "api_key" | "none"; -type ProfileLakehouseAuth = "basic_token" | "username_password" | "none"; +type ProfileManagementAuth = 'oauth' | 'api_key' | 'none'; +type ProfileLakehouseAuth = 'basic_token' | 'username_password' | 'none'; export type ProfileAuth = { management: ProfileManagementAuth; lakehouse: ProfileLakehouseAuth; @@ -189,7 +190,7 @@ export type ProfileConfiguration = { export type ProfileInspect = ProfileConfiguration & { auth: ProfileAuth; - status: "configured" | "partial" | "empty"; + status: 'configured' | 'partial' | 'empty'; }; type ProfileSnapshot = { @@ -198,27 +199,27 @@ type ProfileSnapshot = { }; const PROFILE_UPDATE_CONFIG_KEYS = { - organizationSlug: "organization_slug", - organizationName: "organization_name", - principalType: "principal_type", - principalName: "principal_name", - principalEmail: "principal_email", - principalSlug: "principal_slug", - environment: "api_key_env", - dataPlane: "api_base", - controlPlane: "management_api_base", + organizationSlug: 'organization_slug', + organizationName: 'organization_name', + principalType: 'principal_type', + principalName: 'principal_name', + principalEmail: 'principal_email', + principalSlug: 'principal_slug', + environment: 'api_key_env', + dataPlane: 'api_base', + controlPlane: 'management_api_base', } as const satisfies Record; const MANAGEMENT_AUTH_LABELS = { - oauth: "oauth", - api_key: "api-key", - none: "none", + oauth: 'oauth', + api_key: 'api-key', + none: 'none', } as const satisfies Record; const LAKEHOUSE_AUTH_LABELS = { - basic_token: "basic", - username_password: "user/pass", - none: "none", + basic_token: 'basic', + username_password: 'user/pass', + none: 'none', } as const satisfies Record; const PROFILE_NAME_PART_PATTERN = /[^a-z0-9._-]+/g; @@ -228,10 +229,10 @@ function normalizeProfileNamePart(value: string): string { return value .trim() .toLowerCase() - .replace(PROFILE_NAME_PART_PATTERN, "-") - .replace(PROFILE_NAME_SEPARATOR_PATTERN, "-") - .replace(/^[._-]+/, "") - .replace(/[._-]+$/, ""); + .replace(PROFILE_NAME_PART_PATTERN, '-') + .replace(PROFILE_NAME_SEPARATOR_PATTERN, '-') + .replace(/^[._-]+/, '') + .replace(/[._-]+$/, ''); } export function deriveProfileName(org: string, env: string): string { @@ -239,7 +240,7 @@ export function deriveProfileName(org: string, env: string): string { const envPart = normalizeProfileNamePart(env); if (!orgPart || !envPart) { throw new ConfigurationError( - "Profile auto-naming requires non-empty organization and environment values.", + 'Profile auto-naming requires non-empty organization and environment values.' ); } const profileName = `${orgPart}_${envPart}`; @@ -250,14 +251,14 @@ export function deriveProfileName(org: string, env: string): string { export function deriveServiceAccountProfileName( org: string, env: string, - serviceAccountSlug: string, + serviceAccountSlug: string ): string { const orgPart = normalizeProfileNamePart(org); const envPart = normalizeProfileNamePart(env); const slugPart = normalizeProfileNamePart(serviceAccountSlug); if (!orgPart || !envPart || !slugPart) { throw new ConfigurationError( - "Profile auto-naming requires non-empty organization, environment, and service account values.", + 'Profile auto-naming requires non-empty organization, environment, and service account values.' ); } const profileName = `${orgPart}_${envPart}_${slugPart}`; @@ -283,27 +284,27 @@ function writeProfileUpdate(name: string, update: ProfileUpdate): void { export function detectConfiguredProfileAuth(name: string): ProfileAuth { if (isFromEnvProfile(name)) { return { - management: hasEnvManagementCredentials() ? "api_key" : "none", + management: hasEnvManagementCredentials() ? 'api_key' : 'none', lakehouse: envLakehouseAuthKind(), }; } return { - management: secretExists("oauth/access-token", name) - ? "oauth" - : secretExists("api-key", name) - ? "api_key" - : "none", - lakehouse: secretExists("lakehouse/basic-token", name) - ? "basic_token" - : secretExists("lakehouse/password", name) - ? "username_password" - : "none", + management: secretExists('oauth/access-token', name) + ? 'oauth' + : secretExists('api-key', name) + ? 'api_key' + : 'none', + lakehouse: secretExists('lakehouse/basic-token', name) + ? 'basic_token' + : secretExists('lakehouse/password', name) + ? 'username_password' + : 'none', }; } export function profileHasAnyAuthConfigured(name: string): boolean { const auth = detectConfiguredProfileAuth(name); - return auth.management !== "none" || auth.lakehouse !== "none"; + return auth.management !== 'none' || auth.lakehouse !== 'none'; } function readProfileSnapshot(name: string): ProfileSnapshot { @@ -313,10 +314,13 @@ function readProfileSnapshot(name: string): ProfileSnapshot { }; } -function profileStatus(snapshot: ProfileSnapshot): ProfileInspect["status"] { - const hasMetadata = Object.values(snapshot.config).some((value) => value !== undefined); - const hasAnyAuth = snapshot.auth.management !== "none" || snapshot.auth.lakehouse !== "none"; - return hasAnyAuth ? "configured" : hasMetadata ? "partial" : "empty"; +function profileStatus(snapshot: ProfileSnapshot): ProfileInspect['status'] { + const hasMetadata = Object.values(snapshot.config).some( + value => value !== undefined + ); + const hasAnyAuth = + snapshot.auth.management !== 'none' || snapshot.auth.lakehouse !== 'none'; + return hasAnyAuth ? 'configured' : hasMetadata ? 'partial' : 'empty'; } function profileManagementAuthSummary(auth: ProfileAuth): string { @@ -328,7 +332,7 @@ function profileLakehouseAuthSummary(auth: ProfileAuth): string { } function profilePrincipalSummary( - config: Record, + config: Record ): string | undefined { if (config.principal_email) { return config.principal_email; @@ -345,10 +349,12 @@ export function listProfiles(): ProfileSummary[] { const names = listProfileNames(); if (names.length === 0) { - return [{ name: DEFAULT_PROFILE_NAME, active: active === DEFAULT_PROFILE_NAME }]; + return [ + { name: DEFAULT_PROFILE_NAME, active: active === DEFAULT_PROFILE_NAME }, + ]; } - return names.map((name) => { + return names.map(name => { const snapshot = readProfileSnapshot(name); return { name, @@ -378,26 +384,29 @@ function parseTimestampMs(raw: string | undefined): string | undefined { } function envLakehouseAuthKind(): ProfileLakehouseAuth { - if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { - return "basic_token"; + if (readEnv('ALTERTABLE_BASIC_AUTH_TOKEN')) { + return 'basic_token'; } - if (readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { - return "username_password"; + if ( + readEnv('ALTERTABLE_LAKEHOUSE_USERNAME') && + readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD') + ) { + return 'username_password'; } - return "none"; + return 'none'; } function readEnvironmentProfileConfiguration(): ProfileConfiguration { return { name: FROM_ENV_PSEUDOPROFILE_NAME, active: true, - config_file: "environment variables", + config_file: 'environment variables', organization: {}, principal: {}, - environment: readEnv("ALTERTABLE_ENV"), + environment: readEnv('ALTERTABLE_ENV'), endpoints: { - data_plane: readEnv("ALTERTABLE_API_BASE"), - control_plane: readEnv("ALTERTABLE_MANAGEMENT_API_BASE"), + data_plane: readEnv('ALTERTABLE_API_BASE'), + control_plane: readEnv('ALTERTABLE_MANAGEMENT_API_BASE'), }, timestamps: {}, }; @@ -406,7 +415,7 @@ function readEnvironmentProfileConfiguration(): ProfileConfiguration { function buildProfileConfiguration( name: string, active: boolean, - config: Record, + config: Record ): ProfileConfiguration { return { name, @@ -432,7 +441,9 @@ function buildProfileConfiguration( updated_at: config.updated_at, last_verified_at: config.last_verified_at, oauth_expires_at: parseTimestampMs(config.oauth_expiry), - lakehouse_expires_at: parseTimestampMs(config.lakehouse_credential_expiry), + lakehouse_expires_at: parseTimestampMs( + config.lakehouse_credential_expiry + ), }, }; } @@ -440,7 +451,7 @@ function buildProfileConfiguration( function buildProfileInspection( name: string, active: boolean, - snapshot: ProfileSnapshot, + snapshot: ProfileSnapshot ): ProfileInspect { return { ...buildProfileConfiguration(name, active, snapshot.config), @@ -456,7 +467,10 @@ export function inspectProfile(name: string): ProfileInspect { return { ...configuration, auth, - status: auth.management !== "none" || auth.lakehouse !== "none" ? "configured" : "empty", + status: + auth.management !== 'none' || auth.lakehouse !== 'none' + ? 'configured' + : 'empty', }; } assertSafeProfileName(name); @@ -465,7 +479,11 @@ export function inspectProfile(name: string): ProfileInspect { throw new ConfigurationError(`Profile not found: ${name}`); } - return buildProfileInspection(name, getActiveProfileName() === name, readProfileSnapshot(name)); + return buildProfileInspection( + name, + getActiveProfileName() === name, + readProfileSnapshot(name) + ); } export function readProfileConfiguration(name: string): ProfileConfiguration { @@ -477,8 +495,13 @@ export function readProfileConfiguration(name: string): ProfileConfiguration { throw new ConfigurationError(`Profile not found: ${name}`); } - const activeProfile = kvGet(configFile(), "active_profile") || DEFAULT_PROFILE_NAME; - return buildProfileConfiguration(name, activeProfile === name, readProfileConfigRecord(name)); + const activeProfile = + kvGet(configFile(), 'active_profile') || DEFAULT_PROFILE_NAME; + return buildProfileConfiguration( + name, + activeProfile === name, + readProfileConfigRecord(name) + ); } export function createEmptyProfile(name: string): ProfileInspect { @@ -489,19 +512,22 @@ export function createEmptyProfile(name: string): ProfileInspect { } ensureProfileExists(name); const createdAt = nowIso(); - configSet("created_at", createdAt, name); - configSet("updated_at", createdAt, name); + configSet('created_at', createdAt, name); + configSet('updated_at', createdAt, name); return inspectProfile(name); } -export function updateProfile(name: string, update: ProfileUpdate): ProfileInspect { +export function updateProfile( + name: string, + update: ProfileUpdate +): ProfileInspect { assertSafeProfileName(name); ensureProfilesLayout(); if (!profileExists(name)) { throw new ConfigurationError(`Profile not found: ${name}`); } writeProfileUpdate(name, update); - configSet("updated_at", nowIso(), name); + configSet('updated_at', nowIso(), name); return inspectProfile(name); } @@ -515,7 +541,9 @@ type ProfileSecretSnapshot = { value: string; }; -function snapshotProfileConfig(name: string): ProfileConfigSnapshot | undefined { +function snapshotProfileConfig( + name: string +): ProfileConfigSnapshot | undefined { const filePath = profileConfigFile(name); if (!existsSync(filePath)) { return undefined; @@ -528,9 +556,9 @@ function snapshotProfileConfig(name: string): ProfileConfigSnapshot | undefined function snapshotProfileSecrets( name: string, - secrets: ProfileSecretStore, + secrets: ProfileSecretStore ): ProfileSecretSnapshot[] { - return PROFILE_SECRET_ACCOUNTS.map((account) => ({ + return PROFILE_SECRET_ACCOUNTS.map(account => ({ account, value: secrets.secretGet(account, name), })); @@ -546,25 +574,25 @@ function rollbackProfileRename(options: { const failures: ProfileRollbackFailure[] = []; if (profileExists(target) && !profileExists(source)) { - runProfileRollbackStep(failures, "restore profile directory", () => { + runProfileRollbackStep(failures, 'restore profile directory', () => { operations.renameDirectory(target, source); }); } if (wasActive) { - runProfileRollbackStep(failures, "restore active profile", () => { + runProfileRollbackStep(failures, 'restore active profile', () => { operations.setActive(source); }); } if (!profileExists(source)) { failures.push({ - step: "verify source profile", + step: 'verify source profile', error: new Error(`Profile directory is missing: ${source}`), }); } if (profileExists(target)) { failures.push({ - step: "verify target cleanup", + step: 'verify target cleanup', error: new Error(`Renamed profile directory still exists: ${target}`), }); } @@ -579,16 +607,23 @@ function rollbackProfileDelete(options: { secrets: ProfileSecretStore; operations: ProfileMutationOperations; }): ProfileRollbackFailure[] { - const { name, quarantinedName, configSnapshot, secretSnapshots, secrets, operations } = options; + const { + name, + quarantinedName, + configSnapshot, + secretSnapshots, + secrets, + operations, + } = options; const failures: ProfileRollbackFailure[] = []; if (profileExists(quarantinedName) && !profileExists(name)) { - runProfileRollbackStep(failures, "restore profile directory", () => { + runProfileRollbackStep(failures, 'restore profile directory', () => { operations.renameDirectory(quarantinedName, name); }); } - runProfileRollbackStep(failures, "restore profile configuration", () => { + runProfileRollbackStep(failures, 'restore profile configuration', () => { mkdirSync(profileDir(name), { recursive: true }); if (configSnapshot) { writeFileSync(profileConfigFile(name), configSnapshot.contents, { @@ -606,14 +641,16 @@ function rollbackProfileDelete(options: { if (!profileExists(name)) { failures.push({ - step: "verify source profile", + step: 'verify source profile', error: new Error(`Profile directory is missing: ${name}`), }); } if (profileExists(quarantinedName)) { failures.push({ - step: "verify quarantine cleanup", - error: new Error(`Quarantined profile directory still exists: ${quarantinedName}`), + step: 'verify quarantine cleanup', + error: new Error( + `Quarantined profile directory still exists: ${quarantinedName}` + ), }); } return failures; @@ -623,7 +660,7 @@ export function renameProfile( source: string, target: string, secrets: ProfileSecretStore = PROFILE_SECRET_STORE, - operations: ProfileMutationOperations = PROFILE_MUTATION_OPERATIONS, + operations: ProfileMutationOperations = PROFILE_MUTATION_OPERATIONS ): void { assertSafeProfileName(source); assertSafeProfileName(target); @@ -657,7 +694,7 @@ export function renameProfile( error, rollbackFailures, `Renaming profile "${source}" to "${target}"`, - "Inspect both profiles before retrying.", + 'Inspect both profiles before retrying.' ); } } @@ -665,7 +702,7 @@ export function renameProfile( export function deleteProfile( name: string, secrets: ProfileSecretStore = PROFILE_SECRET_STORE, - operations: ProfileMutationOperations = PROFILE_MUTATION_OPERATIONS, + operations: ProfileMutationOperations = PROFILE_MUTATION_OPERATIONS ): void { assertSafeProfileName(name); ensureProfilesLayout(); @@ -677,14 +714,14 @@ export function deleteProfile( const active = getActiveProfileName(); if (name === active) { throw new ConfigurationError( - `Cannot delete the active profile "${name}". Switch with: altertable profile switch `, + `Cannot delete the active profile "${name}". Switch with: altertable profile switch ` ); } secrets.migrateProfileSecrets(name, PROFILE_SECRET_ACCOUNTS); const secretSnapshots = snapshotProfileSecrets(name, secrets); const configSnapshot = snapshotProfileConfig(name); - const quarantinedName = `.deleting-${randomBytes(8).toString("hex")}`; + const quarantinedName = `.deleting-${randomBytes(8).toString('hex')}`; operations.renameDirectory(name, quarantinedName); try { for (const account of PROFILE_SECRET_ACCOUNTS) { @@ -704,7 +741,7 @@ export function deleteProfile( error, rollbackFailures, `Deleting profile "${name}"`, - "Inspect the profile before retrying.", + 'Inspect the profile before retrying.' ); } } @@ -720,18 +757,18 @@ type UnconfiguredCredential = { type ConfigureManagementApiKeyCredential = { configured: true; - mechanism: "management_api_key"; - source?: "stored" | "environment"; + mechanism: 'management_api_key'; + source?: 'stored' | 'environment'; environment?: string; - api_key?: "set"; + api_key?: 'set'; }; type ConfigureManagementOAuthCredential = { configured: true; - mechanism: "management_oauth"; - source: "stored"; + mechanism: 'management_oauth'; + source: 'stored'; environment?: string; - oauth?: "set"; + oauth?: 'set'; expires?: string; }; @@ -742,18 +779,18 @@ export type ConfigureManagementCredential = type ConfigureLakehouseBasicTokenCredential = { configured: true; - mechanism: "lakehouse_basic_token"; - source?: "stored" | "environment"; - basic_token?: "set"; + mechanism: 'lakehouse_basic_token'; + source?: 'stored' | 'environment'; + basic_token?: 'set'; expires?: string; }; type ConfigureLakehouseUsernamePasswordCredential = { configured: true; - mechanism: "lakehouse_username_password"; - source?: "stored" | "environment"; + mechanism: 'lakehouse_username_password'; + source?: 'stored' | 'environment'; user?: string; - password?: "set"; + password?: 'set'; }; export type ConfigureLakehouseCredential = @@ -786,30 +823,31 @@ export type ConfigureShowData = { }; function hasEnvManagementCredentials(): boolean { - return Boolean(readEnv("ALTERTABLE_API_KEY")); + return Boolean(readEnv('ALTERTABLE_API_KEY')); } function hasStoredManagementCredentials(profileName: string): boolean { - return secretExists("api-key", profileName); + return secretExists('api-key', profileName); } function hasOAuthLogin(profileName: string): boolean { - return secretExists("oauth/access-token", profileName); + return secretExists('oauth/access-token', profileName); } function hasEnvLakehouseCredentials(): boolean { - if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { + if (readEnv('ALTERTABLE_BASIC_AUTH_TOKEN')) { return true; } return Boolean( - readEnv("ALTERTABLE_LAKEHOUSE_USERNAME") && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD"), + readEnv('ALTERTABLE_LAKEHOUSE_USERNAME') && + readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD') ); } function hasStoredLakehouseCredentials(profileName: string): boolean { return ( - secretExists("lakehouse/basic-token", profileName) || - secretExists("lakehouse/password", profileName) + secretExists('lakehouse/basic-token', profileName) || + secretExists('lakehouse/password', profileName) ); } @@ -826,107 +864,126 @@ export function assertNoEnvConfigMode(): void { if (!envConfigMode()) { return; } - const lines = configuredEnvConfig().map(({ name, display }) => ` ${name.padEnd(32)} ${display}`); + const lines = configuredEnvConfig().map( + ({ name, display }) => ` ${name.padEnd(32)} ${display}` + ); throw new ConfigurationError( [ "Profile management commands aren't available when configuring through environment variables.", - "", - "Currently configured:", + '', + 'Currently configured:', ...lines, - ].join("\n"), + ].join('\n') ); } -export function configureCredentialStatus(profileName: string): ConfigureCredentialStatus { +export function configureCredentialStatus( + profileName: string +): ConfigureCredentialStatus { return { hasManagement: hasStoredManagementCredentials(profileName) || hasEnvManagementCredentials() || hasOAuthLogin(profileName), - hasLakehouse: hasStoredLakehouseCredentials(profileName) || hasEnvLakehouseCredentials(), + hasLakehouse: + hasStoredLakehouseCredentials(profileName) || + hasEnvLakehouseCredentials(), }; } -function buildManagementCredential(profileName: string): ConfigureManagementCredential { +function buildManagementCredential( + profileName: string +): ConfigureManagementCredential { // Precedence mirrors getManagementAuthHeader: env key → OAuth login → stored key. if (hasEnvManagementCredentials()) { return { configured: true, - mechanism: "management_api_key", - source: "environment", - environment: readEnv("ALTERTABLE_ENV") ?? configGet("api_key_env", profileName) ?? undefined, + mechanism: 'management_api_key', + source: 'environment', + environment: + readEnv('ALTERTABLE_ENV') ?? + configGet('api_key_env', profileName) ?? + undefined, }; } if (hasOAuthLogin(profileName)) { return { configured: true, - mechanism: "management_oauth", - source: "stored", - environment: configGet("api_key_env", profileName) || undefined, - oauth: "set", - expires: configGet("oauth_expiry", profileName) || undefined, + mechanism: 'management_oauth', + source: 'stored', + environment: configGet('api_key_env', profileName) || undefined, + oauth: 'set', + expires: configGet('oauth_expiry', profileName) || undefined, }; } if (hasStoredManagementCredentials(profileName)) { return { configured: true, - mechanism: "management_api_key", - source: "stored", - environment: configGet("api_key_env", profileName) || undefined, - api_key: "set", + mechanism: 'management_api_key', + source: 'stored', + environment: configGet('api_key_env', profileName) || undefined, + api_key: 'set', }; } return { configured: false }; } -function buildLakehouseCredential(profileName: string): ConfigureLakehouseCredential { +function buildLakehouseCredential( + profileName: string +): ConfigureLakehouseCredential { if (hasStoredLakehouseCredentials(profileName)) { - if (secretExists("lakehouse/basic-token", profileName)) { + if (secretExists('lakehouse/basic-token', profileName)) { return { configured: true, - mechanism: "lakehouse_basic_token", - source: "stored", - basic_token: "set", - expires: configGet("lakehouse_credential_expiry", profileName) || undefined, + mechanism: 'lakehouse_basic_token', + source: 'stored', + basic_token: 'set', + expires: + configGet('lakehouse_credential_expiry', profileName) || undefined, }; } return { configured: true, - mechanism: "lakehouse_username_password", - source: "stored", - user: configGet("user", profileName) || undefined, - password: "set", + mechanism: 'lakehouse_username_password', + source: 'stored', + user: configGet('user', profileName) || undefined, + password: 'set', }; } - if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { + if (readEnv('ALTERTABLE_BASIC_AUTH_TOKEN')) { return { configured: true, - mechanism: "lakehouse_basic_token", - source: "environment", + mechanism: 'lakehouse_basic_token', + source: 'environment', }; } - const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); - if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { + const envUser = readEnv('ALTERTABLE_LAKEHOUSE_USERNAME'); + if (envUser && readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD')) { return { configured: true, - mechanism: "lakehouse_username_password", - source: "environment", + mechanism: 'lakehouse_username_password', + source: 'environment', user: envUser, }; } return { configured: false }; } -function buildConfigureShowOverrides(profileName: string): ConfigureShowOverrides { +function buildConfigureShowOverrides( + profileName: string +): ConfigureShowOverrides { const overrides: ConfigureShowOverrides = {}; - const storedApiKeyEnv = configGet("api_key_env", profileName); - const envOverride = readEnv("ALTERTABLE_ENV"); + const storedApiKeyEnv = configGet('api_key_env', profileName); + const envOverride = readEnv('ALTERTABLE_ENV'); if (envOverride && envOverride !== storedApiKeyEnv) { overrides.environment = envOverride; overrides.stored_environment = storedApiKeyEnv || null; } - if (readEnv("ALTERTABLE_API_KEY") && hasStoredManagementCredentials(profileName)) { + if ( + readEnv('ALTERTABLE_API_KEY') && + hasStoredManagementCredentials(profileName) + ) { overrides.api_key = true; } return overrides; @@ -939,8 +996,8 @@ export function buildConfigureShowData(profileName: string): ConfigureShowData { config_file: profileConfigFile(profileName), secret_store: secretStoreDisplay(), organization: { - slug: configGet("organization_slug", profileName) || undefined, - name: configGet("organization_name", profileName) || undefined, + slug: configGet('organization_slug', profileName) || undefined, + name: configGet('organization_name', profileName) || undefined, }, data_plane: resolveApiBase(profileName), control_plane: resolveManagementApiBase(profileName), @@ -952,33 +1009,35 @@ export function buildConfigureShowData(profileName: string): ConfigureShowData { }; } -export function managementPlaneStatusDetail(profileName: string): string | null { +export function managementPlaneStatusDetail( + profileName: string +): string | null { if (hasEnvManagementCredentials()) { - return "via ALTERTABLE_API_KEY"; + return 'via ALTERTABLE_API_KEY'; } if (hasOAuthLogin(profileName)) { - const env = configGet("api_key_env", profileName); - return env ? `OAuth login (${env})` : "OAuth login"; + const env = configGet('api_key_env', profileName); + return env ? `OAuth login (${env})` : 'OAuth login'; } if (!hasStoredManagementCredentials(profileName)) { return null; } - return configGet("api_key_env", profileName) || "unknown"; + return configGet('api_key_env', profileName) || 'unknown'; } export function lakehousePlaneStatusDetail(profileName: string): string | null { - if (readEnv("ALTERTABLE_BASIC_AUTH_TOKEN")) { - return "via ALTERTABLE_BASIC_AUTH_TOKEN"; + if (readEnv('ALTERTABLE_BASIC_AUTH_TOKEN')) { + return 'via ALTERTABLE_BASIC_AUTH_TOKEN'; } - const envUser = readEnv("ALTERTABLE_LAKEHOUSE_USERNAME"); - if (envUser && readEnv("ALTERTABLE_LAKEHOUSE_PASSWORD")) { + const envUser = readEnv('ALTERTABLE_LAKEHOUSE_USERNAME'); + if (envUser && readEnv('ALTERTABLE_LAKEHOUSE_PASSWORD')) { return envUser; } if (!hasStoredLakehouseCredentials(profileName)) { return null; } - if (secretExists("lakehouse/basic-token", profileName)) { - return "basic token"; + if (secretExists('lakehouse/basic-token', profileName)) { + return 'basic token'; } - return configGet("user", profileName) || "unknown"; + return configGet('user', profileName) || 'unknown'; } diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts index 1ed76444..076a605b 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,52 +1,55 @@ -import { describe, expect, test } from "bun:test"; -import type { ProfileInspect } from "@/lib/profile/model.ts"; -import { formatProfileInspectResult, formatProfileStatus } from "@/lib/profile/render.ts"; +import { describe, expect, test } from 'bun:test'; +import type { ProfileInspect } from '@/lib/profile/model.ts'; +import { + formatProfileInspectResult, + formatProfileStatus, +} from '@/lib/profile/render.ts'; const profile: ProfileInspect = { - name: "acme_prod", + name: 'acme_prod', active: true, - config_file: "/config/profiles/acme_prod/config", - organization: { slug: "acme" }, + config_file: '/config/profiles/acme_prod/config', + organization: { slug: 'acme' }, principal: {}, - environment: "production", + environment: 'production', endpoints: {}, - auth: { management: "api_key", lakehouse: "none" }, - status: "configured", + auth: { management: 'api_key', lakehouse: 'none' }, + status: 'configured', timestamps: {}, }; -describe("profile rendering", () => { - test("renders stored profile details without exposing credentials", () => { +describe('profile rendering', () => { + test('renders stored profile details without exposing credentials', () => { const output = formatProfileInspectResult(profile); - expect(output).toContain("Management auth"); - expect(output).toContain("api_key"); - expect(output).toContain("Environment"); - expect(output).toContain("production"); - expect(output).toContain("Config file"); + expect(output).toContain('Management auth'); + expect(output).toContain('api_key'); + expect(output).toContain('Environment'); + expect(output).toContain('production'); + expect(output).toContain('Config file'); }); - test("renders verification alongside profile details", () => { + test('renders verification alongside profile details', () => { const output = formatProfileStatus({ profile, verification: { profile: profile.name, - configured: ["management"], + configured: ['management'], verified: { management: true, lakehouse: false }, errors: [], }, }); - expect(output).toContain("Management auth"); - expect(output).toContain("Verification:"); - expect(output).toContain("Management:"); - expect(output).toContain("verified"); + expect(output).toContain('Management auth'); + expect(output).toContain('Verification:'); + expect(output).toContain('Management:'); + expect(output).toContain('verified'); }); - test("renders guidance for an empty profile", () => { - const output = formatProfileInspectResult({ ...profile, status: "empty" }); + test('renders guidance for an empty profile', () => { + const output = formatProfileInspectResult({ ...profile, status: 'empty' }); - expect(output).toContain("Next steps:"); - expect(output).toContain("altertable profile configure"); + expect(output).toContain('Next steps:'); + expect(output).toContain('altertable profile configure'); }); }); diff --git a/cli/src/lib/profile/render.ts b/cli/src/lib/profile/render.ts index d0f35811..27744d55 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -4,23 +4,28 @@ import { buildProfileStatusView, configureAuthenticationRows, type ProfileStatusResult, -} from "@/lib/profile/views.ts"; +} from '@/lib/profile/views.ts'; import { buildConfigureShowData, type ProfileInspect, type ProfileSummary, -} from "@/lib/profile/model.ts"; -import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { renderDocumentText, renderRows } from "@/ui/renderers/terminal.ts"; +} from '@/lib/profile/model.ts'; +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { renderDocumentText, renderRows } from '@/ui/renderers/terminal.ts'; import { nestedIndent, TERMINAL_INDENT, TERMINAL_LABEL_WIDTH, TERMINAL_NESTED_LABEL_WIDTH, -} from "@/ui/terminal/spacing.ts"; +} from '@/ui/terminal/spacing.ts'; -export function formatProfileInspectResult(profile: ProfileInspect, interactive = true): string { - return renderDocumentText(buildProfileInspectResultView(profile, interactive)); +export function formatProfileInspectResult( + profile: ProfileInspect, + interactive = true +): string { + return renderDocumentText( + buildProfileInspectResultView(profile, interactive) + ); } export function formatProfileStatus(result: ProfileStatusResult): string { @@ -39,7 +44,7 @@ type FormatConfigureAuthenticationOptions = { function renderConfigureRows( rows: Parameters[0], - options: FormatConfigureAuthenticationOptions, + options: FormatConfigureAuthenticationOptions ): string[] { return renderRows(rows, { indent: options.indent ?? TERMINAL_INDENT, @@ -51,17 +56,22 @@ function renderConfigureRows( export function formatConfigureAuthenticationLines( profileName: string, - options: FormatConfigureAuthenticationOptions = {}, + options: FormatConfigureAuthenticationOptions = {} ): string[] { return renderConfigureRows( - configureAuthenticationRows(buildConfigureShowData(profileName), options.planes), - options, + configureAuthenticationRows( + buildConfigureShowData(profileName), + options.planes + ), + options ); } export function formatConfigureSessionSummary( profileName: string, - configuredPlanes: ConfigureAuthPlane[], + configuredPlanes: ConfigureAuthPlane[] ): string[] { - return formatConfigureAuthenticationLines(profileName, { planes: configuredPlanes }); + return formatConfigureAuthenticationLines(profileName, { + planes: configuredPlanes, + }); } diff --git a/cli/src/lib/profile/views.test.ts b/cli/src/lib/profile/views.test.ts index a214ea84..9fc89576 100644 --- a/cli/src/lib/profile/views.test.ts +++ b/cli/src/lib/profile/views.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "bun:test"; -import type { ProfileInspect, ProfileSummary } from "@/lib/profile/model.ts"; +import { describe, expect, test } from 'bun:test'; +import type { ProfileInspect, ProfileSummary } from '@/lib/profile/model.ts'; import { buildProfileInspectView, buildProfileInspectResultView, @@ -7,109 +7,112 @@ import { buildProfileShellExportView, profileStatusToJson, profileInspectToJson, -} from "@/lib/profile/views.ts"; +} from '@/lib/profile/views.ts'; const profile: ProfileInspect = { - name: "acme_prod", + name: 'acme_prod', active: true, - config_file: "/config/profiles/acme_prod/config", - organization: { slug: "acme", name: "Acme Inc" }, + config_file: '/config/profiles/acme_prod/config', + organization: { slug: 'acme', name: 'Acme Inc' }, principal: {}, - environment: "production", - endpoints: { data_plane: "https://api.example.com" }, - auth: { management: "api_key", lakehouse: "none" }, - status: "configured", + environment: 'production', + endpoints: { data_plane: 'https://api.example.com' }, + auth: { management: 'api_key', lakehouse: 'none' }, + status: 'configured', timestamps: {}, }; -describe("profile views", () => { - test("describes the profile list table", () => { - const summaries: ProfileSummary[] = [{ name: "acme_prod", active: true }]; +describe('profile views', () => { + test('describes the profile list table', () => { + const summaries: ProfileSummary[] = [{ name: 'acme_prod', active: true }]; const [section] = buildProfileListView(summaries).sections; const [block] = section?.blocks ?? []; - expect(block?.kind).toBe("table"); - if (block?.kind === "table") { - expect(block.table.columns.map((column) => column.header)).toEqual([ - " NAME", - "ORG", - "PRINCIPAL", - "ENV", - "MGMT", - "LAKEHOUSE", - "OAUTH EXPIRES", - "STATUS", - "DATA PLANE", + expect(block?.kind).toBe('table'); + if (block?.kind === 'table') { + expect(block.table.columns.map(column => column.header)).toEqual([ + ' NAME', + 'ORG', + 'PRINCIPAL', + 'ENV', + 'MGMT', + 'LAKEHOUSE', + 'OAUTH EXPIRES', + 'STATUS', + 'DATA PLANE', ]); - expect(block.table.emptyMessage).toBe("No profiles configured."); + expect(block.table.emptyMessage).toBe('No profiles configured.'); } }); - test("describes profile inspection rows and linked endpoints", () => { + test('describes profile inspection rows and linked endpoints', () => { const [section] = buildProfileInspectView(profile).sections; const [block] = section?.blocks ?? []; - expect(block?.kind).toBe("rows"); - if (block?.kind === "rows") { + expect(block?.kind).toBe('rows'); + if (block?.kind === 'rows') { expect(block.rows).toEqual( expect.arrayContaining([ - { label: "Profile", value: "acme_prod (active)" }, - { label: "Organization", value: "acme" }, + { label: 'Profile', value: 'acme_prod (active)' }, + { label: 'Organization', value: 'acme' }, { - label: "Data plane", + label: 'Data plane', value: [ { - text: "https://api.example.com", - style: "accent", - href: "https://api.example.com", + text: 'https://api.example.com', + style: 'accent', + href: 'https://api.example.com', }, ], }, - ]), + ]) ); } }); - test("derives a shell export view from the profile selection", () => { - expect(buildProfileShellExportView("acme_prod")).toEqual({ - env: { ALTERTABLE_PROFILE: "acme_prod" }, + test('derives a shell export view from the profile selection', () => { + expect(buildProfileShellExportView('acme_prod')).toEqual({ + env: { ALTERTABLE_PROFILE: 'acme_prod' }, }); }); - test.each(["empty", "partial"] as const)( - "adds actionable next steps to %s profile inspection", - (status) => { + test.each(['empty', 'partial'] as const)( + 'adds actionable next steps to %s profile inspection', + status => { const incompleteProfile = { ...profile, status }; const view = buildProfileInspectResultView(incompleteProfile); expect(view.sections.at(-1)?.blocks).toEqual([ - { kind: "text", lines: ["Next steps:", "Run: altertable profile configure"] }, + { + kind: 'text', + lines: ['Next steps:', 'Run: altertable profile configure'], + }, ]); expect(profileInspectToJson(incompleteProfile)).toMatchObject({ profile: { status }, - next_steps: [expect.stringContaining("--password-stdin")], + next_steps: [expect.stringContaining('--password-stdin')], }); - }, + } ); - test("omits next steps from configured profile inspection", () => { + test('omits next steps from configured profile inspection', () => { expect(profileInspectToJson(profile)).toMatchObject({ next_steps: [] }); }); - test("serializes profile status without changing its public shape", () => { + test('serializes profile status without changing its public shape', () => { const json = profileStatusToJson({ profile, verification: { profile: profile.name, - configured: ["management"], + configured: ['management'], verified: { management: true, lakehouse: false }, errors: [], }, }); expect(json).toMatchObject({ - profile: { name: "acme_prod" }, - verification: { configured: ["management"] }, + profile: { name: 'acme_prod' }, + verification: { configured: ['management'] }, }); }); }); diff --git a/cli/src/lib/profile/views.ts b/cli/src/lib/profile/views.ts index 708a4038..853f887c 100644 --- a/cli/src/lib/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -2,8 +2,8 @@ import { type ConfigureAuthPlane, type ConfigureVerifyResult, formatConfigureVerifyRemediation, -} from "@/lib/profile-status.ts"; -import type { SelectOption } from "@/ui/prompts.ts"; +} from '@/lib/profile-status.ts'; +import type { SelectOption } from '@/ui/prompts.ts'; import { document, rows, @@ -14,24 +14,24 @@ import { type DisplayDocument, type DisplayRow, type DisplayText, -} from "@/ui/document.ts"; -import { TERMINAL_INDENT } from "@/ui/terminal/spacing.ts"; +} from '@/ui/document.ts'; +import { TERMINAL_INDENT } from '@/ui/terminal/spacing.ts'; import type { ConfigureLakehouseCredential, ConfigureManagementCredential, ConfigureShowData, ProfileInspect, ProfileSummary, -} from "@/lib/profile/model.ts"; -import { isFromEnvProfile } from "@/lib/profile-store.ts"; -import type { ShellExportView } from "@/ui/shell/model.ts"; +} from '@/lib/profile/model.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import type { ShellExportView } from '@/ui/shell/model.ts'; -const ACTIVE_PROFILE_MARK = "✓"; -const INACTIVE_PROFILE_MARK = " "; +const ACTIVE_PROFILE_MARK = '✓'; +const INACTIVE_PROFILE_MARK = ' '; const PROFILE_NAME_HEADER = `${INACTIVE_PROFILE_MARK} NAME`; function linkedUrl(value: string): DisplayText { - return /^https?:\/\//.test(value) ? [span(value, "accent", value)] : value; + return /^https?:\/\//.test(value) ? [span(value, 'accent', value)] : value; } function profileInspectRows(profile: ProfileInspect): DisplayRow[] { @@ -40,75 +40,94 @@ function profileInspectRows(profile: ProfileInspect): DisplayRow[] { const identityRows: DisplayRow[] = isFromEnvProfile(profile.name) ? [] : [ - { label: "Profile", value: `${profile.name}${profile.active ? " (active)" : ""}` }, - { label: "Status", value: profile.status }, + { + label: 'Profile', + value: `${profile.name}${profile.active ? ' (active)' : ''}`, + }, + { label: 'Status', value: profile.status }, ]; return [ ...identityRows, - { label: "Principal", value: formatProfilePrincipal(profile) }, - { label: "Organization", value: profile.organization.slug ?? "not set" }, - { label: "Environment", value: profile.environment ?? "not set" }, - { label: "Management auth", value: profile.auth.management }, - { label: "Lakehouse auth", value: profile.auth.lakehouse }, - { label: "OAuth expires", value: profile.timestamps.oauth_expires_at ?? "not set" }, + { label: 'Principal', value: formatProfilePrincipal(profile) }, + { label: 'Organization', value: profile.organization.slug ?? 'not set' }, + { label: 'Environment', value: profile.environment ?? 'not set' }, + { label: 'Management auth', value: profile.auth.management }, + { label: 'Lakehouse auth', value: profile.auth.lakehouse }, { - label: "Lakehouse expires", - value: profile.timestamps.lakehouse_expires_at ?? "not set", + label: 'OAuth expires', + value: profile.timestamps.oauth_expires_at ?? 'not set', }, { - label: "Data plane", - value: linkedUrl(profile.endpoints.data_plane ?? "default"), + label: 'Lakehouse expires', + value: profile.timestamps.lakehouse_expires_at ?? 'not set', }, { - label: "Control plane", - value: linkedUrl(profile.endpoints.control_plane ?? "default"), + label: 'Data plane', + value: linkedUrl(profile.endpoints.data_plane ?? 'default'), }, - { label: "Config file", value: [span(profile.config_file, "accent")] }, + { + label: 'Control plane', + value: linkedUrl(profile.endpoints.control_plane ?? 'default'), + }, + { label: 'Config file', value: [span(profile.config_file, 'accent')] }, ]; } -export function buildProfileInspectView(profile: ProfileInspect): DisplayDocument { +export function buildProfileInspectView( + profile: ProfileInspect +): DisplayDocument { if (isFromEnvProfile(profile.name)) { return document( section( - text([`${TERMINAL_INDENT}Configuration set from environment variables`]), - rows(profileInspectRows(profile)), - ), + text([ + `${TERMINAL_INDENT}Configuration set from environment variables`, + ]), + rows(profileInspectRows(profile)) + ) ); } return document(section(rows(profileInspectRows(profile)))); } -export function profileInspectNextSteps(profile: ProfileInspect, interactive = true): string[] { - if (profile.status === "configured") return []; - if (interactive) return ["Run: altertable profile configure"]; +export function profileInspectNextSteps( + profile: ProfileInspect, + interactive = true +): string[] { + if (profile.status === 'configured') return []; + if (interactive) return ['Run: altertable profile configure']; const nextSteps: string[] = []; - if (profile.auth.management === "none") { + if (profile.auth.management === 'none') { nextSteps.push( - `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env `, + `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env ` ); } - if (profile.auth.lakehouse === "none") { + if (profile.auth.lakehouse === 'none') { nextSteps.push( - `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin`, + `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin` ); } - return nextSteps.length > 0 ? nextSteps : ["Run: altertable profile configure"]; + return nextSteps.length > 0 + ? nextSteps + : ['Run: altertable profile configure']; } export function buildProfileInspectResultView( profile: ProfileInspect, - interactive = true, + interactive = true ): DisplayDocument { const nextSteps = profileInspectNextSteps(profile, interactive); return document( ...buildProfileInspectView(profile).sections, - ...(nextSteps.length > 0 ? [section(text(["Next steps:", ...nextSteps]))] : []), + ...(nextSteps.length > 0 + ? [section(text(['Next steps:', ...nextSteps]))] + : []) ); } -export function profileInspectToJson(profile: ProfileInspect): Record { +export function profileInspectToJson( + profile: ProfileInspect +): Record { return { profile, next_steps: profileInspectNextSteps(profile, false) }; } @@ -123,10 +142,12 @@ function formatProfilePrincipal(profile: ProfileInspect): string { ? `${profile.principal.name} (${profile.principal.slug})` : profile.principal.slug; } - return profile.principal.name ?? "not set"; + return profile.principal.name ?? 'not set'; } -export function buildProfileListView(profiles: readonly ProfileSummary[]): DisplayDocument { +export function buildProfileListView( + profiles: readonly ProfileSummary[] +): DisplayDocument { return document( section( table({ @@ -134,55 +155,55 @@ export function buildProfileListView(profiles: readonly ProfileSummary[]): Displ columns: [ { header: PROFILE_NAME_HEADER, - cell: (profile) => [ + cell: profile => [ span( `${profile.active ? ACTIVE_PROFILE_MARK : INACTIVE_PROFILE_MARK} ${profile.name}`, - "strong", + 'strong' ), ], }, { - header: "ORG", - cell: (profile) => [span(profile.organization ?? "", "muted")], + header: 'ORG', + cell: profile => [span(profile.organization ?? '', 'muted')], }, { - header: "PRINCIPAL", - cell: (profile) => [span(profile.principal ?? "", "muted")], + header: 'PRINCIPAL', + cell: profile => [span(profile.principal ?? '', 'muted')], }, { - header: "ENV", - cell: (profile) => [span(profile.management_env ?? "", "muted")], + header: 'ENV', + cell: profile => [span(profile.management_env ?? '', 'muted')], }, { - header: "MGMT", - cell: (profile) => [span(profile.management_auth ?? "", "string")], + header: 'MGMT', + cell: profile => [span(profile.management_auth ?? '', 'string')], }, { - header: "LAKEHOUSE", - cell: (profile) => [span(profile.lakehouse_auth ?? "", "string")], + header: 'LAKEHOUSE', + cell: profile => [span(profile.lakehouse_auth ?? '', 'string')], }, { - header: "OAUTH EXPIRES", - cell: (profile) => [span(profile.oauth_expires_at ?? "", "muted")], + header: 'OAUTH EXPIRES', + cell: profile => [span(profile.oauth_expires_at ?? '', 'muted')], }, { - header: "STATUS", - cell: (profile) => [span(profile.status ?? "", "accent")], + header: 'STATUS', + cell: profile => [span(profile.status ?? '', 'accent')], }, { - header: "DATA PLANE", - cell: (profile) => linkedUrl(profile.data_plane ?? ""), + header: 'DATA PLANE', + cell: profile => linkedUrl(profile.data_plane ?? ''), }, ], - emptyMessage: "No profiles configured.", - }), - ), + emptyMessage: 'No profiles configured.', + }) + ) ); } export function profileSwitchOption(profile: ProfileSummary): SelectOption { const details = [ - profile.active && "active", + profile.active && 'active', profile.organization && `org: ${profile.organization}`, profile.management_env && `env: ${profile.management_env}`, profile.principal && `principal: ${profile.principal}`, @@ -191,11 +212,16 @@ export function profileSwitchOption(profile: ProfileSummary): SelectOption { ].filter((detail): detail is string => Boolean(detail)); return { value: profile.name, - label: details.length > 0 ? `${profile.name} (${details.join(", ")})` : profile.name, + label: + details.length > 0 + ? `${profile.name} (${details.join(', ')})` + : profile.name, }; } -export function buildProfileShellExportView(profileName: string): ShellExportView { +export function buildProfileShellExportView( + profileName: string +): ShellExportView { return { env: { ALTERTABLE_PROFILE: profileName }, }; @@ -207,44 +233,51 @@ export type ProfileStatusResult = { }; function verificationPlaneLabel(plane: ConfigureAuthPlane): string { - return plane === "management" ? "Management:" : "Lakehouse:"; + return plane === 'management' ? 'Management:' : 'Lakehouse:'; } function verificationSummary(result: ConfigureVerifyResult): string { if (result.configured.length === 0) { - return "no credentials configured"; + return 'no credentials configured'; } if (result.errors.length === 0) { - return "verified"; + return 'verified'; } - return `failed (${result.errors.map((error) => error.plane).join(", ")})`; + return `failed (${result.errors.map(error => error.plane).join(', ')})`; } function verificationRows(result: ConfigureVerifyResult): DisplayRow[] { - const summary: DisplayRow = { label: "Verification:", value: verificationSummary(result) }; - const planes: DisplayRow[] = result.configured.map((plane) => ({ + const summary: DisplayRow = { + label: 'Verification:', + value: verificationSummary(result), + }; + const planes: DisplayRow[] = result.configured.map(plane => ({ label: verificationPlaneLabel(plane), - value: result.verified[plane] ? "verified" : "failed", + value: result.verified[plane] ? 'verified' : 'failed', level: 1, })); return [summary, ...planes]; } -export function buildProfileStatusView(result: ProfileStatusResult): DisplayDocument { +export function buildProfileStatusView( + result: ProfileStatusResult +): DisplayDocument { const errors = result.verification.errors.map( - (error) => - ` ${error.plane}: ${error.message}\n ${formatConfigureVerifyRemediation(error.plane, result.verification.profile)}`, + error => + ` ${error.plane}: ${error.message}\n ${formatConfigureVerifyRemediation(error.plane, result.verification.profile)}` ); return document( ...buildProfileInspectView(result.profile).sections, section( rows(verificationRows(result.verification)), - ...(errors.length > 0 ? [text(errors)] : []), - ), + ...(errors.length > 0 ? [text(errors)] : []) + ) ); } -export function profileStatusToJson(result: ProfileStatusResult): Record { +export function profileStatusToJson( + result: ProfileStatusResult +): Record { return { profile: result.profile, verification: { @@ -257,96 +290,120 @@ export function profileStatusToJson(result: ProfileStatusResult): Record planes === undefined || planes.includes(plane); return [ - ...(includePlane("management") + ...(includePlane('management') ? managementCredentialRows(data.credentials.management, data.organization) : []), - ...(includePlane("lakehouse") ? lakehouseCredentialRows(data.credentials.lakehouse) : []), + ...(includePlane('lakehouse') + ? lakehouseCredentialRows(data.credentials.lakehouse) + : []), ]; } diff --git a/cli/src/lib/progress.test.ts b/cli/src/lib/progress.test.ts index b57d5f83..f93d2d7d 100644 --- a/cli/src/lib/progress.test.ts +++ b/cli/src/lib/progress.test.ts @@ -1,62 +1,71 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { setCliContext } from "@/context.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { setCliContext } from '@/context.ts'; import { formatProgressStatus, formatUploadProgress, shouldShowProgress, startProgress, -} from "@/lib/progress.ts"; -import { setTerminalColorMode } from "@/ui/terminal/styles.ts"; +} from '@/lib/progress.ts'; +import { setTerminalColorMode } from '@/ui/terminal/styles.ts'; -describe("shouldShowProgress", () => { +describe('shouldShowProgress', () => { const originalStderrIsTTY = process.stderr.isTTY; afterEach(() => { setTerminalColorMode(undefined); - Object.defineProperty(process.stderr, "isTTY", { + Object.defineProperty(process.stderr, 'isTTY', { value: originalStderrIsTTY, configurable: true, }); setCliContext({ debug: false, json: false, agent: false }); }); - test("returns false when json mode is enabled", () => { + test('returns false when json mode is enabled', () => { setCliContext({ debug: false, json: true, agent: false }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); expect(shouldShowProgress()).toBe(false); }); - test("returns false when stderr is not a TTY", () => { + test('returns false when stderr is not a TTY', () => { setCliContext({ debug: false, json: false, agent: false }); - Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stderr, 'isTTY', { + value: false, + configurable: true, + }); expect(shouldShowProgress()).toBe(false); }); }); -describe("formatUploadProgress", () => { +describe('formatUploadProgress', () => { const originalStderrIsTTY = process.stderr.isTTY; afterEach(() => { - Object.defineProperty(process.stderr, "isTTY", { + Object.defineProperty(process.stderr, 'isTTY', { value: originalStderrIsTTY, configurable: true, }); setCliContext({ debug: false, json: false, agent: false }); }); - test("shows percent for partial upload", () => { - expect(formatUploadProgress(512, 1024)).toContain("50"); - expect(formatUploadProgress(512, 1024)).toContain("512/1024"); + test('shows percent for partial upload', () => { + expect(formatUploadProgress(512, 1024)).toContain('50'); + expect(formatUploadProgress(512, 1024)).toContain('512/1024'); }); - test("disabled when json mode is enabled", () => { + test('disabled when json mode is enabled', () => { setCliContext({ debug: false, json: true, agent: false }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); expect(shouldShowProgress()).toBe(false); }); }); -describe("formatProgressStatus", () => { +describe('formatProgressStatus', () => { beforeEach(() => { setTerminalColorMode(undefined); }); @@ -65,20 +74,20 @@ describe("formatProgressStatus", () => { setTerminalColorMode(undefined); }); - test("formats semantic progress states", () => { - expect(formatProgressStatus("active", "Working")).toContain("Working"); - expect(formatProgressStatus("success", "Done")).toContain("Done"); - expect(formatProgressStatus("error", "Failed")).toContain("Failed"); + test('formats semantic progress states', () => { + expect(formatProgressStatus('active', 'Working')).toContain('Working'); + expect(formatProgressStatus('success', 'Done')).toContain('Done'); + expect(formatProgressStatus('error', 'Failed')).toContain('Failed'); }); }); -describe("startProgress", () => { +describe('startProgress', () => { beforeEach(() => { setCliContext({ debug: false, json: true, agent: false }); }); - test("no-op handle does not throw", () => { - const handle = startProgress("Working"); + test('no-op handle does not throw', () => { + const handle = startProgress('Working'); expect(() => handle.done()).not.toThrow(); expect(() => handle.fail()).not.toThrow(); }); diff --git a/cli/src/lib/progress.ts b/cli/src/lib/progress.ts index 77fba5f0..8dbfa5f7 100644 --- a/cli/src/lib/progress.ts +++ b/cli/src/lib/progress.ts @@ -1,15 +1,15 @@ -import { isJsonOutput } from "@/context.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import { isJsonOutput } from '@/context.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export type ProgressHandle = { done: (message?: string) => void; fail: (message?: string) => void; }; -export type ProgressStatus = "active" | "success" | "error"; +export type ProgressStatus = 'active' | 'success' | 'error'; -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const SPINNER_INTERVAL_MS = 80; const noopHandle: ProgressHandle = { @@ -27,25 +27,31 @@ export function shouldShowProgress(): boolean { return true; } -export function formatUploadProgress(sentBytes: number, totalBytes: number): string { +export function formatUploadProgress( + sentBytes: number, + totalBytes: number +): string { if (totalBytes <= 0) { - return renderDisplayText([span(`Uploading ${sentBytes} bytes`, "muted")]); + return renderDisplayText([span(`Uploading ${sentBytes} bytes`, 'muted')]); } const percent = Math.min(100, Math.round((sentBytes / totalBytes) * 100)); return renderDisplayText([ - span(`Uploading ${percent}% (${sentBytes}/${totalBytes} bytes)`, "muted"), + span(`Uploading ${percent}% (${sentBytes}/${totalBytes} bytes)`, 'muted'), ]); } -export function formatProgressStatus(status: ProgressStatus, message: string): string { - if (status === "success") { - return renderDisplayText([span("✓", "success"), span(` ${message}`)]); +export function formatProgressStatus( + status: ProgressStatus, + message: string +): string { + if (status === 'success') { + return renderDisplayText([span('✓', 'success'), span(` ${message}`)]); } - if (status === "error") { - return renderDisplayText([span("✗", "error"), span(` ${message}`)]); + if (status === 'error') { + return renderDisplayText([span('✗', 'error'), span(` ${message}`)]); } - return renderDisplayText([span(message, "muted")]); + return renderDisplayText([span(message, 'muted')]); } export type UploadProgressReporter = { @@ -53,7 +59,9 @@ export type UploadProgressReporter = { clear: () => void; }; -export function createUploadProgressReporter(totalBytes: number): UploadProgressReporter { +export function createUploadProgressReporter( + totalBytes: number +): UploadProgressReporter { if (!shouldShowProgress()) { return { report() {}, @@ -66,14 +74,16 @@ export function createUploadProgressReporter(totalBytes: number): UploadProgress function report(sentBytes: number, total: number) { const line = formatUploadProgress(sentBytes, total); const padding = - previousLineLength > line.length ? " ".repeat(previousLineLength - line.length) : ""; + previousLineLength > line.length + ? ' '.repeat(previousLineLength - line.length) + : ''; process.stderr.write(`\r${line}${padding}`); previousLineLength = line.length; } function clear() { if (previousLineLength > 0) { - process.stderr.write(`\r${" ".repeat(previousLineLength)}\r`); + process.stderr.write(`\r${' '.repeat(previousLineLength)}\r`); previousLineLength = 0; } } @@ -87,15 +97,15 @@ export function startProgress(message: string): ProgressHandle { return noopHandle; } - const label = message.endsWith("…") ? message : `${message}…`; + const label = message.endsWith('…') ? message : `${message}…`; let frameIndex = 0; let intervalId: ReturnType | undefined; function writeFrame() { - const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length] ?? "⠋"; + const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length] ?? '⠋'; frameIndex += 1; process.stderr.write( - `\r${renderDisplayText([span(frame, "accent")])} ${formatProgressStatus("active", label)}`, + `\r${renderDisplayText([span(frame, 'accent')])} ${formatProgressStatus('active', label)}` ); } @@ -107,12 +117,12 @@ export function startProgress(message: string): ProgressHandle { clearInterval(intervalId); intervalId = undefined; } - process.stderr.write(`\r${" ".repeat(label.length + 4)}\r`); + process.stderr.write(`\r${' '.repeat(label.length + 4)}\r`); if (finalMessage !== undefined && finalMessage.length > 0) { process.stderr.write(`${finalMessage}\n`); return; } - process.stderr.write("\n"); + process.stderr.write('\n'); } return { diff --git a/cli/src/lib/query-column-types.test.ts b/cli/src/lib/query-column-types.test.ts index f97c5027..40cd35b8 100644 --- a/cli/src/lib/query-column-types.test.ts +++ b/cli/src/lib/query-column-types.test.ts @@ -1,82 +1,96 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test'; import { getColumnTypeMap, mapColumnSqlTypeToDataType, resolveCellDataType, selectDisplayColumnNames, -} from "@/lib/query-column-types.ts"; +} from '@/lib/query-column-types.ts'; -describe("mapColumnSqlTypeToDataType", () => { - test("maps SQL types to terminal data types", () => { - expect(mapColumnSqlTypeToDataType("UUID")).toBe("uuid"); - expect(mapColumnSqlTypeToDataType("BOOLEAN")).toBe("boolean"); - expect(mapColumnSqlTypeToDataType("BIGINT")).toBe("number"); - expect(mapColumnSqlTypeToDataType("TIMESTAMP(3)")).toBe("timestamp"); - expect(mapColumnSqlTypeToDataType("JSON")).toBe("string"); - expect(mapColumnSqlTypeToDataType("VARCHAR")).toBe("string"); +describe('mapColumnSqlTypeToDataType', () => { + test('maps SQL types to terminal data types', () => { + expect(mapColumnSqlTypeToDataType('UUID')).toBe('uuid'); + expect(mapColumnSqlTypeToDataType('BOOLEAN')).toBe('boolean'); + expect(mapColumnSqlTypeToDataType('BIGINT')).toBe('number'); + expect(mapColumnSqlTypeToDataType('TIMESTAMP(3)')).toBe('timestamp'); + expect(mapColumnSqlTypeToDataType('JSON')).toBe('string'); + expect(mapColumnSqlTypeToDataType('VARCHAR')).toBe('string'); }); }); -describe("resolveCellDataType", () => { - test("uses declared SQL type for string values", () => { - const typeMap = getColumnTypeMap([{ name: "event", type: "VARCHAR" }]); - expect(resolveCellDataType("mcp_tool_call", "event", typeMap)).toBe("string"); - expect(resolveCellDataType("123", "duration_ms", new Map([["duration_ms", "VARCHAR"]]))).toBe( - "string", - ); - expect(resolveCellDataType("true", "success", new Map([["success", "VARCHAR"]]))).toBe( - "string", +describe('resolveCellDataType', () => { + test('uses declared SQL type for string values', () => { + const typeMap = getColumnTypeMap([{ name: 'event', type: 'VARCHAR' }]); + expect(resolveCellDataType('mcp_tool_call', 'event', typeMap)).toBe( + 'string' ); expect( resolveCellDataType( - "019ee8e4-1d79-77d9-8693-1f67732b184d", - "author_id", - new Map([["author_id", "VARCHAR"]]), - ), - ).toBe("string"); - expect(resolveCellDataType("123", "duration_ms", new Map([["duration_ms", "BIGINT"]]))).toBe( - "number", - ); + '123', + 'duration_ms', + new Map([['duration_ms', 'VARCHAR']]) + ) + ).toBe('string'); + expect( + resolveCellDataType('true', 'success', new Map([['success', 'VARCHAR']])) + ).toBe('string'); + expect( + resolveCellDataType( + '019ee8e4-1d79-77d9-8693-1f67732b184d', + 'author_id', + new Map([['author_id', 'VARCHAR']]) + ) + ).toBe('string'); + expect( + resolveCellDataType( + '123', + 'duration_ms', + new Map([['duration_ms', 'BIGINT']]) + ) + ).toBe('number'); }); - test("uses SQL type for non-string runtime values", () => { - const typeMap = getColumnTypeMap([{ name: "duration_ms", type: "BIGINT" }]); - expect(resolveCellDataType(285, "duration_ms", typeMap)).toBe("number"); + test('uses SQL type for non-string runtime values', () => { + const typeMap = getColumnTypeMap([{ name: 'duration_ms', type: 'BIGINT' }]); + expect(resolveCellDataType(285, 'duration_ms', typeMap)).toBe('number'); }); - test("classifies untyped UUID and timestamp strings by exact value shape", () => { + test('classifies untyped UUID and timestamp strings by exact value shape', () => { expect( - resolveCellDataType("019ee8e4-1d79-77d9-8693-1f67732b184d", "author_id", new Map()), - ).toBe("uuid"); - expect(resolveCellDataType("2026-06-21T06:35:24.409Z", "created_at", new Map())).toBe( - "timestamp", - ); + resolveCellDataType( + '019ee8e4-1d79-77d9-8693-1f67732b184d', + 'author_id', + new Map() + ) + ).toBe('uuid'); + expect( + resolveCellDataType('2026-06-21T06:35:24.409Z', 'created_at', new Map()) + ).toBe('timestamp'); }); }); -describe("selectDisplayColumnNames", () => { +describe('selectDisplayColumnNames', () => { const agentEventColumns = [ - "uuid", - "event", - "timestamp", - "duration_ms", - "success", - "error_message", - "author_type", - "author_id", - "tool_name", - "input", + 'uuid', + 'event', + 'timestamp', + 'duration_ms', + 'success', + 'error_message', + 'author_type', + 'author_id', + 'tool_name', + 'input', ]; - test("shows all columns by default", () => { + test('shows all columns by default', () => { const { columns } = selectDisplayColumnNames(agentEventColumns, {}); expect(columns).toEqual(agentEventColumns); }); - test("respects explicit column selection", () => { + test('respects explicit column selection', () => { const { columns } = selectDisplayColumnNames(agentEventColumns, { - columns: ["event", "uuid"], + columns: ['event', 'uuid'], }); - expect(columns).toEqual(["event", "uuid"]); + expect(columns).toEqual(['event', 'uuid']); }); }); diff --git a/cli/src/lib/query-column-types.ts b/cli/src/lib/query-column-types.ts index 73e11e8d..852e826b 100644 --- a/cli/src/lib/query-column-types.ts +++ b/cli/src/lib/query-column-types.ts @@ -1,9 +1,20 @@ -import type { LakehouseColumn, LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; +import type { + LakehouseColumn, + LakehouseQueryResult, +} from '@/lib/lakehouse-ndjson.ts'; -export type QueryDataType = "null" | "boolean" | "number" | "string" | "uuid" | "timestamp"; +export type QueryDataType = + | 'null' + | 'boolean' + | 'number' + | 'string' + | 'uuid' + | 'timestamp'; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; export function isTimestampValue(value: string): boolean { return TIMESTAMP_PATTERN.test(value); @@ -11,71 +22,82 @@ export function isTimestampValue(value: string): boolean { function classifyStringDataType(value: string): QueryDataType { if (UUID_PATTERN.test(value)) { - return "uuid"; + return 'uuid'; } if (isTimestampValue(value)) { - return "timestamp"; + return 'timestamp'; } - return "string"; + return 'string'; } export type ColumnTypeMap = Map; -export function getColumnTypeMap(columns: LakehouseQueryResult["columns"]): ColumnTypeMap { +export function getColumnTypeMap( + columns: LakehouseQueryResult['columns'] +): ColumnTypeMap { const typeMap: ColumnTypeMap = new Map(); if (!Array.isArray(columns) || columns.length === 0) { return typeMap; } - if (typeof columns[0] === "string") { + if (typeof columns[0] === 'string') { return typeMap; } for (const column of columns as LakehouseColumn[]) { const name = column.name; - if (typeof name === "string" && name.length > 0) { - typeMap.set(name, typeof column.type === "string" ? column.type : undefined); + if (typeof name === 'string' && name.length > 0) { + typeMap.set( + name, + typeof column.type === 'string' ? column.type : undefined + ); } } return typeMap; } -export function mapColumnSqlTypeToDataType(sqlType: string | undefined): QueryDataType | null { +export function mapColumnSqlTypeToDataType( + sqlType: string | undefined +): QueryDataType | null { if (sqlType === undefined || sqlType.length === 0) { return null; } const normalized = sqlType.toUpperCase(); - if (normalized.includes("UUID")) { - return "uuid"; + if (normalized.includes('UUID')) { + return 'uuid'; } - if (normalized.includes("BOOL")) { - return "boolean"; + if (normalized.includes('BOOL')) { + return 'boolean'; } - if (/INT|FLOAT|DOUBLE|DECIMAL|NUMERIC|REAL|BIGINT|SMALLINT|TINYINT/.test(normalized)) { - return "number"; + if ( + /INT|FLOAT|DOUBLE|DECIMAL|NUMERIC|REAL|BIGINT|SMALLINT|TINYINT/.test( + normalized + ) + ) { + return 'number'; } if (/TIMESTAMP|DATETIME|DATE|TIME/.test(normalized)) { - return "timestamp"; + return 'timestamp'; } - if (normalized.includes("JSON")) { - return "string"; + if (normalized.includes('JSON')) { + return 'string'; } - return "string"; + return 'string'; } export function resolveCellDataType( value: unknown, columnName: string | undefined, - columnTypeMap: ColumnTypeMap, + columnTypeMap: ColumnTypeMap ): QueryDataType { if (value === null || value === undefined) { - return "null"; + return 'null'; } - if (typeof value === "boolean") { - return "boolean"; + if (typeof value === 'boolean') { + return 'boolean'; } - if (typeof value === "number" || typeof value === "bigint") { - return "number"; + if (typeof value === 'number' || typeof value === 'bigint') { + return 'number'; } - if (typeof value === "string") { + if (typeof value === 'string') { if (columnName !== undefined) { const sqlType = columnTypeMap.get(columnName); const fromSql = mapColumnSqlTypeToDataType(sqlType); @@ -85,16 +107,16 @@ export function resolveCellDataType( } return classifyStringDataType(value); } - return "string"; + return 'string'; } export function selectDisplayColumnNames( allNames: string[], - options: { columns?: string[] }, + options: { columns?: string[] } ): { columns: string[] } { if (options.columns !== undefined && options.columns.length > 0) { const nameSet = new Set(allNames); - const filtered = options.columns.filter((name) => nameSet.has(name)); + const filtered = options.columns.filter(name => nameSet.has(name)); const columns = filtered.length > 0 ? filtered : allNames; return { columns }; } diff --git a/cli/src/lib/query-destination.test.ts b/cli/src/lib/query-destination.test.ts index f188603d..f716716b 100644 --- a/cli/src/lib/query-destination.test.ts +++ b/cli/src/lib/query-destination.test.ts @@ -1,16 +1,20 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { NetworkError, getCliExitCode } from "@/lib/errors.ts"; -import { writeQueryDestination } from "@/lib/query-destination.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; +import { describe, expect, spyOn, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NetworkError, getCliExitCode } from '@/lib/errors.ts'; +import { writeQueryDestination } from '@/lib/query-destination.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; -describe("writeQueryDestination", () => { - test("writes opaque stdout bytes without UTF-8 round-trip or trailing newline", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); +describe('writeQueryDestination', () => { + test('writes opaque stdout bytes without UTF-8 round-trip or trailing newline', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const written: Uint8Array[] = []; - runtime.output.writeBytes = (body) => { + runtime.output.writeBytes = body => { written.push(body); }; @@ -28,9 +32,9 @@ describe("writeQueryDestination", () => { expect(written[0]).toEqual(parquetLike); }); - test("writes opaque file bytes unchanged", async () => { - const dir = mkdtempSync(join(tmpdir(), "altertable-query-dest-")); - const path = join(dir, "out.parquet"); + test('writes opaque file bytes unchanged', async () => { + const dir = mkdtempSync(join(tmpdir(), 'altertable-query-dest-')); + const path = join(dir, 'out.parquet'); try { const parquetLike = new Uint8Array([0x50, 0x41, 0x52, 0x31, 0x00, 0xff]); const stream = new ReadableStream({ @@ -47,10 +51,14 @@ describe("writeQueryDestination", () => { } }); - test("writes multi-chunk streams incrementally without buffering", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('writes multi-chunk streams incrementally without buffering', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const written: Uint8Array[] = []; - runtime.output.writeBytes = (body) => { + runtime.output.writeBytes = body => { written.push(body); }; @@ -74,9 +82,9 @@ describe("writeQueryDestination", () => { expect(written).toEqual(chunks); }); - test("writes multi-chunk streams to a file without loss", async () => { - const dir = mkdtempSync(join(tmpdir(), "altertable-query-dest-")); - const path = join(dir, "out.parquet"); + test('writes multi-chunk streams to a file without loss', async () => { + const dir = mkdtempSync(join(tmpdir(), 'altertable-query-dest-')); + const path = join(dir, 'out.parquet'); try { const chunks = [ new Uint8Array([0x50, 0x41]), @@ -94,19 +102,23 @@ describe("writeQueryDestination", () => { await writeQueryDestination(stream, { outputPath: path }); expect(new Uint8Array(readFileSync(path))).toEqual( - new Uint8Array([0x50, 0x41, 0x52, 0x31, 0x00, 0xff]), + new Uint8Array([0x50, 0x41, 0x52, 0x31, 0x00, 0xff]) ); } finally { rmSync(dir, { recursive: true, force: true }); } }); - test.each(["sync", "async", "cancel-error"])( - "cancels the source and preserves a failed output write: %s", - async (mode) => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - const writeError = new Error("broken pipe"); + test.each(['sync', 'async', 'cancel-error'])( + 'cancels the source and preserves a failed output write: %s', + async mode => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + const writeError = new Error('broken pipe'); runtime.output.writeBytes = - mode === "async" + mode === 'async' ? async () => { throw writeError; } @@ -120,7 +132,7 @@ describe("writeQueryDestination", () => { }, cancel(reason) { cancellationReason = reason; - if (mode === "cancel-error") throw new Error("cancel failed"); + if (mode === 'cancel-error') throw new Error('cancel failed'); }, }); let failure: unknown; @@ -132,11 +144,11 @@ describe("writeQueryDestination", () => { expect(failure).toBe(writeError); expect(cancellationReason).toBe(writeError); expect(stream.locked).toBe(false); - }, + } ); - test("cancels the source when opening an output file fails", async () => { - const dir = mkdtempSync(join(tmpdir(), "altertable-query-dest-")); + test('cancels the source when opening an output file fails', async () => { + const dir = mkdtempSync(join(tmpdir(), 'altertable-query-dest-')); let canceled = false; const stream = new ReadableStream({ start(controller) { @@ -148,7 +160,9 @@ describe("writeQueryDestination", () => { }); let failure: unknown; try { - await writeQueryDestination(stream, { outputPath: join(dir, "missing", "out.csv") }); + await writeQueryDestination(stream, { + outputPath: join(dir, 'missing', 'out.csv'), + }); } catch (error) { failure = error; } finally { @@ -158,30 +172,32 @@ describe("writeQueryDestination", () => { expect(canceled).toBe(true); expect(stream.locked).toBe(false); }); - test.each(["transport", "write", "none"])( - "preserves the primary failure when file finalization fails: %s", - async (mode) => { - const dir = mkdtempSync(join(tmpdir(), "altertable-query-dest-")); - const file = Bun.file(join(dir, "out.csv")); + test.each(['transport', 'write', 'none'])( + 'preserves the primary failure when file finalization fails: %s', + async mode => { + const dir = mkdtempSync(join(tmpdir(), 'altertable-query-dest-')); + const file = Bun.file(join(dir, 'out.csv')); const writer = file.writer(); const closeWriter = writer.end.bind(writer); const primaryError = - mode === "transport" ? new NetworkError("connection lost") : new Error("write failed"); - const cleanupError = new Error("finalization failed"); - const fileSpy = spyOn(Bun, "file").mockReturnValue(file); - const writerSpy = spyOn(file, "writer").mockReturnValue(writer); - const endSpy = spyOn(writer, "end").mockImplementation(() => { + mode === 'transport' + ? new NetworkError('connection lost') + : new Error('write failed'); + const cleanupError = new Error('finalization failed'); + const fileSpy = spyOn(Bun, 'file').mockReturnValue(file); + const writerSpy = spyOn(file, 'writer').mockReturnValue(writer); + const endSpy = spyOn(writer, 'end').mockImplementation(() => { throw cleanupError; }); const writeSpy = - mode === "write" - ? spyOn(writer, "write").mockImplementation(() => { + mode === 'write' + ? spyOn(writer, 'write').mockImplementation(() => { throw primaryError; }) : undefined; const stream = new ReadableStream({ start(controller) { - if (mode === "transport") controller.error(primaryError); + if (mode === 'transport') controller.error(primaryError); else { controller.enqueue(Uint8Array.of(1)); controller.close(); @@ -201,9 +217,9 @@ describe("writeQueryDestination", () => { await closeWriter(); rmSync(dir, { recursive: true, force: true }); } - expect(failure).toBe(mode === "none" ? cleanupError : primaryError); - expect(getCliExitCode(failure)).toBe(mode === "transport" ? 9 : 1); + expect(failure).toBe(mode === 'none' ? cleanupError : primaryError); + expect(getCliExitCode(failure)).toBe(mode === 'transport' ? 9 : 1); expect(stream.locked).toBe(false); - }, + } ); }); diff --git a/cli/src/lib/query-destination.ts b/cli/src/lib/query-destination.ts index 4a55c0c0..70041119 100644 --- a/cli/src/lib/query-destination.ts +++ b/cli/src/lib/query-destination.ts @@ -1,4 +1,4 @@ -import type { OutputSink } from "@/lib/runtime.ts"; +import type { OutputSink } from '@/lib/runtime.ts'; export type WriteQueryDestinationOptions = { outputPath?: string; @@ -7,7 +7,7 @@ export type WriteQueryDestinationOptions = { async function writeStreamChunks( stream: ReadableStream, - writeChunk: (chunk: Uint8Array) => void | Promise, + writeChunk: (chunk: Uint8Array) => void | Promise ): Promise { const reader = stream.getReader(); try { @@ -25,12 +25,14 @@ async function writeStreamChunks( export async function writeQueryDestination( content: string | ReadableStream, - options: WriteQueryDestinationOptions = {}, + options: WriteQueryDestinationOptions = {} ): Promise { const { outputPath, sink } = options; - if (typeof content === "string") { - const bytes = new TextEncoder().encode(content.endsWith("\n") ? content : `${content}\n`); + if (typeof content === 'string') { + const bytes = new TextEncoder().encode( + content.endsWith('\n') ? content : `${content}\n` + ); if (outputPath !== undefined) { await Bun.write(outputPath, bytes); return; @@ -47,7 +49,7 @@ export async function writeQueryDestination( if (outputPath !== undefined) { const writer = Bun.file(outputPath).writer(); try { - await writeStreamChunks(content, async (chunk) => { + await writeStreamChunks(content, async chunk => { await writer.write(chunk); }); } catch (error) { @@ -63,11 +65,11 @@ export async function writeQueryDestination( } if (sink) { - await writeStreamChunks(content, (chunk) => sink.writeBytes(chunk)); + await writeStreamChunks(content, chunk => sink.writeBytes(chunk)); return; } - await writeStreamChunks(content, async (chunk) => { + await writeStreamChunks(content, async chunk => { await Bun.write(Bun.stdout, chunk); }); } catch (error) { diff --git a/cli/src/lib/query-format.test.ts b/cli/src/lib/query-format.test.ts index 50c59802..b54a0e81 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configFile, kvSet } from "@/lib/config.ts"; -import { parseLakehouseQueryResponse } from "@/lib/lakehouse-ndjson.ts"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { configFile, kvSet } from '@/lib/config.ts'; +import { parseLakehouseQueryResponse } from '@/lib/lakehouse-ndjson.ts'; import { defaultDisplayOptions, formatQueryCell, @@ -14,15 +14,15 @@ import { selectColumnNames, truncateText, truncateTextMiddle, -} from "@/lib/query-format.ts"; -import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; -import { getVisibleTextWidth } from "@/ui/terminal/styles.ts"; +} from '@/lib/query-format.ts'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; +import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} from '@/test-utils/terminal.ts'; let terminalState: TerminalTestState | undefined; @@ -44,17 +44,20 @@ const SAMPLE_NDJSON = [ '["id","name"]', '[1,"Alice"]', '[2,"Bob"]', -].join("\n"); +].join('\n'); const WIDE_RESULT = { - metadata: { init_time_ms: 285, query_id: "019ee8e4-1d79-77d9-8693-1f67732b184d" }, - columns: ["uuid", "event", "input", "timestamp", "success", "duration"], + metadata: { + init_time_ms: 285, + query_id: '019ee8e4-1d79-77d9-8693-1f67732b184d', + }, + columns: ['uuid', 'event', 'input', 'timestamp', 'success', 'duration'], rows: [ { - uuid: "019ee8e4-1d79-77d9-8693-1f67732b184d", - event: "list_catalogs", + uuid: '019ee8e4-1d79-77d9-8693-1f67732b184d', + event: 'list_catalogs', input: '{"filter":"all","include_metadata":true}', - timestamp: "2026-06-25T12:00:00Z", + timestamp: '2026-06-25T12:00:00Z', success: true, duration: 42, }, @@ -64,144 +67,165 @@ const WIDE_RESULT = { const AGENT_EVENTS_RESULT = { metadata: { init_time_ms: 137 }, columns: [ - "uuid", - "event", - "timestamp", - "duration_ms", - "success", - "error_message", - "author_type", - "author_id", - "tool_name", - "input", + 'uuid', + 'event', + 'timestamp', + 'duration_ms', + 'success', + 'error_message', + 'author_type', + 'author_id', + 'tool_name', + 'input', ], rows: [ { - uuid: "019ee8e4-1d79-77d9-8693-1f67732b184d", - event: "mcp_tool_call", - timestamp: "2026-06-21T06:35:24.409Z", + uuid: '019ee8e4-1d79-77d9-8693-1f67732b184d', + event: 'mcp_tool_call', + timestamp: '2026-06-21T06:35:24.409Z', duration_ms: 285, success: true, error_message: null, - author_type: "Agent", - author_id: "019c6610-ebab-784d-b72b-b89c44804e38", - tool_name: "list_catalogs", - input: '{"catalog_names":["altertable","sample_data"],"level":"overview"}', + author_type: 'Agent', + author_id: '019c6610-ebab-784d-b72b-b89c44804e38', + tool_name: 'list_catalogs', + input: + '{"catalog_names":["altertable","sample_data"],"level":"overview"}', }, ], }; -describe("truncateText", () => { - test("truncates long strings with ellipsis", () => { - expect(truncateText("hello world", 8)).toBe("hello w…"); +describe('truncateText', () => { + test('truncates long strings with ellipsis', () => { + expect(truncateText('hello world', 8)).toBe('hello w…'); }); - test("truncates middle of long strings with ellipsis", () => { - expect(truncateTextMiddle("019ee8e4-1d79-77d9-8693-1f67732b184d", 16)).toBe("019ee8e4…32b184d"); - expect(truncateTextMiddle("abcdef", 2)).toBe("a…"); - expect(truncateTextMiddle("👨‍👩‍👧‍👦abc", 4)).toBe("👨‍👩‍👧‍👦…c"); + test('truncates middle of long strings with ellipsis', () => { + expect(truncateTextMiddle('019ee8e4-1d79-77d9-8693-1f67732b184d', 16)).toBe( + '019ee8e4…32b184d' + ); + expect(truncateTextMiddle('abcdef', 2)).toBe('a…'); + expect(truncateTextMiddle('👨‍👩‍👧‍👦abc', 4)).toBe('👨‍👩‍👧‍👦…c'); }); - test("truncates by terminal width without splitting wide characters", () => { - expect(truncateText("日本語", 4)).toBe("日…"); - expect(getVisibleTextWidth(truncateText("日本語", 4))).toBeLessThanOrEqual(4); + test('truncates by terminal width without splitting wide characters', () => { + expect(truncateText('日本語', 4)).toBe('日…'); + expect(getVisibleTextWidth(truncateText('日本語', 4))).toBeLessThanOrEqual( + 4 + ); }); }); -describe("formatQueryCell", () => { - test("renders NULL for null values", () => { - expect(formatQueryCell(null, {})).toBe("NULL"); - expect(formatQueryCell(undefined, {})).toBe("NULL"); +describe('formatQueryCell', () => { + test('renders NULL for null values', () => { + expect(formatQueryCell(null, {})).toBe('NULL'); + expect(formatQueryCell(undefined, {})).toBe('NULL'); }); - test("shows full UUID strings without maxWidth", () => { - const uuid = "019ee8e4-1d79-77d9-8693-1f67732b184d"; + test('shows full UUID strings without maxWidth', () => { + const uuid = '019ee8e4-1d79-77d9-8693-1f67732b184d'; expect(formatQueryCell(uuid, {})).toBe(uuid); - expect(formatQueryCell(uuid, { maxWidth: 16 })).toBe(truncateTextMiddle(uuid, 16)); + expect(formatQueryCell(uuid, { maxWidth: 16 })).toBe( + truncateTextMiddle(uuid, 16) + ); }); - test("truncates long JSON strings when maxWidth is set", () => { - const json = '{"catalog_names":["altertable","sample_data"],"level":"overview"}'; + test('truncates long JSON strings when maxWidth is set', () => { + const json = + '{"catalog_names":["altertable","sample_data"],"level":"overview"}'; expect(formatQueryCell(json, {})).toBe(json); - expect(formatQueryCell(json, { maxWidth: 20 })).toBe(truncateText(json, 20)); + expect(formatQueryCell(json, { maxWidth: 20 })).toBe( + truncateText(json, 20) + ); }); - test("keeps short JSON objects intact when truncated", () => { - expect(formatQueryCell("{}", { maxWidth: 10 })).toBe("{}"); + test('keeps short JSON objects intact when truncated', () => { + expect(formatQueryCell('{}', { maxWidth: 10 })).toBe('{}'); }); - test("redacts sensitive keys inside JSON string cells", () => { + test('redacts sensitive keys inside JSON string cells', () => { const output = formatQueryCell( '{"password":"secret","nested":{"access_token":"token-value"},"id":9007199254740993,"name":"safe"}', - {}, + {} ); expect(output).toBe( - '{"password":"[REDACTED]","nested":{"access_token":"[REDACTED]"},"id":9007199254740993,"name":"safe"}', + '{"password":"[REDACTED]","nested":{"access_token":"[REDACTED]"},"id":9007199254740993,"name":"safe"}' ); - expect(output).not.toContain("secret"); - expect(output).not.toContain("token-value"); + expect(output).not.toContain('secret'); + expect(output).not.toContain('token-value'); }); - test("appends relative time to timestamps while keeping absolute form", () => { - const timestamp = "2026-06-21T06:35:24.409Z"; - const nowMs = Date.parse("2026-06-27T12:00:00.000Z"); + test('appends relative time to timestamps while keeping absolute form', () => { + const timestamp = '2026-06-21T06:35:24.409Z'; + const nowMs = Date.parse('2026-06-27T12:00:00.000Z'); const originalNow = Date.now; Date.now = () => nowMs; try { - expect(formatQueryCell(timestamp, { includeRelative: true })).toBe(`${timestamp} 6 days ago`); + expect(formatQueryCell(timestamp, { includeRelative: true })).toBe( + `${timestamp} 6 days ago` + ); expect(formatQueryCell(timestamp, {})).toBe(timestamp); } finally { Date.now = originalNow; } }); - test("replaces partial password masking with a fixed placeholder", () => { - const requestedBy = "password:************************************************dfcd8"; - expect(formatQueryCell(requestedBy, {})).toBe("password: [MASKED]"); - expect(formatQueryCell(requestedBy, { colorize: false })).toBe("password: [MASKED]"); + test('replaces partial password masking with a fixed placeholder', () => { + const requestedBy = + 'password:************************************************dfcd8'; + expect(formatQueryCell(requestedBy, {})).toBe('password: [MASKED]'); + expect(formatQueryCell(requestedBy, { colorize: false })).toBe( + 'password: [MASKED]' + ); }); - test("escapes terminal controls even when color is disabled", () => { - const output = formatQueryCell("safe\u001b]0;spoofed\u0007\nnext", { colorize: false }); + test('escapes terminal controls even when color is disabled', () => { + const output = formatQueryCell('safe\u001b]0;spoofed\u0007\nnext', { + colorize: false, + }); - expect(output).toBe("safe\\x1b]0;spoofed\\x07\\x0anext"); - expect(output).not.toContain("\u001b"); - expect(output).not.toContain("\u0007"); + expect(output).toBe('safe\\x1b]0;spoofed\\x07\\x0anext'); + expect(output).not.toContain('\u001b'); + expect(output).not.toContain('\u0007'); }); - test("dims false and empty string values when colorized", () => { + test('dims false and empty string values when colorized', () => { enableTerminalColorForTests(); try { - expect(formatQueryCell(false, { colorize: true })).toContain("\u001b[90m"); - expect(formatQueryCell(true, { colorize: true })).toContain("\u001b[35m"); - expect(formatQueryCell("", { colorize: true })).toContain("\u001b[90m"); + expect(formatQueryCell(false, { colorize: true })).toContain( + '\u001b[90m' + ); + expect(formatQueryCell(true, { colorize: true })).toContain('\u001b[35m'); + expect(formatQueryCell('', { colorize: true })).toContain('\u001b[90m'); } finally { restoreTerminalColorForTests(); } }); }); -describe("selectColumnNames", () => { - test("filters to requested columns in order", () => { - expect(selectColumnNames(["uuid", "event", "timestamp"], ["event", "uuid"])).toEqual([ - "event", - "uuid", - ]); +describe('selectColumnNames', () => { + test('filters to requested columns in order', () => { + expect( + selectColumnNames(['uuid', 'event', 'timestamp'], ['event', 'uuid']) + ).toEqual(['event', 'uuid']); }); - test("falls back to all columns when filter matches nothing", () => { - expect(selectColumnNames(["uuid", "event"], ["missing"])).toEqual(["uuid", "event"]); + test('falls back to all columns when filter matches nothing', () => { + expect(selectColumnNames(['uuid', 'event'], ['missing'])).toEqual([ + 'uuid', + 'event', + ]); }); }); -describe("renderQueryHumanOutput", () => { +describe('renderQueryHumanOutput', () => { const parsedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); let savedNoColor: string | undefined; beforeEach(() => { savedNoColor = process.env.NO_COLOR; - process.env.NO_COLOR = "1"; + process.env.NO_COLOR = '1'; }); afterEach(() => { @@ -212,187 +236,195 @@ describe("renderQueryHumanOutput", () => { } }); - test("narrow table unchanged for Alice/Bob sample", () => { + test('narrow table unchanged for Alice/Bob sample', () => { const output = renderQueryHumanOutput(parsedResult, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth: 80, }); - expect(output).toContain("┌"); - expect(output).toContain("│"); - expect(output).toContain("id"); - expect(output).toContain("name"); - expect(output).toContain("Alice"); - expect(output).toContain("Bob"); + expect(output).toContain('┌'); + expect(output).toContain('│'); + expect(output).toContain('id'); + expect(output).toContain('name'); + expect(output).toContain('Alice'); + expect(output).toContain('Bob'); }); - test("truncates long string columns in table layout", () => { + test('truncates long string columns in table layout', () => { const result = { metadata: {}, - columns: ["label"], - rows: [{ label: "abcdefghijklmnopqrstuvwxyz" }], + columns: ['label'], + rows: [{ label: 'abcdefghijklmnopqrstuvwxyz' }], }; const output = renderQueryHumanOutput(result, { - layout: "table", + layout: 'table', maxColumnWidth: 10, terminalWidth: 80, }); - expect(output).toContain("abcdefghi…"); - expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz"); + expect(output).toContain('abcdefghi…'); + expect(output).not.toContain('abcdefghijklmnopqrstuvwxyz'); }); - test("truncates UUID columns from the middle in table layout", () => { + test('truncates UUID columns from the middle in table layout', () => { const output = renderQueryHumanOutput( { metadata: {}, - columns: ["uuid"], - rows: [{ uuid: "019ee8e4-1d79-77d9-8693-1f67732b184d" }], + columns: ['uuid'], + rows: [{ uuid: '019ee8e4-1d79-77d9-8693-1f67732b184d' }], }, { - layout: "table", + layout: 'table', maxColumnWidth: 16, terminalWidth: 80, colorize: false, - }, + } ); - expect(output).toContain("019ee8e4…32b184d"); + expect(output).toContain('019ee8e4…32b184d'); }); - test("right-aligns numeric columns in table layout", () => { + test('right-aligns numeric columns in table layout', () => { const result = { metadata: {}, - columns: ["event", "duration_ms"], - rows: [{ event: "query", duration_ms: 42 }], + columns: ['event', 'duration_ms'], + rows: [{ event: 'query', duration_ms: 42 }], }; const output = renderQueryHumanOutput(result, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth: 80, }); - expect(output).toContain("│ query │ 42 │"); + expect(output).toContain('│ query │ 42 │'); }); - test("keeps wide cells inside the rendered table frame", () => { + test('keeps wide cells inside the rendered table frame', () => { const output = renderQueryHumanOutput( - { metadata: {}, columns: ["値"], rows: [{ 値: "日本語" }] }, - { layout: "table", maxColumnWidth: 4, terminalWidth: 80, colorize: false }, + { metadata: {}, columns: ['値'], rows: [{ 値: '日本語' }] }, + { layout: 'table', maxColumnWidth: 4, terminalWidth: 80, colorize: false } ); - const tableLines = output.split("\n").filter((line) => /^[┌├│└]/u.test(line)); - expect(tableLines.some((line) => line.includes("日…"))).toBe(true); - expect(tableLines.every((line) => getVisibleTextWidth(line) === 8)).toBe(true); + const tableLines = output.split('\n').filter(line => /^[┌├│└]/u.test(line)); + expect(tableLines.some(line => line.includes('日…'))).toBe(true); + expect(tableLines.every(line => getVisibleTextWidth(line) === 8)).toBe( + true + ); }); - test("pads expanded labels by terminal width", () => { + test('pads expanded labels by terminal width', () => { const output = renderQueryHumanOutput( - { metadata: {}, columns: ["日本語", "id"], rows: [{ 日本語: "value", id: 1 }] }, - { layout: "line", maxColumnWidth: 32, terminalWidth: 80, colorize: false }, + { + metadata: {}, + columns: ['日本語', 'id'], + rows: [{ 日本語: 'value', id: 1 }], + }, + { layout: 'line', maxColumnWidth: 32, terminalWidth: 80, colorize: false } ); - expect(output).toContain("日本語: value"); - expect(output).toContain("id: 1"); + expect(output).toContain('日本語: value'); + expect(output).toContain('id: 1'); }); - test("auto layout picks line output when wider than terminal", () => { + test('auto layout picks line output when wider than terminal', () => { const output = renderQueryHumanOutput(WIDE_RESULT, { - layout: "auto", + layout: 'auto', maxColumnWidth: 32, terminalWidth: 60, }); - expect(output).not.toContain("-[ record 1 ]-"); - expect(output).toContain("event:"); - expect(output).toContain("list_catalogs"); + expect(output).not.toContain('-[ record 1 ]-'); + expect(output).toContain('event:'); + expect(output).toContain('list_catalogs'); }); - test("box mode renders wider than the terminal for horizontal scrolling", () => { + test('box mode renders wider than the terminal for horizontal scrolling', () => { const terminalWidth = 60; const output = renderQueryHumanOutput(AGENT_EVENTS_RESULT, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth, colorize: false, }); - expect(output).toContain("┌"); - expect(output.split("\n").some((line) => line.length > terminalWidth)).toBe(true); + expect(output).toContain('┌'); + expect(output.split('\n').some(line => line.length > terminalWidth)).toBe( + true + ); }); - test("auto layout picks table output when terminal is wide enough", () => { + test('auto layout picks table output when terminal is wide enough', () => { const output = renderQueryHumanOutput(WIDE_RESULT, { - layout: "auto", + layout: 'auto', maxColumnWidth: 32, terminalWidth: 200, }); - expect(output).toContain("uuid"); - expect(output).toContain("event"); - expect(output).not.toContain("-[ record 1 ]-"); + expect(output).toContain('uuid'); + expect(output).toContain('event'); + expect(output).not.toContain('-[ record 1 ]-'); }); - test("column selection filters displayed columns", () => { + test('column selection filters displayed columns', () => { const output = renderQueryHumanOutput(WIDE_RESULT, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth: 200, - columns: ["uuid", "event"], + columns: ['uuid', 'event'], }); - expect(output).toContain("uuid"); - expect(output).toContain("event"); - expect(output).not.toContain("input"); - expect(output).not.toContain("timestamp"); + expect(output).toContain('uuid'); + expect(output).toContain('event'); + expect(output).not.toContain('input'); + expect(output).not.toContain('timestamp'); }); - test("shows all columns by default on wide results", () => { + test('shows all columns by default on wide results', () => { const output = renderQueryHumanOutput(AGENT_EVENTS_RESULT, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth: 200, }); - expect(output).toContain("event"); - expect(output).toContain("tool_name"); - expect(output).toContain("author_id"); - expect(output).not.toContain("columns hidden"); + expect(output).toContain('event'); + expect(output).toContain('tool_name'); + expect(output).toContain('author_id'); + expect(output).not.toContain('columns hidden'); }); - test("renders all columns in table layout when terminal is wide enough", () => { + test('renders all columns in table layout when terminal is wide enough', () => { const terminalWidth = 400; const output = renderQueryHumanOutput(AGENT_EVENTS_RESULT, { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth, }); - expect(output).toContain("tool_name"); - expect(output).toContain("author_id"); - for (const line of output.split("\n")) { + expect(output).toContain('tool_name'); + expect(output).toContain('author_id'); + for (const line of output.split('\n')) { expect(getVisibleTextWidth(line)).toBeLessThanOrEqual(terminalWidth); } }); - test("line mode shows full values", () => { + test('line mode shows full values', () => { const output = renderQueryHumanOutput(AGENT_EVENTS_RESULT, { - layout: "line", + layout: 'line', maxColumnWidth: 32, terminalWidth: 120, }); - expect(output).toContain("author_id"); - expect(output).toContain("catalog_names"); + expect(output).toContain('author_id'); + expect(output).toContain('catalog_names'); }); - test("line mode includes relative timestamps", () => { - const timestamp = "2026-06-21T06:35:24.409Z"; - const nowMs = Date.parse("2026-06-27T12:00:00.000Z"); + test('line mode includes relative timestamps', () => { + const timestamp = '2026-06-21T06:35:24.409Z'; + const nowMs = Date.parse('2026-06-27T12:00:00.000Z'); const originalNow = Date.now; Date.now = () => nowMs; try { const output = renderQueryHumanOutput( { metadata: {}, - columns: ["event", "timestamp"], - rows: [{ event: "mcp_tool_call", timestamp }], + columns: ['event', 'timestamp'], + rows: [{ event: 'mcp_tool_call', timestamp }], }, { - layout: "line", + layout: 'line', maxColumnWidth: 80, terminalWidth: 120, - }, + } ); expect(output).toContain(`${timestamp} 6 days ago`); } finally { @@ -400,105 +432,119 @@ describe("renderQueryHumanOutput", () => { } }); - test("colorizes line-mode labels and values when enabled", () => { + test('colorizes line-mode labels and values when enabled', () => { enableTerminalColorForTests(); try { const output = renderQueryHumanOutput(WIDE_RESULT, { - layout: "line", + layout: 'line', maxColumnWidth: 32, terminalWidth: 80, colorize: true, }); - expect(output).toContain("\u001b[2m"); - expect(output).toContain("\u001b[35m"); - expect(output).toContain("\u001b[33m"); - expect(output).toContain("\u001b[34m"); - expect(output).toContain("\u001b[96m"); - expect(output).toContain("\u001b[90m"); - expect(output).toContain("event"); - expect(output).toContain("list_catalogs"); + expect(output).toContain('\u001b[2m'); + expect(output).toContain('\u001b[35m'); + expect(output).toContain('\u001b[33m'); + expect(output).toContain('\u001b[34m'); + expect(output).toContain('\u001b[96m'); + expect(output).toContain('\u001b[90m'); + expect(output).toContain('event'); + expect(output).toContain('list_catalogs'); } finally { restoreTerminalColorForTests(); } }); - test("keeps array row values aligned when columns are filtered", () => { + test('keeps array row values aligned when columns are filtered', () => { const result = { metadata: {}, - columns: ["uuid", "event", "timestamp", "author_id", "input"], + columns: ['uuid', 'event', 'timestamp', 'author_id', 'input'], rows: [ [ - "019ee8e4-1d79-77d9-8693-1f67732b184d", - "mcp_tool_call", - "2026-06-21T06:35:24.409Z", - "019c6610-ebab-784d-b72b-b89c44804e38", - "{}", + '019ee8e4-1d79-77d9-8693-1f67732b184d', + 'mcp_tool_call', + '2026-06-21T06:35:24.409Z', + '019c6610-ebab-784d-b72b-b89c44804e38', + '{}', ], ], }; const output = renderQueryHumanOutput(result, { - layout: "line", + layout: 'line', maxColumnWidth: 80, terminalWidth: 120, }); - expect(output).toContain("event:"); - expect(output).toContain("mcp_tool_call"); - expect(output).toContain("timestamp:"); - expect(output).toContain("2026-06-21T06:35:24.409Z"); + expect(output).toContain('event:'); + expect(output).toContain('mcp_tool_call'); + expect(output).toContain('timestamp:'); + expect(output).toContain('2026-06-21T06:35:24.409Z'); expect(output).not.toMatch(/event:.*019ee8e4/); }); - test("uses row number separators instead of blank lines for multiple line-mode rows", () => { + test('uses row number separators instead of blank lines for multiple line-mode rows', () => { const result = { metadata: {}, - columns: ["event", "timestamp", "tool_name"], + columns: ['event', 'timestamp', 'tool_name'], rows: [ { - event: "mcp_tool_call", - timestamp: "2026-06-21T06:35:24.409Z", - tool_name: "list_catalogs", + event: 'mcp_tool_call', + timestamp: '2026-06-21T06:35:24.409Z', + tool_name: 'list_catalogs', + }, + { + event: 'mcp_tool_call', + timestamp: '2026-06-21T06:35:25.518Z', + tool_name: 'get_catalog', }, - { event: "mcp_tool_call", timestamp: "2026-06-21T06:35:25.518Z", tool_name: "get_catalog" }, ], }; const output = renderQueryHumanOutput(result, { - layout: "line", + layout: 'line', maxColumnWidth: 80, terminalWidth: 120, colorize: false, }); - const ansiEscapePattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); - const plain = output.replace(ansiEscapePattern, ""); + const ansiEscapePattern = new RegExp( + `${String.fromCharCode(27)}\\[[0-9;]*m`, + 'g' + ); + const plain = output.replace(ansiEscapePattern, ''); expect(plain).toMatch(/#1\nevent:/); expect(plain).toMatch(/tool_name: list_catalogs\n#2\nevent:/); }); - test("omits row numbers for a single line-mode row", () => { + test('omits row numbers for a single line-mode row', () => { const result = { metadata: {}, - columns: ["event", "timestamp"], - rows: [{ event: "mcp_tool_call", timestamp: "2026-06-21T06:35:24.409Z" }], + columns: ['event', 'timestamp'], + rows: [{ event: 'mcp_tool_call', timestamp: '2026-06-21T06:35:24.409Z' }], }; const output = renderQueryHumanOutput(result, { - layout: "line", + layout: 'line', maxColumnWidth: 80, terminalWidth: 120, colorize: false, }); - expect(output).toContain("event:"); - expect(output).not.toContain("#1"); + expect(output).toContain('event:'); + expect(output).not.toContain('#1'); }); - test("colorizes each scalar data type in line output", () => { + test('colorizes each scalar data type in line output', () => { enableTerminalColorForTests(); try { const result = { metadata: {}, - columns: ["event", "timestamp", "success", "duration", "error_message", "input"], + columns: [ + 'event', + 'timestamp', + 'success', + 'duration', + 'error_message', + 'input', + ], rows: [ { - event: "mcp_tool_call", - timestamp: "2026-06-21T06:35:24.409Z", + event: 'mcp_tool_call', + timestamp: '2026-06-21T06:35:24.409Z', success: true, duration: 285, error_message: null, @@ -507,103 +553,106 @@ describe("renderQueryHumanOutput", () => { ], }; const output = renderQueryHumanOutput(result, { - layout: "line", + layout: 'line', maxColumnWidth: 80, terminalWidth: 120, colorize: true, }); - expect(output).toContain("\u001b[34m"); - expect(output).toContain("\u001b[35m"); - expect(output).toContain("\u001b[33m"); - expect(output).toContain("\u001b[90m"); + expect(output).toContain('\u001b[34m'); + expect(output).toContain('\u001b[35m'); + expect(output).toContain('\u001b[33m'); + expect(output).toContain('\u001b[90m'); } finally { restoreTerminalColorForTests(); } }); }); -describe("renderQueryFooter", () => { - test("includes row count, timing, and query_id", () => { +describe('renderQueryFooter', () => { + test('includes row count, timing, and query_id', () => { const footer = renderQueryFooter({ - metadata: { init_time_ms: 285, query_id: "019ee8e4-1d79-77d9-8693-1f67732b184d" }, + metadata: { + init_time_ms: 285, + query_id: '019ee8e4-1d79-77d9-8693-1f67732b184d', + }, columns: [], rows: [{ id: 1 }, { id: 2 }], }); - expect(footer).toContain("2 rows in 285ms"); - expect(footer).toContain("query_id:"); - expect(footer).toContain("019ee8e4-1d79-77d9-8693-1f67732b184d"); + expect(footer).toContain('2 rows in 285ms'); + expect(footer).toContain('query_id:'); + expect(footer).toContain('019ee8e4-1d79-77d9-8693-1f67732b184d'); }); - test("sanitizes query IDs without terminal color", () => { + test('sanitizes query IDs without terminal color', () => { const result = { - metadata: { query_id: "query\u001b]0;spoofed\u0007" }, + metadata: { query_id: 'query\u001b]0;spoofed\u0007' }, columns: [], rows: [], }; expect(renderQueryFooter(result, { colorize: false })).toContain( - "query_id: query\\x1b]0;spoofed\\x07", + 'query_id: query\\x1b]0;spoofed\\x07' ); expect( renderQueryMarkdown(result, [], { - layout: "table", + layout: 'table', maxColumnWidth: 32, terminalWidth: 80, colorize: false, - }), - ).toContain("query_id: query\\x1b]0;spoofed\\x07"); + }) + ).toContain('query_id: query\\x1b]0;spoofed\\x07'); }); }); -describe("CSV regression", () => { - test("renderQueryCsv keeps full JSON without ellipsis", () => { +describe('CSV regression', () => { + test('renderQueryCsv keeps full JSON without ellipsis', () => { const result = { metadata: {}, - columns: ["payload"], - rows: [{ payload: { key: "value", nested: { deep: true } } }], + columns: ['payload'], + rows: [{ payload: { key: 'value', nested: { deep: true } } }], }; const csv = renderQueryCsv(result); - const dataLine = csv.split("\n")[1] ?? ""; + const dataLine = csv.split('\n')[1] ?? ''; const unquoted = dataLine.slice(1, -1).replace(/""/g, '"'); expect(unquoted).toBe('{"key":"value","nested":{"deep":true}}'); - expect(csv).not.toContain("…"); + expect(csv).not.toContain('…'); }); }); -describe("renderQueryMarkdown", () => { - test("renders GitHub-flavored pipe table with full values", () => { +describe('renderQueryMarkdown', () => { + test('renders GitHub-flavored pipe table with full values', () => { const result = { metadata: { init_time_ms: 285 }, - columns: ["id", "name"], - rows: [{ id: 1, name: "Alice" }], + columns: ['id', 'name'], + rows: [{ id: 1, name: 'Alice' }], }; - const output = renderQueryMarkdown(result, ["id", "name"], { - layout: "table", + const output = renderQueryMarkdown(result, ['id', 'name'], { + layout: 'table', maxColumnWidth: 32, terminalWidth: 80, }); - expect(output).toContain("| id | name |"); - expect(output).toContain("| --- | --- |"); - expect(output).toContain("| 1 | Alice |"); - expect(output).toContain(""); + expect(output).toContain('| id | name |'); + expect(output).toContain('| --- | --- |'); + expect(output).toContain('| 1 | Alice |'); + expect(output).toContain(''); }); - test("escapes pipe characters in cells", () => { + test('escapes pipe characters in cells', () => { const result = { metadata: {}, - columns: ["label"], - rows: [{ label: "a|b" }], + columns: ['label'], + rows: [{ label: 'a|b' }], }; - const output = renderQueryMarkdown(result, ["label"], { - layout: "table", + const output = renderQueryMarkdown(result, ['label'], { + layout: 'table', maxColumnWidth: 32, terminalWidth: 80, }); - expect(output).toContain("a\\|b"); + expect(output).toContain('a\\|b'); }); }); -describe("highlightJsonForTerminal", () => { +describe('highlightJsonForTerminal', () => { let savedTest: string | undefined; let savedCi: string | undefined; @@ -625,47 +674,47 @@ describe("highlightJsonForTerminal", () => { } }); - test("returns input unchanged when disabled", () => { + test('returns input unchanged when disabled', () => { const json = '{"key":"value","count":42}'; const output = highlightJsonForTerminal(json, false); expect(output).toBe(json); - expect(output).not.toContain("\u001b"); + expect(output).not.toContain('\u001b'); }); - test("adds ANSI codes when enabled", () => { + test('adds ANSI codes when enabled', () => { enableTerminalColorForTests(); try { const json = '{"key":"value","count":42,"enabled":true,"missing":null}'; const output = highlightJsonForTerminal(json, true); - expect(output).toContain("\u001b[96m"); - expect(output).toContain("\u001b[34m"); - expect(output).toContain("\u001b[35m"); - expect(output).toContain("\u001b[33m"); - expect(output).toContain("\u001b[90m"); + expect(output).toContain('\u001b[96m'); + expect(output).toContain('\u001b[34m'); + expect(output).toContain('\u001b[35m'); + expect(output).toContain('\u001b[33m'); + expect(output).toContain('\u001b[90m'); } finally { restoreTerminalColorForTests(); } }); }); -describe("JSON regression", () => { - test("renderQueryJson keeps full values without ANSI", () => { +describe('JSON regression', () => { + test('renderQueryJson keeps full values without ANSI', () => { const result = { metadata: {}, - columns: ["payload"], - rows: [{ payload: { key: "value" } }], + columns: ['payload'], + rows: [{ payload: { key: 'value' } }], }; const json = renderQueryJson(result); expect(json).toContain('"key": "value"'); - expect(json).not.toContain("\u001b"); + expect(json).not.toContain('\u001b'); }); }); -describe("defaultDisplayOptions", () => { - let testHome = ""; +describe('defaultDisplayOptions', () => { + let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-query-format-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-query-format-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; }); @@ -674,11 +723,11 @@ describe("defaultDisplayOptions", () => { delete process.env.ALTERTABLE_CONFIG_HOME; }); - test("merges config defaults when present", () => { - kvSet(configFile(), "query_max_width", "24"); - kvSet(configFile(), "query_layout", "table"); + test('merges config defaults when present', () => { + kvSet(configFile(), 'query_max_width', '24'); + kvSet(configFile(), 'query_layout', 'table'); const options = defaultDisplayOptions(); expect(options.maxColumnWidth).toBe(24); - expect(options.layout).toBe("table"); + expect(options.layout).toBe('table'); }); }); diff --git a/cli/src/lib/query-format.ts b/cli/src/lib/query-format.ts index cd42a9f1..9b888cb8 100644 --- a/cli/src/lib/query-format.ts +++ b/cli/src/lib/query-format.ts @@ -2,8 +2,11 @@ import type { LakehouseColumn, LakehouseQueryResult, LakehouseRow, -} from "@/lib/lakehouse-ndjson.ts"; -import { getQueryDefaultMaxColumnWidth, getQueryDefaultLayout } from "@/lib/config.ts"; +} from '@/lib/lakehouse-ndjson.ts'; +import { + getQueryDefaultMaxColumnWidth, + getQueryDefaultLayout, +} from '@/lib/config.ts'; import { getColumnTypeMap, isTimestampValue, @@ -11,10 +14,16 @@ import { selectDisplayColumnNames, type ColumnTypeMap, type QueryDataType, -} from "@/lib/query-column-types.ts"; -import { pluralizeLabel } from "@/lib/pluralize.ts"; -import { redactPasswordFieldInText, redactSensitiveJsonText } from "@/lib/redact.ts"; -import { formatRelativeTimestamp, formatTimestampWithRelative } from "@/lib/relative-time.ts"; +} from '@/lib/query-column-types.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; +import { + redactPasswordFieldInText, + redactSensitiveJsonText, +} from '@/lib/redact.ts'; +import { + formatRelativeTimestamp, + formatTimestampWithRelative, +} from '@/lib/relative-time.ts'; import { getTerminalWidth, getVisibleTextWidth, @@ -23,15 +32,17 @@ import { shouldUseTerminalColor, truncateTerminalText, type TerminalTextAlignment, -} from "@/ui/terminal/styles.ts"; -import type { QueryLayout } from "@/ui/layouts/query.ts"; -import { span, type DisplayTextStyle } from "@/ui/document.ts"; +} from '@/ui/terminal/styles.ts'; +import type { QueryLayout } from '@/ui/layouts/query.ts'; +import { span, type DisplayTextStyle } from '@/ui/document.ts'; const DEFAULT_MAX_COLUMN_WIDTH = 32; const TABLE_CELL_PADDING = 1; -const NULL_DISPLAY = "NULL"; -const ELLIPSIS = "…"; -const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const NULL_DISPLAY = 'NULL'; +const ELLIPSIS = '…'; +const graphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', +}); type QueryColumnAlignment = TerminalTextAlignment; @@ -65,7 +76,7 @@ export type QueryDisplayOptions = { export function defaultDisplayOptions(): QueryDisplayOptions { return { - layout: getQueryDefaultLayout() ?? "auto", + layout: getQueryDefaultLayout() ?? 'auto', maxColumnWidth: getQueryDefaultMaxColumnWidth() ?? DEFAULT_MAX_COLUMN_WIDTH, terminalWidth: getTerminalWidth(), colorize: true, @@ -78,23 +89,23 @@ function shouldColorizeQueryCells(colorize: boolean | undefined): boolean { function queryDataTypeStyle(dataType: QueryDataType): DisplayTextStyle { switch (dataType) { - case "null": - return "subtle"; - case "boolean": - return "boolean"; - case "number": - return "number"; - case "uuid": - return "accent"; - case "string": - case "timestamp": - return "string"; + case 'null': + return 'subtle'; + case 'boolean': + return 'boolean'; + case 'number': + return 'number'; + case 'uuid': + return 'accent'; + case 'string': + case 'timestamp': + return 'string'; } } function parseJsonStringValue(value: string): string | null { const trimmed = value.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) { return null; } try { @@ -108,21 +119,26 @@ function styleQueryHeader( name: string, width: number, colorize: boolean, - alignment: QueryColumnAlignment, + alignment: QueryColumnAlignment ): string { const truncated = truncateText(renderDisplayText(name), width); const padded = padVisibleText(truncated, width, alignment); if (!shouldColorizeQueryCells(colorize)) { return padded; } - return renderDisplayText([span(padded, "accent")]); + return renderDisplayText([span(padded, 'accent')]); } function styleQueryTableChrome(text: string, colorize: boolean): string { - return shouldColorizeQueryCells(colorize) ? renderDisplayText([span(text, "subtle")]) : text; + return shouldColorizeQueryCells(colorize) + ? renderDisplayText([span(text, 'subtle')]) + : text; } -export function highlightJsonForTerminal(json: string, enabled: boolean): string { +export function highlightJsonForTerminal( + json: string, + enabled: boolean +): string { if (!enabled) { return json; } @@ -131,22 +147,22 @@ export function highlightJsonForTerminal(json: string, enabled: boolean): string /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match, keyPart, stringPart, booleanPart) => { if (keyPart !== undefined) { - return renderDisplayText([span(keyPart, "accent"), span(":")]); + return renderDisplayText([span(keyPart, 'accent'), span(':')]); } if (stringPart !== undefined) { - return renderDisplayText([span(stringPart, "string")]); + return renderDisplayText([span(stringPart, 'string')]); } if (booleanPart !== undefined) { - if (booleanPart === "null") { - return renderDisplayText([span(booleanPart, "subtle")]); + if (booleanPart === 'null') { + return renderDisplayText([span(booleanPart, 'subtle')]); } - if (booleanPart === "false") { - return renderDisplayText([span(booleanPart, "subtle")]); + if (booleanPart === 'false') { + return renderDisplayText([span(booleanPart, 'subtle')]); } - return renderDisplayText([span(booleanPart, "boolean")]); + return renderDisplayText([span(booleanPart, 'boolean')]); } - return renderDisplayText([span(match, "number")]); - }, + return renderDisplayText([span(match, 'number')]); + } ); } @@ -167,7 +183,7 @@ function truncateDisplayTextMiddle(text: string, maxWidth?: number): string { function formatTimestampQueryCell( value: string, options: QueryCellOptions, - colorize: boolean, + colorize: boolean ): string { const includeRelative = options.includeRelative === true; const relative = includeRelative ? formatRelativeTimestamp(value) : null; @@ -180,27 +196,34 @@ function formatTimestampQueryCell( } if (relative === null || display === value) { - return renderDisplayText([span(display, "string")]); + return renderDisplayText([span(display, 'string')]); } - return renderDisplayText([span(value, "string"), span(` ${relative}`, "subtle")]); + return renderDisplayText([ + span(value, 'string'), + span(` ${relative}`, 'subtle'), + ]); } function formatStringQueryCell( value: string, options: QueryCellOptions, - colorize: boolean, + colorize: boolean ): string { const jsonText = parseJsonStringValue(value); const sanitized = jsonText ?? redactPasswordFieldInText(value); if (sanitized.length === 0) { - return colorize ? renderDisplayText([span('""', "subtle")]) : '""'; + return colorize ? renderDisplayText([span('""', 'subtle')]) : '""'; } const columnTypeMap = options.columnTypeMap ?? new Map(); - const dataType = resolveCellDataType(sanitized, options.columnName, columnTypeMap); + const dataType = resolveCellDataType( + sanitized, + options.columnName, + columnTypeMap + ); - if (dataType === "timestamp" && isTimestampValue(sanitized)) { + if (dataType === 'timestamp' && isTimestampValue(sanitized)) { return formatTimestampQueryCell(sanitized, options, colorize); } @@ -214,7 +237,7 @@ function formatStringQueryCell( const safeValue = renderDisplayText(sanitized); const display = - dataType === "uuid" + dataType === 'uuid' ? truncateDisplayTextMiddle(safeValue, options.maxWidth) : truncateDisplayText(safeValue, options.maxWidth); @@ -237,12 +260,15 @@ export function truncateTextMiddle(text: string, maxWidth: number): string { return ELLIPSIS; } - const characters = Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment); + const characters = Array.from( + graphemeSegmenter.segment(text), + ({ segment }) => segment + ); const availableWidth = maxWidth - getVisibleTextWidth(ELLIPSIS); const prefixWidth = Math.ceil(availableWidth / 2); const suffixWidth = Math.floor(availableWidth / 2); - let prefix = ""; - let suffix = ""; + let prefix = ''; + let suffix = ''; for (const character of characters) { if (getVisibleTextWidth(prefix + character) > prefixWidth) { @@ -251,7 +277,7 @@ export function truncateTextMiddle(text: string, maxWidth: number): string { prefix += character; } for (let index = characters.length - 1; index >= 0; index -= 1) { - const character = characters[index] ?? ""; + const character = characters[index] ?? ''; if (getVisibleTextWidth(character + suffix) > suffixWidth) { break; } @@ -262,45 +288,57 @@ export function truncateTextMiddle(text: string, maxWidth: number): string { export function formatQueryCellRaw(value: unknown): string { if (value === null || value === undefined) { - return ""; + return ''; } - if (typeof value === "object") { + if (typeof value === 'object') { return JSON.stringify(value); } - if (typeof value === "string") { + if (typeof value === 'string') { return redactPasswordFieldInText(value); } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { return String(value); } - return ""; + return ''; } -export function formatQueryCell(value: unknown, options: QueryCellOptions): string { +export function formatQueryCell( + value: unknown, + options: QueryCellOptions +): string { const colorize = shouldColorizeQueryCells(options.colorize); if (value === null || value === undefined) { - return colorize ? renderDisplayText([span(NULL_DISPLAY, "subtle")]) : NULL_DISPLAY; + return colorize + ? renderDisplayText([span(NULL_DISPLAY, 'subtle')]) + : NULL_DISPLAY; } - if (typeof value === "boolean") { + if (typeof value === 'boolean') { const text = String(value); if (!colorize) { return text; } if (value === false) { - return renderDisplayText([span(text, "subtle")]); + return renderDisplayText([span(text, 'subtle')]); } - return renderDisplayText([span(text, "boolean")]); + return renderDisplayText([span(text, 'boolean')]); } - if (typeof value === "number" || typeof value === "bigint") { + if (typeof value === 'number' || typeof value === 'bigint') { const text = String(value); - return colorize ? renderDisplayText([span(text, "number")]) : text; + return colorize ? renderDisplayText([span(text, 'number')]) : text; } - if (typeof value === "string") { + if (typeof value === 'string') { return formatStringQueryCell(value, options, colorize); } - const displayText = truncateDisplayText(JSON.stringify(value), options.maxWidth); + const displayText = truncateDisplayText( + JSON.stringify(value), + options.maxWidth + ); if (!colorize) { return displayText; @@ -310,11 +348,11 @@ export function formatQueryCell(value: unknown, options: QueryCellOptions): stri export function getQueryColumnNames(result: LakehouseQueryResult): string[] { if (result.columns.length > 0) { - if (typeof result.columns[0] === "string") { + if (typeof result.columns[0] === 'string') { return result.columns as string[]; } return (result.columns as LakehouseColumn[]).map((column, index) => { - if (typeof column.name === "string" && column.name.length > 0) { + if (typeof column.name === 'string' && column.name.length > 0) { return column.name; } return `column_${index + 1}`; @@ -322,7 +360,11 @@ export function getQueryColumnNames(result: LakehouseQueryResult): string[] { } const firstRow = result.rows[0]; - if (firstRow !== undefined && !Array.isArray(firstRow) && typeof firstRow === "object") { + if ( + firstRow !== undefined && + !Array.isArray(firstRow) && + typeof firstRow === 'object' + ) { return Object.keys(firstRow); } @@ -333,13 +375,16 @@ export function getQueryColumnNames(result: LakehouseQueryResult): string[] { return []; } -export function selectColumnNames(allNames: string[], selected?: string[]): string[] { +export function selectColumnNames( + allNames: string[], + selected?: string[] +): string[] { if (selected === undefined || selected.length === 0) { return allNames; } const nameSet = new Set(allNames); - const filtered = selected.filter((name) => nameSet.has(name)); + const filtered = selected.filter(name => nameSet.has(name)); if (filtered.length === 0) { return allNames; } @@ -349,10 +394,10 @@ export function selectColumnNames(allNames: string[], selected?: string[]): stri function getRowCellValues( row: LakehouseRow, columnNames: string[], - allColumnNames: string[], + allColumnNames: string[] ): unknown[] { if (Array.isArray(row)) { - return columnNames.map((name) => { + return columnNames.map(name => { const columnIndex = allColumnNames.indexOf(name); if (columnIndex < 0) { return undefined; @@ -360,30 +405,35 @@ function getRowCellValues( return row[columnIndex]; }); } - return columnNames.map((name) => row[name]); + return columnNames.map(name => row[name]); } function collectQueryDisplayRows( result: LakehouseQueryResult, columnNames: string[], - allColumnNames: string[], + allColumnNames: string[] ): QueryDisplayRow[] { - return result.rows.map((row) => { + return result.rows.map(row => { const values = getRowCellValues(row, columnNames, allColumnNames); return { values, - rawCells: values.map((value) => renderDisplayText(formatQueryCellRaw(value))), + rawCells: values.map(value => + renderDisplayText(formatQueryCellRaw(value)) + ), }; }); } function buildQueryRenderModel( result: LakehouseQueryResult, - options: QueryDisplayOptions, + options: QueryDisplayOptions ): QueryRenderModel { const allColumnNames = getQueryColumnNames(result); const columnTypeMap = getColumnTypeMap(result.columns); - const { columns: columnNames } = selectDisplayColumnNames(allColumnNames, options); + const { columns: columnNames } = selectDisplayColumnNames( + allColumnNames, + options + ); return { columnNames, columnTypeMap, @@ -395,17 +445,19 @@ function computeColumnWidths( columnNames: string[], formattedCells: string[][], terminalWidth: number, - maxColumnWidth: number, + maxColumnWidth: number ): number[] { const naturalWidths = columnNames.map((name, columnIndex) => { const nameWidth = getVisibleTextWidth(renderDisplayText(name)); - const cellWidths = formattedCells.map((cells) => getVisibleTextWidth(cells[columnIndex] ?? "")); + const cellWidths = formattedCells.map(cells => + getVisibleTextWidth(cells[columnIndex] ?? '') + ); const natural = Math.max(nameWidth, ...cellWidths); return Math.min(natural, maxColumnWidth); }); - const minimumWidths = columnNames.map((name) => - Math.min(getVisibleTextWidth(renderDisplayText(name)), maxColumnWidth), + const minimumWidths = columnNames.map(name => + Math.min(getVisibleTextWidth(renderDisplayText(name)), maxColumnWidth) ); const totalNatural = naturalWidths.reduce((sum, width) => sum + width, 0); @@ -426,10 +478,11 @@ function computeColumnWidths( const nextFlexible: number[] = []; for (const columnIndex of flexibleColumns) { - const currentWidth = widths[columnIndex] ?? minimumWidths[columnIndex] ?? 0; + const currentWidth = + widths[columnIndex] ?? minimumWidths[columnIndex] ?? 0; const targetWidth = Math.min( naturalWidths[columnIndex] ?? currentWidth, - currentWidth + share, + currentWidth + share ); widths[columnIndex] = targetWidth; remainingWidth -= targetWidth - currentWidth; @@ -456,73 +509,82 @@ function getTableFrameWidth(columnCount: number): number { function computeMinimumTableWidth( columnNames: string[], formattedCells: string[][], - maxColumnWidth: number, + maxColumnWidth: number ): number { const widths = computeColumnWidths( columnNames, formattedCells, Number.MAX_SAFE_INTEGER, - maxColumnWidth, + maxColumnWidth + ); + return ( + widths.reduce((sum, width) => sum + width, 0) + + getTableFrameWidth(columnNames.length) ); - return widths.reduce((sum, width) => sum + width, 0) + getTableFrameWidth(columnNames.length); } function getColumnAlignment( rows: QueryDisplayRow[], columnIndex: number, columnName: string, - columnTypeMap: ColumnTypeMap, + columnTypeMap: ColumnTypeMap ): QueryColumnAlignment { for (const row of rows) { const value = row.values[columnIndex]; const dataType = resolveCellDataType(value, columnName, columnTypeMap); - if (dataType === "null") { + if (dataType === 'null') { continue; } - if (dataType === "number") { - return "right"; + if (dataType === 'number') { + return 'right'; } - if (dataType === "boolean") { - return "center"; + if (dataType === 'boolean') { + return 'center'; } - return "left"; + return 'left'; } const columnType = columnTypeMap.get(columnName); - const inferredType = resolveCellDataType("", columnName, new Map([[columnName, columnType]])); - if (inferredType === "number") { - return "right"; + const inferredType = resolveCellDataType( + '', + columnName, + new Map([[columnName, columnType]]) + ); + if (inferredType === 'number') { + return 'right'; } - if (inferredType === "boolean") { - return "center"; + if (inferredType === 'boolean') { + return 'center'; } - return "left"; + return 'left'; } function getColumnAlignments( rows: QueryDisplayRow[], columnNames: string[], - columnTypeMap: ColumnTypeMap, + columnTypeMap: ColumnTypeMap ): QueryColumnAlignment[] { return columnNames.map((name, columnIndex) => - getColumnAlignment(rows, columnIndex, name, columnTypeMap), + getColumnAlignment(rows, columnIndex, name, columnTypeMap) ); } function renderQueryTableBorder( columnWidths: number[], - position: "top" | "middle" | "bottom", - colorize: boolean, + position: 'top' | 'middle' | 'bottom', + colorize: boolean ): string { const characters = { - top: { left: "┌", separator: "┬", right: "┐" }, - middle: { left: "├", separator: "┼", right: "┤" }, - bottom: { left: "└", separator: "┴", right: "┘" }, + top: { left: '┌', separator: '┬', right: '┐' }, + middle: { left: '├', separator: '┼', right: '┤' }, + bottom: { left: '└', separator: '┴', right: '┘' }, }[position]; - const segments = columnWidths.map((width) => "─".repeat(width + TABLE_CELL_PADDING * 2)); + const segments = columnWidths.map(width => + '─'.repeat(width + TABLE_CELL_PADDING * 2) + ); return styleQueryTableChrome( `${characters.left}${segments.join(characters.separator)}${characters.right}`, - colorize, + colorize ); } @@ -530,15 +592,15 @@ function renderQueryTableRow( cells: string[], columnWidths: number[], alignments: QueryColumnAlignment[], - colorize: boolean, + colorize: boolean ): string { const renderedCells = cells.map((cell, columnIndex) => { const width = columnWidths[columnIndex] ?? getVisibleTextWidth(cell); - const alignment = alignments[columnIndex] ?? "left"; + const alignment = alignments[columnIndex] ?? 'left'; const padded = padVisibleText(cell, width, alignment); - return `${" ".repeat(TABLE_CELL_PADDING)}${padded}${" ".repeat(TABLE_CELL_PADDING)}`; + return `${' '.repeat(TABLE_CELL_PADDING)}${padded}${' '.repeat(TABLE_CELL_PADDING)}`; }); - const border = styleQueryTableChrome("│", colorize); + const border = styleQueryTableChrome('│', colorize); return `${border}${renderedCells.join(border)}${border}`; } @@ -547,10 +609,10 @@ function formatCellsForTable( columnNames: string[], columnWidths: number[], options: QueryDisplayOptions, - columnTypeMap: ColumnTypeMap, + columnTypeMap: ColumnTypeMap ): string[][] { const colorize = options.colorize ?? true; - return rows.map((row) => + return rows.map(row => row.values.map((value, columnIndex) => { const width = columnWidths[columnIndex] ?? DEFAULT_MAX_COLUMN_WIDTH; return formatQueryCell(value, { @@ -559,61 +621,76 @@ function formatCellsForTable( columnName: columnNames[columnIndex], columnTypeMap, }); - }), + }) ); } -function renderQueryTable(model: QueryRenderModel, options: QueryDisplayOptions): string { +function renderQueryTable( + model: QueryRenderModel, + options: QueryDisplayOptions +): string { const { columnNames, columnTypeMap, rows } = model; if (rows.length === 0) { if (columnNames.length === 0) { - return "(no rows)"; + return '(no rows)'; } } const availableCellWidth = Math.max( 0, - options.terminalWidth - getTableFrameWidth(columnNames.length), + options.terminalWidth - getTableFrameWidth(columnNames.length) ); const columnWidths = computeColumnWidths( columnNames, - rows.map((row) => row.rawCells), - options.layout === "table" ? Number.MAX_SAFE_INTEGER : availableCellWidth, - options.maxColumnWidth, + rows.map(row => row.rawCells), + options.layout === 'table' ? Number.MAX_SAFE_INTEGER : availableCellWidth, + options.maxColumnWidth + ); + const rowValues = formatCellsForTable( + rows, + columnNames, + columnWidths, + options, + columnTypeMap ); - const rowValues = formatCellsForTable(rows, columnNames, columnWidths, options, columnTypeMap); const colorize = options.colorize ?? true; const alignments = getColumnAlignments(rows, columnNames, columnTypeMap); const headerCells = columnNames.map((name, columnIndex) => { const width = columnWidths[columnIndex] ?? name.length; - const alignment = alignments[columnIndex] ?? "left"; + const alignment = alignments[columnIndex] ?? 'left'; return styleQueryHeader(name, width, colorize, alignment); }); const lines = [ - renderQueryTableBorder(columnWidths, "top", colorize), + renderQueryTableBorder(columnWidths, 'top', colorize), renderQueryTableRow(headerCells, columnWidths, alignments, colorize), - renderQueryTableBorder(columnWidths, "middle", colorize), - ...rowValues.map((cells) => renderQueryTableRow(cells, columnWidths, alignments, colorize)), - renderQueryTableBorder(columnWidths, "bottom", colorize), + renderQueryTableBorder(columnWidths, 'middle', colorize), + ...rowValues.map(cells => + renderQueryTableRow(cells, columnWidths, alignments, colorize) + ), + renderQueryTableBorder(columnWidths, 'bottom', colorize), ]; - return lines.join("\n"); + return lines.join('\n'); } -function renderQueryExpanded(model: QueryRenderModel, options: QueryDisplayOptions): string { +function renderQueryExpanded( + model: QueryRenderModel, + options: QueryDisplayOptions +): string { const { columnNames, columnTypeMap, rows } = model; if (rows.length === 0) { - return "(no rows)"; + return '(no rows)'; } const colorize = options.colorize ?? true; const labelWidth = columnNames.reduce( - (maxWidth, name) => Math.max(maxWidth, getVisibleTextWidth(renderDisplayText(name))), - 0, + (maxWidth, name) => + Math.max(maxWidth, getVisibleTextWidth(renderDisplayText(name))), + 0 ) + 1; const showRowNumbers = rows.length > 1; @@ -625,71 +702,78 @@ function renderQueryExpanded(model: QueryRenderModel, options: QueryDisplayOptio columnName: name, columnTypeMap, }); - const labelText = padVisibleText(`${renderDisplayText(name)}:`, labelWidth); - const label = renderDisplayText([span(labelText, "muted")]); + const labelText = padVisibleText( + `${renderDisplayText(name)}:`, + labelWidth + ); + const label = renderDisplayText([span(labelText, 'muted')]); return `${label} ${value}`; }); - const body = lines.join("\n"); + const body = lines.join('\n'); if (showRowNumbers) { return `${styleQueryTableChrome(`#${rowIndex + 1}`, colorize)}\n${body}`; } return body; }); - return records.join("\n"); + return records.join('\n'); } export function renderQueryFooter( result: LakehouseQueryResult, - options: { colorize?: boolean } = {}, + options: { colorize?: boolean } = {} ): string { const rowCount = result.rows.length; const initTimeMs = result.metadata.init_time_ms; const queryId = result.metadata.query_id; - const hasInitTime = typeof initTimeMs === "number"; - const hasQueryId = typeof queryId === "string" && queryId.length > 0; + const hasInitTime = typeof initTimeMs === 'number'; + const hasQueryId = typeof queryId === 'string' && queryId.length > 0; if (rowCount === 0 && !hasInitTime && !hasQueryId) { - return ""; + return ''; } const footerParts: string[] = []; if (rowCount > 0 || hasInitTime || hasQueryId) { let summary = hasInitTime - ? `${pluralizeLabel(rowCount, "row")} in ${initTimeMs}ms` - : pluralizeLabel(rowCount, "row"); + ? `${pluralizeLabel(rowCount, 'row')} in ${initTimeMs}ms` + : pluralizeLabel(rowCount, 'row'); if (hasQueryId) { summary += ` query_id: ${queryId}`; } footerParts.push(summary); } - const style = shouldColorizeQueryCells(options.colorize) ? "subtle" : undefined; - return footerParts.map((line) => renderDisplayText([span(line, style)])).join("\n"); + const style = shouldColorizeQueryCells(options.colorize) + ? 'subtle' + : undefined; + return footerParts + .map(line => renderDisplayText([span(line, style)])) + .join('\n'); } function escapeMarkdownCell(value: string): string { - return value.replace(/\|/g, "\\|").replace(/\n/g, " "); + return value.replace(/\|/g, '\\|').replace(/\n/g, ' '); } export function renderQueryMarkdown( result: LakehouseQueryResult, columnNames: string[], - options: QueryDisplayOptions, + options: QueryDisplayOptions ): string { const selectedNames = selectColumnNames(columnNames, options.columns); const columnTypeMap = getColumnTypeMap(result.columns); if (selectedNames.length === 0 && result.rows.length === 0) { const footer = renderQueryFooter(result); - return footer.length > 0 ? `` : "(no rows)"; + return footer.length > 0 ? `` : '(no rows)'; } - const headerRow = `| ${selectedNames.map((name) => escapeMarkdownCell(name)).join(" | ")} |`; - const separatorRow = `| ${selectedNames.map(() => "---").join(" | ")} |`; - const bodyRows = result.rows.map((row) => { + const headerRow = `| ${selectedNames.map(name => escapeMarkdownCell(name)).join(' | ')} |`; + const separatorRow = `| ${selectedNames.map(() => '---').join(' | ')} |`; + const bodyRows = result.rows.map(row => { const values = getRowCellValues(row, selectedNames, columnNames); const cells = values .map((value, columnIndex) => @@ -698,39 +782,39 @@ export function renderQueryMarkdown( colorize: false, columnName: selectedNames[columnIndex], columnTypeMap, - }), - ), + }) + ) ) - .join(" | "); + .join(' | '); return `| ${cells} |`; }); const tableLines = [headerRow, separatorRow, ...bodyRows]; const footer = renderQueryFooter(result); if (footer.length === 0) { - return tableLines.join("\n"); + return tableLines.join('\n'); } - return `${tableLines.join("\n")}\n\n`; + return `${tableLines.join('\n')}\n\n`; } export function renderQueryHumanOutput( result: LakehouseQueryResult, - options: QueryDisplayOptions, + options: QueryDisplayOptions ): string { const model = buildQueryRenderModel(result, options); - const rawCells = model.rows.map((row) => row.rawCells); + const rawCells = model.rows.map(row => row.rawCells); const minimumTableWidth = computeMinimumTableWidth( model.columnNames, rawCells, - options.maxColumnWidth, + options.maxColumnWidth ); let body: string; - if (options.layout === "line") { + if (options.layout === 'line') { body = renderQueryExpanded(model, options); - } else if (options.layout === "table") { + } else if (options.layout === 'table') { body = renderQueryTable(model, options); } else if (minimumTableWidth > options.terminalWidth) { body = renderQueryExpanded(model, options); diff --git a/cli/src/lib/query-output-args.test.ts b/cli/src/lib/query-output-args.test.ts index ea913e88..4e4bfdf0 100644 --- a/cli/src/lib/query-output-args.test.ts +++ b/cli/src/lib/query-output-args.test.ts @@ -1,130 +1,155 @@ -import { describe, expect, test } from "bun:test"; -import { CliError } from "@/lib/errors.ts"; -import { parseQueryOutputOptions, resolveQueryComputeSize } from "@/lib/query-output-args.ts"; +import { describe, expect, test } from 'bun:test'; +import { CliError } from '@/lib/errors.ts'; +import { + parseQueryOutputOptions, + resolveQueryComputeSize, +} from '@/lib/query-output-args.ts'; -describe("parseQueryOutputOptions", () => { - test("composes explicit query presentation settings", () => { +describe('parseQueryOutputOptions', () => { + test('composes explicit query presentation settings', () => { const options = parseQueryOutputOptions( { - format: "markdown", - layout: "line", - columns: "id, name", - "max-width": "24", - pager: "never", + format: 'markdown', + layout: 'line', + columns: 'id, name', + 'max-width': '24', + pager: 'never', }, - { agent: false, json: false, rawArgs: [] }, + { agent: false, json: false, rawArgs: [] } ); expect(options).toMatchObject({ - format: "markdown", - displayOptions: { layout: "line", columns: ["id", "name"], maxColumnWidth: 24 }, - pagerOptions: { mode: "never" }, - computeSize: "AUTO", + format: 'markdown', + displayOptions: { + layout: 'line', + columns: ['id', 'name'], + maxColumnWidth: 24, + }, + pagerOptions: { mode: 'never' }, + computeSize: 'AUTO', }); }); - test("derives machine-readable output from agent context", () => { - expect(parseQueryOutputOptions({}, { agent: true, json: true, rawArgs: [] })).toMatchObject({ - format: "human", - pagerOptions: { mode: "never" }, - computeSize: "AUTO", + test('derives machine-readable output from agent context', () => { + expect( + parseQueryOutputOptions({}, { agent: true, json: true, rawArgs: [] }) + ).toMatchObject({ + format: 'human', + pagerOptions: { mode: 'never' }, + computeSize: 'AUTO', }); }); - test("rejects incompatible or invalid presentation settings", () => { + test('rejects incompatible or invalid presentation settings', () => { for (const run of [ () => - parseQueryOutputOptions({}, { agent: true, json: true, rawArgs: ["--layout", "table"] }), + parseQueryOutputOptions( + {}, + { agent: true, json: true, rawArgs: ['--layout', 'table'] } + ), () => parseQueryOutputOptions( - { "max-width": "4" }, - { agent: false, json: false, rawArgs: ["--max-width", "4"] }, + { 'max-width': '4' }, + { agent: false, json: false, rawArgs: ['--max-width', '4'] } ), () => parseQueryOutputOptions( - { pager: "sometimes" }, - { agent: false, json: false, rawArgs: ["--pager", "sometimes"] }, + { pager: 'sometimes' }, + { agent: false, json: false, rawArgs: ['--pager', 'sometimes'] } ), () => parseQueryOutputOptions( - { format: "parquet" }, - { agent: false, json: true, rawArgs: ["--format", "parquet"] }, + { format: 'parquet' }, + { agent: false, json: true, rawArgs: ['--format', 'parquet'] } ), () => parseQueryOutputOptions( - { format: "csv", layout: "table" }, - { agent: false, json: false, rawArgs: ["--format", "csv", "--layout", "table"] }, + { format: 'csv', layout: 'table' }, + { + agent: false, + json: false, + rawArgs: ['--format', 'csv', '--layout', 'table'], + } ), ]) { expect(run).toThrow(CliError); } }); - test("passes through request options", () => { + test('passes through request options', () => { const options = parseQueryOutputOptions( { - format: "jsonl", - dialect: "snowflake", - catalog: "analytics", - schema: "main", - output: "out.jsonl", - "compute-size": "L", + format: 'jsonl', + dialect: 'snowflake', + catalog: 'analytics', + schema: 'main', + output: 'out.jsonl', + 'compute-size': 'L', }, - { agent: false, json: false, rawArgs: ["--format", "jsonl", "--compute-size", "L"] }, + { + agent: false, + json: false, + rawArgs: ['--format', 'jsonl', '--compute-size', 'L'], + } ); expect(options).toMatchObject({ - format: "jsonl", - dialect: "snowflake", - catalog: "analytics", - schema: "main", - outputPath: "out.jsonl", - computeSize: "L", + format: 'jsonl', + dialect: 'snowflake', + catalog: 'analytics', + schema: 'main', + outputPath: 'out.jsonl', + computeSize: 'L', }); }); - test("passes explicit AUTO with a session through to the request", () => { + test('passes explicit AUTO with a session through to the request', () => { const options = parseQueryOutputOptions( - { "session-id": "session-1", "compute-size": "AUTO" }, + { 'session-id': 'session-1', 'compute-size': 'AUTO' }, { agent: false, json: false, - rawArgs: ["--session-id", "session-1", "--compute-size", "AUTO"], - }, + rawArgs: ['--session-id', 'session-1', '--compute-size', 'AUTO'], + } ); - expect(options.computeSize).toBe("AUTO"); + expect(options.computeSize).toBe('AUTO'); }); }); -describe("resolveQueryComputeSize", () => { - test("defaults to AUTO without a session", () => { - expect(resolveQueryComputeSize({ computeSizeExplicit: false })).toBe("AUTO"); +describe('resolveQueryComputeSize', () => { + test('defaults to AUTO without a session', () => { + expect(resolveQueryComputeSize({ computeSizeExplicit: false })).toBe( + 'AUTO' + ); }); - test("omits default AUTO when a session is present", () => { + test('omits default AUTO when a session is present', () => { expect( - resolveQueryComputeSize({ sessionId: "session-1", computeSizeExplicit: false }), + resolveQueryComputeSize({ + sessionId: 'session-1', + computeSizeExplicit: false, + }) ).toBeUndefined(); }); - test("keeps explicit sizes with a session", () => { + test('keeps explicit sizes with a session', () => { expect( resolveQueryComputeSize({ - sessionId: "session-1", - computeSizeArg: "M", + sessionId: 'session-1', + computeSizeArg: 'M', computeSizeExplicit: true, - }), - ).toBe("M"); + }) + ).toBe('M'); }); - test("keeps explicit AUTO with a session for the backend to validate", () => { + test('keeps explicit AUTO with a session for the backend to validate', () => { expect( resolveQueryComputeSize({ - sessionId: "session-1", - computeSizeArg: "AUTO", + sessionId: 'session-1', + computeSizeArg: 'AUTO', computeSizeExplicit: true, - }), - ).toBe("AUTO"); + }) + ).toBe('AUTO'); }); }); diff --git a/cli/src/lib/query-output-args.ts b/cli/src/lib/query-output-args.ts index db5e07e1..480186fc 100644 --- a/cli/src/lib/query-output-args.ts +++ b/cli/src/lib/query-output-args.ts @@ -1,33 +1,49 @@ -import { asCliArgString } from "@/lib/cli-args.ts"; -import { defineArguments } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; +import { asCliArgString } from '@/lib/cli-args.ts'; +import { defineArguments } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; import { isApiNativeQueryFormat, parseQueryResultFormat, type QueryResultFormat, -} from "@/lib/query-output.ts"; -import { resolvePagerOptions, type PagerMode, type PagerOptions } from "@/lib/pager.ts"; -import { defaultDisplayOptions, type QueryDisplayOptions } from "@/lib/query-format.ts"; -import { isQueryLayout, QUERY_LAYOUT_OPTIONS } from "@/ui/layouts/query.ts"; +} from '@/lib/query-output.ts'; +import { + resolvePagerOptions, + type PagerMode, + type PagerOptions, +} from '@/lib/pager.ts'; +import { + defaultDisplayOptions, + type QueryDisplayOptions, +} from '@/lib/query-format.ts'; +import { isQueryLayout, QUERY_LAYOUT_OPTIONS } from '@/ui/layouts/query.ts'; const MIN_MAX_COLUMN_WIDTH = 8; -const PRESENTATION_RESULT_FORMAT_OPTIONS = ["csv", "markdown"] as const; -const QUERY_RESULT_FORMAT_OPTIONS = ["csv", "jsonl", "parquet", "markdown"] as const; -const PAGER_MODE_OPTIONS = ["auto", "always", "never"] as const; +const PRESENTATION_RESULT_FORMAT_OPTIONS = ['csv', 'markdown'] as const; +const QUERY_RESULT_FORMAT_OPTIONS = [ + 'csv', + 'jsonl', + 'parquet', + 'markdown', +] as const; +const PAGER_MODE_OPTIONS = ['auto', 'always', 'never'] as const; const PAGER_MODES = new Set(PAGER_MODE_OPTIONS); -const AGENT_INCOMPATIBLE_QUERY_FLAGS = ["--layout", "--pager", "--max-width"] as const; +const AGENT_INCOMPATIBLE_QUERY_FLAGS = [ + '--layout', + '--pager', + '--max-width', +] as const; const API_NATIVE_INCOMPATIBLE_PRESENTATION_FLAGS = [ - "--layout", - "--columns", - "--max-width", - "--pager", + '--layout', + '--columns', + '--max-width', + '--pager', ] as const; /** Client-rendered formats shared by commands like `schema`. */ export const queryResultFormatArgs = defineArguments({ format: { - type: "enum", - description: "Serialized output format; use global --json for JSON", + type: 'enum', + description: 'Serialized output format; use global --json for JSON', options: [...PRESENTATION_RESULT_FORMAT_OPTIONS], }, }); @@ -35,57 +51,58 @@ export const queryResultFormatArgs = defineArguments({ /** Lakehouse query formats: csv/jsonl/parquet are API-native; markdown is CLI-rendered. */ export const queryApiResultFormatArgs = defineArguments({ format: { - type: "enum", + type: 'enum', description: - "Result format: csv, jsonl, and parquet stream from the API; markdown is rendered by the CLI. Use global --json for structured JSON", + 'Result format: csv, jsonl, and parquet stream from the API; markdown is rendered by the CLI. Use global --json for structured JSON', options: [...QUERY_RESULT_FORMAT_OPTIONS], }, }); export const queryDisplayArgs = defineArguments({ layout: { - type: "enum", - description: "Human layout: auto, table, or line", + type: 'enum', + description: 'Human layout: auto, table, or line', options: [...QUERY_LAYOUT_OPTIONS], }, - columns: { type: "string", description: "Comma-separated columns to show" }, - "max-width": { - type: "string", - description: "Maximum display width for table columns", - default: "32", + columns: { type: 'string', description: 'Comma-separated columns to show' }, + 'max-width': { + type: 'string', + description: 'Maximum display width for table columns', + default: '32', }, }); export const queryPagerArgs = defineArguments({ pager: { - type: "enum", - description: "Pager mode for human output: auto, always, or never", - default: "auto", + type: 'enum', + description: 'Pager mode for human output: auto, always, or never', + default: 'auto', options: [...PAGER_MODE_OPTIONS], }, }); export const queryRequestArgs = defineArguments({ - "compute-size": { - type: "string", - description: "Compute size for the query", - default: "AUTO", + 'compute-size': { + type: 'string', + description: 'Compute size for the query', + default: 'AUTO', }, dialect: { - type: "string", - description: "Source SQL dialect to transpile from (server default: DuckDB)", + type: 'string', + description: + 'Source SQL dialect to transpile from (server default: DuckDB)', }, catalog: { - type: "string", - description: "Catalog name (optional; can also come from the session)", + type: 'string', + description: 'Catalog name (optional; can also come from the session)', }, schema: { - type: "string", - description: "Schema name (optional; can also come from the session)", + type: 'string', + description: 'Schema name (optional; can also come from the session)', }, output: { - type: "string", - description: "Write result bytes/text to this path instead of stdout", + type: 'string', + description: 'Write result bytes/text to this path instead of stdout', }, }); @@ -107,7 +124,7 @@ type ParseQueryOutputOptions = { }; function hasArgvFlag(rawArgs: readonly string[], flag: string): boolean { - return rawArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); + return rawArgs.some(arg => arg === flag || arg.startsWith(`${flag}=`)); } function validateAgentQueryFlags(options: ParseQueryOutputOptions): void { @@ -116,7 +133,7 @@ function validateAgentQueryFlags(options: ParseQueryOutputOptions): void { for (const flag of AGENT_INCOMPATIBLE_QUERY_FLAGS) { if (hasArgvFlag(options.rawArgs, flag)) { throw new CliError( - `${flag} cannot be used with --agent. Agent mode already selects structured JSON output.`, + `${flag} cannot be used with --agent. Agent mode already selects structured JSON output.` ); } } @@ -124,13 +141,13 @@ function validateAgentQueryFlags(options: ParseQueryOutputOptions): void { function validateApiNativeFormatFlags( format: QueryResultFormat, - options: ParseQueryOutputOptions, + options: ParseQueryOutputOptions ): void { if (!isApiNativeQueryFormat(format)) return; if (options.json || options.agent) { throw new CliError( - `--format ${format} cannot be combined with --json or --agent. Those flags expect structured CLI JSON output.`, + `--format ${format} cannot be combined with --json or --agent. Those flags expect structured CLI JSON output.` ); } @@ -141,53 +158,72 @@ function validateApiNativeFormatFlags( } } -function parseQueryLayout(args: Record): QueryDisplayOptions["layout"] { +function parseQueryLayout( + args: Record +): QueryDisplayOptions['layout'] { const defaults = defaultDisplayOptions(); if (args.layout === undefined) return defaults.layout; const layout = asCliArgString(args.layout); - if (!isQueryLayout(layout)) throw new CliError("--layout must be auto, table, or line."); + if (!isQueryLayout(layout)) + throw new CliError('--layout must be auto, table, or line.'); return layout; } -function parseDisplayOptions(args: Record): QueryDisplayOptions { +function parseDisplayOptions( + args: Record +): QueryDisplayOptions { const defaults = defaultDisplayOptions(); let maxColumnWidth = defaults.maxColumnWidth; - if (args["max-width"] !== undefined) { - const width = Number.parseInt(asCliArgString(args["max-width"]), 10); + if (args['max-width'] !== undefined) { + const width = Number.parseInt(asCliArgString(args['max-width']), 10); if (Number.isNaN(width) || width < MIN_MAX_COLUMN_WIDTH) { - throw new CliError(`--max-width must be an integer >= ${MIN_MAX_COLUMN_WIDTH}.`); + throw new CliError( + `--max-width must be an integer >= ${MIN_MAX_COLUMN_WIDTH}.` + ); } maxColumnWidth = width; } - const columnsText = typeof args.columns === "string" ? args.columns.trim() : ""; + const columnsText = + typeof args.columns === 'string' ? args.columns.trim() : ''; const columns = columnsText ? columnsText - .split(",") - .map((name) => name.trim()) + .split(',') + .map(name => name.trim()) .filter(Boolean) : undefined; - return { ...defaults, layout: parseQueryLayout(args), maxColumnWidth, columns }; + return { + ...defaults, + layout: parseQueryLayout(args), + maxColumnWidth, + columns, + }; } -function parsePagerOptions(args: Record, agent: boolean): PagerOptions { - if (agent) return { mode: "never" }; +function parsePagerOptions( + args: Record, + agent: boolean +): PagerOptions { + if (agent) return { mode: 'never' }; if (args.pager === undefined) return resolvePagerOptions(); const pager = asCliArgString(args.pager); if (!PAGER_MODES.has(pager as PagerMode)) { - throw new CliError("--pager must be auto, always, or never."); + throw new CliError('--pager must be auto, always, or never.'); } return resolvePagerOptions(pager as PagerMode); } -function optionalTrimmedString(args: Record, name: string): string | undefined { +function optionalTrimmedString( + args: Record, + name: string +): string | undefined { const value = args[name]; - if (typeof value !== "string") return undefined; + if (typeof value !== 'string') return undefined; const trimmed = value.trim(); - return trimmed === "" ? undefined : trimmed; + return trimmed === '' ? undefined : trimmed; } export function resolveQueryComputeSize(options: { @@ -195,7 +231,7 @@ export function resolveQueryComputeSize(options: { computeSizeArg?: string; computeSizeExplicit: boolean; }): string | undefined { - const computeSize = options.computeSizeArg ?? "AUTO"; + const computeSize = options.computeSizeArg ?? 'AUTO'; if (options.sessionId && !options.computeSizeExplicit) return undefined; return computeSize; @@ -203,30 +239,32 @@ export function resolveQueryComputeSize(options: { export function parseQueryOutputOptions( args: Record, - options: ParseQueryOutputOptions, + options: ParseQueryOutputOptions ): QueryOutputOptions { validateAgentQueryFlags(options); const format = parseQueryResultFormat( - args.format === undefined ? "human" : asCliArgString(args.format), + args.format === undefined ? 'human' : asCliArgString(args.format) ); validateApiNativeFormatFlags(format, options); - const sessionId = optionalTrimmedString(args, "session-id"); + const sessionId = optionalTrimmedString(args, 'session-id'); const computeSize = resolveQueryComputeSize({ sessionId, computeSizeArg: - args["compute-size"] === undefined ? undefined : asCliArgString(args["compute-size"]), - computeSizeExplicit: hasArgvFlag(options.rawArgs, "--compute-size"), + args['compute-size'] === undefined + ? undefined + : asCliArgString(args['compute-size']), + computeSizeExplicit: hasArgvFlag(options.rawArgs, '--compute-size'), }); return { format, displayOptions: parseDisplayOptions(args), pagerOptions: parsePagerOptions(args, options.agent), - outputPath: optionalTrimmedString(args, "output"), + outputPath: optionalTrimmedString(args, 'output'), computeSize, - dialect: optionalTrimmedString(args, "dialect"), - catalog: optionalTrimmedString(args, "catalog"), - schema: optionalTrimmedString(args, "schema"), + dialect: optionalTrimmedString(args, 'dialect'), + catalog: optionalTrimmedString(args, 'catalog'), + schema: optionalTrimmedString(args, 'schema'), }; } diff --git a/cli/src/lib/query-output.ts b/cli/src/lib/query-output.ts index 711a3fa1..950a5f2a 100644 --- a/cli/src/lib/query-output.ts +++ b/cli/src/lib/query-output.ts @@ -1,5 +1,5 @@ -import type { OutputSink } from "@/lib/runtime.ts"; -import { CliError } from "@/lib/errors.ts"; +import type { OutputSink } from '@/lib/runtime.ts'; +import { CliError } from '@/lib/errors.ts'; import { defaultDisplayOptions, formatQueryCellRaw, @@ -7,33 +7,43 @@ import { renderQueryHumanOutput, renderQueryMarkdown, type QueryDisplayOptions, -} from "@/lib/query-format.ts"; -import { resolvePagerOptions, writePagedOutput, type PagerOptions } from "@/lib/pager.ts"; -import { writeQueryDestination } from "@/lib/query-destination.ts"; -import type { LakehouseApiQueryFormat } from "@/lib/lakehouse/query.ts"; +} from '@/lib/query-format.ts'; +import { + resolvePagerOptions, + writePagedOutput, + type PagerOptions, +} from '@/lib/pager.ts'; +import { writeQueryDestination } from '@/lib/query-destination.ts'; +import type { LakehouseApiQueryFormat } from '@/lib/lakehouse/query.ts'; export type { LakehouseApiQueryFormat }; -export type QueryResultFormat = "human" | "json" | LakehouseApiQueryFormat | "markdown"; +export type QueryResultFormat = + | 'human' + | 'json' + | LakehouseApiQueryFormat + | 'markdown'; -const API_NATIVE_QUERY_FORMATS = new Set(["csv", "jsonl", "parquet"]); +const API_NATIVE_QUERY_FORMATS = new Set(['csv', 'jsonl', 'parquet']); const QUERY_RESULT_FORMATS = new Set([ - "human", - "json", - "csv", - "jsonl", - "parquet", - "markdown", + 'human', + 'json', + 'csv', + 'jsonl', + 'parquet', + 'markdown', ]); export function isApiNativeQueryFormat( - format: QueryResultFormat, + format: QueryResultFormat ): format is LakehouseApiQueryFormat { return API_NATIVE_QUERY_FORMATS.has(format); } export function parseQueryResultFormat(format: string): QueryResultFormat { if (!QUERY_RESULT_FORMATS.has(format as QueryResultFormat)) { - throw new CliError(`Unsupported format: ${format}. Use csv, jsonl, parquet, or markdown.`); + throw new CliError( + `Unsupported format: ${format}. Use csv, jsonl, parquet, or markdown.` + ); } return format as QueryResultFormat; } @@ -47,86 +57,108 @@ export function csvEscapeCell(value: unknown): string { } export function renderQueryCsv( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, + result: import('./lakehouse-ndjson.ts').LakehouseQueryResult ): string { const columnNames = getQueryColumnNames(result); const lines: string[] = []; if (columnNames.length > 0) { - lines.push(columnNames.map(csvEscapeCell).join(",")); + lines.push(columnNames.map(csvEscapeCell).join(',')); } for (const row of result.rows) { if (Array.isArray(row)) { - lines.push(row.map(csvEscapeCell).join(",")); + lines.push(row.map(csvEscapeCell).join(',')); } else { - lines.push(columnNames.map((name) => csvEscapeCell(row[name])).join(",")); + lines.push(columnNames.map(name => csvEscapeCell(row[name])).join(',')); } } - return lines.join("\n"); + return lines.join('\n'); } export function renderQueryJson( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, + result: import('./lakehouse-ndjson.ts').LakehouseQueryResult ): string { return JSON.stringify(result, null, 2); } export function renderQueryOutputText( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, + result: import('./lakehouse-ndjson.ts').LakehouseQueryResult, format: QueryResultFormat, - displayOptions?: QueryDisplayOptions, + displayOptions?: QueryDisplayOptions ): string { - if (format === "json") { + if (format === 'json') { return renderQueryJson(result); } - if (format === "csv") { + if (format === 'csv') { return renderQueryCsv(result); } - if (format === "markdown") { + if (format === 'markdown') { const columnNames = getQueryColumnNames(result); - return renderQueryMarkdown(result, columnNames, displayOptions ?? defaultDisplayOptions()); + return renderQueryMarkdown( + result, + columnNames, + displayOptions ?? defaultDisplayOptions() + ); } if (isApiNativeQueryFormat(format)) { - throw new CliError(`Format ${format} must be streamed from the lakehouse API.`); + throw new CliError( + `Format ${format} must be streamed from the lakehouse API.` + ); } - return renderQueryHumanOutput(result, displayOptions ?? defaultDisplayOptions()); + return renderQueryHumanOutput( + result, + displayOptions ?? defaultDisplayOptions() + ); } export async function writeQueryOutput( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, + result: import('./lakehouse-ndjson.ts').LakehouseQueryResult, format: QueryResultFormat, sink: OutputSink, displayOptions?: QueryDisplayOptions, pagerOptions?: PagerOptions, - outputPath?: string, + outputPath?: string ): Promise { - const effectiveFormat = sink.json ? "json" : format; + const effectiveFormat = sink.json ? 'json' : format; if (isApiNativeQueryFormat(effectiveFormat)) { - throw new CliError(`Format ${effectiveFormat} must be streamed from the lakehouse API.`); + throw new CliError( + `Format ${effectiveFormat} must be streamed from the lakehouse API.` + ); } - const outputText = renderQueryOutputText(result, effectiveFormat, displayOptions); - const usePager = format === "human" && !sink.json && outputPath === undefined; + const outputText = renderQueryOutputText( + result, + effectiveFormat, + displayOptions + ); + const usePager = format === 'human' && !sink.json && outputPath === undefined; if (usePager) { - await writePagedOutput(outputText, pagerOptions ?? resolvePagerOptions(), sink); + await writePagedOutput( + outputText, + pagerOptions ?? resolvePagerOptions(), + sink + ); return; } if (outputPath !== undefined) { - if (effectiveFormat === "json") { - await writeQueryDestination(JSON.stringify(JSON.parse(outputText), null, 2), { - outputPath, - }); + if (effectiveFormat === 'json') { + await writeQueryDestination( + JSON.stringify(JSON.parse(outputText), null, 2), + { + outputPath, + } + ); return; } await writeQueryDestination(outputText, { outputPath }); return; } - if (effectiveFormat === "json" || sink.json) { + if (effectiveFormat === 'json' || sink.json) { sink.writeJson(JSON.parse(outputText)); return; } diff --git a/cli/src/lib/redact.test.ts b/cli/src/lib/redact.test.ts index ce6814f2..90ae9cd1 100644 --- a/cli/src/lib/redact.test.ts +++ b/cli/src/lib/redact.test.ts @@ -1,51 +1,55 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test'; import { MASKED_PASSWORD_PLACEHOLDER, redactPasswordFieldInText, redactSensitiveJsonText, -} from "@/lib/redact.ts"; +} from '@/lib/redact.ts'; -describe("redactPasswordFieldInText", () => { - test("redacts password:value without a space after the colon", () => { - expect(redactPasswordFieldInText("requested_by=password:secret123")).toBe( - `requested_by=password: ${MASKED_PASSWORD_PLACEHOLDER}`, +describe('redactPasswordFieldInText', () => { + test('redacts password:value without a space after the colon', () => { + expect(redactPasswordFieldInText('requested_by=password:secret123')).toBe( + `requested_by=password: ${MASKED_PASSWORD_PLACEHOLDER}` ); }); - test("redacts password: value with whitespace after the colon", () => { - expect(redactPasswordFieldInText("field password: s3cr3t trailing")).toBe( - `field password: ${MASKED_PASSWORD_PLACEHOLDER} trailing`, + test('redacts password: value with whitespace after the colon', () => { + expect(redactPasswordFieldInText('field password: s3cr3t trailing')).toBe( + `field password: ${MASKED_PASSWORD_PLACEHOLDER} trailing` ); }); - test("redacts case-insensitive Password labels", () => { - expect(redactPasswordFieldInText("Password:abc")).toBe( - `Password: ${MASKED_PASSWORD_PLACEHOLDER}`, + test('redacts case-insensitive Password labels', () => { + expect(redactPasswordFieldInText('Password:abc')).toBe( + `Password: ${MASKED_PASSWORD_PLACEHOLDER}` ); }); }); -describe("redactSensitiveJsonText", () => { +describe('redactSensitiveJsonText', () => { const cases = [ { - name: "nested objects inside arrays", + name: 'nested objects inside arrays', input: '[{"token":"secret","id":9007199254740993},{"safe":"value"}]', - expected: '[{"token":"[REDACTED]","id":9007199254740993},{"safe":"value"}]', + expected: + '[{"token":"[REDACTED]","id":9007199254740993},{"safe":"value"}]', }, { - name: "escaped sensitive keys", + name: 'escaped sensitive keys', input: '{"pass\\u0077ord":"secret","id":9007199254740993}', expected: '{"pass\\u0077ord":"[REDACTED]","id":9007199254740993}', }, { - name: "structured sensitive values", - input: '{"client_secret":{"nested":[1,2]},"refresh_token":["a","b"],"safe":{"x":1}}', - expected: '{"client_secret":"[REDACTED]","refresh_token":"[REDACTED]","safe":{"x":1}}', + name: 'structured sensitive values', + input: + '{"client_secret":{"nested":[1,2]},"refresh_token":["a","b"],"safe":{"x":1}}', + expected: + '{"client_secret":"[REDACTED]","refresh_token":"[REDACTED]","safe":{"x":1}}', }, { - name: "surrounding and structural whitespace", + name: 'surrounding and structural whitespace', input: ' \n { "password" : "secret" , "id" : 9007199254740993 } \t', - expected: ' \n { "password" : "[REDACTED]" , "id" : 9007199254740993 } \t', + expected: + ' \n { "password" : "[REDACTED]" , "id" : 9007199254740993 } \t', }, ] as const; @@ -55,7 +59,7 @@ describe("redactSensitiveJsonText", () => { }); } - test("rejects malformed JSON", () => { + test('rejects malformed JSON', () => { expect(() => redactSensitiveJsonText('{"password":"secret"')).toThrow(); }); }); diff --git a/cli/src/lib/redact.ts b/cli/src/lib/redact.ts index bdc5f8b6..4d2a69de 100644 --- a/cli/src/lib/redact.ts +++ b/cli/src/lib/redact.ts @@ -1,13 +1,18 @@ // Substring patterns — a key is sensitive if it *contains* any of these (so // "access_token"/"refresh_token"/"client_secret" are all covered, not just exact "token"). -export const SENSITIVE_JSON_KEY_PATTERNS = new Set(["password", "secret", "token", "api_key"]); -export const MASKED_PASSWORD_PLACEHOLDER = "[MASKED]"; +export const SENSITIVE_JSON_KEY_PATTERNS = new Set([ + 'password', + 'secret', + 'token', + 'api_key', +]); +export const MASKED_PASSWORD_PLACEHOLDER = '[MASKED]'; const PASSWORD_FIELD_PATTERN = /password\s*:\s*\S+/gi; /** Replace password values in credential strings (e.g. requested_by) with a fixed placeholder. */ export function redactPasswordFieldInText(text: string): string { - return text.replace(PASSWORD_FIELD_PATTERN, (match) => { + return text.replace(PASSWORD_FIELD_PATTERN, match => { const separatorIndex = match.search(/:/); const label = match.slice(0, separatorIndex + 1); return `${label} ${MASKED_PASSWORD_PLACEHOLDER}`; @@ -15,7 +20,7 @@ export function redactPasswordFieldInText(text: string): string { } const BODY_MAX_LENGTH = 512; -const BODY_ELLIPSIS = "…"; +const BODY_ELLIPSIS = '…'; function isSensitiveJsonKey(key: string): boolean { const lower = key.toLowerCase(); @@ -31,10 +36,12 @@ export function redactSensitiveJsonValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(redactSensitiveJsonValue); } - if (typeof value === "object" && value !== null) { + if (typeof value === 'object' && value !== null) { const redacted: Record = {}; for (const [key, entryValue] of Object.entries(value)) { - redacted[key] = isSensitiveJsonKey(key) ? "[REDACTED]" : redactSensitiveJsonValue(entryValue); + redacted[key] = isSensitiveJsonKey(key) + ? '[REDACTED]' + : redactSensitiveJsonValue(entryValue); } return redacted; } @@ -54,7 +61,7 @@ class SourcePreservingJsonRedactor { private takeWhitespace(): string { const start = this.position; - while (/\s/.test(this.source[this.position] ?? "")) this.position += 1; + while (/\s/.test(this.source[this.position] ?? '')) this.position += 1; return this.source.slice(start, this.position); } @@ -63,8 +70,8 @@ class SourcePreservingJsonRedactor { this.position = this.findValueEnd(this.position); return '"[REDACTED]"'; } - if (this.source[this.position] === "{") return this.redactObject(); - if (this.source[this.position] === "[") return this.redactArray(); + if (this.source[this.position] === '{') return this.redactObject(); + if (this.source[this.position] === '[') return this.redactArray(); const end = this.findValueEnd(this.position); const value = this.source.slice(this.position, end); @@ -73,11 +80,11 @@ class SourcePreservingJsonRedactor { } private redactObject(): string { - let output = "{"; + let output = '{'; this.position += 1; output += this.takeWhitespace(); - while (this.source[this.position] !== "}") { + while (this.source[this.position] !== '}') { const keyStart = this.position; const keyEnd = this.findStringEnd(keyStart); const rawKey = this.source.slice(keyStart, keyEnd); @@ -86,14 +93,14 @@ class SourcePreservingJsonRedactor { output += rawKey; this.position = keyEnd; output += this.takeWhitespace(); - output += this.source[this.position] ?? ""; + output += this.source[this.position] ?? ''; this.position += 1; output += this.takeWhitespace(); output += this.redactValue(isSensitiveJsonKey(key)); output += this.takeWhitespace(); - if (this.source[this.position] === ",") { - output += ","; + if (this.source[this.position] === ',') { + output += ','; this.position += 1; output += this.takeWhitespace(); } @@ -104,15 +111,15 @@ class SourcePreservingJsonRedactor { } private redactArray(): string { - let output = "["; + let output = '['; this.position += 1; output += this.takeWhitespace(); - while (this.source[this.position] !== "]") { + while (this.source[this.position] !== ']') { output += this.redactValue(false); output += this.takeWhitespace(); - if (this.source[this.position] === ",") { - output += ","; + if (this.source[this.position] === ',') { + output += ','; this.position += 1; output += this.takeWhitespace(); } @@ -125,7 +132,7 @@ class SourcePreservingJsonRedactor { private findStringEnd(start: number): number { let position = start + 1; while (position < this.source.length) { - if (this.source[position] === "\\") { + if (this.source[position] === '\\') { position += 2; continue; } @@ -138,15 +145,18 @@ class SourcePreservingJsonRedactor { private findValueEnd(start: number): number { const firstCharacter = this.source[start]; if (firstCharacter === '"') return this.findStringEnd(start); - if (firstCharacter !== "{" && firstCharacter !== "[") { + if (firstCharacter !== '{' && firstCharacter !== '[') { let position = start; - while (position < this.source.length && !/[\s,}\]]/.test(this.source[position] ?? "")) { + while ( + position < this.source.length && + !/[\s,}\]]/.test(this.source[position] ?? '') + ) { position += 1; } return position; } - const closingCharacters = [firstCharacter === "{" ? "}" : "]"]; + const closingCharacters = [firstCharacter === '{' ? '}' : ']']; let position = start + 1; while (position < this.source.length && closingCharacters.length > 0) { const character = this.source[position]; @@ -154,8 +164,8 @@ class SourcePreservingJsonRedactor { position = this.findStringEnd(position); continue; } - if (character === "{") closingCharacters.push("}"); - if (character === "[") closingCharacters.push("]"); + if (character === '{') closingCharacters.push('}'); + if (character === '[') closingCharacters.push(']'); if (character === closingCharacters.at(-1)) closingCharacters.pop(); position += 1; } @@ -169,7 +179,10 @@ export function redactSensitiveJsonText(source: string): string { return new SourcePreservingJsonRedactor(source).redact(); } -export function truncateBodySnippet(body: string, maxLength = BODY_MAX_LENGTH): string { +export function truncateBodySnippet( + body: string, + maxLength = BODY_MAX_LENGTH +): string { if (body.length <= maxLength) { return body; } @@ -178,7 +191,7 @@ export function truncateBodySnippet(body: string, maxLength = BODY_MAX_LENGTH): export function redactSensitiveJsonString(body: string): string { const trimmed = body.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { const parsed = JSON.parse(body) as unknown; return JSON.stringify(redactSensitiveJsonValue(parsed)); @@ -191,7 +204,7 @@ export function redactSensitiveJsonString(body: string): string { export function redactResponseBodyForDebug(body: string): string { const trimmed = body.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { const parsed = JSON.parse(body) as unknown; return JSON.stringify(redactSensitiveJsonValue(parsed), null, 2); diff --git a/cli/src/lib/relative-time.test.ts b/cli/src/lib/relative-time.test.ts index 299ad8ed..2ed181f9 100644 --- a/cli/src/lib/relative-time.test.ts +++ b/cli/src/lib/relative-time.test.ts @@ -1,48 +1,63 @@ -import { describe, expect, test } from "bun:test"; -import { formatRelativeTimestamp, formatTimestampWithRelative } from "@/lib/relative-time.ts"; +import { describe, expect, test } from 'bun:test'; +import { + formatRelativeTimestamp, + formatTimestampWithRelative, +} from '@/lib/relative-time.ts'; -const NOW_MS = Date.parse("2026-06-27T12:00:00.000Z"); +const NOW_MS = Date.parse('2026-06-27T12:00:00.000Z'); -describe("formatRelativeTimestamp", () => { - test("returns null for invalid timestamps", () => { - expect(formatRelativeTimestamp("not-a-date", NOW_MS)).toBeNull(); +describe('formatRelativeTimestamp', () => { + test('returns null for invalid timestamps', () => { + expect(formatRelativeTimestamp('not-a-date', NOW_MS)).toBeNull(); }); - test("returns null outside the 30-day window", () => { - expect(formatRelativeTimestamp("2025-01-01T00:00:00.000Z", NOW_MS)).toBeNull(); + test('returns null outside the 30-day window', () => { + expect( + formatRelativeTimestamp('2025-01-01T00:00:00.000Z', NOW_MS) + ).toBeNull(); }); - test("formats recent past timestamps", () => { - expect(formatRelativeTimestamp("2026-06-27T11:58:00.000Z", NOW_MS)).toBe("2 minutes ago"); - expect(formatRelativeTimestamp("2026-06-27T11:59:50.000Z", NOW_MS)).toBe("just now"); - expect(formatRelativeTimestamp("2026-06-21T06:35:24.409Z", NOW_MS)).toBe("6 days ago"); + test('formats recent past timestamps', () => { + expect(formatRelativeTimestamp('2026-06-27T11:58:00.000Z', NOW_MS)).toBe( + '2 minutes ago' + ); + expect(formatRelativeTimestamp('2026-06-27T11:59:50.000Z', NOW_MS)).toBe( + 'just now' + ); + expect(formatRelativeTimestamp('2026-06-21T06:35:24.409Z', NOW_MS)).toBe( + '6 days ago' + ); }); - test("formats near-future timestamps", () => { - expect(formatRelativeTimestamp("2026-06-28T12:00:00.000Z", NOW_MS)).toBe("in 1 day"); - expect(formatRelativeTimestamp("2026-06-27T12:05:00.000Z", NOW_MS)).toBe("in 5 minutes"); + test('formats near-future timestamps', () => { + expect(formatRelativeTimestamp('2026-06-28T12:00:00.000Z', NOW_MS)).toBe( + 'in 1 day' + ); + expect(formatRelativeTimestamp('2026-06-27T12:05:00.000Z', NOW_MS)).toBe( + 'in 5 minutes' + ); }); }); -describe("formatTimestampWithRelative", () => { - test("keeps absolute form and appends relative when relevant", () => { - expect(formatTimestampWithRelative("2026-06-21T06:35:24.409Z", { nowMs: NOW_MS })).toBe( - "2026-06-21T06:35:24.409Z 6 days ago", - ); +describe('formatTimestampWithRelative', () => { + test('keeps absolute form and appends relative when relevant', () => { + expect( + formatTimestampWithRelative('2026-06-21T06:35:24.409Z', { nowMs: NOW_MS }) + ).toBe('2026-06-21T06:35:24.409Z 6 days ago'); }); - test("returns absolute only when relative is not relevant", () => { - expect(formatTimestampWithRelative("2025-01-01T00:00:00.000Z", { nowMs: NOW_MS })).toBe( - "2025-01-01T00:00:00.000Z", - ); + test('returns absolute only when relative is not relevant', () => { + expect( + formatTimestampWithRelative('2025-01-01T00:00:00.000Z', { nowMs: NOW_MS }) + ).toBe('2025-01-01T00:00:00.000Z'); }); - test("returns absolute only when includeRelative is false", () => { + test('returns absolute only when includeRelative is false', () => { expect( - formatTimestampWithRelative("2026-06-21T06:35:24.409Z", { + formatTimestampWithRelative('2026-06-21T06:35:24.409Z', { nowMs: NOW_MS, includeRelative: false, - }), - ).toBe("2026-06-21T06:35:24.409Z"); + }) + ).toBe('2026-06-21T06:35:24.409Z'); }); }); diff --git a/cli/src/lib/relative-time.ts b/cli/src/lib/relative-time.ts index 689312e7..85cc0c80 100644 --- a/cli/src/lib/relative-time.ts +++ b/cli/src/lib/relative-time.ts @@ -1,4 +1,4 @@ -import { pluralize } from "@/lib/pluralize.ts"; +import { pluralize } from '@/lib/pluralize.ts'; const SECOND_MS = 1_000; const MINUTE_MS = 60 * SECOND_MS; @@ -8,7 +8,7 @@ const RELATIVE_TIMESTAMP_WINDOW_MS = 30 * DAY_MS; export function formatRelativeTimestamp( isoTimestamp: string, - nowMs: number = Date.now(), + nowMs: number = Date.now() ): string | null { const parsedMs = Date.parse(isoTimestamp); if (Number.isNaN(parsedMs)) { @@ -24,33 +24,35 @@ export function formatRelativeTimestamp( const isFuture = diffMs < 0; if (absDiffMs < 45 * SECOND_MS) { - return isFuture ? "in a moment" : "just now"; + return isFuture ? 'in a moment' : 'just now'; } const minutes = Math.round(absDiffMs / MINUTE_MS); if (absDiffMs < 45 * MINUTE_MS) { - const label = pluralize(minutes, "minute"); + const label = pluralize(minutes, 'minute'); return isFuture ? `in ${minutes} ${label}` : `${minutes} ${label} ago`; } const hours = Math.round(absDiffMs / HOUR_MS); if (absDiffMs < 22 * HOUR_MS) { - const label = pluralize(hours, "hour"); + const label = pluralize(hours, 'hour'); return isFuture ? `in ${hours} ${label}` : `${hours} ${label} ago`; } const days = Math.round(absDiffMs / DAY_MS); - const label = pluralize(days, "day"); + const label = pluralize(days, 'day'); return isFuture ? `in ${days} ${label}` : `${days} ${label} ago`; } export function formatTimestampWithRelative( isoTimestamp: string, - options: { nowMs?: number; includeRelative?: boolean } = {}, + options: { nowMs?: number; includeRelative?: boolean } = {} ): string { const includeRelative = options.includeRelative ?? true; const relative = - includeRelative === true ? formatRelativeTimestamp(isoTimestamp, options.nowMs) : null; + includeRelative === true + ? formatRelativeTimestamp(isoTimestamp, options.nowMs) + : null; if (relative === null) { return isoTimestamp; } diff --git a/cli/src/lib/runtime.ts b/cli/src/lib/runtime.ts index 4a1dafb1..bde43926 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,9 +1,13 @@ -import { existsSync } from "node:fs"; -import { getBootstrapCliContextState, isJsonOutput, type CliContext } from "@/lib/cli-context.ts"; -import { createCliSession, type CliSession } from "@/lib/cli-session.ts"; -import { configFile } from "@/lib/config.ts"; -import { profilesDir } from "@/lib/profile-store.ts"; -import { applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; +import { existsSync } from 'node:fs'; +import { + getBootstrapCliContextState, + isJsonOutput, + type CliContext, +} from '@/lib/cli-context.ts'; +import { createCliSession, type CliSession } from '@/lib/cli-session.ts'; +import { configFile } from '@/lib/config.ts'; +import { profilesDir } from '@/lib/profile-store.ts'; +import { applyTerminalColorFromContext } from '@/ui/terminal/styles.ts'; export type OutputSink = { json: boolean; diff --git a/cli/src/lib/secrets.test.ts b/cli/src/lib/secrets.test.ts index 12b57b7c..814d31e0 100644 --- a/cli/src/lib/secrets.test.ts +++ b/cli/src/lib/secrets.test.ts @@ -1,18 +1,23 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { createFakeKeychain } from "@/test-utils/keychain.ts"; -import { getProfileId } from "@/lib/profile-store.ts"; -import { createSecretStore, secretExists, secretGet, secretSet } from "@/lib/secrets.ts"; -import { credentialsFile } from "@/lib/config.ts"; - -let testHome = ""; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createFakeKeychain } from '@/test-utils/keychain.ts'; +import { getProfileId } from '@/lib/profile-store.ts'; +import { + createSecretStore, + secretExists, + secretGet, + secretSet, +} from '@/lib/secrets.ts'; +import { credentialsFile } from '@/lib/config.ts'; + +let testHome = ''; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-secrets-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-secrets-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + process.env.ALTERTABLE_SECRET_BACKEND = 'keychain'; }); afterEach(() => { @@ -21,78 +26,90 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); -describe("secretSet", () => { - test("stores and reads secrets from the file backend", () => { - process.env.ALTERTABLE_SECRET_BACKEND = "file"; +describe('secretSet', () => { + test('stores and reads secrets from the file backend', () => { + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; - secretSet("api-key", "atm_test", "default"); + secretSet('api-key', 'atm_test', 'default'); - expect(secretGet("api-key", "default")).toBe("atm_test"); - expect(secretExists("api-key", "default")).toBe(true); + expect(secretGet('api-key', 'default')).toBe('atm_test'); + expect(secretExists('api-key', 'default')).toBe(true); }); - test("argv secrets use the protected file instead of Keychain process arguments", () => { + test('argv secrets use the protected file instead of Keychain process arguments', () => { const keychain = createFakeKeychain(); - keychain.store.secretSet("lakehouse/password", "secret", "default", { + keychain.store.secretSet('lakehouse/password', 'secret', 'default', { fromArgv: true, }); - expect(keychain.calls.some((args) => args.includes("add-generic-password"))).toBe(false); - expect(keychain.store.secretGet("lakehouse/password", "default")).toBe("secret"); + expect( + keychain.calls.some(args => args.includes('add-generic-password')) + ).toBe(false); + expect(keychain.store.secretGet('lakehouse/password', 'default')).toBe( + 'secret' + ); }); - test("distinguishes a Keychain failure from a missing secret", () => { + test('distinguishes a Keychain failure from a missing secret', () => { const keychain = createFakeKeychain(); - keychain.failingReads.add("profile/default/api-key"); + keychain.failingReads.add('profile/default/api-key'); - expect(() => keychain.store.secretExists("api-key", "default")).toThrow( - "Failed to inspect secret in macOS keychain", + expect(() => keychain.store.secretExists('api-key', 'default')).toThrow( + 'Failed to inspect secret in macOS keychain' ); - expect(() => keychain.store.secretGet("api-key", "default")).toThrow( - "Failed to read secret from macOS keychain", + expect(() => keychain.store.secretGet('api-key', 'default')).toThrow( + 'Failed to read secret from macOS keychain' ); }); }); -describe("secretDelete", () => { - test("fails closed when Keychain deletion fails", () => { +describe('secretDelete', () => { + test('fails closed when Keychain deletion fails', () => { const keychain = createFakeKeychain(); - keychain.store.secretSet("api-key", "atm_test", "default"); - const account = `profile/${getProfileId("default")}/api-key`; + keychain.store.secretSet('api-key', 'atm_test', 'default'); + const account = `profile/${getProfileId('default')}/api-key`; keychain.failingDeletes.add(account); - expect(() => keychain.store.secretDelete("api-key", "default")).toThrow( - "Failed to delete secret from macOS keychain", + expect(() => keychain.store.secretDelete('api-key', 'default')).toThrow( + 'Failed to delete secret from macOS keychain' ); - expect(keychain.store.secretGet("api-key", "default")).toBe("atm_test"); + expect(keychain.store.secretGet('api-key', 'default')).toBe('atm_test'); }); - test("accepts an already absent Keychain item", () => { + test('accepts an already absent Keychain item', () => { const keychain = createFakeKeychain(); - expect(() => keychain.store.secretDelete("api-key", "default")).not.toThrow(); + expect(() => + keychain.store.secretDelete('api-key', 'default') + ).not.toThrow(); }); }); -for (const platform of ["linux", "win32"] as const) { +for (const platform of ['linux', 'win32'] as const) { test(`file credentials use platform-appropriate mode checks on ${platform}`, () => { - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; const store = createSecretStore({ platform, spawnSync() { - throw new Error("File credentials must not invoke a system keychain command."); + throw new Error( + 'File credentials must not invoke a system keychain command.' + ); }, }); - store.secretSet("api-key", "atm_file_test", "default"); + store.secretSet('api-key', 'atm_file_test', 'default'); // Windows reports mode bits without Unix owner/group/other distinctions. chmodSync(credentialsFile(), 0o666); - if (platform === "win32") { - expect(store.secretGet("api-key", "default")).toBe("atm_file_test"); - expect(store.secretExists("api-key", "default")).toBe(true); + if (platform === 'win32') { + expect(store.secretGet('api-key', 'default')).toBe('atm_file_test'); + expect(store.secretExists('api-key', 'default')).toBe(true); } else { - expect(() => store.secretGet("api-key", "default")).toThrow("permissions"); - expect(() => store.secretExists("api-key", "default")).toThrow("permissions"); + expect(() => store.secretGet('api-key', 'default')).toThrow( + 'permissions' + ); + expect(() => store.secretExists('api-key', 'default')).toThrow( + 'permissions' + ); } }); } diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 02363b85..2be74040 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,18 +1,18 @@ -import type { SpawnSyncOptions } from "node:child_process"; -import { spawnSync } from "node:child_process"; -import { chmodSync, statSync } from "node:fs"; -import { credentialsFile, kvGet, kvSet, kvUnset } from "@/lib/config.ts"; -import { CliError } from "@/lib/errors.ts"; -import { logWarn } from "@/lib/log.ts"; -import { readEnv } from "@/lib/env.ts"; +import type { SpawnSyncOptions } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, statSync } from 'node:fs'; +import { credentialsFile, kvGet, kvSet, kvUnset } from '@/lib/config.ts'; +import { CliError } from '@/lib/errors.ts'; +import { logWarn } from '@/lib/log.ts'; +import { readEnv } from '@/lib/env.ts'; import { assertSafeProfileName, ensureProfileId, getProfileId, isFromEnvProfile, -} from "@/lib/profile-store.ts"; +} from '@/lib/profile-store.ts'; -type SecretBackend = "macos" | "file"; +type SecretBackend = 'macos' | 'file'; // `security` returns errSecItemNotFound (-25300) modulo 256 as its process status. const KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44; @@ -24,12 +24,18 @@ type SecretProcessResult = { }; function keychainLookupFailed(result: SecretProcessResult): boolean { - return result.status !== 0 && result.status !== KEYCHAIN_ITEM_NOT_FOUND_STATUS; + return ( + result.status !== 0 && result.status !== KEYCHAIN_ITEM_NOT_FOUND_STATUS + ); } type SecretProcess = { platform: NodeJS.Platform; - spawnSync(command: string, args: string[], options: SpawnSyncOptions): SecretProcessResult; + spawnSync( + command: string, + args: string[], + options: SpawnSyncOptions + ): SecretProcessResult; }; export type SecretSetOptions = { @@ -37,7 +43,12 @@ export type SecretSetOptions = { }; export type SecretStore = { - secretSet(account: string, value: string, profileName: string, options?: SecretSetOptions): void; + secretSet( + account: string, + value: string, + profileName: string, + options?: SecretSetOptions + ): void; secretGet(key: string, profileName: string): string; secretExists(key: string, profileName: string): boolean; secretDelete(key: string, profileName: string): void; @@ -57,13 +68,13 @@ function fileMode(filePath: string): string { const stat = statSync(filePath); return (stat.mode & 0o777).toString(8); } catch { - return ""; + return ''; } } function requireSafeCredentialsFile(platform: NodeJS.Platform): void { // Windows mode bits do not describe the file's ACL permissions. - if (platform === "win32") return; + if (platform === 'win32') return; const filePath = credentialsFile(); try { statSync(filePath); @@ -77,7 +88,7 @@ function requireSafeCredentialsFile(platform: NodeJS.Platform): void { const modeNumber = Number.parseInt(mode, 8); if (modeNumber & 0o077) { throw new CliError( - `Refusing to read ${filePath}: permissions ${mode} are too open (group/other access). Run: chmod 600 ${filePath}`, + `Refusing to read ${filePath}: permissions ${mode} are too open (group/other access). Run: chmod 600 ${filePath}` ); } } @@ -92,91 +103,125 @@ function stableSecretKey(key: string, profileId: string): string { } export function createSecretStore( - secretProcess: SecretProcess = SYSTEM_SECRET_PROCESS, + secretProcess: SecretProcess = SYSTEM_SECRET_PROCESS ): SecretStore { let fileBackendWarned = false; function warnFileBackendOnce(): void { if (fileBackendWarned) return; const protection = - secretProcess.platform === "win32" ? "Windows ACLs are not validated" : "chmod 600"; + secretProcess.platform === 'win32' + ? 'Windows ACLs are not validated' + : 'chmod 600'; logWarn( - `No macOS keychain; storing secrets at ${credentialsFile()} (${protection}). Prefer environment variables in CI.`, + `No macOS keychain; storing secrets at ${credentialsFile()} (${protection}). Prefer environment variables in CI.` ); fileBackendWarned = true; } function hasSecurityCommand(): boolean { - const result = secretProcess.spawnSync("security", ["help"], { stdio: "ignore" }); + const result = secretProcess.spawnSync('security', ['help'], { + stdio: 'ignore', + }); return result.status === 0 || result.error === undefined; } function secretBackend(): SecretBackend { - const override = readEnv("ALTERTABLE_SECRET_BACKEND") ?? ""; - if (override === "file") return "file"; - if (override === "keychain") { - if (secretProcess.platform === "darwin" && hasSecurityCommand()) return "macos"; + const override = readEnv('ALTERTABLE_SECRET_BACKEND') ?? ''; + if (override === 'file') return 'file'; + if (override === 'keychain') { + if (secretProcess.platform === 'darwin' && hasSecurityCommand()) + return 'macos'; throw new CliError( - "ALTERTABLE_SECRET_BACKEND=keychain but the macOS keychain is unavailable.", + 'ALTERTABLE_SECRET_BACKEND=keychain but the macOS keychain is unavailable.' ); } - if (secretProcess.platform === "darwin" && hasSecurityCommand()) return "macos"; - return "file"; + if (secretProcess.platform === 'darwin' && hasSecurityCommand()) + return 'macos'; + return 'file'; } function secretSetMacos(storageAccount: string, value: string): void { const result = secretProcess.spawnSync( - "security", - ["add-generic-password", "-U", "-s", "altertable", "-a", storageAccount, "-w", value], - { stdio: "ignore" }, + 'security', + [ + 'add-generic-password', + '-U', + '-s', + 'altertable', + '-a', + storageAccount, + '-w', + value, + ], + { stdio: 'ignore' } ); if (result.status !== 0) { - throw new CliError(`Failed to store secret in macOS keychain (${storageAccount}).`); + throw new CliError( + `Failed to store secret in macOS keychain (${storageAccount}).` + ); } } function readStoredSecret(storageAccount: string): string { - if (secretBackend() === "macos") { + if (secretBackend() === 'macos') { const result = secretProcess.spawnSync( - "security", - ["find-generic-password", "-s", "altertable", "-a", storageAccount, "-w"], - { encoding: "utf8" }, + 'security', + [ + 'find-generic-password', + '-s', + 'altertable', + '-a', + storageAccount, + '-w', + ], + { encoding: 'utf8' } ); if (keychainLookupFailed(result)) { - throw new CliError(`Failed to read secret from macOS keychain (${storageAccount}).`); + throw new CliError( + `Failed to read secret from macOS keychain (${storageAccount}).` + ); } - const keychainValue = result.status === 0 ? String(result.stdout).trim() : ""; - if (keychainValue !== "") return keychainValue; + const keychainValue = + result.status === 0 ? String(result.stdout).trim() : ''; + if (keychainValue !== '') return keychainValue; } requireSafeCredentialsFile(secretProcess.platform); return kvGet(credentialsFile(), storageAccount); } function storedSecretExists(storageAccount: string): boolean { - if (secretBackend() === "macos") { + if (secretBackend() === 'macos') { const result = secretProcess.spawnSync( - "security", - ["find-generic-password", "-s", "altertable", "-a", storageAccount], - { stdio: "ignore" }, + 'security', + ['find-generic-password', '-s', 'altertable', '-a', storageAccount], + { stdio: 'ignore' } ); if (result.status === 0) return true; if (keychainLookupFailed(result)) { - throw new CliError(`Failed to inspect secret in macOS keychain (${storageAccount}).`); + throw new CliError( + `Failed to inspect secret in macOS keychain (${storageAccount}).` + ); } } requireSafeCredentialsFile(secretProcess.platform); - return kvGet(credentialsFile(), storageAccount) !== ""; + return kvGet(credentialsFile(), storageAccount) !== ''; } function deleteStoredSecret(storageAccount: string): void { - if (secretBackend() === "macos") { + if (secretBackend() === 'macos') { const result = secretProcess.spawnSync( - "security", - ["delete-generic-password", "-s", "altertable", "-a", storageAccount], - { stdio: "ignore" }, + 'security', + ['delete-generic-password', '-s', 'altertable', '-a', storageAccount], + { stdio: 'ignore' } ); - if (result.status !== 0 && result.status !== KEYCHAIN_ITEM_NOT_FOUND_STATUS) { - throw new CliError(`Failed to delete secret from macOS keychain (${storageAccount}).`); + if ( + result.status !== 0 && + result.status !== KEYCHAIN_ITEM_NOT_FOUND_STATUS + ) { + throw new CliError( + `Failed to delete secret from macOS keychain (${storageAccount}).` + ); } } kvUnset(credentialsFile(), storageAccount); @@ -186,15 +231,18 @@ export function createSecretStore( account: string, value: string, profileName: string, - options?: SecretSetOptions, + options?: SecretSetOptions ): void { // Env config isolates to `_from_env`, which has no store. Profile-mutating // commands are refused in env mode; in-process caches are deliberately dropped. if (isFromEnvProfile(profileName)) return; - const storageAccount = stableSecretKey(account, ensureProfileId(profileName)); + const storageAccount = stableSecretKey( + account, + ensureProfileId(profileName) + ); const backend = secretBackend(); - if (backend === "macos" && !options?.fromArgv) { + if (backend === 'macos' && !options?.fromArgv) { secretSetMacos(storageAccount, value); return; } @@ -202,16 +250,16 @@ export function createSecretStore( const filePath = credentialsFile(); kvSet(filePath, storageAccount, value); chmodSync(filePath, 0o600); - if (backend !== "macos") warnFileBackendOnce(); + if (backend !== 'macos') warnFileBackendOnce(); } function secretGet(key: string, profileName: string): string { - if (isFromEnvProfile(profileName)) return ""; + if (isFromEnvProfile(profileName)) return ''; const profileId = getProfileId(profileName); if (profileId) { const value = readStoredSecret(stableSecretKey(key, profileId)); - if (value !== "") return value; + if (value !== '') return value; } return readStoredSecret(legacySecretKey(key, profileName)); } @@ -236,17 +284,20 @@ export function createSecretStore( deleteStoredSecret(legacySecretKey(key, profileName)); } - function migrateProfileSecrets(profileName: string, keys: readonly string[]): void { + function migrateProfileSecrets( + profileName: string, + keys: readonly string[] + ): void { const profileId = ensureProfileId(profileName); - const legacySecrets = keys.flatMap((key) => { + const legacySecrets = keys.flatMap(key => { const value = readStoredSecret(legacySecretKey(key, profileName)); - return value === "" ? [] : [{ key, value }]; + return value === '' ? [] : [{ key, value }]; }); for (const { key, value } of legacySecrets) { const storageAccount = stableSecretKey(key, profileId); if (!storedSecretExists(storageAccount)) { - if (secretBackend() === "macos") { + if (secretBackend() === 'macos') { secretSetMacos(storageAccount, value); } else { kvSet(credentialsFile(), storageAccount, value); @@ -260,7 +311,7 @@ export function createSecretStore( } function secretStoreDisplay(): string { - return secretBackend() === "macos" ? "MacOS keychain" : credentialsFile(); + return secretBackend() === 'macos' ? 'MacOS keychain' : credentialsFile(); } return { @@ -279,7 +330,7 @@ export function secretSet( account: string, value: string, profileName: string, - options?: SecretSetOptions, + options?: SecretSetOptions ): void { systemSecretStore.secretSet(account, value, profileName, options); } @@ -296,7 +347,10 @@ export function secretDelete(key: string, profileName: string): void { systemSecretStore.secretDelete(key, profileName); } -export function migrateProfileSecrets(profileName: string, keys: readonly string[]): void { +export function migrateProfileSecrets( + profileName: string, + keys: readonly string[] +): void { systemSecretStore.migrateProfileSecrets(profileName, keys); } diff --git a/cli/src/lib/service-account-provision.test.ts b/cli/src/lib/service-account-provision.test.ts index 880bd2cf..e3bfdec3 100644 --- a/cli/src/lib/service-account-provision.test.ts +++ b/cli/src/lib/service-account-provision.test.ts @@ -1,53 +1,67 @@ -import { describe, expect, test } from "bun:test"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { parseServiceAccountScope } from "@/lib/service-account-provision.ts"; +import { describe, expect, test } from 'bun:test'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { parseServiceAccountScope } from '@/lib/service-account-provision.ts'; -describe("parseServiceAccountScope", () => { - test("parses a bare mode as a blanket scope", () => { - expect(parseServiceAccountScope("ro")).toBe("ro"); - expect(parseServiceAccountScope("rw")).toBe("rw"); - expect(parseServiceAccountScope(" RO ")).toBe("ro"); +describe('parseServiceAccountScope', () => { + test('parses a bare mode as a blanket scope', () => { + expect(parseServiceAccountScope('ro')).toBe('ro'); + expect(parseServiceAccountScope('rw')).toBe('rw'); + expect(parseServiceAccountScope(' RO ')).toBe('ro'); }); - test("parses multiple catalog:mode entries", () => { - expect(parseServiceAccountScope("catalog1:ro,catalog2:rw")).toEqual({ - catalog1: "ro", - catalog2: "rw", + test('parses multiple catalog:mode entries', () => { + expect(parseServiceAccountScope('catalog1:ro,catalog2:rw')).toEqual({ + catalog1: 'ro', + catalog2: 'rw', }); }); - test("parses a single entry", () => { - expect(parseServiceAccountScope("analytics:rw")).toEqual({ analytics: "rw" }); + test('parses a single entry', () => { + expect(parseServiceAccountScope('analytics:rw')).toEqual({ + analytics: 'rw', + }); }); - test("trims whitespace and lowercases the mode", () => { - expect(parseServiceAccountScope(" catalog1 : RO , catalog2: Rw ")).toEqual({ - catalog1: "ro", - catalog2: "rw", + test('trims whitespace and lowercases the mode', () => { + expect(parseServiceAccountScope(' catalog1 : RO , catalog2: Rw ')).toEqual({ + catalog1: 'ro', + catalog2: 'rw', }); }); - test("splits on the last colon so catalog names may contain colons", () => { - expect(parseServiceAccountScope("org:analytics:ro")).toEqual({ "org:analytics": "ro" }); + test('splits on the last colon so catalog names may contain colons', () => { + expect(parseServiceAccountScope('org:analytics:ro')).toEqual({ + 'org:analytics': 'ro', + }); }); - test("rejects a mode other than ro or rw", () => { - expect(() => parseServiceAccountScope("catalog1:read")).toThrow(ConfigurationError); - expect(() => parseServiceAccountScope("catalog1:read")).toThrow("catalog1:read"); + test('rejects a mode other than ro or rw', () => { + expect(() => parseServiceAccountScope('catalog1:read')).toThrow( + ConfigurationError + ); + expect(() => parseServiceAccountScope('catalog1:read')).toThrow( + 'catalog1:read' + ); }); - test("rejects an entry with no colon", () => { - expect(() => parseServiceAccountScope("catalog1")).toThrow(ConfigurationError); - expect(() => parseServiceAccountScope("catalog1")).toThrow("catalog1"); + test('rejects an entry with no colon', () => { + expect(() => parseServiceAccountScope('catalog1')).toThrow( + ConfigurationError + ); + expect(() => parseServiceAccountScope('catalog1')).toThrow('catalog1'); }); - test("rejects an empty catalog name", () => { - expect(() => parseServiceAccountScope(":ro")).toThrow(ConfigurationError); - expect(() => parseServiceAccountScope(":ro")).toThrow(":ro"); + test('rejects an empty catalog name', () => { + expect(() => parseServiceAccountScope(':ro')).toThrow(ConfigurationError); + expect(() => parseServiceAccountScope(':ro')).toThrow(':ro'); }); - test("rejects a duplicate catalog", () => { - expect(() => parseServiceAccountScope("catalog1:ro,catalog1:rw")).toThrow(ConfigurationError); - expect(() => parseServiceAccountScope("catalog1:ro,catalog1:rw")).toThrow("catalog1"); + test('rejects a duplicate catalog', () => { + expect(() => parseServiceAccountScope('catalog1:ro,catalog1:rw')).toThrow( + ConfigurationError + ); + expect(() => parseServiceAccountScope('catalog1:ro,catalog1:rw')).toThrow( + 'catalog1' + ); }); }); diff --git a/cli/src/lib/service-account-provision.ts b/cli/src/lib/service-account-provision.ts index d263dae3..37af7918 100644 --- a/cli/src/lib/service-account-provision.ts +++ b/cli/src/lib/service-account-provision.ts @@ -1,22 +1,27 @@ -import type { components } from "@/generated/openapi-types.ts"; -import { assertRetrieved, ConfigurationError, HttpError } from "@/lib/errors.ts"; -import { httpSend } from "@/lib/http.ts"; -import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; - -export type ServiceAccountAccessMode = "ro" | "rw"; +import type { components } from '@/generated/openapi-types.ts'; +import { + assertRetrieved, + ConfigurationError, + HttpError, +} from '@/lib/errors.ts'; +import { httpSend } from '@/lib/http.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; + +export type ServiceAccountAccessMode = 'ro' | 'rw'; // A bare mode applies to every catalog; the map form restricts per catalog. export type ServiceAccountScope = | ServiceAccountAccessMode | Record; -export type ServiceAccountRoleAssignment = components["schemas"]["RoleAssignment"]; +export type ServiceAccountRoleAssignment = + components['schemas']['RoleAssignment']; // The spec leaves `service_oauth_token` optional because the endpoint only issues // one when asked; this module always asks and rejects a response without it. export type EnvironmentServiceAccountResponse = - components["schemas"]["EnvironmentServiceAccountResponse"] & { + components['schemas']['EnvironmentServiceAccountResponse'] & { service_oauth_token: string; }; @@ -40,17 +45,17 @@ export type EnvironmentCatalog = { name: string; slug: string }; export function parseServiceAccountScope(value: string): ServiceAccountScope { const bareMode = value.trim().toLowerCase(); - if (bareMode === "ro" || bareMode === "rw") { + if (bareMode === 'ro' || bareMode === 'rw') { return bareMode; } const scope: Record = {}; - for (const rawEntry of value.split(",")) { + for (const rawEntry of value.split(',')) { const entry = rawEntry.trim(); - const separatorIndex = entry.lastIndexOf(":"); + const separatorIndex = entry.lastIndexOf(':'); if (separatorIndex === -1) { throw new ConfigurationError( - `Invalid --scope entry "${entry}": expected ro, rw, catalog:ro or catalog:rw.`, + `Invalid --scope entry "${entry}": expected ro, rw, catalog:ro or catalog:rw.` ); } @@ -60,14 +65,18 @@ export function parseServiceAccountScope(value: string): ServiceAccountScope { .trim() .toLowerCase(); if (!catalog) { - throw new ConfigurationError(`Invalid --scope entry "${entry}": catalog name is required.`); + throw new ConfigurationError( + `Invalid --scope entry "${entry}": catalog name is required.` + ); } - if (mode !== "ro" && mode !== "rw") { - throw new ConfigurationError(`Invalid --scope entry "${entry}": mode must be ro or rw.`); + if (mode !== 'ro' && mode !== 'rw') { + throw new ConfigurationError( + `Invalid --scope entry "${entry}": mode must be ro or rw.` + ); } if (scope[catalog] !== undefined) { throw new ConfigurationError( - `Invalid --scope entry "${entry}": catalog "${catalog}" is duplicated.`, + `Invalid --scope entry "${entry}": catalog "${catalog}" is duplicated.` ); } scope[catalog] = mode; @@ -99,7 +108,7 @@ function withServiceAccountRequestContext(error: unknown): unknown { } export async function provisionEnvironmentServiceAccount( - options: ProvisionEnvironmentServiceAccountOptions, + options: ProvisionEnvironmentServiceAccountOptions ): Promise { const requestBody: { label: string; @@ -116,41 +125,45 @@ export async function provisionEnvironmentServiceAccount( let response: string; try { response = await httpSend({ - method: "POST", + method: 'POST', url: `${options.managementApiBase}${encodeManagementEndpoint(`/environments/${options.environment}/service_accounts`)}`, authHeader: `Authorization: Bearer ${options.accessToken}`, body: JSON.stringify(requestBody), - contentType: "application/json", - authPlane: "management", + contentType: 'application/json', + authPlane: 'management', }); } catch (error) { throw withServiceAccountRequestContext(error); } const created = parseApiJson( - response, - ) as components["schemas"]["EnvironmentServiceAccountResponse"]; + response + ) as components['schemas']['EnvironmentServiceAccountResponse']; const token = assertRetrieved( created.service_oauth_token, - "login", - "service_account.service_oauth_token", + 'login', + 'service_account.service_oauth_token' + ); + assertRetrieved( + created.service_account?.slug, + 'login', + 'service_account.slug' ); - assertRetrieved(created.service_account?.slug, "login", "service_account.slug"); return { ...created, service_oauth_token: token }; } function collectCatalogs( target: Map, - entries: CatalogIdentity[], + entries: CatalogIdentity[] ): void { for (const entry of entries) { if (!entry.id || (!entry.name && !entry.slug)) { continue; } target.set(entry.id, { - name: entry.name ?? entry.slug ?? "", - slug: entry.slug ?? "", + name: entry.name ?? entry.slug ?? '', + slug: entry.slug ?? '', }); } } @@ -161,18 +174,21 @@ function collectCatalogs( * exists by the time this runs, so a failed lookup must not fail the login. */ export async function fetchEnvironmentCatalogs( - options: EnvironmentCatalogLookupOptions, + options: EnvironmentCatalogLookupOptions ): Promise> { const catalogs = new Map(); - for (const kind of ["databases", "connections"] as const) { + for (const kind of ['databases', 'connections'] as const) { try { const response = await httpSend({ - method: "GET", + method: 'GET', url: `${options.managementApiBase}${encodeManagementEndpoint(`/environments/${options.environment}/${kind}`)}`, authHeader: `Authorization: Bearer ${options.accessToken}`, - authPlane: "management", + authPlane: 'management', }); - const parsed = parseApiJson(response) as Record; + const parsed = parseApiJson(response) as Record< + string, + CatalogIdentity[] | undefined + >; collectCatalogs(catalogs, parsed[kind] ?? []); } catch { continue; diff --git a/cli/src/lib/sleep.ts b/cli/src/lib/sleep.ts index df0d03d8..69879b42 100644 --- a/cli/src/lib/sleep.ts +++ b/cli/src/lib/sleep.ts @@ -1,5 +1,5 @@ export function sleep(milliseconds: number): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { setTimeout(resolve, milliseconds); }); } diff --git a/cli/src/lib/stream-lines.test.ts b/cli/src/lib/stream-lines.test.ts index 3fcb5ee6..2c69ed2b 100644 --- a/cli/src/lib/stream-lines.test.ts +++ b/cli/src/lib/stream-lines.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test } from "bun:test"; -import { readTextStreamLines } from "@/lib/stream-lines.ts"; +import { describe, expect, test } from 'bun:test'; +import { readTextStreamLines } from '@/lib/stream-lines.ts'; -async function collectLines(stream: ReadableStream): Promise { +async function collectLines( + stream: ReadableStream +): Promise { const lines: string[] = []; for await (const line of readTextStreamLines(stream)) { lines.push(line); @@ -9,42 +11,42 @@ async function collectLines(stream: ReadableStream): Promise { - test("splits lines across chunks", async () => { +describe('readTextStreamLines', () => { + test('splits lines across chunks', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode("line1\nline")); - controller.enqueue(encoder.encode("2\nline3")); + controller.enqueue(encoder.encode('line1\nline')); + controller.enqueue(encoder.encode('2\nline3')); controller.close(); }, }); - expect(await collectLines(stream)).toEqual(["line1", "line2", "line3"]); + expect(await collectLines(stream)).toEqual(['line1', 'line2', 'line3']); }); - test("handles CRLF line endings", async () => { + test('handles CRLF line endings', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode("a\r\nb\r\n")); + controller.enqueue(encoder.encode('a\r\nb\r\n')); controller.close(); }, }); - expect(await collectLines(stream)).toEqual(["a", "b"]); + expect(await collectLines(stream)).toEqual(['a', 'b']); }); - test("buffers incomplete line until newline arrives", async () => { + test('buffers incomplete line until newline arrives', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode("partial")); - controller.enqueue(encoder.encode("-line\n")); + controller.enqueue(encoder.encode('partial')); + controller.enqueue(encoder.encode('-line\n')); controller.close(); }, }); - expect(await collectLines(stream)).toEqual(["partial-line"]); + expect(await collectLines(stream)).toEqual(['partial-line']); }); }); diff --git a/cli/src/lib/stream-lines.ts b/cli/src/lib/stream-lines.ts index 2d5bef20..cf9d9354 100644 --- a/cli/src/lib/stream-lines.ts +++ b/cli/src/lib/stream-lines.ts @@ -1,9 +1,9 @@ export async function* readTextStreamLines( - stream: ReadableStream, + stream: ReadableStream ): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); - let buffer = ""; + let buffer = ''; let complete = false; try { @@ -15,21 +15,21 @@ export async function* readTextStreamLines( } buffer += decoder.decode(value, { stream: true }); - let newlineIndex = buffer.indexOf("\n"); + let newlineIndex = buffer.indexOf('\n'); while (newlineIndex !== -1) { let line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); - if (line.endsWith("\r")) { + if (line.endsWith('\r')) { line = line.slice(0, -1); } yield line; - newlineIndex = buffer.indexOf("\n"); + newlineIndex = buffer.indexOf('\n'); } } buffer += decoder.decode(); if (buffer.length > 0) { - yield buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + yield buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer; } } finally { if (!complete) { diff --git a/cli/src/lib/tabular-result.ts b/cli/src/lib/tabular-result.ts index 0586c41a..5b8488ce 100644 --- a/cli/src/lib/tabular-result.ts +++ b/cli/src/lib/tabular-result.ts @@ -1,19 +1,19 @@ -import { CliError } from "@/lib/errors.ts"; -import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; +import { CliError } from '@/lib/errors.ts'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; import { defaultDisplayOptions, getQueryColumnNames, renderQueryHumanOutput, renderQueryMarkdown, -} from "@/lib/query-format.ts"; +} from '@/lib/query-format.ts'; -export type ManagementOutputFormat = "json" | "table" | "csv" | "markdown"; +export type ManagementOutputFormat = 'json' | 'table' | 'csv' | 'markdown'; const MANAGEMENT_OUTPUT_FORMATS = new Set([ - "json", - "table", - "csv", - "markdown", + 'json', + 'table', + 'csv', + 'markdown', ]); export type TabularResult = { @@ -21,23 +21,37 @@ export type TabularResult = { rows: Record[]; }; -export function parseManagementOutputFormat(format: string): ManagementOutputFormat { +export function parseManagementOutputFormat( + format: string +): ManagementOutputFormat { if (!MANAGEMENT_OUTPUT_FORMATS.has(format as ManagementOutputFormat)) { - throw new CliError(`Unsupported format: ${format}. Use json, table, csv, or markdown.`); + throw new CliError( + `Unsupported format: ${format}. Use json, table, csv, or markdown.` + ); } return format as ManagementOutputFormat; } -export function renderTabularOutput(result: TabularResult, format: ManagementOutputFormat): string { - const queryResult = { metadata: {}, columns: result.columns, rows: result.rows }; - if (format === "json") return renderQueryJson(queryResult); - if (format === "csv") return renderQueryCsv(queryResult); - if (format === "markdown") { +export function renderTabularOutput( + result: TabularResult, + format: ManagementOutputFormat +): string { + const queryResult = { + metadata: {}, + columns: result.columns, + rows: result.rows, + }; + if (format === 'json') return renderQueryJson(queryResult); + if (format === 'csv') return renderQueryCsv(queryResult); + if (format === 'markdown') { return renderQueryMarkdown( queryResult, getQueryColumnNames(queryResult), - defaultDisplayOptions(), + defaultDisplayOptions() ); } - return renderQueryHumanOutput(queryResult, { ...defaultDisplayOptions(), layout: "table" }); + return renderQueryHumanOutput(queryResult, { + ...defaultDisplayOptions(), + layout: 'table', + }); } diff --git a/cli/src/lib/timeout-args.ts b/cli/src/lib/timeout-args.ts index dc84fc83..6707ed97 100644 --- a/cli/src/lib/timeout-args.ts +++ b/cli/src/lib/timeout-args.ts @@ -1,10 +1,16 @@ -import { CliError } from "@/lib/errors.ts"; +import { CliError } from '@/lib/errors.ts'; export function parseTimeoutSeconds(value: unknown, flagName: string): number { const text = String(value).trim(); const parsedSeconds = Number.parseInt(text, 10); - if (Number.isNaN(parsedSeconds) || parsedSeconds < 0 || String(parsedSeconds) !== text) { - throw new CliError(`Invalid ${flagName}: must be a non-negative integer (seconds).`); + if ( + Number.isNaN(parsedSeconds) || + parsedSeconds < 0 || + String(parsedSeconds) !== text + ) { + throw new CliError( + `Invalid ${flagName}: must be a non-negative integer (seconds).` + ); } return parsedSeconds * 1_000; } diff --git a/cli/src/lib/updater-config.ts b/cli/src/lib/updater-config.ts index a78f32e8..5c2ade99 100644 --- a/cli/src/lib/updater-config.ts +++ b/cli/src/lib/updater-config.ts @@ -1,22 +1,22 @@ -import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; +import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; import { RELEASE_CHECKSUMS_ASSET, RELEASE_PLATFORM_CONFIG, type ReleasePlatform, -} from "@/release-manifest.ts"; -import { objectKeys } from "@/lib/object.ts"; +} from '@/release-manifest.ts'; +import { objectKeys } from '@/lib/object.ts'; const DAILY_MS = 24 * 60 * 60 * 1000; -const EXECUTABLE_NAME = "altertable"; +const EXECUTABLE_NAME = 'altertable'; const UpdaterSourceConfig = { npm: { - registryUrl: "https://registry.npmjs.org", - packageBaseUrl: "https://www.npmjs.com/package", + registryUrl: 'https://registry.npmjs.org', + packageBaseUrl: 'https://www.npmjs.com/package', }, github: { - apiBaseUrl: "https://api.github.com/repos", - webBaseUrl: "https://github.com", + apiBaseUrl: 'https://api.github.com/repos', + webBaseUrl: 'https://github.com', }, } as const; @@ -28,32 +28,32 @@ const UpdaterIntervalsMs = { const UpdaterInstallCommands = { bun: { - command: "bun", - argsBeforePackage: ["install", "-g"], - globalBinArgs: ["pm", "bin", "-g"], + command: 'bun', + argsBeforePackage: ['install', '-g'], + globalBinArgs: ['pm', 'bin', '-g'], }, npm: { - command: "npm", - argsBeforePackage: ["install", "-g"], - globalBinArgs: ["prefix", "-g"], + command: 'npm', + argsBeforePackage: ['install', '-g'], + globalBinArgs: ['prefix', '-g'], globalBinIsPrefix: true, }, pnpm: { - command: "pnpm", - argsBeforePackage: ["add", "-g"], - globalBinArgs: ["bin", "-g"], + command: 'pnpm', + argsBeforePackage: ['add', '-g'], + globalBinArgs: ['bin', '-g'], }, yarn: { - command: "yarn", - argsBeforePackage: ["global", "add"], - globalBinArgs: ["global", "bin"], + command: 'yarn', + argsBeforePackage: ['global', 'add'], + globalBinArgs: ['global', 'bin'], }, } as const; export const UpdaterInstallMethod = { - auto: "auto", - packageManager: "package-manager", - githubBinary: "github-binary", + auto: 'auto', + packageManager: 'package-manager', + githubBinary: 'github-binary', } as const; const UpdaterInstallMethodConfig = { @@ -63,17 +63,17 @@ const UpdaterInstallMethodConfig = { } as const; export const UpdaterInstallationKind = { - nativeBinary: "native-binary", - packageManager: "package-manager", - source: "source", - unknown: "unknown", + nativeBinary: 'native-binary', + packageManager: 'package-manager', + source: 'source', + unknown: 'unknown', } as const; const UpdaterDefault = { - source: "npm", - checkInterval: "daily", - installManager: "npm", - installMethod: "auto", + source: 'npm', + checkInterval: 'daily', + installManager: 'npm', + installMethod: 'auto', } as const satisfies { source: keyof typeof UpdaterSourceConfig; checkInterval: keyof typeof UpdaterIntervalsMs; @@ -85,9 +85,9 @@ export const UpdaterConfig = { packageName: CLI_PACKAGE_METADATA.name, githubRepo: CLI_PACKAGE_METADATA.repositorySlug, executableName: EXECUTABLE_NAME, - stateFileName: "update-state.json", + stateFileName: 'update-state.json', configKeys: { - checkInterval: "update_check_interval", + checkInterval: 'update_check_interval', }, defaults: UpdaterDefault, sources: UpdaterSourceConfig, @@ -96,7 +96,7 @@ export const UpdaterConfig = { manual: 10_000, }, intervalsMs: UpdaterIntervalsMs, - automaticCheckSkipCommands: ["completion", "doctor", "update", "upgrade"], + automaticCheckSkipCommands: ['completion', 'doctor', 'update', 'upgrade'], installCommands: UpdaterInstallCommands, installMethods: UpdaterInstallMethodConfig, releasePlatforms: RELEASE_PLATFORM_CONFIG, @@ -104,16 +104,16 @@ export const UpdaterConfig = { binaryAssetPrefix: EXECUTABLE_NAME, checksumsAssetName: RELEASE_CHECKSUMS_ASSET, installationDetection: { - sourceScriptSuffixes: ["/src/cli.ts"], - sourceScriptExtensions: [".ts"], - packageScriptSuffixes: ["/dist/cli.js"], - packageScriptNames: ["cli.js"], - packageScriptExtensions: [".js"], - bunExecutableNames: ["bun"], - bunExecutablePrefix: "bun-", + sourceScriptSuffixes: ['/src/cli.ts'], + sourceScriptExtensions: ['.ts'], + packageScriptSuffixes: ['/dist/cli.js'], + packageScriptNames: ['cli.js'], + packageScriptExtensions: ['.js'], + bunExecutableNames: ['bun'], + bunExecutablePrefix: 'bun-', }, commands: { - selfUpdate: "altertable update", + selfUpdate: 'altertable update', }, } as const; @@ -121,7 +121,7 @@ export type UpdateSource = keyof typeof UpdaterConfig.sources; export type UpdateCheckInterval = keyof typeof UpdaterConfig.intervalsMs; export type InstallManager = keyof typeof UpdaterConfig.installCommands; export type UpdateInstallMethod = keyof typeof UpdaterConfig.installMethods; -export type ResolvedUpdateInstallMethod = Exclude; +export type ResolvedUpdateInstallMethod = Exclude; export type { ReleasePlatform }; export type InstallationKind = (typeof UpdaterConfig.installationKinds)[keyof typeof UpdaterConfig.installationKinds]; diff --git a/cli/src/lib/updater.test.ts b/cli/src/lib/updater.test.ts index 09ac5c7d..f04533a9 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -1,21 +1,31 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { buildMainCommand } from "@/cli.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { buildMainCommand } from '@/cli.ts'; import { executeUpdateCommand, type UpdateCommandDependencies, updateCommand, -} from "@/commands/update/index.ts"; -import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; -import { VERSION } from "@/version.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { executeCommand, resolveCommandSelection } from "@/lib/command-parser.ts"; -import { configFile, kvSet } from "@/lib/config.ts"; -import { resolveProcessExecutablePath } from "@/lib/executable-path.ts"; +} from '@/commands/update/index.ts'; +import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; +import { VERSION } from '@/version.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { + executeCommand, + resolveCommandSelection, +} from '@/lib/command-parser.ts'; +import { configFile, kvSet } from '@/lib/config.ts'; +import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; import { checkForUpdate, compareVersions, @@ -38,17 +48,17 @@ import { resolveUpdateSource, shouldRunAutomaticUpdateCheck, verifySha256, -} from "@/lib/updater.ts"; -import { UpdaterConfig } from "@/lib/updater-config.ts"; -import { createCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; - -let testHome = ""; -const packageJsonPath = resolve(import.meta.dir, "../../package.json"); -const UPDATE_TEST_VERSION = `${Number(VERSION.split(".")[0]) + 1}.0.0`; -const LINUX_X64_ASSET = "altertable-linux-x64"; +} from '@/lib/updater.ts'; +import { UpdaterConfig } from '@/lib/updater-config.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; + +let testHome = ''; +const packageJsonPath = resolve(import.meta.dir, '../../package.json'); +const UPDATE_TEST_VERSION = `${Number(VERSION.split('.')[0]) + 1}.0.0`; +const LINUX_X64_ASSET = 'altertable-linux-x64'; const LINUX_X64_DOWNLOAD_URL = `https://download.example/${LINUX_X64_ASSET}`; -const CHECKSUMS_DOWNLOAD_URL = "https://download.example/checksums.txt"; +const CHECKSUMS_DOWNLOAD_URL = 'https://download.example/checksums.txt'; type CapturedCommandOutput = { stdout: string[]; @@ -56,7 +66,7 @@ type CapturedCommandOutput = { }; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-updater-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-updater-test-')); process.env.ALTERTABLE_CONFIG_HOME = testHome; }); @@ -75,7 +85,9 @@ afterEach(() => { function jsonFetch(data: unknown): typeof fetch { return (async () => - new Response(JSON.stringify(data), { status: 200 })) as unknown as typeof fetch; + new Response(JSON.stringify(data), { + status: 200, + })) as unknown as typeof fetch; } function fetchInputUrl(input: string | URL | Request): string { @@ -83,10 +95,12 @@ function fetchInputUrl(input: string | URL | Request): string { } function sha256(bytes: string | Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); + return createHash('sha256').update(bytes).digest('hex'); } -function githubReleaseMetadata(version = UPDATE_TEST_VERSION): Record { +function githubReleaseMetadata( + version = UPDATE_TEST_VERSION +): Record { return { tag_name: `v${version}`, html_url: `https://github.com/altertable-ai/altertable-cli/releases/tag/v${version}`, @@ -112,25 +126,31 @@ function githubBinaryFetch(options: { return (async (url: string | URL | Request) => { const requestedUrl = fetchInputUrl(url); if (requestedUrl.includes(`/releases/tags/v${version}`)) { - return new Response(JSON.stringify(githubReleaseMetadata(version)), { status: 200 }); + return new Response(JSON.stringify(githubReleaseMetadata(version)), { + status: 200, + }); } if (requestedUrl === LINUX_X64_DOWNLOAD_URL) { return new Response(options.binary, { status: 200 }); } if (requestedUrl === CHECKSUMS_DOWNLOAD_URL) { - return new Response(`${options.checksum} ${LINUX_X64_ASSET}\n`, { status: 200 }); + return new Response(`${options.checksum} ${LINUX_X64_ASSET}\n`, { + status: 200, + }); } - return new Response("not found", { status: 404 }); + return new Response('not found', { status: 404 }); }) as typeof fetch; } -async function runUpdateCommand(rawArgs: string[]): Promise { +async function runUpdateCommand( + rawArgs: string[] +): Promise { const output: CapturedCommandOutput = { stdout: [], stderr: [] }; const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeHuman = (text) => { + runtime.output.writeHuman = text => { output.stdout.push(text); }; - runtime.output.writeMetadata = (lines) => { + runtime.output.writeMetadata = lines => { output.stderr.push(...lines); }; @@ -142,168 +162,189 @@ async function runUpdateCommand(rawArgs: string[]): Promise> { - return JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + return JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record< + string, + unknown + >; } function repositorySlugFromPackageJson(data: Record): string { const repository = data.repository; - if (typeof repository !== "object" || repository === null || !("url" in repository)) { - return ""; + if ( + typeof repository !== 'object' || + repository === null || + !('url' in repository) + ) { + return ''; } const url = (repository as Record).url; - if (typeof url !== "string") { - return ""; + if (typeof url !== 'string') { + return ''; } return url - .replace(/^git\+/, "") - .replace(/^https:\/\/github\.com\//, "") - .replace(/\.git$/, ""); + .replace(/^git\+/, '') + .replace(/^https:\/\/github\.com\//, '') + .replace(/\.git$/, ''); } -describe("package metadata", () => { - test("keeps updater package identity aligned with package.json", async () => { +describe('package metadata', () => { + test('keeps updater package identity aligned with package.json', async () => { const packageJson = await readPackageJson(); expect(packageJson.name).toBe(CLI_PACKAGE_METADATA.name); - expect(repositorySlugFromPackageJson(packageJson)).toBe(CLI_PACKAGE_METADATA.repositorySlug); + expect(repositorySlugFromPackageJson(packageJson)).toBe( + CLI_PACKAGE_METADATA.repositorySlug + ); expect(UpdaterConfig.packageName).toBe(CLI_PACKAGE_METADATA.name); expect(UpdaterConfig.githubRepo).toBe(CLI_PACKAGE_METADATA.repositorySlug); }); }); -describe("version comparison", () => { - test("orders stable and prerelease versions", () => { - expect(compareVersions("1.2.0", "1.1.9")).toBe(1); - expect(compareVersions("v1.2.0", "1.2.0")).toBe(0); - expect(compareVersions("1.2.0", "1.2.0-beta.1")).toBe(1); - expect(compareVersions("1.2.0-beta.2", "1.2.0-beta.10")).toBe(-1); +describe('version comparison', () => { + test('orders stable and prerelease versions', () => { + expect(compareVersions('1.2.0', '1.1.9')).toBe(1); + expect(compareVersions('v1.2.0', '1.2.0')).toBe(0); + expect(compareVersions('1.2.0', '1.2.0-beta.1')).toBe(1); + expect(compareVersions('1.2.0-beta.2', '1.2.0-beta.10')).toBe(-1); }); }); -describe("release discovery", () => { - test("builds package URLs and install specs from updater config", () => { +describe('release discovery', () => { + test('builds package URLs and install specs from updater config', () => { expect(packageReleaseUrl(`v${UPDATE_TEST_VERSION}`)).toBe( `${UpdaterConfig.sources.npm.packageBaseUrl}/${encodeURIComponent( - UpdaterConfig.packageName, - )}/v/${UPDATE_TEST_VERSION}`, + UpdaterConfig.packageName + )}/v/${UPDATE_TEST_VERSION}` ); - expect(createInstallPlan(UPDATE_TEST_VERSION, "npm").display).toBe( - `npm install -g ${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}`, + expect(createInstallPlan(UPDATE_TEST_VERSION, 'npm').display).toBe( + `npm install -g ${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}` ); }); - test("builds source-aware release URLs for explicit target versions", () => { - expect(releaseUrlForSource("npm", `v${UPDATE_TEST_VERSION}`)).toBe( + test('builds source-aware release URLs for explicit target versions', () => { + expect(releaseUrlForSource('npm', `v${UPDATE_TEST_VERSION}`)).toBe( `${UpdaterConfig.sources.npm.packageBaseUrl}/${encodeURIComponent( - UpdaterConfig.packageName, - )}/v/${UPDATE_TEST_VERSION}`, + UpdaterConfig.packageName + )}/v/${UPDATE_TEST_VERSION}` ); - expect(releaseUrlForSource("github", `v${UPDATE_TEST_VERSION}`)).toBe( - `${UpdaterConfig.sources.github.webBaseUrl}/${UpdaterConfig.githubRepo}/releases/tag/v${UPDATE_TEST_VERSION}`, + expect(releaseUrlForSource('github', `v${UPDATE_TEST_VERSION}`)).toBe( + `${UpdaterConfig.sources.github.webBaseUrl}/${UpdaterConfig.githubRepo}/releases/tag/v${UPDATE_TEST_VERSION}` ); }); - test("builds release metadata URLs from configured bases", async () => { + test('builds release metadata URLs from configured bases', async () => { const requestedUrls: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { requestedUrls.push(fetchInputUrl(url)); - return new Response(JSON.stringify({ version: UPDATE_TEST_VERSION }), { status: 200 }); + return new Response(JSON.stringify({ version: UPDATE_TEST_VERSION }), { + status: 200, + }); }) as typeof fetch; - process.env.ALTERTABLE_UPDATE_REGISTRY_URL = "https://registry.example.test/custom/"; - await fetchLatestRelease({ source: "npm", fetchImpl }); + process.env.ALTERTABLE_UPDATE_REGISTRY_URL = + 'https://registry.example.test/custom/'; + await fetchLatestRelease({ source: 'npm', fetchImpl }); expect(requestedUrls[0]).toBe( `https://registry.example.test/custom/${encodeURIComponent( - UpdaterConfig.packageName, - )}/latest`, + UpdaterConfig.packageName + )}/latest` ); }); - test("builds GitHub release URL from configured repository", async () => { + test('builds GitHub release URL from configured repository', async () => { const requestedUrls: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { requestedUrls.push(fetchInputUrl(url)); - return new Response(JSON.stringify({ tag_name: `v${UPDATE_TEST_VERSION}` }), { - status: 200, - }); + return new Response( + JSON.stringify({ tag_name: `v${UPDATE_TEST_VERSION}` }), + { + status: 200, + } + ); }) as typeof fetch; - process.env.ALTERTABLE_UPDATE_GITHUB_REPO = "example/custom-cli"; - const release = await fetchLatestRelease({ source: "github", fetchImpl }); + process.env.ALTERTABLE_UPDATE_GITHUB_REPO = 'example/custom-cli'; + const release = await fetchLatestRelease({ source: 'github', fetchImpl }); expect(requestedUrls[0]).toBe( - `${UpdaterConfig.sources.github.apiBaseUrl}/example/custom-cli/releases/latest`, + `${UpdaterConfig.sources.github.apiBaseUrl}/example/custom-cli/releases/latest` ); expect(release.releaseUrl).toBe( - `${UpdaterConfig.sources.github.webBaseUrl}/${UpdaterConfig.githubRepo}/releases`, + `${UpdaterConfig.sources.github.webBaseUrl}/${UpdaterConfig.githubRepo}/releases` ); }); - test("reads npm latest metadata", async () => { + test('reads npm latest metadata', async () => { const release = await fetchLatestRelease({ - source: "npm", + source: 'npm', fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), }); expect(release).toEqual({ version: UPDATE_TEST_VERSION, - source: "npm", + source: 'npm', releaseUrl: `https://www.npmjs.com/package/%40altertable%2Fcli/v/${UPDATE_TEST_VERSION}`, }); }); - test("reads GitHub latest release metadata", async () => { + test('reads GitHub latest release metadata', async () => { const release = await fetchLatestRelease({ - source: "github", + source: 'github', fetchImpl: jsonFetch(githubReleaseMetadata()), }); expect(release.version).toBe(UPDATE_TEST_VERSION); - expect(release.source).toBe("github"); - expect(release.releaseUrl).toContain(`releases/tag/v${UPDATE_TEST_VERSION}`); + expect(release.source).toBe('github'); + expect(release.releaseUrl).toContain( + `releases/tag/v${UPDATE_TEST_VERSION}` + ); }); - test("explains missing npm release metadata", async () => { + test('explains missing npm release metadata', async () => { try { await fetchLatestRelease({ - source: "npm", + source: 'npm', fetchImpl: (async () => - new Response("missing", { status: 404 })) as unknown as typeof fetch, + new Response('missing', { status: 404 })) as unknown as typeof fetch, }); - throw new Error("Expected missing npm metadata to fail."); + throw new Error('Expected missing npm metadata to fail.'); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe( - `No published npm release found for ${UpdaterConfig.packageName}.`, + `No published npm release found for ${UpdaterConfig.packageName}.` ); expect((error as { details?: string }).details).toContain( - "Try GitHub releases instead: altertable update --source github", + 'Try GitHub releases instead: altertable update --source github' ); expect((error as { details?: string }).details).toContain( - "If you are running from a source checkout, update it with git pull.", + 'If you are running from a source checkout, update it with git pull.' ); } }); - test("explains missing GitHub release metadata", async () => { + test('explains missing GitHub release metadata', async () => { try { await fetchLatestRelease({ - source: "github", + source: 'github', fetchImpl: (async () => - new Response("missing", { status: 404 })) as unknown as typeof fetch, + new Response('missing', { status: 404 })) as unknown as typeof fetch, }); - throw new Error("Expected missing GitHub metadata to fail."); + throw new Error('Expected missing GitHub metadata to fail.'); } catch (error) { expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("No GitHub release metadata found for latest."); - expect((error as { details?: string }).details).toContain("Open releases:"); + expect((error as Error).message).toBe( + 'No GitHub release metadata found for latest.' + ); + expect((error as { details?: string }).details).toContain( + 'Open releases:' + ); } }); - test("checkForUpdate writes cached state", async () => { + test('checkForUpdate writes cached state', async () => { const result = await checkForUpdate({ - source: "npm", + source: 'npm', fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), }); @@ -312,338 +353,370 @@ describe("release discovery", () => { }); }); -describe("binary self-update", () => { - test("detects supported release platforms", () => { - expect(detectReleasePlatform("darwin", "arm64")).toBe("darwin-arm64"); - expect(detectReleasePlatform("darwin", "x64")).toBe("darwin-x64"); - expect(detectReleasePlatform("linux", "arm64")).toBe("linux-arm64"); - expect(detectReleasePlatform("linux", "x64")).toBe("linux-x64"); - expect(() => detectReleasePlatform("win32", "x64")).toThrow("Unsupported self-update platform"); +describe('binary self-update', () => { + test('detects supported release platforms', () => { + expect(detectReleasePlatform('darwin', 'arm64')).toBe('darwin-arm64'); + expect(detectReleasePlatform('darwin', 'x64')).toBe('darwin-x64'); + expect(detectReleasePlatform('linux', 'arm64')).toBe('linux-arm64'); + expect(detectReleasePlatform('linux', 'x64')).toBe('linux-x64'); + expect(() => detectReleasePlatform('win32', 'x64')).toThrow( + 'Unsupported self-update platform' + ); }); - test("detects installation origin", () => { + test('detects installation origin', () => { expect( detectCurrentInstallation({ - execPath: "/opt/homebrew/bin/bun", - argv: ["bun", "/repo/cli/src/cli.ts"], - }).kind, - ).toBe("source"); + execPath: '/opt/homebrew/bin/bun', + argv: ['bun', '/repo/cli/src/cli.ts'], + }).kind + ).toBe('source'); expect( detectCurrentInstallation({ - execPath: "/usr/local/bin/bun", - argv: ["bun", "/usr/local/lib/node_modules/@altertable/cli/dist/cli.js"], - }).kind, - ).toBe("package-manager"); + execPath: '/usr/local/bin/bun', + argv: [ + 'bun', + '/usr/local/lib/node_modules/@altertable/cli/dist/cli.js', + ], + }).kind + ).toBe('package-manager'); expect( detectCurrentInstallation({ - execPath: "/usr/local/bin/altertable", - argv: ["/usr/local/bin/altertable"], - }).kind, - ).toBe("native-binary"); + execPath: '/usr/local/bin/altertable', + argv: ['/usr/local/bin/altertable'], + }).kind + ).toBe('native-binary'); }); - test("resolves relative compiled executable paths from the original invocation", () => { - const executable = join(testHome, "bin", "altertable"); - mkdirSync(join(testHome, "bin"), { recursive: true }); - writeFileSync(executable, "binary", { mode: 0o755 }); + test('resolves relative compiled executable paths from the original invocation', () => { + const executable = join(testHome, 'bin', 'altertable'); + mkdirSync(join(testHome, 'bin'), { recursive: true }); + writeFileSync(executable, 'binary', { mode: 0o755 }); expect( resolveProcessExecutablePath({ - execPath: "altertable", + execPath: 'altertable', argv0: executable, - cwd: join(testHome, "elsewhere"), - path: "", - }), + cwd: join(testHome, 'elsewhere'), + path: '', + }) ).toBe(realpathSync(executable)); }); - test("resolves bare compiled executable names from PATH", () => { - const binDirectory = join(testHome, "bin"); - const executable = join(binDirectory, "altertable"); + test('resolves bare compiled executable names from PATH', () => { + const binDirectory = join(testHome, 'bin'); + const executable = join(binDirectory, 'altertable'); mkdirSync(binDirectory, { recursive: true }); - writeFileSync(executable, "binary", { mode: 0o755 }); + writeFileSync(executable, 'binary', { mode: 0o755 }); expect( resolveProcessExecutablePath({ - execPath: "altertable", - argv0: "altertable", - cwd: join(testHome, "elsewhere"), + execPath: 'altertable', + argv0: 'altertable', + cwd: join(testHome, 'elsewhere'), path: binDirectory, - }), + }) ).toBe(realpathSync(executable)); }); - test("skips non-executable PATH entries", () => { - const directoryEntryBin = join(testHome, "directory-entry-bin"); - const nonExecutableBin = join(testHome, "non-executable-bin"); - const validBinDirectory = join(testHome, "valid-bin"); - const directoryEntry = join(directoryEntryBin, "altertable"); - const nonExecutable = join(nonExecutableBin, "altertable"); - const executable = join(validBinDirectory, "altertable"); + test('skips non-executable PATH entries', () => { + const directoryEntryBin = join(testHome, 'directory-entry-bin'); + const nonExecutableBin = join(testHome, 'non-executable-bin'); + const validBinDirectory = join(testHome, 'valid-bin'); + const directoryEntry = join(directoryEntryBin, 'altertable'); + const nonExecutable = join(nonExecutableBin, 'altertable'); + const executable = join(validBinDirectory, 'altertable'); mkdirSync(directoryEntry, { recursive: true }); mkdirSync(nonExecutableBin, { recursive: true }); mkdirSync(validBinDirectory, { recursive: true }); - writeFileSync(nonExecutable, "binary", { mode: 0o644 }); - writeFileSync(executable, "binary", { mode: 0o755 }); + writeFileSync(nonExecutable, 'binary', { mode: 0o644 }); + writeFileSync(executable, 'binary', { mode: 0o755 }); expect( resolveProcessExecutablePath({ - execPath: "altertable", - argv0: "altertable", - cwd: join(testHome, "elsewhere"), - path: [directoryEntryBin, nonExecutableBin, validBinDirectory].join(delimiter), - }), + execPath: 'altertable', + argv0: 'altertable', + cwd: join(testHome, 'elsewhere'), + path: [directoryEntryBin, nonExecutableBin, validBinDirectory].join( + delimiter + ), + }) ).toBe(realpathSync(executable)); }); - test("recommends install commands from installation origin", () => { - process.env.ALTERTABLE_UPDATE_INSTALLER = "npm"; + test('recommends install commands from installation origin', () => { + process.env.ALTERTABLE_UPDATE_INSTALLER = 'npm'; expect( recommendedInstallCommand(UPDATE_TEST_VERSION, { - kind: "native-binary", - executablePath: "/usr/local/bin/altertable", - }), - ).toBe("altertable update"); + kind: 'native-binary', + executablePath: '/usr/local/bin/altertable', + }) + ).toBe('altertable update'); expect( recommendedInstallCommand(UPDATE_TEST_VERSION, { - kind: "package-manager", - executablePath: "/usr/local/lib/node_modules/@altertable/cli/dist/cli.js", - }), - ).toBe(`npm install -g ${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}`); + kind: 'package-manager', + executablePath: + '/usr/local/lib/node_modules/@altertable/cli/dist/cli.js', + }) + ).toBe( + `npm install -g ${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}` + ); expect( recommendedInstallCommand(UPDATE_TEST_VERSION, { - kind: "source", - executablePath: "/repo/cli/src/cli.ts", - }), - ).toBe("altertable update"); + kind: 'source', + executablePath: '/repo/cli/src/cli.ts', + }) + ).toBe('altertable update'); expect( recommendedInstallCommand(UPDATE_TEST_VERSION, { - kind: "unknown", - executablePath: "/custom/altertable", - }), - ).toBe("altertable update"); + kind: 'unknown', + executablePath: '/custom/altertable', + }) + ).toBe('altertable update'); }); - test("parses and verifies SHA-256 checksums", () => { - const filePath = join(testHome, "asset"); - writeFileSync(filePath, "release-binary"); - const hash = sha256("release-binary"); + test('parses and verifies SHA-256 checksums', () => { + const filePath = join(testHome, 'asset'); + writeFileSync(filePath, 'release-binary'); + const hash = sha256('release-binary'); - expect(parseChecksums(`${hash} altertable-linux-x64\n`).get("altertable-linux-x64")).toBe( - hash, - ); + expect( + parseChecksums(`${hash} altertable-linux-x64\n`).get( + 'altertable-linux-x64' + ) + ).toBe(hash); expect(verifySha256(filePath, hash)).toBe(true); - expect(verifySha256(filePath, "0".repeat(64))).toBe(false); + expect(verifySha256(filePath, '0'.repeat(64))).toBe(false); }); - test("reads GitHub binary release assets", async () => { + test('reads GitHub binary release assets', async () => { const requestedUrls: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { requestedUrls.push(fetchInputUrl(url)); - return new Response(JSON.stringify(githubReleaseMetadata()), { status: 200 }); + return new Response(JSON.stringify(githubReleaseMetadata()), { + status: 200, + }); }) as typeof fetch; const release = await fetchGitHubBinaryRelease({ version: UPDATE_TEST_VERSION, - platform: "linux-x64", + platform: 'linux-x64', fetchImpl, }); - expect(requestedUrls[0]).toContain(`/releases/tags/v${UPDATE_TEST_VERSION}`); - expect(release.assetName).toBe(releaseAssetName("linux-x64")); + expect(requestedUrls[0]).toContain( + `/releases/tags/v${UPDATE_TEST_VERSION}` + ); + expect(release.assetName).toBe(releaseAssetName('linux-x64')); expect(release.assetDownloadUrl).toBe(LINUX_X64_DOWNLOAD_URL); }); - test("installs a GitHub binary after checksum verification and verifies version", async () => { - const targetPath = join(testHome, "altertable"); + test('installs a GitHub binary after checksum verification and verifies version', async () => { + const targetPath = join(testHome, 'altertable'); const newBinary = `#!/bin/sh\necho ${UPDATE_TEST_VERSION}\n`; const hash = sha256(newBinary); - writeFileSync(targetPath, "#!/bin/sh\necho 1.0.0\n", { mode: 0o755 }); + writeFileSync(targetPath, '#!/bin/sh\necho 1.0.0\n', { mode: 0o755 }); const result = await installGitHubBinaryRelease(UPDATE_TEST_VERSION, { targetPath, - platform: "linux-x64", + platform: 'linux-x64', fetchImpl: githubBinaryFetch({ binary: newBinary, checksum: hash }), }); - expect(result.method).toBe("github-binary"); + expect(result.method).toBe('github-binary'); expect(result.verified_version).toBe(UPDATE_TEST_VERSION); - expect(readFileSync(targetPath, "utf8")).toBe(newBinary); + expect(readFileSync(targetPath, 'utf8')).toBe(newBinary); }); - test("rejects GitHub binary install on checksum mismatch without replacing target", async () => { - const targetPath = join(testHome, "altertable"); - const originalBinary = "#!/bin/sh\necho 1.0.0\n"; + test('rejects GitHub binary install on checksum mismatch without replacing target', async () => { + const targetPath = join(testHome, 'altertable'); + const originalBinary = '#!/bin/sh\necho 1.0.0\n'; writeFileSync(targetPath, originalBinary, { mode: 0o755 }); try { await installGitHubBinaryRelease(UPDATE_TEST_VERSION, { targetPath, - platform: "linux-x64", + platform: 'linux-x64', fetchImpl: githubBinaryFetch({ binary: `#!/bin/sh\necho ${UPDATE_TEST_VERSION}\n`, - checksum: "0".repeat(64), + checksum: '0'.repeat(64), }), }); - throw new Error("Expected checksum verification to fail."); + throw new Error('Expected checksum verification to fail.'); } catch (error) { expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Checksum verification failed"); + expect((error as Error).message).toContain( + 'Checksum verification failed' + ); } - expect(readFileSync(targetPath, "utf8")).toBe(originalBinary); + expect(readFileSync(targetPath, 'utf8')).toBe(originalBinary); }); - test("verifies a downloaded GitHub binary before replacing the target", async () => { - const targetPath = join(testHome, "altertable"); - const originalBinary = "#!/bin/sh\necho 1.0.0\n"; - const unexpectedBinary = "#!/bin/sh\necho 9.9.9\n"; + test('verifies a downloaded GitHub binary before replacing the target', async () => { + const targetPath = join(testHome, 'altertable'); + const originalBinary = '#!/bin/sh\necho 1.0.0\n'; + const unexpectedBinary = '#!/bin/sh\necho 9.9.9\n'; writeFileSync(targetPath, originalBinary, { mode: 0o755 }); try { await installGitHubBinaryRelease(UPDATE_TEST_VERSION, { targetPath, - platform: "linux-x64", + platform: 'linux-x64', fetchImpl: githubBinaryFetch({ binary: unexpectedBinary, checksum: sha256(unexpectedBinary), }), }); - throw new Error("Expected downloaded binary verification to fail."); + throw new Error('Expected downloaded binary verification to fail.'); } catch (error) { expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Downloaded Altertable CLI version is 9.9.9"); + expect((error as Error).message).toContain( + 'Downloaded Altertable CLI version is 9.9.9' + ); } - expect(readFileSync(targetPath, "utf8")).toBe(originalBinary); + expect(readFileSync(targetPath, 'utf8')).toBe(originalBinary); }); test.each([ - ["bun", ["pm", "bin", "-g"]], - ["npm", ["prefix", "-g"]], - ["pnpm", ["bin", "-g"]], - ["yarn", ["global", "bin"]], - ] as const)("source checkout verifies the %s global install", async (manager, binArgs) => { - process.env.ALTERTABLE_UPDATE_INSTALLER = manager; - const commands: string[] = []; - const globalDirectory = join(testHome, `${manager}-global`); - const globalBinDirectory = - manager === "npm" && process.platform !== "win32" - ? join(globalDirectory, "bin") - : globalDirectory; - const installedCli = join( - globalBinDirectory, - process.platform === "win32" ? "altertable.cmd" : "altertable", - ); - const result = await installCliUpdate(UPDATE_TEST_VERSION, { - stdio: "pipe", - installation: { - kind: "source", - executablePath: "/repo/cli/src/cli.ts", - reason: "test", - }, - spawnImpl: ((command: string, args: string[]) => { - commands.push([command, ...args].join(" ")); - if (command === manager && args.join(" ") === binArgs.join(" ")) { - return { - status: 0, - signal: null, - stdout: globalDirectory, - stderr: "", - pid: 1, - output: [], - }; - } - if (command === installedCli) { + ['bun', ['pm', 'bin', '-g']], + ['npm', ['prefix', '-g']], + ['pnpm', ['bin', '-g']], + ['yarn', ['global', 'bin']], + ] as const)( + 'source checkout verifies the %s global install', + async (manager, binArgs) => { + process.env.ALTERTABLE_UPDATE_INSTALLER = manager; + const commands: string[] = []; + const globalDirectory = join(testHome, `${manager}-global`); + const globalBinDirectory = + manager === 'npm' && process.platform !== 'win32' + ? join(globalDirectory, 'bin') + : globalDirectory; + const installedCli = join( + globalBinDirectory, + process.platform === 'win32' ? 'altertable.cmd' : 'altertable' + ); + const result = await installCliUpdate(UPDATE_TEST_VERSION, { + stdio: 'pipe', + installation: { + kind: 'source', + executablePath: '/repo/cli/src/cli.ts', + reason: 'test', + }, + spawnImpl: ((command: string, args: string[]) => { + commands.push([command, ...args].join(' ')); + if (command === manager && args.join(' ') === binArgs.join(' ')) { + return { + status: 0, + signal: null, + stdout: globalDirectory, + stderr: '', + pid: 1, + output: [], + }; + } + if (command === installedCli) { + return { + status: 0, + signal: null, + stdout: UPDATE_TEST_VERSION, + stderr: '', + pid: 1, + output: [], + }; + } return { status: 0, signal: null, - stdout: UPDATE_TEST_VERSION, - stderr: "", + stdout: '', + stderr: '', pid: 1, output: [], }; - } - return { status: 0, signal: null, stdout: "", stderr: "", pid: 1, output: [] }; - }) as unknown as typeof import("node:child_process").spawnSync, - }); + }) as unknown as typeof import('node:child_process').spawnSync, + }); - expect(commands[0]).toContain(`${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}`); - expect(commands[1]).toBe([manager, ...binArgs].join(" ")); - expect(commands[2]).toBe(`${installedCli} --version`); - expect(commands).not.toContain("altertable --version"); - expect(result.method).toBe("package-manager"); - expect(result.verified_version).toBe(UPDATE_TEST_VERSION); - }); + expect(commands[0]).toContain( + `${UpdaterConfig.packageName}@${UPDATE_TEST_VERSION}` + ); + expect(commands[1]).toBe([manager, ...binArgs].join(' ')); + expect(commands[2]).toBe(`${installedCli} --version`); + expect(commands).not.toContain('altertable --version'); + expect(result.method).toBe('package-manager'); + expect(result.verified_version).toBe(UPDATE_TEST_VERSION); + } + ); }); -describe("automatic update checks", () => { - test("resolves top-level commands with root value flags", async () => { +describe('automatic update checks', () => { + test('resolves top-level commands with root value flags', async () => { const root = buildMainCommand(); for (const [rawArgs, expected] of [ - [["--profile", "dev", "query"], "query"], - [["--connect-timeout", "3", "query"], "query"], - [["--read-timeout=10", "query"], "query"], - [["--", "query"], undefined], + [['--profile', 'dev', 'query'], 'query'], + [['--connect-timeout', '3', 'query'], 'query'], + [['--read-timeout=10', 'query'], 'query'], + [['--', 'query'], undefined], ] as const) { const selection = await resolveCommandSelection(root, rawArgs); expect(selection.commandPath.at(0)).toBe(expected); } }); - test("requires human stderr and skips JSON output", () => { + test('requires human stderr and skips JSON output', () => { expect( shouldRunAutomaticUpdateCheck({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', state: {}, stderrIsTTY: true, - }), + }) ).toBe(true); expect( shouldRunAutomaticUpdateCheck({ context: { debug: false, json: true, agent: false }, - commandName: "context", + commandName: 'context', state: {}, stderrIsTTY: true, - }), + }) ).toBe(false); expect( shouldRunAutomaticUpdateCheck({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', state: {}, stderrIsTTY: false, - }), + }) ).toBe(false); }); - test("honors interval policy and environment opt-out", () => { - kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, "never"); - expect(getUpdateCheckInterval()).toBe("never"); + test('honors interval policy and environment opt-out', () => { + kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, 'never'); + expect(getUpdateCheckInterval()).toBe('never'); expect( shouldRunAutomaticUpdateCheck({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', state: {}, stderrIsTTY: true, - }), + }) ).toBe(false); - kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, "daily"); - process.env.ALTERTABLE_NO_UPDATE_CHECK = "1"; + kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, 'daily'); + process.env.ALTERTABLE_NO_UPDATE_CHECK = '1'; expect( shouldRunAutomaticUpdateCheck({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', state: {}, stderrIsTTY: true, - }), + }) ).toBe(false); }); - test("skips commands configured for automatic update checks", () => { - expect(UpdaterConfig.automaticCheckSkipCommands).toContain("doctor"); + test('skips commands configured for automatic update checks', () => { + expect(UpdaterConfig.automaticCheckSkipCommands).toContain('doctor'); for (const commandName of UpdaterConfig.automaticCheckSkipCommands) { expect( shouldRunAutomaticUpdateCheck({ @@ -651,78 +724,90 @@ describe("automatic update checks", () => { commandName, state: {}, stderrIsTTY: true, - }), + }) ).toBe(false); } }); - test("detects package manager from environment", () => { - expect(detectInstallManager({ npm_config_user_agent: "pnpm/9.0.0 npm/? node/?" })).toBe("pnpm"); - expect(detectInstallManager({ BUN_INSTALL: "/tmp/bun" })).toBe("bun"); - expect(detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: "yarn" })).toBe("yarn"); - expect(() => detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: "invalid" })).toThrow( - ConfigurationError, + test('detects package manager from environment', () => { + expect( + detectInstallManager({ npm_config_user_agent: 'pnpm/9.0.0 npm/? node/?' }) + ).toBe('pnpm'); + expect(detectInstallManager({ BUN_INSTALL: '/tmp/bun' })).toBe('bun'); + expect(detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: 'yarn' })).toBe( + 'yarn' ); + expect(() => + detectInstallManager({ ALTERTABLE_UPDATE_INSTALLER: 'invalid' }) + ).toThrow(ConfigurationError); }); - test("does not reject when failure state persistence is unavailable", async () => { + test('does not reject when failure state persistence is unavailable', async () => { mkdirSync(join(testHome, UpdaterConfig.stateFileName)); await maybeShowUpdateNotice({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', stderrIsTTY: true, fetchImpl: (async () => { - throw new Error("network unavailable"); + throw new Error('network unavailable'); }) as unknown as typeof fetch, }); }); - test("does not reject when automatic check policy cannot read config", async () => { - const invalidConfigHome = join(testHome, "not-a-directory"); - writeFileSync(invalidConfigHome, ""); + test('does not reject when automatic check policy cannot read config', async () => { + const invalidConfigHome = join(testHome, 'not-a-directory'); + writeFileSync(invalidConfigHome, ''); process.env.ALTERTABLE_CONFIG_HOME = invalidConfigHome; await maybeShowUpdateNotice({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', stderrIsTTY: true, fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), }); }); - test("writes automatic notices to metadata output", async () => { + test('writes automatic notices to metadata output', async () => { const stderr: string[] = []; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeMetadata = (lines) => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + runtime.output.writeMetadata = lines => { stderr.push(...lines); }; await runWithCliRuntime(runtime, async () => { await maybeShowUpdateNotice({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', stderrIsTTY: true, fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), }); }); - expect(stderr.join("\n")).toContain(`Update available: Altertable CLI ${UPDATE_TEST_VERSION}`); - expect(stderr.join("\n")).toContain("altertable update"); + expect(stderr.join('\n')).toContain( + `Update available: Altertable CLI ${UPDATE_TEST_VERSION}` + ); + expect(stderr.join('\n')).toContain('altertable update'); }); - test("writes cached update notices without refreshing release metadata", async () => { - await checkForUpdate({ fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }) }); + test('writes cached update notices without refreshing release metadata', async () => { + await checkForUpdate({ + fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), + }); const stderr: string[] = []; let fetchCalls = 0; await maybeShowUpdateNotice({ context: { debug: false, json: false, agent: false }, - commandName: "help", + commandName: 'help', stderrIsTTY: true, fetchImpl: (async () => { fetchCalls += 1; - throw new Error("cached notices should not fetch"); + throw new Error('cached notices should not fetch'); }) as unknown as typeof fetch, sink: { json: false, @@ -739,21 +824,27 @@ describe("automatic update checks", () => { }); expect(fetchCalls).toBe(0); - expect(stderr.join("\n")).toContain(`Update available: Altertable CLI ${UPDATE_TEST_VERSION}`); + expect(stderr.join('\n')).toContain( + `Update available: Altertable CLI ${UPDATE_TEST_VERSION}` + ); }); - test("falls back to a cached notice when a scheduled refresh fails", async () => { - await checkForUpdate({ fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }) }); + test('falls back to a cached notice when a scheduled refresh fails', async () => { + await checkForUpdate({ + fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), + }); const state = readUpdateState(); const stderr: string[] = []; await maybeShowUpdateNotice({ context: { debug: false, json: false, agent: false }, - commandName: "context", + commandName: 'context', stderrIsTTY: true, - now: new Date(Date.parse(state.last_checked_at ?? "") + 24 * 60 * 60 * 1000), + now: new Date( + Date.parse(state.last_checked_at ?? '') + 24 * 60 * 60 * 1000 + ), fetchImpl: (async () => { - throw new Error("network unavailable"); + throw new Error('network unavailable'); }) as unknown as typeof fetch, sink: { json: false, @@ -769,68 +860,88 @@ describe("automatic update checks", () => { }, }); - expect(stderr.join("\n")).toContain(`Update available: Altertable CLI ${UPDATE_TEST_VERSION}`); - expect(readUpdateState().last_error).toBe("Unable to check for CLI updates."); + expect(stderr.join('\n')).toContain( + `Update available: Altertable CLI ${UPDATE_TEST_VERSION}` + ); + expect(readUpdateState().last_error).toBe( + 'Unable to check for CLI updates.' + ); }); }); -describe("update command", () => { - test("checks an explicit target version without network", async () => { - const output = await runUpdateCommand(["update", UPDATE_TEST_VERSION, "--check"]); +describe('update command', () => { + test('checks an explicit target version without network', async () => { + const output = await runUpdateCommand([ + 'update', + UPDATE_TEST_VERSION, + '--check', + ]); expect(output.stdout[0]).toBe( [ `A new version of Altertable CLI is available: v${UPDATE_TEST_VERSION} (current v${VERSION})`, `Run ${UpdaterConfig.commands.selfUpdate} to install it.`, - ].join("\n"), + ].join('\n') ); - expect(output.stdout[0]).not.toContain("Install:"); - expect(output.stdout[0]).not.toContain("Release:"); + expect(output.stdout[0]).not.toContain('Install:'); + expect(output.stdout[0]).not.toContain('Release:'); }); - test("reports an explicit target that is already installed", async () => { - const output = await runUpdateCommand(["update", VERSION]); + test('reports an explicit target that is already installed', async () => { + const output = await runUpdateCommand(['update', VERSION]); - expect(output.stdout[0]).toBe(`Target version v${VERSION} is already installed.`); + expect(output.stdout[0]).toBe( + `Target version v${VERSION} is already installed.` + ); }); - test("checks an explicit target that is older than the installed version", async () => { - const targetVersion = "1.1.0"; - const output = await runUpdateCommand(["update", targetVersion, "--check"]); + test('checks an explicit target that is older than the installed version', async () => { + const targetVersion = '1.1.0'; + const output = await runUpdateCommand(['update', targetVersion, '--check']); expect(output.stdout[0]).toBe( [ `Target version v${targetVersion} is older than installed Altertable CLI v${VERSION}.`, `Run altertable update ${targetVersion} --force to install it anyway.`, - ].join("\n"), + ].join('\n') ); }); - test("upgrade aliases update", async () => { - const output = await runUpdateCommand(["upgrade", UPDATE_TEST_VERSION, "--check"]); + test('upgrade aliases update', async () => { + const output = await runUpdateCommand([ + 'upgrade', + UPDATE_TEST_VERSION, + '--check', + ]); expect(output.stdout[0]).toContain( - `A new version of Altertable CLI is available: v${UPDATE_TEST_VERSION}`, + `A new version of Altertable CLI is available: v${UPDATE_TEST_VERSION}` ); }); - test("installs the latest release by default", async () => { + test('installs the latest release by default', async () => { const output: CapturedCommandOutput = { stdout: [], stderr: [] }; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeHuman = (text) => output.stdout.push(text); - runtime.output.writeMetadata = (lines) => output.stderr.push(...lines); - let installedVersion = ""; + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + runtime.output.writeHuman = text => output.stdout.push(text); + runtime.output.writeMetadata = lines => output.stderr.push(...lines); + let installedVersion = ''; const dependencies: UpdateCommandDependencies = { async checkForUpdate() { - return await checkForUpdate({ fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }) }); + return await checkForUpdate({ + fetchImpl: jsonFetch({ version: UPDATE_TEST_VERSION }), + }); }, async installCliUpdate(version) { installedVersion = version; return { installed_version: version, verified_version: version, - method: "github-binary", - target_path: "/tmp/altertable", + method: 'github-binary', + target_path: '/tmp/altertable', }; }, }; @@ -841,120 +952,149 @@ describe("update command", () => { expect(output.stderr).toEqual([ `Altertable CLI v${UPDATE_TEST_VERSION} is out! You're on v${VERSION}`, ]); - const summary = output.stdout.join("\n"); - expect(summary).toStartWith("Upgraded.\n"); - expect(summary).not.toContain("Welcome"); - expect(summary).toContain(`Release notes for Altertable CLI v${UPDATE_TEST_VERSION}:`); + const summary = output.stdout.join('\n'); + expect(summary).toStartWith('Upgraded.\n'); + expect(summary).not.toContain('Welcome'); + expect(summary).toContain( + `Release notes for Altertable CLI v${UPDATE_TEST_VERSION}:` + ); expect(summary).toContain(`/releases/tag/v${UPDATE_TEST_VERSION}`); - expect(summary).toContain("https://github.com/altertable-ai/altertable-cli/issues"); + expect(summary).toContain( + 'https://github.com/altertable-ai/altertable-cli/issues' + ); expect(summary).toContain(`/compare/v${VERSION}...v${UPDATE_TEST_VERSION}`); }); - test("keeps installation JSON free of human output", async () => { - const runtime = createCliRuntime({ debug: false, json: true, agent: false }); + test('keeps installation JSON free of human output', async () => { + const runtime = createCliRuntime({ + debug: false, + json: true, + agent: false, + }); const humanOutput: string[] = []; const jsonOutput: unknown[] = []; - runtime.output.writeHuman = (text) => humanOutput.push(text); - runtime.output.writeMetadata = (lines) => humanOutput.push(...lines); - runtime.output.writeJson = (data) => jsonOutput.push(data); + runtime.output.writeHuman = text => humanOutput.push(text); + runtime.output.writeMetadata = lines => humanOutput.push(...lines); + runtime.output.writeJson = data => jsonOutput.push(data); const installed = { installed_version: UPDATE_TEST_VERSION, verified_version: UPDATE_TEST_VERSION, - method: "github-binary" as const, - target_path: "/tmp/altertable", + method: 'github-binary' as const, + target_path: '/tmp/altertable', }; - await executeUpdateCommand({ version: UPDATE_TEST_VERSION }, runtime.output, { - checkForUpdate, - async installCliUpdate(_version, options) { - expect(options?.stdio).toBe("pipe"); - return installed; - }, - }); + await executeUpdateCommand( + { version: UPDATE_TEST_VERSION }, + runtime.output, + { + checkForUpdate, + async installCliUpdate(_version, options) { + expect(options?.stdio).toBe('pipe'); + return installed; + }, + } + ); expect(humanOutput).toEqual([]); expect(jsonOutput).toEqual([installed]); }); - test("does not announce success when installation fails", async () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + test('does not announce success when installation fails', async () => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); const humanOutput: string[] = []; - runtime.output.writeHuman = (text) => humanOutput.push(text); + runtime.output.writeHuman = text => humanOutput.push(text); runtime.output.writeMetadata = () => {}; const rejected = expect( executeUpdateCommand({ version: UPDATE_TEST_VERSION }, runtime.output, { checkForUpdate, async installCliUpdate() { - throw new Error("Installation failed"); + throw new Error('Installation failed'); }, - }), - ).rejects.toThrow("Installation failed"); + }) + ).rejects.toThrow('Installation failed'); expect(humanOutput).toEqual([]); return rejected; }); - test("rejects a downgrade unless forced", () => { - return expect(runUpdateCommand(["update", "1.1.0"])).rejects.toThrow( - "Target version v1.1.0 is older", + test('rejects a downgrade unless forced', () => { + return expect(runUpdateCommand(['update', '1.1.0'])).rejects.toThrow( + 'Target version v1.1.0 is older' ); }); test.each([ { - version: "1.1.0", + version: '1.1.0', announcement: `Downgrading Altertable CLI to v1.1.0. You're on v${VERSION}`, - outcome: "Downgraded.", + outcome: 'Downgraded.', }, { version: VERSION, announcement: `Reinstalling Altertable CLI v${VERSION}.`, - outcome: "Reinstalled.", + outcome: 'Reinstalled.', }, - ])("describes a forced $outcome accurately", async ({ version, announcement, outcome }) => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - const stdout: string[] = []; - const stderr: string[] = []; - runtime.output.writeHuman = (text) => stdout.push(text); - runtime.output.writeMetadata = (lines) => stderr.push(...lines); - let installedVersion = ""; - await executeUpdateCommand({ version, force: true }, runtime.output, { - checkForUpdate, - async installCliUpdate(version) { - installedVersion = version; - return { - installed_version: version, - verified_version: version, - method: "github-binary", - target_path: "/tmp/altertable", - }; - }, - }); - expect(installedVersion).toBe(version); - expect(stderr).toEqual([announcement]); - expect(stdout[0]).toStartWith(`${outcome}\n`); - expect(stdout[0]).toContain(`Release notes for Altertable CLI v${version}:`); - expect(stdout[0]).not.toContain("What's new"); - if (version === VERSION) { - expect(stdout[0]).not.toContain("Commit log:"); + ])( + 'describes a forced $outcome accurately', + async ({ version, announcement, outcome }) => { + const runtime = createCliRuntime({ + debug: false, + json: false, + agent: false, + }); + const stdout: string[] = []; + const stderr: string[] = []; + runtime.output.writeHuman = text => stdout.push(text); + runtime.output.writeMetadata = lines => stderr.push(...lines); + let installedVersion = ''; + await executeUpdateCommand({ version, force: true }, runtime.output, { + checkForUpdate, + async installCliUpdate(version) { + installedVersion = version; + return { + installed_version: version, + verified_version: version, + method: 'github-binary', + target_path: '/tmp/altertable', + }; + }, + }); + expect(installedVersion).toBe(version); + expect(stderr).toEqual([announcement]); + expect(stdout[0]).toStartWith(`${outcome}\n`); + expect(stdout[0]).toContain( + `Release notes for Altertable CLI v${version}:` + ); + expect(stdout[0]).not.toContain("What's new"); + if (version === VERSION) { + expect(stdout[0]).not.toContain('Commit log:'); + } } - }); + ); - test("rejects the removed install positional", () => { - return expect(runUpdateCommand(["update", "install", "--check"])).rejects.toThrow( - "Invalid update version: install.", - ); + test('rejects the removed install positional', () => { + return expect( + runUpdateCommand(['update', 'install', '--check']) + ).rejects.toThrow('Invalid update version: install.'); }); - test("rejects extra positional versions", () => { + test('rejects extra positional versions', () => { return expect( - runUpdateCommand(["update", UPDATE_TEST_VERSION, "extra", "--check"]), - ).rejects.toThrow("Unexpected argument: extra."); + runUpdateCommand(['update', UPDATE_TEST_VERSION, 'extra', '--check']) + ).rejects.toThrow('Unexpected argument: extra.'); }); - test("only exposes the simple public arguments", () => { - expect(Object.keys(updateCommand.args ?? {}).sort()).toEqual(["check", "force", "version"]); + test('only exposes the simple public arguments', () => { + expect(Object.keys(updateCommand.args ?? {}).sort()).toEqual([ + 'check', + 'force', + 'version', + ]); }); - test("rejects an invalid source environment override", () => { - process.env.ALTERTABLE_UPDATE_SOURCE = "invalid"; + test('rejects an invalid source environment override', () => { + process.env.ALTERTABLE_UPDATE_SOURCE = 'invalid'; expect(() => resolveUpdateSource()).toThrow(ConfigurationError); }); diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index a4baf247..701d3edf 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -1,20 +1,27 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { chmodSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; -import { createHash, randomBytes } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import type { CliContext } from "@/context.ts"; -import { isJsonOutput } from "@/context.ts"; -import { USER_AGENT, VERSION } from "@/version.ts"; -import { configDir, configGetGlobal } from "@/lib/config.ts"; -import { CliError, HttpError, NetworkError } from "@/lib/errors.ts"; -import { hasObjectKey } from "@/lib/object.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { getOutputSink } from "@/lib/runtime.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import { span } from "@/ui/document.ts"; -import { copyProcessEnv, readEnv, readEnvFrom } from "@/lib/env.ts"; -import { resolveProcessExecutablePath } from "@/lib/executable-path.ts"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { chmodSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import type { CliContext } from '@/context.ts'; +import { isJsonOutput } from '@/context.ts'; +import { USER_AGENT, VERSION } from '@/version.ts'; +import { configDir, configGetGlobal } from '@/lib/config.ts'; +import { CliError, HttpError, NetworkError } from '@/lib/errors.ts'; +import { hasObjectKey } from '@/lib/object.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { getOutputSink } from '@/lib/runtime.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { span } from '@/ui/document.ts'; +import { copyProcessEnv, readEnv, readEnvFrom } from '@/lib/env.ts'; +import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; import { UpdaterInstallationKind, UpdaterCheckIntervals, @@ -27,7 +34,7 @@ import { type UpdateCheckInterval, type UpdateInstallMethod, type UpdateSource, -} from "@/lib/updater-config.ts"; +} from '@/lib/updater-config.ts'; export type ReleaseInfo = { version: string; @@ -106,7 +113,7 @@ type FetchGitHubBinaryReleaseOptions = { type SpawnSyncImpl = typeof spawnSync; type RunInstallPlanOptions = { - stdio?: "inherit" | "pipe"; + stdio?: 'inherit' | 'pipe'; spawnImpl?: SpawnSyncImpl; verifyCommand?: string; verifyGlobalInstall?: boolean; @@ -121,7 +128,7 @@ type InstallCliUpdateOptions = { timeoutMs?: number; fetchImpl?: typeof fetch; spawnImpl?: SpawnSyncImpl; - stdio?: "inherit" | "pipe"; + stdio?: 'inherit' | 'pipe'; verify?: boolean; installation?: CurrentInstallation; }; @@ -134,27 +141,31 @@ type AutomaticNoticeOptions = FetchLatestOptions & { stderrIsTTY?: boolean; }; -const AUTO_SKIP_COMMANDS: ReadonlySet = new Set(UpdaterConfig.automaticCheckSkipCommands); +const AUTO_SKIP_COMMANDS: ReadonlySet = new Set( + UpdaterConfig.automaticCheckSkipCommands +); function parseVersion(version: string): ParsedVersion | undefined { const match = version .trim() - .replace(/^v/i, "") + .replace(/^v/i, '') .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/); if (!match) { return undefined; } return { - major: Number.parseInt(match[1] ?? "0", 10), - minor: Number.parseInt(match[2] ?? "0", 10), - patch: Number.parseInt(match[3] ?? "0", 10), - prerelease: match[4] ? match[4].split(".") : [], + major: Number.parseInt(match[1] ?? '0', 10), + minor: Number.parseInt(match[2] ?? '0', 10), + patch: Number.parseInt(match[3] ?? '0', 10), + prerelease: match[4] ? match[4].split('.') : [], }; } function comparePrereleaseIdentifier(left: string, right: string): number { const leftNumber = /^\d+$/.test(left) ? Number.parseInt(left, 10) : undefined; - const rightNumber = /^\d+$/.test(right) ? Number.parseInt(right, 10) : undefined; + const rightNumber = /^\d+$/.test(right) + ? Number.parseInt(right, 10) + : undefined; if (leftNumber !== undefined && rightNumber !== undefined) { return Math.sign(leftNumber - rightNumber); @@ -169,21 +180,26 @@ function comparePrereleaseIdentifier(left: string, right: string): number { } export function normalizeVersion(version: string): string { - return version.trim().replace(/^v/i, ""); + return version.trim().replace(/^v/i, ''); } export function isValidVersion(version: string): boolean { return parseVersion(version) !== undefined; } -export function compareVersions(leftVersion: string, rightVersion: string): number { +export function compareVersions( + leftVersion: string, + rightVersion: string +): number { const left = parseVersion(leftVersion); const right = parseVersion(rightVersion); if (!left || !right) { - return normalizeVersion(leftVersion).localeCompare(normalizeVersion(rightVersion)); + return normalizeVersion(leftVersion).localeCompare( + normalizeVersion(rightVersion) + ); } - for (const key of ["major", "minor", "patch"] as const) { + for (const key of ['major', 'minor', 'patch'] as const) { const delta = left[key] - right[key]; if (delta !== 0) { return Math.sign(delta); @@ -210,7 +226,10 @@ export function compareVersions(leftVersion: string, rightVersion: string): numb if (rightIdentifier === undefined) { return 1; } - const compared = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + const compared = comparePrereleaseIdentifier( + leftIdentifier, + rightIdentifier + ); if (compared !== 0) { return Math.sign(compared); } @@ -224,9 +243,11 @@ function updaterStateFile(): string { function writeJsonFileAtomic(filePath: string, data: unknown): void { mkdirSync(dirname(filePath), { recursive: true }); - const tmpPath = tempSiblingPath(filePath, "update"); + const tmpPath = tempSiblingPath(filePath, 'update'); try { - writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 }); + writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); renameSync(tmpPath, filePath); } finally { rmSync(tmpPath, { force: true }); @@ -240,8 +261,10 @@ function writeJsonFileAtomic(filePath: string, data: unknown): void { export function readUpdateState(): UpdateState { try { - const parsed = JSON.parse(readFileSync(updaterStateFile(), "utf8")) as UpdateState; - if (typeof parsed === "object" && parsed !== null) { + const parsed = JSON.parse( + readFileSync(updaterStateFile(), 'utf8') + ) as UpdateState; + if (typeof parsed === 'object' && parsed !== null) { return parsed; } } catch { @@ -262,7 +285,9 @@ function tryWriteUpdateState(state: UpdateState): void { } } -export function parseUpdateCheckInterval(value: string): UpdateCheckInterval | undefined { +export function parseUpdateCheckInterval( + value: string +): UpdateCheckInterval | undefined { if (isAllowedValue(UpdaterCheckIntervals, value)) { return value; } @@ -271,48 +296,58 @@ export function parseUpdateCheckInterval(value: string): UpdateCheckInterval | u export function getUpdateCheckInterval(): UpdateCheckInterval { const fromConfig = parseUpdateCheckInterval( - configGetGlobal(UpdaterConfig.configKeys.checkInterval), + configGetGlobal(UpdaterConfig.configKeys.checkInterval) ); return fromConfig ?? UpdaterConfig.defaults.checkInterval; } export function resolveUpdateSource(source?: UpdateSource): UpdateSource { - return source ?? readEnv("ALTERTABLE_UPDATE_SOURCE") ?? UpdaterConfig.defaults.source; + return ( + source ?? + readEnv('ALTERTABLE_UPDATE_SOURCE') ?? + UpdaterConfig.defaults.source + ); } -export function resolveUpdateInstallMethod(method?: UpdateInstallMethod): UpdateInstallMethod { +export function resolveUpdateInstallMethod( + method?: UpdateInstallMethod +): UpdateInstallMethod { return ( - method ?? readEnv("ALTERTABLE_UPDATE_INSTALL_METHOD") ?? UpdaterConfig.defaults.installMethod + method ?? + readEnv('ALTERTABLE_UPDATE_INSTALL_METHOD') ?? + UpdaterConfig.defaults.installMethod ); } function isAllowedValue( values: readonly TValue[], - value: string | undefined, + value: string | undefined ): value is TValue { return value !== undefined && values.includes(value as TValue); } function repoSegments(repo: string): [string, string] { - const segments = repo.split("/").filter((segment) => segment.length > 0); + const segments = repo.split('/').filter(segment => segment.length > 0); if (segments.length !== 2) { throw new CliError(`Invalid GitHub repository slug: ${repo}.`); } - return [segments[0] ?? "", segments[1] ?? ""]; + return [segments[0] ?? '', segments[1] ?? '']; } function npmRegistryUrl(): string { const registry = - readEnv("ALTERTABLE_UPDATE_REGISTRY_URL") ?? UpdaterConfig.sources.npm.registryUrl; + readEnv('ALTERTABLE_UPDATE_REGISTRY_URL') ?? + UpdaterConfig.sources.npm.registryUrl; const url = new URL(registry); - appendEncodedUrlPath(url, UpdaterConfig.packageName, "latest"); + appendEncodedUrlPath(url, UpdaterConfig.packageName, 'latest'); return url.toString(); } function githubLatestReleaseUrl(): string { - const repo = readEnv("ALTERTABLE_UPDATE_GITHUB_REPO") ?? UpdaterConfig.githubRepo; + const repo = + readEnv('ALTERTABLE_UPDATE_GITHUB_REPO') ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); - appendEncodedUrlPath(url, ...repoSegments(repo), "releases", "latest"); + appendEncodedUrlPath(url, ...repoSegments(repo), 'releases', 'latest'); return url.toString(); } @@ -320,21 +355,26 @@ function githubReleaseMetadataUrl(version?: string): string { if (!version) { return githubLatestReleaseUrl(); } - const repo = readEnv("ALTERTABLE_UPDATE_GITHUB_REPO") ?? UpdaterConfig.githubRepo; + const repo = + readEnv('ALTERTABLE_UPDATE_GITHUB_REPO') ?? UpdaterConfig.githubRepo; const url = new URL(UpdaterConfig.sources.github.apiBaseUrl); appendEncodedUrlPath( url, ...repoSegments(repo), - "releases", - "tags", - `v${normalizeVersion(version)}`, + 'releases', + 'tags', + `v${normalizeVersion(version)}` ); return url.toString(); } function githubReleasesFallbackUrl(): string { const url = new URL(UpdaterConfig.sources.github.webBaseUrl); - appendEncodedUrlPath(url, ...repoSegments(UpdaterConfig.githubRepo), "releases"); + appendEncodedUrlPath( + url, + ...repoSegments(UpdaterConfig.githubRepo), + 'releases' + ); return url.toString(); } @@ -343,54 +383,66 @@ function githubReleaseTagUrl(version: string): string { appendEncodedUrlPath( url, ...repoSegments(UpdaterConfig.githubRepo), - "releases", - "tag", - `v${normalizeVersion(version)}`, + 'releases', + 'tag', + `v${normalizeVersion(version)}` ); return url.toString(); } export function packageReleaseUrl(version: string): string { const url = new URL(UpdaterConfig.sources.npm.packageBaseUrl); - appendEncodedUrlPath(url, UpdaterConfig.packageName, "v", normalizeVersion(version)); + appendEncodedUrlPath( + url, + UpdaterConfig.packageName, + 'v', + normalizeVersion(version) + ); return url.toString(); } -export function releaseUrlForSource(source: UpdateSource, version: string): string { - if (source === "github") { +export function releaseUrlForSource( + source: UpdateSource, + version: string +): string { + if (source === 'github') { return githubReleaseTagUrl(version); } return packageReleaseUrl(version); } function appendEncodedUrlPath(url: URL, ...rawSegments: string[]): void { - const baseSegments = url.pathname.split("/").filter((segment) => segment.length > 0); + const baseSegments = url.pathname + .split('/') + .filter(segment => segment.length > 0); url.pathname = [...baseSegments, ...rawSegments.map(encodeURIComponent)] - .filter((segment) => segment.length > 0) - .join("/") - .replace(/^/, "/"); + .filter(segment => segment.length > 0) + .join('/') + .replace(/^/, '/'); } async function fetchResponse( url: string, timeoutMs: number, - fetchImpl: typeof fetch, + fetchImpl: typeof fetch ): Promise { let response: Response; try { response = await fetchImpl(url, { headers: { - Accept: "application/json", - "User-Agent": USER_AGENT, + Accept: 'application/json', + 'User-Agent': USER_AGENT, }, signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { - throw new NetworkError("Unable to check for CLI updates.", { cause: error }); + throw new NetworkError('Unable to check for CLI updates.', { + cause: error, + }); } if (!response.ok) { - let body = ""; + let body = ''; try { body = await response.text(); } catch { @@ -399,7 +451,7 @@ async function fetchResponse( throw new HttpError({ status: response.status, body, - method: "GET", + method: 'GET', url, }); } @@ -410,41 +462,44 @@ async function fetchResponse( async function fetchJson( url: string, timeoutMs: number, - fetchImpl: typeof fetch, + fetchImpl: typeof fetch ): Promise { const response = await fetchResponse(url, timeoutMs, fetchImpl); try { return await response.json(); } catch (error) { - throw new CliError("Update metadata was not valid JSON.", { cause: error }); + throw new CliError('Update metadata was not valid JSON.', { cause: error }); } } function updateMetadataNotFoundError( source: UpdateSource, error: HttpError, - version?: string, + version?: string ): CliError { - if (source === "github") { - const label = version ? `v${normalizeVersion(version)}` : "latest"; + if (source === 'github') { + const label = version ? `v${normalizeVersion(version)}` : 'latest'; return new CliError(`No GitHub release metadata found for ${label}.`, { cause: error, details: [ `Checked: ${error.url}`, `Open releases: ${githubReleasesFallbackUrl()}`, - "If you are running from a source checkout, update it with git pull.", - ].join("\n"), + 'If you are running from a source checkout, update it with git pull.', + ].join('\n'), }); } - return new CliError(`No published npm release found for ${UpdaterConfig.packageName}.`, { - cause: error, - details: [ - `Checked: ${error.url}`, - "Try GitHub releases instead: altertable update --source github", - "If you are running from a source checkout, update it with git pull.", - ].join("\n"), - }); + return new CliError( + `No published npm release found for ${UpdaterConfig.packageName}.`, + { + cause: error, + details: [ + `Checked: ${error.url}`, + 'Try GitHub releases instead: altertable update --source github', + 'If you are running from a source checkout, update it with git pull.', + ].join('\n'), + } + ); } async function fetchUpdateJson( @@ -452,7 +507,7 @@ async function fetchUpdateJson( url: string, timeoutMs: number, fetchImpl: typeof fetch, - version?: string, + version?: string ): Promise { try { return await fetchJson(url, timeoutMs, fetchImpl); @@ -464,7 +519,11 @@ async function fetchUpdateJson( } } -async function fetchText(url: string, timeoutMs: number, fetchImpl: typeof fetch): Promise { +async function fetchText( + url: string, + timeoutMs: number, + fetchImpl: typeof fetch +): Promise { const response = await fetchResponse(url, timeoutMs, fetchImpl); return await response.text(); } @@ -472,22 +531,22 @@ async function fetchText(url: string, timeoutMs: number, fetchImpl: typeof fetch async function fetchBytes( url: string, timeoutMs: number, - fetchImpl: typeof fetch, + fetchImpl: typeof fetch ): Promise { const response = await fetchResponse(url, timeoutMs, fetchImpl); return new Uint8Array(await response.arrayBuffer()); } function readStringProperty(data: unknown, key: string): string { - if (typeof data !== "object" || data === null || !(key in data)) { - return ""; + if (typeof data !== 'object' || data === null || !(key in data)) { + return ''; } const value = (data as Record)[key]; - return typeof value === "string" ? value : ""; + return typeof value === 'string' ? value : ''; } function readArrayProperty(data: unknown, key: string): unknown[] { - if (typeof data !== "object" || data === null || !(key in data)) { + if (typeof data !== 'object' || data === null || !(key in data)) { return []; } const value = (data as Record)[key]; @@ -496,7 +555,7 @@ function readArrayProperty(data: unknown, key: string): unknown[] { export function detectReleasePlatform( platform: NodeJS.Platform = process.platform, - arch: string = process.arch, + arch: string = process.arch ): ReleasePlatform { const releasePlatform = `${platform}-${arch}`; if (hasObjectKey(UpdaterConfig.releasePlatforms, releasePlatform)) { @@ -511,9 +570,11 @@ export function releaseAssetName(platform: ReleasePlatform): string { return UpdaterConfig.releasePlatforms[platform].asset; } -function readGitHubAsset(data: unknown): { name: string; downloadUrl: string } | undefined { - const name = readStringProperty(data, "name"); - const downloadUrl = readStringProperty(data, "browser_download_url"); +function readGitHubAsset( + data: unknown +): { name: string; downloadUrl: string } | undefined { + const name = readStringProperty(data, 'name'); + const downloadUrl = readStringProperty(data, 'browser_download_url'); if (!name || !downloadUrl) { return undefined; } @@ -521,7 +582,7 @@ function readGitHubAsset(data: unknown): { name: string; downloadUrl: string } | } function findGitHubAsset(data: unknown, assetName: string): string { - for (const rawAsset of readArrayProperty(data, "assets")) { + for (const rawAsset of readArrayProperty(data, 'assets')) { const asset = readGitHubAsset(rawAsset); if (asset?.name === assetName) { return asset.downloadUrl; @@ -531,30 +592,34 @@ function findGitHubAsset(data: unknown, assetName: string): string { } export async function fetchGitHubBinaryRelease( - options: FetchGitHubBinaryReleaseOptions = {}, + options: FetchGitHubBinaryReleaseOptions = {} ): Promise { const platform = options.platform ?? detectReleasePlatform(); const assetName = releaseAssetName(platform); const timeoutMs = options.timeoutMs ?? UpdaterConfig.timeoutsMs.manual; const fetchImpl = options.fetchImpl ?? fetch; const data = await fetchUpdateJson( - "github", + 'github', githubReleaseMetadataUrl(options.version), timeoutMs, fetchImpl, - options.version, + options.version ); - const version = normalizeVersion(readStringProperty(data, "tag_name")); + const version = normalizeVersion(readStringProperty(data, 'tag_name')); if (!version) { - throw new CliError("GitHub release metadata did not include a tag_name."); + throw new CliError('GitHub release metadata did not include a tag_name.'); } return { version, - releaseUrl: readStringProperty(data, "html_url") || githubReleaseTagUrl(version), + releaseUrl: + readStringProperty(data, 'html_url') || githubReleaseTagUrl(version), assetName, assetDownloadUrl: findGitHubAsset(data, assetName), - checksumsDownloadUrl: findGitHubAsset(data, UpdaterConfig.checksumsAssetName), + checksumsDownloadUrl: findGitHubAsset( + data, + UpdaterConfig.checksumsAssetName + ), }; } @@ -579,32 +644,47 @@ export function parseChecksums(text: string): Map { } export function verifySha256(filePath: string, expectedHash: string): boolean { - const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex"); + const actualHash = createHash('sha256') + .update(readFileSync(filePath)) + .digest('hex'); return actualHash === expectedHash.toLowerCase(); } -export async function fetchLatestRelease(options: FetchLatestOptions = {}): Promise { +export async function fetchLatestRelease( + options: FetchLatestOptions = {} +): Promise { const source = resolveUpdateSource(options.source); const timeoutMs = options.timeoutMs ?? UpdaterConfig.timeoutsMs.manual; const fetchImpl = options.fetchImpl ?? fetch; - if (source === "github") { - const data = await fetchUpdateJson(source, githubLatestReleaseUrl(), timeoutMs, fetchImpl); - const version = normalizeVersion(readStringProperty(data, "tag_name")); + if (source === 'github') { + const data = await fetchUpdateJson( + source, + githubLatestReleaseUrl(), + timeoutMs, + fetchImpl + ); + const version = normalizeVersion(readStringProperty(data, 'tag_name')); if (!version) { - throw new CliError("GitHub release metadata did not include a tag_name."); + throw new CliError('GitHub release metadata did not include a tag_name.'); } return { version, source, - releaseUrl: readStringProperty(data, "html_url") || githubReleasesFallbackUrl(), + releaseUrl: + readStringProperty(data, 'html_url') || githubReleasesFallbackUrl(), }; } - const data = await fetchUpdateJson(source, npmRegistryUrl(), timeoutMs, fetchImpl); - const version = normalizeVersion(readStringProperty(data, "version")); + const data = await fetchUpdateJson( + source, + npmRegistryUrl(), + timeoutMs, + fetchImpl + ); + const version = normalizeVersion(readStringProperty(data, 'version')); if (!version) { - throw new CliError("npm package metadata did not include a version."); + throw new CliError('npm package metadata did not include a version.'); } return { version, @@ -613,34 +693,36 @@ export async function fetchLatestRelease(options: FetchLatestOptions = {}): Prom }; } -export function detectInstallManager(env: NodeJS.ProcessEnv = copyProcessEnv()): InstallManager { - const override = readEnvFrom(env, "ALTERTABLE_UPDATE_INSTALLER"); +export function detectInstallManager( + env: NodeJS.ProcessEnv = copyProcessEnv() +): InstallManager { + const override = readEnvFrom(env, 'ALTERTABLE_UPDATE_INSTALLER'); if (override) { return override; } - const userAgent = readEnvFrom(env, "npm_config_user_agent") ?? ""; - if (userAgent.startsWith("bun/")) { - return "bun"; + const userAgent = readEnvFrom(env, 'npm_config_user_agent') ?? ''; + if (userAgent.startsWith('bun/')) { + return 'bun'; } - if (userAgent.startsWith("pnpm/")) { - return "pnpm"; + if (userAgent.startsWith('pnpm/')) { + return 'pnpm'; } - if (userAgent.startsWith("yarn/")) { - return "yarn"; + if (userAgent.startsWith('yarn/')) { + return 'yarn'; } - if (userAgent.startsWith("npm/")) { - return "npm"; + if (userAgent.startsWith('npm/')) { + return 'npm'; } - if (readEnvFrom(env, "BUN_INSTALL")) { - return "bun"; + if (readEnvFrom(env, 'BUN_INSTALL')) { + return 'bun'; } return UpdaterConfig.defaults.installManager; } export function createInstallPlan( version: string, - manager: InstallManager = detectInstallManager(), + manager: InstallManager = detectInstallManager() ): InstallPlan { const packageSpecifier = `${UpdaterConfig.packageName}@${normalizeVersion(version)}`; const plan = UpdaterConfig.installCommands[manager]; @@ -649,24 +731,27 @@ export function createInstallPlan( manager, command: plan.command, args, - display: [plan.command, ...args].join(" "), + display: [plan.command, ...args].join(' '), }; } function pathEndsWith(filePath: string, suffix: string): boolean { - return filePath.replace(/\\/g, "/").endsWith(suffix); + return filePath.replace(/\\/g, '/').endsWith(suffix); } function anyEndsWith(value: string, suffixes: readonly string[]): boolean { - return suffixes.some((suffix) => value.endsWith(suffix)); + return suffixes.some(suffix => value.endsWith(suffix)); } function anyEquals(value: string, values: readonly string[]): boolean { - return values.some((candidate) => candidate === value); + return values.some(candidate => candidate === value); } -function pathEndsWithAny(filePath: string, suffixes: readonly string[]): boolean { - return suffixes.some((suffix) => pathEndsWith(filePath, suffix)); +function pathEndsWithAny( + filePath: string, + suffixes: readonly string[] +): boolean { + return suffixes.some(suffix => pathEndsWith(filePath, suffix)); } export function detectCurrentInstallation( @@ -676,14 +761,14 @@ export function detectCurrentInstallation( argv?: readonly string[]; cwd?: string; path?: string; - } = {}, + } = {} ): CurrentInstallation { const execPath = resolveProcessExecutablePath({ ...options, - path: options.path ?? readEnv("PATH"), + path: options.path ?? readEnv('PATH'), }); const argv = options.argv ?? process.argv; - const scriptPath = argv[1] ?? ""; + const scriptPath = argv[1] ?? ''; const execName = basename(execPath); const scriptName = basename(scriptPath); const detection = UpdaterConfig.installationDetection; @@ -695,7 +780,7 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.source, executablePath: scriptPath || execPath, - reason: "running from TypeScript source", + reason: 'running from TypeScript source', }; } @@ -707,7 +792,7 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.packageManager, executablePath: scriptPath || execPath, - reason: "running a JavaScript package entrypoint", + reason: 'running a JavaScript package entrypoint', }; } @@ -718,7 +803,7 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.nativeBinary, executablePath: execPath, - reason: "running a native release binary", + reason: 'running a native release binary', }; } @@ -729,20 +814,20 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.source, executablePath: scriptPath || execPath, - reason: "running under Bun", + reason: 'running under Bun', }; } return { kind: UpdaterInstallationKind.unknown, executablePath: execPath, - reason: "unable to identify installation origin", + reason: 'unable to identify installation origin', }; } function resolveInstallMethodForInstallation( requestedMethod: UpdateInstallMethod, - installation: CurrentInstallation, + installation: CurrentInstallation ): ResolvedUpdateInstallMethod { if (requestedMethod !== UpdaterInstallMethod.auto) { return requestedMethod; @@ -754,47 +839,60 @@ function resolveInstallMethodForInstallation( } function spawnOutputText(value: unknown): string { - if (typeof value === "string") { + if (typeof value === 'string') { return value; } if (value instanceof Uint8Array) { - return Buffer.from(value).toString("utf8"); + return Buffer.from(value).toString('utf8'); } - return ""; + return ''; } export function verifyInstalledCliVersion( expectedVersion: string, - command = "altertable", + command = 'altertable', spawnImpl: SpawnSyncImpl = spawnSync, - description = "Installed Altertable CLI", + description = 'Installed Altertable CLI' ): string { - const result = spawnImpl(command, ["--version"], { encoding: "utf8" }); + const result = spawnImpl(command, ['--version'], { encoding: 'utf8' }); if (result.error) { - throw new CliError(`Unable to verify ${description.toLowerCase()} version.`, { - cause: result.error, - }); + throw new CliError( + `Unable to verify ${description.toLowerCase()} version.`, + { + cause: result.error, + } + ); } if (result.status !== 0) { - throw new CliError(`${description} version check failed with exit code ${result.status ?? 1}.`); + throw new CliError( + `${description} version check failed with exit code ${result.status ?? 1}.` + ); } - const installedVersion = normalizeVersion(spawnOutputText(result.stdout).trim()); + const installedVersion = normalizeVersion( + spawnOutputText(result.stdout).trim() + ); if (compareVersions(installedVersion, expectedVersion) !== 0) { throw new CliError( - `${description} version is ${installedVersion || "unknown"}, expected ${normalizeVersion( - expectedVersion, - )}.`, + `${description} version is ${installedVersion || 'unknown'}, expected ${normalizeVersion( + expectedVersion + )}.` ); } return installedVersion; } function tempSiblingPath(targetPath: string, suffix: string): string { - return join(dirname(targetPath), `.altertable-${suffix}-${randomBytes(8).toString("hex")}`); + return join( + dirname(targetPath), + `.altertable-${suffix}-${randomBytes(8).toString('hex')}` + ); } -function replaceFileAtomically(targetPath: string, replacementPath: string): void { - const backupPath = tempSiblingPath(targetPath, "backup"); +function replaceFileAtomically( + targetPath: string, + replacementPath: string +): void { + const backupPath = tempSiblingPath(targetPath, 'backup'); let backupCreated = false; try { renameSync(targetPath, backupPath); @@ -810,7 +908,7 @@ function replaceFileAtomically(targetPath: string, replacementPath: string): voi } } rmSync(replacementPath, { force: true }); - throw new CliError("Unable to replace the Altertable CLI binary.", { + throw new CliError('Unable to replace the Altertable CLI binary.', { cause: error, details: `Backup path: ${backupPath}`, }); @@ -820,11 +918,17 @@ function replaceFileAtomically(targetPath: string, replacementPath: string): voi function resolveGitHubBinaryTarget(options: InstallCliUpdateOptions): string { const installation = options.installation ?? detectCurrentInstallation(); const targetPath = options.targetPath ?? installation.executablePath; - if (!options.targetPath && installation.kind !== UpdaterInstallationKind.nativeBinary) { - throw new CliError("Self-update is only supported for native release binaries.", { - details: - "Install the published package with your package manager, or use a native release binary.", - }); + if ( + !options.targetPath && + installation.kind !== UpdaterInstallationKind.nativeBinary + ) { + throw new CliError( + 'Self-update is only supported for native release binaries.', + { + details: + 'Install the published package with your package manager, or use a native release binary.', + } + ); } return targetPath; } @@ -835,25 +939,31 @@ async function writeVerifiedGitHubBinaryDownload(options: { timeoutMs: number; fetchImpl: typeof fetch; }): Promise { - const tempPath = tempSiblingPath(options.targetPath, "download"); + const tempPath = tempSiblingPath(options.targetPath, 'download'); try { const bytes = await fetchBytes( options.release.assetDownloadUrl, options.timeoutMs, - options.fetchImpl, + options.fetchImpl ); writeFileSync(tempPath, bytes, { mode: 0o755 }); const checksumsText = await fetchText( options.release.checksumsDownloadUrl, options.timeoutMs, - options.fetchImpl, + options.fetchImpl + ); + const expectedHash = parseChecksums(checksumsText).get( + options.release.assetName ); - const expectedHash = parseChecksums(checksumsText).get(options.release.assetName); if (!expectedHash) { - throw new CliError(`Checksum file did not include ${options.release.assetName}.`); + throw new CliError( + `Checksum file did not include ${options.release.assetName}.` + ); } if (!verifySha256(tempPath, expectedHash)) { - throw new CliError(`Checksum verification failed for ${options.release.assetName}.`); + throw new CliError( + `Checksum verification failed for ${options.release.assetName}.` + ); } chmodSync(tempPath, 0o755); return tempPath; @@ -862,13 +972,15 @@ async function writeVerifiedGitHubBinaryDownload(options: { if (error instanceof CliError) { throw error; } - throw new CliError("Unable to install GitHub release binary.", { cause: error }); + throw new CliError('Unable to install GitHub release binary.', { + cause: error, + }); } } export async function installGitHubBinaryRelease( version: string, - options: InstallCliUpdateOptions = {}, + options: InstallCliUpdateOptions = {} ): Promise { const targetPath = resolveGitHubBinaryTarget(options); const timeoutMs = options.timeoutMs ?? UpdaterConfig.timeoutsMs.manual; @@ -893,7 +1005,7 @@ export async function installGitHubBinaryRelease( release.version, tempPath, options.spawnImpl, - "Downloaded Altertable CLI", + 'Downloaded Altertable CLI' ); replaceFileAtomically(targetPath, tempPath); @@ -908,61 +1020,83 @@ export async function installGitHubBinaryRelease( } } -export function runInstallPlan(plan: InstallPlan, options: RunInstallPlanOptions = {}): string { - const stdio = options.stdio ?? "inherit"; +export function runInstallPlan( + plan: InstallPlan, + options: RunInstallPlanOptions = {} +): string { + const stdio = options.stdio ?? 'inherit'; const spawnImpl = options.spawnImpl ?? spawnSync; const result = spawnImpl(plan.command, plan.args, { stdio }); if (result.error) { - throw new CliError(`Unable to run ${plan.command}.`, { cause: result.error }); + throw new CliError(`Unable to run ${plan.command}.`, { + cause: result.error, + }); } if (result.signal) { throw new CliError(`Update command was interrupted by ${result.signal}.`); } if (result.status !== 0) { const stderr = spawnOutputText(result.stderr).trim(); - throw new CliError(`Update command failed with exit code ${result.status ?? 1}.`, { - details: stderr || undefined, - }); + throw new CliError( + `Update command failed with exit code ${result.status ?? 1}.`, + { + details: stderr || undefined, + } + ); } if (options.verify === false) { - return normalizeVersion(options.expectedVersion ?? ""); + return normalizeVersion(options.expectedVersion ?? ''); } const verifyCommand = options.verifyCommand ?? (options.verifyGlobalInstall ? resolveGlobalInstalledCliPath(plan.manager, spawnImpl) - : "altertable"); + : 'altertable'); return verifyInstalledCliVersion( - options.expectedVersion ?? plan.args[plan.args.length - 1]?.split("@").pop() ?? "", + options.expectedVersion ?? + plan.args[plan.args.length - 1]?.split('@').pop() ?? + '', verifyCommand, - spawnImpl, + spawnImpl ); } -function resolveGlobalInstalledCliPath(manager: InstallManager, spawnImpl: SpawnSyncImpl): string { +function resolveGlobalInstalledCliPath( + manager: InstallManager, + spawnImpl: SpawnSyncImpl +): string { const config = UpdaterConfig.installCommands[manager]; - const result = spawnImpl(config.command, [...config.globalBinArgs], { encoding: "utf8" }); + const result = spawnImpl(config.command, [...config.globalBinArgs], { + encoding: 'utf8', + }); if (result.error) { - throw new CliError(`Unable to locate the ${manager} global binary directory.`, { - cause: result.error, - }); + throw new CliError( + `Unable to locate the ${manager} global binary directory.`, + { + cause: result.error, + } + ); } if (result.status !== 0) { throw new CliError( - `${manager} global binary directory lookup failed with exit code ${result.status ?? 1}.`, + `${manager} global binary directory lookup failed with exit code ${result.status ?? 1}.` ); } const outputPath = spawnOutputText(result.stdout).trim(); if (!outputPath) { - throw new CliError(`${manager} did not report its global binary directory.`); + throw new CliError( + `${manager} did not report its global binary directory.` + ); } const binDirectory = - "globalBinIsPrefix" in config && config.globalBinIsPrefix && process.platform !== "win32" - ? join(outputPath, "bin") + 'globalBinIsPrefix' in config && + config.globalBinIsPrefix && + process.platform !== 'win32' + ? join(outputPath, 'bin') : outputPath; const executableName = - process.platform === "win32" + process.platform === 'win32' ? `${UpdaterConfig.executableName}.cmd` : UpdaterConfig.executableName; return join(binDirectory, executableName); @@ -970,14 +1104,15 @@ function resolveGlobalInstalledCliPath(manager: InstallManager, spawnImpl: Spawn function installPackageManagerRelease( version: string, - options: InstallCliUpdateOptions = {}, + options: InstallCliUpdateOptions = {} ): UpdateInstallResult { const plan = createInstallPlan(version); const verifiedVersion = runInstallPlan(plan, { stdio: options.stdio, spawnImpl: options.spawnImpl, expectedVersion: version, - verifyGlobalInstall: options.installation?.kind === UpdaterInstallationKind.source, + verifyGlobalInstall: + options.installation?.kind === UpdaterInstallationKind.source, verify: options.verify, }); return { @@ -991,14 +1126,20 @@ function installPackageManagerRelease( export async function installCliUpdate( version: string, - options: InstallCliUpdateOptions = {}, + options: InstallCliUpdateOptions = {} ): Promise { const installation = options.installation ?? detectCurrentInstallation(); const requestedMethod = resolveUpdateInstallMethod(options.method); - const method = resolveInstallMethodForInstallation(requestedMethod, installation); + const method = resolveInstallMethodForInstallation( + requestedMethod, + installation + ); if (method === UpdaterInstallMethod.githubBinary) { - return await installGitHubBinaryRelease(version, { ...options, installation }); + return await installGitHubBinaryRelease(version, { + ...options, + installation, + }); } return installPackageManagerRelease(version, { ...options, installation }); @@ -1006,7 +1147,7 @@ export async function installCliUpdate( export function recommendedInstallCommand( version: string, - installation: CurrentInstallation = detectCurrentInstallation(), + installation: CurrentInstallation = detectCurrentInstallation() ): string { if (installation.kind === UpdaterInstallationKind.packageManager) { return createInstallPlan(version).display; @@ -1014,7 +1155,9 @@ export function recommendedInstallCommand( return UpdaterConfig.commands.selfUpdate; } -export async function checkForUpdate(options: FetchLatestOptions = {}): Promise { +export async function checkForUpdate( + options: FetchLatestOptions = {} +): Promise { const release = await fetchLatestRelease(options); const checkedAt = new Date().toISOString(); const result: UpdateCheckResult = { @@ -1038,13 +1181,15 @@ export async function checkForUpdate(options: FetchLatestOptions = {}): Promise< } function envDisablesAutomaticChecks(): boolean { - const noUpdate = readEnv("ALTERTABLE_NO_UPDATE_CHECK"); + const noUpdate = readEnv('ALTERTABLE_NO_UPDATE_CHECK'); if (noUpdate === true) { return true; } - const updateCheck = readEnv("ALTERTABLE_UPDATE_CHECK"); - return updateCheck === "0" || updateCheck === "false" || updateCheck === "never"; + const updateCheck = readEnv('ALTERTABLE_UPDATE_CHECK'); + return ( + updateCheck === '0' || updateCheck === 'false' || updateCheck === 'never' + ); } function intervalMs(interval: UpdateCheckInterval): number { @@ -1088,7 +1233,7 @@ function shouldShowAutomaticUpdateNotice(options: { if (stderrIsTTY !== true) { return false; } - if (readEnv("CI") || readEnv("TEST") || envDisablesAutomaticChecks()) { + if (readEnv('CI') || readEnv('TEST') || envDisablesAutomaticChecks()) { return false; } @@ -1098,25 +1243,36 @@ function shouldShowAutomaticUpdateNotice(options: { } const interval = getUpdateCheckInterval(); - if (interval === "never") { + if (interval === 'never') { return false; } return true; } -function writeAutomaticUpdateNotice(sink: OutputSink, latestVersion: string): void { +function writeAutomaticUpdateNotice( + sink: OutputSink, + latestVersion: string +): void { sink.writeMetadata([ - "", + '', renderDisplayText([ - span(`Update available: Altertable CLI ${latestVersion} (current ${VERSION}).`, "subtle"), + span( + `Update available: Altertable CLI ${latestVersion} (current ${VERSION}).`, + 'subtle' + ), ]), renderDisplayText([ - span(`Run ${recommendedInstallCommand(latestVersion)} to install it.`, "subtle"), + span( + `Run ${recommendedInstallCommand(latestVersion)} to install it.`, + 'subtle' + ), ]), ]); } -export async function maybeShowUpdateNotice(options: AutomaticNoticeOptions): Promise { +export async function maybeShowUpdateNotice( + options: AutomaticNoticeOptions +): Promise { try { if (!shouldShowAutomaticUpdateNotice(options)) { return; @@ -1135,7 +1291,10 @@ export async function maybeShowUpdateNotice(options: AutomaticNoticeOptions): Pr }); latestVersion = result.latest_version; } catch (error) { - const message = error instanceof Error ? error.message : "Unknown update check error."; + const message = + error instanceof Error + ? error.message + : 'Unknown update check error.'; tryWriteUpdateState({ ...previous, last_checked_at: new Date().toISOString(), @@ -1152,7 +1311,10 @@ export async function maybeShowUpdateNotice(options: AutomaticNoticeOptions): Pr } } -export function formatUpdateResult(result: UpdateCheckResult, targetVersion?: string): string { +export function formatUpdateResult( + result: UpdateCheckResult, + targetVersion?: string +): string { if (targetVersion) { const target = normalizeVersion(targetVersion); const current = normalizeVersion(result.current_version); @@ -1160,39 +1322,43 @@ export function formatUpdateResult(result: UpdateCheckResult, targetVersion?: st if (comparison < 0) { return [ renderDisplayText([ - span(`Target version v${target} is older than installed Altertable CLI v${current}.`), + span( + `Target version v${target} is older than installed Altertable CLI v${current}.` + ), ]), renderDisplayText([ - span("Run "), - span(`altertable update ${target} --force`, "accent"), - span(" to install it anyway."), + span('Run '), + span(`altertable update ${target} --force`, 'accent'), + span(' to install it anyway.'), ]), - ].join("\n"); + ].join('\n'); } if (comparison === 0) { - return renderDisplayText([span(`Target version v${target} is already installed.`)]); + return renderDisplayText([ + span(`Target version v${target} is already installed.`), + ]); } } if (!result.update_available) { return renderDisplayText([ - span("Congrats!", "success"), + span('Congrats!', 'success'), span(" You're already on the latest version of Altertable CLI "), - span(`(which is v${normalizeVersion(result.current_version)})`, "subtle"), + span(`(which is v${normalizeVersion(result.current_version)})`, 'subtle'), ]); } return [ renderDisplayText([ span( - `A new version of Altertable CLI is available: v${normalizeVersion(result.latest_version)} `, + `A new version of Altertable CLI is available: v${normalizeVersion(result.latest_version)} ` ), - span(`(current v${normalizeVersion(result.current_version)})`, "subtle"), + span(`(current v${normalizeVersion(result.current_version)})`, 'subtle'), ]), renderDisplayText([ - span("Run "), - span(result.install_command, "accent"), - span(" to install it."), + span('Run '), + span(result.install_command, 'accent'), + span(' to install it.'), ]), - ].join("\n"); + ].join('\n'); } diff --git a/cli/src/lib/url-policy.test.ts b/cli/src/lib/url-policy.test.ts index 2d70447a..75fc141f 100644 --- a/cli/src/lib/url-policy.test.ts +++ b/cli/src/lib/url-policy.test.ts @@ -1,17 +1,21 @@ -import { describe, expect, test } from "bun:test"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; +import { describe, expect, test } from 'bun:test'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; -describe("assertAllowedApiBase", () => { - test("allows HTTPS and local HTTP endpoints", () => { - expect(() => assertAllowedApiBase("https://api.altertable.ai")).not.toThrow(); - expect(() => assertAllowedApiBase("http://localhost:15000")).not.toThrow(); - expect(() => assertAllowedApiBase("http://127.0.0.1:8080")).not.toThrow(); +describe('assertAllowedApiBase', () => { + test('allows HTTPS and local HTTP endpoints', () => { + expect(() => + assertAllowedApiBase('https://api.altertable.ai') + ).not.toThrow(); + expect(() => assertAllowedApiBase('http://localhost:15000')).not.toThrow(); + expect(() => assertAllowedApiBase('http://127.0.0.1:8080')).not.toThrow(); }); - test("requires an explicit opt-in for non-local HTTP endpoints", () => { - const endpoint = "http://192.168.1.5:8080"; + test('requires an explicit opt-in for non-local HTTP endpoints', () => { + const endpoint = 'http://192.168.1.5:8080'; - expect(() => assertAllowedApiBase(endpoint)).toThrow("Insecure HTTP URL"); - expect(() => assertAllowedApiBase(endpoint, { allowInsecureHttp: true })).not.toThrow(); + expect(() => assertAllowedApiBase(endpoint)).toThrow('Insecure HTTP URL'); + expect(() => + assertAllowedApiBase(endpoint, { allowInsecureHttp: true }) + ).not.toThrow(); }); }); diff --git a/cli/src/lib/url-policy.ts b/cli/src/lib/url-policy.ts index 7d52f2e9..58ead537 100644 --- a/cli/src/lib/url-policy.ts +++ b/cli/src/lib/url-policy.ts @@ -1,12 +1,15 @@ -import { CliError } from "@/lib/errors.ts"; +import { CliError } from '@/lib/errors.ts'; -const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]); +const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']); function isLocalhostHttp(url: URL): boolean { - return url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname); + return url.protocol === 'http:' && LOCALHOST_HOSTS.has(url.hostname); } -export function assertAllowedApiBase(url: string, options?: { allowInsecureHttp?: boolean }): void { +export function assertAllowedApiBase( + url: string, + options?: { allowInsecureHttp?: boolean } +): void { let parsed: URL; try { parsed = new URL(url); @@ -14,11 +17,11 @@ export function assertAllowedApiBase(url: string, options?: { allowInsecureHttp? throw new CliError(`Invalid URL: ${url}`); } - if (parsed.protocol === "https:") { + if (parsed.protocol === 'https:') { return; } - if (parsed.protocol === "http:") { + if (parsed.protocol === 'http:') { if (isLocalhostHttp(parsed)) { return; } @@ -26,9 +29,11 @@ export function assertAllowedApiBase(url: string, options?: { allowInsecureHttp? return; } throw new CliError( - `Insecure HTTP URL is only allowed for localhost. Use https:// or pass --allow-insecure-http (not recommended).`, + `Insecure HTTP URL is only allowed for localhost. Use https:// or pass --allow-insecure-http (not recommended).` ); } - throw new CliError(`Unsupported URL scheme: ${parsed.protocol} (use http:// or https://)`); + throw new CliError( + `Unsupported URL scheme: ${parsed.protocol} (use http:// or https://)` + ); } diff --git a/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 94593ca3..1c2787e7 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -1,297 +1,356 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { buildMainCommand } from "@/cli.ts"; -import { getBootstrapCliContext } from "@/context.ts"; -import { defineCommand } from "@/lib/command.ts"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildMainCommand } from '@/cli.ts'; +import { getBootstrapCliContext } from '@/context.ts'; +import { defineCommand } from '@/lib/command.ts'; import { buildStructuredHelp, renderAltertableUsage, resolveSubCommandForUsage, -} from "@/lib/usage.ts"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; -import { VERSION } from "@/version.ts"; -import { span } from "@/ui/document.ts"; -import { getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; -import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; +} from '@/lib/usage.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { VERSION } from '@/version.ts'; +import { span } from '@/ui/document.ts'; +import { + getVisibleTextWidth, + renderDisplayText, +} from '@/ui/terminal/styles.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} from '@/test-utils/terminal.ts'; -describe("renderAltertableUsage", () => { - test("groups root commands before global flags in a custom help panel", async () => { +describe('renderAltertableUsage', () => { + test('groups root commands before global flags in a custom help panel', async () => { const usage = await renderAltertableUsage(buildMainCommand()); - expect(usage.replace(/\s+/g, " ")).toContain( - `Altertable CLI v${VERSION} • Query and manage your data platform from the terminal.`, + expect(usage.replace(/\s+/g, ' ')).toContain( + `Altertable CLI v${VERSION} • Query and manage your data platform from the terminal.` + ); + expect(usage).toContain('Usage\n altertable [flags]'); + expect(usage).toContain('\n Commands\n'); + expect(usage).toContain('\n Commands\n Platform\n'); + expect(usage).toContain('Platform'); + expect(usage).toContain('Ingest'); + expect(usage).toContain('Query'); + const commandsSection = usage.indexOf('\n Commands\n'); + const platformSection = usage.indexOf('\n Platform\n'); + const ingestSection = usage.indexOf('\n Ingest\n'); + const querySection = usage.indexOf('\n Query\n'); + const guidanceSection = usage.indexOf( + '\n Use altertable --help' ); - expect(usage).toContain("Usage\n altertable [flags]"); - expect(usage).toContain("\n Commands\n"); - expect(usage).toContain("\n Commands\n Platform\n"); - expect(usage).toContain("Platform"); - expect(usage).toContain("Ingest"); - expect(usage).toContain("Query"); - const commandsSection = usage.indexOf("\n Commands\n"); - const platformSection = usage.indexOf("\n Platform\n"); - const ingestSection = usage.indexOf("\n Ingest\n"); - const querySection = usage.indexOf("\n Query\n"); - const guidanceSection = usage.indexOf("\n Use altertable --help"); - const rootOptionsSection = usage.indexOf("\n Root options\n"); - const flagsSection = usage.indexOf("\n Global flags\n"); + const rootOptionsSection = usage.indexOf('\n Root options\n'); + const flagsSection = usage.indexOf('\n Global flags\n'); expect(commandsSection).toBeLessThan(platformSection); expect(platformSection).toBeLessThan(ingestSection); expect(ingestSection).toBeLessThan(querySection); expect(querySection).toBeLessThan(guidanceSection); expect(guidanceSection).toBeLessThan(rootOptionsSection); expect(rootOptionsSection).toBeLessThan(flagsSection); - expect(usage).toContain("Use altertable --help for more information about a"); - expect(usage).toContain("command."); - expect(usage).toContain("-h, --help"); - expect(usage).toContain("-v, --version"); + expect(usage).toContain( + 'Use altertable --help for more information about a' + ); + expect(usage).toContain('command.'); + expect(usage).toContain('-h, --help'); + expect(usage).toContain('-v, --version'); }); - test("wraps descriptions with hanging indentation at the terminal width", async () => { + test('wraps descriptions with hanging indentation at the terminal width', async () => { const originalColumns = process.stdout.columns; - Object.defineProperty(process.stdout, "columns", { value: 80, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { + value: 80, + configurable: true, + }); try { const usage = await renderAltertableUsage(buildMainCommand()); expect(usage).toContain( - ` catalog Manage catalogs (databases and connections) in the current\n${" ".repeat(16)}environment.`, + ` catalog Manage catalogs (databases and connections) in the current\n${' '.repeat(16)}environment.` ); expect(usage).toContain( - ` --agent Agent-friendly preset: structured JSON\n${" ".repeat(41)}output, no pager or terminal styling`, + ` --agent Agent-friendly preset: structured JSON\n${' '.repeat(41)}output, no pager or terminal styling` ); } finally { - Object.defineProperty(process.stdout, "columns", { + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true, }); } }); - test("keeps the complete panel inside a very narrow terminal", async () => { + test('keeps the complete panel inside a very narrow terminal', async () => { const originalColumns = process.stdout.columns; - Object.defineProperty(process.stdout, "columns", { value: 40, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { + value: 40, + configurable: true, + }); try { const usage = await renderAltertableUsage(buildMainCommand()); - expect(usage.split("\n").every((line) => getVisibleTextWidth(line) <= 40)).toBe(true); + expect( + usage.split('\n').every(line => getVisibleTextWidth(line) <= 40) + ).toBe(true); } finally { - Object.defineProperty(process.stdout, "columns", { + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true, }); } }); - test("renders command usage with shared sections and examples", async () => { + test('renders command usage with shared sections and examples', async () => { const command = defineCommand({ metadata: { - name: "demo", - description: "Demo command.", - examples: ["altertable demo --flag value"], + name: 'demo', + description: 'Demo command.', + examples: ['altertable demo --flag value'], }, }); const usage = await renderAltertableUsage(command); - expect(usage).toContain("Demo command."); - expect(usage).toContain("Usage\n demo"); - expect(usage).toContain("Examples"); - expect(usage).toContain("altertable demo --flag value"); + expect(usage).toContain('Demo command.'); + expect(usage).toContain('Usage\n demo'); + expect(usage).toContain('Examples'); + expect(usage).toContain('altertable demo --flag value'); }); - test("renders nested command help", async () => { + test('renders nested command help', async () => { const main = buildMainCommand(); - const [command, parent] = await resolveSubCommandForUsage(main, ["profile"]); + const [command, parent] = await resolveSubCommandForUsage(main, [ + 'profile', + ]); const usage = await renderAltertableUsage(command, parent); - expect(usage).toContain("Manage named profiles and stored credentials."); + expect(usage).toContain('Manage named profiles and stored credentials.'); expect(usage).toContain( - "Usage\n altertable profile configure|list|show|status|switch|current|env|delete", + 'Usage\n altertable profile configure|list|show|status|switch|current|env|delete' ); - expect(usage).toContain("Commands"); - expect(usage).toContain("\n Use altertable profile --help"); - expect(usage).toContain("a command."); + expect(usage).toContain('Commands'); + expect(usage).toContain('\n Use altertable profile --help'); + expect(usage).toContain('a command.'); }); - test("highlights command guidance like examples", async () => { + test('highlights command guidance like examples', async () => { const terminalState = snapshotTerminalState(); const originalColumns = process.stdout.columns; - Object.defineProperty(process.stdout, "columns", { value: 120, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { + value: 120, + configurable: true, + }); forceTerminalColorForTests(); try { const main = buildMainCommand(); - const [command, parent] = await resolveSubCommandForUsage(main, ["profile"]); + const [command, parent] = await resolveSubCommandForUsage(main, [ + 'profile', + ]); const usage = await renderAltertableUsage(command, parent); expect(usage).toContain( - ` Use ${renderDisplayText([span("altertable profile --help", "accent")])} for more information about a command.`, + ` Use ${renderDisplayText([span('altertable profile --help', 'accent')])} for more information about a command.` ); expect(usage).toContain( - ` ${renderDisplayText([span("altertable profile show", "accent")])}`, + ` ${renderDisplayText([span('altertable profile show', 'accent')])}` ); } finally { restoreTerminalState(terminalState); - Object.defineProperty(process.stdout, "columns", { + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true, }); } }); - test("hides advanced subcommands from summary usage", async () => { + test('hides advanced subcommands from summary usage', async () => { const command = defineCommand({ - metadata: { name: "demo", description: "Demo command." }, + metadata: { name: 'demo', description: 'Demo command.' }, subcommands: { - visible: { metadata: { name: "visible", description: "Visible command" } }, + visible: { + metadata: { name: 'visible', description: 'Visible command' }, + }, advanced: { - metadata: { name: "advanced", description: "Advanced command", hidden: true }, + metadata: { + name: 'advanced', + description: 'Advanced command', + hidden: true, + }, }, }, }); const usage = await renderAltertableUsage(command); - expect(usage).toContain("visible"); - expect(usage).not.toContain("advanced"); + expect(usage).toContain('visible'); + expect(usage).not.toContain('advanced'); }); - test("resolves api command examples for unknown endpoint errors", async () => { + test('resolves api command examples for unknown endpoint errors', async () => { const main = buildMainCommand(); - const [command] = await resolveSubCommandForUsage(main, ["api", "/environments/production"]); + const [command] = await resolveSubCommandForUsage(main, [ + 'api', + '/environments/production', + ]); const usage = await renderAltertableUsage(command, main); - expect(usage).toContain("altertable api /whoami"); - expect(usage).toContain("altertable api /environments/production/connections"); + expect(usage).toContain('altertable api /whoami'); + expect(usage).toContain( + 'altertable api /environments/production/connections' + ); }); - test("builds stable structured help for agents", async () => { + test('builds stable structured help for agents', async () => { const main = buildMainCommand(); - const [query, parent] = await resolveSubCommandForUsage(main, ["query"]); + const [query, parent] = await resolveSubCommandForUsage(main, ['query']); const help = await buildStructuredHelp(query, parent, main); - expect(help.command).toBe("altertable query"); + expect(help.command).toBe('altertable query'); expect(help.usage).toEqual([ - "altertable query [flags] ", - "altertable query show|cancel", + 'altertable query [flags] ', + 'altertable query show|cancel', ]); expect(help.arguments).toContainEqual( - expect.objectContaining({ name: "statement", type: "positional" }), + expect.objectContaining({ name: 'statement', type: 'positional' }) ); expect(help.options).toContainEqual( expect.objectContaining({ - name: "format", - values: ["csv", "jsonl", "parquet", "markdown"], - }), + name: 'format', + values: ['csv', 'jsonl', 'parquet', 'markdown'], + }) + ); + expect(help.global_options).toContainEqual( + expect.objectContaining({ name: 'json' }) ); - expect(help.global_options).toContainEqual(expect.objectContaining({ name: "json" })); expect(help.global_options).toContainEqual( - expect.objectContaining({ name: "help", scope: "global" }), + expect.objectContaining({ name: 'help', scope: 'global' }) + ); + expect(help.global_options.some(option => option.name === 'version')).toBe( + false ); - expect(help.global_options.some((option) => option.name === "version")).toBe(false); - expect(help.subcommands.map((command) => command.name)).toEqual(["show", "cancel"]); + expect(help.subcommands.map(command => command.name)).toEqual([ + 'show', + 'cancel', + ]); }); - test("renders direct operands and subcommands as alternative invocation forms", async () => { + test('renders direct operands and subcommands as alternative invocation forms', async () => { const main = buildMainCommand(); - const [query, queryParent] = await resolveSubCommandForUsage(main, ["query"]); - const [append, appendParent] = await resolveSubCommandForUsage(main, ["append"]); + const [query, queryParent] = await resolveSubCommandForUsage(main, [ + 'query', + ]); + const [append, appendParent] = await resolveSubCommandForUsage(main, [ + 'append', + ]); const queryUsage = await renderAltertableUsage(query, queryParent); expect(queryUsage).toContain( - "Usage\n altertable query [flags] \n altertable query show|cancel", + 'Usage\n altertable query [flags] \n altertable query show|cancel' ); - expect(queryUsage).not.toContain("[STATEMENT] show|cancel"); + expect(queryUsage).not.toContain('[STATEMENT] show|cancel'); const appendHelp = await buildStructuredHelp(append, appendParent, main); expect(appendHelp.usage).toEqual([ - "altertable append [flags] ", - "altertable append status", + 'altertable append [flags] ', + 'altertable append status', ]); expect(appendHelp.options).toContainEqual( - expect.objectContaining({ name: "to", required: true }), + expect.objectContaining({ name: 'to', required: true }) ); }); - test("shares finite positional values with human and structured help", async () => { + test('shares finite positional values with human and structured help', async () => { const main = buildMainCommand(); - const [generate, parent] = await resolveSubCommandForUsage(main, ["completion", "generate"]); + const [generate, parent] = await resolveSubCommandForUsage(main, [ + 'completion', + 'generate', + ]); - expect(await renderAltertableUsage(generate, parent)).toContain(""); + expect(await renderAltertableUsage(generate, parent)).toContain( + '' + ); expect(await buildStructuredHelp(generate, parent, main)).toMatchObject({ arguments: [ expect.objectContaining({ - name: "shell", - values: ["bash", "fish", "zsh"], + name: 'shell', + values: ['bash', 'fish', 'zsh'], }), ], }); }); - test("keeps root flags exclusively in global options", async () => { + test('keeps root flags exclusively in global options', async () => { const main = buildMainCommand(); const help = await buildStructuredHelp(main, undefined, main); - expect(help.command).toBe("altertable"); + expect(help.command).toBe('altertable'); expect(help.options).toEqual([]); - expect(help.global_options.map((option) => option.name)).toEqual([ - "help", - "version", - "debug", - "json", - "agent", - "no-agent", - "no-color", - "profile", - "connect-timeout", - "read-timeout", - "ignore-ssl-errors", + expect(help.global_options.map(option => option.name)).toEqual([ + 'help', + 'version', + 'debug', + 'json', + 'agent', + 'no-agent', + 'no-color', + 'profile', + 'connect-timeout', + 'read-timeout', + 'ignore-ssl-errors', ]); expect(help.global_options).toContainEqual( - expect.objectContaining({ name: "version", scope: "root-only" }), + expect.objectContaining({ name: 'version', scope: 'root-only' }) ); }); - test("shares command metadata across human, structured, and completion help", async () => { + test('shares command metadata across human, structured, and completion help', async () => { const inspect = defineCommand({ metadata: { - name: "inspect", - alias: "show", - description: "Inspect a managed resource.", - examples: ["altertable inspect catalog"], + name: 'inspect', + alias: 'show', + description: 'Inspect a managed resource.', + examples: ['altertable inspect catalog'], }, }); const root = defineCommand({ - metadata: { name: "altertable" }, + metadata: { name: 'altertable' }, subcommands: { inspect }, }); - expect(await renderAltertableUsage(inspect)).toContain("Inspect a managed resource."); - expect(await renderAltertableUsage(inspect)).toContain("altertable inspect catalog"); + expect(await renderAltertableUsage(inspect)).toContain( + 'Inspect a managed resource.' + ); + expect(await renderAltertableUsage(inspect)).toContain( + 'altertable inspect catalog' + ); expect(await buildStructuredHelp(inspect)).toMatchObject({ - command: "inspect", - aliases: ["show"], - description: "Inspect a managed resource.", - examples: ["altertable inspect catalog"], + command: 'inspect', + aliases: ['show'], + description: 'Inspect a managed resource.', + examples: ['altertable inspect catalog'], }); expect((await buildCompletionSpec(root)).subcommands).toEqual([ - expect.objectContaining({ name: "inspect", description: "Inspect a managed resource." }), - expect.objectContaining({ name: "show", description: "Inspect a managed resource." }), + expect.objectContaining({ + name: 'inspect', + description: 'Inspect a managed resource.', + }), + expect.objectContaining({ + name: 'show', + description: 'Inspect a managed resource.', + }), ]); }); }); -describe("renderAltertableUsage active context", () => { - let testHome = ""; +describe('renderAltertableUsage active context', () => { + let testHome = ''; let terminalState: TerminalTestState; beforeEach(() => { terminalState = snapshotTerminalState(); - testHome = mkdtempSync(join(tmpdir(), "altertable-usage-test-")); + testHome = mkdtempSync(join(tmpdir(), 'altertable-usage-test-')); }); afterEach(() => { @@ -301,19 +360,22 @@ describe("renderAltertableUsage active context", () => { delete process.env.ALTERTABLE_SECRET_BACKEND; }); - test("omits active context on subcommand help", async () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + test('omits active context on subcommand help', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; + process.env.ALTERTABLE_SECRET_BACKEND = 'file'; const runtime = createCliRuntime(getBootstrapCliContext()); await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); + await configureRunSet({ apiKey: 'atm_test', env: 'production' }); setCliRuntime(runtime); const main = buildMainCommand(); - const [command] = await resolveSubCommandForUsage(main, ["query"]); + const [command] = await resolveSubCommandForUsage(main, ['query']); const usage = await renderAltertableUsage(command, main); - expect(usage).not.toContain("ENV"); + expect(usage).not.toContain('ENV'); }); }); }); diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 2f2e03dc..0189fd57 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,5 +1,8 @@ -import type { Command } from "@/lib/command.ts"; -import { ALTERTABLE_COMMAND_GROUPS, type AltertableCommandGroup } from "@/lib/command.ts"; +import type { Command } from '@/lib/command.ts'; +import { + ALTERTABLE_COMMAND_GROUPS, + type AltertableCommandGroup, +} from '@/lib/command.ts'; import { aliasFlagToken, resolveCommandDescriptor, @@ -7,15 +10,18 @@ import { type CommandArgumentDescriptor, type CommandDescriptor, type ResolvedCommandMetadata, -} from "@/lib/command-descriptor.ts"; -import { span, type DisplaySpan } from "@/ui/document.ts"; -import { getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; -import { readEnv } from "@/lib/env.ts"; -import { getOutputSink } from "@/lib/runtime.ts"; -import { resolveCommandSelection } from "@/lib/command-parser.ts"; - -const HELP_INDENT = " "; -const HELP_COLUMN_GAP = " "; +} from '@/lib/command-descriptor.ts'; +import { span, type DisplaySpan } from '@/ui/document.ts'; +import { + getVisibleTextWidth, + renderDisplayText, +} from '@/ui/terminal/styles.ts'; +import { readEnv } from '@/lib/env.ts'; +import { getOutputSink } from '@/lib/runtime.ts'; +import { resolveCommandSelection } from '@/lib/command-parser.ts'; + +const HELP_INDENT = ' '; +const HELP_COLUMN_GAP = ' '; const HELP_MIN_DESCRIPTION_WIDTH = 16; const HELP_FALLBACK_TERMINAL_WIDTH = 68; const COMMAND_PATTERN = /altertable(?:\s+[^\s'",]+)*/g; @@ -24,7 +30,7 @@ function renderHighlightedCommands(text: string): string { let offset = 0; for (const match of text.matchAll(COMMAND_PATTERN)) { const index = match.index ?? 0; - spans.push(span(text.slice(offset, index)), span(match[0], "accent")); + spans.push(span(text.slice(offset, index)), span(match[0], 'accent')); offset = index + match[0].length; } spans.push(span(text.slice(offset))); @@ -33,15 +39,15 @@ function renderHighlightedCommands(text: string): string { function formatCommandExamplesSection(examples: readonly string[]): string { if (examples.length === 0) { - return ""; + return ''; } - const heading = renderDisplayText([span("EXAMPLES", "heading")]); - return `\n\n${heading}\n\n${examples.map((example) => ` ${renderHighlightedCommands(example)}`).join("\n")}`; + const heading = renderDisplayText([span('EXAMPLES', 'heading')]); + return `\n\n${heading}\n\n${examples.map(example => ` ${renderHighlightedCommands(example)}`).join('\n')}`; } export async function resolveSubCommandForUsage( command: Command, - rawArgs: string[], + rawArgs: string[] ): Promise<[Command, Command | undefined]> { const selected = await resolveCommandSelection(command, rawArgs); return [selected.command, selected.parent]; @@ -54,23 +60,26 @@ type HelpEntry = { function wrapHelpText(text: string, maxWidth: number): string[] { maxWidth = Math.max(1, maxWidth); - const normalizedText = text.trim().replace(/\s+/g, " "); + const normalizedText = text.trim().replace(/\s+/g, ' '); if (normalizedText.length === 0) { - return [""]; + return ['']; } const lines: string[] = []; - let currentLine = ""; - for (const word of normalizedText.split(" ")) { + let currentLine = ''; + for (const word of normalizedText.split(' ')) { if (getVisibleTextWidth(word) > maxWidth) { if (currentLine.length > 0) { lines.push(currentLine); - currentLine = ""; + currentLine = ''; } let remainingWord = word; while (getVisibleTextWidth(remainingWord) > maxWidth) { let splitAt = maxWidth; - while (splitAt > 1 && getVisibleTextWidth(remainingWord.slice(0, splitAt)) > maxWidth) { + while ( + splitAt > 1 && + getVisibleTextWidth(remainingWord.slice(0, splitAt)) > maxWidth + ) { splitAt -= 1; } lines.push(remainingWord.slice(0, splitAt)); @@ -80,7 +89,8 @@ function wrapHelpText(text: string, maxWidth: number): string[] { continue; } - const candidate = currentLine.length === 0 ? word : `${currentLine} ${word}`; + const candidate = + currentLine.length === 0 ? word : `${currentLine} ${word}`; if (currentLine.length > 0 && getVisibleTextWidth(candidate) > maxWidth) { lines.push(currentLine); currentLine = word; @@ -96,19 +106,23 @@ function wrapHelpText(text: string, maxWidth: number): string[] { function getHelpTerminalWidth(): number { const columns = globalThis.process?.stdout?.columns; - if (typeof columns === "number" && columns > 0) { + if (typeof columns === 'number' && columns > 0) { return columns; } - const environmentColumns = Number.parseInt(readEnv("COLUMNS") ?? "", 10); + const environmentColumns = Number.parseInt(readEnv('COLUMNS') ?? '', 10); return Number.isFinite(environmentColumns) && environmentColumns > 0 ? environmentColumns : HELP_FALLBACK_TERMINAL_WIDTH; } function formatHelpEntries(entries: readonly HelpEntry[]): string[] { - const labelWidth = Math.max(...entries.map((entry) => getVisibleTextWidth(entry.label)), 0); - const descriptionColumn = HELP_INDENT.length + labelWidth + HELP_COLUMN_GAP.length; + const labelWidth = Math.max( + ...entries.map(entry => getVisibleTextWidth(entry.label)), + 0 + ); + const descriptionColumn = + HELP_INDENT.length + labelWidth + HELP_COLUMN_GAP.length; const terminalWidth = getHelpTerminalWidth(); const descriptionWidth = terminalWidth - descriptionColumn; const stacked = descriptionWidth < HELP_MIN_DESCRIPTION_WIDTH; @@ -116,50 +130,53 @@ function formatHelpEntries(entries: readonly HelpEntry[]): string[] { return entries.flatMap(({ label, description }) => { const availableWidth = Math.max( 1, - stacked ? terminalWidth - HELP_INDENT.length : descriptionWidth, + stacked ? terminalWidth - HELP_INDENT.length : descriptionWidth ); const wrappedDescription = wrapHelpText(description, availableWidth); if (stacked) { const wrappedLabel = wrapHelpText(label, availableWidth); return [ - ...wrappedLabel.map((line) => `${HELP_INDENT}${renderDisplayText([span(line, "accent")])}`), - ...wrappedDescription.map((line) => `${HELP_INDENT}${line}`), + ...wrappedLabel.map( + line => `${HELP_INDENT}${renderDisplayText([span(line, 'accent')])}` + ), + ...wrappedDescription.map(line => `${HELP_INDENT}${line}`), ]; } return wrappedDescription.map((line, index) => { if (index === 0) { - return `${HELP_INDENT}${renderDisplayText([span(label.padEnd(labelWidth), "accent")])}${HELP_COLUMN_GAP}${line}`; + return `${HELP_INDENT}${renderDisplayText([span(label.padEnd(labelWidth), 'accent')])}${HELP_COLUMN_GAP}${line}`; } - return `${" ".repeat(descriptionColumn)}${line}`; + return `${' '.repeat(descriptionColumn)}${line}`; }); }); } function formatHelpParagraph(text: string, indent: string): string[] { const width = Math.max(1, getHelpTerminalWidth() - indent.length); - return wrapHelpText(text, width).map((line) => `${indent}${line}`); + return wrapHelpText(text, width).map(line => `${indent}${line}`); } function formatHelpGuidance(commandName: string): string[] { const invocation = `${commandName} --help`; const guidance = `Use ${invocation} for more information about a command.`; - return formatHelpParagraph(guidance, HELP_INDENT).map((line) => { + return formatHelpParagraph(guidance, HELP_INDENT).map(line => { const invocationStart = line.indexOf(invocation); if (invocationStart === -1) { return line; } return renderDisplayText([ span(line.slice(0, invocationStart)), - span(invocation, "accent"), + span(invocation, 'accent'), span(line.slice(invocationStart + invocation.length)), ]); }); } function valueHint(argument: CommandArgumentDescriptor): string | undefined { - if (argument.values.length > 0) return argument.valueHint ?? argument.values.join("|"); - if (argument.type === "string") { + if (argument.values.length > 0) + return argument.valueHint ?? argument.values.join('|'); + if (argument.type === 'string') { return argument.valueHint ?? argument.name; } return undefined; @@ -169,27 +186,28 @@ function flagLabel(argument: CommandArgumentDescriptor): string { const aliases = argument.aliases.map(aliasFlagToken); const longFlag = `--${argument.name}`; const hint = valueHint(argument); - const value = hint ? ` <${hint}>` : ""; - return [...aliases, `${longFlag}${value}`].join(", "); + const value = hint ? ` <${hint}>` : ''; + return [...aliases, `${longFlag}${value}`].join(', '); } function positionalLabel(argument: CommandArgumentDescriptor): string { return ( - argument.valueHint ?? (argument.values.length > 0 ? argument.values.join("|") : argument.name) + argument.valueHint ?? + (argument.values.length > 0 ? argument.values.join('|') : argument.name) ).toUpperCase(); } function formatDefaultValue(value: unknown): string { - if (value === null) return "null"; + if (value === null) return 'null'; switch (typeof value) { - case "string": + case 'string': return value; - case "number": - case "boolean": - case "bigint": + case 'number': + case 'boolean': + case 'bigint': return String(value); default: - return JSON.stringify(value) ?? ""; + return JSON.stringify(value) ?? ''; } } @@ -200,49 +218,54 @@ function argumentDescription(argument: CommandArgumentDescriptor): string { : `(Default: ${formatDefaultValue(argument.default)})`; return [ argument.description, - argument.required ? "(Required)" : undefined, - argument.repeatable ? "(Repeatable)" : undefined, + argument.required ? '(Required)' : undefined, + argument.repeatable ? '(Repeatable)' : undefined, defaultValue, ] .filter((part): part is string => part !== undefined) - .join(" "); + .join(' '); } function usageCommandName( metadata: ResolvedCommandMetadata, - parentMetadata?: ResolvedCommandMetadata, + parentMetadata?: ResolvedCommandMetadata ): string { - const name = metadata.name ?? "command"; + const name = metadata.name ?? 'command'; if (!parentMetadata) { return name; } - if (parentMetadata.name === "altertable") { + if (parentMetadata.name === 'altertable') { return `altertable ${name}`; } - return `altertable ${parentMetadata.name ?? "command"} ${name}`; + return `altertable ${parentMetadata.name ?? 'command'} ${name}`; } -function usageVariants(commandName: string, descriptor: CommandDescriptor): string[] { +function usageVariants( + commandName: string, + descriptor: CommandDescriptor +): string[] { const positionalArgs = descriptor.arguments - .filter((argument) => argument.type === "positional") - .map((argument) => { + .filter(argument => argument.type === 'positional') + .map(argument => { const label = positionalLabel(argument); return argument.required ? `<${label}>` : `[${label}]`; }); - const hasOptions = descriptor.arguments.some((argument) => argument.type !== "positional"); + const hasOptions = descriptor.arguments.some( + argument => argument.type !== 'positional' + ); const commandNames = visibleCommandDescriptors(descriptor.subcommands) .map(({ key, metadata }) => key ?? metadata.name) - .join("|"); + .join('|'); const variants: string[] = []; - if (descriptor.metadata.invocations.includes("direct")) { + if (descriptor.metadata.invocations.includes('direct')) { variants.push( - [commandName, hasOptions ? "[flags]" : undefined, ...positionalArgs] + [commandName, hasOptions ? '[flags]' : undefined, ...positionalArgs] .filter((token): token is string => token !== undefined) - .join(" "), + .join(' ') ); } - if (descriptor.metadata.invocations.includes("subcommand") && commandNames) { + if (descriptor.metadata.invocations.includes('subcommand') && commandNames) { variants.push(`${commandName} ${commandNames}`); } return variants; @@ -250,96 +273,100 @@ function usageVariants(commandName: string, descriptor: CommandDescriptor): stri function renderCommandUsage( descriptor: CommandDescriptor, - parentDescriptor?: CommandDescriptor, + parentDescriptor?: CommandDescriptor ): string { const metadata = descriptor.metadata; const parentMetadata = parentDescriptor?.metadata; const subcommands = visibleCommandDescriptors(descriptor.subcommands); const commandName = usageCommandName(metadata, parentMetadata); const positionalEntries = descriptor.arguments - .filter((argument) => argument.type === "positional") - .map((argument) => ({ + .filter(argument => argument.type === 'positional') + .map(argument => ({ label: positionalLabel(argument), description: argumentDescription(argument), })); const optionEntries = descriptor.arguments - .filter((argument) => argument.type !== "positional") - .map((argument) => ({ + .filter(argument => argument.type !== 'positional') + .map(argument => ({ label: flagLabel(argument), description: argumentDescription(argument), })); - const commandEntries = subcommands.map(({ key, metadata: subcommandMetadata }) => ({ - label: [key ?? subcommandMetadata.name, ...subcommandMetadata.aliases] - .filter((name): name is string => name !== undefined) - .join(", "), - description: subcommandMetadata.description, - })); + const commandEntries = subcommands.map( + ({ key, metadata: subcommandMetadata }) => ({ + label: [key ?? subcommandMetadata.name, ...subcommandMetadata.aliases] + .filter((name): name is string => name !== undefined) + .join(', '), + description: subcommandMetadata.description, + }) + ); const lines = [ - ...(metadata.description ? wrapHelpText(metadata.description, getHelpTerminalWidth()) : []), - "", - ` ${renderDisplayText([span("Usage", "strong")])}`, - ...usageVariants(commandName, descriptor).map((usage) => ` ${usage}`), + ...(metadata.description + ? wrapHelpText(metadata.description, getHelpTerminalWidth()) + : []), + '', + ` ${renderDisplayText([span('Usage', 'strong')])}`, + ...usageVariants(commandName, descriptor).map(usage => ` ${usage}`), ]; if (positionalEntries.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Arguments", "strong")])}`, - ...formatHelpEntries(positionalEntries), + '', + ` ${renderDisplayText([span('Arguments', 'strong')])}`, + ...formatHelpEntries(positionalEntries) ); } if (optionEntries.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Options", "strong")])}`, - ...formatHelpEntries(optionEntries), + '', + ` ${renderDisplayText([span('Options', 'strong')])}`, + ...formatHelpEntries(optionEntries) ); } if (commandEntries.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Commands", "strong")])}`, - ...formatHelpEntries(commandEntries), + '', + ` ${renderDisplayText([span('Commands', 'strong')])}`, + ...formatHelpEntries(commandEntries) ); - lines.push("", ...formatHelpGuidance(commandName)); + lines.push('', ...formatHelpGuidance(commandName)); } const examples = metadata.examples; if (examples.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Examples", "strong")])}`, - ...examples.flatMap((example) => - formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands), - ), + '', + ` ${renderDisplayText([span('Examples', 'strong')])}`, + ...examples.flatMap(example => + formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands) + ) ); } - return lines.join("\n"); + return lines.join('\n'); } function renderRootUsage(descriptor: CommandDescriptor): string { const metadata = descriptor.metadata; const groupedEntries = Object.fromEntries( - ALTERTABLE_COMMAND_GROUPS.map(({ id }) => [id, [] as HelpEntry[]]), + ALTERTABLE_COMMAND_GROUPS.map(({ id }) => [id, [] as HelpEntry[]]) ) as Record; const lines = [ ...wrapHelpText(metadata.description, getHelpTerminalWidth()), - "", - ` ${renderDisplayText([span("Usage", "strong")])}`, - " altertable [flags]", - "", - ` ${renderDisplayText([span("Commands", "strong")])}`, + '', + ` ${renderDisplayText([span('Usage', 'strong')])}`, + ' altertable [flags]', + '', + ` ${renderDisplayText([span('Commands', 'strong')])}`, ]; for (const { key, metadata: commandMetadata } of visibleCommandDescriptors( - descriptor.subcommands, + descriptor.subcommands )) { if (!commandMetadata.commandGroup) { continue; } groupedEntries[commandMetadata.commandGroup].push({ - label: key ?? commandMetadata.name ?? "command", + label: key ?? commandMetadata.name ?? 'command', description: commandMetadata.description, }); } @@ -349,67 +376,73 @@ function renderRootUsage(descriptor: CommandDescriptor): string { const entries = groupedEntries[id]; if (entries.length > 0) { lines.push( - ...(renderedGroup ? [""] : []), - ` ${renderDisplayText([span(title, "muted")])}`, - ...formatHelpEntries(entries), + ...(renderedGroup ? [''] : []), + ` ${renderDisplayText([span(title, 'muted')])}`, + ...formatHelpEntries(entries) ); renderedGroup = true; } } - lines.push("", ...formatHelpGuidance("altertable")); + lines.push('', ...formatHelpGuidance('altertable')); const rootOnlyFlags = descriptor.arguments - .filter((argument) => argument.scope === "root-only") - .map((argument) => ({ + .filter(argument => argument.scope === 'root-only') + .map(argument => ({ label: flagLabel(argument), description: argument.description, })); const globalFlags = descriptor.arguments - .filter((argument) => argument.scope !== "root-only") - .map((argument) => ({ + .filter(argument => argument.scope !== 'root-only') + .map(argument => ({ label: flagLabel(argument), description: argument.description, })); if (rootOnlyFlags.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Root options", "strong")])}`, - ...formatHelpEntries(rootOnlyFlags), + '', + ` ${renderDisplayText([span('Root options', 'strong')])}`, + ...formatHelpEntries(rootOnlyFlags) ); } lines.push( - "", - ` ${renderDisplayText([span("Global flags", "strong")])}`, - ...formatHelpEntries(globalFlags), + '', + ` ${renderDisplayText([span('Global flags', 'strong')])}`, + ...formatHelpEntries(globalFlags) ); const examples = metadata.examples; if (examples.length > 0) { lines.push( - "", - ` ${renderDisplayText([span("Examples", "strong")])}`, - ...examples.flatMap((example) => - formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands), - ), + '', + ` ${renderDisplayText([span('Examples', 'strong')])}`, + ...examples.flatMap(example => + formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands) + ) ); } - return lines.join("\n"); + return lines.join('\n'); } export function renderAltertableUsageFromDescriptor( descriptor: CommandDescriptor, - parentDescriptor?: CommandDescriptor, + parentDescriptor?: CommandDescriptor ): string { - if (parentDescriptor === undefined && descriptor.metadata.name === "altertable") { + if ( + parentDescriptor === undefined && + descriptor.metadata.name === 'altertable' + ) { return renderRootUsage(descriptor); } return renderCommandUsage(descriptor, parentDescriptor); } -export async function renderAltertableUsage(command: Command, parent?: Command): Promise { +export async function renderAltertableUsage( + command: Command, + parent?: Command +): Promise { const [descriptor, parentDescriptor] = await Promise.all([ resolveCommandDescriptor(command), parent ? resolveCommandDescriptor(parent) : undefined, @@ -425,7 +458,7 @@ type StructuredArgument = { description: string; default?: unknown; values?: string[]; - scope: CommandArgumentDescriptor["scope"]; + scope: CommandArgumentDescriptor['scope']; repeatable?: boolean; }; @@ -441,7 +474,9 @@ export type StructuredHelp = { examples: string[]; }; -function structuredArgument(argument: CommandArgumentDescriptor): StructuredArgument { +function structuredArgument( + argument: CommandArgumentDescriptor +): StructuredArgument { return { name: argument.name, aliases: argument.aliases, @@ -458,30 +493,35 @@ function structuredArgument(argument: CommandArgumentDescriptor): StructuredArgu export function buildStructuredHelpFromDescriptor( descriptor: CommandDescriptor, parentDescriptor?: CommandDescriptor, - rootDescriptor?: CommandDescriptor, + rootDescriptor?: CommandDescriptor ): StructuredHelp { const metadata = descriptor.metadata; const parentMetadata = parentDescriptor?.metadata; const subcommands = visibleCommandDescriptors(descriptor.subcommands); const commandName = usageCommandName(metadata, parentMetadata); - const isRootCommand = parentDescriptor === undefined && metadata.name === "altertable"; + const isRootCommand = + parentDescriptor === undefined && metadata.name === 'altertable'; const usage = isRootCommand - ? ["altertable [flags]"] + ? ['altertable [flags]'] : usageVariants(commandName, descriptor); const entries = descriptor.arguments.map(structuredArgument); const globalArguments = isRootCommand ? (rootDescriptor?.arguments ?? []) - : (rootDescriptor?.arguments.filter((argument) => argument.scope === "global") ?? []); + : (rootDescriptor?.arguments.filter( + argument => argument.scope === 'global' + ) ?? []); return { command: commandName, description: metadata.description, usage, aliases: metadata.aliases, - arguments: entries.filter((entry) => entry.type === "positional"), - options: isRootCommand ? [] : entries.filter((entry) => entry.type !== "positional"), + arguments: entries.filter(entry => entry.type === 'positional'), + options: isRootCommand + ? [] + : entries.filter(entry => entry.type !== 'positional'), subcommands: subcommands.map(({ key, metadata: subcommandMetadata }) => ({ - name: key ?? subcommandMetadata.name ?? "command", + name: key ?? subcommandMetadata.name ?? 'command', aliases: subcommandMetadata.aliases, description: subcommandMetadata.description, })), @@ -493,21 +533,29 @@ export function buildStructuredHelpFromDescriptor( export async function buildStructuredHelp( command: Command, parent?: Command, - root?: Command, + root?: Command ): Promise { const descriptorPromise = resolveCommandDescriptor(command); const [descriptor, parentDescriptor, rootDescriptor] = await Promise.all([ descriptorPromise, parent ? resolveCommandDescriptor(parent) : undefined, - root ? (root === command ? descriptorPromise : resolveCommandDescriptor(root)) : undefined, + root + ? root === command + ? descriptorPromise + : resolveCommandDescriptor(root) + : undefined, ]); - return buildStructuredHelpFromDescriptor(descriptor, parentDescriptor, rootDescriptor); + return buildStructuredHelpFromDescriptor( + descriptor, + parentDescriptor, + rootDescriptor + ); } export async function showAltertableUsage( command: Command, parent?: Command, - root?: Command, + root?: Command ): Promise { const sink = getOutputSink(); if (sink.json) { @@ -517,10 +565,13 @@ export async function showAltertableUsage( sink.writeHuman(`${await renderAltertableUsage(command, parent)}\n`); } -export async function showCommandExamplesForArgs(root: Command, rawArgs: string[]): Promise { +export async function showCommandExamplesForArgs( + root: Command, + rawArgs: string[] +): Promise { const [command] = await resolveSubCommandForUsage(root, rawArgs); const section = formatCommandExamplesSection( - (await resolveCommandDescriptor(command)).metadata.examples, + (await resolveCommandDescriptor(command)).metadata.examples ); if (section.length === 0) { return; diff --git a/cli/src/lib/user-directories.ts b/cli/src/lib/user-directories.ts index 49864136..090199e8 100644 --- a/cli/src/lib/user-directories.ts +++ b/cli/src/lib/user-directories.ts @@ -1,42 +1,48 @@ -import { homedir } from "node:os"; -import { isAbsolute, join, parse } from "node:path"; -import { readEnv } from "@/lib/env.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; +import { homedir } from 'node:os'; +import { isAbsolute, join, parse } from 'node:path'; +import { readEnv } from '@/lib/env.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; export function homeDir(): string { try { - return requireAbsoluteDirectory(homedir(), "OS home directory"); + return requireAbsoluteDirectory(homedir(), 'OS home directory'); } catch (cause) { - throw new ConfigurationError("Unable to determine an absolute OS home directory.", { cause }); + throw new ConfigurationError( + 'Unable to determine an absolute OS home directory.', + { cause } + ); } } export function configHome(): string { - const override = readEnv("XDG_CONFIG_HOME"); + const override = readEnv('XDG_CONFIG_HOME'); return override - ? requireAbsoluteDirectory(override, "XDG_CONFIG_HOME") - : join(homeDir(), ".config"); + ? requireAbsoluteDirectory(override, 'XDG_CONFIG_HOME') + : join(homeDir(), '.config'); } export function dataHome(): string { - const override = readEnv("XDG_DATA_HOME"); + const override = readEnv('XDG_DATA_HOME'); return override - ? requireAbsoluteDirectory(override, "XDG_DATA_HOME") - : join(homeDir(), ".local", "share"); + ? requireAbsoluteDirectory(override, 'XDG_DATA_HOME') + : join(homeDir(), '.local', 'share'); } export function configDir(): string { - const override = readEnv("ALTERTABLE_CONFIG_HOME"); + const override = readEnv('ALTERTABLE_CONFIG_HOME'); return override - ? requireAbsoluteDirectory(override, "ALTERTABLE_CONFIG_HOME") - : join(configHome(), "altertable"); + ? requireAbsoluteDirectory(override, 'ALTERTABLE_CONFIG_HOME') + : join(configHome(), 'altertable'); } function requireAbsoluteDirectory(value: string, source: string): string { // Windows root-relative paths (e.g. \settings) depend on the current drive. - if (!isAbsolute(value) || (process.platform === "win32" && parse(value).root.length === 1)) { + if ( + !isAbsolute(value) || + (process.platform === 'win32' && parse(value).root.length === 1) + ) { throw new ConfigurationError( - `${source} must be an absolute directory independent of the working directory.`, + `${source} must be an absolute directory independent of the working directory.` ); } return value; diff --git a/cli/src/package-metadata.ts b/cli/src/package-metadata.ts index 4b0cd99a..174aac20 100644 --- a/cli/src/package-metadata.ts +++ b/cli/src/package-metadata.ts @@ -1,4 +1,4 @@ export const CLI_PACKAGE_METADATA = { - name: "@altertable/cli", - repositorySlug: "altertable-ai/altertable-cli", + name: '@altertable/cli', + repositorySlug: 'altertable-ai/altertable-cli', } as const; diff --git a/cli/src/release-manifest.ts b/cli/src/release-manifest.ts index 8194ab76..8ed59636 100644 --- a/cli/src/release-manifest.ts +++ b/cli/src/release-manifest.ts @@ -1,5 +1,5 @@ -export type ReleaseOperatingSystem = "darwin" | "linux"; -export type ReleaseArchitecture = "arm64" | "x64"; +export type ReleaseOperatingSystem = 'darwin' | 'linux'; +export type ReleaseArchitecture = 'arm64' | 'x64'; export type ReleaseTarget = { bunTarget: `bun-${string}`; @@ -18,48 +18,48 @@ export type ReleaseTarget = { */ export const RELEASE_TARGETS = [ { - bunTarget: "bun-darwin-arm64", - platform: "darwin-arm64", - os: "darwin", - arch: "arm64", - asset: "altertable-darwin-arm64", - runner: "macos-15", + bunTarget: 'bun-darwin-arm64', + platform: 'darwin-arm64', + os: 'darwin', + arch: 'arm64', + asset: 'altertable-darwin-arm64', + runner: 'macos-15', }, { - bunTarget: "bun-darwin-x64", - platform: "darwin-x64", - os: "darwin", - arch: "x64", - asset: "altertable-darwin-x64", - runner: "macos-15-intel", + bunTarget: 'bun-darwin-x64', + platform: 'darwin-x64', + os: 'darwin', + arch: 'x64', + asset: 'altertable-darwin-x64', + runner: 'macos-15-intel', }, { - bunTarget: "bun-linux-arm64", - platform: "linux-arm64", - os: "linux", - arch: "arm64", - asset: "altertable-linux-arm64", - runner: "ubuntu-24.04-arm", + bunTarget: 'bun-linux-arm64', + platform: 'linux-arm64', + os: 'linux', + arch: 'arm64', + asset: 'altertable-linux-arm64', + runner: 'ubuntu-24.04-arm', }, { - bunTarget: "bun-linux-x64-baseline", - platform: "linux-x64", - os: "linux", - arch: "x64", - asset: "altertable-linux-x64", - runner: "ubuntu-24.04", + bunTarget: 'bun-linux-x64-baseline', + platform: 'linux-x64', + os: 'linux', + arch: 'x64', + asset: 'altertable-linux-x64', + runner: 'ubuntu-24.04', }, ] as const satisfies readonly ReleaseTarget[]; -export type ReleasePlatform = (typeof RELEASE_TARGETS)[number]["platform"]; -export type ReleaseAssetName = (typeof RELEASE_TARGETS)[number]["asset"]; +export type ReleasePlatform = (typeof RELEASE_TARGETS)[number]['platform']; +export type ReleaseAssetName = (typeof RELEASE_TARGETS)[number]['asset']; export const RELEASE_PLATFORM_CONFIG: Readonly< Record > = Object.freeze( Object.fromEntries( - RELEASE_TARGETS.map((target) => [target.platform, { asset: target.asset }]), - ) as Record, + RELEASE_TARGETS.map(target => [target.platform, { asset: target.asset }]) + ) as Record ); -export const RELEASE_CHECKSUMS_ASSET = "checksums.txt"; +export const RELEASE_CHECKSUMS_ASSET = 'checksums.txt'; diff --git a/cli/src/test-utils/cli.ts b/cli/src/test-utils/cli.ts index 54f4ba76..42783801 100644 --- a/cli/src/test-utils/cli.ts +++ b/cli/src/test-utils/cli.ts @@ -1,8 +1,8 @@ -import { buildMainCommand } from "@/cli.ts"; -import type { CliContext } from "@/context.ts"; -import { executeCommand } from "@/lib/command-parser.ts"; -import { createCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { buildMainCommand } from '@/cli.ts'; +import type { CliContext } from '@/context.ts'; +import { executeCommand } from '@/lib/command-parser.ts'; +import { createCliRuntime, type CliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; export type CliTestHarness = { runtime: CliRuntime; @@ -13,19 +13,19 @@ export type CliTestHarness = { }; export function createCliTestHarness( - context: CliContext = { debug: false, json: false, agent: false }, + context: CliContext = { debug: false, json: false, agent: false } ): CliTestHarness { const runtime = createCliRuntime(context); const stdout: string[] = []; const stderr: string[] = []; - runtime.output.writeStderr = (line) => stderr.push(line); - runtime.output.writeJson = (data) => stdout.push(JSON.stringify(data)); - runtime.output.writeRaw = (body) => stdout.push(body); - runtime.output.writeBytes = (body) => { - stdout.push(Buffer.from(body).toString("utf8")); + runtime.output.writeStderr = line => stderr.push(line); + runtime.output.writeJson = data => stdout.push(JSON.stringify(data)); + runtime.output.writeRaw = body => stdout.push(body); + runtime.output.writeBytes = body => { + stdout.push(Buffer.from(body).toString('utf8')); }; - runtime.output.writeHuman = (text) => stdout.push(text); - runtime.output.writeMetadata = (lines) => stderr.push(...lines); + runtime.output.writeHuman = text => stdout.push(text); + runtime.output.writeMetadata = lines => stderr.push(...lines); return { runtime, @@ -34,7 +34,7 @@ export function createCliTestHarness( exitCode: 0, async run(rawArgs) { const result = await runWithCliRuntime(runtime, () => - executeCommand(buildMainCommand(), rawArgs), + executeCommand(buildMainCommand(), rawArgs) ); this.exitCode = result.exitCode; }, @@ -43,7 +43,7 @@ export function createCliTestHarness( export async function runCommandWithTestRuntime( rawArgs: string[], - context: CliContext = { debug: false, json: true, agent: false }, + context: CliContext = { debug: false, json: true, agent: false } ): Promise { const harness = createCliTestHarness(context); await harness.run(rawArgs); diff --git a/cli/src/test-utils/keychain.ts b/cli/src/test-utils/keychain.ts index de28f30d..a559bdc3 100644 --- a/cli/src/test-utils/keychain.ts +++ b/cli/src/test-utils/keychain.ts @@ -1,4 +1,4 @@ -import { createSecretStore, type SecretStore } from "@/lib/secrets.ts"; +import { createSecretStore, type SecretStore } from '@/lib/secrets.ts'; type FakeKeychain = { store: SecretStore; @@ -10,7 +10,7 @@ type FakeKeychain = { function argumentAfter(args: string[], flag: string): string { const index = args.indexOf(flag); - return index >= 0 ? (args[index + 1] ?? "") : ""; + return index >= 0 ? (args[index + 1] ?? '') : ''; } export function createFakeKeychain(): FakeKeychain { @@ -21,31 +21,35 @@ export function createFakeKeychain(): FakeKeychain { const failingDeletes = new Set(); const store = createSecretStore({ - platform: "darwin", + platform: 'darwin', spawnSync(_command, args) { calls.push([...args]); - if (args.includes("help")) return { status: 0, stdout: Buffer.from("") }; + if (args.includes('help')) return { status: 0, stdout: Buffer.from('') }; - const account = argumentAfter(args, "-a"); - if (args.includes("add-generic-password")) { - if (failingWrites.has(account)) return { status: 1, stdout: Buffer.from("") }; - values.set(account, argumentAfter(args, "-w")); - return { status: 0, stdout: Buffer.from("") }; + const account = argumentAfter(args, '-a'); + if (args.includes('add-generic-password')) { + if (failingWrites.has(account)) + return { status: 1, stdout: Buffer.from('') }; + values.set(account, argumentAfter(args, '-w')); + return { status: 0, stdout: Buffer.from('') }; } - if (args.includes("find-generic-password")) { - if (failingReads.has(account)) return { status: 1, stdout: Buffer.from("") }; + if (args.includes('find-generic-password')) { + if (failingReads.has(account)) + return { status: 1, stdout: Buffer.from('') }; const value = values.get(account); return value === undefined - ? { status: 44, stdout: Buffer.from("") } + ? { status: 44, stdout: Buffer.from('') } : { status: 0, stdout: Buffer.from(value) }; } - if (args.includes("delete-generic-password")) { - if (failingDeletes.has(account)) return { status: 1, stdout: Buffer.from("") }; - if (!values.has(account)) return { status: 44, stdout: Buffer.from("") }; + if (args.includes('delete-generic-password')) { + if (failingDeletes.has(account)) + return { status: 1, stdout: Buffer.from('') }; + if (!values.has(account)) + return { status: 44, stdout: Buffer.from('') }; values.delete(account); - return { status: 0, stdout: Buffer.from("") }; + return { status: 0, stdout: Buffer.from('') }; } - return { status: 1, stdout: Buffer.from("") }; + return { status: 1, stdout: Buffer.from('') }; }, }); diff --git a/cli/src/test-utils/lakehouse.ts b/cli/src/test-utils/lakehouse.ts index 0bb3c4d6..f4499748 100644 --- a/cli/src/test-utils/lakehouse.ts +++ b/cli/src/test-utils/lakehouse.ts @@ -1,8 +1,14 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { setCliContext } from '@/context.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; export type MockHttpResponse = { urlPattern: string; @@ -22,15 +28,17 @@ export type LakehouseTestWorkspace = { cleanup(): void; }; -export function createLakehouseTestWorkspace(name: string): LakehouseTestWorkspace { +export function createLakehouseTestWorkspace( + name: string +): LakehouseTestWorkspace { const home = mkdtempSync(join(tmpdir(), `altertable-${name}-test-`)); - const mockFile = join(home, "mocks.json"); - const logFile = join(home, "http.log"); + const mockFile = join(home, 'mocks.json'); + const logFile = join(home, 'http.log'); process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; - process.env.ALTERTABLE_API_BASE = "https://example.com"; - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "testuser"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "testpass"; + process.env.ALTERTABLE_API_BASE = 'https://example.com'; + process.env.ALTERTABLE_LAKEHOUSE_USERNAME = 'testuser'; + process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = 'testpass'; setCliContext({ debug: false, json: false, agent: false }); refreshCliRuntimeContext(getCliRuntime().context); @@ -50,13 +58,13 @@ export function createLakehouseTestWorkspace(name: string): LakehouseTestWorkspa return path; }, readHttpLog() { - return readFileSync(logFile, "utf8"); + return readFileSync(logFile, 'utf8'); }, readPayloads() { - return readFileSync(logFile, "utf8") - .split("\n") - .filter((line) => line.startsWith("PAYLOAD=")) - .map((line) => line.slice("PAYLOAD=".length)); + return readFileSync(logFile, 'utf8') + .split('\n') + .filter(line => line.startsWith('PAYLOAD=')) + .map(line => line.slice('PAYLOAD='.length)); }, cleanup() { rmSync(home, { recursive: true, force: true }); diff --git a/cli/src/test-utils/runtime.ts b/cli/src/test-utils/runtime.ts index f4fdf07c..6afc945f 100644 --- a/cli/src/test-utils/runtime.ts +++ b/cli/src/test-utils/runtime.ts @@ -1,4 +1,8 @@ -import { getCliRuntime, setCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; +import { + getCliRuntime, + setCliRuntime, + type CliRuntime, +} from '@/lib/runtime.ts'; export function runWithCliRuntime(runtime: CliRuntime, run: () => T): T { const previousRuntime = getCliRuntime(); diff --git a/cli/src/test-utils/terminal.ts b/cli/src/test-utils/terminal.ts index 7846ac1b..56493019 100644 --- a/cli/src/test-utils/terminal.ts +++ b/cli/src/test-utils/terminal.ts @@ -1,13 +1,13 @@ -import { setTerminalColorMode } from "@/ui/terminal/styles.ts"; +import { setTerminalColorMode } from '@/ui/terminal/styles.ts'; const TERMINAL_ENV_KEYS = [ - "ALTERTABLE_COLOR", - "CI", - "FORCE_COLOR", - "NO_COLOR", - "OSC_HYPERLINK", - "TERM", - "TEST", + 'ALTERTABLE_COLOR', + 'CI', + 'FORCE_COLOR', + 'NO_COLOR', + 'OSC_HYPERLINK', + 'TERM', + 'TEST', ] as const; type TerminalEnvKey = (typeof TERMINAL_ENV_KEYS)[number]; @@ -21,8 +21,8 @@ export type TerminalTestState = { export function snapshotTerminalState(): TerminalTestState { return { env: Object.fromEntries( - TERMINAL_ENV_KEYS.map((key) => [key, process.env[key]]), - ) as TerminalTestState["env"], + TERMINAL_ENV_KEYS.map(key => [key, process.env[key]]) + ) as TerminalTestState['env'], stdoutIsTTY: process.stdout.isTTY, stderrIsTTY: process.stderr.isTTY, }; @@ -37,11 +37,11 @@ export function restoreTerminalState(state: TerminalTestState): void { process.env[key] = value; } } - Object.defineProperty(process.stdout, "isTTY", { + Object.defineProperty(process.stdout, 'isTTY', { value: state.stdoutIsTTY, configurable: true, }); - Object.defineProperty(process.stderr, "isTTY", { + Object.defineProperty(process.stderr, 'isTTY', { value: state.stderrIsTTY, configurable: true, }); @@ -53,13 +53,19 @@ export function forceTerminalColorForTests(): void { delete process.env.CI; delete process.env.FORCE_COLOR; delete process.env.ALTERTABLE_COLOR; - process.env.TERM = "xterm-256color"; - setTerminalColorMode("always"); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + process.env.TERM = 'xterm-256color'; + setTerminalColorMode('always'); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); } export function forceNoTerminalColorForTests(): void { - setTerminalColorMode("never"); - process.env.NO_COLOR = "1"; + setTerminalColorMode('never'); + process.env.NO_COLOR = '1'; } diff --git a/cli/src/test-utils/time.ts b/cli/src/test-utils/time.ts index 59d80191..05c4b908 100644 --- a/cli/src/test-utils/time.ts +++ b/cli/src/test-utils/time.ts @@ -1,3 +1,3 @@ export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/cli/src/ui/document.ts b/cli/src/ui/document.ts index 104c5be3..ffa20017 100644 --- a/cli/src/ui/document.ts +++ b/cli/src/ui/document.ts @@ -1,16 +1,16 @@ export type DisplayTextStyle = - | "strong" - | "accent" - | "string" - | "boolean" - | "number" - | "muted" - | "subtle" - | "success" - | "warning" - | "error" - | "heading" - | "httpMethod"; + | 'strong' + | 'accent' + | 'string' + | 'boolean' + | 'number' + | 'muted' + | 'subtle' + | 'success' + | 'warning' + | 'error' + | 'heading' + | 'httpMethod'; export type DisplaySpan = { text: string; @@ -41,15 +41,15 @@ export type DisplayTableOptions = { export type DisplayBlock = | { - kind: "rows"; + kind: 'rows'; rows: readonly DisplayRow[]; } | { - kind: "table"; + kind: 'table'; table: DisplayTable; } | { - kind: "text"; + kind: 'text'; lines: readonly DisplayText[]; }; @@ -69,18 +69,22 @@ export type DisplayTable = { }; export function rows(rows: readonly DisplayRow[]): DisplayBlock { - return { kind: "rows", rows }; + return { kind: 'rows', rows }; } export function table(displayTable: DisplayTable): DisplayBlock { - return { kind: "table", table: displayTable as DisplayTable }; + return { kind: 'table', table: displayTable as DisplayTable }; } export function text(lines: readonly DisplayText[]): DisplayBlock { - return { kind: "text", lines }; + return { kind: 'text', lines }; } -export function span(text: string, style?: DisplayTextStyle, href?: string): DisplaySpan { +export function span( + text: string, + style?: DisplayTextStyle, + href?: string +): DisplaySpan { return { text, ...(style ? { style } : {}), @@ -92,6 +96,8 @@ export function section(...blocks: readonly DisplayBlock[]): DisplaySection { return { blocks }; } -export function document(...sections: readonly DisplaySection[]): DisplayDocument { +export function document( + ...sections: readonly DisplaySection[] +): DisplayDocument { return { sections }; } diff --git a/cli/src/ui/error.ts b/cli/src/ui/error.ts index f9e9e45d..e781640a 100644 --- a/cli/src/ui/error.ts +++ b/cli/src/ui/error.ts @@ -1,16 +1,19 @@ -import { CliError, serializeCliError } from "@/lib/errors.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import { CliError, serializeCliError } from '@/lib/errors.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export function renderCliErrorJson(error: unknown): string { return JSON.stringify(serializeCliError(error)); } export function renderCliError(error: unknown): string { - if (error instanceof CliError || (error instanceof Error && error.name === "CLIError")) { + if ( + error instanceof CliError || + (error instanceof Error && error.name === 'CLIError') + ) { return renderCliErrorDetails(error.message); } - return renderCliErrorDetails("Unexpected error."); + return renderCliErrorDetails('Unexpected error.'); } export function renderCliErrorDetails(details: string): string { @@ -18,8 +21,8 @@ export function renderCliErrorDetails(details: string): string { .split(/\r\n|\r|\n/) .map((line, index) => index === 0 - ? renderDisplayText([span("[ERROR]", "error"), span(` ${line}`)]) - : renderDisplayText(line), + ? renderDisplayText([span('[ERROR]', 'error'), span(` ${line}`)]) + : renderDisplayText(line) ) - .join("\n"); + .join('\n'); } diff --git a/cli/src/ui/layouts/query.ts b/cli/src/ui/layouts/query.ts index fd39301c..3b3af6e6 100644 --- a/cli/src/ui/layouts/query.ts +++ b/cli/src/ui/layouts/query.ts @@ -1,4 +1,4 @@ -export const QUERY_LAYOUT_OPTIONS = ["auto", "table", "line"] as const; +export const QUERY_LAYOUT_OPTIONS = ['auto', 'table', 'line'] as const; export type QueryLayout = (typeof QUERY_LAYOUT_OPTIONS)[number]; diff --git a/cli/src/ui/layouts/tree.test.ts b/cli/src/ui/layouts/tree.test.ts index dd5c0094..de3ff064 100644 --- a/cli/src/ui/layouts/tree.test.ts +++ b/cli/src/ui/layouts/tree.test.ts @@ -1,38 +1,43 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test'; -import { renderTree } from "@/ui/renderers/terminal.ts"; +import { renderTree } from '@/ui/renderers/terminal.ts'; -describe("tree layout", () => { - test("renders nested tree branches", () => { +describe('tree layout', () => { + test('renders nested tree branches', () => { expect( renderTree({ - title: "Catalog", + title: 'Catalog', children: [ { - label: "main", - children: [{ label: "users" }, { label: "events", children: [{ label: "id" }] }], + label: 'main', + children: [ + { label: 'users' }, + { label: 'events', children: [{ label: 'id' }] }, + ], }, - { label: "scratch" }, + { label: 'scratch' }, ], - }), + }) ).toEqual([ - "Catalog", - "├── main", - "│ ├── users", - "│ └── events", - "│ └── id", - "└── scratch", + 'Catalog', + '├── main', + '│ ├── users', + '│ └── events', + '│ └── id', + '└── scratch', ]); }); - test("renders empty root and empty children labels", () => { + test('renders empty root and empty children labels', () => { expect( renderTree({ - children: [{ label: "main", emptyLabel: "" }], - emptyLabel: "", - }), - ).toEqual(["└── main", " └── "]); + children: [{ label: 'main', emptyLabel: '' }], + emptyLabel: '', + }) + ).toEqual(['└── main', ' └── ']); - expect(renderTree({ children: [], emptyLabel: "" })).toEqual(["└── "]); + expect(renderTree({ children: [], emptyLabel: '' })).toEqual([ + '└── ', + ]); }); }); diff --git a/cli/src/ui/layouts/tree.ts b/cli/src/ui/layouts/tree.ts index b778dda8..e8956fbd 100644 --- a/cli/src/ui/layouts/tree.ts +++ b/cli/src/ui/layouts/tree.ts @@ -1,4 +1,4 @@ -import type { DisplayText } from "@/ui/document.ts"; +import type { DisplayText } from '@/ui/document.ts'; export type TreeNode = { label: DisplayText; diff --git a/cli/src/ui/prompts.ts b/cli/src/ui/prompts.ts index eab9f3f6..3cf8a30b 100644 --- a/cli/src/ui/prompts.ts +++ b/cli/src/ui/prompts.ts @@ -4,10 +4,10 @@ import { password as clackPassword, select as clackSelect, text as clackText, -} from "@clack/prompts"; -import { stdin as input, stderr as output } from "node:process"; -import { CliError } from "@/lib/errors.ts"; -import { ensurePromptColorAlignment } from "@/ui/terminal/styles.ts"; +} from '@clack/prompts'; +import { stdin as input, stderr as output } from 'node:process'; +import { CliError } from '@/lib/errors.ts'; +import { ensurePromptColorAlignment } from '@/ui/terminal/styles.ts'; export type SelectOption = { value: string; label: string }; export type ReadSelectOptions = { leadingNewline?: boolean }; @@ -20,14 +20,14 @@ export type Prompts = { title: string, options: SelectOption[], defaultValue?: string, - selectOptions?: ReadSelectOptions, + selectOptions?: ReadSelectOptions ): Promise; readConfirm(prompt: string, defaultYes?: boolean): Promise; }; export class PromptCancelled extends CliError { constructor() { - super("Prompt cancelled."); + super('Prompt cancelled.'); } } @@ -36,31 +36,31 @@ function writePromptLine(line: string): void { } async function readStdinLine(): Promise { - return await new Promise((resolve) => { - let buffer = ""; + return await new Promise(resolve => { + let buffer = ''; let settled = false; function settle(): void { if (settled) return; settled = true; - input.off("data", onData); - input.off("end", settle); - resolve((buffer.split("\n")[0] ?? buffer).trim()); + input.off('data', onData); + input.off('end', settle); + resolve((buffer.split('\n')[0] ?? buffer).trim()); } function onData(chunk: Buffer): void { - buffer += chunk.toString("utf8"); - if (buffer.includes("\n")) settle(); + buffer += chunk.toString('utf8'); + if (buffer.includes('\n')) settle(); } - input.on("data", onData); - input.on("end", settle); + input.on('data', onData); + input.on('end', settle); input.resume(); }); } function normalizePromptMessage(prompt: string): string { - return prompt.trim().replace(/:\s*$/, ""); + return prompt.trim().replace(/:\s*$/, ''); } function resolvePromptResult(result: string | symbol): string { @@ -75,7 +75,7 @@ async function readInteractiveLine(prompt: string): Promise { } ensurePromptColorAlignment(); return resolvePromptResult( - await clackText({ message: normalizePromptMessage(prompt), input, output }), + await clackText({ message: normalizePromptMessage(prompt), input, output }) ); } @@ -86,7 +86,11 @@ async function readHiddenPassword(prompt: string): Promise { } ensurePromptColorAlignment(); return resolvePromptResult( - await clackPassword({ message: normalizePromptMessage(prompt), input, output }), + await clackPassword({ + message: normalizePromptMessage(prompt), + input, + output, + }) ); } @@ -94,31 +98,34 @@ async function readSelect( title: string, options: SelectOption[], defaultValue?: string, - selectOptions: ReadSelectOptions = {}, + selectOptions: ReadSelectOptions = {} ): Promise { - if (options.length === 0) throw new CliError("No options available."); - if (!input.isTTY) throw new CliError("Interactive select requires a TTY."); - if (selectOptions.leadingNewline !== false) writePromptLine("\n"); + if (options.length === 0) throw new CliError('No options available.'); + if (!input.isTTY) throw new CliError('Interactive select requires a TTY.'); + if (selectOptions.leadingNewline !== false) writePromptLine('\n'); ensurePromptColorAlignment(); return resolvePromptResult( await clackSelect({ message: title, - options: options.map((option) => ({ + options: options.map(option => ({ ...option, - hint: option.value === defaultValue ? "default" : undefined, + hint: option.value === defaultValue ? 'default' : undefined, })), initialValue: defaultValue, input, output, - }), + }) ); } -async function readConfirm(prompt: string, defaultYes = true): Promise { +async function readConfirm( + prompt: string, + defaultYes = true +): Promise { if (!input.isTTY) { const answer = (await readStdinLine()).trim().toLowerCase(); - return answer === "" ? defaultYes : answer === "y" || answer === "yes"; + return answer === '' ? defaultYes : answer === 'y' || answer === 'yes'; } ensurePromptColorAlignment(); diff --git a/cli/src/ui/renderers/terminal.ts b/cli/src/ui/renderers/terminal.ts index 16c23ca3..927dcd71 100644 --- a/cli/src/ui/renderers/terminal.ts +++ b/cli/src/ui/renderers/terminal.ts @@ -4,16 +4,16 @@ import { type DisplayDocument, type DisplayRow, type DisplaySection, -} from "@/ui/document.ts"; -import type { TreeNode, TreeView } from "@/ui/layouts/tree.ts"; +} from '@/ui/document.ts'; +import type { TreeNode, TreeView } from '@/ui/layouts/tree.ts'; import { nestedIndent, TERMINAL_INDENT, TERMINAL_LABEL_WIDTH, TERMINAL_NESTED_LABEL_WIDTH, -} from "@/ui/terminal/spacing.ts"; -import { renderFixedTable } from "@/ui/terminal/table.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; +} from '@/ui/terminal/spacing.ts'; +import { renderFixedTable } from '@/ui/terminal/table.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export type TerminalRenderOptions = { indent?: string; @@ -25,68 +25,75 @@ export type TerminalRenderOptions = { function renderLabelValue( label: string, value: string, - options: { indent: string; labelWidth: number }, + options: { indent: string; labelWidth: number } ): string { - return `${options.indent}${renderDisplayText([span(label.padEnd(options.labelWidth), "muted")])} ${value}`; + return `${options.indent}${renderDisplayText([span(label.padEnd(options.labelWidth), 'muted')])} ${value}`; } export function renderRows( rows: readonly DisplayRow[], - options: TerminalRenderOptions = {}, + options: TerminalRenderOptions = {} ): string[] { const indent = options.indent ?? TERMINAL_INDENT; const labelWidth = options.labelWidth ?? TERMINAL_LABEL_WIDTH; const childIndent = options.nestedIndent ?? nestedIndent(indent); - const nestedLabelWidth = options.nestedLabelWidth ?? TERMINAL_NESTED_LABEL_WIDTH; + const nestedLabelWidth = + options.nestedLabelWidth ?? TERMINAL_NESTED_LABEL_WIDTH; - return rows.map((row) => + return rows.map(row => renderLabelValue(row.label, renderDisplayText(row.value), { indent: row.level === 1 ? childIndent : indent, labelWidth: row.level === 1 ? nestedLabelWidth : labelWidth, - }), + }) ); } -function renderBlock(block: DisplayBlock, options: TerminalRenderOptions): string[] { - if (block.kind === "text") { +function renderBlock( + block: DisplayBlock, + options: TerminalRenderOptions +): string[] { + if (block.kind === 'text') { return block.lines.map(renderDisplayText); } - if (block.kind === "table") { + if (block.kind === 'table') { return renderFixedTable( [...block.table.rows], [...block.table.columns], block.table.emptyMessage, - block.table.options, - ).split("\n"); + block.table.options + ).split('\n'); } return renderRows(block.rows, options); } -function renderSection(section: DisplaySection, options: TerminalRenderOptions): string[] { - return section.blocks.flatMap((block) => renderBlock(block, options)); +function renderSection( + section: DisplaySection, + options: TerminalRenderOptions +): string[] { + return section.blocks.flatMap(block => renderBlock(block, options)); } export function renderDocument( document: DisplayDocument, - options: TerminalRenderOptions = {}, + options: TerminalRenderOptions = {} ): string[] { return document.sections.flatMap((section, index) => [ - ...(index === 0 ? [] : [""]), + ...(index === 0 ? [] : ['']), ...renderSection(section, options), ]); } export function renderDocumentText( document: DisplayDocument, - options: TerminalRenderOptions = {}, + options: TerminalRenderOptions = {} ): string { - return renderDocument(document, options).join("\n"); + return renderDocument(document, options).join('\n'); } -const TREE_BRANCH = "├── "; -const TREE_LAST_BRANCH = "└── "; -const TREE_CHILD_PREFIX = "│ "; -const TREE_LAST_CHILD_PREFIX = " "; +const TREE_BRANCH = '├── '; +const TREE_LAST_BRANCH = '└── '; +const TREE_CHILD_PREFIX = '│ '; +const TREE_LAST_CHILD_PREFIX = ' '; function renderTreeNodes(nodes: readonly TreeNode[], prefix: string): string[] { return nodes.flatMap((node, index) => { @@ -99,7 +106,9 @@ function renderTreeNodes(nodes: readonly TreeNode[], prefix: string): string[] { if (children.length > 0) { lines.push(...renderTreeNodes(children, childPrefix)); } else if (node.emptyLabel) { - lines.push(`${childPrefix}${TREE_LAST_BRANCH}${renderDisplayText(node.emptyLabel)}`); + lines.push( + `${childPrefix}${TREE_LAST_BRANCH}${renderDisplayText(node.emptyLabel)}` + ); } return lines; @@ -110,14 +119,16 @@ export function renderTree(view: TreeView): string[] { const lines = view.title ? [renderDisplayText(view.title)] : []; if (view.children.length === 0) { - lines.push(`${TREE_LAST_BRANCH}${renderDisplayText(view.emptyLabel ?? "")}`); + lines.push( + `${TREE_LAST_BRANCH}${renderDisplayText(view.emptyLabel ?? '')}` + ); return lines; } - lines.push(...renderTreeNodes(view.children, "")); + lines.push(...renderTreeNodes(view.children, '')); return lines; } export function renderTreeText(view: TreeView): string { - return renderTree(view).join("\n"); + return renderTree(view).join('\n'); } diff --git a/cli/src/ui/shell/render.test.ts b/cli/src/ui/shell/render.test.ts index 67d93008..4b6636da 100644 --- a/cli/src/ui/shell/render.test.ts +++ b/cli/src/ui/shell/render.test.ts @@ -1,13 +1,15 @@ -import { describe, expect, test } from "bun:test"; -import { renderShellExportView } from "@/ui/shell/render.ts"; +import { describe, expect, test } from 'bun:test'; +import { renderShellExportView } from '@/ui/shell/render.ts'; -describe("renderShellExportView", () => { - test("renders comments followed by quoted environment exports", () => { +describe('renderShellExportView', () => { + test('renders comments followed by quoted environment exports', () => { expect( renderShellExportView({ - env: { ALTERTABLE_PROFILE: "acme_prod" }, - comments: ["Generated by: altertable profile env"], - }), - ).toBe('# Generated by: altertable profile env\nexport ALTERTABLE_PROFILE="acme_prod"'); + env: { ALTERTABLE_PROFILE: 'acme_prod' }, + comments: ['Generated by: altertable profile env'], + }) + ).toBe( + '# Generated by: altertable profile env\nexport ALTERTABLE_PROFILE="acme_prod"' + ); }); }); diff --git a/cli/src/ui/shell/render.ts b/cli/src/ui/shell/render.ts index 4e1ac12e..721b42ed 100644 --- a/cli/src/ui/shell/render.ts +++ b/cli/src/ui/shell/render.ts @@ -1,4 +1,4 @@ -import type { ShellExportView } from "@/ui/shell/model.ts"; +import type { ShellExportView } from '@/ui/shell/model.ts'; export function shellExportLine(name: string, value: string): string { return `export ${name}=${JSON.stringify(value)}`; @@ -6,7 +6,9 @@ export function shellExportLine(name: string, value: string): string { export function renderShellExportView(view: ShellExportView): string { return [ - ...(view.comments ?? []).map((comment) => `# ${comment}`), - ...Object.entries(view.env).map(([name, value]) => shellExportLine(name, value)), - ].join("\n"); + ...(view.comments ?? []).map(comment => `# ${comment}`), + ...Object.entries(view.env).map(([name, value]) => + shellExportLine(name, value) + ), + ].join('\n'); } diff --git a/cli/src/ui/terminal/spacing.ts b/cli/src/ui/terminal/spacing.ts index 93500512..86d8b135 100644 --- a/cli/src/ui/terminal/spacing.ts +++ b/cli/src/ui/terminal/spacing.ts @@ -1,4 +1,4 @@ -export const TERMINAL_INDENT = " "; +export const TERMINAL_INDENT = ' '; export const TERMINAL_LABEL_WIDTH = 17; export const TERMINAL_NESTED_LABEL_WIDTH = 14; diff --git a/cli/src/ui/terminal/styles.test.ts b/cli/src/ui/terminal/styles.test.ts index c816453d..bebf02d5 100644 --- a/cli/src/ui/terminal/styles.test.ts +++ b/cli/src/ui/terminal/styles.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { getVisibleTextWidth, setTerminalColorMode, @@ -6,8 +6,8 @@ import { truncateTerminalText, applyTerminalColorFromContext, renderDisplayText, -} from "@/ui/terminal/styles.ts"; -import { span } from "@/ui/document.ts"; +} from '@/ui/terminal/styles.ts'; +import { span } from '@/ui/document.ts'; const originalNoColor = process.env.NO_COLOR; const originalTerm = process.env.TERM; @@ -23,10 +23,16 @@ function enableTerminalColorForTests(): void { delete process.env.CI; delete process.env.FORCE_COLOR; delete process.env.ALTERTABLE_COLOR; - process.env.TERM = "xterm-256color"; - setTerminalColorMode("always"); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + process.env.TERM = 'xterm-256color'; + setTerminalColorMode('always'); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); } afterEach(() => { @@ -56,17 +62,17 @@ afterEach(() => { } else { process.env.OSC_HYPERLINK = originalOscHyperlink; } - Object.defineProperty(process.stdout, "isTTY", { + Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true, }); - Object.defineProperty(process.stderr, "isTTY", { + Object.defineProperty(process.stderr, 'isTTY', { value: originalStderrIsTTY, configurable: true, }); }); -describe("terminal-style", () => { +describe('terminal-style', () => { let savedTest: string | undefined; let savedCi: string | undefined; @@ -89,10 +95,10 @@ describe("terminal-style", () => { } }); - test("clears CLI-owned NO_COLOR when --no-color is removed from context", () => { + test('clears CLI-owned NO_COLOR when --no-color is removed from context', () => { enableTerminalColorForTests(); applyTerminalColorFromContext({ noColor: true }); - expect(process.env.NO_COLOR).toBe("1"); + expect(process.env.NO_COLOR).toBe('1'); expect(shouldUseTerminalColor()).toBe(false); applyTerminalColorFromContext({ noColor: false }); @@ -100,176 +106,212 @@ describe("terminal-style", () => { expect(shouldUseTerminalColor()).toBe(true); }); - test("preserves user NO_COLOR when --no-color was never set", () => { - process.env.NO_COLOR = "1"; + test('preserves user NO_COLOR when --no-color was never set', () => { + process.env.NO_COLOR = '1'; applyTerminalColorFromContext({ noColor: false }); - expect(process.env.NO_COLOR).toBe("1"); + expect(process.env.NO_COLOR).toBe('1'); }); - test("returns plain text when NO_COLOR is set", () => { - process.env.NO_COLOR = "1"; + test('returns plain text when NO_COLOR is set', () => { + process.env.NO_COLOR = '1'; expect( renderDisplayText([ - span("altertable", "accent"), - span(" USAGE", "strong"), - span(" [default]", "subtle"), - span(" ok", "success"), - span(" careful", "warning"), - span(" failed", "error"), - ]), - ).toBe("altertable USAGE [default] ok careful failed"); - expect(renderDisplayText([span("Options", "heading")])).toBe("OPTIONS"); + span('altertable', 'accent'), + span(' USAGE', 'strong'), + span(' [default]', 'subtle'), + span(' ok', 'success'), + span(' careful', 'warning'), + span(' failed', 'error'), + ]) + ).toBe('altertable USAGE [default] ok careful failed'); + expect(renderDisplayText([span('Options', 'heading')])).toBe('OPTIONS'); }); - test("disables color when stdout and stderr are not TTY in auto mode", () => { + test('disables color when stdout and stderr are not TTY in auto mode', () => { delete process.env.NO_COLOR; delete process.env.TEST; delete process.env.CI; delete process.env.FORCE_COLOR; - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + Object.defineProperty(process.stderr, 'isTTY', { + value: false, + configurable: true, + }); expect(shouldUseTerminalColor()).toBe(false); - expect(renderDisplayText([span("altertable", "accent")])).toBe("altertable"); + expect(renderDisplayText([span('altertable', 'accent')])).toBe( + 'altertable' + ); }); - test("enables color with FORCE_COLOR even when not a TTY", () => { + test('enables color with FORCE_COLOR even when not a TTY', () => { delete process.env.NO_COLOR; delete process.env.TEST; delete process.env.CI; - process.env.FORCE_COLOR = "1"; - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + process.env.FORCE_COLOR = '1'; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + Object.defineProperty(process.stderr, 'isTTY', { + value: false, + configurable: true, + }); expect(shouldUseTerminalColor()).toBe(true); }); - test("respects ALTERTABLE_COLOR=never", () => { + test('respects ALTERTABLE_COLOR=never', () => { enableTerminalColorForTests(); - process.env.ALTERTABLE_COLOR = "never"; + process.env.ALTERTABLE_COLOR = 'never'; setTerminalColorMode(undefined); expect(shouldUseTerminalColor()).toBe(false); }); - test("wraps text with ANSI codes when color is enabled", () => { + test('wraps text with ANSI codes when color is enabled', () => { enableTerminalColorForTests(); - expect(renderDisplayText([span("configure", "accent")])).toContain( - "\u001b[96mconfigure\u001b[39m", + expect(renderDisplayText([span('configure', 'accent')])).toContain( + '\u001b[96mconfigure\u001b[39m' ); - expect(renderDisplayText([span("ok", "success")])).toContain("\u001b[32mok\u001b[39m"); - expect(renderDisplayText([span("careful", "warning")])).toContain( - "\u001b[93mcareful\u001b[39m", + expect(renderDisplayText([span('ok', 'success')])).toContain( + '\u001b[32mok\u001b[39m' ); - expect(renderDisplayText([span("failed", "error")])).toContain("\u001b[31mfailed\u001b[39m"); - expect(renderDisplayText([span("Usage", "heading")])).toContain("USAGE"); + expect(renderDisplayText([span('careful', 'warning')])).toContain( + '\u001b[93mcareful\u001b[39m' + ); + expect(renderDisplayText([span('failed', 'error')])).toContain( + '\u001b[31mfailed\u001b[39m' + ); + expect(renderDisplayText([span('Usage', 'heading')])).toContain('USAGE'); }); - test("maps each data type to a dedicated color", () => { + test('maps each data type to a dedicated color', () => { enableTerminalColorForTests(); - expect(renderDisplayText([span("NULL", "subtle")])).toContain("\u001b[90m"); - expect(renderDisplayText([span("true", "boolean")])).toContain("\u001b[35m"); - expect(renderDisplayText([span("42", "number")])).toContain("\u001b[33m"); - expect(renderDisplayText([span("hello", "string")])).toContain("\u001b[34m"); - expect(renderDisplayText([span("uuid", "accent")])).toContain("\u001b[96m"); + expect(renderDisplayText([span('NULL', 'subtle')])).toContain('\u001b[90m'); + expect(renderDisplayText([span('true', 'boolean')])).toContain( + '\u001b[35m' + ); + expect(renderDisplayText([span('42', 'number')])).toContain('\u001b[33m'); + expect(renderDisplayText([span('hello', 'string')])).toContain( + '\u001b[34m' + ); + expect(renderDisplayText([span('uuid', 'accent')])).toContain('\u001b[96m'); }); - test("styles HTTP methods with semantic colors", () => { + test('styles HTTP methods with semantic colors', () => { enableTerminalColorForTests(); - expect(renderDisplayText([span("GET", "httpMethod")])).toContain("\u001b[32m"); - expect(renderDisplayText([span("POST", "httpMethod")])).toContain("\u001b[96m"); - expect(renderDisplayText([span("PATCH", "httpMethod")])).toContain("\u001b[93m"); - expect(renderDisplayText([span("PUT", "httpMethod")])).toContain("\u001b[34m"); - expect(renderDisplayText([span("DELETE", "httpMethod")])).toContain("\u001b[31m"); + expect(renderDisplayText([span('GET', 'httpMethod')])).toContain( + '\u001b[32m' + ); + expect(renderDisplayText([span('POST', 'httpMethod')])).toContain( + '\u001b[96m' + ); + expect(renderDisplayText([span('PATCH', 'httpMethod')])).toContain( + '\u001b[93m' + ); + expect(renderDisplayText([span('PUT', 'httpMethod')])).toContain( + '\u001b[34m' + ); + expect(renderDisplayText([span('DELETE', 'httpMethod')])).toContain( + '\u001b[31m' + ); }); - test("styles timestamp absolute and relative parts with distinct contrast", () => { + test('styles timestamp absolute and relative parts with distinct contrast', () => { enableTerminalColorForTests(); const output = renderDisplayText([ - span("2026-06-21T06:35:24.409Z", "string"), - span(" 6 days ago", "subtle"), + span('2026-06-21T06:35:24.409Z', 'string'), + span(' 6 days ago', 'subtle'), ]); - expect(output).toContain("\u001b[34m2026-06-21T06:35:24.409Z\u001b[39m"); - expect(output).toContain("\u001b[90m 6 days ago\u001b[39m"); + expect(output).toContain('\u001b[34m2026-06-21T06:35:24.409Z\u001b[39m'); + expect(output).toContain('\u001b[90m 6 days ago\u001b[39m'); }); - test("truncateTerminalText respects visible width with ANSI codes", () => { + test('truncateTerminalText respects visible width with ANSI codes', () => { enableTerminalColorForTests(); - const styled = renderDisplayText([span("abcdefghijklmnopqrstuvwxyz", "accent")]); + const styled = renderDisplayText([ + span('abcdefghijklmnopqrstuvwxyz', 'accent'), + ]); const truncated = truncateTerminalText(styled, 10); - expect(truncated).toContain("…"); - expect(truncated).not.toContain("klmnopqrstuvwxyz"); + expect(truncated).toContain('…'); + expect(truncated).not.toContain('klmnopqrstuvwxyz'); expect(getVisibleTextWidth(truncated)).toBeLessThanOrEqual(10); }); - test("truncateTerminalText keeps terminal hyperlinks non-printing", () => { + test('truncateTerminalText keeps terminal hyperlinks non-printing', () => { enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "1"; + process.env.OSC_HYPERLINK = '1'; const linked = renderDisplayText([ - span("abcdefghijklmnopqrstuvwxyz", "accent", "https://example.com"), + span('abcdefghijklmnopqrstuvwxyz', 'accent', 'https://example.com'), ]); const truncated = truncateTerminalText(linked, 10); - expect(truncated).toContain("\u001b]8;;https://example.com\u0007"); - expect(truncated).toContain("\u001b]8;;\u0007"); + expect(truncated).toContain('\u001b]8;;https://example.com\u0007'); + expect(truncated).toContain('\u001b]8;;\u0007'); expect(getVisibleTextWidth(truncated)).toBeLessThanOrEqual(10); }); - test("counts wide characters as double width", () => { - expect(getVisibleTextWidth("日本語")).toBe(6); - expect(getVisibleTextWidth("abc")).toBe(3); - expect(getVisibleTextWidth("e\u0301")).toBe(1); - expect(getVisibleTextWidth("👨‍👩‍👧‍👦")).toBe(2); - expect(getVisibleTextWidth("🇫🇷")).toBe(2); + test('counts wide characters as double width', () => { + expect(getVisibleTextWidth('日本語')).toBe(6); + expect(getVisibleTextWidth('abc')).toBe(3); + expect(getVisibleTextWidth('e\u0301')).toBe(1); + expect(getVisibleTextWidth('👨‍👩‍👧‍👦')).toBe(2); + expect(getVisibleTextWidth('🇫🇷')).toBe(2); }); - test("truncates without splitting joined emoji", () => { - const truncated = truncateTerminalText("👨‍👩‍👧‍👦abc", 4); + test('truncates without splitting joined emoji', () => { + const truncated = truncateTerminalText('👨‍👩‍👧‍👦abc', 4); - expect(truncated).toBe("👨‍👩‍👧‍👦a…"); + expect(truncated).toBe('👨‍👩‍👧‍👦a…'); expect(getVisibleTextWidth(truncated)).toBe(4); - const flag = truncateTerminalText("🇫🇷abc", 4); - expect(flag).toBe("🇫🇷a…"); + const flag = truncateTerminalText('🇫🇷abc', 4); + expect(flag).toBe('🇫🇷a…'); expect(getVisibleTextWidth(flag)).toBe(4); }); - test("renders semantic links as OSC 8 hyperlinks when supported", () => { + test('renders semantic links as OSC 8 hyperlinks when supported', () => { enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "1"; - const url = "https://api.altertable.ai"; - const linked = renderDisplayText([span(url, "accent", url)]); - expect(linked).toContain("\u001b]8;;https://api.altertable.ai\u0007"); - expect(linked).toContain("\u001b]8;;\u0007"); + process.env.OSC_HYPERLINK = '1'; + const url = 'https://api.altertable.ai'; + const linked = renderDisplayText([span(url, 'accent', url)]); + expect(linked).toContain('\u001b]8;;https://api.altertable.ai\u0007'); + expect(linked).toContain('\u001b]8;;\u0007'); }); - test("renders styled presentation spans and links", () => { + test('renders styled presentation spans and links', () => { enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "0"; - const url = "https://api.altertable.ai"; - expect(renderDisplayText([span(url, "accent", url)])).toBe( - "\u001b[96mhttps://api.altertable.ai\u001b[39m", - ); - expect(renderDisplayText([span("Docs", "accent", "https://example.com")])).toContain( - "Docs\u001b[39m \u001b[90m(https://example.com)", + process.env.OSC_HYPERLINK = '0'; + const url = 'https://api.altertable.ai'; + expect(renderDisplayText([span(url, 'accent', url)])).toBe( + '\u001b[96mhttps://api.altertable.ai\u001b[39m' ); + expect( + renderDisplayText([span('Docs', 'accent', 'https://example.com')]) + ).toContain('Docs\u001b[39m \u001b[90m(https://example.com)'); }); - test("escapes terminal controls and bidirectional overrides in span text", () => { - setTerminalColorMode("never"); - const output = renderDisplayText("safe\u001b]0;spoofed\u0007\nnext\u202eright"); + test('escapes terminal controls and bidirectional overrides in span text', () => { + setTerminalColorMode('never'); + const output = renderDisplayText( + 'safe\u001b]0;spoofed\u0007\nnext\u202eright' + ); - expect(output).toBe("safe\\x1b]0;spoofed\\x07\\x0anext\\u202eright"); - expect(output).not.toContain("\u001b"); - expect(output).not.toContain("\u0007"); - expect(output).not.toContain("\u202e"); + expect(output).toBe('safe\\x1b]0;spoofed\\x07\\x0anext\\u202eright'); + expect(output).not.toContain('\u001b'); + expect(output).not.toContain('\u0007'); + expect(output).not.toContain('\u202e'); }); - test("never emits unsafe hyperlink targets", () => { + test('never emits unsafe hyperlink targets', () => { enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "1"; + process.env.OSC_HYPERLINK = '1'; const output = renderDisplayText([ - span("Docs", "accent", "https://example.com\u0007\u001b]0;spoofed"), + span('Docs', 'accent', 'https://example.com\u0007\u001b]0;spoofed'), ]); - expect(output).not.toContain("\u001b]8;;"); - expect(output).not.toContain("\u0007"); - expect(output).toContain("https://example.com\\x07\\x1b]0;spoofed"); + expect(output).not.toContain('\u001b]8;;'); + expect(output).not.toContain('\u0007'); + expect(output).toContain('https://example.com\\x07\\x1b]0;spoofed'); }); }); diff --git a/cli/src/ui/terminal/styles.ts b/cli/src/ui/terminal/styles.ts index 0315a73f..dc84c2f1 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -1,22 +1,30 @@ -import { readEnv, setEnv, unsetEnv } from "@/lib/env.ts"; +import { readEnv, setEnv, unsetEnv } from '@/lib/env.ts'; -import type { DisplayText, DisplayTextStyle } from "@/ui/document.ts"; +import type { DisplayText, DisplayTextStyle } from '@/ui/document.ts'; export const DEFAULT_TERMINAL_WIDTH = 80; -const TERMINAL_ELLIPSIS = "…"; +const TERMINAL_ELLIPSIS = '…'; const ANSI_ESCAPE_CHARACTER = String.fromCharCode(27); -const ANSI_RESET = "\u001b[0m"; -const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_CHARACTER}\\[[0-9;]*m`, "g"); -const OSC_HYPERLINK_START = "\u001b]8;;"; -const OSC_HYPERLINK_SEPARATOR = "\u0007"; -const OSC_HYPERLINK_END = "\u001b]8;;\u0007"; -const OSC_HYPERLINK_PATTERN = new RegExp(`${ANSI_ESCAPE_CHARACTER}\\]8;;[^\\u0007]*\\u0007`, "g"); +const ANSI_RESET = '\u001b[0m'; +const ANSI_ESCAPE_PATTERN = new RegExp( + `${ANSI_ESCAPE_CHARACTER}\\[[0-9;]*m`, + 'g' +); +const OSC_HYPERLINK_START = '\u001b]8;;'; +const OSC_HYPERLINK_SEPARATOR = '\u0007'; +const OSC_HYPERLINK_END = '\u001b]8;;\u0007'; +const OSC_HYPERLINK_PATTERN = new RegExp( + `${ANSI_ESCAPE_CHARACTER}\\]8;;[^\\u0007]*\\u0007`, + 'g' +); const TERMINAL_CONTROL_PATTERN = new RegExp( `${ANSI_ESCAPE_CHARACTER}\\[[0-9;]*m|${ANSI_ESCAPE_CHARACTER}\\]8;;[^\\u0007]*\\u0007`, - "g", + 'g' ); -type TerminalStyle = Exclude | "underline"; +type TerminalStyle = + | Exclude + | 'underline'; const STYLE_CODES: Record = { strong: { open: 1, close: 22 }, @@ -32,39 +40,45 @@ const STYLE_CODES: Record = { error: { open: 31, close: 39 }, }; -export type TerminalColorMode = "auto" | "always" | "never"; +export type TerminalColorMode = 'auto' | 'always' | 'never'; let contextColorMode: TerminalColorMode | undefined; let noColorEnvSetByCli = false; -const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const graphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', +}); -export function setTerminalColorMode(mode: TerminalColorMode | undefined): void { +export function setTerminalColorMode( + mode: TerminalColorMode | undefined +): void { contextColorMode = mode; } -export function applyTerminalColorFromContext(context: { noColor?: boolean }): void { +export function applyTerminalColorFromContext(context: { + noColor?: boolean; +}): void { if (context.noColor) { - setTerminalColorMode("never"); - setEnv("NO_COLOR", "1"); + setTerminalColorMode('never'); + setEnv('NO_COLOR', '1'); noColorEnvSetByCli = true; return; } setTerminalColorMode(undefined); if (noColorEnvSetByCli) { - unsetEnv("NO_COLOR"); + unsetEnv('NO_COLOR'); noColorEnvSetByCli = false; } } export function ensurePromptColorAlignment(): void { if (!shouldUseTerminalColor()) { - setEnv("NO_COLOR", "1"); + setEnv('NO_COLOR', '1'); } } function readEnvColorMode(): TerminalColorMode | undefined { try { - return readEnv("ALTERTABLE_COLOR"); + return readEnv('ALTERTABLE_COLOR'); } catch { // Error rendering must remain available while startup validation reports // an invalid ALTERTABLE_COLOR value. @@ -80,15 +94,15 @@ function resolveTerminalColorMode(): TerminalColorMode { if (envMode !== undefined) { return envMode; } - const noColor = readEnv("NO_COLOR"); - const forceColor = readEnv("FORCE_COLOR"); - if (noColor === "1" || forceColor === "0") { - return "never"; + const noColor = readEnv('NO_COLOR'); + const forceColor = readEnv('FORCE_COLOR'); + if (noColor === '1' || forceColor === '0') { + return 'never'; } - if (forceColor === "1" || forceColor === "2" || forceColor === "3") { - return "always"; + if (forceColor === '1' || forceColor === '2' || forceColor === '3') { + return 'always'; } - return "auto"; + return 'auto'; } function isInteractiveTerminal(): boolean { @@ -97,13 +111,17 @@ function isInteractiveTerminal(): boolean { export function shouldUseTerminalColor(): boolean { const mode = resolveTerminalColorMode(); - if (mode === "never") { + if (mode === 'never') { return false; } - if (mode === "always") { + if (mode === 'always') { return true; } - if (readEnv("TERM") === "dumb" || Boolean(readEnv("TEST")) || Boolean(readEnv("CI"))) { + if ( + readEnv('TERM') === 'dumb' || + Boolean(readEnv('TEST')) || + Boolean(readEnv('CI')) + ) { return false; } return isInteractiveTerminal(); @@ -113,27 +131,27 @@ export function shouldUseTerminalHyperlinks(): boolean { if (!shouldUseTerminalColor()) { return false; } - const override = readEnv("OSC_HYPERLINK"); - if (override === "0" || override === "false") { + const override = readEnv('OSC_HYPERLINK'); + if (override === '0' || override === 'false') { return false; } - if (override === "1" || override === "true") { + if (override === '1' || override === 'true') { return true; } - const termProgram = readEnv("TERM_PROGRAM"); - if (readEnv("SSH_CONNECTION") && termProgram !== "Apple_Terminal") { + const termProgram = readEnv('TERM_PROGRAM'); + if (readEnv('SSH_CONNECTION') && termProgram !== 'Apple_Terminal') { return false; } if ( - termProgram === "iTerm.app" || - termProgram === "Apple_Terminal" || - termProgram === "ghostty" || - termProgram === "WezTerm" || - termProgram === "vscode" + termProgram === 'iTerm.app' || + termProgram === 'Apple_Terminal' || + termProgram === 'ghostty' || + termProgram === 'WezTerm' || + termProgram === 'vscode' ) { return true; } - if (readEnv("WT_SESSION") || readEnv("GHOSTTY_RESOURCES_DIR")) { + if (readEnv('WT_SESSION') || readEnv('GHOSTTY_RESOURCES_DIR')) { return true; } return isInteractiveTerminal(); @@ -171,7 +189,7 @@ function getCodePointDisplayWidth(codePoint: number): number { } function getGraphemeDisplayWidth(grapheme: string): number { - let width = grapheme.includes("\ufe0f") ? 2 : 0; + let width = grapheme.includes('\ufe0f') ? 2 : 0; let regionalIndicatorCount = 0; for (const character of grapheme) { const codePoint = character.codePointAt(0); @@ -201,29 +219,29 @@ export function getTerminalWidth(): number { } export function getVisibleTextWidth(text: string): number { - return getPlainTextDisplayWidth(text.replace(TERMINAL_CONTROL_PATTERN, "")); + return getPlainTextDisplayWidth(text.replace(TERMINAL_CONTROL_PATTERN, '')); } -export type TerminalTextAlignment = "left" | "right" | "center"; +export type TerminalTextAlignment = 'left' | 'right' | 'center'; export function padVisibleText( text: string, width: number, - alignment: TerminalTextAlignment = "left", + alignment: TerminalTextAlignment = 'left' ): string { const paddingWidth = Math.max(0, width - getVisibleTextWidth(text)); if (paddingWidth === 0) { return text; } - if (alignment === "right") { - return `${" ".repeat(paddingWidth)}${text}`; + if (alignment === 'right') { + return `${' '.repeat(paddingWidth)}${text}`; } - if (alignment === "center") { + if (alignment === 'center') { const leftPadding = Math.floor(paddingWidth / 2); const rightPadding = paddingWidth - leftPadding; - return `${" ".repeat(leftPadding)}${text}${" ".repeat(rightPadding)}`; + return `${' '.repeat(leftPadding)}${text}${' '.repeat(rightPadding)}`; } - return `${text}${" ".repeat(paddingWidth)}`; + return `${text}${' '.repeat(paddingWidth)}`; } export function truncateTerminalText(text: string, maxWidth?: number): string { @@ -236,12 +254,12 @@ export function truncateTerminalText(text: string, maxWidth?: number): string { const targetWidth = maxWidth - TERMINAL_ELLIPSIS.length; let visibleWidth = 0; - let result = ""; + let result = ''; for (let index = 0; index < text.length; index += 1) { const character = text[index]; - if (character === ANSI_ESCAPE_CHARACTER && text[index + 1] === "[") { - const closeIndex = text.indexOf("m", index + 2); + if (character === ANSI_ESCAPE_CHARACTER && text[index + 1] === '[') { + const closeIndex = text.indexOf('m', index + 2); if (closeIndex === -1) { break; } @@ -249,7 +267,7 @@ export function truncateTerminalText(text: string, maxWidth?: number): string { index = closeIndex; continue; } - if (character === ANSI_ESCAPE_CHARACTER && text[index + 1] === "]") { + if (character === ANSI_ESCAPE_CHARACTER && text[index + 1] === ']') { const closeIndex = text.indexOf(OSC_HYPERLINK_SEPARATOR, index + 2); if (closeIndex === -1) { break; @@ -259,8 +277,10 @@ export function truncateTerminalText(text: string, maxWidth?: number): string { continue; } - const grapheme = graphemeSegmenter.segment(text.slice(index))[Symbol.iterator]().next() - .value?.segment; + const grapheme = graphemeSegmenter + .segment(text.slice(index)) + [Symbol.iterator]() + .next().value?.segment; if (grapheme === undefined) { break; } @@ -273,10 +293,12 @@ export function truncateTerminalText(text: string, maxWidth?: number): string { visibleWidth += graphemeWidth; } - const closeOpenStyle = ANSI_ESCAPE_PATTERN.test(result) ? ANSI_RESET : ""; + const closeOpenStyle = ANSI_ESCAPE_PATTERN.test(result) ? ANSI_RESET : ''; ANSI_ESCAPE_PATTERN.lastIndex = 0; const closeOpenLink = - (result.match(OSC_HYPERLINK_PATTERN)?.length ?? 0) % 2 === 1 ? OSC_HYPERLINK_END : ""; + (result.match(OSC_HYPERLINK_PATTERN)?.length ?? 0) % 2 === 1 + ? OSC_HYPERLINK_END + : ''; return result + TERMINAL_ELLIPSIS + closeOpenStyle + closeOpenLink; } @@ -290,7 +312,7 @@ function applyTerminalStyle(style: TerminalStyle, text: string): string { } function sanitizeTerminalText(text: string): string { - let safe = ""; + let safe = ''; for (const character of text) { const codePoint = character.codePointAt(0) ?? 0; const unsafe = @@ -301,9 +323,9 @@ function sanitizeTerminalText(text: string): string { if (!unsafe) { safe += character; } else if (codePoint <= 0xff) { - safe += `\\x${codePoint.toString(16).padStart(2, "0")}`; + safe += `\\x${codePoint.toString(16).padStart(2, '0')}`; } else { - safe += `\\u${codePoint.toString(16).padStart(4, "0")}`; + safe += `\\u${codePoint.toString(16).padStart(4, '0')}`; } } return safe; @@ -315,7 +337,7 @@ function safeHyperlinkTarget(href: string): string | null { } try { const protocol = new URL(href).protocol; - return protocol === "http:" || protocol === "https:" ? href : null; + return protocol === 'http:' || protocol === 'https:' ? href : null; } catch { return null; } @@ -323,18 +345,18 @@ function safeHyperlinkTarget(href: string): string | null { function renderHttpMethod(method: string): string { switch (method.toUpperCase()) { - case "GET": - return applyTerminalStyle("success", method.toUpperCase()); - case "POST": - return applyTerminalStyle("accent", method.toUpperCase()); - case "PATCH": - return applyTerminalStyle("warning", method.toUpperCase()); - case "PUT": - return applyTerminalStyle("string", method.toUpperCase()); - case "DELETE": - return applyTerminalStyle("error", method.toUpperCase()); + case 'GET': + return applyTerminalStyle('success', method.toUpperCase()); + case 'POST': + return applyTerminalStyle('accent', method.toUpperCase()); + case 'PATCH': + return applyTerminalStyle('warning', method.toUpperCase()); + case 'PUT': + return applyTerminalStyle('string', method.toUpperCase()); + case 'DELETE': + return applyTerminalStyle('error', method.toUpperCase()); default: - return applyTerminalStyle("subtle", method.toUpperCase()); + return applyTerminalStyle('subtle', method.toUpperCase()); } } @@ -346,32 +368,37 @@ function renderLink(label: string, url: string): string { } function renderHeading(text: string): string { - return applyTerminalStyle("strong", applyTerminalStyle("underline", text.toUpperCase())); + return applyTerminalStyle( + 'strong', + applyTerminalStyle('underline', text.toUpperCase()) + ); } const DISPLAY_TEXT_STYLE_RENDERERS = { - strong: (text: string) => applyTerminalStyle("strong", text), - accent: (text: string) => applyTerminalStyle("accent", text), - string: (text: string) => applyTerminalStyle("string", text), - boolean: (text: string) => applyTerminalStyle("boolean", text), - number: (text: string) => applyTerminalStyle("number", text), - muted: (text: string) => applyTerminalStyle("muted", text), - subtle: (text: string) => applyTerminalStyle("subtle", text), - success: (text: string) => applyTerminalStyle("success", text), - warning: (text: string) => applyTerminalStyle("warning", text), - error: (text: string) => applyTerminalStyle("error", text), + strong: (text: string) => applyTerminalStyle('strong', text), + accent: (text: string) => applyTerminalStyle('accent', text), + string: (text: string) => applyTerminalStyle('string', text), + boolean: (text: string) => applyTerminalStyle('boolean', text), + number: (text: string) => applyTerminalStyle('number', text), + muted: (text: string) => applyTerminalStyle('muted', text), + subtle: (text: string) => applyTerminalStyle('subtle', text), + success: (text: string) => applyTerminalStyle('success', text), + warning: (text: string) => applyTerminalStyle('warning', text), + error: (text: string) => applyTerminalStyle('error', text), heading: renderHeading, httpMethod: renderHttpMethod, } satisfies Record string>; export function renderDisplayText(text: DisplayText): string { - if (typeof text === "string") { + if (typeof text === 'string') { return sanitizeTerminalText(text); } return text - .map((item) => { + .map(item => { const safeText = sanitizeTerminalText(item.text); - const styled = item.style ? DISPLAY_TEXT_STYLE_RENDERERS[item.style](safeText) : safeText; + const styled = item.style + ? DISPLAY_TEXT_STYLE_RENDERERS[item.style](safeText) + : safeText; if (!item.href) { return styled; } @@ -382,7 +409,7 @@ export function renderDisplayText(text: DisplayText): string { const visibleHref = sanitizeTerminalText(item.href); return safeText === visibleHref ? styled - : `${styled} ${applyTerminalStyle("subtle", `(${visibleHref})`)}`; + : `${styled} ${applyTerminalStyle('subtle', `(${visibleHref})`)}`; }) - .join(""); + .join(''); } diff --git a/cli/src/ui/terminal/table.test.ts b/cli/src/ui/terminal/table.test.ts index ba08de35..18480d67 100644 --- a/cli/src/ui/terminal/table.test.ts +++ b/cli/src/ui/terminal/table.test.ts @@ -1,8 +1,11 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { renderFixedTable } from "@/ui/terminal/table.ts"; -import { formatApiRoutes } from "@/commands/api/lib/render.ts"; -import { setTerminalColorMode, getVisibleTextWidth } from "@/ui/terminal/styles.ts"; -import { span } from "@/ui/document.ts"; +import { afterEach, describe, expect, test } from 'bun:test'; +import { renderFixedTable } from '@/ui/terminal/table.ts'; +import { formatApiRoutes } from '@/commands/api/lib/render.ts'; +import { + setTerminalColorMode, + getVisibleTextWidth, +} from '@/ui/terminal/styles.ts'; +import { span } from '@/ui/document.ts'; const originalAltertableColor = process.env.ALTERTABLE_COLOR; const originalOscHyperlink = process.env.OSC_HYPERLINK; @@ -20,235 +23,249 @@ afterEach(() => { } else { process.env.OSC_HYPERLINK = originalOscHyperlink; } - Object.defineProperty(process.stdout, "isTTY", { + Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true, }); }); -describe("renderFixedTable", () => { - test("computes column widths from header and cell values", () => { +describe('renderFixedTable', () => { + test('computes column widths from header and cell values', () => { const output = renderFixedTable( - [{ name: "Alice", role: "admin" }], + [{ name: 'Alice', role: 'admin' }], [ - { header: "NAME", cell: (row) => row.name }, - { header: "ROLE", cell: (row) => row.role }, - ], + { header: 'NAME', cell: row => row.name }, + { header: 'ROLE', cell: row => row.role }, + ] ); - expect(output).toBe("NAME ROLE \nAlice admin"); + expect(output).toBe('NAME ROLE \nAlice admin'); }); - test("truncates cells when maxWidth is set", () => { + test('truncates cells when maxWidth is set', () => { const output = renderFixedTable( - [{ label: "abcdefghijklmnopqrstuvwxyz" }], - [{ header: "LABEL", cell: (row) => row.label, maxWidth: 10 }], + [{ label: 'abcdefghijklmnopqrstuvwxyz' }], + [{ header: 'LABEL', cell: row => row.label, maxWidth: 10 }] ); - expect(output).toContain("abcdefghi…"); - expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz"); + expect(output).toContain('abcdefghi…'); + expect(output).not.toContain('abcdefghijklmnopqrstuvwxyz'); }); - test("sizes and truncates semantic spans by visible text", () => { - setTerminalColorMode("always"); + test('sizes and truncates semantic spans by visible text', () => { + setTerminalColorMode('always'); const output = renderFixedTable( - [{ label: "abcdefghijklmnopqrstuvwxyz" }], - [{ header: "LABEL", cell: (row) => [span(row.label, "accent")], maxWidth: 10 }], + [{ label: 'abcdefghijklmnopqrstuvwxyz' }], + [ + { + header: 'LABEL', + cell: row => [span(row.label, 'accent')], + maxWidth: 10, + }, + ] ); - expect(output).toContain("\u001b[96mabcdefghi…"); - expect(getVisibleTextWidth(output.split("\n")[1] ?? "")).toBe(10); + expect(output).toContain('\u001b[96mabcdefghi…'); + expect(getVisibleTextWidth(output.split('\n')[1] ?? '')).toBe(10); }); - test("measures the rendered fallback for labeled links", () => { - setTerminalColorMode("never"); - process.env.OSC_HYPERLINK = "0"; + test('measures the rendered fallback for labeled links', () => { + setTerminalColorMode('never'); + process.env.OSC_HYPERLINK = '0'; const output = renderFixedTable( - [{ label: "Docs", url: "https://example.com" }], - [{ header: "LINK", cell: (row) => [span(row.label, "accent", row.url)] }], + [{ label: 'Docs', url: 'https://example.com' }], + [{ header: 'LINK', cell: row => [span(row.label, 'accent', row.url)] }] ); - expect(output.split("\n")[1]).toBe("Docs (https://example.com)"); + expect(output.split('\n')[1]).toBe('Docs (https://example.com)'); }); - test("returns empty message for no rows", () => { - expect(renderFixedTable([], [{ header: "ID", cell: () => "" }], "No rows.")).toBe("No rows."); + test('returns empty message for no rows', () => { + expect( + renderFixedTable([], [{ header: 'ID', cell: () => '' }], 'No rows.') + ).toBe('No rows.'); }); - test("inserts a blank line between grouped rows", () => { + test('inserts a blank line between grouped rows', () => { const output = renderFixedTable( [ - { group: "a", name: "first" }, - { group: "a", name: "second" }, - { group: "b", name: "third" }, + { group: 'a', name: 'first' }, + { group: 'a', name: 'second' }, + { group: 'b', name: 'third' }, ], - [{ header: "NAME", cell: (row) => row.name }], - "No rows.", - { groupBy: (row) => row.group }, + [{ header: 'NAME', cell: row => row.name }], + 'No rows.', + { groupBy: row => row.group } ); - const lines = output.split("\n"); - expect(lines).toEqual(["NAME ", "first ", "second", "", "third "]); + const lines = output.split('\n'); + expect(lines).toEqual(['NAME ', 'first ', 'second', '', 'third ']); }); - test("shrinks flex columns when the table exceeds terminal width", () => { + test('shrinks flex columns when the table exceeds terminal width', () => { const output = renderFixedTable( - [{ label: "abcdefghijklmnopqrstuvwxyz", code: "1234" }], + [{ label: 'abcdefghijklmnopqrstuvwxyz', code: '1234' }], [ - { header: "LABEL", cell: (row) => row.label, flex: true }, - { header: "CODE", cell: (row) => row.code }, + { header: 'LABEL', cell: row => row.label, flex: true }, + { header: 'CODE', cell: row => row.code }, ], - "No rows.", - { terminalWidth: 20 }, + 'No rows.', + { terminalWidth: 20 } ); - expect(output).toContain("…"); - expect(output).toContain("1234"); + expect(output).toContain('…'); + expect(output).toContain('1234'); }); - test("keeps natural widths when horizontal scrolling is enabled", () => { + test('keeps natural widths when horizontal scrolling is enabled', () => { const output = renderFixedTable( - [{ label: "abcdefghijklmnopqrstuvwxyz", code: "1234" }], + [{ label: 'abcdefghijklmnopqrstuvwxyz', code: '1234' }], [ - { header: "LABEL", cell: (row) => row.label, flex: true }, - { header: "CODE", cell: (row) => row.code }, + { header: 'LABEL', cell: row => row.label, flex: true }, + { header: 'CODE', cell: row => row.code }, ], - "No rows.", - { terminalWidth: 20, horizontalScroll: true }, + 'No rows.', + { terminalWidth: 20, horizontalScroll: true } ); - expect(output).toContain("abcdefghijklmnopqrstuvwxyz"); - expect(output).not.toContain("…"); + expect(output).toContain('abcdefghijklmnopqrstuvwxyz'); + expect(output).not.toContain('…'); }); - test("sizes columns using visible width for wide characters", () => { + test('sizes columns using visible width for wide characters', () => { const output = renderFixedTable( - [{ label: "日本語", code: "x" }], + [{ label: '日本語', code: 'x' }], [ - { header: "L", cell: (row) => row.label, flex: true }, - { header: "C", cell: (row) => row.code }, + { header: 'L', cell: row => row.label, flex: true }, + { header: 'C', cell: row => row.code }, ], - "No rows.", - { terminalWidth: 7 }, + 'No rows.', + { terminalWidth: 7 } ); - for (const line of output.split("\n")) { + for (const line of output.split('\n')) { expect(getVisibleTextWidth(line)).toBeLessThanOrEqual(7); } }); }); -describe("formatApiRoutes", () => { - test("renders method, path, operation, and summary columns", () => { +describe('formatApiRoutes', () => { + test('renders method, path, operation, and summary columns', () => { const output = formatApiRoutes([ { - method: "POST", - path: "/environments", - operationId: "createEnvironment", - summary: "Create an environment", + method: 'POST', + path: '/environments', + operationId: 'createEnvironment', + summary: 'Create an environment', }, ]); - expect(output).toContain("METHOD"); - expect(output).toContain("PATH"); - expect(output).toContain("OPERATION"); - expect(output).toContain("SUMMARY"); - expect(output).toContain("POST"); - expect(output).toContain("/environments"); - expect(output).toContain("createEnvironment"); - expect(output).toContain("Create an environment"); + expect(output).toContain('METHOD'); + expect(output).toContain('PATH'); + expect(output).toContain('OPERATION'); + expect(output).toContain('SUMMARY'); + expect(output).toContain('POST'); + expect(output).toContain('/environments'); + expect(output).toContain('createEnvironment'); + expect(output).toContain('Create an environment'); }); - test("colorizes HTTP methods when terminal color is enabled", () => { - process.env.ALTERTABLE_COLOR = "always"; - setTerminalColorMode("always"); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + test('colorizes HTTP methods when terminal color is enabled', () => { + process.env.ALTERTABLE_COLOR = 'always'; + setTerminalColorMode('always'); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); const output = formatApiRoutes([ { - method: "DELETE", - path: "/items", - operationId: "deleteItem", - summary: "Delete an item", + method: 'DELETE', + path: '/items', + operationId: 'deleteItem', + summary: 'Delete an item', }, ]); - expect(output).toContain("\u001b[31mDELETE\u001b[39m"); + expect(output).toContain('\u001b[31mDELETE\u001b[39m'); }); - test("keeps long routes on one horizontally scrollable row", () => { + test('keeps long routes on one horizontally scrollable row', () => { const output = formatApiRoutes( [ { - method: "POST", - path: "/service_accounts/{service_account_id}/environments/{environment_id}/credentials", - operationId: "createServiceAccountCredential", - summary: "Create a credential for a service account (returns the password once)", + method: 'POST', + path: '/service_accounts/{service_account_id}/environments/{environment_id}/credentials', + operationId: 'createServiceAccountCredential', + summary: + 'Create a credential for a service account (returns the password once)', }, ], - "No operations found.", - { terminalWidth: 80 }, + 'No operations found.', + { terminalWidth: 80 } ); - const lines = output.split("\n"); + const lines = output.split('\n'); expect(lines).toHaveLength(2); expect(lines[1]).toContain( - "/service_accounts/{service_account_id}/environments/{environment_id}/credentials", + '/service_accounts/{service_account_id}/environments/{environment_id}/credentials' ); - expect(lines[1]).toContain("createServiceAccountCredential"); - expect(lines[1]).toContain("Create a credential for a service account"); + expect(lines[1]).toContain('createServiceAccountCredential'); + expect(lines[1]).toContain('Create a credential for a service account'); }); - test("does not truncate long paths on very narrow terminals", () => { + test('does not truncate long paths on very narrow terminals', () => { const output = formatApiRoutes( [ { - method: "DELETE", - path: "/service_accounts/{service_account_id}/environments/{environment_id}/credentials/{id}", - operationId: "revokeServiceAccountCredential", + method: 'DELETE', + path: '/service_accounts/{service_account_id}/environments/{environment_id}/credentials/{id}', + operationId: 'revokeServiceAccountCredential', summary: "Revoke a service account's credential", }, ], - "No operations found.", - { terminalWidth: 40 }, + 'No operations found.', + { terminalWidth: 40 } ); - expect(output).not.toContain("…"); + expect(output).not.toContain('…'); expect(output).toContain( - "/service_accounts/{service_account_id}/environments/{environment_id}/credentials/{id}", + '/service_accounts/{service_account_id}/environments/{environment_id}/credentials/{id}' ); - expect(output).toContain("revokeServiceAccountCredential"); + expect(output).toContain('revokeServiceAccountCredential'); }); - test("inserts a blank line between routes with different path roots", () => { + test('inserts a blank line between routes with different path roots', () => { const output = formatApiRoutes([ { - method: "GET", - path: "/environments/{id}", - operationId: "getEnvironment", - summary: "Get an environment by ID or slug", + method: 'GET', + path: '/environments/{id}', + operationId: 'getEnvironment', + summary: 'Get an environment by ID or slug', }, { - method: "POST", - path: "/service_accounts", - operationId: "createServiceAccount", - summary: "Create a service account", + method: 'POST', + path: '/service_accounts', + operationId: 'createServiceAccount', + summary: 'Create a service account', }, ]); - const lines = output.split("\n"); - const environmentSummaryIndex = lines.findIndex((line) => - line.includes("Get an environment by ID or slug"), + const lines = output.split('\n'); + const environmentSummaryIndex = lines.findIndex(line => + line.includes('Get an environment by ID or slug') + ); + const serviceAccountMethodIndex = lines.findIndex(line => + line.includes('/service_accounts') ); - const serviceAccountMethodIndex = lines.findIndex((line) => line.includes("/service_accounts")); expect(environmentSummaryIndex).toBeGreaterThanOrEqual(0); expect(serviceAccountMethodIndex).toBeGreaterThan(environmentSummaryIndex); - expect(lines[serviceAccountMethodIndex - 1]).toBe(""); + expect(lines[serviceAccountMethodIndex - 1]).toBe(''); }); - test("wraps route list without a section title", () => { + test('wraps route list without a section title', () => { const output = formatApiRoutes([ { - method: "GET", - path: "/whoami", - operationId: "whoami", - summary: "Identify the authenticated principal", + method: 'GET', + path: '/whoami', + operationId: 'whoami', + summary: 'Identify the authenticated principal', }, ]); - expect(output).not.toContain("API ROUTES"); - expect(output).toContain("METHOD"); - expect(output).toContain("/whoami"); + expect(output).not.toContain('API ROUTES'); + expect(output).toContain('METHOD'); + expect(output).toContain('/whoami'); }); }); diff --git a/cli/src/ui/terminal/table.ts b/cli/src/ui/terminal/table.ts index 5767b286..51a68d7a 100644 --- a/cli/src/ui/terminal/table.ts +++ b/cli/src/ui/terminal/table.ts @@ -4,10 +4,14 @@ import { padVisibleText, renderDisplayText, truncateTerminalText, -} from "@/ui/terminal/styles.ts"; -import { span, type DisplayTableColumn, type DisplayTableOptions } from "@/ui/document.ts"; +} from '@/ui/terminal/styles.ts'; +import { + span, + type DisplayTableColumn, + type DisplayTableOptions, +} from '@/ui/document.ts'; -const COLUMN_GAP = " "; +const COLUMN_GAP = ' '; const FLEX_SHRINK_MIN_WIDTH = 4; export type TableColumn = DisplayTableColumn; @@ -18,38 +22,39 @@ function truncateCell(text: string, maxWidth?: number): string { } function applyHeaderStyle(text: string): string { - return renderDisplayText([span(text, "subtle")]); + return renderDisplayText([span(text, 'subtle')]); } function tablePlainWidth(columnWidths: number[], columnCount: number): number { return ( - columnWidths.reduce((sum, width) => sum + width, 0) + (columnCount - 1) * COLUMN_GAP.length + columnWidths.reduce((sum, width) => sum + width, 0) + + (columnCount - 1) * COLUMN_GAP.length ); } function computeNaturalColumnWidths( columns: TableColumn[], renderedHeaders: string[], - renderedCells: string[][], + renderedCells: string[][] ): number[] { return columns.map((_column, columnIndex) => { const values = [ - renderedHeaders[columnIndex] ?? "", - ...renderedCells.map((cells) => cells[columnIndex] ?? ""), + renderedHeaders[columnIndex] ?? '', + ...renderedCells.map(cells => cells[columnIndex] ?? ''), ]; - return Math.max(...values.map((value) => getVisibleTextWidth(value)), 1); + return Math.max(...values.map(value => getVisibleTextWidth(value)), 1); }); } function shrinkFlexColumnsToFit( columns: TableColumn[], widths: number[], - terminalWidth: number, + terminalWidth: number ): number[] { const nextWidths = [...widths]; const flexColumnIndexes = columns .map((column, columnIndex) => (column.flex ? columnIndex : -1)) - .filter((columnIndex) => columnIndex >= 0); + .filter(columnIndex => columnIndex >= 0); while (tablePlainWidth(nextWidths, columns.length) > terminalWidth) { let shrank = false; @@ -74,9 +79,13 @@ function computeColumnWidths( columns: TableColumn[], renderedHeaders: string[], renderedCells: string[][], - options: FixedTableRenderOptions, + options: FixedTableRenderOptions ): number[] { - const naturalWidths = computeNaturalColumnWidths(columns, renderedHeaders, renderedCells); + const naturalWidths = computeNaturalColumnWidths( + columns, + renderedHeaders, + renderedCells + ); const cappedWidths = naturalWidths.map((width, columnIndex) => { const maxWidth = columns[columnIndex]?.maxWidth; return maxWidth === undefined ? width : Math.min(width, maxWidth); @@ -94,18 +103,25 @@ function computeColumnWidths( export function renderFixedTable( rows: T[], columns: TableColumn[], - emptyMessage = "No rows.", - options: FixedTableRenderOptions = {}, + emptyMessage = 'No rows.', + options: FixedTableRenderOptions = {} ): string { if (rows.length === 0) { - return renderDisplayText([span(emptyMessage, "subtle")]); + return renderDisplayText([span(emptyMessage, 'subtle')]); } - const renderedHeaders = columns.map((column) => renderDisplayText(column.header.toUpperCase())); - const renderedCells = rows.map((row) => - columns.map((column) => renderDisplayText(column.cell(row))), + const renderedHeaders = columns.map(column => + renderDisplayText(column.header.toUpperCase()) + ); + const renderedCells = rows.map(row => + columns.map(column => renderDisplayText(column.cell(row))) + ); + const columnWidths = computeColumnWidths( + columns, + renderedHeaders, + renderedCells, + options ); - const columnWidths = computeColumnWidths(columns, renderedHeaders, renderedCells, options); function formatRow(rowCells: readonly string[], rowIndex: number): string { return rowCells @@ -132,12 +148,15 @@ export function renderFixedTable( } if (rowIndex > 0 && options.groupBy) { const previousRow = rows[rowIndex - 1]; - if (previousRow !== undefined && options.groupBy(previousRow) !== options.groupBy(row)) { - bodyLines.push(""); + if ( + previousRow !== undefined && + options.groupBy(previousRow) !== options.groupBy(row) + ) { + bodyLines.push(''); } } bodyLines.push(formatRow(rowCells, rowIndex)); } - return [header, ...bodyLines].join("\n"); + return [header, ...bodyLines].join('\n'); } diff --git a/cli/src/version.ts b/cli/src/version.ts index cb53954e..dfa6f0ed 100644 --- a/cli/src/version.ts +++ b/cli/src/version.ts @@ -1,2 +1,2 @@ -export const VERSION = "1.12.0"; // x-release-please-version +export const VERSION = '1.12.0'; // x-release-please-version export const USER_AGENT = `altertable-cli/${VERSION}`; diff --git a/data-app/starter/.oxfmtrc.json b/data-app/starter/.oxfmtrc.json index e9c09693..94ca9973 100644 --- a/data-app/starter/.oxfmtrc.json +++ b/data-app/starter/.oxfmtrc.json @@ -1,4 +1,9 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["dist/**", "bun.lock"] + "ignorePatterns": ["dist/**", "bun.lock"], + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid", + "printWidth": 80, + "endOfLine": "lf" } diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index 047bc1a7..b6fc8b43 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -15,8 +15,8 @@ "@types/bun": "1.4.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "oxfmt": "0.70.0", - "oxlint": "1.85.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2", }, @@ -47,43 +47,43 @@ "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA=="], "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], @@ -97,43 +97,43 @@ "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], "@react-types/shared": ["@react-types/shared@3.36.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], @@ -269,9 +269,9 @@ "lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="], - "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], + "oxfmt": ["oxfmt@0.71.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.71.0", "@oxfmt/binding-android-arm64": "0.71.0", "@oxfmt/binding-darwin-arm64": "0.71.0", "@oxfmt/binding-darwin-x64": "0.71.0", "@oxfmt/binding-freebsd-x64": "0.71.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.71.0", "@oxfmt/binding-linux-arm-musleabihf": "0.71.0", "@oxfmt/binding-linux-arm64-gnu": "0.71.0", "@oxfmt/binding-linux-arm64-musl": "0.71.0", "@oxfmt/binding-linux-ppc64-gnu": "0.71.0", "@oxfmt/binding-linux-riscv64-gnu": "0.71.0", "@oxfmt/binding-linux-riscv64-musl": "0.71.0", "@oxfmt/binding-linux-s390x-gnu": "0.71.0", "@oxfmt/binding-linux-x64-gnu": "0.71.0", "@oxfmt/binding-linux-x64-musl": "0.71.0", "@oxfmt/binding-openharmony-arm64": "0.71.0", "@oxfmt/binding-win32-arm64-msvc": "0.71.0", "@oxfmt/binding-win32-ia32-msvc": "0.71.0", "@oxfmt/binding-win32-x64-msvc": "0.71.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg=="], - "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], + "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], @@ -305,7 +305,7 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], diff --git a/data-app/starter/package.json b/data-app/starter/package.json index ac9b6f6c..feb60682 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -25,8 +25,8 @@ "@types/bun": "1.4.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "oxfmt": "0.70.0", - "oxlint": "1.85.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2" } diff --git a/data-app/starter/src/App.tsx b/data-app/starter/src/App.tsx index df01f1f7..7e8c8271 100644 --- a/data-app/starter/src/App.tsx +++ b/data-app/starter/src/App.tsx @@ -1,5 +1,5 @@ -import { GettingStarted } from "@altertable/data-app-react/ui"; -import { dataContext } from "#app/data-context.ts"; +import { GettingStarted } from '@altertable/data-app-react/ui'; +import { dataContext } from '#app/data-context.ts'; /** Connectivity-only screen. Investigate the requested question, then replace this with a view * that leads with a supported finding and explores the useful angles behind it. */ diff --git a/data-app/starter/src/assets.d.ts b/data-app/starter/src/assets.d.ts index cbe652db..35306c6f 100644 --- a/data-app/starter/src/assets.d.ts +++ b/data-app/starter/src/assets.d.ts @@ -1 +1 @@ -declare module "*.css"; +declare module '*.css'; diff --git a/data-app/starter/src/config.ts b/data-app/starter/src/config.ts index fa2a7219..5452cbaa 100644 --- a/data-app/starter/src/config.ts +++ b/data-app/starter/src/config.ts @@ -1,17 +1,18 @@ -import { defineDataApp } from "@altertable/data-app"; -import { parseAppearance } from "@altertable/data-app/appearance"; -import app from "#config"; +import { defineDataApp } from '@altertable/data-app'; +import { parseAppearance } from '@altertable/data-app/appearance'; +import app from '#config'; export default defineDataApp({ title: app.title, - description: "Verify the configured lakehouse connection before authoring an exploration.", + description: + 'Verify the configured lakehouse connection before authoring an exploration.', scope: app.scope, appearance: parseAppearance(app.appearance), queries: { connection: { - statement: "SELECT 1 AS connection_check", + statement: 'SELECT 1 AS connection_check', params: {}, - projections: { connection_check: { type: "number" } }, + projections: { connection_check: { type: 'number' } }, }, }, }); diff --git a/data-app/starter/src/data-context.ts b/data-app/starter/src/data-context.ts index 642c0ffe..7596d909 100644 --- a/data-app/starter/src/data-context.ts +++ b/data-app/starter/src/data-context.ts @@ -1,20 +1,20 @@ -import { createDataContext } from "@altertable/data-app-react"; -import { defineQueryNames } from "@altertable/data-app/contract"; -import type app from "#app/config.ts"; +import { createDataContext } from '@altertable/data-app-react'; +import { defineQueryNames } from '@altertable/data-app/contract'; +import type app from '#app/config.ts'; /** Setup context only. Replace it with the exploration's scope, exact definitions, limitations, * and query evidence after inspecting the source data. */ const queryNames = defineQueryNames({ - connection: "connection" satisfies keyof typeof app.queries, + connection: 'connection' satisfies keyof typeof app.queries, }); export const dataContext = createDataContext(queryNames)({ description: - "This starter runs a lightweight query to verify that the selected lakehouse profile can execute SQL. It does not check access to individual datasets. Replace the check with a query that answers a real question before sharing the app.", + 'This starter runs a lightweight query to verify that the selected lakehouse profile can execute SQL. It does not check access to individual datasets. Replace the check with a query that answers a real question before sharing the app.', glossary: { connectionVerified: { - term: "Connected", - definition: "The latest connection-check query completed successfully.", + term: 'Connected', + definition: 'The latest connection-check query completed successfully.', queryNames: [queryNames.connection], }, }, diff --git a/data-app/starter/src/main.tsx b/data-app/starter/src/main.tsx index 0acbba6b..ff1f0be7 100644 --- a/data-app/starter/src/main.tsx +++ b/data-app/starter/src/main.tsx @@ -1,6 +1,6 @@ -import { injectDataAppStyles, mountDataApp } from "@altertable/data-app-react"; -import { App } from "#app/App.tsx"; -import app from "#app/config.ts"; +import { injectDataAppStyles, mountDataApp } from '@altertable/data-app-react'; +import { App } from '#app/App.tsx'; +import app from '#app/config.ts'; injectDataAppStyles(); mountDataApp({ app, component: App }); diff --git a/data-app/starter/src/operations.ts b/data-app/starter/src/operations.ts index ad72a8e8..7c33fcce 100644 --- a/data-app/starter/src/operations.ts +++ b/data-app/starter/src/operations.ts @@ -1,5 +1,5 @@ -import { connectionCheck } from "@altertable/data-app/contract"; -import app from "#app/config.ts"; +import { connectionCheck } from '@altertable/data-app/contract'; +import app from '#app/config.ts'; /** Connectivity probe only: its successful query supplies no analytical result. Replace it with * bounded, validated operations that cover the questions the finished app will answer. */ diff --git a/data-app/starter/src/preview.ts b/data-app/starter/src/preview.ts index 108cd246..bb8b8458 100644 --- a/data-app/starter/src/preview.ts +++ b/data-app/starter/src/preview.ts @@ -1,9 +1,9 @@ -import { resolve } from "node:path"; -import { localLakehouse } from "@altertable/data-app-bun"; -import { serveAppFile } from "@altertable/data-app-bun/preview"; +import { resolve } from 'node:path'; +import { localLakehouse } from '@altertable/data-app-bun'; +import { serveAppFile } from '@altertable/data-app-bun/preview'; const preview = await serveAppFile({ - file: resolve(import.meta.dir, "../index.tsx"), + file: resolve(import.meta.dir, '../index.tsx'), port: Number(process.env.PORT ?? 25837), lakehouse: localLakehouse(), }); @@ -20,6 +20,6 @@ async function shutdown() { process.exit(1); } } -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); console.log(`Data app running at ${preview.url}`); diff --git a/data-app/starter/src/server.ts b/data-app/starter/src/server.ts index b7e60b80..3e919413 100644 --- a/data-app/starter/src/server.ts +++ b/data-app/starter/src/server.ts @@ -1,6 +1,6 @@ -import page from "#app/index.html"; -import { serveLocalApp } from "@altertable/data-app-bun"; -import { operations } from "#app/operations.ts"; -import app from "#app/config.ts"; +import page from '#app/index.html'; +import { serveLocalApp } from '@altertable/data-app-bun'; +import { operations } from '#app/operations.ts'; +import app from '#app/config.ts'; serveLocalApp({ page, operations, title: app.title }); diff --git a/data-app/tests/calendar.spec.ts b/data-app/tests/calendar.spec.ts index 1a6099ff..079803a3 100644 --- a/data-app/tests/calendar.spec.ts +++ b/data-app/tests/calendar.spec.ts @@ -1,20 +1,24 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test'; -test("the small-screen calendar fills its panel and keeps the selected range legible", async ({ +test('the small-screen calendar fills its panel and keeps the selected range legible', async ({ page, }) => { for (const width of [320, 390, 550]) { await page.setViewportSize({ width, height: 800 }); - await page.goto("/components"); - await page.getByRole("button", { name: "Choose dates" }).click(); - const popover = page.locator(".altertable-date-range-popover"); - await expect(popover.getByRole("group", { name: "Quick ranges" })).toBeVisible(); - const grid = popover.locator(".react-aria-CalendarGrid"); + await page.goto('/components'); + await page.getByRole('button', { name: 'Choose dates' }).click(); + const popover = page.locator('.altertable-date-range-popover'); + await expect( + popover.getByRole('group', { name: 'Quick ranges' }) + ).toBeVisible(); + const grid = popover.locator('.react-aria-CalendarGrid'); const sizes = await page.evaluate(() => { const popup = document - .querySelector(".altertable-date-range-popover")! + .querySelector('.altertable-date-range-popover')! + .getBoundingClientRect(); + const calendar = document + .querySelector('.react-aria-CalendarGrid')! .getBoundingClientRect(); - const calendar = document.querySelector(".react-aria-CalendarGrid")!.getBoundingClientRect(); return { popup: popup.width, calendar: calendar.width, @@ -24,18 +28,22 @@ test("the small-screen calendar fills its panel and keeps the selected range leg expect(sizes.calendar).toBeGreaterThanOrEqual(sizes.popup - 28); expect(sizes.overflow).toBe(false); await expect(grid).toBeVisible(); - await expect(popover.getByText("Selected dates")).toBeVisible(); - await expect(popover.getByText("Sep 10–12, 2026")).toBeVisible(); - await expect(popover.getByText("3 days · UTC")).toBeVisible(); + await expect(popover.getByText('Selected dates')).toBeVisible(); + await expect(popover.getByText('Sep 10–12, 2026')).toBeVisible(); + await expect(popover.getByText('3 days · UTC')).toBeVisible(); await expect( - popover.locator(".altertable-date-range-custom .altertable-date-range-compare"), - ).toContainText("Sep 7–9, 2026"); - await page.keyboard.press("Escape"); + popover.locator( + '.altertable-date-range-custom .altertable-date-range-compare' + ) + ).toContainText('Sep 7–9, 2026'); + await page.keyboard.press('Escape'); } await page.setViewportSize({ width: 320, height: 568 }); - await page.goto("/components"); - await page.getByRole("button", { name: "Choose dates" }).click(); - const compare = page.getByRole("checkbox", { name: /Compare with previous period/ }); + await page.goto('/components'); + await page.getByRole('button', { name: 'Choose dates' }).click(); + const compare = page.getByRole('checkbox', { + name: /Compare with previous period/, + }); await compare.scrollIntoViewIfNeeded(); await expect(compare).toBeVisible(); }); diff --git a/data-app/tests/harness.spec.ts b/data-app/tests/harness.spec.ts index 0efe8a77..8f87e753 100644 --- a/data-app/tests/harness.spec.ts +++ b/data-app/tests/harness.spec.ts @@ -1,111 +1,132 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test'; -test("view binding preserves displayed inputs through comparison, navigation, and failures", async ({ +test('view binding preserves displayed inputs through comparison, navigation, and failures', async ({ page, }) => { - let mode: "ready" | "empty" | "error" = "ready"; - const inputs: { range: { start: string; end: string }; comparison: unknown }[] = []; + let mode: 'ready' | 'empty' | 'error' = 'ready'; + const inputs: { + range: { start: string; end: string }; + comparison: unknown; + }[] = []; let release: (() => void) | undefined; let hold = true; - await page.route("**/api/data/activity", async (route) => { + await page.route('**/api/data/activity', async route => { inputs.push(route.request().postDataJSON()); if (hold) - await new Promise((resolve) => { + await new Promise(resolve => { release = resolve; }); - if (mode === "error") + if (mode === 'error') return route.fulfill({ status: 503, - json: { error: { code: "source_unavailable", message: "Unavailable" } }, + json: { error: { code: 'source_unavailable', message: 'Unavailable' } }, }); return route.fulfill({ json: { - data: { count: 120, features: mode === "empty" ? [] : ["Queries", "Insights"] }, - queriedAt: "2026-03-12T00:00:00Z", - requestId: "fixture", + data: { + count: 120, + features: mode === 'empty' ? [] : ['Queries', 'Insights'], + }, + queriedAt: '2026-03-12T00:00:00Z', + requestId: 'fixture', queryIds: [], }, }); }); - await page.goto("/view"); - await expect(page.getByText("Loading activity…")).toBeVisible(); - await expect(page.getByTestId("shared-grid")).toBeVisible(); + await page.goto('/view'); + await expect(page.getByText('Loading activity…')).toBeVisible(); + await expect(page.getByTestId('shared-grid')).toBeVisible(); expect(inputs[0]).toEqual({ - range: { start: "2026-03-10", end: "2026-03-12" }, + range: { start: '2026-03-10', end: '2026-03-12' }, comparison: null, }); hold = false; release?.(); - await expect(page.getByText("Results for Mar 10–12, 2026 UTC")).toBeVisible(); + await expect(page.getByText('Results for Mar 10–12, 2026 UTC')).toBeVisible(); await page - .getByRole("button", { name: "Explore Activity across product features and organizations" }) + .getByRole('button', { + name: 'Explore Activity across product features and organizations', + }) .click(); - await expect(page.getByRole("dialog")).toContainText("Recorded product actions."); - await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Choose dates" }).click(); - await page.getByRole("checkbox", { name: /Compare with previous period/ }).check(); + await expect(page.getByRole('dialog')).toContainText( + 'Recorded product actions.' + ); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Choose dates' }).click(); + await page + .getByRole('checkbox', { name: /Compare with previous period/ }) + .check(); await expect .poll(() => inputs.at(-1)?.comparison) - .toEqual({ start: "2026-03-07", end: "2026-03-09" }); - await page.keyboard.press("Escape"); + .toEqual({ start: '2026-03-07', end: '2026-03-09' }); + await page.keyboard.press('Escape'); await expect(page).toHaveURL(/compare=previous/); await page.goBack(); await expect(page).not.toHaveURL(/compare=previous/); // A different requested period fails while the visible result keeps its original label. - mode = "error"; + mode = 'error'; await page.evaluate(() => { - history.pushState(null, "", "?start=2026-03-13&end=2026-03-15"); - dispatchEvent(new PopStateEvent("popstate")); + history.pushState(null, '', '?start=2026-03-13&end=2026-03-15'); + dispatchEvent(new PopStateEvent('popstate')); }); - await expect(page.getByText("Results for Mar 10–12, 2026 UTC")).toBeVisible(); - await expect(page.getByRole("alert")).toContainText("Mar 10–12, 2026 UTC"); - mode = "empty"; - await page.getByRole("button", { name: "Try again" }).click(); - await expect(page.getByText("No activity in this range")).toBeVisible(); - await expect(page.getByTestId("shared-grid")).toHaveCount(0); + await expect(page.getByText('Results for Mar 10–12, 2026 UTC')).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('Mar 10–12, 2026 UTC'); + mode = 'empty'; + await page.getByRole('button', { name: 'Try again' }).click(); + await expect(page.getByText('No activity in this range')).toBeVisible(); + await expect(page.getByTestId('shared-grid')).toHaveCount(0); await page.reload(); - mode = "error"; + mode = 'error'; await page.reload(); - await expect(page.getByText("Couldn’t load results")).toBeVisible(); + await expect(page.getByText('Couldn’t load results')).toBeVisible(); }); -test("shared loading and ready layouts fit narrow containers in both themes", async ({ +test('shared loading and ready layouts fit narrow containers in both themes', async ({ page, }, testInfo) => { let release: (() => void) | undefined; - await page.route("**/api/data/activity", async (route) => { - await new Promise((resolve) => { + await page.route('**/api/data/activity', async route => { + await new Promise(resolve => { release = resolve; }); await route.fulfill({ json: { - data: { count: 120, features: ["Long feature names remain readable within their widget"] }, - queriedAt: "2026-03-12T00:00:00Z", - requestId: "fixture", + data: { + count: 120, + features: ['Long feature names remain readable within their widget'], + }, + queriedAt: '2026-03-12T00:00:00Z', + requestId: 'fixture', queryIds: [], }, }); }); - await page.goto("/view"); - await expect(page.getByText("Loading activity…")).toBeVisible(); + await page.goto('/view'); + await expect(page.getByText('Loading activity…')).toBeVisible(); for (const width of [320, 550, 900]) { await page.setViewportSize({ width, height: 800 }); - const grid = await page.getByTestId("shared-grid").boundingBox(); - const support = await page.getByTestId("shared-support").boundingBox(); - if (width < 640) expect(Math.abs(support!.width - grid!.width)).toBeLessThan(2); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( - true, - ); + const grid = await page.getByTestId('shared-grid').boundingBox(); + const support = await page.getByTestId('shared-support').boundingBox(); + if (width < 640) + expect(Math.abs(support!.width - grid!.width)).toBeLessThan(2); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); } release?.(); - await expect(page.getByText("Results for Mar 10–12, 2026 UTC")).toBeVisible(); - for (const theme of ["light", "dark"]) { - if (theme === "dark") await page.getByRole("button", { name: "Switch to dark theme" }).click(); + await expect(page.getByText('Results for Mar 10–12, 2026 UTC')).toBeVisible(); + for (const theme of ['light', 'dark']) { + if (theme === 'dark') + await page.getByRole('button', { name: 'Switch to dark theme' }).click(); for (const width of [320, 550, 900]) { await page.setViewportSize({ width, height: 800 }); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( - true, - ); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); await page.screenshot({ path: testInfo.outputPath(`view-${theme}-${width}.png`), fullPage: true, diff --git a/data-app/tests/package.json b/data-app/tests/package.json index d012dc63..64173c33 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -6,7 +6,8 @@ "test": "playwright test", "typecheck": "tsc --noEmit", "lint": "../starter/node_modules/.bin/oxlint --type-aware *.ts", - "format:check": "../starter/node_modules/.bin/oxfmt --check *.ts" + "format:check": "../starter/node_modules/.bin/oxfmt --config ../starter/.oxfmtrc.json --check *.ts", + "format": "../starter/node_modules/.bin/oxfmt --config ../starter/.oxfmtrc.json *.ts" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/data-app/tests/playwright.config.ts b/data-app/tests/playwright.config.ts index 82c9f90a..dfd7163a 100644 --- a/data-app/tests/playwright.config.ts +++ b/data-app/tests/playwright.config.ts @@ -1,16 +1,19 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test'; const port = Number(process.env.DATA_APP_TEST_PORT ?? 26418); export default defineConfig({ - testDir: ".", - testMatch: "*.spec.ts", + testDir: '.', + testMatch: '*.spec.ts', workers: 1, - use: { baseURL: `http://127.0.0.1:${port}`, trace: "retain-on-failure" }, + use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure' }, projects: [ - { name: "desktop", use: { ...devices["Desktop Chrome"] } }, - { name: "phone", use: { ...devices["iPhone 13"], defaultBrowserType: "chromium" } }, + { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, + { + name: 'phone', + use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' }, + }, ], webServer: { - command: "bun server.ts", + command: 'bun server.ts', url: `http://127.0.0.1:${port}`, reuseExistingServer: false, }, diff --git a/data-app/tests/server.ts b/data-app/tests/server.ts index d1ebdf9e..7706973e 100644 --- a/data-app/tests/server.ts +++ b/data-app/tests/server.ts @@ -1,43 +1,50 @@ -import starter from "../starter/src/index.html"; -import view from "../starter/fixtures/view.html"; -import components from "../starter/fixtures/index.html"; -import { operations as activityOperations } from "../starter/fixtures/activity.ts"; -import { createDataHandler } from "@altertable/data-app/server"; -import { DataSourceError } from "@altertable/data-app/contract"; -import { operations } from "../starter/src/operations.ts"; +import starter from '../starter/src/index.html'; +import view from '../starter/fixtures/view.html'; +import components from '../starter/fixtures/index.html'; +import { operations as activityOperations } from '../starter/fixtures/activity.ts'; +import { createDataHandler } from '@altertable/data-app/server'; +import { DataSourceError } from '@altertable/data-app/contract'; +import { operations } from '../starter/src/operations.ts'; let fail = false; -const handler = createDataHandler({ ...operations, ...activityOperations }, async () => ({ - canDiscloseSql: true, - lakehouse: { - async queryAll(statement, options) { - if (statement.startsWith("SELECT 120 AS count")) { - if (!options.params?.start || !options.params.end) - throw new Error("Missing activity params"); - if (fail) throw new DataSourceError("unavailable"); +const handler = createDataHandler( + { ...operations, ...activityOperations }, + async () => ({ + canDiscloseSql: true, + lakehouse: { + async queryAll(statement, options) { + if (statement.startsWith('SELECT 120 AS count')) { + if (!options.params?.start || !options.params.end) + throw new Error('Missing activity params'); + if (fail) throw new DataSourceError('unavailable'); + return { + columns: [{ name: 'count' }, { name: 'feature' }], + rows: [ + [120, 'Queries'], + [120, 'Insights'], + ], + }; + } + if (statement !== 'SELECT 1 AS connection_check' || options.limit !== 1) + throw new Error('Unexpected connection query'); + if (fail) throw new DataSourceError('unavailable'); return { - columns: [{ name: "count" }, { name: "feature" }], - rows: [ - [120, "Queries"], - [120, "Insights"], - ], + columns: [{ name: 'connection_check' }], + rows: [[1]], + queryId: 'test-query', }; - } - if (statement !== "SELECT 1 AS connection_check" || options.limit !== 1) - throw new Error("Unexpected connection query"); - if (fail) throw new DataSourceError("unavailable"); - return { columns: [{ name: "connection_check" }], rows: [[1]], queryId: "test-query" }; + }, }, - }, -})); + }) +); Bun.serve({ development: false, - hostname: "127.0.0.1", + hostname: '127.0.0.1', port: Number(process.env.DATA_APP_TEST_PORT ?? 26418), - routes: { "/": starter, "/components": components, "/view": view }, + routes: { '/': starter, '/components': components, '/view': view }, async fetch(request) { - if (new URL(request.url).pathname === "/__test/state") { - fail = (await request.text()) === "failure"; - return new Response("ok"); + if (new URL(request.url).pathname === '/__test/state') { + fail = (await request.text()) === 'failure'; + return new Response('ok'); } return handler(request); }, diff --git a/data-app/tests/starter.spec.ts b/data-app/tests/starter.spec.ts index 2bd2d1dd..100d8418 100644 --- a/data-app/tests/starter.spec.ts +++ b/data-app/tests/starter.spec.ts @@ -1,332 +1,437 @@ -import { expect, test } from "@playwright/test"; -import { readFile } from "node:fs/promises"; +import { expect, test } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; test.beforeEach(async ({ request }) => { - await request.post("/__test/state", { data: "success" }); + await request.post('/__test/state', { data: 'success' }); }); -test("exports the displayed dataset as CSV", async ({ page }) => { - await page.goto("/components"); - await expect(page.getByText("Connection view ready")).toBeVisible(); - const completed = page.waitForEvent("download"); - await page.getByRole("button", { name: "Export CSV", exact: true }).click(); +test('exports the displayed dataset as CSV', async ({ page }) => { + await page.goto('/components'); + await expect(page.getByText('Connection view ready')).toBeVisible(); + const completed = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export CSV', exact: true }).click(); const download = await completed; const path = await download.path(); expect(path).not.toBeNull(); - const csv = await readFile(path!, "utf8"); - expect(csv).toContain("Order"); - expect(csv).toContain("Order 1"); - expect(csv).toContain("Order 11"); + const csv = await readFile(path!, 'utf8'); + expect(csv).toContain('Order'); + expect(csv).toContain('Order 1'); + expect(csv).toContain('Order 11'); }); -test("connection succeeds only after a query, then offers next steps and a theme button", async ({ +test('connection succeeds only after a query, then offers next steps and a theme button', async ({ page, }, testInfo) => { let release!: () => void; - const held = new Promise((resolve) => { + const held = new Promise(resolve => { release = resolve; }); - await page.route("**/api/data/connection", async (route) => { + await page.route('**/api/data/connection', async route => { await held; await route.continue(); }); - await page.goto("/"); - await expect(page.getByRole("heading", { name: "Checking connection…" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Connected", exact: true })).toHaveCount(0); + await page.goto('/'); + await expect( + page.getByRole('heading', { name: 'Checking connection…' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Connected', exact: true }) + ).toHaveCount(0); release(); - await expect(page.getByRole("heading", { name: "Connected", exact: true })).toBeVisible(); - await expect(page.getByText("Choose a question", { exact: true })).toBeVisible(); - await expect(page.getByRole("table")).toHaveCount(0); - await page.screenshot({ path: testInfo.outputPath("starter-light.png"), fullPage: true }); - await page.getByRole("button", { name: "Switch to dark theme" }).click(); - await expect(page.getByRole("button", { name: "Switch to light theme" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("starter-dark.png"), fullPage: true }); + await expect( + page.getByRole('heading', { name: 'Connected', exact: true }) + ).toBeVisible(); + await expect( + page.getByText('Choose a question', { exact: true }) + ).toBeVisible(); + await expect(page.getByRole('table')).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath('starter-light.png'), + fullPage: true, + }); + await page.getByRole('button', { name: 'Switch to dark theme' }).click(); + await expect( + page.getByRole('button', { name: 'Switch to light theme' }) + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('starter-dark.png'), + fullPage: true, + }); await page.reload(); - await expect(page.getByRole("button", { name: "Switch to light theme" })).toBeVisible(); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( - true, - ); + await expect( + page.getByRole('button', { name: 'Switch to light theme' }) + ).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); }); -test("failed checks offer retry and invalidate an earlier successful connection", async ({ +test('failed checks offer retry and invalidate an earlier successful connection', async ({ page, request, }) => { - await request.post("/__test/state", { data: "failure" }); - await page.goto("/"); - await expect(page.getByRole("heading", { name: "Connection not verified" })).toBeVisible(); - await request.post("/__test/state", { data: "success" }); - await page.getByRole("button", { name: "Try again" }).click(); - await expect(page.getByRole("heading", { name: "Connected", exact: true })).toBeVisible(); - await request.post("/__test/state", { data: "failure" }); - await page.getByRole("button", { name: "Check connection", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Connection not verified" })).toBeVisible(); + await request.post('/__test/state', { data: 'failure' }); + await page.goto('/'); + await expect( + page.getByRole('heading', { name: 'Connection not verified' }) + ).toBeVisible(); + await request.post('/__test/state', { data: 'success' }); + await page.getByRole('button', { name: 'Try again' }).click(); + await expect( + page.getByRole('heading', { name: 'Connected', exact: true }) + ).toBeVisible(); + await request.post('/__test/state', { data: 'failure' }); + await page + .getByRole('button', { name: 'Check connection', exact: true }) + .click(); + await expect( + page.getByRole('heading', { name: 'Connection not verified' }) + ).toBeVisible(); await expect(page.getByText(/An earlier query succeeded/)).toBeVisible(); }); -test("About the data keeps exploration context and Glossary without Overview", async ({ page }) => { - await page.goto("/"); - await page.getByRole("button", { name: "Explore data" }).click(); - await expect(page.getByRole("tab", { name: "Glossary" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Overview" })).toHaveCount(0); +test('About the data keeps exploration context and Glossary without Overview', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Explore data' }).click(); + await expect(page.getByRole('tab', { name: 'Glossary' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Overview' })).toHaveCount(0); await expect(page.getByText(/runs a lightweight query/)).toBeVisible(); }); -test("source identifiers retain exact table and column identity in About the data", async ({ +test('source identifiers retain exact table and column identity in About the data', async ({ page, }) => { - await page.goto("/components"); - await page.getByRole("button", { name: "Explore data" }).click(); - const about = page.getByRole("dialog", { name: "About the data" }); - await expect(about.locator('[data-identifier-id="tables.orders"]')).toHaveText( - "commerce.sales.orders", - ); - await expect(about.locator('[data-identifier-id="columns.orders.customerId"]')).toHaveText( - "customer_id", - ); - await expect(about.locator('[data-identifier-id="columns.orders.customerId"]')).toHaveAttribute( - "title", - "commerce.sales.orders.customer_id", - ); - expect(await about.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe( - true, - ); + await page.goto('/components'); + await page.getByRole('button', { name: 'Explore data' }).click(); + const about = page.getByRole('dialog', { name: 'About the data' }); + await expect( + about.locator('[data-identifier-id="tables.orders"]') + ).toHaveText('commerce.sales.orders'); + await expect( + about.locator('[data-identifier-id="columns.orders.customerId"]') + ).toHaveText('customer_id'); + await expect( + about.locator('[data-identifier-id="columns.orders.customerId"]') + ).toHaveAttribute('title', 'commerce.sales.orders.customer_id'); + expect( + await about.evaluate( + element => element.scrollWidth <= element.clientWidth + 1 + ) + ).toBe(true); }); -test("glossary definitions open in place on hover and keyboard activation", async ({ page }) => { - await page.goto("/components"); - const term = page.getByRole("button", { name: "Explain Orders" }); - expect(await term.evaluate((element) => getComputedStyle(element).cursor)).toBe("help"); +test('glossary definitions open in place on hover and keyboard activation', async ({ + page, +}) => { + await page.goto('/components'); + const term = page.getByRole('button', { name: 'Explain Orders' }); + expect(await term.evaluate(element => getComputedStyle(element).cursor)).toBe( + 'help' + ); const restingUnderline = await term.evaluate( - (element) => getComputedStyle(element).textDecorationColor, + element => getComputedStyle(element).textDecorationColor ); await term.hover(); - expect(await term.evaluate((element) => getComputedStyle(element).textDecorationColor)).not.toBe( - restingUnderline, + expect( + await term.evaluate( + element => getComputedStyle(element).textDecorationColor + ) + ).not.toBe(restingUnderline); + const definition = page.getByRole('dialog', { name: 'Orders' }); + await expect(definition).toContainText( + 'Completed orders grouped by customer_id.' ); - const definition = page.getByRole("dialog", { name: "Orders" }); - await expect(definition).toContainText("Completed orders grouped by customer_id."); await page.mouse.move(0, 0); await expect(definition).toHaveCount(0); await term.focus(); - await term.press("Enter"); + await term.press('Enter'); await expect(definition).toBeVisible(); - await page.keyboard.press("Escape"); + await page.keyboard.press('Escape'); await expect(definition).toHaveCount(0); }); -test("date comparison offers the preceding range only when available", async ({ page }) => { - await page.goto("/components"); - await page.getByRole("button", { name: "Choose dates" }).click(); - const compare = page.getByRole("checkbox", { name: /Compare with previous period/ }); +test('date comparison offers the preceding range only when available', async ({ + page, +}) => { + await page.goto('/components'); + await page.getByRole('button', { name: 'Choose dates' }).click(); + const compare = page.getByRole('checkbox', { + name: /Compare with previous period/, + }); await expect(compare).toBeEnabled(); - await expect(page.getByText("Sep 7–9, 2026")).toBeVisible(); + await expect(page.getByText('Sep 7–9, 2026')).toBeVisible(); await compare.check(); await expect( - page.getByRole("group", { name: "View controls" }).locator(".altertable-date-range-comparison"), - ).toHaveText("vs prior"); + page + .getByRole('group', { name: 'View controls' }) + .locator('.altertable-date-range-comparison') + ).toHaveText('vs prior'); await expect(compare).toBeChecked(); }); -test("Present mode retains navigation, deep links, inspection, and theme switching", async ({ +test('Present mode retains navigation, deep links, inspection, and theme switching', async ({ page, }) => { - await page.goto("/components"); - await page.getByRole("button", { name: "Present story", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Orders increased" })).toBeVisible(); + await page.goto('/components'); + await page + .getByRole('button', { name: 'Present story', exact: true }) + .click(); + await expect( + page.getByRole('heading', { name: 'Orders increased' }) + ).toBeVisible(); await expect(page).toHaveURL(/present=1.*step=orders/); - await page.keyboard.press("ArrowRight"); - await expect(page.getByRole("heading", { name: "More returning customers" })).toBeVisible(); + await page.keyboard.press('ArrowRight'); + await expect( + page.getByRole('heading', { name: 'More returning customers' }) + ).toBeVisible(); await page.reload(); - await expect(page.getByRole("heading", { name: "More returning customers" })).toBeVisible(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: "Switch to dark theme" }).click(); - await expect(dialog.getByRole("button", { name: "Switch to light theme" })).toBeVisible(); - await dialog.getByRole("button", { name: "Explore sources", exact: true }).click(); - await expect(page.getByRole("tab", { name: "Glossary" })).toBeVisible(); - await page.getByRole("tab", { name: "Glossary" }).click(); - await expect(page.getByRole("tabpanel", { name: "Glossary" })).toContainText( - "Completed orders grouped by customer_id.", + await expect( + page.getByRole('heading', { name: 'More returning customers' }) + ).toBeVisible(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Switch to dark theme' }).click(); + await expect( + dialog.getByRole('button', { name: 'Switch to light theme' }) + ).toBeVisible(); + await dialog + .getByRole('button', { name: 'Explore sources', exact: true }) + .click(); + await expect(page.getByRole('tab', { name: 'Glossary' })).toBeVisible(); + await page.getByRole('tab', { name: 'Glossary' }).click(); + await expect(page.getByRole('tabpanel', { name: 'Glossary' })).toContainText( + 'Completed orders grouped by customer_id.' ); - await page.getByRole("tab", { name: "Queries" }).click(); - await expect(page.getByText("SELECT 1 AS connection_check")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("button", { name: "Close panel" })).toHaveCount(0); - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole('tab', { name: 'Queries' }).click(); + await expect(page.getByText('SELECT 1 AS connection_check')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'Close panel' })).toHaveCount( + 0 + ); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); }); -test("data view keeps the last result visible when refresh fails", async ({ +test('data view keeps the last result visible when refresh fails', async ({ page, request, }, testInfo) => { - await request.post("/__test/state", { data: "failure" }); - await page.goto("/components"); - const error = page.getByRole("alert"); - await expect(error).toContainText("Couldn’t load results"); - await expect(error).toContainText("The lakehouse isn’t responding."); - await expect(error.getByRole("button", { name: "Retry" })).toBeVisible(); + await request.post('/__test/state', { data: 'failure' }); + await page.goto('/components'); + const error = page.getByRole('alert'); + await expect(error).toContainText('Couldn’t load results'); + await expect(error).toContainText('The lakehouse isn’t responding.'); + await expect(error.getByRole('button', { name: 'Retry' })).toBeVisible(); expect( - await error.evaluate((element) => element.getBoundingClientRect().width), + await error.evaluate(element => element.getBoundingClientRect().width) ).toBeLessThanOrEqual(520); - await page.screenshot({ path: testInfo.outputPath("initial-data-error.png"), fullPage: true }); - await request.post("/__test/state", { data: "success" }); - await error.getByRole("button", { name: "Retry" }).click(); - await expect(page.getByText("Connection view ready")).toBeVisible(); - await expect(page.getByRole("group", { name: "Reporting period" })).toHaveCount(0); - await expect(page).toHaveTitle("Orders exploration • Acme/production • Altertable app"); - await request.post("/__test/state", { data: "failure" }); - await page.getByRole("button", { name: "Refresh data" }).click(); - await expect(page.getByText("Connection view ready")).toBeVisible(); - await expect(page.getByRole("alert")).toContainText(/Couldn’t refresh. Showing the last result/); + await page.screenshot({ + path: testInfo.outputPath('initial-data-error.png'), + fullPage: true, + }); + await request.post('/__test/state', { data: 'success' }); + await error.getByRole('button', { name: 'Retry' }).click(); + await expect(page.getByText('Connection view ready')).toBeVisible(); + await expect( + page.getByRole('group', { name: 'Reporting period' }) + ).toHaveCount(0); + await expect(page).toHaveTitle( + 'Orders exploration • Acme/production • Altertable app' + ); + await request.post('/__test/state', { data: 'failure' }); + await page.getByRole('button', { name: 'Refresh data' }).click(); + await expect(page.getByText('Connection view ready')).toBeVisible(); + await expect(page.getByRole('alert')).toContainText( + /Couldn’t refresh. Showing the last result/ + ); }); -test("widget inspection inherits the page glossary and empty states", async ({ page }) => { - await page.goto("/components"); - await expect(page.getByText("Connection view ready")).toBeVisible(); - await page.getByRole("button", { name: "Explore Completed orders" }).click(); - await expect(page.getByRole("dialog")).toContainText("Completed orders grouped by customer_id."); - await page.getByRole("tab", { name: "Queries" }).click(); - await expect(page.getByRole("dialog")).toContainText("connection.sql"); - await expect(page.getByRole("region", { name: "Query notebook" })).toBeVisible(); - await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByRole("button", { name: "Copy all", exact: true }).click(); - await expect(page.getByRole("button", { name: "Copied all", exact: true })).toBeVisible(); - expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("-- connection.sql"); +test('widget inspection inherits the page glossary and empty states', async ({ + page, +}) => { + await page.goto('/components'); + await expect(page.getByText('Connection view ready')).toBeVisible(); + await page.getByRole('button', { name: 'Explore Completed orders' }).click(); + await expect(page.getByRole('dialog')).toContainText( + 'Completed orders grouped by customer_id.' + ); + await page.getByRole('tab', { name: 'Queries' }).click(); + await expect(page.getByRole('dialog')).toContainText('connection.sql'); + await expect( + page.getByRole('region', { name: 'Query notebook' }) + ).toBeVisible(); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.getByRole('button', { name: 'Copy all', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Copied all', exact: true }) + ).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( + '-- connection.sql' + ); }); -test("table pages show bounded rows and reset when filtering changes", async ({ page }) => { - await page.goto("/components"); - await expect(page.getByText("Connection view ready")).toBeVisible(); - const table = page.getByRole("table"); - const pagination = page.getByRole("navigation", { name: "Table pages" }); - await expect(pagination).toContainText("1–4 of 11 results"); - await expect(table.getByText("Order 4")).toBeVisible(); - await expect(table.getByText("Order 5")).toHaveCount(0); - await pagination.getByRole("button", { name: "Next page" }).click(); - await expect(pagination).toContainText("5–8 of 11 results"); - await expect(table.getByText("Order 5")).toBeVisible(); - await pagination.getByRole("button", { name: "Next page" }).click(); - await expect(pagination).toContainText("9–11 of 11 results"); - await expect(pagination.getByRole("button", { name: "Next page" })).toBeDisabled(); - await page.getByRole("searchbox", { name: "Search orders" }).fill("Order"); - await expect(pagination).toContainText("1–4 of 11 results"); - await page.getByRole("searchbox", { name: "Search orders" }).fill("Order 5"); +test('table pages show bounded rows and reset when filtering changes', async ({ + page, +}) => { + await page.goto('/components'); + await expect(page.getByText('Connection view ready')).toBeVisible(); + const table = page.getByRole('table'); + const pagination = page.getByRole('navigation', { name: 'Table pages' }); + await expect(pagination).toContainText('1–4 of 11 results'); + await expect(table.getByText('Order 4')).toBeVisible(); + await expect(table.getByText('Order 5')).toHaveCount(0); + await pagination.getByRole('button', { name: 'Next page' }).click(); + await expect(pagination).toContainText('5–8 of 11 results'); + await expect(table.getByText('Order 5')).toBeVisible(); + await pagination.getByRole('button', { name: 'Next page' }).click(); + await expect(pagination).toContainText('9–11 of 11 results'); + await expect( + pagination.getByRole('button', { name: 'Next page' }) + ).toBeDisabled(); + await page.getByRole('searchbox', { name: 'Search orders' }).fill('Order'); + await expect(pagination).toContainText('1–4 of 11 results'); + await page.getByRole('searchbox', { name: 'Search orders' }).fill('Order 5'); await expect(pagination).toHaveCount(0); - await expect(table.getByText("Order 5")).toBeVisible(); - await expect(table.locator("tbody tr")).toHaveCount(1); - await page.getByRole("searchbox", { name: "Search orders" }).fill(""); - await expect(pagination).toContainText("1–4 of 11 results"); - await expect(pagination.getByRole("button", { name: "Previous page" })).toBeDisabled(); + await expect(table.getByText('Order 5')).toBeVisible(); + await expect(table.locator('tbody tr')).toHaveCount(1); + await page.getByRole('searchbox', { name: 'Search orders' }).fill(''); + await expect(pagination).toContainText('1–4 of 11 results'); + await expect( + pagination.getByRole('button', { name: 'Previous page' }) + ).toBeDisabled(); }); -test("request progress appears before Refresh", async ({ page }) => { - await page.goto("/components"); - await expect(page.getByText("Connection view ready")).toBeVisible(); +test('request progress appears before Refresh', async ({ page }) => { + await page.goto('/components'); + await expect(page.getByText('Connection view ready')).toBeVisible(); let release!: () => void; - const held = new Promise((resolve) => { + const held = new Promise(resolve => { release = resolve; }); - await page.route("**/api/data/connection", async (route) => { + await page.route('**/api/data/connection', async route => { await held; await route.continue(); }); - await page.getByRole("button", { name: "Refresh data" }).click(); - const status = page.locator(".altertable-refresh-status"); - await expect(status).toHaveText("Updating data"); + await page.getByRole('button', { name: 'Refresh data' }).click(); + const status = page.locator('.altertable-refresh-status'); + await expect(status).toHaveText('Updating data'); await expect(status).toBeVisible(); const label = await status.boundingBox(); - const action = await page.getByRole("button", { name: "Cancel refresh" }).boundingBox(); + const action = await page + .getByRole('button', { name: 'Cancel refresh' }) + .boundingBox(); expect(label!.x + label!.width).toBeLessThan(action!.x); release(); await expect(status).toBeEmpty(); }); -test("variables sit below the header divider while actions stay in the header", async ({ +test('variables sit below the header divider while actions stay in the header', async ({ page, }) => { - await page.goto("/components"); - const header = page.locator(".altertable-app-header"); - const bar = page.getByRole("group", { name: "View controls" }); - const actions = header.getByRole("group", { name: "Page actions" }); - await expect(bar.getByRole("button", { name: "Choose dates" })).toBeVisible(); - await expect(actions.getByRole("button", { name: "Refresh data" })).toBeVisible(); + await page.goto('/components'); + const header = page.locator('.altertable-app-header'); + const bar = page.getByRole('group', { name: 'View controls' }); + const actions = header.getByRole('group', { name: 'Page actions' }); + await expect(bar.getByRole('button', { name: 'Choose dates' })).toBeVisible(); + await expect( + actions.getByRole('button', { name: 'Refresh data' }) + ).toBeVisible(); const headerBox = await header.boundingBox(); const barBox = await bar.boundingBox(); expect(barBox!.y).toBeGreaterThanOrEqual(headerBox!.y + headerBox!.height); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( - true, - ); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); }); -test("mobile header pairs scope with actions and keeps the description below", async ({ +test('mobile header pairs scope with actions and keeps the description below', async ({ page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/components"); - const header = page.locator(".altertable-app-header"); - const title = header.getByRole("heading", { name: "Orders exploration" }); - const scope = header.locator(".altertable-app-header-context"); - const actions = header.getByRole("group", { name: "Page actions" }); - const description = header.locator(":scope > .altertable-app-header-heading > p"); + await page.goto('/components'); + const header = page.locator('.altertable-app-header'); + const title = header.getByRole('heading', { name: 'Orders exploration' }); + const scope = header.locator('.altertable-app-header-context'); + const actions = header.getByRole('group', { name: 'Page actions' }); + const description = header.locator( + ':scope > .altertable-app-header-heading > p' + ); const titleBox = await title.boundingBox(); const scopeBox = await scope.boundingBox(); const actionsBox = await actions.boundingBox(); const descriptionBox = await description.boundingBox(); expect(scopeBox!.y).toBeGreaterThanOrEqual(titleBox!.y + titleBox!.height); expect(Math.abs(scopeBox!.y - actionsBox!.y)).toBeLessThan(12); - expect(descriptionBox!.y).toBeGreaterThanOrEqual(actionsBox!.y + actionsBox!.height); - await page.screenshot({ path: testInfo.outputPath("mobile-header.png") }); + expect(descriptionBox!.y).toBeGreaterThanOrEqual( + actionsBox!.y + actionsBox!.height + ); + await page.screenshot({ path: testInfo.outputPath('mobile-header.png') }); await page.setViewportSize({ width: 280, height: 844 }); const narrowScope = await scope.boundingBox(); const narrowActions = await actions.boundingBox(); - expect(narrowActions!.y).toBeGreaterThanOrEqual(narrowScope!.y + narrowScope!.height); + expect(narrowActions!.y).toBeGreaterThanOrEqual( + narrowScope!.y + narrowScope!.height + ); }); -test("grid uses one gap and keeps shorter panels at content height", async ({ page }, testInfo) => { - await page.goto("/components"); - const grid = page.getByTestId("layout-grid"); +test('grid uses one gap and keeps shorter panels at content height', async ({ + page, +}, testInfo) => { + await page.goto('/components'); + const grid = page.getByTestId('layout-grid'); const stackGap = await page - .getByTestId("layout-stack") - .evaluate((element) => getComputedStyle(element).gap); - const expectedGap = testInfo.project.name === "desktop" ? 24 : 16; + .getByTestId('layout-stack') + .evaluate(element => getComputedStyle(element).gap); + const expectedGap = testInfo.project.name === 'desktop' ? 24 : 16; expect(stackGap).toBe(`${expectedGap}px`); - const items = grid.locator(":scope > div"); + const items = grid.locator(':scope > div'); const short = await items.nth(0).boundingBox(); const tall = await items.nth(1).boundingBox(); expect(short).not.toBeNull(); expect(tall).not.toBeNull(); expect(short!.height).toBeLessThan(tall!.height); - if (testInfo.project.name === "desktop") { + if (testInfo.project.name === 'desktop') { expect(Math.round(tall!.x - short!.x - short!.width)).toBe(expectedGap); } else { expect(Math.round(tall!.y - short!.y - short!.height)).toBe(expectedGap); } - const constrained = page.getByTestId("constrained-grid").locator(":scope > div"); + const constrained = page + .getByTestId('constrained-grid') + .locator(':scope > div'); const firstNarrow = await constrained.nth(0).boundingBox(); const secondNarrow = await constrained.nth(1).boundingBox(); expect(Math.round(secondNarrow!.x)).toBe(Math.round(firstNarrow!.x)); - expect(Math.round(secondNarrow!.y - firstNarrow!.y - firstNarrow!.height)).toBe(expectedGap); + expect( + Math.round(secondNarrow!.y - firstNarrow!.y - firstNarrow!.height) + ).toBe(expectedGap); const border = await page - .locator(".altertable-app-header") - .evaluate((element) => getComputedStyle(element).borderBottomWidth); - expect(border).toBe("1px"); + .locator('.altertable-app-header') + .evaluate(element => getComputedStyle(element).borderBottomWidth); + expect(border).toBe('1px'); }); -test("grid spans respond to their container at phone, tablet, and desktop widths", async ({ +test('grid spans respond to their container at phone, tablet, and desktop widths', async ({ page, }) => { for (const width of [390, 550, 800, 1280]) { await page.setViewportSize({ width, height: 900 }); - await page.goto("/components"); - const primary = await page.getByTestId("primary-grid-item").boundingBox(); - const support = await page.getByTestId("support-grid-item").boundingBox(); + await page.goto('/components'); + const primary = await page.getByTestId('primary-grid-item').boundingBox(); + const support = await page.getByTestId('support-grid-item').boundingBox(); const gap = await page - .getByTestId("spanned-grid") - .evaluate((element) => getComputedStyle(element).gap); + .getByTestId('spanned-grid') + .evaluate(element => getComputedStyle(element).gap); expect(gap).toBe(`${Math.max(16, Math.min(24, width * 0.025))}px`); - const peers = await page.getByTestId("peer-grid").locator(":scope > *").all(); + const peers = await page + .getByTestId('peer-grid') + .locator(':scope > *') + .all(); const firstPeer = await peers[0]!.boundingBox(); const lastPeer = await peers[2]!.boundingBox(); expect(primary).not.toBeNull(); @@ -337,7 +442,9 @@ test("grid spans respond to their container at phone, tablet, and desktop widths expect(support!.y).toBeGreaterThanOrEqual(primary!.y + primary!.height); expect(Math.round(support!.x)).toBe(Math.round(primary!.x)); expect(Math.round(support!.width)).toBe(Math.round(primary!.width)); - expect(lastPeer!.y).toBeGreaterThanOrEqual(firstPeer!.y + firstPeer!.height); + expect(lastPeer!.y).toBeGreaterThanOrEqual( + firstPeer!.y + firstPeer!.height + ); expect(Math.round(lastPeer!.width)).toBe(Math.round(primary!.width)); } else { expect(Math.round(primary!.y)).toBe(Math.round(support!.y)); @@ -345,44 +452,48 @@ test("grid spans respond to their container at phone, tablet, and desktop widths expect(Math.round(firstPeer!.y)).toBe(Math.round(lastPeer!.y)); } expect( - await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) ).toBe(true); } }); -test("initial loading keeps the grid shape without displaying snapshot values", async ({ +test('initial loading keeps the grid shape without displaying snapshot values', async ({ page, }) => { let release!: () => void; - const held = new Promise((resolve) => { + const held = new Promise(resolve => { release = resolve; }); - await page.route("**/api/data/connection", async (route) => { + await page.route('**/api/data/connection', async route => { await held; await route.continue(); }); - await page.goto("/components"); - const loading = page.getByTestId("loading-skeleton-grid"); - await expect(page.getByRole("status", { name: "Loading orders" })).toBeVisible(); - await expect(page.getByText("Connection view ready")).toHaveCount(0); + await page.goto('/components'); + const loading = page.getByTestId('loading-skeleton-grid'); + await expect( + page.getByRole('status', { name: 'Loading orders' }) + ).toBeVisible(); + await expect(page.getByText('Connection view ready')).toHaveCount(0); release(); - await expect(page.getByText("Connection view ready")).toBeVisible(); + await expect(page.getByText('Connection view ready')).toBeVisible(); await expect(loading).toHaveCount(0); }); -test("authored widget layout gives its main visual more room and stacks on phones", async ({ +test('authored widget layout gives its main visual more room and stacks on phones', async ({ page, }, testInfo) => { - await page.goto("/components"); - const story = page.getByTestId("layout-story"); - const lead = await story.getByTestId("story-lead").boundingBox(); - const visual = await story.getByTestId("story-visual").boundingBox(); - const support = await story.getByTestId("story-support").boundingBox(); + await page.goto('/components'); + const story = page.getByTestId('layout-story'); + const lead = await story.getByTestId('story-lead').boundingBox(); + const visual = await story.getByTestId('story-visual').boundingBox(); + const support = await story.getByTestId('story-support').boundingBox(); expect(lead).not.toBeNull(); expect(visual).not.toBeNull(); expect(support).not.toBeNull(); expect(visual!.y).toBeGreaterThan(lead!.y); - if (testInfo.project.name === "desktop") { + if (testInfo.project.name === 'desktop') { expect(visual!.width).toBeGreaterThan(support!.width); expect(Math.round(visual!.y)).toBe(Math.round(support!.y)); } else { diff --git a/scripts/verify.sh b/scripts/verify.sh index 08f33088..62b52779 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -47,7 +47,7 @@ cd "${REPO_ROOT}/cli" run_step "install deps" bun install --frozen-lockfile run_step "typecheck" bun run typecheck -run_step "top-level test typecheck" ./node_modules/.bin/tsc -p "${REPO_ROOT}/tsconfig.tests.json" +run_step "top-level test typecheck" bun node_modules/typescript-checker/bin/tsc -p "${REPO_ROOT}/tsconfig.tests.json" run_step "lint" bun run lint run_step "format:check" bun run format:check run_step "data app checks" bun run data-app:check From d7114ec8f993fd6c372e5a5e95e4b91284d06869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:12:10 +0200 Subject: [PATCH 02/10] chore(tooling): expose root checks and verify packed consumers Expose direct root scripts for the existing CLI workflow without combining the independently locked starter and published-package tests into workspaces. Move frozen development installs to an explicit setup command and CI step so checking does not modify installed dependencies. Pin CI Node from .node-version. Replace npm package dry runs with tarball extraction and executable, OpenAPI, and offline scaffold smoke checks from the extracted artifact. Preserve full generated-app smoke coverage, native build orchestration, Bun 1.1 runtime compatibility, and release recovery/provenance. Validation: full scripts/verify.sh --integration, frozen setup, actual npm tarball smoke, minimum Bun 1.1.0 scaffold smoke, and host native compile/smoke. Other native platforms remain covered by the CI matrix. --- .github/workflows/verify.yml | 9 ++++++- AGENTS.md | 2 ++ CONTRIBUTING.md | 4 ++-- DEVELOPMENT.md | 44 +++++++++++++++++++++++++++++++++-- cli/AGENTS.md | 2 ++ cli/package.json | 7 +++--- cli/scripts/check-data-app.ts | 1 - cli/scripts/check-package.ts | 39 +++++++++++++++++++++++++++++++ package.json | 19 +++++++++++++++ scripts/verify.sh | 1 - 10 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 cli/scripts/check-package.ts create mode 100644 package.json diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 251994af..8789b070 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -43,13 +43,20 @@ jobs: with: bun-version-file: .bun-version + - name: Install Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + - name: Install completion shells run: sudo apt-get update && sudo apt-get install --yes fish zsh + - name: Install dependencies + run: bun run setup + - name: Install data app browser working-directory: data-app/tests run: | - bun install --frozen-lockfile bunx playwright install --with-deps chromium - name: Verify repository and integration paths diff --git a/AGENTS.md b/AGENTS.md index 237fd7f5..2c922248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ For data app starter, distribution, and browser work, follow [data-app/AGENTS.md ## Start here +Install dependencies separately with `bun run setup` from the repository root before verification. + ```bash git submodule update --init --recursive # first checkout only ./scripts/verify.sh --quick # focused CLI changes, including coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df496dc6..ddc24b7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ The CLI is a TypeScript/Bun project in `cli/`. The entrypoint `bin/altertable` i 1. Fork and clone the repository 2. Initialize submodules: `git submodule update --init --recursive` -3. Install dependencies: `cd cli && bun install` +3. Install dependencies: `bun run setup` (from the repository root) 4. Install the [Oxc VS Code extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode) (works in Cursor) for format-on-save and lint diagnostics See [DEVELOPMENT.md](DEVELOPMENT.md) for build, compile, and release details. @@ -15,7 +15,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for build, compile, and release details. 1. Create a branch from `main` 2. Edit `cli/src/**` -3. Run checks: `./scripts/verify.sh` (use `--quick` for iterative CLI-only work; full verify before opening PR) +3. Run checks: `bun run check:package` (use `bun run check` for iterative work; full gate before opening PR) 4. Run integration tests when touching lakehouse HTTP paths: `./scripts/verify.sh --integration` (mock at `:15000`) 5. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, etc.) 6. Update `CHANGELOG.md` under `[Unreleased]` when the change is user-facing diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index eb09a30e..89b70d0c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,6 +6,46 @@ The Bun runtime used by contributors, CI, and release builds is pinned in [`.bun GitHub Actions use the Node.js LTS version pinned in [`.node-version`](.node-version). +From the repository root: + +```fish +bun run setup # frozen installs for CLI and isolated consumers +bun run dev -- --help # same as bin/altertable +bun run build # npm bundle in cli/dist/cli.js +bun run typecheck +bun run lint +bun run lint:fix +bun run format +bun run format:check +bun run test # Bun CLI unit tests, no watch +bun run test:watch +bun run check # canonical quick gate, including consumer checks +bun run check:package # full gate, browser tests and packed consumer smoke +``` + +The root is a private command entry point, not a workspace: `cli/bun.lock` and the +starter and browser-test lockfiles remain independent. Individual commands above +operate on the CLI; both check commands also cover top-level tests and the data app +consumers. Install dependencies explicitly with `bun run setup` before checking; +verification does not install development dependencies. Artifact smoke tests still +install generated apps to exercise the real consumer path. + +CLI tests intentionally use `bun test` because they exercise Bun APIs and the CLI's +runtime. The Bun build plugin embeds the offline starter in both npm and native +artifacts; retaining it avoids introducing another build tool and plugin adapter. +Formatting uses single quotes, ES5 trailing commas, unparenthesized single arrow +parameters, an 80-column print width, and LF line endings. Generated API files stay +under their generators' control. + +Typechecking uses TypeScript 7.0.2. The CLI installs that compiler under the explicit +`typescript-checker` alias because `openapi-typescript` still needs the TypeScript +6.0.3 JavaScript compiler API to generate identical API types. Its `typescript` +dependency stays at 6.0.3 for generation only; both CLI and top-level test checks +invoke the version 7 compiler explicitly. Knip excludes this alias from unused +dependency detection because it is executed by its installed file path. + +Additional commands from `cli/`: + ```bash cd cli bun install @@ -23,7 +63,7 @@ bun run generate:commands # regenerate Markdown, JSON, and JSON Schema comm bun run generate:check # non-mutating generated-artifact drift check bun run spec:refresh # fetch hosted OpenAPI spec (see specs/rest/SPEC.md) + generate bun run build # bundle to cli/dist/cli.js -bun run pack:check # build + dry-run pack (verify publish contents) +bun run pack:check # build, pack, extract, and smoke-test the npm artifact ``` The CLI reference wire contract is rendered from the command-reference module into @@ -46,7 +86,7 @@ The JS bundle is the npm `bin` entry and a GitHub Release asset: cd cli bun run build # writes cli/dist/cli.js bun run scripts/smoke-npm-bundle.ts -bun run pack:check # ensures dist/ is the only packed file +bun run pack:check # smoke-test the actual packed executable and offline scaffold ``` ## Compile native binaries diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 5baacdd1..e9eb9f9e 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -14,6 +14,8 @@ Executor-oriented notes for working in the TypeScript CLI subtree. Preferred — from repo root: +Install dependencies separately with `bun run setup` from the repository root before verification. + ```bash ./scripts/verify.sh --quick # CLI checks only (typecheck, lint, format, knip, coverage, openapi drift) ./scripts/verify.sh # full gate (mirrors CI minus native compile) diff --git a/cli/package.json b/cli/package.json index 82a6a60e..7b38b9d0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@altertable/cli", "version": "1.12.0", - "description": "Altertable CLI \u2014 query and manage your data platform", + "description": "Altertable CLI — query and manage your data platform", "homepage": "https://github.com/altertable-ai/altertable-cli#readme", "bugs": { "url": "https://github.com/altertable-ai/altertable-cli/issues" @@ -41,11 +41,12 @@ "release:smoke": "bun run scripts/release.ts smoke", "release:verify": "bun run scripts/release.ts verify", "release:upload-github": "bun run scripts/publish-release.ts upload-github", - "pack:check": "bun run build && bun pm pack --dry-run", + "pack:check": "bun run build && bun run scripts/check-package.ts", "knip": "knip --no-config-hints", "knip:production": "knip --production --no-config-hints", "data-app:check": "bun run scripts/check-data-app.ts", - "data-app:test:browser": "cd ../data-app/tests && bun run test" + "data-app:test:browser": "cd ../data-app/tests && bun run test", + "test:watch": "bun test --watch" }, "dependencies": { "@clack/prompts": "1.7.0", diff --git a/cli/scripts/check-data-app.ts b/cli/scripts/check-data-app.ts index d10bf589..941858f7 100644 --- a/cli/scripts/check-data-app.ts +++ b/cli/scripts/check-data-app.ts @@ -3,7 +3,6 @@ import { dataAppDirectory } from '@/commands/app/lib/distribution.ts'; for (const project of ['starter', 'tests']) { const commands = [ - ['install', '--frozen-lockfile'], ...['typecheck', 'lint', 'format:check'].map(name => ['run', name]), ...(project === 'starter' ? [['run', 'build']] : []), ]; diff --git a/cli/scripts/check-package.ts b/cli/scripts/check-package.ts new file mode 100644 index 00000000..0e36759f --- /dev/null +++ b/cli/scripts/check-package.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { smokeNpmBundle } from '@/../scripts/smoke-npm-bundle.ts'; + +const packageDirectory = join(import.meta.dir, '..'); +const directory = await mkdtemp(join(tmpdir(), 'altertable-npm-consumer-')); + +async function run(command: string[]): Promise { + const child = Bun.spawn(command, { + cwd: packageDirectory, + stdout: 'inherit', + stderr: 'inherit', + }); + if (await child.exited) { + throw new Error(`Package check failed: ${command.join(' ')}`); + } +} + +try { + const archive = join(directory, 'cli.tgz'); + await run([process.execPath, 'pm', 'pack', '--filename', archive]); + await run(['tar', '-xzf', archive, '-C', directory]); + const manifest = await Bun.file( + join(directory, 'package/package.json') + ).json(); + if ( + manifest.name !== '@altertable/cli' || + manifest.bin?.altertable !== './dist/cli.js' + ) { + throw new Error('Packed CLI name or executable entry is incorrect.'); + } + await smokeNpmBundle([ + `--bundle=${join(directory, 'package', manifest.bin.altertable)}`, + '--scaffold-only', + ]); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..037d93cf --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "altertable-cli-development", + "private": true, + "scripts": { + "build": "bun run --cwd cli build", + "dev": "bun run --cwd cli dev", + "typecheck": "bun run --cwd cli typecheck", + "lint": "bun run --cwd cli lint", + "lint:fix": "bun run --cwd cli lint:fix", + "format": "bun run --cwd cli format", + "format:check": "bun run --cwd cli format:check", + "test": "bun run --cwd cli test", + "test:watch": "bun run --cwd cli test:watch", + "setup": "bun install --cwd cli --frozen-lockfile && bun install --cwd data-app/starter --frozen-lockfile && bun install --cwd data-app/tests --frozen-lockfile", + "check": "./scripts/verify.sh --quick", + "check:package": "./scripts/verify.sh" + }, + "packageManager": "bun@1.4.2" +} diff --git a/scripts/verify.sh b/scripts/verify.sh index 62b52779..8fb21e57 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -45,7 +45,6 @@ check_mock_server() { cd "${REPO_ROOT}/cli" -run_step "install deps" bun install --frozen-lockfile run_step "typecheck" bun run typecheck run_step "top-level test typecheck" bun node_modules/typescript-checker/bin/tsc -p "${REPO_ROOT}/tsconfig.tests.json" run_step "lint" bun run lint From 3034c604474a6806c6f29739501f3bd456f25770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:27:36 +0200 Subject: [PATCH 03/10] chore(tooling): expose both Knip checks at the repository root Keep Knip 6.32.2 and both existing canonical verification checks. Add direct root commands for normal and production reachability analysis, and document them alongside the other development commands. Validation: both root Knip commands pass with Bun 1.4.2 and Node 24.18.0; no additional exceptions or configuration changes are needed. --- DEVELOPMENT.md | 2 ++ package.json | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 89b70d0c..38f01379 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -19,6 +19,8 @@ bun run format bun run format:check bun run test # Bun CLI unit tests, no watch bun run test:watch +bun run knip # unused code, exports, and dependencies +bun run knip:production # production reachability bun run check # canonical quick gate, including consumer checks bun run check:package # full gate, browser tests and packed consumer smoke ``` diff --git a/package.json b/package.json index 037d93cf..bbc7d6c5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test:watch": "bun run --cwd cli test:watch", "setup": "bun install --cwd cli --frozen-lockfile && bun install --cwd data-app/starter --frozen-lockfile && bun install --cwd data-app/tests --frozen-lockfile", "check": "./scripts/verify.sh --quick", - "check:package": "./scripts/verify.sh" + "check:package": "./scripts/verify.sh", + "knip": "bun run --cwd cli knip", + "knip:production": "bun run --cwd cli knip:production" }, "packageManager": "bun@1.4.2" } From 2909864d3443c96474b1d1e9d1bd3a8010d3de7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:32:40 +0200 Subject: [PATCH 04/10] chore(ci): run the canonical root check with shared Knip commands Make bun run check the full repository gate and have CI call it with the integration flag. Preserve quick verification through --quick and retain check:package as a full-gate alias. Route both Knip modes through their root commands once each. Remove the separate bundle build because pack:check already builds before exercising the tarball. Validation: bun run check --integration passes under Bun 1.4.2 and Node 24.18.0, including unit coverage, black-box tests, both Knip modes, 42 browser tests, packed consumers, and lakehouse integration. --- .github/workflows/verify.yml | 2 +- AGENTS.md | 10 +++++----- CONTRIBUTING.md | 4 ++-- DEVELOPMENT.md | 19 ++++++++++--------- cli/AGENTS.md | 10 +++++----- package.json | 4 ++-- scripts/verify.sh | 3 +-- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 8789b070..75ad0a2f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -60,7 +60,7 @@ jobs: bunx playwright install --with-deps chromium - name: Verify repository and integration paths - run: ./scripts/verify.sh --integration + run: bun run check --integration - name: Upload coverage report uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/AGENTS.md b/AGENTS.md index 2c922248..7769be60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,9 @@ Install dependencies separately with `bun run setup` from the repository root be ```bash git submodule update --init --recursive # first checkout only -./scripts/verify.sh --quick # focused CLI changes, including coverage -./scripts/verify.sh # full gate before PR (mirrors CI minus native compile) -./scripts/verify.sh --integration # lakehouse HTTP paths (mock at :15000) +bun run check --quick # focused CLI changes, including coverage +bun run check # full gate before PR (mirrors CI minus native compile) +bun run check --integration # lakehouse HTTP paths (mock at :15000) ``` For data app changes, run the focused checks first from the repository root: @@ -40,8 +40,8 @@ bun run --cwd cli data-app:test:browser The starter build requires its published package dependencies; see [data-app/README.md](data-app/README.md) for setup and browser prerequisites. Run `bun run --cwd cli data-app:check` to check all -three data app projects together. Use `./scripts/verify.sh --quick` after focused checks, -then default `./scripts/verify.sh` before opening a PR. +three data app projects together. Use `bun run check --quick` after focused checks, +then default `bun run check` before opening a PR. ## Deep guides diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddc24b7d..1e1b70af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,8 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for build, compile, and release details. 1. Create a branch from `main` 2. Edit `cli/src/**` -3. Run checks: `bun run check:package` (use `bun run check` for iterative work; full gate before opening PR) -4. Run integration tests when touching lakehouse HTTP paths: `./scripts/verify.sh --integration` (mock at `:15000`) +3. Run checks: `bun run check` (use `bun run check --quick` for iterative work; full gate before opening PR) +4. Run integration tests when touching lakehouse HTTP paths: `bun run check --integration` (mock at `:15000`) 5. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, etc.) 6. Update `CHANGELOG.md` under `[Unreleased]` when the change is user-facing 7. Open a pull request diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 38f01379..20e9aea9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,13 +21,14 @@ bun run test # Bun CLI unit tests, no watch bun run test:watch bun run knip # unused code, exports, and dependencies bun run knip:production # production reachability -bun run check # canonical quick gate, including consumer checks -bun run check:package # full gate, browser tests and packed consumer smoke +bun run check # full canonical gate, including consumers and packages +bun run check --quick # focused gate without browser/package smoke tests +bun run check:package # alias for the full canonical gate ``` The root is a private command entry point, not a workspace: `cli/bun.lock` and the starter and browser-test lockfiles remain independent. Individual commands above -operate on the CLI; both check commands also cover top-level tests and the data app +operate on the CLI; the canonical check also covers top-level tests and the data app consumers. Install dependencies explicitly with `bun run setup` before checking; verification does not install development dependencies. Artifact smoke tests still install generated apps to exercise the real consumer path. @@ -148,7 +149,7 @@ On push to `main`, `.github/workflows/release-please.yml`: The completed GitHub Release becomes public only after npm publication succeeds. Both `.github/workflows/test.yml` and the release workflow call `.github/workflows/verify.yml`, which -runs the repository and integration gates and executes the npm bundle on the minimum supported Bun +runs `bun run check --integration` and executes the npm bundle on the minimum supported Bun runtime. Branch CI then loads its native compile matrix from the typed release manifest and smoke-tests every Linux and macOS release target. Main-branch verification is never cancelled by a newer push. Workflows use explicit permissions, pinned runners/actions, concurrency controls, and job @@ -219,12 +220,12 @@ The stored profile records only the URLs, not the flag, so subsequent commands a ## Verify (agents and contributors) -From repo root, one script mirrors CI (minus native binary compile): +From the repository root, the canonical command mirrors CI (minus native binary compile): -```bash -./scripts/verify.sh --quick # typecheck, lint, format, knip, coverage, generated-artifact drift -./scripts/verify.sh # + build, pack:check, top-level black-box tests -./scripts/verify.sh --integration # + tests/integration.e2e.ts (requires mock at :15000) +```fish +bun run check --quick # typecheck, lint, format, knip, coverage, drift, black-box tests +bun run check # + browser tests, package build, and consumer smoke +bun run check --integration # + tests/integration.e2e.ts (requires mock at :15000) ``` See [AGENTS.md](AGENTS.md) and [cli/AGENTS.md](cli/AGENTS.md) for agent-oriented docs. diff --git a/cli/AGENTS.md b/cli/AGENTS.md index e9eb9f9e..06067798 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -1,6 +1,6 @@ # Agent guide — `cli/` -Repo-level router: [../AGENTS.md](../AGENTS.md). Run `./scripts/verify.sh` from repo root. +Repo-level router: [../AGENTS.md](../AGENTS.md). Run `bun run check` from repo root. Executor-oriented notes for working in the TypeScript CLI subtree. @@ -17,9 +17,9 @@ Preferred — from repo root: Install dependencies separately with `bun run setup` from the repository root before verification. ```bash -./scripts/verify.sh --quick # CLI checks only (typecheck, lint, format, knip, coverage, openapi drift) -./scripts/verify.sh # full gate (mirrors CI minus native compile) -./scripts/verify.sh --integration # + tests/integration.e2e.ts (mock at :15000) +bun run check --quick # CLI checks only (typecheck, lint, format, knip, coverage, openapi drift) +bun run check # full gate (mirrors CI minus native compile) +bun run check --integration # + tests/integration.e2e.ts (mock at :15000) ``` Manual equivalents from `cli/`: @@ -212,7 +212,7 @@ Bump the OpenAPI spec and extend `openapi-http-conformance.test.ts` placeholder | lib pure function | `cd cli && bun test path/to.test.ts` | | command validation | colocated `src/commands//.test.ts` | | HTTP behavior | mock file via `ALTERTABLE_MOCK_HTTP_FILE` | -| end-to-end lakehouse | `./scripts/verify.sh --integration` | +| end-to-end lakehouse | `bun run check --integration` | | completion structure | `cd cli && bun test src/commands/completion` | Test env vars: `ALTERTABLE_CONFIG_HOME`, `ALTERTABLE_SECRET_BACKEND=file`, `ALTERTABLE_MOCK_HTTP_FILE`, `ALTERTABLE_HTTP_LOG`. diff --git a/package.json b/package.json index bbc7d6c5..7e0a3397 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "test": "bun run --cwd cli test", "test:watch": "bun run --cwd cli test:watch", "setup": "bun install --cwd cli --frozen-lockfile && bun install --cwd data-app/starter --frozen-lockfile && bun install --cwd data-app/tests --frozen-lockfile", - "check": "./scripts/verify.sh --quick", - "check:package": "./scripts/verify.sh", + "check": "./scripts/verify.sh", + "check:package": "bun run check", "knip": "bun run --cwd cli knip", "knip:production": "bun run --cwd cli knip:production" }, diff --git a/scripts/verify.sh b/scripts/verify.sh index 8fb21e57..54da93df 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -52,10 +52,10 @@ run_step "format:check" bun run format:check run_step "data app checks" bun run data-app:check run_step "generated artifact drift check" bun run generate:check run_step "unit tests with coverage" bun run test:coverage +cd "${REPO_ROOT}" run_step "knip" bun run knip run_step "knip (production)" bun run knip:production -cd "${REPO_ROOT}" run_step "top-level JS tests" bun test "${REPO_ROOT}"/tests/*.test.ts if [[ "${QUICK}" == true ]]; then @@ -65,7 +65,6 @@ fi cd "${REPO_ROOT}/cli" run_step "data app browser tests" bun run data-app:test:browser -run_step "build" bun run build run_step "pack:check" bun run pack:check run_step "npm bundle smoke test" bun run scripts/smoke-npm-bundle.ts From e298a14afab15d0b50017965b2bd450e9616c4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:45:06 +0200 Subject: [PATCH 05/10] style(tooling): enforce sorted imports across CLI and starter Enable Oxfmt import sorting while preserving package JSON field order with sortPackageJson: false. Apply import grouping consistently to CLI source, development scripts, and isolated starter/browser consumers. Retain strict TypeScript checking and the generator compiler API exception. Validation: full root integration check, generated artifact drift checks, both Knip modes, 42 browser tests, and host native compile/smoke pass. --- cli/.oxfmtrc.json | 4 +- cli/scripts/check-data-app.ts | 1 + cli/scripts/check-package.ts | 1 + cli/scripts/command-reference.test.ts | 13 ++++--- cli/scripts/command-reference.ts | 12 +++--- cli/scripts/generate-command-reference.ts | 13 ++++--- cli/scripts/generate-openapi-index.ts | 1 + cli/scripts/generate-openapi-types.ts | 4 +- cli/scripts/generated-artifact.test.ts | 3 +- cli/scripts/npm-bundle.ts | 1 + cli/scripts/package-data-app.ts | 1 + cli/scripts/publish-release.ts | 1 + cli/scripts/release.test.ts | 39 ++++++++++--------- cli/scripts/release.ts | 10 +++-- cli/scripts/script-output.test.ts | 3 +- cli/scripts/smoke-npm-bundle.ts | 3 +- cli/src/cli.ts | 36 ++++++++--------- cli/src/commands/api/index.test.ts | 11 +++--- cli/src/commands/api/index.ts | 14 +++---- cli/src/commands/api/lib/body.ts | 1 + .../commands/api/lib/http-conformance.test.ts | 7 ++-- cli/src/commands/api/lib/http.test.ts | 7 ++-- cli/src/commands/api/lib/http.ts | 6 +-- cli/src/commands/api/lib/views.test.ts | 1 + cli/src/commands/api/routes.ts | 6 +-- cli/src/commands/api/spec.ts | 4 +- cli/src/commands/app/build.ts | 5 ++- cli/src/commands/app/create.test.ts | 3 +- cli/src/commands/app/create.ts | 7 ++-- cli/src/commands/app/dev.ts | 6 +-- cli/src/commands/app/index.test.ts | 1 + cli/src/commands/app/index.ts | 2 +- cli/src/commands/app/lib/dev-proxy.test.ts | 1 + cli/src/commands/app/lib/dev-proxy.ts | 1 + cli/src/commands/app/lib/distribution.test.ts | 1 + cli/src/commands/app/lib/package.test.ts | 1 + cli/src/commands/app/lib/package.ts | 3 +- cli/src/commands/app/lib/remote.ts | 1 + cli/src/commands/app/lib/run.ts | 3 +- cli/src/commands/app/lib/scope.test.ts | 1 + cli/src/commands/app/lib/scope.ts | 3 +- cli/src/commands/app/lint.test.ts | 1 + cli/src/commands/app/lint.ts | 19 ++++----- cli/src/commands/app/publish.ts | 12 +++--- cli/src/commands/app/remote.test.ts | 1 + cli/src/commands/app/update.ts | 14 +++---- cli/src/commands/app/upgrade.test.ts | 1 + cli/src/commands/app/upgrade.ts | 11 +++--- cli/src/commands/app/validate.ts | 12 +++--- cli/src/commands/append/index.test.ts | 1 + cli/src/commands/append/index.ts | 8 ++-- cli/src/commands/append/lib/data.test.ts | 1 + cli/src/commands/append/lib/data.ts | 1 + cli/src/commands/append/status.ts | 2 +- cli/src/commands/catalogs/create.ts | 4 +- cli/src/commands/catalogs/index.ts | 4 +- cli/src/commands/completion/generate.ts | 4 +- cli/src/commands/completion/index.test.ts | 5 ++- cli/src/commands/completion/index.ts | 2 +- cli/src/commands/completion/install.ts | 4 +- cli/src/commands/completion/lib/completion.ts | 15 +++---- cli/src/commands/completion/lib/spec.test.ts | 21 +++++----- cli/src/commands/completion/lib/spec.ts | 10 ++--- cli/src/commands/doctor/index.test.ts | 5 ++- cli/src/commands/doctor/index.ts | 6 +-- cli/src/commands/doctor/lib/checks.ts | 36 ++++++++--------- cli/src/commands/doctor/lib/render.test.ts | 1 + cli/src/commands/doctor/lib/runner.test.ts | 3 +- cli/src/commands/doctor/lib/runner.ts | 2 +- cli/src/commands/duckdb/index.test.ts | 1 + cli/src/commands/duckdb/index.ts | 15 +++---- cli/src/commands/index.ts | 18 ++++----- cli/src/commands/login/index.test.ts | 7 ++-- cli/src/commands/login/index.ts | 22 +++++------ cli/src/commands/logout/index.test.ts | 3 +- cli/src/commands/logout/index.ts | 2 +- cli/src/commands/profile/configure.ts | 4 +- cli/src/commands/profile/current.ts | 4 +- cli/src/commands/profile/delete.ts | 6 +-- cli/src/commands/profile/env.ts | 6 +-- cli/src/commands/profile/index.ts | 2 +- cli/src/commands/profile/lib/profile.ts | 8 ++-- cli/src/commands/profile/list.ts | 4 +- cli/src/commands/profile/rename.ts | 4 +- cli/src/commands/profile/show.ts | 8 ++-- cli/src/commands/profile/status.ts | 10 ++--- cli/src/commands/profile/switch.ts | 10 ++--- cli/src/commands/query/cancel.ts | 4 +- cli/src/commands/query/index.test.ts | 1 + cli/src/commands/query/index.ts | 18 ++++----- cli/src/commands/query/show.ts | 4 +- cli/src/commands/schema/index.test.ts | 3 +- cli/src/commands/schema/index.ts | 8 ++-- cli/src/commands/schema/lib/views.ts | 4 +- cli/src/commands/update/index.ts | 8 ++-- cli/src/commands/upload/index.test.ts | 1 + cli/src/commands/upload/index.ts | 6 +-- cli/src/commands/upsert/index.test.ts | 1 + cli/src/commands/upsert/index.ts | 6 +-- cli/src/context.ts | 12 +++--- cli/src/lib/auth.test.ts | 3 +- cli/src/lib/auth.ts | 4 +- cli/src/lib/cli-session.test.ts | 5 ++- cli/src/lib/command-descriptor.test.ts | 3 +- cli/src/lib/command-output.test.ts | 1 + cli/src/lib/command-output.ts | 4 +- cli/src/lib/command-parser.ts | 4 +- cli/src/lib/command.test.ts | 3 +- cli/src/lib/config-files.ts | 3 +- cli/src/lib/config.test.ts | 1 + cli/src/lib/config.ts | 4 +- cli/src/lib/early-bootstrap.test.ts | 1 + cli/src/lib/env.test.ts | 1 + cli/src/lib/errors.test.ts | 11 +++--- cli/src/lib/global-flags.test.ts | 3 +- cli/src/lib/global-flags.ts | 2 +- cli/src/lib/http-headers.test.ts | 1 + cli/src/lib/http-request.test.ts | 3 +- cli/src/lib/http-request.ts | 16 ++++---- cli/src/lib/http.test.ts | 13 ++++--- cli/src/lib/http.ts | 17 ++++---- cli/src/lib/lakehouse-provision.test.ts | 3 +- cli/src/lib/lakehouse-provision.ts | 2 +- cli/src/lib/lakehouse-transport.ts | 2 +- cli/src/lib/lakehouse/args.test.ts | 1 + cli/src/lib/lakehouse/args.ts | 1 + cli/src/lib/lakehouse/file.ts | 1 + cli/src/lib/lakehouse/query.test.ts | 21 +++++----- cli/src/lib/lakehouse/query.ts | 10 ++--- cli/src/lib/management-output.test.ts | 1 + cli/src/lib/management-output.ts | 2 +- cli/src/lib/management/auth.test.ts | 9 +++-- cli/src/lib/management/model.test.ts | 1 + cli/src/lib/management/render.test.ts | 3 +- cli/src/lib/oauth-flow.test.ts | 5 ++- cli/src/lib/oauth-flow.ts | 12 +++--- cli/src/lib/oauth-profile.test.ts | 17 ++++---- cli/src/lib/oauth-profile.ts | 4 +- cli/src/lib/openapi-spec.test.ts | 1 + cli/src/lib/openapi-spec.ts | 3 +- cli/src/lib/pager.test.ts | 3 +- cli/src/lib/pager.ts | 3 +- cli/src/lib/pluralize.test.ts | 1 + cli/src/lib/profile-configure-core.test.ts | 1 + cli/src/lib/profile-configure-core.ts | 19 ++++----- ...rofile-configure-credential-status.test.ts | 5 ++- .../lib/profile-configure-data-plane.test.ts | 3 +- cli/src/lib/profile-configure-interactive.ts | 4 +- cli/src/lib/profile-configure.test.ts | 21 +++++----- cli/src/lib/profile-configure.ts | 24 ++++++------ cli/src/lib/profile-status.test.ts | 7 ++-- cli/src/lib/profile-status.ts | 2 +- cli/src/lib/profile-store.test.ts | 3 +- cli/src/lib/profile-store.ts | 3 +- cli/src/lib/profile/model.test.ts | 21 +++++----- cli/src/lib/profile/model.ts | 21 +++++----- cli/src/lib/profile/render.test.ts | 1 + cli/src/lib/profile/render.ts | 12 +++--- cli/src/lib/profile/views.test.ts | 1 + cli/src/lib/profile/views.ts | 20 +++++----- cli/src/lib/progress.test.ts | 1 + cli/src/lib/query-column-types.test.ts | 1 + cli/src/lib/query-destination.test.ts | 1 + cli/src/lib/query-format.test.ts | 5 ++- cli/src/lib/query-format.ts | 14 +++---- cli/src/lib/query-output-args.test.ts | 1 + cli/src/lib/query-output-args.ts | 10 ++--- cli/src/lib/query-output.ts | 16 ++++---- cli/src/lib/redact.test.ts | 1 + cli/src/lib/relative-time.test.ts | 1 + cli/src/lib/runtime.ts | 1 + cli/src/lib/secrets.test.ts | 7 ++-- cli/src/lib/secrets.ts | 3 +- cli/src/lib/service-account-provision.test.ts | 1 + cli/src/lib/stream-lines.test.ts | 1 + cli/src/lib/tabular-result.ts | 2 +- cli/src/lib/updater-config.ts | 2 +- cli/src/lib/updater.test.ts | 13 ++++--- cli/src/lib/updater.ts | 15 +++---- cli/src/lib/url-policy.test.ts | 1 + cli/src/lib/usage.test.ts | 19 ++++----- cli/src/lib/usage.ts | 16 ++++---- cli/src/lib/user-directories.ts | 1 + cli/src/test-utils/lakehouse.ts | 3 +- cli/src/ui/prompts.ts | 4 +- cli/src/ui/renderers/terminal.ts | 2 +- cli/src/ui/shell/render.test.ts | 1 + cli/src/ui/terminal/styles.test.ts | 3 +- cli/src/ui/terminal/styles.ts | 1 - cli/src/ui/terminal/table.test.ts | 5 ++- cli/src/ui/terminal/table.ts | 10 ++--- data-app/starter/.oxfmtrc.json | 4 +- data-app/starter/src/App.tsx | 1 + data-app/starter/src/config.ts | 1 + data-app/starter/src/data-context.ts | 1 + data-app/starter/src/main.tsx | 1 + data-app/starter/src/operations.ts | 1 + data-app/starter/src/preview.ts | 1 + data-app/starter/src/server.ts | 5 ++- data-app/tests/server.ts | 11 +++--- data-app/tests/starter.spec.ts | 3 +- 201 files changed, 681 insertions(+), 545 deletions(-) diff --git a/cli/.oxfmtrc.json b/cli/.oxfmtrc.json index f462b1a7..e57a5994 100644 --- a/cli/.oxfmtrc.json +++ b/cli/.oxfmtrc.json @@ -5,5 +5,7 @@ "trailingComma": "es5", "arrowParens": "avoid", "printWidth": 80, - "endOfLine": "lf" + "endOfLine": "lf", + "sortImports": true, + "sortPackageJson": false } diff --git a/cli/scripts/check-data-app.ts b/cli/scripts/check-data-app.ts index 941858f7..1f24152c 100644 --- a/cli/scripts/check-data-app.ts +++ b/cli/scripts/check-data-app.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; + import { dataAppDirectory } from '@/commands/app/lib/distribution.ts'; for (const project of ['starter', 'tests']) { diff --git a/cli/scripts/check-package.ts b/cli/scripts/check-package.ts index 0e36759f..e171f951 100644 --- a/cli/scripts/check-package.ts +++ b/cli/scripts/check-package.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { smokeNpmBundle } from '@/../scripts/smoke-npm-bundle.ts'; const packageDirectory = join(import.meta.dir, '..'); diff --git a/cli/scripts/command-reference.test.ts b/cli/scripts/command-reference.test.ts index 56adb75d..3f4736c5 100644 --- a/cli/scripts/command-reference.test.ts +++ b/cli/scripts/command-reference.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; + +import { + buildCommandReference, + renderCliReferenceSchema, + renderCommandReferenceJson, + renderCommandReferenceMarkdown, +} from '@/../scripts/command-reference.ts'; import { buildMainCommand } from '@/cli.ts'; import { resolveCommandDescriptor, @@ -13,12 +20,6 @@ import { POSITIONAL_COMPLETION_KINDS, } from '@/lib/command.ts'; import { VERSION } from '@/version.ts'; -import { - buildCommandReference, - renderCliReferenceSchema, - renderCommandReferenceJson, - renderCommandReferenceMarkdown, -} from '@/../scripts/command-reference.ts'; async function resolveReference() { const descriptor = await resolveCommandDescriptor(buildMainCommand()); diff --git a/cli/scripts/command-reference.ts b/cli/scripts/command-reference.ts index cc8d0c75..89d3fe2c 100644 --- a/cli/scripts/command-reference.ts +++ b/cli/scripts/command-reference.ts @@ -1,3 +1,9 @@ +import { + aliasFlagToken, + visibleCommandDescriptors, + type CommandArgumentDescriptor, + type CommandDescriptor, +} from '@/lib/command-descriptor.ts'; import { ALTERTABLE_COMMAND_GROUPS, COMMAND_ARGUMENT_TYPES, @@ -5,12 +11,6 @@ import { POSITIONAL_COMPLETION_KINDS, type AltertableCommandGroup, } from '@/lib/command.ts'; -import { - aliasFlagToken, - visibleCommandDescriptors, - type CommandArgumentDescriptor, - type CommandDescriptor, -} from '@/lib/command-descriptor.ts'; export const CLI_REFERENCE_SCHEMA_VERSION = 1; diff --git a/cli/scripts/generate-command-reference.ts b/cli/scripts/generate-command-reference.ts index 737e2004..80d924f8 100644 --- a/cli/scripts/generate-command-reference.ts +++ b/cli/scripts/generate-command-reference.ts @@ -1,10 +1,5 @@ import { join } from 'node:path'; -import { buildMainCommand } from '@/cli.ts'; -import { - resolveCommandDescriptor, - validateCommandDescriptor, -} from '@/lib/command-descriptor.ts'; -import { VERSION } from '@/version.ts'; + import { buildCommandReference, renderCliReferenceSchema, @@ -15,6 +10,12 @@ import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, } from '@/../scripts/generated-artifact.ts'; +import { buildMainCommand } from '@/cli.ts'; +import { + resolveCommandDescriptor, + validateCommandDescriptor, +} from '@/lib/command-descriptor.ts'; +import { VERSION } from '@/version.ts'; const descriptor = await resolveCommandDescriptor(buildMainCommand()); validateCommandDescriptor(descriptor); diff --git a/cli/scripts/generate-openapi-index.ts b/cli/scripts/generate-openapi-index.ts index bbbc45e7..004823ad 100644 --- a/cli/scripts/generate-openapi-index.ts +++ b/cli/scripts/generate-openapi-index.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; + import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, diff --git a/cli/scripts/generate-openapi-types.ts b/cli/scripts/generate-openapi-types.ts index b8c1ff87..08ab0999 100644 --- a/cli/scripts/generate-openapi-types.ts +++ b/cli/scripts/generate-openapi-types.ts @@ -1,6 +1,8 @@ -import openapiTS, { astToString } from 'openapi-typescript'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; + +import openapiTS, { astToString } from 'openapi-typescript'; + import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, diff --git a/cli/scripts/generated-artifact.test.ts b/cli/scripts/generated-artifact.test.ts index add2e617..84633230 100644 --- a/cli/scripts/generated-artifact.test.ts +++ b/cli/scripts/generated-artifact.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { parseGeneratedArtifactMode, updateOrCheckGeneratedArtifact, diff --git a/cli/scripts/npm-bundle.ts b/cli/scripts/npm-bundle.ts index f7bd4754..ac91a570 100644 --- a/cli/scripts/npm-bundle.ts +++ b/cli/scripts/npm-bundle.ts @@ -1,5 +1,6 @@ import { mkdir } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; + import { dataAppPlugin } from '@/../scripts/package-data-app.ts'; export const NPM_BUNDLE_OPTIONS = { diff --git a/cli/scripts/package-data-app.ts b/cli/scripts/package-data-app.ts index 8aaa660d..95820707 100644 --- a/cli/scripts/package-data-app.ts +++ b/cli/scripts/package-data-app.ts @@ -1,4 +1,5 @@ import type { BunPlugin } from 'bun'; + import { readDataAppPayload } from '@/commands/app/lib/distribution.ts'; /** Both npm and native releases embed the same deterministic starter template. */ diff --git a/cli/scripts/publish-release.ts b/cli/scripts/publish-release.ts index 0bfabb3d..08b35835 100644 --- a/cli/scripts/publish-release.ts +++ b/cli/scripts/publish-release.ts @@ -1,4 +1,5 @@ import { basename, join, resolve } from 'node:path'; + import { isRecord } from '../src/lib/object.ts'; export type NpmVersionLookup = diff --git a/cli/scripts/release.test.ts b/cli/scripts/release.test.ts index 8979044a..96dd89b6 100644 --- a/cli/scripts/release.test.ts +++ b/cli/scripts/release.test.ts @@ -2,23 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { - assertReleaseTag, - assertToolchain, - buildJavaScriptBundle, - compileCommand, - expectedReleaseTag, - HARDENED_COMPILE_FLAGS, - JAVASCRIPT_BUNDLE_OPTIONS, - nativeReleaseTarget, - readToolchainContract, - RELEASE_MANIFEST_SCHEMA_VERSION, - darwinSignatureCommand, - signatureVerificationCommand, - stageCommandReferenceAssets, - SUPPORTED_BUN_RUNTIME_RANGE, - writeReleaseMetadata, -} from '@/../scripts/release.ts'; + import { NPM_BUNDLE_PATH } from '@/../scripts/npm-bundle.ts'; import { githubReleasePublishCommand, @@ -32,8 +16,6 @@ import { readReleaseAssetPaths, SETUP_NODE_AUTH_TOKEN_PLACEHOLDER, } from '@/../scripts/publish-release.ts'; -import { UpdaterConfig } from '@/lib/updater-config.ts'; -import { releaseAssetName } from '@/lib/updater.ts'; import { RELEASE_BUNDLE_ASSET, RELEASE_CHECKSUMS_ASSET, @@ -43,6 +25,25 @@ import { RELEASE_TARGETS, releaseCiMatrix, } from '@/../scripts/release-manifest.ts'; +import { + assertReleaseTag, + assertToolchain, + buildJavaScriptBundle, + compileCommand, + expectedReleaseTag, + HARDENED_COMPILE_FLAGS, + JAVASCRIPT_BUNDLE_OPTIONS, + nativeReleaseTarget, + readToolchainContract, + RELEASE_MANIFEST_SCHEMA_VERSION, + darwinSignatureCommand, + signatureVerificationCommand, + stageCommandReferenceAssets, + SUPPORTED_BUN_RUNTIME_RANGE, + writeReleaseMetadata, +} from '@/../scripts/release.ts'; +import { UpdaterConfig } from '@/lib/updater-config.ts'; +import { releaseAssetName } from '@/lib/updater.ts'; import { VERSION } from '@/version.ts'; const temporaryDirectories: string[] = []; diff --git a/cli/scripts/release.ts b/cli/scripts/release.ts index 83cc2751..b084b9ec 100644 --- a/cli/scripts/release.ts +++ b/cli/scripts/release.ts @@ -1,14 +1,13 @@ import { randomUUID } from 'node:crypto'; import { copyFile, mkdir, rename, stat } from 'node:fs/promises'; import { join } from 'node:path'; -import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; -import packageJson from '../package.json' with { type: 'json' }; + +import { CLI_REFERENCE_SCHEMA_VERSION } from '@/../scripts/command-reference.ts'; import { buildNpmBundle, NPM_BUNDLE_OPTIONS, NPM_BUNDLE_PATH, } from '@/../scripts/npm-bundle.ts'; -import { VERSION } from '@/version.ts'; import { findReleaseTargetByBunTarget, findReleaseTargetByPlatform, @@ -21,7 +20,10 @@ import { releaseCiMatrix, type ReleaseTarget, } from '@/../scripts/release-manifest.ts'; -import { CLI_REFERENCE_SCHEMA_VERSION } from '@/../scripts/command-reference.ts'; +import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; +import { VERSION } from '@/version.ts'; + +import packageJson from '../package.json' with { type: 'json' }; export const RELEASE_MANIFEST_SCHEMA_VERSION = 3; export const SUPPORTED_BUN_RUNTIME_RANGE = '>=1.1.0'; diff --git a/cli/scripts/script-output.test.ts b/cli/scripts/script-output.test.ts index 25b567e2..630f1f4f 100644 --- a/cli/scripts/script-output.test.ts +++ b/cli/scripts/script-output.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; + import { renderQueryMarkdown } from '@/lib/query-format.ts'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; describe('script output lossless guarantees', () => { test('CSV round-trips cells with commas, quotes, newlines, and Unicode', () => { diff --git a/cli/scripts/smoke-npm-bundle.ts b/cli/scripts/smoke-npm-bundle.ts index 9a2a2ea5..7110a392 100644 --- a/cli/scripts/smoke-npm-bundle.ts +++ b/cli/scripts/smoke-npm-bundle.ts @@ -1,6 +1,7 @@ -import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; import { join } from 'node:path'; +import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; + const repositoryRoot = join(import.meta.dir, '../..'); const defaultBundlePath = join(repositoryRoot, 'cli/dist/cli.js'); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 1d862ab4..e706f18e 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,17 +1,18 @@ #!/usr/bin/env bun -import { VERSION } from '@/version.ts'; +import { buildTopLevelCommands } from '@/commands/index.ts'; import { getCliContext, getBootstrapCliContext, isJsonOutput, setCliContext, } from '@/context.ts'; -import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; import { - mergeGlobalFlagsFromArgs, - parseGlobalOutputFlags, -} from '@/lib/global-flags.ts'; -import { buildTopLevelCommands } from '@/commands/index.ts'; + executeCommand, + resolveCommandSelection, +} from '@/lib/command-parser.ts'; +import { defineArguments, defineCommand, type Command } from '@/lib/command.ts'; +import { findEarlyBootstrapExit } from '@/lib/early-bootstrap.ts'; +import { validateEnvironment } from '@/lib/env.ts'; import { CliError, EXIT_SUCCESS, @@ -20,24 +21,23 @@ import { shouldShowCommandExamplesOnError, } from '@/lib/errors.ts'; import { - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, -} from '@/ui/error.ts'; -import { defineArguments, defineCommand, type Command } from '@/lib/command.ts'; -import { - executeCommand, - resolveCommandSelection, -} from '@/lib/command-parser.ts'; + mergeGlobalFlagsFromArgs, + parseGlobalOutputFlags, +} from '@/lib/global-flags.ts'; +import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; +import { maybeShowUpdateNotice } from '@/lib/updater.ts'; import { resolveSubCommandForUsage, showAltertableUsage, showCommandExamplesForArgs, } from '@/lib/usage.ts'; -import { findEarlyBootstrapExit } from '@/lib/early-bootstrap.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; import { applyTerminalColorFromContext } from '@/ui/terminal/styles.ts'; -import { maybeShowUpdateNotice } from '@/lib/updater.ts'; -import { validateEnvironment } from '@/lib/env.ts'; +import { VERSION } from '@/version.ts'; const ROOT_ARGS = defineArguments({ help: { diff --git a/cli/src/commands/api/index.test.ts b/cli/src/commands/api/index.test.ts index 73ae30a1..fe89d335 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -1,20 +1,21 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { buildMainCommand } from '@/cli.ts'; import { apiCommand } from '@/commands/api/index.ts'; -import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; -import { setCliContext } from '@/context.ts'; import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; +import { setCliContext } from '@/context.ts'; +import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; +import { executeCommand } from '@/lib/command-parser.ts'; +import type { Command } from '@/lib/command.ts'; import { createCliRuntime, getCliRuntime, setCliRuntime, } from '@/lib/runtime.ts'; import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; -import type { Command } from '@/lib/command.ts'; -import { executeCommand } from '@/lib/command-parser.ts'; describe('api', () => { beforeEach(() => { diff --git a/cli/src/commands/api/index.ts b/cli/src/commands/api/index.ts index b52b953b..94232a64 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -1,15 +1,15 @@ -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; -import { - executeApiHttp, - apiHttpResultOutput, -} from '@/commands/api/lib/http.ts'; import { API_HTTP_BASE_ARGS, resolveApiCommandRequest, } from '@/commands/api/lib/command.ts'; -import { apiSpecCommand } from '@/commands/api/spec.ts'; +import { + executeApiHttp, + apiHttpResultOutput, +} from '@/commands/api/lib/http.ts'; import { apiRouteCommand, apiRoutesCommand } from '@/commands/api/routes.ts'; +import { apiSpecCommand } from '@/commands/api/spec.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; export const apiCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/api/lib/body.ts b/cli/src/commands/api/lib/body.ts index 2c6b9014..105f6d38 100644 --- a/cli/src/commands/api/lib/body.ts +++ b/cli/src/commands/api/lib/body.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; + import { CliError, ParseError } from '@/lib/errors.ts'; const BODY_METHOD_NAMES = ['POST', 'PATCH', 'PUT'] as const; diff --git a/cli/src/commands/api/lib/http-conformance.test.ts b/cli/src/commands/api/lib/http-conformance.test.ts index 8e0f3c82..ebb70a44 100644 --- a/cli/src/commands/api/lib/http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; -import { setCliContext } from '@/context.ts'; +import { join } from 'node:path'; + import { executeApiHttp, resolveApiHttp } from '@/commands/api/lib/http.ts'; +import { setCliContext } from '@/context.ts'; +import { OPENAPI_OPERATIONS } from '@/generated/openapi-operations.ts'; import { createExecutionContext } from '@/lib/execution-context.ts'; import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; diff --git a/cli/src/commands/api/lib/http.test.ts b/cli/src/commands/api/lib/http.test.ts index 44340a14..72b1ec0d 100644 --- a/cli/src/commands/api/lib/http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { setCliContext } from '@/context.ts'; +import { join } from 'node:path'; + import { readJsonInput, resolveApiRequestPayload, @@ -14,10 +14,11 @@ import { resolveApiHttp, type ApiHttpArgs, } from '@/commands/api/lib/http.ts'; +import { setCliContext } from '@/context.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { ParseError } from '@/lib/errors.ts'; import { createExecutionContext } from '@/lib/execution-context.ts'; import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; -import { ParseError } from '@/lib/errors.ts'; let testHome = ''; let mockFile = ''; diff --git a/cli/src/commands/api/lib/http.ts b/cli/src/commands/api/lib/http.ts index 2e40da65..0110063e 100644 --- a/cli/src/commands/api/lib/http.ts +++ b/cli/src/commands/api/lib/http.ts @@ -5,11 +5,11 @@ import { } from '@/commands/api/lib/body.ts'; import type { CommandOutputMode } from '@/lib/command-output.ts'; import { CliError } from '@/lib/errors.ts'; -import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; -import { parseManagementOutputFormat } from '@/lib/tabular-result.ts'; -import type { OutputSink } from '@/lib/runtime.ts'; import type { ExecutionContext } from '@/lib/execution-context.ts'; import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; +import { parseManagementOutputFormat } from '@/lib/tabular-result.ts'; export type ApiHttpArgs = { method?: string; diff --git a/cli/src/commands/api/lib/views.test.ts b/cli/src/commands/api/lib/views.test.ts index b3477195..872c2468 100644 --- a/cli/src/commands/api/lib/views.test.ts +++ b/cli/src/commands/api/lib/views.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from 'bun:test'; + import { buildApiOperationDetailsView } from '@/commands/api/lib/views.ts'; import { renderDocumentText } from '@/ui/renderers/terminal.ts'; import { setTerminalColorMode } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/commands/api/routes.ts b/cli/src/commands/api/routes.ts index 91cb5204..85906789 100644 --- a/cli/src/commands/api/routes.ts +++ b/cli/src/commands/api/routes.ts @@ -1,6 +1,3 @@ -import { optionalStringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; import { apiOperationDetails, apiOperationsJson, @@ -10,6 +7,9 @@ import { formatApiOperationDetails, formatApiRoutes, } from '@/commands/api/lib/render.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import type { OutputSink } from '@/lib/runtime.ts'; export const apiRouteCommand = defineCommand({ diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts index 83f08723..6ffb0b8a 100644 --- a/cli/src/commands/api/spec.ts +++ b/cli/src/commands/api/spec.ts @@ -1,6 +1,6 @@ -import { getOpenapiSpecJson, getOpenapiSpecYaml } from '@/lib/openapi-spec.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { getOpenapiSpecJson, getOpenapiSpecYaml } from '@/lib/openapi-spec.ts'; import type { OutputSink } from '@/lib/runtime.ts'; export const apiSpecCommand = defineCommand({ diff --git a/cli/src/commands/app/build.ts b/cli/src/commands/app/build.ts index e57b57d7..c119b5a9 100644 --- a/cli/src/commands/app/build.ts +++ b/cli/src/commands/app/build.ts @@ -1,12 +1,13 @@ import { join } from 'node:path'; -import { defineCommand } from '@/lib/command.ts'; -import { EXIT_GENERIC } from '@/lib/errors.ts'; + import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, } from '@/commands/app/lib/run.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { EXIT_GENERIC } from '@/lib/errors.ts'; export const appBuildCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/create.test.ts b/cli/src/commands/app/create.test.ts index 102f3d2a..bd1ca97b 100644 --- a/cli/src/commands/app/create.test.ts +++ b/cli/src/commands/app/create.test.ts @@ -8,14 +8,15 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, posix } from 'node:path'; + import { readDataAppPayload } from '@/commands/app/lib/distribution.ts'; -import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { recommendedDataAppVersion } from '@/commands/app/lib/package.ts'; import { configSet, ensureProfileExists, setActiveProfile, } from '@/lib/profile-store.ts'; +import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; let home: string; let previousConfigHome: string | undefined; diff --git a/cli/src/commands/app/create.ts b/cli/src/commands/app/create.ts index eff9bb23..15e69a3c 100644 --- a/cli/src/commands/app/create.ts +++ b/cli/src/commands/app/create.ts @@ -1,11 +1,12 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; + +import { createAppFiles } from '@/commands/app/lib/distribution.ts'; +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; import { defineCommand } from '@/lib/command.ts'; -import { CliError } from '@/lib/errors.ts'; import { readEnv } from '@/lib/env.ts'; +import { CliError } from '@/lib/errors.ts'; import { configGet } from '@/lib/profile-store.ts'; -import { createAppFiles } from '@/commands/app/lib/distribution.ts'; -import { dataAppPayload } from '@/commands/app/lib/payload.ts'; export const appCreateCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 6f8d7d2f..ed602a0d 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,6 +1,3 @@ -import { checkAppScope } from '@/commands/app/lib/scope.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { CliError, EXIT_GENERIC } from '@/lib/errors.ts'; import { startAppDevProxy } from '@/commands/app/lib/dev-proxy.ts'; import { appDirectory, @@ -8,6 +5,9 @@ import { requireAppScripts, runAppCommand, } from '@/commands/app/lib/run.ts'; +import { checkAppScope } from '@/commands/app/lib/scope.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError, EXIT_GENERIC } from '@/lib/errors.ts'; export const appDevCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/index.test.ts b/cli/src/commands/app/index.test.ts index 74aac154..bd5008f1 100644 --- a/cli/src/commands/app/index.test.ts +++ b/cli/src/commands/app/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/app/index.ts b/cli/src/commands/app/index.ts index 954ba9ca..fcdcf177 100644 --- a/cli/src/commands/app/index.ts +++ b/cli/src/commands/app/index.ts @@ -1,4 +1,3 @@ -import { defineCommand } from '@/lib/command.ts'; import { appBuildCommand } from '@/commands/app/build.ts'; import { appCreateCommand } from '@/commands/app/create.ts'; import { appDevCommand } from '@/commands/app/dev.ts'; @@ -7,6 +6,7 @@ import { appPublishCommand } from '@/commands/app/publish.ts'; import { appUpdateCommand } from '@/commands/app/update.ts'; import { appUpgradeCommand } from '@/commands/app/upgrade.ts'; import { appValidateCommand } from '@/commands/app/validate.ts'; +import { defineCommand } from '@/lib/command.ts'; export const appCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/lib/dev-proxy.test.ts b/cli/src/commands/app/lib/dev-proxy.test.ts index f6efa7ef..59f71da9 100644 --- a/cli/src/commands/app/lib/dev-proxy.test.ts +++ b/cli/src/commands/app/lib/dev-proxy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { startAppDevProxy } from '@/commands/app/lib/dev-proxy.ts'; import type { ExecutionContext } from '@/lib/execution-context.ts'; import type { sendHttpStream } from '@/lib/http-request.ts'; diff --git a/cli/src/commands/app/lib/dev-proxy.ts b/cli/src/commands/app/lib/dev-proxy.ts index 5883d974..78904fca 100644 --- a/cli/src/commands/app/lib/dev-proxy.ts +++ b/cli/src/commands/app/lib/dev-proxy.ts @@ -1,4 +1,5 @@ import { randomBytes, timingSafeEqual } from 'node:crypto'; + import { ConfigurationError, HttpError } from '@/lib/errors.ts'; import type { ExecutionContext } from '@/lib/execution-context.ts'; import { sendHttpStream } from '@/lib/http-request.ts'; diff --git a/cli/src/commands/app/lib/distribution.test.ts b/cli/src/commands/app/lib/distribution.test.ts index 2a23bc33..f793392a 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -3,6 +3,7 @@ import { rejects } from 'node:assert/strict'; import { cp, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, posix } from 'node:path'; + import { createAppFiles, dataAppDirectory, diff --git a/cli/src/commands/app/lib/package.test.ts b/cli/src/commands/app/lib/package.test.ts index b1c71d11..ea0808d8 100644 --- a/cli/src/commands/app/lib/package.test.ts +++ b/cli/src/commands/app/lib/package.test.ts @@ -3,6 +3,7 @@ import { rejects } from 'node:assert/strict'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { dataAppPackage, dataAppPackages, diff --git a/cli/src/commands/app/lib/package.ts b/cli/src/commands/app/lib/package.ts index e248ca8f..918265e9 100644 --- a/cli/src/commands/app/lib/package.ts +++ b/cli/src/commands/app/lib/package.ts @@ -1,8 +1,9 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; + +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; import { ConfigurationError } from '@/lib/errors.ts'; import { isRecord } from '@/lib/object.ts'; -import { dataAppPayload } from '@/commands/app/lib/payload.ts'; export const dataAppPackage = '@altertable/data-app'; export const dataAppPackages = [ diff --git a/cli/src/commands/app/lib/remote.ts b/cli/src/commands/app/lib/remote.ts index 03ca5b0e..7d9bb174 100644 --- a/cli/src/commands/app/lib/remote.ts +++ b/cli/src/commands/app/lib/remote.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; + import { CliError, EXIT_VALIDATION } from '@/lib/errors.ts'; import type { HttpRequest } from '@/lib/http-request.ts'; diff --git a/cli/src/commands/app/lib/run.ts b/cli/src/commands/app/lib/run.ts index 889a03a0..2fc9973d 100644 --- a/cli/src/commands/app/lib/run.ts +++ b/cli/src/commands/app/lib/run.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { CliError, ConfigurationError } from '@/lib/errors.ts'; + import { copyProcessEnv } from '@/lib/env.ts'; +import { CliError, ConfigurationError } from '@/lib/errors.ts'; export type AppScript = 'typecheck' | 'lint' | 'format:check' | 'dev' | 'build'; export type AppCommand = 'install' | 'install-frozen' | AppScript; diff --git a/cli/src/commands/app/lib/scope.test.ts b/cli/src/commands/app/lib/scope.test.ts index dad5a165..59653e86 100644 --- a/cli/src/commands/app/lib/scope.test.ts +++ b/cli/src/commands/app/lib/scope.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'bun:test'; + import { assertAppScope } from '@/commands/app/lib/scope.ts'; test('live apps require the selected organization and environment before querying', () => { diff --git a/cli/src/commands/app/lib/scope.ts b/cli/src/commands/app/lib/scope.ts index 5dcec065..383fcd4b 100644 --- a/cli/src/commands/app/lib/scope.ts +++ b/cli/src/commands/app/lib/scope.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; + import { configGet } from '@/lib/config.ts'; import { readEnv } from '@/lib/env.ts'; -import { isFromEnvProfile } from '@/lib/profile-store.ts'; import { ConfigurationError } from '@/lib/errors.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; export function assertAppScope( scope: unknown, diff --git a/cli/src/commands/app/lint.test.ts b/cli/src/commands/app/lint.test.ts index 193baeba..76498315 100644 --- a/cli/src/commands/app/lint.test.ts +++ b/cli/src/commands/app/lint.test.ts @@ -8,6 +8,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { checkAppProject, checkClientBundle } from '@/commands/app/lint.ts'; let directory: string; diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 17c2a118..0e6df8e3 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -1,20 +1,21 @@ -import { checkAppScope } from '@/commands/app/lib/scope.ts'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { defineCommand } from '@/lib/command.ts'; -import { getLakehouseCredentialPair } from '@/lib/auth.ts'; -import { resolveApiBase } from '@/lib/config.ts'; -import { ConfigurationError, EXIT_GENERIC } from '@/lib/errors.ts'; -import { copyProcessEnv } from '@/lib/env.ts'; -import { configureVerify } from '@/lib/profile-status.ts'; + +import { requirePublishedDataApp } from '@/commands/app/lib/package.ts'; +import projectCheckScript from '@/commands/app/lib/project-check.js.txt'; import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, } from '@/commands/app/lib/run.ts'; -import { requirePublishedDataApp } from '@/commands/app/lib/package.ts'; -import projectCheckScript from '@/commands/app/lib/project-check.js.txt'; +import { checkAppScope } from '@/commands/app/lib/scope.ts'; +import { getLakehouseCredentialPair } from '@/lib/auth.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { resolveApiBase } from '@/lib/config.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; +import { ConfigurationError, EXIT_GENERIC } from '@/lib/errors.ts'; +import { configureVerify } from '@/lib/profile-status.ts'; type AppManifest = { schemaVersion: 1; diff --git a/cli/src/commands/app/publish.ts b/cli/src/commands/app/publish.ts index ad12ce0e..bd36e403 100644 --- a/cli/src/commands/app/publish.ts +++ b/cli/src/commands/app/publish.ts @@ -1,14 +1,14 @@ -import { optionalStringArg, stringArg } from '@/lib/args.ts'; -import { requireManagementPlane } from '@/lib/auth.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; -import { sendHttp } from '@/lib/http-request.ts'; -import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, readRequiredFile, type PublishedDataApp, } from '@/commands/app/lib/remote.ts'; +import { optionalStringArg, stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; export const appPublishCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/remote.test.ts b/cli/src/commands/app/remote.test.ts index 247912e9..bcbde047 100644 --- a/cli/src/commands/app/remote.test.ts +++ b/cli/src/commands/app/remote.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/app/update.ts b/cli/src/commands/app/update.ts index 2b78d5dc..d32d9c59 100644 --- a/cli/src/commands/app/update.ts +++ b/cli/src/commands/app/update.ts @@ -1,16 +1,16 @@ -import { optionalStringArg, stringArg } from '@/lib/args.ts'; -import { requireManagementPlane } from '@/lib/auth.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; -import { CliError } from '@/lib/errors.ts'; -import { sendHttp } from '@/lib/http-request.ts'; -import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, readRequiredFile, type RemoteDataAppPayload, type PublishedDataApp, } from '@/commands/app/lib/remote.ts'; +import { optionalStringArg, stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; export const appUpdateCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/upgrade.test.ts b/cli/src/commands/app/upgrade.test.ts index eb4c4dba..c48944b3 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from 'bun:test'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; + import { createAppFiles, readDataAppPayload, diff --git a/cli/src/commands/app/upgrade.ts b/cli/src/commands/app/upgrade.ts index 737cd617..16660662 100644 --- a/cli/src/commands/app/upgrade.ts +++ b/cli/src/commands/app/upgrade.ts @@ -1,10 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { defineCommand } from '@/lib/command.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; -import { dataAppPayload } from '@/commands/app/lib/payload.ts'; -import { appDirectory } from '@/commands/app/lib/run.ts'; -import { copyProcessEnv } from '@/lib/env.ts'; + import { dataAppPackage, dataAppPackages, @@ -14,6 +10,11 @@ import { recommendedDataAppPeers, recommendedDataAppVersion, } from '@/commands/app/lib/package.ts'; +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; +import { appDirectory } from '@/commands/app/lib/run.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; export const appUpgradeCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/app/validate.ts b/cli/src/commands/app/validate.ts index c7efccf2..adca95c1 100644 --- a/cli/src/commands/app/validate.ts +++ b/cli/src/commands/app/validate.ts @@ -1,9 +1,3 @@ -import { stringArg } from '@/lib/args.ts'; -import { requireManagementPlane } from '@/lib/auth.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; -import { sendHttp } from '@/lib/http-request.ts'; -import { parseApiJson } from '@/lib/parse-api-json.ts'; import { buildDataAppHttpRequest, formatValidationSummary, @@ -11,6 +5,12 @@ import { validationExitCode, type ValidateDataAppResponse, } from '@/commands/app/lib/remote.ts'; +import { stringArg } from '@/lib/args.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; export const appValidateCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/append/index.test.ts b/cli/src/commands/append/index.test.ts index c6130b01..dfb1e82d 100644 --- a/cli/src/commands/append/index.test.ts +++ b/cli/src/commands/append/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/append/index.ts b/cli/src/commands/append/index.ts index 9a78c24c..a85744c1 100644 --- a/cli/src/commands/append/index.ts +++ b/cli/src/commands/append/index.ts @@ -1,13 +1,13 @@ +import { appendGroupArgs } from '@/commands/append/lib/args.ts'; +import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; +import { appendStatusCommand } from '@/commands/append/status.ts'; import { booleanArg, stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; import { buildLakehouseAppendRequest } from '@/lib/lakehouse-transport.ts'; import { parseLakehouseTarget } from '@/lib/lakehouse/args.ts'; import { startProgress } from '@/lib/progress.ts'; -import { appendStatusCommand } from '@/commands/append/status.ts'; -import { appendGroupArgs } from '@/commands/append/lib/args.ts'; -import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; export const appendCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/append/lib/data.test.ts b/cli/src/commands/append/lib/data.test.ts index b7e66013..e0e82199 100644 --- a/cli/src/commands/append/lib/data.test.ts +++ b/cli/src/commands/append/lib/data.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; import { CliError } from '@/lib/errors.ts'; diff --git a/cli/src/commands/append/lib/data.ts b/cli/src/commands/append/lib/data.ts index e31ae7c7..ebca05e3 100644 --- a/cli/src/commands/append/lib/data.ts +++ b/cli/src/commands/append/lib/data.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; + import { CliError } from '@/lib/errors.ts'; export function parseAppendJsonContent(dataArg: string): string { diff --git a/cli/src/commands/append/status.ts b/cli/src/commands/append/status.ts index 455d64f9..4c65f7e0 100644 --- a/cli/src/commands/append/status.ts +++ b/cli/src/commands/append/status.ts @@ -1,6 +1,6 @@ import { stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; export const appendStatusCommand = defineCommand({ diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts index bc1fd31e..e9c6593e 100644 --- a/cli/src/commands/catalogs/create.ts +++ b/cli/src/commands/catalogs/create.ts @@ -1,7 +1,7 @@ -import { requireManagementPlane } from '@/lib/auth.ts'; import { buildCatalogCreateRequest } from '@/commands/catalogs/lib/requests.ts'; -import { defineCommand } from '@/lib/command.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; export const catalogsCreateCommand = defineCommand({ diff --git a/cli/src/commands/catalogs/index.ts b/cli/src/commands/catalogs/index.ts index fbf409ce..c2096c52 100644 --- a/cli/src/commands/catalogs/index.ts +++ b/cli/src/commands/catalogs/index.ts @@ -1,8 +1,8 @@ -import { defineCommand } from '@/lib/command.ts'; import { catalogsCreateCommand } from '@/commands/catalogs/create.ts'; import { requireManagementPlane } from '@/lib/auth.ts'; -import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; import { formatCatalogsSummary, formatCatalogsTable, diff --git a/cli/src/commands/completion/generate.ts b/cli/src/commands/completion/generate.ts index 30c7cefa..9ce6bc8a 100644 --- a/cli/src/commands/completion/generate.ts +++ b/cli/src/commands/completion/generate.ts @@ -1,11 +1,11 @@ -import { defineCommand, type Command } from '@/lib/command.ts'; -import { stringArg } from '@/lib/args.ts'; import { formatCompletionScript, resolveShell, SUPPORTED_SHELLS, type GetRootCommand, } from '@/commands/completion/lib/completion.ts'; +import { stringArg } from '@/lib/args.ts'; +import { defineCommand, type Command } from '@/lib/command.ts'; export function createGenerateCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index afbdd3ee..fe714406 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { defineCommand, type Command } from '@/lib/command.ts'; -import { executeCommand } from '@/lib/command-parser.ts'; + import { buildMainCommand } from '@/cli.ts'; import { createCompletionCommand } from '@/commands/completion/index.ts'; +import { executeCommand } from '@/lib/command-parser.ts'; +import { defineCommand, type Command } from '@/lib/command.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts index 1baf2614..87ea46de 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -1,4 +1,3 @@ -import { defineCommand, type Command } from '@/lib/command.ts'; import { createGenerateCommand } from '@/commands/completion/generate.ts'; import { createInstallCommand } from '@/commands/completion/install.ts'; import { @@ -6,6 +5,7 @@ import { formatCompletionHelpMessage, type GetRootCommand, } from '@/commands/completion/lib/completion.ts'; +import { defineCommand, type Command } from '@/lib/command.ts'; export function createCompletionCommand( getRootCommand: GetRootCommand diff --git a/cli/src/commands/completion/install.ts b/cli/src/commands/completion/install.ts index 7e5eefea..1c7d4e34 100644 --- a/cli/src/commands/completion/install.ts +++ b/cli/src/commands/completion/install.ts @@ -1,5 +1,3 @@ -import { defineCommand, type Command } from '@/lib/command.ts'; -import { optionalStringArg } from '@/lib/args.ts'; import { formatCompletionScript, formatInstallMessage, @@ -8,6 +6,8 @@ import { SUPPORTED_SHELLS, type GetRootCommand, } from '@/commands/completion/lib/completion.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { defineCommand, type Command } from '@/lib/command.ts'; export function createInstallCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ diff --git a/cli/src/commands/completion/lib/completion.ts b/cli/src/commands/completion/lib/completion.ts index e020f1aa..1c7bb61e 100644 --- a/cli/src/commands/completion/lib/completion.ts +++ b/cli/src/commands/completion/lib/completion.ts @@ -1,8 +1,15 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; + +import { + formatBashCompletion, + formatFishCompletion, + formatZshCompletion, +} from '@/commands/completion/lib/format.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; import type { Command } from '@/lib/command.ts'; -import { CliError } from '@/lib/errors.ts'; import { readEnv } from '@/lib/env.ts'; +import { CliError } from '@/lib/errors.ts'; import { homeDir, configHome, dataHome } from '@/lib/user-directories.ts'; import { document, @@ -13,12 +20,6 @@ import { type DisplayText, } from '@/ui/document.ts'; import { renderDocumentText } from '@/ui/renderers/terminal.ts'; -import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; -import { - formatBashCompletion, - formatFishCompletion, - formatZshCompletion, -} from '@/commands/completion/lib/format.ts'; export type GetRootCommand = () => Command; export type SupportedShell = 'bash' | 'zsh' | 'fish'; diff --git a/cli/src/commands/completion/lib/spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts index ce45a468..9b74a64a 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -2,17 +2,8 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { defineCommand } from '@/lib/command.ts'; + import { buildMainCommand } from '@/cli.ts'; -import { - buildCompletionSpec, - collectCompletionContexts, -} from '@/commands/completion/lib/spec.ts'; -import { - buildCompletionModel, - findCompletionContext, - normalizeCompletionArgv, -} from '@/commands/completion/lib/model.ts'; import { formatBashCompletion, formatBashFlagWordList, @@ -21,6 +12,16 @@ import { formatZshCompletion, mergeCompletionFlags, } from '@/commands/completion/lib/format.ts'; +import { + buildCompletionModel, + findCompletionContext, + normalizeCompletionArgv, +} from '@/commands/completion/lib/model.ts'; +import { + buildCompletionSpec, + collectCompletionContexts, +} from '@/commands/completion/lib/spec.ts'; +import { defineCommand } from '@/lib/command.ts'; type CompletionSpec = Awaited>; diff --git a/cli/src/commands/completion/lib/spec.ts b/cli/src/commands/completion/lib/spec.ts index c8b1a9fe..f8067e79 100644 --- a/cli/src/commands/completion/lib/spec.ts +++ b/cli/src/commands/completion/lib/spec.ts @@ -1,14 +1,14 @@ -import type { Command } from '@/lib/command.ts'; -import type { - CommandFlagScope, - PositionalCompletionKind, -} from '@/lib/command.ts'; import { resolveCommandDescriptor, visibleCommandDescriptors, type CommandArgumentDescriptor, type CommandDescriptor, } from '@/lib/command-descriptor.ts'; +import type { Command } from '@/lib/command.ts'; +import type { + CommandFlagScope, + PositionalCompletionKind, +} from '@/lib/command.ts'; /** * Static completion spec projected from the normalized command descriptor. diff --git a/cli/src/commands/doctor/index.test.ts b/cli/src/commands/doctor/index.test.ts index 9d165b94..c5decb00 100644 --- a/cli/src/commands/doctor/index.test.ts +++ b/cli/src/commands/doctor/index.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { configFile, credentialsFile, kvSet } from '@/lib/config.ts'; import { configureRunSet } from '@/lib/profile-configure-core.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -import { configFile, credentialsFile, kvSet } from '@/lib/config.ts'; let testHome = ''; let mockFile = ''; diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 9ed092fe..f2053246 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -1,10 +1,10 @@ -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; import { createDoctorChecks } from '@/commands/doctor/lib/checks.ts'; import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; import { runDoctorChecks } from '@/commands/doctor/lib/runner.ts'; -import { createDiagnosticExecutionContext } from '@/lib/execution-context.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { EXIT_GENERIC, EXIT_SUCCESS } from '@/lib/errors.ts'; +import { createDiagnosticExecutionContext } from '@/lib/execution-context.ts'; export const doctorCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/doctor/lib/checks.ts b/cli/src/commands/doctor/lib/checks.ts index 81e88ca1..71e9df54 100644 --- a/cli/src/commands/doctor/lib/checks.ts +++ b/cli/src/commands/doctor/lib/checks.ts @@ -1,31 +1,31 @@ -import { VERSION } from '@/version.ts'; -import { - detectConfiguredProfileAuth, - readProfileConfiguration, - type ProfileAuth, -} from '@/lib/profile/model.ts'; -import { envConfigMode } from '@/lib/profile-store.ts'; -import { readEnv } from '@/lib/env.ts'; +import type { + DoctorCheck, + DoctorCheckContext, + DoctorCheckOutcome, +} from '@/commands/doctor/lib/model.ts'; import { configGetGlobal, resolveApiBase, resolveManagementApiBase, } from '@/lib/config.ts'; -import { secretStoreDisplay } from '@/lib/secrets.ts'; +import { readEnv } from '@/lib/env.ts'; +import { ParseError } from '@/lib/errors.ts'; import { sendHttp } from '@/lib/http-request.ts'; -import { buildLakehouseVerifyRequest } from '@/lib/lakehouse/query.ts'; -import { parseWhoamiResponse } from '@/lib/management/model.ts'; -import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; import { parseLakehouseQueryResponse, type LakehouseRow, } from '@/lib/lakehouse-ndjson.ts'; -import { ParseError } from '@/lib/errors.ts'; -import type { - DoctorCheck, - DoctorCheckContext, - DoctorCheckOutcome, -} from '@/commands/doctor/lib/model.ts'; +import { buildLakehouseVerifyRequest } from '@/lib/lakehouse/query.ts'; +import { parseWhoamiResponse } from '@/lib/management/model.ts'; +import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; +import { envConfigMode } from '@/lib/profile-store.ts'; +import { + detectConfiguredProfileAuth, + readProfileConfiguration, + type ProfileAuth, +} from '@/lib/profile/model.ts'; +import { secretStoreDisplay } from '@/lib/secrets.ts'; +import { VERSION } from '@/version.ts'; function passOutcome( message: string, diff --git a/cli/src/commands/doctor/lib/render.test.ts b/cli/src/commands/doctor/lib/render.test.ts index 7c6758ef..b683282b 100644 --- a/cli/src/commands/doctor/lib/render.test.ts +++ b/cli/src/commands/doctor/lib/render.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; describe('formatDoctorReport', () => { diff --git a/cli/src/commands/doctor/lib/runner.test.ts b/cli/src/commands/doctor/lib/runner.test.ts index fb0502c6..a8ed0feb 100644 --- a/cli/src/commands/doctor/lib/runner.test.ts +++ b/cli/src/commands/doctor/lib/runner.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { ConfigurationError, HttpError } from '@/lib/errors.ts'; + import type { DoctorCheck, DoctorCheckContext, } from '@/commands/doctor/lib/model.ts'; import { runDoctorChecks } from '@/commands/doctor/lib/runner.ts'; +import { ConfigurationError, HttpError } from '@/lib/errors.ts'; function createDoctorContext(offline = false): DoctorCheckContext { return { diff --git a/cli/src/commands/doctor/lib/runner.ts b/cli/src/commands/doctor/lib/runner.ts index c01fe76f..7bd1b912 100644 --- a/cli/src/commands/doctor/lib/runner.ts +++ b/cli/src/commands/doctor/lib/runner.ts @@ -1,4 +1,3 @@ -import { serializeCliError } from '@/lib/errors.ts'; import type { DoctorCheck, DoctorCheckContext, @@ -6,6 +5,7 @@ import type { DoctorReport, DoctorSummary, } from '@/commands/doctor/lib/model.ts'; +import { serializeCliError } from '@/lib/errors.ts'; function summarizeCheckResults( checks: readonly DoctorCheckResult[] diff --git a/cli/src/commands/duckdb/index.test.ts b/cli/src/commands/duckdb/index.test.ts index adab58a6..90526d2a 100644 --- a/cli/src/commands/duckdb/index.test.ts +++ b/cli/src/commands/duckdb/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { chmodSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; + import { configSet } from '@/lib/config.ts'; import { secretSet } from '@/lib/secrets.ts'; import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index c82e8ac1..b58989c3 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -1,20 +1,21 @@ import { spawnSync } from 'node:child_process'; + +import { optionalStringArg } from '@/lib/args.ts'; import { getLakehouseCredentialPair, requireManagementPlane, type LakehouseBasicAuthPair, } from '@/lib/auth.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { readEnv } from '@/lib/env.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; +import type { CatalogRow } from '@/lib/management/model.ts'; import { configureVerify, type ConfigureAuthPlane, } from '@/lib/profile-status.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { optionalStringArg } from '@/lib/args.ts'; -import { fetchManagementCatalogRows } from '@/lib/management/catalogs.ts'; -import type { CatalogRow } from '@/lib/management/model.ts'; -import type { ExecutionContext } from '@/lib/execution-context.ts'; -import { readEnv } from '@/lib/env.ts'; export const duckdbCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 0fb53842..363347f6 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,19 +1,19 @@ -import type { Command } from '@/lib/command.ts'; +import { apiCommand } from '@/commands/api/index.ts'; +import { appCommand } from '@/commands/app/index.ts'; +import { appendCommand } from '@/commands/append/index.ts'; +import { catalogCommand, catalogsCommand } from '@/commands/catalogs/index.ts'; +import { createCompletionCommand } from '@/commands/completion/index.ts'; +import { doctorCommand } from '@/commands/doctor/index.ts'; +import { duckdbCommand } from '@/commands/duckdb/index.ts'; import { loginCommand } from '@/commands/login/index.ts'; import { logoutCommand } from '@/commands/logout/index.ts'; import { profileCommand } from '@/commands/profile/index.ts'; -import { catalogCommand, catalogsCommand } from '@/commands/catalogs/index.ts'; -import { duckdbCommand } from '@/commands/duckdb/index.ts'; -import { appendCommand } from '@/commands/append/index.ts'; import { queryCommand } from '@/commands/query/index.ts'; import { schemaCommand } from '@/commands/schema/index.ts'; +import { updateCommand } from '@/commands/update/index.ts'; import { uploadCommand } from '@/commands/upload/index.ts'; import { upsertCommand } from '@/commands/upsert/index.ts'; -import { apiCommand } from '@/commands/api/index.ts'; -import { createCompletionCommand } from '@/commands/completion/index.ts'; -import { updateCommand } from '@/commands/update/index.ts'; -import { doctorCommand } from '@/commands/doctor/index.ts'; -import { appCommand } from '@/commands/app/index.ts'; +import type { Command } from '@/lib/command.ts'; export function buildTopLevelCommands( getMainCommand: () => Command diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts index 06958eee..fcef8e4e 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -7,24 +7,25 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { configGet } from '@/lib/config.ts'; import { HttpError } from '@/lib/errors.ts'; import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; -import { secretGet, secretSet } from '@/lib/secrets.ts'; import { getActiveProfileName, profileExists, setActiveProfile, } from '@/lib/profile-store.ts'; import { createEmptyProfile, updateProfile } from '@/lib/profile/model.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; import { createCliTestHarness, runCommandWithTestRuntime, } from '@/test-utils/cli.ts'; -import { delay } from '@/test-utils/time.ts'; import { forceNoTerminalColorForTests } from '@/test-utils/terminal.ts'; +import { delay } from '@/test-utils/time.ts'; const DEFAULT_WHOAMI = { principal: { diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index a5c51ed6..8b4c6134 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -1,15 +1,18 @@ +import { getCliContext, isJsonOutput, setCliContext } from '@/context.ts'; import { defineCommand } from '@/lib/command.ts'; +import { resolveManagementApiRoot } from '@/lib/config.ts'; import { assertRetrieved, ConfigurationError } from '@/lib/errors.ts'; -import { getCliContext, isJsonOutput, setCliContext } from '@/context.ts'; -import { assertAllowedApiBase } from '@/lib/url-policy.ts'; -import { refreshCliRuntimeContext, type OutputSink } from '@/lib/runtime.ts'; import { httpSend } from '@/lib/http.ts'; -import { resolveManagementApiRoot } from '@/lib/config.ts'; import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; -import { runLoginFlow, type TokenResponse } from '@/lib/oauth-flow.ts'; -import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; import type { WhoamiResponse } from '@/lib/management/model.ts'; import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; +import { runLoginFlow, type TokenResponse } from '@/lib/oauth-flow.ts'; +import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; +import { + profileExists, + resolveWorkingProfile, + setActiveProfile, +} from '@/lib/profile-store.ts'; import { assertNoEnvConfigMode, createEmptyProfile, @@ -18,11 +21,7 @@ import { profileHasAnyAuthConfigured, updateProfile, } from '@/lib/profile/model.ts'; -import { - profileExists, - resolveWorkingProfile, - setActiveProfile, -} from '@/lib/profile-store.ts'; +import { refreshCliRuntimeContext, type OutputSink } from '@/lib/runtime.ts'; import { secretSet } from '@/lib/secrets.ts'; import { fetchEnvironmentCatalogs, @@ -34,6 +33,7 @@ import { type ServiceAccountRoleAssignment, type ServiceAccountScope, } from '@/lib/service-account-provision.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; import { document, section, span, table } from '@/ui/document.ts'; import { renderDocumentText } from '@/ui/renderers/terminal.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/commands/logout/index.test.ts b/cli/src/commands/logout/index.test.ts index 7c89497b..d060646b 100644 --- a/cli/src/commands/logout/index.test.ts +++ b/cli/src/commands/logout/index.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { configGet, configSet } from '@/lib/config.ts'; import { getActiveProfileName, diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts index 627465fd..23805702 100644 --- a/cli/src/commands/logout/index.ts +++ b/cli/src/commands/logout/index.ts @@ -1,6 +1,6 @@ import { defineCommand } from '@/lib/command.ts'; -import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; import { configureRunClear } from '@/lib/profile-configure-core.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; export const logoutCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/profile/configure.ts b/cli/src/commands/profile/configure.ts index c0bd88e3..19cb40ae 100644 --- a/cli/src/commands/profile/configure.ts +++ b/cli/src/commands/profile/configure.ts @@ -1,13 +1,13 @@ +import { optionalArg } from '@/commands/profile/lib/profile.ts'; import { getCliContext } from '@/context.ts'; import { defineCommand } from '@/lib/command.ts'; import { CliError } from '@/lib/errors.ts'; -import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; import { configureArgs, runProfileConfigure, type ConfigureCommandArgs, } from '@/lib/profile-configure.ts'; -import { optionalArg } from '@/commands/profile/lib/profile.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; export const profileConfigureCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/profile/current.ts b/cli/src/commands/profile/current.ts index 0e6e5ab0..93aa0474 100644 --- a/cli/src/commands/profile/current.ts +++ b/cli/src/commands/profile/current.ts @@ -1,6 +1,6 @@ -import { getActiveProfileName } from '@/lib/profile-store.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { getActiveProfileName } from '@/lib/profile-store.ts'; export const profileCurrentCommand = defineCommand({ metadata: { name: 'current', description: 'Show the active profile name' }, diff --git a/cli/src/commands/profile/delete.ts b/cli/src/commands/profile/delete.ts index a338febe..a30865d2 100644 --- a/cli/src/commands/profile/delete.ts +++ b/cli/src/commands/profile/delete.ts @@ -1,8 +1,8 @@ -import { assertNoEnvConfigMode, deleteProfile } from '@/lib/profile/model.ts'; -import { CliError } from '@/lib/errors.ts'; import { requireProfileName } from '@/commands/profile/lib/profile.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { assertNoEnvConfigMode, deleteProfile } from '@/lib/profile/model.ts'; export const profileDeleteCommand = defineCommand({ metadata: { name: 'delete', description: 'Delete a profile' }, diff --git a/cli/src/commands/profile/env.ts b/cli/src/commands/profile/env.ts index fb15d2be..1323f095 100644 --- a/cli/src/commands/profile/env.ts +++ b/cli/src/commands/profile/env.ts @@ -1,11 +1,11 @@ -import { buildProfileShellExportView } from '@/lib/profile/views.ts'; import { profileNameArgOrActive, requireStoredProfileForExport, } from '@/commands/profile/lib/profile.ts'; -import { renderShellExportView } from '@/ui/shell/render.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { buildProfileShellExportView } from '@/lib/profile/views.ts'; +import { renderShellExportView } from '@/ui/shell/render.ts'; export const profileEnvCommand = defineCommand({ metadata: { name: 'env', description: 'Print shell exports for a profile' }, diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts index 68a57277..fd984744 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -1,4 +1,3 @@ -import { defineCommand } from '@/lib/command.ts'; import { profileConfigureCommand } from '@/commands/profile/configure.ts'; import { profileCurrentCommand } from '@/commands/profile/current.ts'; import { profileDeleteCommand } from '@/commands/profile/delete.ts'; @@ -8,6 +7,7 @@ import { profileRenameCommand } from '@/commands/profile/rename.ts'; import { profileShowCommand } from '@/commands/profile/show.ts'; import { profileStatusCommand } from '@/commands/profile/status.ts'; import { profileSwitchCommand } from '@/commands/profile/switch.ts'; +import { defineCommand } from '@/lib/command.ts'; export const profileCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts index 890ba8a8..0262aa93 100644 --- a/cli/src/commands/profile/lib/profile.ts +++ b/cli/src/commands/profile/lib/profile.ts @@ -1,17 +1,17 @@ -import { asCliArgString } from '@/lib/cli-args.ts'; import { getCliContext } from '@/context.ts'; +import { asCliArgString } from '@/lib/cli-args.ts'; import { CliError, ConfigurationError } from '@/lib/errors.ts'; -import type { ProfileInspect } from '@/lib/profile/model.ts'; import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; -import { defaultPrompts, type Prompts } from '@/ui/prompts.ts'; -import { profileSwitchOption } from '@/lib/profile/views.ts'; import { envConfigMode, getActiveProfileName, isFromEnvProfile, profileExists, } from '@/lib/profile-store.ts'; +import type { ProfileInspect } from '@/lib/profile/model.ts'; import { listProfiles } from '@/lib/profile/model.ts'; +import { profileSwitchOption } from '@/lib/profile/views.ts'; +import { defaultPrompts, type Prompts } from '@/ui/prompts.ts'; export function requireProfileName(name: unknown): string { const trimmed = asCliArgString(name).trim(); diff --git a/cli/src/commands/profile/list.ts b/cli/src/commands/profile/list.ts index afe16137..8282f8ba 100644 --- a/cli/src/commands/profile/list.ts +++ b/cli/src/commands/profile/list.ts @@ -1,7 +1,7 @@ +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { listProfiles } from '@/lib/profile/model.ts'; import { formatProfileList } from '@/lib/profile/render.ts'; -import { defineCommand } from '@/lib/command.ts'; -import { writeCommandOutput } from '@/lib/command-output.ts'; export const profileListCommand = defineCommand({ metadata: { name: 'list', description: 'List configured profiles' }, diff --git a/cli/src/commands/profile/rename.ts b/cli/src/commands/profile/rename.ts index b4a536ef..60165a11 100644 --- a/cli/src/commands/profile/rename.ts +++ b/cli/src/commands/profile/rename.ts @@ -1,7 +1,7 @@ -import { assertNoEnvConfigMode, renameProfile } from '@/lib/profile/model.ts'; import { requireProfileName } from '@/commands/profile/lib/profile.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { assertNoEnvConfigMode, renameProfile } from '@/lib/profile/model.ts'; export const profileRenameCommand = defineCommand({ metadata: { name: 'rename', description: 'Rename a profile', hidden: true }, diff --git a/cli/src/commands/profile/show.ts b/cli/src/commands/profile/show.ts index 5db41e4a..1c264286 100644 --- a/cli/src/commands/profile/show.ts +++ b/cli/src/commands/profile/show.ts @@ -1,12 +1,12 @@ -import { inspectProfile } from '@/lib/profile/model.ts'; -import { formatProfileInspectResult } from '@/lib/profile/render.ts'; -import { profileInspectToJson } from '@/lib/profile/views.ts'; import { existingProfileName, profileShowTargetName, } from '@/commands/profile/lib/profile.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { inspectProfile } from '@/lib/profile/model.ts'; +import { formatProfileInspectResult } from '@/lib/profile/render.ts'; +import { profileInspectToJson } from '@/lib/profile/views.ts'; export const profileShowCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts index 1c05d408..78e6b790 100644 --- a/cli/src/commands/profile/status.ts +++ b/cli/src/commands/profile/status.ts @@ -1,15 +1,15 @@ -import { inspectProfile } from '@/lib/profile/model.ts'; -import { formatProfileStatus } from '@/lib/profile/render.ts'; -import { profileStatusToJson } from '@/lib/profile/views.ts'; -import { configureVerify } from '@/lib/profile-status.ts'; import { configuredVerificationPlanes, existingProfileName, profileShowTargetName, } from '@/commands/profile/lib/profile.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { EXIT_GENERIC, EXIT_SUCCESS } from '@/lib/errors.ts'; +import { configureVerify } from '@/lib/profile-status.ts'; +import { inspectProfile } from '@/lib/profile/model.ts'; +import { formatProfileStatus } from '@/lib/profile/render.ts'; +import { profileStatusToJson } from '@/lib/profile/views.ts'; export const profileStatusCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts index eef81eaa..925c2030 100644 --- a/cli/src/commands/profile/switch.ts +++ b/cli/src/commands/profile/switch.ts @@ -1,13 +1,13 @@ -import { getCliContext, isJsonOutput } from '@/context.ts'; -import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; -import { setActiveProfile } from '@/lib/profile-store.ts'; -import { CliError } from '@/lib/errors.ts'; import { promptProfileSwitch, requireProfileName, } from '@/commands/profile/lib/profile.ts'; -import { defineCommand } from '@/lib/command.ts'; +import { getCliContext, isJsonOutput } from '@/context.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; +import { setActiveProfile } from '@/lib/profile-store.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.ts'; export const profileSwitchCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/query/cancel.ts b/cli/src/commands/query/cancel.ts index cc19a091..88678467 100644 --- a/cli/src/commands/query/cancel.ts +++ b/cli/src/commands/query/cancel.ts @@ -1,8 +1,8 @@ import { stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; -import { buildLakehouseQueryCancelRequest } from '@/lib/lakehouse/query.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; +import { buildLakehouseQueryCancelRequest } from '@/lib/lakehouse/query.ts'; export const queryCancelCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/query/index.test.ts b/cli/src/commands/query/index.test.ts index 0bdfc0a6..53d27545 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index 7abb18c9..b11d302e 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,20 +1,20 @@ -import { defineCommand } from '@/lib/command.ts'; +import { queryCancelCommand } from '@/commands/query/cancel.ts'; import { queryRunArgs } from '@/commands/query/lib/args.ts'; import { queryShowCommand } from '@/commands/query/show.ts'; -import { queryCancelCommand } from '@/commands/query/cancel.ts'; -import { CliError } from '@/lib/errors.ts'; import { optionalStringArg } from '@/lib/args.ts'; -import { writeQueryDestination } from '@/lib/query-destination.ts'; -import { - isApiNativeQueryFormat, - writeQueryOutput, -} from '@/lib/query-output.ts'; -import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; import { executeLakehouseQuery, executeLakehouseQueryBytes, type LakehouseQueryInput, } from '@/lib/lakehouse/query.ts'; +import { writeQueryDestination } from '@/lib/query-destination.ts'; +import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; +import { + isApiNativeQueryFormat, + writeQueryOutput, +} from '@/lib/query-output.ts'; export const queryCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/query/show.ts b/cli/src/commands/query/show.ts index 3657c56a..3ccb7685 100644 --- a/cli/src/commands/query/show.ts +++ b/cli/src/commands/query/show.ts @@ -1,8 +1,8 @@ import { stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; -import { buildLakehouseQueryShowRequest } from '@/lib/lakehouse/query.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; +import { buildLakehouseQueryShowRequest } from '@/lib/lakehouse/query.ts'; export const queryShowCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/schema/index.test.ts b/cli/src/commands/schema/index.test.ts index aaf49f77..1789f436 100644 --- a/cli/src/commands/schema/index.test.ts +++ b/cli/src/commands/schema/index.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; const SCHEMA_COLUMNS = [ diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index 14855d5b..7221c84e 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,11 +1,11 @@ +import { schemaArgs } from '@/commands/schema/lib/args.ts'; +import { buildSchemaTreeView } from '@/commands/schema/lib/views.ts'; import { stringArg } from '@/lib/args.ts'; import { defineCommand } from '@/lib/command.ts'; -import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; +import { executeLakehouseQuery } from '@/lib/lakehouse/query.ts'; import { writePagedOutput } from '@/lib/pager.ts'; +import { parseQueryOutputOptions } from '@/lib/query-output-args.ts'; import { writeQueryOutput } from '@/lib/query-output.ts'; -import { executeLakehouseQuery } from '@/lib/lakehouse/query.ts'; -import { buildSchemaTreeView } from '@/commands/schema/lib/views.ts'; -import { schemaArgs } from '@/commands/schema/lib/args.ts'; import { renderTreeText } from '@/ui/renderers/terminal.ts'; export const schemaCommand = defineCommand({ diff --git a/cli/src/commands/schema/lib/views.ts b/cli/src/commands/schema/lib/views.ts index 0d3dc130..7d96158e 100644 --- a/cli/src/commands/schema/lib/views.ts +++ b/cli/src/commands/schema/lib/views.ts @@ -1,10 +1,10 @@ -import { getQueryColumnNames } from '@/lib/query-format.ts'; import type { LakehouseQueryResult, LakehouseRow, } from '@/lib/lakehouse-ndjson.ts'; -import type { TreeNode, TreeView } from '@/ui/layouts/tree.ts'; +import { getQueryColumnNames } from '@/lib/query-format.ts'; import { span, type DisplaySpan } from '@/ui/document.ts'; +import type { TreeNode, TreeView } from '@/ui/layouts/tree.ts'; type SchemaTreeColumn = { name: string; diff --git a/cli/src/commands/update/index.ts b/cli/src/commands/update/index.ts index bb5d6551..9c3e9137 100644 --- a/cli/src/commands/update/index.ts +++ b/cli/src/commands/update/index.ts @@ -1,7 +1,3 @@ -import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; -import { span } from '@/ui/document.ts'; -import { renderDisplayText } from '@/ui/terminal/styles.ts'; -import { VERSION } from '@/version.ts'; import { asCliArgString } from '@/lib/cli-args.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; import { defineCommand } from '@/lib/command.ts'; @@ -19,6 +15,10 @@ import { resolveUpdateSource, type UpdateCheckResult, } from '@/lib/updater.ts'; +import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { VERSION } from '@/version.ts'; export const updateCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/upload/index.test.ts b/cli/src/commands/upload/index.test.ts index 66a45f32..9aba671a 100644 --- a/cli/src/commands/upload/index.test.ts +++ b/cli/src/commands/upload/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts index b27f5616..e226ce9a 100644 --- a/cli/src/commands/upload/index.ts +++ b/cli/src/commands/upload/index.ts @@ -1,14 +1,14 @@ +import { uploadArgs, UPLOAD_MODE_OPTIONS } from '@/commands/upload/lib/args.ts'; import { enumArg, optionalStringArg, stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; +import { createLakehouseUploadRequest } from '@/lib/lakehouse-transport.ts'; import { parseLakehouseFileContentType, parseLakehouseTarget, } from '@/lib/lakehouse/args.ts'; -import { createLakehouseUploadRequest } from '@/lib/lakehouse-transport.ts'; import { getFileSizeBytes } from '@/lib/lakehouse/file.ts'; -import { uploadArgs, UPLOAD_MODE_OPTIONS } from '@/commands/upload/lib/args.ts'; export const uploadCommand = defineCommand({ metadata: { diff --git a/cli/src/commands/upsert/index.test.ts b/cli/src/commands/upsert/index.test.ts index 8837d440..a4274dfb 100644 --- a/cli/src/commands/upsert/index.test.ts +++ b/cli/src/commands/upsert/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { runCommandWithTestRuntime } from '@/test-utils/cli.ts'; import { createLakehouseTestWorkspace, diff --git a/cli/src/commands/upsert/index.ts b/cli/src/commands/upsert/index.ts index 1de18b12..7c272c71 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -1,14 +1,14 @@ +import { upsertArgs } from '@/commands/upsert/lib/args.ts'; import { optionalStringArg, stringArg } from '@/lib/args.ts'; -import { defineCommand } from '@/lib/command.ts'; import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; import { sendHttp } from '@/lib/http-request.ts'; +import { createLakehouseUpsertRequest } from '@/lib/lakehouse-transport.ts'; import { parseLakehouseFileContentType, parseLakehouseTarget, } from '@/lib/lakehouse/args.ts'; import { getFileSizeBytes } from '@/lib/lakehouse/file.ts'; -import { createLakehouseUpsertRequest } from '@/lib/lakehouse-transport.ts'; -import { upsertArgs } from '@/commands/upsert/lib/args.ts'; export const upsertCommand = defineCommand({ metadata: { diff --git a/cli/src/context.ts b/cli/src/context.ts index 8fd20dbc..26747e1e 100644 --- a/cli/src/context.ts +++ b/cli/src/context.ts @@ -1,15 +1,15 @@ -import { DEFAULT_CONNECT_TIMEOUT_MS } from '@/lib/transport-defaults.ts'; -import { - getCliRuntime, - isCliRuntimeReady, - refreshCliRuntimeContext, -} from '@/lib/runtime.ts'; import { getBootstrapCliContextState, isJsonOutput as contextUsesJsonOutput, setBootstrapCliContext, type CliContext, } from '@/lib/cli-context.ts'; +import { + getCliRuntime, + isCliRuntimeReady, + refreshCliRuntimeContext, +} from '@/lib/runtime.ts'; +import { DEFAULT_CONNECT_TIMEOUT_MS } from '@/lib/transport-defaults.ts'; export type { CliContext } from '@/lib/cli-context.ts'; diff --git a/cli/src/lib/auth.test.ts b/cli/src/lib/auth.test.ts index 64b0285b..a9f64df4 100644 --- a/cli/src/lib/auth.test.ts +++ b/cli/src/lib/auth.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { getLakehouseAuthHeader, getLakehouseCredentialPair, diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index fa79599a..420a94d0 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -1,9 +1,9 @@ import { configGet } from '@/lib/config.ts'; +import { readEnv } from '@/lib/env.ts'; import { CliError, ConfigurationError, EXIT_CONFIG } from '@/lib/errors.ts'; import { optionalAuth } from '@/lib/execution-context.ts'; -import { secretGet } from '@/lib/secrets.ts'; -import { readEnv } from '@/lib/env.ts'; import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import { secretGet } from '@/lib/secrets.ts'; export function basicAuthToken(user: string, password: string): string { return Buffer.from(`${user}:${password}`).toString('base64'); diff --git a/cli/src/lib/cli-session.test.ts b/cli/src/lib/cli-session.test.ts index 79a091d5..430bd360 100644 --- a/cli/src/lib/cli-session.test.ts +++ b/cli/src/lib/cli-session.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; import { createCliSession } from '@/lib/cli-session.ts'; import { configureRunSet } from '@/lib/profile-configure-core.ts'; -import { setCliContext } from '@/context.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; diff --git a/cli/src/lib/command-descriptor.test.ts b/cli/src/lib/command-descriptor.test.ts index ab9ea292..ad3c3dea 100644 --- a/cli/src/lib/command-descriptor.test.ts +++ b/cli/src/lib/command-descriptor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; + import { buildMainCommand } from '@/cli.ts'; import { buildCompletionSpecFromDescriptor } from '@/commands/completion/lib/spec.ts'; -import { defineCommand } from '@/lib/command.ts'; import { resolveCommandDescriptor, validateCommandDescriptor, @@ -10,6 +10,7 @@ import { type CommandDescriptor, type ResolvedCommandMetadata, } from '@/lib/command-descriptor.ts'; +import { defineCommand } from '@/lib/command.ts'; import { buildStructuredHelpFromDescriptor, renderAltertableUsageFromDescriptor, diff --git a/cli/src/lib/command-output.test.ts b/cli/src/lib/command-output.test.ts index 67f63a18..78939a0b 100644 --- a/cli/src/lib/command-output.test.ts +++ b/cli/src/lib/command-output.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { writeCommandOutput } from '@/lib/command-output.ts'; import { createCliRuntime, type OutputSink } from '@/lib/runtime.ts'; diff --git a/cli/src/lib/command-output.ts b/cli/src/lib/command-output.ts index 5215dcbb..9ad60c32 100644 --- a/cli/src/lib/command-output.ts +++ b/cli/src/lib/command-output.ts @@ -1,8 +1,8 @@ -import type { ManagementOutputFormat } from '@/lib/tabular-result.ts'; import { renderManagementOutput } from '@/lib/management-output.ts'; -import { parseApiJson } from '@/lib/parse-api-json.ts'; import { resolvePagerOptions, writePagedOutput } from '@/lib/pager.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; import type { OutputSink } from '@/lib/runtime.ts'; +import type { ManagementOutputFormat } from '@/lib/tabular-result.ts'; import { span } from '@/ui/document.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/lib/command-parser.ts b/cli/src/lib/command-parser.ts index f302fa1d..6636455c 100644 --- a/cli/src/lib/command-parser.ts +++ b/cli/src/lib/command-parser.ts @@ -1,3 +1,4 @@ +import { aliasFlagToken } from '@/lib/command-descriptor.ts'; import { commandArgumentValues, resolveCommandValue, @@ -7,12 +8,11 @@ import { type CommandMetadata, type CommandRunResult, } from '@/lib/command.ts'; -import { aliasFlagToken } from '@/lib/command-descriptor.ts'; +import { EXIT_SUCCESS } from '@/lib/errors.ts'; import { createExecutionContext, type ExecutionContext, } from '@/lib/execution-context.ts'; -import { EXIT_SUCCESS } from '@/lib/errors.ts'; import { getCliRuntime } from '@/lib/runtime.ts'; type ParsedArgumentValue = string | boolean | string[] | undefined; diff --git a/cli/src/lib/command.test.ts b/cli/src/lib/command.test.ts index 637828c8..6264b304 100644 --- a/cli/src/lib/command.test.ts +++ b/cli/src/lib/command.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { defineCommand } from '@/lib/command.ts'; + import { CommandParseError, executeCommand } from '@/lib/command-parser.ts'; +import { defineCommand } from '@/lib/command.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; diff --git a/cli/src/lib/config-files.ts b/cli/src/lib/config-files.ts index 217a2831..9d5e55e0 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -7,8 +7,9 @@ import { writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; -import { configDir } from '@/lib/user-directories.ts'; + import { ConfigurationError } from '@/lib/errors.ts'; +import { configDir } from '@/lib/user-directories.ts'; export { configDir } from '@/lib/user-directories.ts'; diff --git a/cli/src/lib/config.test.ts b/cli/src/lib/config.test.ts index 926313bf..d33b0206 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -9,6 +9,7 @@ import { } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; + import { configDir, configFile, diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 37a986de..5ec634fa 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,4 +1,3 @@ -import { configGet, configSet } from '@/lib/profile-store.ts'; import { configDir, configFile, @@ -7,8 +6,9 @@ import { kvSet, kvUnset, } from '@/lib/config-files.ts'; -import { assertAllowedApiBase } from '@/lib/url-policy.ts'; import { readEnv } from '@/lib/env.ts'; +import { configGet, configSet } from '@/lib/profile-store.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; import { isQueryLayout, type QueryLayout } from '@/ui/layouts/query.ts'; export { configDir, configFile, credentialsFile, kvGet, kvSet, kvUnset }; diff --git a/cli/src/lib/early-bootstrap.test.ts b/cli/src/lib/early-bootstrap.test.ts index be1beb90..4f8571a8 100644 --- a/cli/src/lib/early-bootstrap.test.ts +++ b/cli/src/lib/early-bootstrap.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { EARLY_BOOTSTRAP_EXITS, HELP_FLAGS, diff --git a/cli/src/lib/env.test.ts b/cli/src/lib/env.test.ts index a7d5e569..5272fb6a 100644 --- a/cli/src/lib/env.test.ts +++ b/cli/src/lib/env.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; + import { assertSecretEnvMetadata, CONFIG_ENV_NAMES, diff --git a/cli/src/lib/errors.test.ts b/cli/src/lib/errors.test.ts index 354d462f..d3b679ab 100644 --- a/cli/src/lib/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { CliError, ConfigurationError, @@ -19,17 +20,17 @@ import { serializeCliError, shouldShowCommandExamplesOnError, } from '@/lib/errors.ts'; -import { - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, -} from '@/ui/error.ts'; import { forceNoTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, } from '@/test-utils/terminal.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; let terminalState: TerminalTestState; diff --git a/cli/src/lib/global-flags.test.ts b/cli/src/lib/global-flags.test.ts index 15c697a3..bf4aec91 100644 --- a/cli/src/lib/global-flags.test.ts +++ b/cli/src/lib/global-flags.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test'; + import { isJsonOutput, setCliContext } from '@/context.ts'; +import { CliError } from '@/lib/errors.ts'; import { AGENT_ENV_VARIABLES, mergeGlobalFlagsFromArgs, @@ -12,7 +14,6 @@ import { setCliRuntime, } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -import { CliError } from '@/lib/errors.ts'; import { renderCliErrorJson } from '@/ui/error.ts'; describe('parseGlobalOutputFlags', () => { diff --git a/cli/src/lib/global-flags.ts b/cli/src/lib/global-flags.ts index 09273991..157089c7 100644 --- a/cli/src/lib/global-flags.ts +++ b/cli/src/lib/global-flags.ts @@ -1,6 +1,6 @@ -import { copyProcessEnv } from '@/lib/env.ts'; import type { CliContext } from '@/context.ts'; import { asCliArgString } from '@/lib/cli-args.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; import { parseTimeoutSeconds } from '@/lib/timeout-args.ts'; export const AGENT_ENV_VARIABLES = [ diff --git a/cli/src/lib/http-headers.test.ts b/cli/src/lib/http-headers.test.ts index 0bd43a81..fa6eecab 100644 --- a/cli/src/lib/http-headers.test.ts +++ b/cli/src/lib/http-headers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { buildRequestHeaders } from '@/lib/http.ts'; describe('buildRequestHeaders', () => { diff --git a/cli/src/lib/http-request.test.ts b/cli/src/lib/http-request.test.ts index d7cfd717..6e2b4518 100644 --- a/cli/src/lib/http-request.test.ts +++ b/cli/src/lib/http-request.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { basicAuthToken } from '@/lib/auth.ts'; import { configGet, configSet } from '@/lib/config.ts'; import type { ExecutionContext } from '@/lib/execution-context.ts'; diff --git a/cli/src/lib/http-request.ts b/cli/src/lib/http-request.ts index 7879dfd8..be749e09 100644 --- a/cli/src/lib/http-request.ts +++ b/cli/src/lib/http-request.ts @@ -1,21 +1,21 @@ +import { getLakehouseAuthHeader, getManagementAuthHeader } from '@/lib/auth.ts'; +import { resolveApiBase, resolveManagementApiBase } from '@/lib/config.ts'; import { HttpError, type AuthPlane } from '@/lib/errors.ts'; -import { httpSend, httpSendStream, type HttpSendOptions } from '@/lib/http.ts'; -import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; import { optionalAuth, type ExecutionContext, } from '@/lib/execution-context.ts'; -import { getLakehouseAuthHeader, getManagementAuthHeader } from '@/lib/auth.ts'; -import { resolveApiBase, resolveManagementApiBase } from '@/lib/config.ts'; -import { - ensureFreshAccessToken, - hasOAuthSession, -} from '@/lib/oauth-profile.ts'; +import { httpSend, httpSendStream, type HttpSendOptions } from '@/lib/http.ts'; import { canRecoverLakehouseAuth, hasManagementCredentials, provisionLakehouseCredential, } from '@/lib/lakehouse-provision.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; +import { + ensureFreshAccessToken, + hasOAuthSession, +} from '@/lib/oauth-profile.ts'; type PlaneUrlBuilder = (endpoint: string, context: ExecutionContext) => string; diff --git a/cli/src/lib/http.test.ts b/cli/src/lib/http.test.ts index 06a05b21..34601e71 100644 --- a/cli/src/lib/http.test.ts +++ b/cli/src/lib/http.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; import { CliError, @@ -9,6 +10,11 @@ import { NetworkError, TimeoutError, } from '@/lib/errors.ts'; +import { + computeRetryDelayMs, + isRetryableMethod, + parseRetryAfterMs, +} from '@/lib/http-retry.ts'; import { buildFetchInit, getSharedDispatcher, @@ -20,11 +26,6 @@ import { resolveReadTimeoutMs, shouldIgnoreSslErrors, } from '@/lib/http.ts'; -import { - computeRetryDelayMs, - isRetryableMethod, - parseRetryAfterMs, -} from '@/lib/http-retry.ts'; import { redactResponseBodyForDebug } from '@/lib/redact.ts'; import { delay } from '@/test-utils/time.ts'; diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts index 392821fe..35549d2d 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -1,6 +1,7 @@ import { appendFileSync } from 'node:fs'; + import { getCliContext, getConnectTimeoutMs } from '@/context.ts'; -import { USER_AGENT } from '@/version.ts'; +import { readEnv } from '@/lib/env.ts'; import { CliError, HttpError, @@ -8,24 +9,24 @@ import { TimeoutError, type AuthPlane, } from '@/lib/errors.ts'; +import { + delayBeforeHttpRetry, + MAX_RETRY_ATTEMPTS, + shouldRetryHttpRequest, +} from '@/lib/http-retry.ts'; import { logDebug, logWarn } from '@/lib/log.ts'; -import { getOutputSink } from '@/lib/runtime.ts'; import { redactResponseBodyForDebug, redactSensitiveJsonString, redactSensitiveJsonValue, truncateBodySnippet, } from '@/lib/redact.ts'; -import { - delayBeforeHttpRetry, - MAX_RETRY_ATTEMPTS, - shouldRetryHttpRequest, -} from '@/lib/http-retry.ts'; +import { getOutputSink } from '@/lib/runtime.ts'; import { DEFAULT_READ_TIMEOUT_MS, STREAM_READ_TIMEOUT_MS, } from '@/lib/transport-defaults.ts'; -import { readEnv } from '@/lib/env.ts'; +import { USER_AGENT } from '@/version.ts'; export type HttpSendOptions = { method: string; diff --git a/cli/src/lib/lakehouse-provision.test.ts b/cli/src/lib/lakehouse-provision.test.ts index 360d42a2..1d62555f 100644 --- a/cli/src/lib/lakehouse-provision.test.ts +++ b/cli/src/lib/lakehouse-provision.test.ts @@ -6,8 +6,9 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; import { configGet, configSet } from '@/lib/config.ts'; import { createExecutionContext } from '@/lib/execution-context.ts'; diff --git a/cli/src/lib/lakehouse-provision.ts b/cli/src/lib/lakehouse-provision.ts index 76eba269..2a9e8346 100644 --- a/cli/src/lib/lakehouse-provision.ts +++ b/cli/src/lib/lakehouse-provision.ts @@ -16,7 +16,6 @@ import { optionalAuth, type ExecutionContext, } from '@/lib/execution-context.ts'; -import { isFromEnvProfile } from '@/lib/profile-store.ts'; import { httpSend } from '@/lib/http.ts'; import { encodeManagementEndpoint } from '@/lib/management-endpoint.ts'; import { @@ -24,6 +23,7 @@ import { hasOAuthSession, } from '@/lib/oauth-profile.ts'; import { parseApiJson } from '@/lib/parse-api-json.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; import { secretSet } from '@/lib/secrets.ts'; import { span } from '@/ui/document.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/lib/lakehouse-transport.ts b/cli/src/lib/lakehouse-transport.ts index 3fcbaf1c..34dea81f 100644 --- a/cli/src/lib/lakehouse-transport.ts +++ b/cli/src/lib/lakehouse-transport.ts @@ -1,9 +1,9 @@ +import type { HttpRequest } from '@/lib/http-request.ts'; import { type HttpSendOptions } from '@/lib/http.ts'; import { createUploadProgressReporter, shouldShowProgress, } from '@/lib/progress.ts'; -import type { HttpRequest } from '@/lib/http-request.ts'; export type LakehouseAppendOptions = { sync?: boolean; diff --git a/cli/src/lib/lakehouse/args.test.ts b/cli/src/lib/lakehouse/args.test.ts index 17be6aaa..22732cfe 100644 --- a/cli/src/lib/lakehouse/args.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { CliError } from '@/lib/errors.ts'; import { inferLakehouseFileFormat, diff --git a/cli/src/lib/lakehouse/args.ts b/cli/src/lib/lakehouse/args.ts index 060329a1..c6edb937 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,4 +1,5 @@ import { extname } from 'node:path'; + import { defineArguments } from '@/lib/command.ts'; import { CliError } from '@/lib/errors.ts'; diff --git a/cli/src/lib/lakehouse/file.ts b/cli/src/lib/lakehouse/file.ts index 649935f0..d2fa60fb 100644 --- a/cli/src/lib/lakehouse/file.ts +++ b/cli/src/lib/lakehouse/file.ts @@ -1,4 +1,5 @@ import { statSync } from 'node:fs'; + import { CliError } from '@/lib/errors.ts'; export function getFileSizeBytes(filePath: string): number { diff --git a/cli/src/lib/lakehouse/query.test.ts b/cli/src/lib/lakehouse/query.test.ts index 1eabcf58..732472a3 100644 --- a/cli/src/lib/lakehouse/query.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -1,28 +1,29 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; import { ParseError, QueryError } from '@/lib/errors.ts'; -import { - csvEscapeCell, - renderQueryCsv, - renderQueryJson, - renderQueryOutputText, -} from '@/lib/query-output.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; +import { httpSendStream, resolveReadTimeoutMs } from '@/lib/http.ts'; import { parseLakehouseQueryResponse, parseLakehouseQueryStream, type LakehouseRow, } from '@/lib/lakehouse-ndjson.ts'; -import { getQueryColumnNames } from '@/lib/query-format.ts'; -import { httpSendStream, resolveReadTimeoutMs } from '@/lib/http.ts'; -import { createExecutionContext } from '@/lib/execution-context.ts'; import { buildLakehouseQueryRequest, executeLakehouseQuery, executeLakehouseQueryBytes, } from '@/lib/lakehouse/query.ts'; +import { getQueryColumnNames } from '@/lib/query-format.ts'; +import { + csvEscapeCell, + renderQueryCsv, + renderQueryJson, + renderQueryOutputText, +} from '@/lib/query-output.ts'; import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; const SAMPLE_NDJSON = [ diff --git a/cli/src/lib/lakehouse/query.ts b/cli/src/lib/lakehouse/query.ts index a78e6010..3d58e7e2 100644 --- a/cli/src/lib/lakehouse/query.ts +++ b/cli/src/lib/lakehouse/query.ts @@ -1,14 +1,14 @@ import type { ExecutionContext } from '@/lib/execution-context.ts'; -import { - parseLakehouseQueryResponse, - parseLakehouseQueryStream, - type LakehouseQueryResult, -} from '@/lib/lakehouse-ndjson.ts'; import { sendHttp, sendHttpStream, type HttpRequest, } from '@/lib/http-request.ts'; +import { + parseLakehouseQueryResponse, + parseLakehouseQueryStream, + type LakehouseQueryResult, +} from '@/lib/lakehouse-ndjson.ts'; export type LakehouseApiQueryFormat = 'csv' | 'jsonl' | 'parquet'; diff --git a/cli/src/lib/management-output.test.ts b/cli/src/lib/management-output.test.ts index a24b8dce..593360cf 100644 --- a/cli/src/lib/management-output.test.ts +++ b/cli/src/lib/management-output.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { extractManagementRows, renderManagementOutput, diff --git a/cli/src/lib/management-output.ts b/cli/src/lib/management-output.ts index a93a6ba9..f916f8ff 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,5 +1,5 @@ -import { parseApiJson } from '@/lib/parse-api-json.ts'; import { isRecord } from '@/lib/object.ts'; +import { parseApiJson } from '@/lib/parse-api-json.ts'; import { redactSensitiveJsonValue } from '@/lib/redact.ts'; import { renderTabularOutput, diff --git a/cli/src/lib/management/auth.test.ts b/cli/src/lib/management/auth.test.ts index 338f6d10..62c0d466 100644 --- a/cli/src/lib/management/auth.test.ts +++ b/cli/src/lib/management/auth.test.ts @@ -1,14 +1,15 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; import { getManagementAuthHeader, requireManagementPlane } from '@/lib/auth.ts'; import { configSet } from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; +import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; import { FROM_ENV_PSEUDOPROFILE_NAME } from '@/lib/profile-store.ts'; import { secretSet } from '@/lib/secrets.ts'; -import { storeOAuthTokens } from '@/lib/oauth-profile.ts'; -import { setCliContext } from '@/context.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; const profileName = 'default'; diff --git a/cli/src/lib/management/model.test.ts b/cli/src/lib/management/model.test.ts index a205f9a8..04b0265a 100644 --- a/cli/src/lib/management/model.test.ts +++ b/cli/src/lib/management/model.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { ParseError } from '@/lib/errors.ts'; import { parseWhoamiResponse } from '@/lib/management/model.ts'; diff --git a/cli/src/lib/management/render.test.ts b/cli/src/lib/management/render.test.ts index 4b5454b9..c73e98cb 100644 --- a/cli/src/lib/management/render.test.ts +++ b/cli/src/lib/management/render.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from 'bun:test'; + +import type { CatalogRow } from '@/lib/management/model.ts'; import { formatCatalogsSummary, formatCatalogsTable, } from '@/lib/management/render.ts'; -import type { CatalogRow } from '@/lib/management/model.ts'; const sampleRows: CatalogRow[] = [ { diff --git a/cli/src/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts index ca92ff57..ed96e55f 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { configGet, resolveOAuthBase } from '@/lib/config.ts'; +import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; +import { configGet, resolveOAuthBase } from '@/lib/config.ts'; import { buildAuthorizeUrl, parseCallback, diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index 112e082d..5aac2f31 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -1,12 +1,14 @@ -import * as oauth from 'oauth4webapi'; -import { createServer } from 'node:http'; import { spawn } from 'node:child_process'; -import { httpSend } from '@/lib/http.ts'; +import { createServer } from 'node:http'; + +import * as oauth from 'oauth4webapi'; + +import { readEnv } from '@/lib/env.ts'; import { ConfigurationError } from '@/lib/errors.ts'; +import { httpSend } from '@/lib/http.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; import { span } from '@/ui/document.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; -import type { OutputSink } from '@/lib/runtime.ts'; -import { readEnv } from '@/lib/env.ts'; const LOGIN_TIMEOUT_MS = 180_000; diff --git a/cli/src/lib/oauth-profile.test.ts b/cli/src/lib/oauth-profile.test.ts index 2580932d..67549fc3 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -1,20 +1,21 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { exchangeCode } from '@/lib/oauth-flow.ts'; -import { - ensureFreshAccessToken, - storeOAuthTokens, -} from '@/lib/oauth-profile.ts'; +import { join } from 'node:path'; + +import { setCliContext, getCliContext } from '@/context.ts'; import { getManagementAuthHeader } from '@/lib/auth.ts'; import { configGet, configSet, resolveOAuthBase } from '@/lib/config.ts'; -import { secretGet, secretSet } from '@/lib/secrets.ts'; -import { setCliContext, getCliContext } from '@/context.ts'; import { ConfigurationError, HttpError, NetworkError } from '@/lib/errors.ts'; import { createExecutionContext } from '@/lib/execution-context.ts'; import { sendHttp } from '@/lib/http-request.ts'; +import { exchangeCode } from '@/lib/oauth-flow.ts'; +import { + ensureFreshAccessToken, + storeOAuthTokens, +} from '@/lib/oauth-profile.ts'; import { createCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { secretGet, secretSet } from '@/lib/secrets.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; const profileName = 'default'; diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index ab234211..756cd28e 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -1,8 +1,8 @@ import { configGet, configSet, resolveOAuthBase } from '@/lib/config.ts'; -import { secretDelete, secretGet, secretSet } from '@/lib/secrets.ts'; +import { readEnv } from '@/lib/env.ts'; import { ConfigurationError, HttpError } from '@/lib/errors.ts'; import { refreshAccessToken, type TokenResponse } from '@/lib/oauth-flow.ts'; -import { readEnv } from '@/lib/env.ts'; +import { secretDelete, secretGet, secretSet } from '@/lib/secrets.ts'; const ACCESS_TOKEN_ACCOUNT = 'oauth/access-token'; const REFRESH_TOKEN_ACCOUNT = 'oauth/refresh-token'; diff --git a/cli/src/lib/openapi-spec.test.ts b/cli/src/lib/openapi-spec.test.ts index 6c900685..5ec93eca 100644 --- a/cli/src/lib/openapi-spec.test.ts +++ b/cli/src/lib/openapi-spec.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { getOpenapiSpecYaml, stripOpenapiSourceHeader, diff --git a/cli/src/lib/openapi-spec.ts b/cli/src/lib/openapi-spec.ts index 52dc9859..d67e3958 100644 --- a/cli/src/lib/openapi-spec.ts +++ b/cli/src/lib/openapi-spec.ts @@ -1,7 +1,8 @@ +import { parse } from 'yaml'; + // The bundled asset lives outside src/, so the @ alias cannot resolve it. // oxlint-disable-next-line no-relative-import-paths/no-relative-import-paths import openapiYaml from '../../openapi/openapi.yaml' with { type: 'text' }; -import { parse } from 'yaml'; const OPENAPI_SOURCE_HEADER_PATTERN = /^# AUTO-GENERATED[^\n]*\n(?:# [^\n]*\n)*/; diff --git a/cli/src/lib/pager.test.ts b/cli/src/lib/pager.test.ts index 63c6cfef..2f4c9844 100644 --- a/cli/src/lib/pager.test.ts +++ b/cli/src/lib/pager.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { configFile, kvSet } from '@/lib/config.ts'; import { buildPagerEnv, diff --git a/cli/src/lib/pager.ts b/cli/src/lib/pager.ts index 05502e81..a82dd810 100644 --- a/cli/src/lib/pager.ts +++ b/cli/src/lib/pager.ts @@ -1,8 +1,9 @@ import { spawnSync } from 'node:child_process'; + import { getQueryDefaultPager } from '@/lib/config.ts'; +import { copyProcessEnv } from '@/lib/env.ts'; import type { OutputSink } from '@/lib/runtime.ts'; import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; -import { copyProcessEnv } from '@/lib/env.ts'; export type PagerMode = 'auto' | 'always' | 'never'; diff --git a/cli/src/lib/pluralize.test.ts b/cli/src/lib/pluralize.test.ts index c4ad3077..011aa4ea 100644 --- a/cli/src/lib/pluralize.test.ts +++ b/cli/src/lib/pluralize.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { pluralize, pluralizeLabel } from '@/lib/pluralize.ts'; describe('pluralize', () => { diff --git a/cli/src/lib/profile-configure-core.test.ts b/cli/src/lib/profile-configure-core.test.ts index fe68e53a..73b51585 100644 --- a/cli/src/lib/profile-configure-core.test.ts +++ b/cli/src/lib/profile-configure-core.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { configGet } from '@/lib/config.ts'; import { ConfigurationError } from '@/lib/errors.ts'; import { diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index 82778f22..e6b95ab0 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -1,4 +1,6 @@ import { unlinkSync, rmSync, existsSync, readFileSync } from 'node:fs'; + +import { getCliContext } from '@/context.ts'; import { configFile, configGet, @@ -7,11 +9,7 @@ import { kvUnset, } from '@/lib/config.ts'; import { CliError, ConfigurationError } from '@/lib/errors.ts'; -import { - deleteProfile, - deriveProfileName, - listProfiles, -} from '@/lib/profile/model.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; import { ensureProfileExists, getActiveProfileName, @@ -21,15 +19,18 @@ import { resolveWorkingProfile, setActiveProfile, } from '@/lib/profile-store.ts'; -import { pluralizeLabel } from '@/lib/pluralize.ts'; -import { getCliContext } from '@/context.ts'; -import { secretDelete, secretSet } from '@/lib/secrets.ts'; -import { assertAllowedApiBase } from '@/lib/url-policy.ts'; +import { + deleteProfile, + deriveProfileName, + listProfiles, +} from '@/lib/profile/model.ts'; import { getCliRuntime, getOutputSink, type OutputSink, } from '@/lib/runtime.ts'; +import { secretDelete, secretSet } from '@/lib/secrets.ts'; +import { assertAllowedApiBase } from '@/lib/url-policy.ts'; import { span } from '@/ui/document.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/lib/profile-configure-credential-status.test.ts b/cli/src/lib/profile-configure-credential-status.test.ts index f236d40b..75ec8663 100644 --- a/cli/src/lib/profile-configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { configSet } from '@/lib/config.ts'; import { buildConfigureShowData, configureCredentialStatus, @@ -13,7 +15,6 @@ import { formatConfigureSessionSummary, } from '@/lib/profile/render.ts'; import { secretSet } from '@/lib/secrets.ts'; -import { configSet } from '@/lib/config.ts'; const profileName = 'default'; diff --git a/cli/src/lib/profile-configure-data-plane.test.ts b/cli/src/lib/profile-configure-data-plane.test.ts index 59dc4ec2..c3bdb071 100644 --- a/cli/src/lib/profile-configure-data-plane.test.ts +++ b/cli/src/lib/profile-configure-data-plane.test.ts @@ -2,12 +2,13 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { getBootstrapCliContext } from '@/context.ts'; import { configGet } from '@/lib/config.ts'; import { configureRunSet } from '@/lib/profile-configure-core.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; -import { runWithCliRuntime } from '@/test-utils/runtime.ts'; import { secretGet } from '@/lib/secrets.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; let testHome = ''; const profileName = 'default'; diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index d5c4055b..05eb0004 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -1,6 +1,5 @@ import { configGet } from '@/lib/config.ts'; import { CliError } from '@/lib/errors.ts'; -import { secretExists, secretGet } from '@/lib/secrets.ts'; import { type ConfigureOptions } from '@/lib/profile-configure-core.ts'; import { configureCredentialStatus, @@ -8,9 +7,10 @@ import { managementPlaneStatusDetail, } from '@/lib/profile/model.ts'; import type { OutputSink } from '@/lib/runtime.ts'; +import { secretExists, secretGet } from '@/lib/secrets.ts'; import { span } from '@/ui/document.ts'; -import { renderDisplayText } from '@/ui/terminal/styles.ts'; import type { Prompts, SelectOption } from '@/ui/prompts.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; export type ConfigureWizardScope = 'both' | 'management' | 'lakehouse'; diff --git a/cli/src/lib/profile-configure.test.ts b/cli/src/lib/profile-configure.test.ts index 8825ebd3..e9363636 100644 --- a/cli/src/lib/profile-configure.test.ts +++ b/cli/src/lib/profile-configure.test.ts @@ -1,22 +1,23 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import type { Prompts } from '@/ui/prompts.ts'; -import { - hasConfigureCredentialFlags, - runConfigureWizard, -} from '@/lib/profile-configure.ts'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; +import { configGet } from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; import { configureRunSet, withConfigureProfileContext, } from '@/lib/profile-configure-core.ts'; -import { configGet } from '@/lib/config.ts'; -import { secretGet } from '@/lib/secrets.ts'; -import { setCliContext } from '@/context.ts'; +import { + hasConfigureCredentialFlags, + runConfigureWizard, +} from '@/lib/profile-configure.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; +import { secretGet } from '@/lib/secrets.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; +import type { Prompts } from '@/ui/prompts.ts'; const profileName = 'default'; diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index 59e26d86..5fa5083b 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -1,15 +1,11 @@ +import { getCliContext, isJsonOutput } from '@/context.ts'; import { defineArguments } from '@/lib/command.ts'; +import { ConfigurationError, CliError } from '@/lib/errors.ts'; import { configureRunSet, withConfigureProfileContext, type ConfigureOptions, } from '@/lib/profile-configure-core.ts'; -import { formatConfigureSessionSummary } from '@/lib/profile/render.ts'; -import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; -import { ConfigurationError, CliError } from '@/lib/errors.ts'; -import { getCliContext, isJsonOutput } from '@/context.ts'; -import { resolveWorkingProfile } from '@/lib/profile-store.ts'; -import { getOutputSink, type OutputSink } from '@/lib/runtime.ts'; import { collectLakehouseCredentials, collectManagementCredentials, @@ -18,13 +14,11 @@ import { type ConfigureWizardOptions, type ConfigureWizardScope, } from '@/lib/profile-configure-interactive.ts'; -import { defaultPrompts, PromptCancelled } from '@/ui/prompts.ts'; -import { - getTerminalWidth, - getVisibleTextWidth, - renderDisplayText, -} from '@/ui/terminal/styles.ts'; +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { resolveWorkingProfile } from '@/lib/profile-store.ts'; import { deriveProfileName } from '@/lib/profile/model.ts'; +import { formatConfigureSessionSummary } from '@/lib/profile/render.ts'; +import { getOutputSink, type OutputSink } from '@/lib/runtime.ts'; import { document, section, @@ -32,7 +26,13 @@ import { text, type DisplayText, } from '@/ui/document.ts'; +import { defaultPrompts, PromptCancelled } from '@/ui/prompts.ts'; import { renderDocumentText } from '@/ui/renderers/terminal.ts'; +import { + getTerminalWidth, + getVisibleTextWidth, + renderDisplayText, +} from '@/ui/terminal/styles.ts'; const DEFAULT_SCOPE: ConfigureWizardScope = 'both'; const AUTO_PROFILE_NAME = 'auto'; diff --git a/cli/src/lib/profile-status.test.ts b/cli/src/lib/profile-status.test.ts index 852043df..7036c198 100644 --- a/cli/src/lib/profile-status.test.ts +++ b/cli/src/lib/profile-status.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext, getCliContext } from '@/context.ts'; +import { createExecutionContext } from '@/lib/execution-context.ts'; import { configureRunSet } from '@/lib/profile-configure-core.ts'; import { configureVerify } from '@/lib/profile-status.ts'; -import { setCliContext, getCliContext } from '@/context.ts'; import { createCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -import { createExecutionContext } from '@/lib/execution-context.ts'; let testHome = ''; let mockFile = ''; diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 7d851864..e2684936 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -1,10 +1,10 @@ import { configGet } from '@/lib/config.ts'; import { CliError } from '@/lib/errors.ts'; import type { ExecutionContext } from '@/lib/execution-context.ts'; +import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; import { buildLakehouseVerifyRequest } from '@/lib/lakehouse/query.ts'; import type { WhoamiResponse } from '@/lib/management/model.ts'; import { formatWhoamiPrincipalLine } from '@/lib/management/render.ts'; -import { sendHttp, type HttpRequest } from '@/lib/http-request.ts'; import { formatProgressStatus, startProgress } from '@/lib/progress.ts'; export type ConfigureAuthPlane = 'management' | 'lakehouse'; diff --git a/cli/src/lib/profile-store.test.ts b/cli/src/lib/profile-store.test.ts index 87f9edf4..1bb8c9fc 100644 --- a/cli/src/lib/profile-store.test.ts +++ b/cli/src/lib/profile-store.test.ts @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { kvGet } from '@/lib/config.ts'; +import { kvSet } from '@/lib/config.ts'; import { DEFAULT_PROFILE_NAME, ensureProfileExists, @@ -16,7 +18,6 @@ import { resolveProfileReference, setActiveProfile, } from '@/lib/profile-store.ts'; -import { kvSet } from '@/lib/config.ts'; let testHome = ''; diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index 2e890a13..bb651688 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import { join, resolve, sep } from 'node:path'; + import { configDir, configFile, kvGet, kvSet } from '@/lib/config-files.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; import { configuredEnvConfig, readEnv } from '@/lib/env.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; export const DEFAULT_PROFILE_NAME = 'default'; const PROFILE_ID_KEY = 'profile_id'; diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index 8e8d3851..8315cf70 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -8,10 +8,20 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; import { credentialsFile, kvGet, kvSet } from '@/lib/config.ts'; -import { configureRunSet } from '@/lib/profile-configure-core.ts'; import { ConfigurationError } from '@/lib/errors.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { + getActiveProfileName, + getProfileId, + ensureProfilesLayout, + profileConfigFile, + profileDir, + profileExists, + setActiveProfile, +} from '@/lib/profile-store.ts'; import { createEmptyProfile, deleteProfile, @@ -22,15 +32,6 @@ import { renameProfile, updateProfile, } from '@/lib/profile/model.ts'; -import { - getActiveProfileName, - getProfileId, - ensureProfilesLayout, - profileConfigFile, - profileDir, - profileExists, - setActiveProfile, -} from '@/lib/profile-store.ts'; import { secretGet } from '@/lib/secrets.ts'; import { createFakeKeychain } from '@/test-utils/keychain.ts'; diff --git a/cli/src/lib/profile/model.ts b/cli/src/lib/profile/model.ts index 394260dc..18755321 100644 --- a/cli/src/lib/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -8,6 +8,7 @@ import { statSync, writeFileSync, } from 'node:fs'; + import { configDir, configFile, @@ -17,17 +18,8 @@ import { resolveApiBase, resolveManagementApiBase, } from '@/lib/config.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; import { configuredEnvConfig, readEnv } from '@/lib/env.ts'; -import { - migrateProfileSecrets, - secretDelete, - secretExists, - secretGet, - secretSet, - secretStoreDisplay, - type SecretStore, -} from '@/lib/secrets.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; import { assertSafeProfileName, DEFAULT_PROFILE_NAME, @@ -45,6 +37,15 @@ import { readProfileConfigRecord, setActiveProfile, } from '@/lib/profile-store.ts'; +import { + migrateProfileSecrets, + secretDelete, + secretExists, + secretGet, + secretSet, + secretStoreDisplay, + type SecretStore, +} from '@/lib/secrets.ts'; const PROFILE_SECRET_ACCOUNTS = [ 'api-key', diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts index 076a605b..90ac4b34 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import type { ProfileInspect } from '@/lib/profile/model.ts'; import { formatProfileInspectResult, diff --git a/cli/src/lib/profile/render.ts b/cli/src/lib/profile/render.ts index 27744d55..01ab9684 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,3 +1,9 @@ +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { + buildConfigureShowData, + type ProfileInspect, + type ProfileSummary, +} from '@/lib/profile/model.ts'; import { buildProfileInspectResultView, buildProfileListView, @@ -5,12 +11,6 @@ import { configureAuthenticationRows, type ProfileStatusResult, } from '@/lib/profile/views.ts'; -import { - buildConfigureShowData, - type ProfileInspect, - type ProfileSummary, -} from '@/lib/profile/model.ts'; -import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; import { renderDocumentText, renderRows } from '@/ui/renderers/terminal.ts'; import { nestedIndent, diff --git a/cli/src/lib/profile/views.test.ts b/cli/src/lib/profile/views.test.ts index 9fc89576..8801738a 100644 --- a/cli/src/lib/profile/views.test.ts +++ b/cli/src/lib/profile/views.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import type { ProfileInspect, ProfileSummary } from '@/lib/profile/model.ts'; import { buildProfileInspectView, diff --git a/cli/src/lib/profile/views.ts b/cli/src/lib/profile/views.ts index 853f887c..5fa1310f 100644 --- a/cli/src/lib/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -3,7 +3,14 @@ import { type ConfigureVerifyResult, formatConfigureVerifyRemediation, } from '@/lib/profile-status.ts'; -import type { SelectOption } from '@/ui/prompts.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import type { + ConfigureLakehouseCredential, + ConfigureManagementCredential, + ConfigureShowData, + ProfileInspect, + ProfileSummary, +} from '@/lib/profile/model.ts'; import { document, rows, @@ -15,16 +22,9 @@ import { type DisplayRow, type DisplayText, } from '@/ui/document.ts'; -import { TERMINAL_INDENT } from '@/ui/terminal/spacing.ts'; -import type { - ConfigureLakehouseCredential, - ConfigureManagementCredential, - ConfigureShowData, - ProfileInspect, - ProfileSummary, -} from '@/lib/profile/model.ts'; -import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import type { SelectOption } from '@/ui/prompts.ts'; import type { ShellExportView } from '@/ui/shell/model.ts'; +import { TERMINAL_INDENT } from '@/ui/terminal/spacing.ts'; const ACTIVE_PROFILE_MARK = '✓'; const INACTIVE_PROFILE_MARK = ' '; diff --git a/cli/src/lib/progress.test.ts b/cli/src/lib/progress.test.ts index f93d2d7d..df3e1174 100644 --- a/cli/src/lib/progress.test.ts +++ b/cli/src/lib/progress.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; + import { setCliContext } from '@/context.ts'; import { formatProgressStatus, diff --git a/cli/src/lib/query-column-types.test.ts b/cli/src/lib/query-column-types.test.ts index 40cd35b8..72b03c39 100644 --- a/cli/src/lib/query-column-types.test.ts +++ b/cli/src/lib/query-column-types.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { getColumnTypeMap, mapColumnSqlTypeToDataType, diff --git a/cli/src/lib/query-destination.test.ts b/cli/src/lib/query-destination.test.ts index f716716b..6d910f39 100644 --- a/cli/src/lib/query-destination.test.ts +++ b/cli/src/lib/query-destination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { NetworkError, getCliExitCode } from '@/lib/errors.ts'; import { writeQueryDestination } from '@/lib/query-destination.ts'; import { createCliRuntime } from '@/lib/runtime.ts'; diff --git a/cli/src/lib/query-format.test.ts b/cli/src/lib/query-format.test.ts index b54a0e81..1b4b72b2 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { configFile, kvSet } from '@/lib/config.ts'; import { parseLakehouseQueryResponse } from '@/lib/lakehouse-ndjson.ts'; import { @@ -16,13 +17,13 @@ import { truncateTextMiddle, } from '@/lib/query-format.ts'; import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; -import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, } from '@/test-utils/terminal.ts'; +import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; let terminalState: TerminalTestState | undefined; diff --git a/cli/src/lib/query-format.ts b/cli/src/lib/query-format.ts index 9b888cb8..03ef80cc 100644 --- a/cli/src/lib/query-format.ts +++ b/cli/src/lib/query-format.ts @@ -1,12 +1,13 @@ +import { + getQueryDefaultMaxColumnWidth, + getQueryDefaultLayout, +} from '@/lib/config.ts'; import type { LakehouseColumn, LakehouseQueryResult, LakehouseRow, } from '@/lib/lakehouse-ndjson.ts'; -import { - getQueryDefaultMaxColumnWidth, - getQueryDefaultLayout, -} from '@/lib/config.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; import { getColumnTypeMap, isTimestampValue, @@ -15,7 +16,6 @@ import { type ColumnTypeMap, type QueryDataType, } from '@/lib/query-column-types.ts'; -import { pluralizeLabel } from '@/lib/pluralize.ts'; import { redactPasswordFieldInText, redactSensitiveJsonText, @@ -24,6 +24,8 @@ import { formatRelativeTimestamp, formatTimestampWithRelative, } from '@/lib/relative-time.ts'; +import { span, type DisplayTextStyle } from '@/ui/document.ts'; +import type { QueryLayout } from '@/ui/layouts/query.ts'; import { getTerminalWidth, getVisibleTextWidth, @@ -33,8 +35,6 @@ import { truncateTerminalText, type TerminalTextAlignment, } from '@/ui/terminal/styles.ts'; -import type { QueryLayout } from '@/ui/layouts/query.ts'; -import { span, type DisplayTextStyle } from '@/ui/document.ts'; const DEFAULT_MAX_COLUMN_WIDTH = 32; const TABLE_CELL_PADDING = 1; diff --git a/cli/src/lib/query-output-args.test.ts b/cli/src/lib/query-output-args.test.ts index 4e4bfdf0..42a6286f 100644 --- a/cli/src/lib/query-output-args.test.ts +++ b/cli/src/lib/query-output-args.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { CliError } from '@/lib/errors.ts'; import { parseQueryOutputOptions, diff --git a/cli/src/lib/query-output-args.ts b/cli/src/lib/query-output-args.ts index 480186fc..6925a6f0 100644 --- a/cli/src/lib/query-output-args.ts +++ b/cli/src/lib/query-output-args.ts @@ -1,11 +1,6 @@ import { asCliArgString } from '@/lib/cli-args.ts'; import { defineArguments } from '@/lib/command.ts'; import { CliError } from '@/lib/errors.ts'; -import { - isApiNativeQueryFormat, - parseQueryResultFormat, - type QueryResultFormat, -} from '@/lib/query-output.ts'; import { resolvePagerOptions, type PagerMode, @@ -15,6 +10,11 @@ import { defaultDisplayOptions, type QueryDisplayOptions, } from '@/lib/query-format.ts'; +import { + isApiNativeQueryFormat, + parseQueryResultFormat, + type QueryResultFormat, +} from '@/lib/query-output.ts'; import { isQueryLayout, QUERY_LAYOUT_OPTIONS } from '@/ui/layouts/query.ts'; const MIN_MAX_COLUMN_WIDTH = 8; diff --git a/cli/src/lib/query-output.ts b/cli/src/lib/query-output.ts index 950a5f2a..a6a8bffe 100644 --- a/cli/src/lib/query-output.ts +++ b/cli/src/lib/query-output.ts @@ -1,5 +1,11 @@ -import type { OutputSink } from '@/lib/runtime.ts'; import { CliError } from '@/lib/errors.ts'; +import type { LakehouseApiQueryFormat } from '@/lib/lakehouse/query.ts'; +import { + resolvePagerOptions, + writePagedOutput, + type PagerOptions, +} from '@/lib/pager.ts'; +import { writeQueryDestination } from '@/lib/query-destination.ts'; import { defaultDisplayOptions, formatQueryCellRaw, @@ -8,13 +14,7 @@ import { renderQueryMarkdown, type QueryDisplayOptions, } from '@/lib/query-format.ts'; -import { - resolvePagerOptions, - writePagedOutput, - type PagerOptions, -} from '@/lib/pager.ts'; -import { writeQueryDestination } from '@/lib/query-destination.ts'; -import type { LakehouseApiQueryFormat } from '@/lib/lakehouse/query.ts'; +import type { OutputSink } from '@/lib/runtime.ts'; export type { LakehouseApiQueryFormat }; export type QueryResultFormat = diff --git a/cli/src/lib/redact.test.ts b/cli/src/lib/redact.test.ts index 90ae9cd1..9478e71a 100644 --- a/cli/src/lib/redact.test.ts +++ b/cli/src/lib/redact.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { MASKED_PASSWORD_PLACEHOLDER, redactPasswordFieldInText, diff --git a/cli/src/lib/relative-time.test.ts b/cli/src/lib/relative-time.test.ts index 2ed181f9..572dac04 100644 --- a/cli/src/lib/relative-time.test.ts +++ b/cli/src/lib/relative-time.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { formatRelativeTimestamp, formatTimestampWithRelative, diff --git a/cli/src/lib/runtime.ts b/cli/src/lib/runtime.ts index bde43926..b8ef112d 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs'; + import { getBootstrapCliContextState, isJsonOutput, diff --git a/cli/src/lib/secrets.test.ts b/cli/src/lib/secrets.test.ts index 814d31e0..e9f8b7f6 100644 --- a/cli/src/lib/secrets.test.ts +++ b/cli/src/lib/secrets.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createFakeKeychain } from '@/test-utils/keychain.ts'; +import { join } from 'node:path'; + +import { credentialsFile } from '@/lib/config.ts'; import { getProfileId } from '@/lib/profile-store.ts'; import { createSecretStore, @@ -10,7 +11,7 @@ import { secretGet, secretSet, } from '@/lib/secrets.ts'; -import { credentialsFile } from '@/lib/config.ts'; +import { createFakeKeychain } from '@/test-utils/keychain.ts'; let testHome = ''; diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 2be74040..e8e7ef3c 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,10 +1,11 @@ import type { SpawnSyncOptions } from 'node:child_process'; import { spawnSync } from 'node:child_process'; import { chmodSync, statSync } from 'node:fs'; + import { credentialsFile, kvGet, kvSet, kvUnset } from '@/lib/config.ts'; +import { readEnv } from '@/lib/env.ts'; import { CliError } from '@/lib/errors.ts'; import { logWarn } from '@/lib/log.ts'; -import { readEnv } from '@/lib/env.ts'; import { assertSafeProfileName, ensureProfileId, diff --git a/cli/src/lib/service-account-provision.test.ts b/cli/src/lib/service-account-provision.test.ts index e3bfdec3..58e9d5e9 100644 --- a/cli/src/lib/service-account-provision.test.ts +++ b/cli/src/lib/service-account-provision.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { ConfigurationError } from '@/lib/errors.ts'; import { parseServiceAccountScope } from '@/lib/service-account-provision.ts'; diff --git a/cli/src/lib/stream-lines.test.ts b/cli/src/lib/stream-lines.test.ts index 2c69ed2b..7ccd4271 100644 --- a/cli/src/lib/stream-lines.test.ts +++ b/cli/src/lib/stream-lines.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { readTextStreamLines } from '@/lib/stream-lines.ts'; async function collectLines( diff --git a/cli/src/lib/tabular-result.ts b/cli/src/lib/tabular-result.ts index 5b8488ce..edc776f6 100644 --- a/cli/src/lib/tabular-result.ts +++ b/cli/src/lib/tabular-result.ts @@ -1,11 +1,11 @@ import { CliError } from '@/lib/errors.ts'; -import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; import { defaultDisplayOptions, getQueryColumnNames, renderQueryHumanOutput, renderQueryMarkdown, } from '@/lib/query-format.ts'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.ts'; export type ManagementOutputFormat = 'json' | 'table' | 'csv' | 'markdown'; diff --git a/cli/src/lib/updater-config.ts b/cli/src/lib/updater-config.ts index 5c2ade99..c288c838 100644 --- a/cli/src/lib/updater-config.ts +++ b/cli/src/lib/updater-config.ts @@ -1,10 +1,10 @@ +import { objectKeys } from '@/lib/object.ts'; import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; import { RELEASE_CHECKSUMS_ASSET, RELEASE_PLATFORM_CONFIG, type ReleasePlatform, } from '@/release-manifest.ts'; -import { objectKeys } from '@/lib/object.ts'; const DAILY_MS = 24 * 60 * 60 * 1000; const EXECUTABLE_NAME = 'altertable'; diff --git a/cli/src/lib/updater.test.ts b/cli/src/lib/updater.test.ts index f04533a9..a8f7e2bc 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, @@ -10,22 +11,22 @@ import { import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { delimiter, join, resolve } from 'node:path'; -import { createHash } from 'node:crypto'; + import { buildMainCommand } from '@/cli.ts'; import { executeUpdateCommand, type UpdateCommandDependencies, updateCommand, } from '@/commands/update/index.ts'; -import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; -import { VERSION } from '@/version.ts'; -import { ConfigurationError } from '@/lib/errors.ts'; import { executeCommand, resolveCommandSelection, } from '@/lib/command-parser.ts'; import { configFile, kvSet } from '@/lib/config.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { UpdaterConfig } from '@/lib/updater-config.ts'; import { checkForUpdate, compareVersions, @@ -49,9 +50,9 @@ import { shouldRunAutomaticUpdateCheck, verifySha256, } from '@/lib/updater.ts'; -import { UpdaterConfig } from '@/lib/updater-config.ts'; -import { createCliRuntime } from '@/lib/runtime.ts'; +import { CLI_PACKAGE_METADATA } from '@/package-metadata.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; +import { VERSION } from '@/version.ts'; let testHome = ''; const packageJsonPath = resolve(import.meta.dir, '../../package.json'); diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 701d3edf..4a19704e 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, @@ -8,20 +10,16 @@ import { } from 'node:fs'; import { chmodSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; -import { createHash, randomBytes } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; + import type { CliContext } from '@/context.ts'; import { isJsonOutput } from '@/context.ts'; -import { USER_AGENT, VERSION } from '@/version.ts'; import { configDir, configGetGlobal } from '@/lib/config.ts'; +import { copyProcessEnv, readEnv, readEnvFrom } from '@/lib/env.ts'; import { CliError, HttpError, NetworkError } from '@/lib/errors.ts'; +import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; import { hasObjectKey } from '@/lib/object.ts'; import type { OutputSink } from '@/lib/runtime.ts'; import { getOutputSink } from '@/lib/runtime.ts'; -import { renderDisplayText } from '@/ui/terminal/styles.ts'; -import { span } from '@/ui/document.ts'; -import { copyProcessEnv, readEnv, readEnvFrom } from '@/lib/env.ts'; -import { resolveProcessExecutablePath } from '@/lib/executable-path.ts'; import { UpdaterInstallationKind, UpdaterCheckIntervals, @@ -35,6 +33,9 @@ import { type UpdateInstallMethod, type UpdateSource, } from '@/lib/updater-config.ts'; +import { span } from '@/ui/document.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { USER_AGENT, VERSION } from '@/version.ts'; export type ReleaseInfo = { version: string; diff --git a/cli/src/lib/url-policy.test.ts b/cli/src/lib/url-policy.test.ts index 75fc141f..bd399c22 100644 --- a/cli/src/lib/url-policy.test.ts +++ b/cli/src/lib/url-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { assertAllowedApiBase } from '@/lib/url-policy.ts'; describe('assertAllowedApiBase', () => { diff --git a/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 1c2787e7..93106cc2 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -2,30 +2,31 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; + import { buildMainCommand } from '@/cli.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; import { getBootstrapCliContext } from '@/context.ts'; import { defineCommand } from '@/lib/command.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; import { buildStructuredHelp, renderAltertableUsage, resolveSubCommandForUsage, } from '@/lib/usage.ts'; -import { configureRunSet } from '@/lib/profile-configure-core.ts'; -import { createCliRuntime, setCliRuntime } from '@/lib/runtime.ts'; import { runWithCliRuntime } from '@/test-utils/runtime.ts'; -import { VERSION } from '@/version.ts'; -import { span } from '@/ui/document.ts'; -import { - getVisibleTextWidth, - renderDisplayText, -} from '@/ui/terminal/styles.ts'; -import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, } from '@/test-utils/terminal.ts'; +import { span } from '@/ui/document.ts'; +import { + getVisibleTextWidth, + renderDisplayText, +} from '@/ui/terminal/styles.ts'; +import { VERSION } from '@/version.ts'; describe('renderAltertableUsage', () => { test('groups root commands before global flags in a custom help panel', async () => { diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 0189fd57..e2ceeb30 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,8 +1,3 @@ -import type { Command } from '@/lib/command.ts'; -import { - ALTERTABLE_COMMAND_GROUPS, - type AltertableCommandGroup, -} from '@/lib/command.ts'; import { aliasFlagToken, resolveCommandDescriptor, @@ -11,14 +6,19 @@ import { type CommandDescriptor, type ResolvedCommandMetadata, } from '@/lib/command-descriptor.ts'; +import { resolveCommandSelection } from '@/lib/command-parser.ts'; +import type { Command } from '@/lib/command.ts'; +import { + ALTERTABLE_COMMAND_GROUPS, + type AltertableCommandGroup, +} from '@/lib/command.ts'; +import { readEnv } from '@/lib/env.ts'; +import { getOutputSink } from '@/lib/runtime.ts'; import { span, type DisplaySpan } from '@/ui/document.ts'; import { getVisibleTextWidth, renderDisplayText, } from '@/ui/terminal/styles.ts'; -import { readEnv } from '@/lib/env.ts'; -import { getOutputSink } from '@/lib/runtime.ts'; -import { resolveCommandSelection } from '@/lib/command-parser.ts'; const HELP_INDENT = ' '; const HELP_COLUMN_GAP = ' '; diff --git a/cli/src/lib/user-directories.ts b/cli/src/lib/user-directories.ts index 090199e8..dab504e7 100644 --- a/cli/src/lib/user-directories.ts +++ b/cli/src/lib/user-directories.ts @@ -1,5 +1,6 @@ import { homedir } from 'node:os'; import { isAbsolute, join, parse } from 'node:path'; + import { readEnv } from '@/lib/env.ts'; import { ConfigurationError } from '@/lib/errors.ts'; diff --git a/cli/src/test-utils/lakehouse.ts b/cli/src/test-utils/lakehouse.ts index f4499748..8558303d 100644 --- a/cli/src/test-utils/lakehouse.ts +++ b/cli/src/test-utils/lakehouse.ts @@ -5,8 +5,9 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { setCliContext } from '@/context.ts'; import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; diff --git a/cli/src/ui/prompts.ts b/cli/src/ui/prompts.ts index 3cf8a30b..dc0c232c 100644 --- a/cli/src/ui/prompts.ts +++ b/cli/src/ui/prompts.ts @@ -1,3 +1,5 @@ +import { stdin as input, stderr as output } from 'node:process'; + import { confirm as clackConfirm, isCancel, @@ -5,7 +7,7 @@ import { select as clackSelect, text as clackText, } from '@clack/prompts'; -import { stdin as input, stderr as output } from 'node:process'; + import { CliError } from '@/lib/errors.ts'; import { ensurePromptColorAlignment } from '@/ui/terminal/styles.ts'; diff --git a/cli/src/ui/renderers/terminal.ts b/cli/src/ui/renderers/terminal.ts index 927dcd71..f974f3ed 100644 --- a/cli/src/ui/renderers/terminal.ts +++ b/cli/src/ui/renderers/terminal.ts @@ -12,8 +12,8 @@ import { TERMINAL_LABEL_WIDTH, TERMINAL_NESTED_LABEL_WIDTH, } from '@/ui/terminal/spacing.ts'; -import { renderFixedTable } from '@/ui/terminal/table.ts'; import { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { renderFixedTable } from '@/ui/terminal/table.ts'; export type TerminalRenderOptions = { indent?: string; diff --git a/cli/src/ui/shell/render.test.ts b/cli/src/ui/shell/render.test.ts index 4b6636da..9e1e14bf 100644 --- a/cli/src/ui/shell/render.test.ts +++ b/cli/src/ui/shell/render.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; + import { renderShellExportView } from '@/ui/shell/render.ts'; describe('renderShellExportView', () => { diff --git a/cli/src/ui/terminal/styles.test.ts b/cli/src/ui/terminal/styles.test.ts index bebf02d5..c51507c7 100644 --- a/cli/src/ui/terminal/styles.test.ts +++ b/cli/src/ui/terminal/styles.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { span } from '@/ui/document.ts'; import { getVisibleTextWidth, setTerminalColorMode, @@ -7,7 +9,6 @@ import { applyTerminalColorFromContext, renderDisplayText, } from '@/ui/terminal/styles.ts'; -import { span } from '@/ui/document.ts'; const originalNoColor = process.env.NO_COLOR; const originalTerm = process.env.TERM; diff --git a/cli/src/ui/terminal/styles.ts b/cli/src/ui/terminal/styles.ts index dc84c2f1..55864357 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -1,5 +1,4 @@ import { readEnv, setEnv, unsetEnv } from '@/lib/env.ts'; - import type { DisplayText, DisplayTextStyle } from '@/ui/document.ts'; export const DEFAULT_TERMINAL_WIDTH = 80; diff --git a/cli/src/ui/terminal/table.test.ts b/cli/src/ui/terminal/table.test.ts index 18480d67..6f213abd 100644 --- a/cli/src/ui/terminal/table.test.ts +++ b/cli/src/ui/terminal/table.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { renderFixedTable } from '@/ui/terminal/table.ts'; + import { formatApiRoutes } from '@/commands/api/lib/render.ts'; +import { span } from '@/ui/document.ts'; import { setTerminalColorMode, getVisibleTextWidth, } from '@/ui/terminal/styles.ts'; -import { span } from '@/ui/document.ts'; +import { renderFixedTable } from '@/ui/terminal/table.ts'; const originalAltertableColor = process.env.ALTERTABLE_COLOR; const originalOscHyperlink = process.env.OSC_HYPERLINK; diff --git a/cli/src/ui/terminal/table.ts b/cli/src/ui/terminal/table.ts index 51a68d7a..cd992011 100644 --- a/cli/src/ui/terminal/table.ts +++ b/cli/src/ui/terminal/table.ts @@ -1,3 +1,8 @@ +import { + span, + type DisplayTableColumn, + type DisplayTableOptions, +} from '@/ui/document.ts'; import { getTerminalWidth, getVisibleTextWidth, @@ -5,11 +10,6 @@ import { renderDisplayText, truncateTerminalText, } from '@/ui/terminal/styles.ts'; -import { - span, - type DisplayTableColumn, - type DisplayTableOptions, -} from '@/ui/document.ts'; const COLUMN_GAP = ' '; const FLEX_SHRINK_MIN_WIDTH = 4; diff --git a/data-app/starter/.oxfmtrc.json b/data-app/starter/.oxfmtrc.json index 94ca9973..569314e7 100644 --- a/data-app/starter/.oxfmtrc.json +++ b/data-app/starter/.oxfmtrc.json @@ -5,5 +5,7 @@ "trailingComma": "es5", "arrowParens": "avoid", "printWidth": 80, - "endOfLine": "lf" + "endOfLine": "lf", + "sortImports": true, + "sortPackageJson": false } diff --git a/data-app/starter/src/App.tsx b/data-app/starter/src/App.tsx index 7e8c8271..f52c1a74 100644 --- a/data-app/starter/src/App.tsx +++ b/data-app/starter/src/App.tsx @@ -1,4 +1,5 @@ import { GettingStarted } from '@altertable/data-app-react/ui'; + import { dataContext } from '#app/data-context.ts'; /** Connectivity-only screen. Investigate the requested question, then replace this with a view diff --git a/data-app/starter/src/config.ts b/data-app/starter/src/config.ts index 5452cbaa..3f9f57b2 100644 --- a/data-app/starter/src/config.ts +++ b/data-app/starter/src/config.ts @@ -1,5 +1,6 @@ import { defineDataApp } from '@altertable/data-app'; import { parseAppearance } from '@altertable/data-app/appearance'; + import app from '#config'; export default defineDataApp({ diff --git a/data-app/starter/src/data-context.ts b/data-app/starter/src/data-context.ts index 7596d909..5dbb0d18 100644 --- a/data-app/starter/src/data-context.ts +++ b/data-app/starter/src/data-context.ts @@ -1,5 +1,6 @@ import { createDataContext } from '@altertable/data-app-react'; import { defineQueryNames } from '@altertable/data-app/contract'; + import type app from '#app/config.ts'; /** Setup context only. Replace it with the exploration's scope, exact definitions, limitations, diff --git a/data-app/starter/src/main.tsx b/data-app/starter/src/main.tsx index ff1f0be7..47c2f1ee 100644 --- a/data-app/starter/src/main.tsx +++ b/data-app/starter/src/main.tsx @@ -1,4 +1,5 @@ import { injectDataAppStyles, mountDataApp } from '@altertable/data-app-react'; + import { App } from '#app/App.tsx'; import app from '#app/config.ts'; diff --git a/data-app/starter/src/operations.ts b/data-app/starter/src/operations.ts index 7c33fcce..6eb959a0 100644 --- a/data-app/starter/src/operations.ts +++ b/data-app/starter/src/operations.ts @@ -1,4 +1,5 @@ import { connectionCheck } from '@altertable/data-app/contract'; + import app from '#app/config.ts'; /** Connectivity probe only: its successful query supplies no analytical result. Replace it with diff --git a/data-app/starter/src/preview.ts b/data-app/starter/src/preview.ts index bb8b8458..1c14ea64 100644 --- a/data-app/starter/src/preview.ts +++ b/data-app/starter/src/preview.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; + import { localLakehouse } from '@altertable/data-app-bun'; import { serveAppFile } from '@altertable/data-app-bun/preview'; diff --git a/data-app/starter/src/server.ts b/data-app/starter/src/server.ts index 3e919413..7cfdb49e 100644 --- a/data-app/starter/src/server.ts +++ b/data-app/starter/src/server.ts @@ -1,6 +1,7 @@ -import page from '#app/index.html'; import { serveLocalApp } from '@altertable/data-app-bun'; -import { operations } from '#app/operations.ts'; + import app from '#app/config.ts'; +import page from '#app/index.html'; +import { operations } from '#app/operations.ts'; serveLocalApp({ page, operations, title: app.title }); diff --git a/data-app/tests/server.ts b/data-app/tests/server.ts index 7706973e..2def910c 100644 --- a/data-app/tests/server.ts +++ b/data-app/tests/server.ts @@ -1,9 +1,10 @@ -import starter from '../starter/src/index.html'; -import view from '../starter/fixtures/view.html'; -import components from '../starter/fixtures/index.html'; -import { operations as activityOperations } from '../starter/fixtures/activity.ts'; -import { createDataHandler } from '@altertable/data-app/server'; import { DataSourceError } from '@altertable/data-app/contract'; +import { createDataHandler } from '@altertable/data-app/server'; + +import { operations as activityOperations } from '../starter/fixtures/activity.ts'; +import components from '../starter/fixtures/index.html'; +import view from '../starter/fixtures/view.html'; +import starter from '../starter/src/index.html'; import { operations } from '../starter/src/operations.ts'; let fail = false; const handler = createDataHandler( diff --git a/data-app/tests/starter.spec.ts b/data-app/tests/starter.spec.ts index 100d8418..50a1851b 100644 --- a/data-app/tests/starter.spec.ts +++ b/data-app/tests/starter.spec.ts @@ -1,6 +1,7 @@ -import { expect, test } from '@playwright/test'; import { readFile } from 'node:fs/promises'; +import { expect, test } from '@playwright/test'; + test.beforeEach(async ({ request }) => { await request.post('/__test/state', { data: 'success' }); }); From 2311f39c5bd4812bf9cdd66a21e37fed2a18a3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 19:45:06 +0200 Subject: [PATCH 06/10] chore(tooling): focus package checks on packed consumers Make root check:package build and validate the extracted npm artifact independently of the repository test suite. Run the full generated-app lifecycle against that artifact instead of checking only its scaffold. Have the canonical gate call the focused package command once and remove the redundant raw-bundle smoke. Preserve offline scaffolding coverage, minimum Bun runtime compatibility, and native release checks. Validation: standalone check:package passes without the integration mock; full check --integration, host native compile/smoke, and final Bun 1.1.0 bundle smoke pass. --- DEVELOPMENT.md | 10 ++++++++-- cli/scripts/check-package.ts | 1 - package.json | 2 +- scripts/verify.sh | 4 +--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 20e9aea9..e0151090 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,7 +23,7 @@ bun run knip # unused code, exports, and dependencies bun run knip:production # production reachability bun run check # full canonical gate, including consumers and packages bun run check --quick # focused gate without browser/package smoke tests -bun run check:package # alias for the full canonical gate +bun run check:package # focused build, pack, and extracted-consumer smoke ``` The root is a private command entry point, not a workspace: `cli/bun.lock` and the @@ -37,7 +37,9 @@ CLI tests intentionally use `bun test` because they exercise Bun APIs and the CL runtime. The Bun build plugin embeds the offline starter in both npm and native artifacts; retaining it avoids introducing another build tool and plugin adapter. Formatting uses single quotes, ES5 trailing commas, unparenthesized single arrow -parameters, an 80-column print width, and LF line endings. Generated API files stay +parameters, an 80-column print width, and LF line endings. Import sorting is enabled while +package JSON key order is preserved (`sortPackageJson: false` in Oxfmt 0.71). +Generated API files stay under their generators' control. Typechecking uses TypeScript 7.0.2. The CLI installs that compiler under the explicit @@ -47,6 +49,10 @@ dependency stays at 6.0.3 for generation only; both CLI and top-level test check invoke the version 7 compiler explicitly. Knip excludes this alias from unused dependency detection because it is executed by its installed file path. +`check:package` builds once, packs and extracts the CLI, and runs the full generated-app +consumer smoke against the extracted executable. It does not run repository unit tests, +Playwright tests, or lakehouse integration. The full `check` invokes it once. + Additional commands from `cli/`: ```bash diff --git a/cli/scripts/check-package.ts b/cli/scripts/check-package.ts index e171f951..d0b6b00b 100644 --- a/cli/scripts/check-package.ts +++ b/cli/scripts/check-package.ts @@ -33,7 +33,6 @@ try { } await smokeNpmBundle([ `--bundle=${join(directory, 'package', manifest.bin.altertable)}`, - '--scaffold-only', ]); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/package.json b/package.json index 7e0a3397..86b07b37 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:watch": "bun run --cwd cli test:watch", "setup": "bun install --cwd cli --frozen-lockfile && bun install --cwd data-app/starter --frozen-lockfile && bun install --cwd data-app/tests --frozen-lockfile", "check": "./scripts/verify.sh", - "check:package": "bun run check", + "check:package": "bun run --cwd cli pack:check", "knip": "bun run --cwd cli knip", "knip:production": "bun run --cwd cli knip:production" }, diff --git a/scripts/verify.sh b/scripts/verify.sh index 54da93df..fd210ba9 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -65,9 +65,7 @@ fi cd "${REPO_ROOT}/cli" run_step "data app browser tests" bun run data-app:test:browser -run_step "pack:check" bun run pack:check - -run_step "npm bundle smoke test" bun run scripts/smoke-npm-bundle.ts +run_step "package checks" bun run --cwd "${REPO_ROOT}" check:package if [[ "${RUN_INTEGRATION}" == true ]]; then check_mock_server From f437d093326ea159cd8c82ba624fb8527b0d3fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 20:57:30 +0200 Subject: [PATCH 07/10] docs(data-app): install isolated consumers before verification Start the documented verification sequence with root setup so CLI, starter, and browser-test dependencies are installed before typechecking. Use the canonical root check commands. Validation: exported fresh tracked source without node_modules, ran bun run setup, then ran the data-app typecheck, lint, formatting, and build checks successfully. --- data-app/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data-app/README.md b/data-app/README.md index 862a9c3f..dc3f1784 100644 --- a/data-app/README.md +++ b/data-app/README.md @@ -61,11 +61,11 @@ app imports; authors update their source as needed, run `app lint`, and restart ## Verify ```fish +bun run setup bun run --cwd cli data-app:check -bun install --cwd data-app/tests --frozen-lockfile bun run --cwd cli data-app:test:browser -./scripts/verify.sh --quick -./scripts/verify.sh +bun run check --quick +bun run check ``` Install Playwright Chromium with `bunx playwright install chromium` from `data-app/tests/` From 90b7beb554cd57b9df77a417a628f565b78f1433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 20:57:30 +0200 Subject: [PATCH 08/10] fix(tooling): reject conflicting quick and integration checks Reject --quick combined with --integration before starting verification. Previously the quick exit silently skipped the requested integration tests. Explain that --integration runs the full integration gate and show mutually exclusive modes in usage. Validation: both flag orders fail immediately through the script and root command; default and each individual mode still reach verification. Bash syntax and diff checks pass. --- scripts/verify.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/verify.sh b/scripts/verify.sh index fd210ba9..c6eab485 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -17,12 +17,18 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Unknown flag: $1" >&2 - echo "Usage: $0 [--quick] [--integration]" >&2 + echo "Usage: $0 [--quick | --integration]" >&2 exit 1 ;; esac done +if [[ "${QUICK}" == true && "${RUN_INTEGRATION}" == true ]]; then + echo "--quick and --integration cannot be combined." >&2 + echo "Use --integration to run the full gate with integration tests." >&2 + exit 1 +fi + run_step() { local name="$1" shift From e45fcedf8f56b15880cd41d339c9d15d02418bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 20 Oct 2026 09:34:27 +0200 Subject: [PATCH 09/10] chore(tooling): upgrade Bun and runtime types to 1.4.3 Pin development, CI, and release builds to Bun 1.4.3 through the shared version file and package manager metadata. Update Bun declarations in the CLI and independently locked starter/browser consumers without changing published runtime requirements. Validation: frozen setup and full root integration verification under Bun 1.4.3 with Node 24.18.0, host native release build/smoke, and final npm bundle scaffold smoke on Bun 1.1.0 all pass. --- .bun-version | 2 +- cli/bun.lock | 6 +++--- cli/package.json | 4 ++-- data-app/starter/bun.lock | 6 +++--- data-app/starter/package.json | 2 +- data-app/tests/bun.lock | 6 +++--- data-app/tests/package.json | 2 +- package.json | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.bun-version b/.bun-version index 9df886c4..428b770e 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.4.2 +1.4.3 diff --git a/cli/bun.lock b/cli/bun.lock index 51de258a..018658d7 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -10,7 +10,7 @@ "yaml": "2.9.0", }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "eslint-plugin-no-relative-import-paths": "1.6.1", "knip": "6.32.2", "openapi-typescript": "7.13.0", @@ -213,7 +213,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], @@ -267,7 +267,7 @@ "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], diff --git a/cli/package.json b/cli/package.json index 7b38b9d0..fd39407d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -54,7 +54,7 @@ "yaml": "2.9.0" }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "eslint-plugin-no-relative-import-paths": "1.6.1", "knip": "6.32.2", "openapi-typescript": "7.13.0", @@ -67,5 +67,5 @@ "engines": { "bun": ">=1.1.0" }, - "packageManager": "bun@1.4.2" + "packageManager": "bun@1.4.3" } diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index b6fc8b43..a0f036e8 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -12,7 +12,7 @@ "react-dom": "19.3.0", }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "oxfmt": "0.71.0", @@ -149,7 +149,7 @@ "@tanstack/react-query": ["@tanstack/react-query@5.104.0", "", { "dependencies": { "@tanstack/query-core": "5.104.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw=="], - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -219,7 +219,7 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], diff --git a/data-app/starter/package.json b/data-app/starter/package.json index feb60682..c1153608 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -22,7 +22,7 @@ "react-dom": "19.3.0" }, "devDependencies": { - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "oxfmt": "0.71.0", diff --git a/data-app/tests/bun.lock b/data-app/tests/bun.lock index e327efa8..feb3461a 100644 --- a/data-app/tests/bun.lock +++ b/data-app/tests/bun.lock @@ -9,7 +9,7 @@ }, "devDependencies": { "@playwright/test": "1.63.0", - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2", }, @@ -36,7 +36,7 @@ "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], @@ -80,7 +80,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], diff --git a/data-app/tests/package.json b/data-app/tests/package.json index 64173c33..2131f915 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "@playwright/test": "1.63.0", - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2" }, diff --git a/package.json b/package.json index 86b07b37..c47fcf4d 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,5 @@ "knip": "bun run --cwd cli knip", "knip:production": "bun run --cwd cli knip:production" }, - "packageManager": "bun@1.4.2" + "packageManager": "bun@1.4.3" } From a101704444946263abf4cab7bf394b344e06b5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 20 Oct 2026 09:35:30 +0200 Subject: [PATCH 10/10] chore(tooling): name the private root altertable-cli-workspace Use the agreed repository-workspace naming convention for the private root command package. Keep published package names and all independent consumer dependency graphs unchanged. Validation: frozen root setup installs all three existing lockfiles without changes; root manifest formatting and diff checks pass. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c47fcf4d..431ce0a5 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "altertable-cli-development", + "name": "altertable-cli-workspace", "private": true, "scripts": { "build": "bun run --cwd cli build",