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/.github/workflows/verify.yml b/.github/workflows/verify.yml index 251994af..75ad0a2f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -43,17 +43,24 @@ 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 - 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 237fd7f5..7769be60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,13 @@ 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 -./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: @@ -38,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 df496dc6..1e1b70af 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,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: `./scripts/verify.sh` (use `--quick` for iterative CLI-only work; full verify 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 eb09a30e..e0151090 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,6 +6,55 @@ 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 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 # focused build, pack, and extracted-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; 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. + +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. 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 +`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. + +`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 cd cli bun install @@ -23,7 +72,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 +95,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 @@ -106,7 +155,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 @@ -177,12 +226,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/.oxfmtrc.json b/cli/.oxfmtrc.json index 60961c94..e57a5994 100644 --- a/cli/.oxfmtrc.json +++ b/cli/.oxfmtrc.json @@ -1,4 +1,11 @@ { "$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", + "sortImports": true, + "sortPackageJson": false } diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 68b85b7e..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. @@ -14,10 +14,12 @@ 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) -./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/`: @@ -167,13 +169,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)); }, @@ -206,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/cli/bun.lock b/cli/bun.lock index 7a112f10..e8c6923d 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -11,14 +11,15 @@ "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", - "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", }, }, }, @@ -119,93 +120,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=="], @@ -215,10 +216,50 @@ "@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=="], + "@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=="], @@ -229,7 +270,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=="], @@ -283,11 +324,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=="], @@ -311,7 +352,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=="], @@ -319,6 +360,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 6cf5bd06..7921ff00 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", @@ -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": { "@altertable/data-app-manifest": "0.72.0", @@ -54,17 +55,18 @@ "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", - "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" }, - "packageManager": "bun@1.4.2" + "packageManager": "bun@1.4.3" } diff --git a/cli/scripts/check-data-app.ts b/cli/scripts/check-data-app.ts index e51aa073..1f24152c 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'; -for (const project of ["starter", "tests"]) { +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"]] : []), + ...['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/check-package.ts b/cli/scripts/check-package.ts new file mode 100644 index 00000000..d0b6b00b --- /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)}`, + ]); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/cli/scripts/command-reference.test.ts b/cli/scripts/command-reference.test.ts index ba341f21..3f4736c5 100644 --- a/cli/scripts/command-reference.test.ts +++ b/cli/scripts/command-reference.test.ts @@ -1,21 +1,25 @@ -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"; +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"; +} from '@/../scripts/command-reference.ts'; +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'; async function resolveReference() { const descriptor = await resolveCommandDescriptor(buildMainCommand()); @@ -53,74 +57,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..89d3fe2c 100644 --- a/cli/scripts/command-reference.ts +++ b/cli/scripts/command-reference.ts @@ -1,22 +1,22 @@ +import { + aliasFlagToken, + visibleCommandDescriptors, + type CommandArgumentDescriptor, + type CommandDescriptor, +} from '@/lib/command-descriptor.ts'; import { ALTERTABLE_COMMAND_GROUPS, COMMAND_ARGUMENT_TYPES, COMMAND_FLAG_SCOPES, POSITIONAL_COMPLETION_KINDS, type AltertableCommandGroup, -} from "@/lib/command.ts"; -import { - aliasFlagToken, - visibleCommandDescriptors, - type CommandArgumentDescriptor, - type CommandDescriptor, -} from "@/lib/command-descriptor.ts"; +} from '@/lib/command.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..80d924f8 100644 --- a/cli/scripts/generate-command-reference.ts +++ b/cli/scripts/generate-command-reference.ts @@ -1,17 +1,21 @@ -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 { 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'; +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); @@ -19,15 +23,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 +40,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..004823ad 100644 --- a/cli/scripts/generate-openapi-index.ts +++ b/cli/scripts/generate-openapi-index.ts @@ -1,23 +1,32 @@ -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 +51,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 +65,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 +79,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..08ab0999 100644 --- a/cli/scripts/generate-openapi-types.ts +++ b/cli/scripts/generate-openapi-types.ts @@ -1,15 +1,17 @@ -import openapiTS, { astToString } from "openapi-typescript"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import openapiTS, { astToString } from 'openapi-typescript'; + 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 +20,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..84633230 100644 --- a/cli/scripts/generated-artifact.test.ts +++ b/cli/scripts/generated-artifact.test.ts @@ -1,18 +1,21 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + 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 +24,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..ac91a570 100644 --- a/cli/scripts/npm-bundle.ts +++ b/cli/scripts/npm-bundle.ts @@ -1,18 +1,21 @@ -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 +25,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..08b35835 100644 --- a/cli/scripts/publish-release.ts +++ b/cli/scripts/publish-release.ts @@ -1,12 +1,15 @@ -import { basename, join, resolve } from "node:path"; -import { isRecord } from "../src/lib/object.ts"; +import { basename, join, resolve } from 'node:path'; -export type NpmVersionLookup = { status: "published"; version: string } | { status: "missing" }; +import { isRecord } from '../src/lib/object.ts'; -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 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'; type PackageIdentity = { name: string; @@ -15,24 +18,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 +52,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 +61,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 +174,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 +214,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..96dd89b6 100644 --- a/cli/scripts/release.test.ts +++ b/cli/scripts/release.test.ts @@ -1,25 +1,9 @@ -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 { 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 { NPM_BUNDLE_PATH } from '@/../scripts/npm-bundle.ts'; import { githubReleasePublishCommand, githubReleaseUploadCommand, @@ -31,9 +15,7 @@ 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 { RELEASE_BUNDLE_ASSET, RELEASE_CHECKSUMS_ASSET, @@ -42,11 +24,30 @@ import { RELEASE_METADATA_ASSET, RELEASE_TARGETS, releaseCiMatrix, -} from "@/../scripts/release-manifest.ts"; -import { VERSION } from "@/version.ts"; +} 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[] = []; -const repositoryRoot = join(import.meta.dir, "../.."); +const repositoryRoot = join(import.meta.dir, '../..'); type WorkflowStep = { env?: Record; @@ -68,14 +69,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 +89,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 +145,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 +208,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 +286,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 +362,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, "", "network timeout")).toThrow( - "npm view failed (1): network timeout", + expect(parseNpmVersionLookup(1, '', 'npm error code E404')).toEqual({ + status: 'missing', + }); + 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, "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, '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 +459,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 +488,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 +759,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 +769,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..b084b9ec 100644 --- a/cli/scripts/release.ts +++ b/cli/scripts/release.ts @@ -1,10 +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 { 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 { CLI_REFERENCE_SCHEMA_VERSION } from '@/../scripts/command-reference.ts'; +import { + buildNpmBundle, + NPM_BUNDLE_OPTIONS, + NPM_BUNDLE_PATH, +} from '@/../scripts/npm-bundle.ts'; import { findReleaseTargetByBunTarget, findReleaseTargetByPlatform, @@ -16,29 +19,32 @@ 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 { 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"; +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 +52,7 @@ type PackageJson = typeof packageJson & { }; export type ReleaseArtifact = { - kind: "native" | "javascript" | "documentation"; + kind: 'native' | 'javascript' | 'documentation'; file: string; bytes: number; sha256: string; @@ -54,20 +60,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 +106,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 +137,19 @@ export async function assertToolchain(actualBunVersion = Bun.version): Promise { await assertToolchain(); await mkdir(outputDirectory, { recursive: true }); @@ -187,7 +202,7 @@ export async function compileReleaseTarget( } export async function buildJavaScriptBundle( - outputDirectory = defaultOutputDirectory, + outputDirectory = defaultOutputDirectory ): Promise { await assertToolchain(); await mkdir(outputDirectory, { recursive: true }); @@ -195,14 +210,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 +234,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 +249,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 +263,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 +280,7 @@ export async function buildAllReleaseArtifacts( options: { outputDirectory?: string; tag?: string; - } = {}, + } = {} ): Promise { await assertToolchain(); const outputDirectory = options.outputDirectory ?? defaultOutputDirectory; @@ -278,7 +296,7 @@ export async function buildAllReleaseArtifacts( export async function writeReleaseMetadata( outputDirectory = defaultOutputDirectory, - tag = expectedReleaseTag, + tag = expectedReleaseTag ): Promise { await assertToolchain(); assertReleaseTag(tag); @@ -290,27 +308,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 +344,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 +362,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 +373,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 +393,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 +447,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..630f1f4f 100644 --- a/cli/scripts/script-output.test.ts +++ b/cli/scripts/script-output.test.ts @@ -1,56 +1,59 @@ -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'; -describe("script output lossless guarantees", () => { - test("CSV round-trips cells with commas, quotes, newlines, and Unicode", () => { +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', () => { 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 80553e24..84516a93 100644 --- a/cli/scripts/smoke-data-app.ts +++ b/cli/scripts/smoke-data-app.ts @@ -1,49 +1,82 @@ -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', + ...(singleFile ? ['--file', join(app, 'index.tsx')] : ['--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 +85,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 +96,139 @@ 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.72.0") - throw new Error(`Packaged starter must pin ${name} to the tested release`); + if (manifest.dependencies?.[name] !== '0.72.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`); await preview(true); } finally { await upstream?.stop(true); @@ -176,7 +239,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..7110a392 100644 --- a/cli/scripts/smoke-npm-bundle.ts +++ b/cli/scripts/smoke-npm-bundle.ts @@ -1,52 +1,67 @@ -import { smokeDataApp } from "@/../scripts/smoke-data-app.ts"; -import { join } from "node:path"; +import { join } from 'node:path'; -const repositoryRoot = join(import.meta.dir, "../.."); -const defaultBundlePath = join(repositoryRoot, "cli/dist/cli.js"); +import { smokeDataApp } from '@/../scripts/smoke-data-app.ts'; + +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..e706f18e 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 { buildTopLevelCommands } from '@/commands/index.ts'; +import { + getCliContext, + getBootstrapCliContext, + isJsonOutput, + setCliContext, +} from '@/context.ts'; +import { + 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, 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 { + 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 { applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; -import { maybeShowUpdateNotice } from "@/lib/updater.ts"; -import { validateEnvironment } from "@/lib/env.ts"; +} from '@/lib/usage.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; +import { applyTerminalColorFromContext } from '@/ui/terminal/styles.ts'; +import { VERSION } from '@/version.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..fe89d335 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -1,18 +1,23 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { buildMainCommand } from '@/cli.ts'; +import { apiCommand } from '@/commands/api/index.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'; + +describe('api', () => { beforeEach(() => { setCliContext({ debug: false, json: false, agent: false }); }); @@ -21,44 +26,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 +78,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 +179,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..94232a64 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 { + API_HTTP_BASE_ARGS, + resolveApiCommandRequest, +} from '@/commands/api/lib/command.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: { - 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..105f6d38 100644 --- a/cli/src/commands/api/lib/body.ts +++ b/cli/src/commands/api/lib/body.ts @@ -1,10 +1,11 @@ -import { readFileSync } from "node:fs"; -import { CliError, ParseError } from "@/lib/errors.ts"; +import { readFileSync } from 'node:fs'; -const BODY_METHOD_NAMES = ["POST", "PATCH", "PUT"] as const; +import { CliError, ParseError } from '@/lib/errors.ts'; + +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 +13,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 +30,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 +51,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 +89,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 +120,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 +162,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 +181,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 +207,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 +219,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..ebb70a44 100644 --- a/cli/src/commands/api/lib/http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -1,19 +1,20 @@ -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 { tmpdir } from 'node:os'; +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'; 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 +27,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 +47,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 +70,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 +82,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..72b1ec0d 100644 --- a/cli/src/commands/api/lib/http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -1,46 +1,50 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +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"; - -let testHome = ""; -let mockFile = ""; -let logFile = ""; -let stdout = ""; +} 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'; + +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 +59,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..0110063e 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 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; @@ -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..872c2468 100644 --- a/cli/src/commands/api/lib/views.test.ts +++ b/cli/src/commands/api/lib/views.test.ts @@ -1,31 +1,34 @@ -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..85906789 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 { + apiOperationDetails, + apiOperationsJson, + apiRouteRows, +} from '@/commands/api/lib/model.ts'; +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({ 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..6ffb0b8a 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 { 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({ 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..c119b5a9 100644 --- a/cli/src/commands/app/build.ts +++ b/cli/src/commands/app/build.ts @@ -1,32 +1,37 @@ -import { join } from "node:path"; -import { defineCommand } from "@/lib/command.ts"; -import { EXIT_GENERIC } from "@/lib/errors.ts"; +import { join } from 'node:path'; + import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, -} from "@/commands/app/lib/run.ts"; +} from '@/commands/app/lib/run.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { EXIT_GENERIC } from '@/lib/errors.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..bd1ca97b 100644 --- a/cli/src/commands/app/create.test.ts +++ b/cli/src/commands/app/create.test.ts @@ -1,18 +1,29 @@ -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 { 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; 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 +32,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(readFileSync(join(directory, "src/App.tsx"), "utf8")).toContain( - "Connectivity-only screen", + 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/operations.ts"), "utf8")).toContain( - "supplies no analytical result", + expect(readFileSync(join(directory, 'src/App.tsx'), 'utf8')).toContain( + 'Connectivity-only screen' ); - 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 +515,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..15e69a3c 100644 --- a/cli/src/commands/app/create.ts +++ b/cli/src/commands/app/create.ts @@ -1,104 +1,138 @@ -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 { createAppFiles } from '@/commands/app/lib/distribution.ts'; +import { dataAppPayload } from '@/commands/app/lib/payload.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { readEnv } from '@/lib/env.ts'; +import { CliError } from '@/lib/errors.ts'; +import { configGet } from '@/lib/profile-store.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 +140,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 b6f4baba..e56ad7c9 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,51 +1,60 @@ -import { resolve } from "node:path"; -import { prepareFilePreview } from "@/commands/app/lib/file-dev.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 { resolve } from 'node:path'; + +import { startAppDevProxy } from '@/commands/app/lib/dev-proxy.ts'; +import { prepareFilePreview } from '@/commands/app/lib/file-dev.ts'; import { appDirectory, assertAppOutputMode, requireAppScripts, runAppCommand, -} from "@/commands/app/lib/run.ts"; +} 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: { - name: "dev", + name: 'dev', description: - "Preview a local data app directory, or a single-file data app, with lakehouse access.", + 'Preview a local data app directory, or a single-file data app, with lakehouse access.', examples: [ - "altertable app dev", - "altertable app dev --port 3022", - "altertable --profile staging app dev --dir ./my-app", - "altertable app dev --file ./index.tsx", + 'altertable app dev', + 'altertable app dev --port 3022', + 'altertable --profile staging app dev --dir ./my-app', + 'altertable app dev --file ./index.tsx', ], }, args: { - dir: { type: "string", description: "App directory (default: current directory)." }, + dir: { + type: 'string', + description: 'App directory (default: current directory).', + }, file: { - type: "string", + type: 'string', description: - "Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.", - completion: "file", + 'Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.', + completion: 'file', }, port: { - type: "string", - description: "Local dev server port (1–65535; default: app setting, or 25837 for --file).", + type: 'string', + description: + 'Local dev server port (1–65535; default: app setting, or 25837 for --file).', }, }, async run({ args, execution, runtime }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); - const file = args.file === undefined ? undefined : appFile(args.file, args.dir); - const port = appPort(args.port) ?? (file ? "25837" : undefined); + const file = + args.file === undefined ? undefined : appFile(args.file, args.dir); + const port = appPort(args.port) ?? (file ? '25837' : undefined); const directory = file ? await prepareFilePreview(file, execution.profile) : appDirectory(args.dir); - requireAppScripts(directory, ["dev"]); + requireAppScripts(directory, ['dev']); if (!file) await checkAppScope(directory, execution.profile); - const installExitCode = await runAppCommand(file ? "install-frozen" : "install", directory); + const installExitCode = await runAppCommand( + file ? 'install-frozen' : 'install', + directory + ); if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; const proxy = startAppDevProxy(execution); try { @@ -54,7 +63,7 @@ export const appDevCommand = defineCommand({ ...(file ? { ALTERTABLE_APP_FILE: file } : {}), ...(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(); @@ -63,17 +72,21 @@ export const appDevCommand = defineCommand({ }); function appFile(file: unknown, dir: unknown): string { - if (dir !== undefined) throw new CliError("Pass either --dir or --file."); - if (typeof file !== "string" || file.length === 0) { - throw new CliError("--file requires a file path."); + if (dir !== undefined) throw new CliError('Pass either --dir or --file.'); + if (typeof file !== 'string' || file.length === 0) { + throw new CliError('--file requires a file path.'); } return resolve(file); } 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 b56ece36..c3fdb7d3 100644 --- a/cli/src/commands/app/index.test.ts +++ b/cli/src/commands/app/index.test.ts @@ -1,41 +1,42 @@ -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 +47,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 +59,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,137 +73,177 @@ 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); }); - test("dev validates a single file before preparing its preview", async () => { + test('dev validates a single file before preparing its preview', async () => { const file = workspace.writeFile( - "index.tsx", + 'index.tsx', `import { defineDataApp } from "@altertable/data-app"; -defineDataApp({ scope: { organization: "acme", environment: "staging" } });`, +defineDataApp({ scope: { organization: "acme", environment: "staging" } });` ); async function failure(args: string[]): Promise { - return runAppCommand(["app", "dev", ...args]).catch((error: unknown) => error); + return runAppCommand(['app', 'dev', ...args]).catch( + (error: unknown) => error + ); } function message(text: string) { return { message: expect.stringContaining(text) }; } - expect(await failure(["--file", file])).toMatchObject(message("data app file scope")); - expect(await failure(["--dir", directory, "--file", file])).toMatchObject( - message("either --dir or --file"), + expect(await failure(['--file', file])).toMatchObject( + message('data app file scope') ); - expect(await failure(["--file", ""])).toMatchObject(message("--file requires a file path")); - expect(await failure(["--file", join(directory, "missing.tsx")])).toMatchObject( - message("Could not read source"), + expect(await failure(['--dir', directory, '--file', file])).toMatchObject( + message('either --dir or --file') + ); + expect(await failure(['--file', ''])).toMatchObject( + message('--file requires a file path') + ); + expect( + await failure(['--file', join(directory, 'missing.tsx')]) + ).toMatchObject(message('Could not read source')); + expect(await failure(['--file', directory])).toMatchObject( + message('Could not read source') ); - expect(await failure(["--file", directory])).toMatchObject(message("Could not read source")); }); }); diff --git a/cli/src/commands/app/index.ts b/cli/src/commands/app/index.ts index 7f43ecd2..a11244a1 100644 --- a/cli/src/commands/app/index.ts +++ b/cli/src/commands/app/index.ts @@ -1,29 +1,29 @@ -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 { 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'; 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 dev --file index.tsx", - "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 dev --file index.tsx', + '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..59f71da9 100644 --- a/cli/src/commands/app/lib/dev-proxy.test.ts +++ b/cli/src/commands/app/lib/dev-proxy.test.ts @@ -1,12 +1,13 @@ -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'; -describe("data app dev proxy", () => { - test("keeps credentials in the CLI and accepts only authenticated query requests", async () => { +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 () => { 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 +16,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..78904fca 100644 --- a/cli/src/commands/app/lib/dev-proxy.ts +++ b/cli/src/commands/app/lib/dev-proxy.ts @@ -1,55 +1,70 @@ -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 07c5fc19..cd3c4341 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -1,95 +1,113 @@ -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/index.html'), + '' ); writeFileSync( - join(directory, "src/main.ts"), - 'import { operations } from "./operations.ts"; console.log(operations);', + join(directory, 'src/main.ts'), + 'import { operations } from "./operations.ts"; console.log(operations);' + ); + const boundaryError = await checkAppProject(directory).catch( + (error: unknown) => error ); - const boundaryError = await checkAppProject(directory).catch((error: unknown) => error); - expect(boundaryError).toMatchObject({ message: "Data app validation failed." }); + 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 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 +240,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 +251,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..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 { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +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"; +} from '@/commands/app/lib/run.ts'; +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; @@ -24,37 +25,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 +81,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 +89,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 +112,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..bd36e403 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 { buildDataAppHttpRequest, readRequiredFile, type PublishedDataApp, -} from "@/commands/app/lib/remote.ts"; +} 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: { - 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..bcbde047 100644 --- a/cli/src/commands/app/remote.test.ts +++ b/cli/src/commands/app/remote.test.ts @@ -1,17 +1,18 @@ -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 +22,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 +71,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..d32d9c59 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 { buildDataAppHttpRequest, readRequiredFile, type RemoteDataAppPayload, type PublishedDataApp, -} from "@/commands/app/lib/remote.ts"; +} 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: { - 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..c48944b3 100644 --- a/cli/src/commands/app/upgrade.test.ts +++ b/cli/src/commands/app/upgrade.test.ts @@ -1,30 +1,36 @@ -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 +41,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 +104,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 +342,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..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 { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { dataAppPackage, dataAppPackages, @@ -13,21 +9,35 @@ import { readAppPackage, recommendedDataAppPeers, recommendedDataAppVersion, -} from "@/commands/app/lib/package.ts"; +} 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: { - 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 +47,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 +56,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 +69,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 +127,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 +137,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 +181,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 +211,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..adca95c1 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 { buildDataAppHttpRequest, formatValidationSummary, readRequiredFile, validationExitCode, type ValidateDataAppResponse, -} from "@/commands/app/lib/remote.ts"; +} 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: { - 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..dfb1e82d 100644 --- a/cli/src/commands/append/index.test.ts +++ b/cli/src/commands/append/index.test.ts @@ -1,41 +1,54 @@ -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..a85744c1 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 { 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 { 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'; 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..e0e82199 100644 --- a/cli/src/commands/append/lib/data.test.ts +++ b/cli/src/commands/append/lib/data.test.ts @@ -1,20 +1,21 @@ -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'; -describe("parseAppendJsonContent", () => { - test("preserves inline JSON without rounding large integers", () => { +import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; +import { CliError } from '@/lib/errors.ts'; + +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..ebca05e3 100644 --- a/cli/src/commands/append/lib/data.ts +++ b/cli/src/commands/append/lib/data.ts @@ -1,26 +1,27 @@ -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..4c65f7e0 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 { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.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..e9c6593e 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 { buildCatalogCreateRequest } from '@/commands/catalogs/lib/requests.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({ 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..c2096c52 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 { catalogsCreateCommand } from '@/commands/catalogs/create.ts'; +import { requireManagementPlane } from '@/lib/auth.ts'; +import { writeCommandOutput } from '@/lib/command-output.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { fetchManagementCatalogRows } from '@/lib/management/catalogs.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..9ce6bc8a 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 { formatCompletionScript, resolveShell, SUPPORTED_SHELLS, type GetRootCommand, -} from "@/commands/completion/lib/completion.ts"; +} 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({ 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..fe714406 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -1,85 +1,102 @@ -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 { 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'; 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 +105,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..87ea46de 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 { 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'; +import { defineCommand, type Command } from '@/lib/command.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..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, @@ -7,36 +5,40 @@ import { resolveShell, SUPPORTED_SHELLS, type GetRootCommand, -} from "@/commands/completion/lib/completion.ts"; +} 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({ 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..1c7bb61e 100644 --- a/cli/src/commands/completion/lib/completion.ts +++ b/cli/src/commands/completion/lib/completion.ts @@ -1,39 +1,48 @@ -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 { formatBashCompletion, formatFishCompletion, formatZshCompletion, -} from "@/commands/completion/lib/format.ts"; +} from '@/commands/completion/lib/format.ts'; +import { buildCompletionSpec } from '@/commands/completion/lib/spec.ts'; +import type { Command } from '@/lib/command.ts'; +import { readEnv } from '@/lib/env.ts'; +import { CliError } from '@/lib/errors.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'; 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 +51,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 +59,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 +80,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 +111,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 +148,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 +156,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 +192,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 +232,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..9b74a64a 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,15 +1,9 @@ -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 { 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 { buildMainCommand } from '@/cli.ts'; import { formatBashCompletion, formatBashFlagWordList, @@ -17,16 +11,26 @@ import { formatFishPathCondition, formatZshCompletion, mergeCompletionFlags, -} from "@/commands/completion/lib/format.ts"; +} 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>; 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 +40,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 +81,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 +102,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 +118,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 +144,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 +181,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 +235,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 +415,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"); - }); - - 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"); + 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} 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..f8067e79 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 { resolveCommandDescriptor, visibleCommandDescriptors, type CommandArgumentDescriptor, type CommandDescriptor, -} from "@/lib/command-descriptor.ts"; +} 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. @@ -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..c5decb00 100644 --- a/cli/src/commands/doctor/index.test.ts +++ b/cli/src/commands/doctor/index.test.ts @@ -1,48 +1,53 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; -let testHome = ""; -let mockFile = ""; +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'; + +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 +55,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 +105,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 +129,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 +202,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..f2053246 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 { createDoctorChecks } from '@/commands/doctor/lib/checks.ts'; +import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; +import { runDoctorChecks } from '@/commands/doctor/lib/runner.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: { - 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..71e9df54 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 { - 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"; import type { DoctorCheck, DoctorCheckContext, DoctorCheckOutcome, -} from "@/commands/doctor/lib/model.ts"; +} from '@/commands/doctor/lib/model.ts'; +import { + configGetGlobal, + resolveApiBase, + resolveManagementApiBase, +} from '@/lib/config.ts'; +import { readEnv } from '@/lib/env.ts'; +import { ParseError } from '@/lib/errors.ts'; +import { sendHttp } from '@/lib/http-request.ts'; +import { + parseLakehouseQueryResponse, + type LakehouseRow, +} from '@/lib/lakehouse-ndjson.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, 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..b683282b 100644 --- a/cli/src/commands/doctor/lib/render.test.ts +++ b/cli/src/commands/doctor/lib/render.test.ts @@ -1,26 +1,27 @@ -import { describe, expect, test } from "bun:test"; -import { formatDoctorReport } from "@/commands/doctor/lib/render.ts"; +import { describe, expect, test } from 'bun:test'; -describe("formatDoctorReport", () => { - test("renders structured failure status and details", () => { +import { formatDoctorReport } from '@/commands/doctor/lib/render.ts'; + +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..a8ed0feb 100644 --- a/cli/src/commands/doctor/lib/runner.test.ts +++ b/cli/src/commands/doctor/lib/runner.test.ts @@ -1,14 +1,18 @@ -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 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 { offline, interactive: false, execution: { - profile: "test", + profile: 'test', cli: { debug: false, json: true, agent: false }, output: { json: true, @@ -24,218 +28,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..7bd1b912 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 type { DoctorCheck, DoctorCheckContext, DoctorCheckResult, DoctorReport, DoctorSummary, -} from "@/commands/doctor/lib/model.ts"; +} from '@/commands/doctor/lib/model.ts'; +import { serializeCliError } from '@/lib/errors.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..90526d2a 100644 --- a/cli/src/commands/duckdb/index.test.ts +++ b/cli/src/commands/duckdb/index.test.ts @@ -1,29 +1,30 @@ -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 +41,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..b58989c3 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -1,34 +1,39 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync } from 'node:child_process'; + +import { optionalStringArg } from '@/lib/args.ts'; 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 { 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'; 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 +44,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 +128,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 +145,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..363347f6 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 { 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 { 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 type { Command } from '@/lib/command.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..fcef8e4e 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -1,70 +1,88 @@ -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 { 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 { + 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 { forceNoTerminalColorForTests } from '@/test-utils/terminal.ts'; +import { delay } from '@/test-utils/time.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 +94,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 +396,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..8b4c6134 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -1,15 +1,18 @@ -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 { 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 { httpSend } from '@/lib/http.ts'; +import { encodeManagementEndpoint } from '@/lib/management-endpoint.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, @@ -17,9 +20,9 @@ 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 { refreshCliRuntimeContext, type OutputSink } from '@/lib/runtime.ts'; +import { secretSet } from '@/lib/secrets.ts'; import { fetchEnvironmentCatalogs, parseServiceAccountScope, @@ -29,65 +32,69 @@ 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 { 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'; 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..d060646b 100644 --- a/cli/src/commands/logout/index.test.ts +++ b/cli/src/commands/logout/index.test.ts @@ -1,18 +1,23 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +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 +28,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..23805702 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 { configureRunClear } from '@/lib/profile-configure-core.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.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..19cb40ae 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 { 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 { configureArgs, runProfileConfigure, type ConfigureCommandArgs, -} from "@/lib/profile-configure.ts"; -import { optionalArg } from "@/commands/profile/lib/profile.ts"; +} from '@/lib/profile-configure.ts'; +import { assertNoEnvConfigMode } from '@/lib/profile/model.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..93aa0474 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 { 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" }, + 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..a30865d2 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 { requireProfileName } from '@/commands/profile/lib/profile.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" }, + 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..1323f095 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 { 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 { 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" }, + 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..fd984744 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 { 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'; 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..0262aa93 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 { getCliContext } from '@/context.ts'; +import { asCliArgString } from '@/lib/cli-args.ts'; +import { CliError, ConfigurationError } from '@/lib/errors.ts'; +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; import { envConfigMode, getActiveProfileName, isFromEnvProfile, profileExists, -} from "@/lib/profile-store.ts"; -import { listProfiles } from "@/lib/profile/model.ts"; +} 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(); - 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..8282f8ba 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 { 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'; 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..60165a11 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 { requireProfileName } from '@/commands/profile/lib/profile.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 }, + 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..1c264286 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 { + existingProfileName, + profileShowTargetName, +} from '@/commands/profile/lib/profile.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: { 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..78e6b790 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 { 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 { 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: { 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..925c2030 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 { + promptProfileSwitch, + requireProfileName, +} from '@/commands/profile/lib/profile.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: { 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..88678467 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 { writeCommandOutput } from '@/lib/command-output.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: { - 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..53d27545 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -1,189 +1,237 @@ -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..b11d302e 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 { queryCancelCommand } from '@/commands/query/cancel.ts'; +import { queryRunArgs } from '@/commands/query/lib/args.ts'; +import { queryShowCommand } from '@/commands/query/show.ts'; +import { optionalStringArg } from '@/lib/args.ts'; +import { defineCommand } from '@/lib/command.ts'; +import { CliError } from '@/lib/errors.ts'; import { executeLakehouseQuery, executeLakehouseQueryBytes, type LakehouseQueryInput, -} from "@/lib/lakehouse/query.ts"; +} 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: { - 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..3ccb7685 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 { writeCommandOutput } from '@/lib/command-output.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: { - 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..1789f436 100644 --- a/cli/src/commands/schema/index.test.ts +++ b/cli/src/commands/schema/index.test.ts @@ -1,34 +1,35 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +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 +46,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..7221c84e 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 { 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 { 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 { 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..7d96158e 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 type { + LakehouseQueryResult, + LakehouseRow, +} from '@/lib/lakehouse-ndjson.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; @@ -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..9c3e9137 100644 --- a/cli/src/commands/update/index.ts +++ b/cli/src/commands/update/index.ts @@ -1,12 +1,8 @@ -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 { 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 +14,37 @@ import { releaseUrlForSource, resolveUpdateSource, type UpdateCheckResult, -} from "@/lib/updater.ts"; +} 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: { - 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..9aba671a 100644 --- a/cli/src/commands/upload/index.test.ts +++ b/cli/src/commands/upload/index.test.ts @@ -1,50 +1,58 @@ -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..e226ce9a 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 { uploadArgs, UPLOAD_MODE_OPTIONS } from '@/commands/upload/lib/args.ts'; +import { enumArg, optionalStringArg, stringArg } from '@/lib/args.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 { getFileSizeBytes } from '@/lib/lakehouse/file.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..a4274dfb 100644 --- a/cli/src/commands/upsert/index.test.ts +++ b/cli/src/commands/upsert/index.test.ts @@ -1,36 +1,39 @@ -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..7c272c71 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 { upsertArgs } from '@/commands/upsert/lib/args.ts'; +import { optionalStringArg, stringArg } from '@/lib/args.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'; 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..26747e1e 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 { getBootstrapCliContextState, isJsonOutput as contextUsesJsonOutput, setBootstrapCliContext, type CliContext, -} from "@/lib/cli-context.ts"; +} 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"; +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..a9f64df4 100644 --- a/cli/src/lib/auth.test.ts +++ b/cli/src/lib/auth.test.ts @@ -1,25 +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 { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + 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 +34,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( + 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).", + "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( + ConfigurationError + ); expect(() => getManagementAuthHeader(profileName)).toThrow( - "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." ); }); - 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..420a94d0 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 { readEnv } from '@/lib/env.ts'; +import { CliError, ConfigurationError, EXIT_CONFIG } from '@/lib/errors.ts'; +import { optionalAuth } from '@/lib/execution-context.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"); + 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..430bd360 100644 --- a/cli/src/lib/cli-session.test.ts +++ b/cli/src/lib/cli-session.test.ts @@ -1,19 +1,20 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; -let testHome = ""; +import { setCliContext } from '@/context.ts'; +import { createCliSession } from '@/lib/cli-session.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; +import { createCliRuntime } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; + +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 +26,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..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 { describe, expect, test } from 'bun:test'; + +import { buildMainCommand } from '@/cli.ts'; +import { buildCompletionSpecFromDescriptor } from '@/commands/completion/lib/spec.ts'; import { resolveCommandDescriptor, validateCommandDescriptor, @@ -9,11 +9,12 @@ import { type CommandArgumentDescriptor, type CommandDescriptor, type ResolvedCommandMetadata, -} from "@/lib/command-descriptor.ts"; +} from '@/lib/command-descriptor.ts'; +import { defineCommand } from '@/lib/command.ts'; import { buildStructuredHelpFromDescriptor, renderAltertableUsageFromDescriptor, -} from "@/lib/usage.ts"; +} from '@/lib/usage.ts'; function structuredArgumentContract(argument: CommandArgumentDescriptor) { return { @@ -31,17 +32,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 +51,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 +77,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 +89,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 +121,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 +135,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 +207,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..78939a0b 100644 --- a/cli/src/lib/command-output.test.ts +++ b/cli/src/lib/command-output.test.ts @@ -1,6 +1,7 @@ -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 +11,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..9ad60c32 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 { renderManagementOutput } from '@/lib/management-output.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'; 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..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, @@ -6,11 +7,13 @@ 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 { EXIT_SUCCESS } from '@/lib/errors.ts'; +import { + createExecutionContext, + type ExecutionContext, +} from '@/lib/execution-context.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..6264b304 100644 --- a/cli/src/lib/command.test.ts +++ b/cli/src/lib/command.test.ts @@ -1,39 +1,48 @@ -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'; -describe("command composition", () => { - test("defineCommand preserves the declarative command object", () => { - const definition = { metadata: { name: "leaf" } }; +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'; + +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 +53,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 +85,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 +212,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 +251,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 +280,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..9d5e55e0 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -1,45 +1,68 @@ -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'; -export { 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'; 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 +82,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 +107,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 +132,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 +145,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..d33b0206 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -1,7 +1,15 @@ -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 +23,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 +43,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 +140,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..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, @@ -6,10 +5,11 @@ 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 { 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 }; 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..4f8571a8 100644 --- a/cli/src/lib/early-bootstrap.test.ts +++ b/cli/src/lib/early-bootstrap.test.ts @@ -1,33 +1,39 @@ -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..5272fb6a 100644 --- a/cli/src/lib/env.test.ts +++ b/cli/src/lib/env.test.ts @@ -1,44 +1,56 @@ -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 +59,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..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 { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + import { CliError, ConfigurationError, @@ -18,14 +19,18 @@ import { httpStatusMessage, serializeCliError, shouldShowCommandExamplesOnError, -} from "@/lib/errors.ts"; -import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; +} from '@/lib/errors.ts'; import { forceNoTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} from '@/test-utils/terminal.ts'; +import { + renderCliError, + renderCliErrorDetails, + renderCliErrorJson, +} from '@/ui/error.ts'; let terminalState: TerminalTestState; @@ -38,177 +43,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..bf4aec91 100644 --- a/cli/src/lib/global-flags.test.ts +++ b/cli/src/lib/global-flags.test.ts @@ -1,42 +1,43 @@ -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 { CliError } from '@/lib/errors.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 { 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 +49,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 +113,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..157089c7 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 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 = [ - "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..fa6eecab 100644 --- a/cli/src/lib/http-headers.test.ts +++ b/cli/src/lib/http-headers.test.ts @@ -1,26 +1,27 @@ -import { describe, expect, test } from "bun:test"; -import { buildRequestHeaders } from "@/lib/http.ts"; +import { describe, expect, test } from 'bun:test'; -describe("buildRequestHeaders", () => { - test("omits Authorization when authHeader is empty", () => { +import { buildRequestHeaders } from '@/lib/http.ts'; + +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..6e2b4518 100644 --- a/cli/src/lib/http-request.test.ts +++ b/cli/src/lib/http-request.test.ts @@ -1,23 +1,24 @@ -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 = ""; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; +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 = ''; 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 +31,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..be749e09 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 { getLakehouseAuthHeader, getManagementAuthHeader } from '@/lib/auth.ts'; +import { resolveApiBase, resolveManagementApiBase } from '@/lib/config.ts'; +import { HttpError, type AuthPlane } from '@/lib/errors.ts'; +import { + optionalAuth, + type ExecutionContext, +} from '@/lib/execution-context.ts'; +import { httpSend, httpSendStream, type HttpSendOptions } from '@/lib/http.ts'; import { canRecoverLakehouseAuth, hasManagementCredentials, provisionLakehouseCredential, -} from "@/lib/lakehouse-provision.ts"; +} 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; @@ -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..34601e71 100644 --- a/cli/src/lib/http.test.ts +++ b/cli/src/lib/http.test.ts @@ -1,9 +1,20 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; +import { + CliError, + HttpError, + NetworkError, + TimeoutError, +} from '@/lib/errors.ts'; +import { + computeRetryDelayMs, + isRetryableMethod, + parseRetryAfterMs, +} from '@/lib/http-retry.ts'; import { buildFetchInit, getSharedDispatcher, @@ -14,22 +25,21 @@ 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 { 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 +51,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 +280,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 +308,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 +374,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 +384,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 +400,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 +442,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 +486,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 +533,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 +547,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 +598,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 +629,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..35549d2d 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -1,22 +1,32 @@ -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 { readEnv } from '@/lib/env.ts'; +import { + CliError, + HttpError, + NetworkError, + 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 { redactResponseBodyForDebug, redactSensitiveJsonString, redactSensitiveJsonValue, truncateBodySnippet, -} from "@/lib/redact.ts"; +} from '@/lib/redact.ts'; +import { getOutputSink } from '@/lib/runtime.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"; + DEFAULT_READ_TIMEOUT_MS, + STREAM_READ_TIMEOUT_MS, +} from '@/lib/transport-defaults.ts'; +import { USER_AGENT } from '@/version.ts'; export type HttpSendOptions = { method: string; @@ -54,7 +64,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 +112,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 +125,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 +136,7 @@ function warnInsecureTlsOnce(): void { export function buildFetchInit( options: HttpSendOptions, headers: Record, - signal: AbortSignal, + signal: AbortSignal ): RequestInit { const ignoreSslErrors = shouldIgnoreSslErrors(); if (ignoreSslErrors) { @@ -137,7 +148,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 +161,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 +192,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 +262,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 +281,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 +290,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 +299,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 +313,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 +329,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 +347,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 +360,7 @@ async function executeMockStream( options.method, options.url, options.authPlane, - match.retryAfter ?? null, + match.retryAfter ?? null ); } @@ -347,15 +377,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 +398,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 +452,7 @@ function throwHttpError( method: string, url: string, authPlane?: AuthPlane, - retryAfterHeader?: string | null, + retryAfterHeader?: string | null ): never { throw new HttpError({ status, @@ -428,29 +461,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 +513,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 +550,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 +580,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 +623,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 +639,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 +659,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 +676,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 +696,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..1d62555f 100644 --- a/cli/src/lib/lakehouse-provision.test.ts +++ b/cli/src/lib/lakehouse-provision.test.ts @@ -1,33 +1,44 @@ -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 { 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'; +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 +46,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 +63,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..2a9e8346 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 { 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 { 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'; +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..34dea81f 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 { HttpRequest } from '@/lib/http-request.ts'; +import { type HttpSendOptions } from '@/lib/http.ts'; +import { + createUploadProgressReporter, + shouldShowProgress, +} from '@/lib/progress.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..22732cfe 100644 --- a/cli/src/lib/lakehouse/args.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -1,41 +1,50 @@ -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(parseLakehouseFileContentType('json', 'data')).toBe( + 'application/json' + ); + expect(parseLakehouseFileContentType('parquet', 'data.csv')).toBe( + 'application/vnd.apache.parquet' ); - expect(inferLakehouseFileFormat("data.JSON")).toBe("json"); + 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..c6edb937 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,24 +1,29 @@ -import { extname } from "node:path"; -import { defineArguments } from "@/lib/command.ts"; -import { CliError } from "@/lib/errors.ts"; +import { extname } from 'node:path'; -export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; +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 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 +35,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 +67,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..d2fa60fb 100644 --- a/cli/src/lib/lakehouse/file.ts +++ b/cli/src/lib/lakehouse/file.ts @@ -1,5 +1,6 @@ -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..732472a3 100644 --- a/cli/src/lib/lakehouse/query.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -1,47 +1,48 @@ -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"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; +import { ParseError, QueryError } from '@/lib/errors.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"; +} from '@/lib/lakehouse-ndjson.ts'; import { buildLakehouseQueryRequest, executeLakehouseQuery, executeLakehouseQueryBytes, -} from "@/lib/lakehouse/query.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +} 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 = [ '{"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 +55,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 +144,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 +153,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 +170,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 +186,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 +220,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 +246,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 +352,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 +433,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 +500,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..3d58e7e2 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 { + sendHttp, + sendHttpStream, + type HttpRequest, +} from '@/lib/http-request.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'; -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..593360cf 100644 --- a/cli/src/lib/management-output.test.ts +++ b/cli/src/lib/management-output.test.ts @@ -1,89 +1,95 @@ -import { describe, expect, test } from "bun:test"; -import { extractManagementRows, renderManagementOutput } from "@/lib/management-output.ts"; +import { describe, expect, test } from 'bun:test'; -describe("extractManagementRows", () => { - test("extracts rows from list responses", () => { +import { + extractManagementRows, + renderManagementOutput, +} from '@/lib/management-output.ts'; + +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..f916f8ff 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 { isRecord } from '@/lib/object.ts'; +import { parseApiJson } from '@/lib/parse-api-json.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..62c0d466 100644 --- a/cli/src/lib/management/auth.test.ts +++ b/cli/src/lib/management/auth.test.ts @@ -1,23 +1,24 @@ -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 { 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'; + +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 +31,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..04b0265a 100644 --- a/cli/src/lib/management/model.test.ts +++ b/cli/src/lib/management/model.test.ts @@ -1,40 +1,43 @@ -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..c73e98cb 100644 --- a/cli/src/lib/management/render.test.ts +++ b/cli/src/lib/management/render.test.ts @@ -1,47 +1,55 @@ -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 type { CatalogRow } from '@/lib/management/model.ts'; +import { + formatCatalogsSummary, + formatCatalogsTable, +} from '@/lib/management/render.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..ed96e55f 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -1,24 +1,29 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; +import { configGet, resolveOAuthBase } from '@/lib/config.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 +34,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..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 { 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 { spawn } from 'node:child_process'; +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'; const LOGIN_TIMEOUT_MS = 180_000; @@ -18,31 +20,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 +68,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 +108,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 +161,7 @@ export async function exchangeCode( redirectUri: string; verifier: string; }, - oauthBase: string, + oauthBase: string ): Promise { const authServer = resolveAuthServer(oauthBase); const client = resolveOauthClient(); @@ -151,7 +172,7 @@ export async function exchangeCode( authServer, client, new URLSearchParams({ code: params.code }), - oauth.skipStateCheck, + oauth.skipStateCheck ); const response = await oauth.authorizationCodeGrantRequest( authServer, @@ -160,16 +181,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 +199,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 +216,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 +228,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 +252,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 +277,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 +292,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 +301,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..67549fc3 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -1,32 +1,36 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; -const profileName = "default"; -let testHome = ""; -let mockFile = ""; +import { setCliContext, getCliContext } from '@/context.ts'; +import { getManagementAuthHeader } from '@/lib/auth.ts'; +import { configGet, configSet, resolveOAuthBase } from '@/lib/config.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'; +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 +49,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 +144,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 +170,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 +189,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..756cd28e 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 { readEnv } from '@/lib/env.ts'; +import { ConfigurationError, HttpError } from '@/lib/errors.ts'; +import { refreshAccessToken, type TokenResponse } from '@/lib/oauth-flow.ts'; +import { secretDelete, secretGet, secretSet } from '@/lib/secrets.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..5ec93eca 100644 --- a/cli/src/lib/openapi-spec.test.ts +++ b/cli/src/lib/openapi-spec.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { getOpenapiSpecYaml, stripOpenapiSourceHeader } from "@/lib/openapi-spec.ts"; +import { describe, expect, test } from 'bun:test'; -describe("openapi-spec", () => { - test("stripOpenapiSourceHeader removes bundled source comments", () => { +import { + getOpenapiSpecYaml, + stripOpenapiSourceHeader, +} from '@/lib/openapi-spec.ts'; + +describe('openapi-spec', () => { + test('stripOpenapiSourceHeader removes bundled source comments', () => { const yaml = `# AUTO-GENERATED by example # Edit source instead. openapi: 3.1.0 @@ -16,14 +20,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..d67e3958 100644 --- a/cli/src/lib/openapi-spec.ts +++ b/cli/src/lib/openapi-spec.ts @@ -1,15 +1,17 @@ +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"; +import openapiYaml from '../../openapi/openapi.yaml' with { type: 'text' }; -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..2f4c9844 100644 --- a/cli/src/lib/pager.test.ts +++ b/cli/src/lib/pager.test.ts @@ -1,15 +1,20 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; -describe("resolvePagerOptions", () => { - let testHome = ""; +import { configFile, kvSet } from '@/lib/config.ts'; +import { + buildPagerEnv, + resolvePagerOptions, + shouldUsePager, +} from '@/lib/pager.ts'; + +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 +23,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..a82dd810 100644 --- a/cli/src/lib/pager.ts +++ b/cli/src/lib/pager.ts @@ -1,10 +1,11 @@ -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'; -export type PagerMode = "auto" | "always" | "never"; +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'; + +export type PagerMode = 'auto' | 'always' | 'never'; export type PagerOptions = { mode: PagerMode; @@ -16,43 +17,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..011aa4ea 100644 --- a/cli/src/lib/pluralize.test.ts +++ b/cli/src/lib/pluralize.test.ts @@ -1,26 +1,27 @@ -import { describe, expect, test } from "bun:test"; -import { pluralize, pluralizeLabel } from "@/lib/pluralize.ts"; +import { describe, expect, test } from 'bun:test'; -describe("pluralize", () => { - test("returns singular for count of 1", () => { - expect(pluralize(1, "row")).toBe("row"); - expect(pluralize(1, "minute")).toBe("minute"); +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'); }); - 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..73b51585 100644 --- a/cli/src/lib/profile-configure-core.test.ts +++ b/cli/src/lib/profile-configure-core.test.ts @@ -1,30 +1,36 @@ -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 +39,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..e6b95ab0 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -1,7 +1,15 @@ -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 { getCliContext } from '@/context.ts'; +import { + configFile, + configGet, + configSet, + credentialsFile, + kvUnset, +} from '@/lib/config.ts'; +import { CliError, ConfigurationError } from '@/lib/errors.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; import { ensureProfileExists, getActiveProfileName, @@ -10,17 +18,24 @@ 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 { + 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'; 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 +55,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 +284,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 +293,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 +316,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..75ec8663 100644 --- a/cli/src/lib/profile-configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -1,28 +1,29 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { configSet } from '@/lib/config.ts'; 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'; -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 +37,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..c3bdb071 100644 --- a/cli/src/lib/profile-configure-data-plane.test.ts +++ b/cli/src/lib/profile-configure-data-plane.test.ts @@ -1,26 +1,27 @@ -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 { secretGet } from '@/lib/secrets.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.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 +30,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..05eb0004 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 { 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 { secretExists, secretGet } from '@/lib/secrets.ts'; +import { span } from '@/ui/document.ts'; +import type { Prompts, SelectOption } from '@/ui/prompts.ts'; +import { renderDisplayText } from '@/ui/terminal/styles.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..e9363636 100644 --- a/cli/src/lib/profile-configure.test.ts +++ b/cli/src/lib/profile-configure.test.ts @@ -1,20 +1,27 @@ -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 { tmpdir } from 'node:os'; +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 { + 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 type { Prompts } from '@/ui/prompts.ts'; + +const profileName = 'default'; + +let testHome = ''; function createMockPrompts(responses: { selects?: string[]; @@ -32,7 +39,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 +47,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 +60,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 +72,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..5fa5083b 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -1,15 +1,11 @@ -import { defineArguments } from "@/lib/command.ts"; +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"; +} from '@/lib/profile-configure-core.ts'; import { collectLakehouseCredentials, collectManagementCredentials, @@ -17,31 +13,45 @@ 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 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, + span, + 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"; +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..7036c198 100644 --- a/cli/src/lib/profile-status.test.ts +++ b/cli/src/lib/profile-status.test.ts @@ -1,25 +1,26 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; -let testHome = ""; -let mockFile = ""; +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 { createCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; + +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 +36,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 +66,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..e2684936 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 { 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 { 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..1bb8c9fc 100644 --- a/cli/src/lib/profile-store.test.ts +++ b/cli/src/lib/profile-store.test.ts @@ -1,8 +1,10 @@ -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 { kvSet } from '@/lib/config.ts'; import { DEFAULT_PROFILE_NAME, ensureProfileExists, @@ -15,13 +17,12 @@ import { resolveWorkingProfile, resolveProfileReference, setActiveProfile, -} from "@/lib/profile-store.ts"; -import { kvSet } from "@/lib/config.ts"; +} from '@/lib/profile-store.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 +33,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..bb651688 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -1,17 +1,19 @@ -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 { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; -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 { configDir, configFile, kvGet, kvSet } from '@/lib/config-files.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'; +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 +28,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 +61,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 +102,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 +138,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 +148,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 +161,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 +196,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 +215,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 +224,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 +238,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 +259,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 +281,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..8315cf70 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -1,21 +1,18 @@ -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 { - createEmptyProfile, - deleteProfile, - deriveProfileName, - deriveServiceAccountProfileName, - inspectProfile, - listProfiles, - renameProfile, - updateProfile, -} from "@/lib/profile/model.ts"; + 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 { ConfigurationError } from '@/lib/errors.ts'; +import { configureRunSet } from '@/lib/profile-configure-core.ts'; import { getActiveProfileName, getProfileId, @@ -24,16 +21,26 @@ 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 { + createEmptyProfile, + deleteProfile, + deriveProfileName, + deriveServiceAccountProfileName, + inspectProfile, + listProfiles, + renameProfile, + updateProfile, +} from '@/lib/profile/model.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 +50,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 +278,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 +371,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 +402,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 +413,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..18755321 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,8 @@ import { rmSync, statSync, writeFileSync, -} from "node:fs"; +} from 'node:fs'; + import { configDir, configFile, @@ -16,18 +17,9 @@ import { kvGet, 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"; +} from '@/lib/config.ts'; +import { configuredEnvConfig, readEnv } from '@/lib/env.ts'; +import { ConfigurationError } from '@/lib/errors.ts'; import { assertSafeProfileName, DEFAULT_PROFILE_NAME, @@ -44,19 +36,28 @@ import { profileExists, readProfileConfigRecord, setActiveProfile, -} from "@/lib/profile-store.ts"; +} from '@/lib/profile-store.ts'; +import { + migrateProfileSecrets, + secretDelete, + secretExists, + secretGet, + secretSet, + secretStoreDisplay, + type SecretStore, +} from '@/lib/secrets.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 +95,7 @@ function profileMutationErrorDetail(error: unknown): string { function runProfileRollbackStep( failures: ProfileRollbackFailure[], step: string, - operation: () => void, + operation: () => void ): void { try { operation(); @@ -107,7 +108,7 @@ function rethrowProfileMutationFailure( originalError: unknown, rollbackFailures: ProfileRollbackFailure[], operation: string, - recoveryAdvice: string, + recoveryAdvice: string ): never { if (rollbackFailures.length === 0) { throw originalError; @@ -119,15 +120,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 +191,7 @@ export type ProfileConfiguration = { export type ProfileInspect = ProfileConfiguration & { auth: ProfileAuth; - status: "configured" | "partial" | "empty"; + status: 'configured' | 'partial' | 'empty'; }; type ProfileSnapshot = { @@ -198,27 +200,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 +230,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 +241,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 +252,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 +285,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 +315,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 +333,7 @@ function profileLakehouseAuthSummary(auth: ProfileAuth): string { } function profilePrincipalSummary( - config: Record, + config: Record ): string | undefined { if (config.principal_email) { return config.principal_email; @@ -345,10 +350,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 +385,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 +416,7 @@ function readEnvironmentProfileConfiguration(): ProfileConfiguration { function buildProfileConfiguration( name: string, active: boolean, - config: Record, + config: Record ): ProfileConfiguration { return { name, @@ -432,7 +442,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 +452,7 @@ function buildProfileConfiguration( function buildProfileInspection( name: string, active: boolean, - snapshot: ProfileSnapshot, + snapshot: ProfileSnapshot ): ProfileInspect { return { ...buildProfileConfiguration(name, active, snapshot.config), @@ -456,7 +468,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 +480,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 +496,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 +513,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 +542,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 +557,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 +575,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 +608,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 +642,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 +661,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 +695,7 @@ export function renameProfile( error, rollbackFailures, `Renaming profile "${source}" to "${target}"`, - "Inspect both profiles before retrying.", + 'Inspect both profiles before retrying.' ); } } @@ -665,7 +703,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 +715,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 +742,7 @@ export function deleteProfile( error, rollbackFailures, `Deleting profile "${name}"`, - "Inspect the profile before retrying.", + 'Inspect the profile before retrying.' ); } } @@ -720,18 +758,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 +780,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 +824,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 +865,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 +997,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 +1010,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..90ac4b34 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,52 +1,56 @@ -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..01ab9684 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,26 +1,31 @@ +import type { ConfigureAuthPlane } from '@/lib/profile-status.ts'; +import { + buildConfigureShowData, + type ProfileInspect, + type ProfileSummary, +} from '@/lib/profile/model.ts'; import { buildProfileInspectResultView, buildProfileListView, buildProfileStatusView, 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"; +} from '@/lib/profile/views.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..8801738a 100644 --- a/cli/src/lib/profile/views.test.ts +++ b/cli/src/lib/profile/views.test.ts @@ -1,5 +1,6 @@ -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 +8,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..5fa1310f 100644 --- a/cli/src/lib/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -2,8 +2,15 @@ import { type ConfigureAuthPlane, type ConfigureVerifyResult, formatConfigureVerifyRemediation, -} from "@/lib/profile-status.ts"; -import type { SelectOption } from "@/ui/prompts.ts"; +} from '@/lib/profile-status.ts'; +import { isFromEnvProfile } from '@/lib/profile-store.ts'; +import type { + ConfigureLakehouseCredential, + ConfigureManagementCredential, + ConfigureShowData, + ProfileInspect, + ProfileSummary, +} from '@/lib/profile/model.ts'; import { document, rows, @@ -14,24 +21,17 @@ import { type DisplayDocument, 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 { ShellExportView } from "@/ui/shell/model.ts"; +} from '@/ui/document.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 = " "; +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..df3e1174 100644 --- a/cli/src/lib/progress.test.ts +++ b/cli/src/lib/progress.test.ts @@ -1,62 +1,72 @@ -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 +75,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..72b03c39 100644 --- a/cli/src/lib/query-column-types.test.ts +++ b/cli/src/lib/query-column-types.test.ts @@ -1,82 +1,97 @@ -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..6d910f39 100644 --- a/cli/src/lib/query-destination.test.ts +++ b/cli/src/lib/query-destination.test.ts @@ -1,16 +1,21 @@ -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'; -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 }); +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, + }); const written: Uint8Array[] = []; - runtime.output.writeBytes = (body) => { + runtime.output.writeBytes = body => { written.push(body); }; @@ -28,9 +33,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 +52,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 +83,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 +103,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 +133,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 +145,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 +161,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 +173,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 +218,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..1b4b72b2 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -1,9 +1,10 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { configFile, kvSet } from '@/lib/config.ts'; +import { parseLakehouseQueryResponse } from '@/lib/lakehouse-ndjson.ts'; import { defaultDisplayOptions, formatQueryCell, @@ -14,15 +15,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 { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} from '@/test-utils/terminal.ts'; +import { getVisibleTextWidth } from '@/ui/terminal/styles.ts'; let terminalState: TerminalTestState | undefined; @@ -44,17 +45,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 +68,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 +237,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 +433,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 +554,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 +675,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 +724,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..03ef80cc 100644 --- a/cli/src/lib/query-format.ts +++ b/cli/src/lib/query-format.ts @@ -1,9 +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"; +} from '@/lib/lakehouse-ndjson.ts'; +import { pluralizeLabel } from '@/lib/pluralize.ts'; import { getColumnTypeMap, isTimestampValue, @@ -11,10 +15,17 @@ 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 { + redactPasswordFieldInText, + redactSensitiveJsonText, +} from '@/lib/redact.ts'; +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, @@ -23,15 +34,15 @@ 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'; 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..42a6286f 100644 --- a/cli/src/lib/query-output-args.test.ts +++ b/cli/src/lib/query-output-args.test.ts @@ -1,130 +1,156 @@ -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'; -describe("parseQueryOutputOptions", () => { - test("composes explicit query presentation settings", () => { +import { CliError } from '@/lib/errors.ts'; +import { + parseQueryOutputOptions, + resolveQueryComputeSize, +} from '@/lib/query-output-args.ts'; + +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..6925a6f0 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 { + resolvePagerOptions, + type PagerMode, + type PagerOptions, +} from '@/lib/pager.ts'; +import { + defaultDisplayOptions, + type QueryDisplayOptions, +} from '@/lib/query-format.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 { 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..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 { 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, @@ -7,33 +13,37 @@ 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 type { OutputSink } from '@/lib/runtime.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..9478e71a 100644 --- a/cli/src/lib/redact.test.ts +++ b/cli/src/lib/redact.test.ts @@ -1,51 +1,56 @@ -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 +60,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..572dac04 100644 --- a/cli/src/lib/relative-time.test.ts +++ b/cli/src/lib/relative-time.test.ts @@ -1,48 +1,64 @@ -import { describe, expect, test } from "bun:test"; -import { formatRelativeTimestamp, formatTimestampWithRelative } from "@/lib/relative-time.ts"; +import { describe, expect, test } from 'bun:test'; -const NOW_MS = Date.parse("2026-06-27T12:00:00.000Z"); +import { + formatRelativeTimestamp, + formatTimestampWithRelative, +} from '@/lib/relative-time.ts'; -describe("formatRelativeTimestamp", () => { - test("returns null for invalid timestamps", () => { - expect(formatRelativeTimestamp("not-a-date", NOW_MS)).toBeNull(); +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(); }); - 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..b8ef112d 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,9 +1,14 @@ -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..e9f8b7f6 100644 --- a/cli/src/lib/secrets.test.ts +++ b/cli/src/lib/secrets.test.ts @@ -1,18 +1,24 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { credentialsFile } from '@/lib/config.ts'; +import { getProfileId } from '@/lib/profile-store.ts'; +import { + createSecretStore, + secretExists, + secretGet, + secretSet, +} from '@/lib/secrets.ts'; +import { createFakeKeychain } from '@/test-utils/keychain.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 +27,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..e8e7ef3c 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,18 +1,19 @@ -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 { readEnv } from '@/lib/env.ts'; +import { CliError } from '@/lib/errors.ts'; +import { logWarn } from '@/lib/log.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 +25,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 +44,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 +69,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 +89,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 +104,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 +232,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 +251,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 +285,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 +312,7 @@ export function createSecretStore( } function secretStoreDisplay(): string { - return secretBackend() === "macos" ? "MacOS keychain" : credentialsFile(); + return secretBackend() === 'macos' ? 'MacOS keychain' : credentialsFile(); } return { @@ -279,7 +331,7 @@ export function secretSet( account: string, value: string, profileName: string, - options?: SecretSetOptions, + options?: SecretSetOptions ): void { systemSecretStore.secretSet(account, value, profileName, options); } @@ -296,7 +348,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..58e9d5e9 100644 --- a/cli/src/lib/service-account-provision.test.ts +++ b/cli/src/lib/service-account-provision.test.ts @@ -1,53 +1,68 @@ -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'; -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"); +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'); }); - 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..7ccd4271 100644 --- a/cli/src/lib/stream-lines.test.ts +++ b/cli/src/lib/stream-lines.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from "bun:test"; -import { readTextStreamLines } from "@/lib/stream-lines.ts"; +import { describe, expect, test } from 'bun:test'; -async function collectLines(stream: ReadableStream): Promise { +import { readTextStreamLines } from '@/lib/stream-lines.ts'; + +async function collectLines( + stream: ReadableStream +): Promise { const lines: string[] = []; for await (const line of readTextStreamLines(stream)) { lines.push(line); @@ -9,42 +12,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..edc776f6 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 { defaultDisplayOptions, getQueryColumnNames, renderQueryHumanOutput, renderQueryMarkdown, -} from "@/lib/query-format.ts"; +} from '@/lib/query-format.ts'; +import { renderQueryCsv, renderQueryJson } from '@/lib/query-output.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..c288c838 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 { 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"; +} from '@/release-manifest.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..a8f7e2bc 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -1,21 +1,32 @@ -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 { createHash } from 'node:crypto'; +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 { 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 { + 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, @@ -38,17 +49,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 { 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'); +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 +67,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 +86,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 +96,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 +127,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 +163,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 +354,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 +725,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 +825,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 +861,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 +953,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..4a19704e 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -1,20 +1,25 @@ -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 { spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { chmodSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +import type { CliContext } from '@/context.ts'; +import { isJsonOutput } from '@/context.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 { UpdaterInstallationKind, UpdaterCheckIntervals, @@ -27,7 +32,10 @@ import { type UpdateCheckInterval, type UpdateInstallMethod, type UpdateSource, -} from "@/lib/updater-config.ts"; +} 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; @@ -106,7 +114,7 @@ type FetchGitHubBinaryReleaseOptions = { type SpawnSyncImpl = typeof spawnSync; type RunInstallPlanOptions = { - stdio?: "inherit" | "pipe"; + stdio?: 'inherit' | 'pipe'; spawnImpl?: SpawnSyncImpl; verifyCommand?: string; verifyGlobalInstall?: boolean; @@ -121,7 +129,7 @@ type InstallCliUpdateOptions = { timeoutMs?: number; fetchImpl?: typeof fetch; spawnImpl?: SpawnSyncImpl; - stdio?: "inherit" | "pipe"; + stdio?: 'inherit' | 'pipe'; verify?: boolean; installation?: CurrentInstallation; }; @@ -134,27 +142,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 +181,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 +227,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 +244,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 +262,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 +286,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 +297,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 +356,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 +384,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 +452,7 @@ async function fetchResponse( throw new HttpError({ status: response.status, body, - method: "GET", + method: 'GET', url, }); } @@ -410,41 +463,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 +508,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 +520,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 +532,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 +556,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 +571,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 +583,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 +593,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 +645,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 +694,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 +732,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 +762,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 +781,7 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.source, executablePath: scriptPath || execPath, - reason: "running from TypeScript source", + reason: 'running from TypeScript source', }; } @@ -707,7 +793,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 +804,7 @@ export function detectCurrentInstallation( return { kind: UpdaterInstallationKind.nativeBinary, executablePath: execPath, - reason: "running a native release binary", + reason: 'running a native release binary', }; } @@ -729,20 +815,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 +840,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 +909,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 +919,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 +940,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 +973,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 +1006,7 @@ export async function installGitHubBinaryRelease( release.version, tempPath, options.spawnImpl, - "Downloaded Altertable CLI", + 'Downloaded Altertable CLI' ); replaceFileAtomically(targetPath, tempPath); @@ -908,61 +1021,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 +1105,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 +1127,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 +1148,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 +1156,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 +1182,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 +1234,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 +1244,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 +1292,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 +1312,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 +1323,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..bd399c22 100644 --- a/cli/src/lib/url-policy.test.ts +++ b/cli/src/lib/url-policy.test.ts @@ -1,17 +1,22 @@ -import { describe, expect, test } from "bun:test"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; +import { describe, expect, test } from 'bun:test'; -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(); +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(); }); - 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..93106cc2 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -1,297 +1,357 @@ -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 { 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"; +} from '@/lib/usage.ts'; +import { runWithCliRuntime } from '@/test-utils/runtime.ts'; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-utils/terminal.ts"; +} 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 () => { +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"); - const rootOptionsSection = usage.indexOf("\n Root options\n"); - const flagsSection = usage.indexOf("\n Global flags\n"); + 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'); 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: "help", scope: "global" }), + expect.objectContaining({ name: 'json' }) ); - expect(help.global_options.some((option) => option.name === "version")).toBe(false); - expect(help.subcommands.map((command) => command.name)).toEqual(["show", "cancel"]); + expect(help.global_options).toContainEqual( + expect.objectContaining({ name: 'help', scope: 'global' }) + ); + expect(help.global_options.some(option => option.name === 'version')).toBe( + false + ); + 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 +361,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..e2ceeb30 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,5 +1,3 @@ -import type { Command } from "@/lib/command.ts"; -import { ALTERTABLE_COMMAND_GROUPS, type AltertableCommandGroup } from "@/lib/command.ts"; import { aliasFlagToken, resolveCommandDescriptor, @@ -7,15 +5,23 @@ 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 { 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'; + +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..dab504e7 100644 --- a/cli/src/lib/user-directories.ts +++ b/cli/src/lib/user-directories.ts @@ -1,42 +1,49 @@ -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..8558303d 100644 --- a/cli/src/test-utils/lakehouse.ts +++ b/cli/src/test-utils/lakehouse.ts @@ -1,8 +1,15 @@ -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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { setCliContext } from '@/context.ts'; +import { getCliRuntime, refreshCliRuntimeContext } from '@/lib/runtime.ts'; export type MockHttpResponse = { urlPattern: string; @@ -22,15 +29,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 +59,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..dc0c232c 100644 --- a/cli/src/ui/prompts.ts +++ b/cli/src/ui/prompts.ts @@ -1,13 +1,15 @@ +import { stdin as input, stderr as output } from 'node:process'; + import { confirm as clackConfirm, isCancel, 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 { 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 +22,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 +38,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 +77,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 +88,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 +100,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..f974f3ed 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 { renderDisplayText } from '@/ui/terminal/styles.ts'; +import { renderFixedTable } from '@/ui/terminal/table.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..9e1e14bf 100644 --- a/cli/src/ui/shell/render.test.ts +++ b/cli/src/ui/shell/render.test.ts @@ -1,13 +1,16 @@ -import { describe, expect, test } from "bun:test"; -import { renderShellExportView } from "@/ui/shell/render.ts"; +import { describe, expect, test } from 'bun:test'; -describe("renderShellExportView", () => { - test("renders comments followed by quoted environment exports", () => { +import { renderShellExportView } from '@/ui/shell/render.ts'; + +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..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 { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { span } from '@/ui/document.ts'; import { getVisibleTextWidth, setTerminalColorMode, @@ -6,8 +8,7 @@ import { truncateTerminalText, applyTerminalColorFromContext, renderDisplayText, -} from "@/ui/terminal/styles.ts"; -import { span } from "@/ui/document.ts"; +} from '@/ui/terminal/styles.ts'; const originalNoColor = process.env.NO_COLOR; const originalTerm = process.env.TERM; @@ -23,10 +24,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 +63,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 +96,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 +107,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("careful", "warning")])).toContain( - "\u001b[93mcareful\u001b[39m", + expect(renderDisplayText([span('failed', 'error')])).toContain( + '\u001b[31mfailed\u001b[39m' ); - expect(renderDisplayText([span("failed", "error")])).toContain("\u001b[31mfailed\u001b[39m"); - expect(renderDisplayText([span("Usage", "heading")])).toContain("USAGE"); + 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..55864357 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -1,22 +1,29 @@ -import { readEnv, setEnv, unsetEnv } from "@/lib/env.ts"; - -import type { DisplayText, DisplayTextStyle } from "@/ui/document.ts"; +import { readEnv, setEnv, unsetEnv } from '@/lib/env.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 +39,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 +93,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 +110,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 +130,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 +188,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 +218,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 +253,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 +266,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 +276,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 +292,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 +311,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 +322,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 +336,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 +344,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 +367,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 +408,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..6f213abd 100644 --- a/cli/src/ui/terminal/table.test.ts +++ b/cli/src/ui/terminal/table.test.ts @@ -1,8 +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 { setTerminalColorMode, getVisibleTextWidth } from "@/ui/terminal/styles.ts"; -import { span } from "@/ui/document.ts"; +import { afterEach, describe, expect, test } from 'bun:test'; + +import { formatApiRoutes } from '@/commands/api/lib/render.ts'; +import { span } from '@/ui/document.ts'; +import { + setTerminalColorMode, + getVisibleTextWidth, +} from '@/ui/terminal/styles.ts'; +import { renderFixedTable } from '@/ui/terminal/table.ts'; const originalAltertableColor = process.env.ALTERTABLE_COLOR; const originalOscHyperlink = process.env.OSC_HYPERLINK; @@ -20,235 +24,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..cd992011 100644 --- a/cli/src/ui/terminal/table.ts +++ b/cli/src/ui/terminal/table.ts @@ -1,13 +1,17 @@ +import { + span, + type DisplayTableColumn, + type DisplayTableOptions, +} from '@/ui/document.ts'; import { getTerminalWidth, getVisibleTextWidth, padVisibleText, renderDisplayText, truncateTerminalText, -} from "@/ui/terminal/styles.ts"; -import { span, type DisplayTableColumn, type DisplayTableOptions } from "@/ui/document.ts"; +} from '@/ui/terminal/styles.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/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/` diff --git a/data-app/starter/.oxfmtrc.json b/data-app/starter/.oxfmtrc.json index e9c09693..569314e7 100644 --- a/data-app/starter/.oxfmtrc.json +++ b/data-app/starter/.oxfmtrc.json @@ -1,4 +1,11 @@ { "$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", + "sortImports": true, + "sortPackageJson": false } diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index 93b990cb..89875691 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -12,11 +12,11 @@ "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.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=="], @@ -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=="], @@ -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 389733e9..75fba8c2 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -22,11 +22,11 @@ "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.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..f52c1a74 100644 --- a/data-app/starter/src/App.tsx +++ b/data-app/starter/src/App.tsx @@ -1,5 +1,6 @@ -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..3f9f57b2 100644 --- a/data-app/starter/src/config.ts +++ b/data-app/starter/src/config.ts @@ -1,17 +1,19 @@ -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..5dbb0d18 100644 --- a/data-app/starter/src/data-context.ts +++ b/data-app/starter/src/data-context.ts @@ -1,20 +1,21 @@ -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..47c2f1ee 100644 --- a/data-app/starter/src/main.tsx +++ b/data-app/starter/src/main.tsx @@ -1,6 +1,7 @@ -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..6eb959a0 100644 --- a/data-app/starter/src/operations.ts +++ b/data-app/starter/src/operations.ts @@ -1,5 +1,6 @@ -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..1c14ea64 100644 --- a/data-app/starter/src/preview.ts +++ b/data-app/starter/src/preview.ts @@ -1,9 +1,10 @@ -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 +21,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..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 { serveLocalApp } from '@altertable/data-app-bun'; + +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/bun.lock b/data-app/tests/bun.lock index 6f2b44b6..dd5bd58a 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/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 ec16deec..01fd7edb 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -6,11 +6,12 @@ "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", - "@types/bun": "1.4.2", + "@types/bun": "1.4.3", "oxlint-tsgolint": "7.0.2003", "typescript": "7.0.2" }, 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..2def910c 100644 --- a/data-app/tests/server.ts +++ b/data-app/tests/server.ts @@ -1,43 +1,51 @@ -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 { 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({ ...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..50a1851b 100644 --- a/data-app/tests/starter.spec.ts +++ b/data-app/tests/starter.spec.ts @@ -1,332 +1,438 @@ -import { expect, test } from "@playwright/test"; -import { readFile } from "node:fs/promises"; +import { readFile } from 'node:fs/promises'; + +import { expect, test } from '@playwright/test'; 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.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.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 +443,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 +453,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/package.json b/package.json new file mode 100644 index 00000000..431ce0a5 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "altertable-cli-workspace", + "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", + "check:package": "bun run --cwd cli pack:check", + "knip": "bun run --cwd cli knip", + "knip:production": "bun run --cwd cli knip:production" + }, + "packageManager": "bun@1.4.3" +} diff --git a/scripts/verify.sh b/scripts/verify.sh index 08f33088..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 @@ -45,18 +51,17 @@ 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" ./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 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 @@ -66,10 +71,7 @@ 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 +run_step "package checks" bun run --cwd "${REPO_ROOT}" check:package if [[ "${RUN_INTEGRATION}" == true ]]; then check_mock_server