diff --git a/AGENTS.md b/AGENTS.md index d33d898..824bfb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Altertable CLI — a TypeScript/Bun command-line tool for querying and managing | Path | When to edit | |------|--------------| | `cli/src/` | CLI commands, HTTP clients, formatting, config | -| `cli/tests/` | Bun unit tests for CLI logic | +| `cli/src/**/*.test.ts` | Colocated Bun unit tests for CLI logic | | `tests/` | Black-box end-user CLI tests run through `bin/altertable` | | `specs/` | Client API specs (submodule — read-only from this repo) | | `bin/altertable` | Dev launcher — do not edit | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dab72e..98feb9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,24 +32,29 @@ Match existing conventions in `cli/src/`: ## Command Architecture -Commands should keep platform concepts split and composed. The intended flow is: +Commands should keep the execution path local and readable. The intended flow is: ```text -parse args -> build operation plan -> run effects -> decode result -> present output +parse args -> build request -> send request -> decode result -> present output ``` Use these boundaries when adding or changing commands: -- `cli/src/commands/**` owns command shape, argument parsing, operation ids, capability metadata, and presentation choice. -- `cli/src/lib/operation-codec.ts` owns small reusable argument codecs. Prefer these helpers over ad hoc `String(...)` coercion. -- `cli/src/lib/operation-effect.ts` owns executable operation plans and the effect handler registry. -- `cli/src/lib/http-operation.ts` owns named HTTP operation descriptors. Prefer descriptors over command-local request objects. -- `cli/src/lib/operation-transport.ts` owns plane-aware HTTP transport. Do not build management/lakehouse URLs in commands. +- `cli/src/commands//index.ts` owns the command group; each leaf command has a sibling `.ts` file. +- `cli/src/commands//lib/**` owns implementation code used only by that command family. +- `cli/src/lib/**` is reserved for code shared by multiple command families. +- `cli/src/lib/args.ts` owns small reusable argument codecs. Prefer these helpers over ad hoc `String(...)` coercion. +- `cli/src/lib/http-request.ts` owns plane-aware HTTP transport. Do not build management/lakehouse URLs in commands. - Request builders should return data structures. They should not perform transport as a side effect. +- Keep request descriptions declarative so a future dry-run mode can inspect them without performing I/O. - Presentation should return `CommandOutputMode` or write through the command output sink. Avoid mixing transport, parsing, and terminal output in the same function. -- Register every command surface with a stable operation id, capabilities, effects, planes, mutability, and output shape through `defineOperationCommand`. +- Declare the exported command immediately after imports, then place its helpers and types below it so reviewers see the public shape first. +- Define commands and argument schemas through `cli/src/lib/command.ts`; Citty is an implementation detail of that boundary. +- Derive related argument schemas from one shared definition instead of repeating flags and descriptions. +- Register top-level commands in `cli/src/commands/index.ts`. +- Colocate unit tests beside their subject as `.test.ts`; reserve root `tests/` for black-box behavior. -Avoid adding compatibility wrappers that both build and execute requests. If a shared path is needed, share the request builder, effect builder, parser, or presenter instead. +If a path needs sharing, start at the narrowest common owner and move it to top-level `lib/` only when multiple command families use it. ## Tests diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2fbbd76..ded60f3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -184,7 +184,7 @@ bun test "$PWD"/tests/*.test.ts ### Shell completion -Shell completion scripts are generated from the Citty `CommandDef` tree in `cli/src/cli.ts`. The spec walker lives in `cli/src/lib/completion-spec.ts`; bash/zsh/fish formatters and shared path/flag helpers live in `cli/src/lib/completion-format.ts`. Command-specific flags are taken from `CompletionNode.flags` on each visited node; positional arguments and dynamic API values are not completed. When you change command structure, run `cd cli && bun test cli/tests/completion-spec.test.ts cli/tests/completion.test.ts`. +Shell completion scripts are generated from the Citty `CommandDef` tree in `cli/src/cli.ts`. The spec walker and formatters live in `cli/src/commands/completion/lib/`, beside their tests. Command-specific flags are taken from `CompletionNode.flags` on each visited node; positional arguments and dynamic API values are not completed. When you change command structure, run `cd cli && bun test src/commands/completion/lib/spec.test.ts src/commands/completion/index.test.ts`. Integration tests against the mock server: diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 9e3467a..13bc653 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -41,15 +41,18 @@ bun test "$PWD"/tests/integration.e2e.ts ## Architecture - **Dual-plane auth**: lakehouse data plane (HTTP Basic) vs management REST (Bearer API key). See root README credential tables. -- **`CliRuntime` + `OutputSink`** (`src/lib/runtime.ts`): `defineAltertableCommand` injects `sink` into every command `run` handler. Commands pass `sink` to output helpers — not raw `console.log` / `console.error` (completion scripts are the exception; they must emit raw script text). -- **`writeCommandOutput`** (`src/lib/command-output.ts`): unified success output — raw API, normalized envelopes, tabular management, deletes. Accepts `sink` as the last argument; lib callers may omit it (defaults to `getOutputSink()`). -- Helpers: `writeManagementOutput`, `writeLakehouseOutput`, `writeJsonOrRaw` (thin wrappers around `writeCommandOutput`). +- **`CliRuntime` + `OutputSink`** (`src/lib/runtime.ts`): `defineCommand` injects `runtime`, `sink`, and a lazy `execution` context into every command handler. Commands pass `sink` to output helpers rather than writing to the console. +- **Direct execution**: a leaf command parses its arguments, builds a plain request value, sends it, and presents the result. Request values stay declarative as the seam for a future dry-run mode; there is no operation/effect framework. +- **`writeCommandOutput`** (`src/lib/command-output.ts`): unified success output — raw API, normalized envelopes, tabular management, deletes. Commands pass their injected `sink` explicitly. - **When to use `sink` directly**: bespoke output (custom tables, metadata messages) — `sink.writeJson`, `sink.writeHuman`, `sink.writeMetadata`. - **When to use helpers**: API-shaped responses with `--json` parity — pass `sink` as the last argument. -- Lakehouse streaming/query formatting lives in `lakehouse-client.ts` and `query-format.ts`. +- Lakehouse streaming lives in `lakehouse/`; query presentation lives in `query-output.ts` and `query-format.ts`. ## Conventions +- Declare and export each command immediately after its imports; keep supporting helpers and types below it. +- Import command types and `defineArgs` from `src/lib/command.ts`; only that boundary should depend on Citty's types. +- Derive related argument schemas from shared fragments instead of repeating flag definitions. - Function declarations over const function expressions (except one-liners) - Types over interfaces - Explicit variable names; match surrounding file style @@ -78,39 +81,42 @@ bun test "$PWD"/tests/integration.e2e.ts | ------------------------------------- | ---------------------------------------------------------- | | `src/cli.ts` | Citty root command, global flags, error bootstrap | | `src/context.ts` | Parsed global flags (`json`, `debug`, `profile`, timeouts) | -| `src/commands/*` | One file per top-level command group | -| `src/lib/*` | Shared clients, formatting, config, completion | +| `src/commands//index.ts` | One top-level command group | +| `src/commands//.ts` | One leaf command | +| `src/commands//lib/*` | Implementation private to that command family | +| `src/lib/*` | Code shared by multiple command families | +| `src/test-utils/*` | Shared CLI test harnesses and temporary workspaces | | `src/generated/openapi-types.ts` | Generated — run `bun run generate` after OpenAPI changes | | `src/generated/openapi-operations.ts` | Generated operation index for `api routes` | -| `tests/*.test.ts` | Bun unit tests under `cli/tests/` | +| `src/**/*.test.ts` | Unit tests colocated beside their subject | | `../tests/*.test.ts` | Black-box end-user CLI tests at repo root | | `../tests/integration.e2e.ts` | Mock-server lakehouse integration test | -**Largest/hot files** — read before large refactors: `lib/http.ts`, `lib/profile-configure-core.ts`, `lib/query-format.ts`, `lib/api-http.ts`. - -| Module | Role | -| -------------------------------------- | -------------------------------------------------------------------------------- | -| `commands/lakehouse.ts` | Data-plane commands (query, upload, append, …) | -| `commands/api.ts` | Management HTTP invoker (`api GET /path`), spec, routes | -| `lib/api-http.ts` | HTTP invoker logic for `api` | -| `lib/api-body.ts` | `--body`, `@file`, `-f key=value` body builders | -| `lib/profile-configure-core.ts` | Credential store (`configureRunSet`, show, clear) | -| `lib/profile-configure.ts` | `profile --configure` dispatch (flags vs wizard, `--scope`) + interactive wizard | -| `lib/profile-configure-interactive.ts` | Wizard prompts + credential collection | -| `lib/profile-status.ts` | Post-configure credential verification (`configureVerify`) | -| `features/profile/model.ts` | Profile store/inspect + credential presence (stored + env) | -| `commands/profile.ts` | Profile subcommands, `profile --configure`, `profile show` | -| `lib/lakehouse-client.ts` | Lakehouse HTTP + query rendering | -| `lib/http.ts` | Shared HTTP transport, logging, mock file support | -| `lib/management-transport.ts` | Management API HTTP transport | -| `lib/management-formatters.ts` | Human formatters for identity and `catalogs` | -| `lib/catalog-rows.ts` | Catalog list row builder for `catalogs list` | -| `lib/errors.ts` | Exit codes, `CliError`, JSON error envelope | -| `lib/completion-spec.ts` | Walks Citty tree for shell completion | +**Largest/hot files** — read before large refactors: `lib/http.ts`, `lib/profile-configure-core.ts`, `lib/query-format.ts`, `commands/api/lib/http.ts`. + +| Module | Role | +| --------------------------------------- | -------------------------------------------------------------------------------- | +| `commands/query/`, `append/`, `upload/` | Data-plane commands | +| `commands/api/` | Management HTTP invoker (`api GET /path`), spec, routes | +| `commands/api/lib/http.ts` | HTTP invoker logic for `api` | +| `commands/api/lib/body.ts` | `--body`, `@file`, `-f key=value` body builders | +| `lib/profile-configure-core.ts` | Credential store (`configureRunSet`, show, clear) | +| `lib/profile-configure.ts` | `profile --configure` dispatch (flags vs wizard, `--scope`) + interactive wizard | +| `lib/profile-configure-interactive.ts` | Wizard prompts + credential collection | +| `lib/profile-status.ts` | Post-configure credential verification (`configureVerify`) | +| `lib/profile/model.ts` | Profile store/inspect + credential presence shared by auth commands | +| `commands/profile/` | Profile subcommands, `profile --configure`, `profile show` | +| `lib/query-output.ts` | Shared query output formats and sink dispatch | +| `lib/http.ts` | Shared HTTP transport, logging, mock file support | +| `lib/management/` | Shared management identity, catalogs, and presentation | +| `commands/catalogs/lib/requests.ts` | Declarative catalog create request builder | +| `ui/prompts.ts` | Shared interactive prompt adapter and types | +| `lib/errors.ts` | Exit codes, `CliError`, JSON error envelope | +| `commands/completion/lib/spec.ts` | Walks Citty tree for shell completion | ## Command tree -Source of truth: `src/cli.ts` + `commands/api.ts`. Verify with `bin/altertable --help`. +Source of truth: `src/commands/index.ts`. Verify with `bin/altertable --help`. ``` altertable @@ -133,27 +139,25 @@ altertable ### Recipe A — New top-level product command -1. Create `src/commands/myfeature.ts` exporting `myfeatureCommand` via `defineAltertableCommand` -2. Register in `src/cli.ts` `topLevelCommands` -3. Pass `sink` from `run({ sink })` to `writeCommandOutput` or plane-specific wrappers (`writeManagementOutput`, `writeLakehouseOutput`) -4. Management plane: `managementRequest()` from `lib/management-transport.ts` -5. Lakehouse plane: functions from `lib/lakehouse-client.ts` -6. Add unit test in `cli/tests/`; black-box test in `tests/` if integration-worthy -7. Flags on command `args` are picked up by `completion-spec.ts` — run completion tests after structural changes +1. Create `src/commands/myfeature/index.ts` exporting `myfeatureCommand` via `defineCommand`. +2. Put each subcommand in `src/commands/myfeature/.ts`. +3. Put private helpers and request builders in `src/commands/myfeature/lib/`. +4. Register the top-level command in `src/commands/index.ts`. +5. Add `.test.ts` beside the command or library under test; add a root `tests/` case only for black-box behavior. +6. Flags on command `args` are picked up by completion automatically; run completion tests after structural changes. Minimal pattern (management HTTP command): ```typescript -import { defineAltertableCommand } from "@/lib/command-context.ts"; -import { writeJsonOrRaw } from "@/lib/command-output.ts"; -import { formatWhoami, type WhoamiResponse } from "@/lib/management-formatters.ts"; -import { managementRequest } from "@/lib/management-transport.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { sendHttp } from "@/lib/http-request.ts"; -export const myfeatureCommand = defineAltertableCommand({ +export const myfeatureCommand = defineCommand({ meta: { name: "myfeature", description: "…" }, - async run({ sink }) { - const response = await managementRequest("GET", "/path"); - writeJsonOrRaw(response, (data) => formatWhoami(data as WhoamiResponse), sink); + async run({ execution, sink }) { + const request = { plane: "management", method: "GET", endpoint: "/path" } as const; + const response = await sendHttp(request, execution); + sink.writeJson(JSON.parse(response)); }, }); ``` @@ -174,24 +178,24 @@ Bump the OpenAPI spec and extend `openapi-http-conformance.test.ts` placeholder ### Recipe C — Change exit codes or JSON errors 1. Edit `src/lib/errors.ts` only -2. Update `cli/tests/errors.test.ts` and `tests/scripting.test.ts` +2. Update `cli/src/lib/errors.test.ts` and `tests/scripting.test.ts` 3. Update README scripting table — do not renumber existing codes ## Testing guide -| Change type | Run | -| -------------------- | ------------------------------------------------------------------------- | -| lib pure function | `cd cli && bun test path/to.test.ts` | -| command validation | `commands-*.test.ts` pattern | -| HTTP behavior | mock file via `ALTERTABLE_MOCK_HTTP_FILE` (see `tests/helpers.ts`) | -| end-to-end lakehouse | `./scripts/verify.sh --integration` | -| completion structure | `bun test cli/tests/completion-spec.test.ts cli/tests/completion.test.ts` | +| Change type | Run | +| -------------------- | ------------------------------------------------ | +| 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` | +| 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`. Lakehouse endpoint coverage: [DEVELOPMENT.md spec conformance table](../DEVELOPMENT.md#cli-spec-conformance-lakehouse). -Example mock HTTP test pattern: `cli/tests/lakehouse.test.ts` sets `ALTERTABLE_MOCK_HTTP_FILE`. Root black-box tests use `tests/helpers.ts`. +Example mock HTTP test pattern: command tests use `src/test-utils/lakehouse.ts` with `ALTERTABLE_MOCK_HTTP_FILE`. Root black-box tests use `tests/helpers.ts`. ## Invariants (do not break) @@ -200,7 +204,7 @@ Example mock HTTP test pattern: `cli/tests/lakehouse.test.ts` sets `ALTERTABLE_M - Dual-plane configure: one authentication mechanism per flag-based invocation; the interactive wizard may configure both planes in one session - HTTP log redaction in tests (`setupHttpLog` / `readHttpLog` in `tests/helpers.ts`) - `bin/altertable` launcher unchanged -- No raw `console.log` in commands except `completion.ts` +- No raw `console.log` in commands ## Plans diff --git a/cli/bunfig.toml b/cli/bunfig.toml index 899e33c..1e6a31b 100644 --- a/cli/bunfig.toml +++ b/cli/bunfig.toml @@ -3,4 +3,4 @@ coverageReporter = ["text", "lcov"] coverageDir = "coverage" coverageSkipTestFiles = true coverageThreshold = { line = 0.8, function = 0.8 } -coveragePathIgnorePatterns = ["src/generated/**", "tests/**"] +coveragePathIgnorePatterns = ["src/generated/**"] diff --git a/cli/knip.json b/cli/knip.json index 82713e8..e5e0b41 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,9 +1,15 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["tests/**/*.test.ts", "scripts/**/*.ts"], - "project": ["src/**/*.ts", "tests/**/*.test.ts", "scripts/**/*.ts"], + "entry": [ + "src/cli.ts!", + "src/**/*.test.ts", + "src/**/fixtures/*.ts", + "scripts/**/*.test.ts", + "scripts/**/*.ts" + ], + "project": ["src/**/*.ts!", "!src/test-utils/**!", "scripts/**/*.ts"], "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, "ignoreDependencies": ["undici"], - "ignoreBinaries": ["less", "duckdb"] + "ignoreBinaries": ["less"] } diff --git a/cli/package.json b/cli/package.json index 88bb37b..7f96408 100644 --- a/cli/package.json +++ b/cli/package.json @@ -40,7 +40,8 @@ "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", - "knip": "knip" + "knip": "knip --no-config-hints", + "knip:production": "knip --production --no-config-hints" }, "dependencies": { "@clack/prompts": "1.7.0", diff --git a/cli/scripts/release-manifest.ts b/cli/scripts/release-manifest.ts new file mode 100644 index 0000000..7e66b88 --- /dev/null +++ b/cli/scripts/release-manifest.ts @@ -0,0 +1,29 @@ +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 const RELEASE_BUNDLE_ASSET = "altertable-cli.js"; +export const RELEASE_METADATA_ASSET = "release-manifest.json"; + +type ReleaseBunTarget = (typeof RELEASE_TARGETS)[number]["bunTarget"]; + +export function findReleaseTargetByBunTarget(bunTarget: string) { + return RELEASE_TARGETS.find((target) => target.bunTarget === bunTarget); +} + +export function findReleaseTargetByPlatform(platform: string) { + return RELEASE_TARGETS.find((target) => target.platform === platform); +} + +export function releaseCiMatrix(): { + include: Array<{ target: ReleaseBunTarget; artifact: ReleaseAssetName; runner: string }>; +} { + return { + include: RELEASE_TARGETS.map((target) => ({ + target: target.bunTarget, + artifact: target.asset, + runner: target.runner, + })), + }; +} diff --git a/cli/tests/release.test.ts b/cli/scripts/release.test.ts similarity index 99% rename from cli/tests/release.test.ts rename to cli/scripts/release.test.ts index e5d411a..9e89bb0 100644 --- a/cli/tests/release.test.ts +++ b/cli/scripts/release.test.ts @@ -37,7 +37,7 @@ import { RELEASE_METADATA_ASSET, RELEASE_TARGETS, releaseCiMatrix, -} from "@/release-manifest.ts"; +} from "@/../scripts/release-manifest.ts"; import { VERSION } from "@/version.ts"; const temporaryDirectories: string[] = []; diff --git a/cli/scripts/release.ts b/cli/scripts/release.ts index cea9ca3..3f09deb 100644 --- a/cli/scripts/release.ts +++ b/cli/scripts/release.ts @@ -13,7 +13,7 @@ import { RELEASE_TARGETS, releaseCiMatrix, type ReleaseTarget, -} from "@/release-manifest.ts"; +} from "@/../scripts/release-manifest.ts"; export const RELEASE_MANIFEST_SCHEMA_VERSION = 2; export const SUPPORTED_BUN_RUNTIME_RANGE = ">=1.1.0"; diff --git a/cli/tests/script-output.test.ts b/cli/scripts/script-output.test.ts similarity index 96% rename from cli/tests/script-output.test.ts rename to cli/scripts/script-output.test.ts index 68fdb1f..ab23e36 100644 --- a/cli/tests/script-output.test.ts +++ b/cli/scripts/script-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { renderQueryCsv, renderQueryJson } from "@/lib/lakehouse-client.ts"; +import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; import { renderQueryMarkdown } from "@/lib/query-format.ts"; describe("script output lossless guarantees", () => { diff --git a/cli/src/cli.ts b/cli/src/cli.ts index aff886c..78572d6 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,5 +1,4 @@ #!/usr/bin/env bun -import { runCommand, type ArgsDef, type CommandDef } from "citty"; import { VERSION } from "@/version.ts"; import { getCliContext, @@ -10,29 +9,16 @@ import { } from "@/context.ts"; import { createCliRuntime, refreshCliRuntimeContext, setCliRuntime } from "@/lib/runtime.ts"; import { parseGlobalFlags, parseGlobalFlagsFromArgs } from "@/lib/global-flags.ts"; -import { loginCommand, logoutCommand } from "@/commands/login.ts"; -import { profileCommand } from "@/commands/profile.ts"; -import { catalogsCommand } from "@/commands/catalogs.ts"; -import { duckdbCommand } from "@/commands/duckdb.ts"; -import { appendCommand } from "@/commands/lakehouse/append.ts"; -import { queryCommand, normalizeQueryInvocatorRawArgs } from "@/commands/lakehouse/query.ts"; -import { schemaCommand } from "@/commands/lakehouse/schema.ts"; -import { uploadCommand } from "@/commands/lakehouse/upload.ts"; -import { upsertCommand } from "@/commands/lakehouse/upsert.ts"; -import { apiCommand, normalizeApiInvocatorRawArgs } from "@/commands/api.ts"; -import { createCompletionCommand } from "@/commands/completion.ts"; -import { updateCommand } from "@/commands/update.ts"; +import { buildTopLevelCommands, normalizeCommandRawArgs } from "@/commands/index.ts"; import { CliError, EXIT_SUCCESS, getCliExitCode, isCittyCliError, - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; -import { defineRootCommand } from "@/lib/command-context.ts"; +import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; +import { defineArgs, defineRootCommand, runCommandTree, type Command } from "@/lib/command.ts"; import { resolveSubCommandForUsage, showAltertableUsage, @@ -52,7 +38,7 @@ function buildEarlyCliContext(argv: readonly string[]): CliContext { return parseGlobalFlags(argv); } -const ROOT_ARGS = { +const ROOT_ARGS = defineArgs({ debug: { type: "boolean", alias: "d", description: "Enable debug output" }, json: { type: "boolean", description: "Machine-readable JSON output" }, agent: { @@ -75,7 +61,7 @@ const ROOT_ARGS = { type: "string", description: "HTTP read timeout in seconds (default 60; 0 = no limit for streams)", }, -} satisfies ArgsDef; +}); const ROOT_VALUE_FLAGS = valueFlagsFor(ROOT_ARGS); @@ -83,26 +69,10 @@ export function resolveTopLevelCommandName(rawArgs: readonly string[]): string | return findFirstPositionalToken(rawArgs, { valueFlags: ROOT_VALUE_FLAGS })?.value; } -export function buildMainCommand(): CommandDef { - let mainCommand: CommandDef; - - const completionCommand = createCompletionCommand(() => mainCommand); - - const topLevelCommands: Record = { - login: loginCommand, - logout: logoutCommand, - profile: profileCommand, - catalogs: catalogsCommand, - query: queryCommand, - schema: schemaCommand, - duckdb: duckdbCommand, - append: appendCommand, - upload: uploadCommand, - upsert: upsertCommand, - api: apiCommand, - update: updateCommand, - completion: completionCommand, - }; +export function buildMainCommand(): Command { + let mainCommand: Command; + + const topLevelCommands = buildTopLevelCommands(() => mainCommand); mainCommand = defineRootCommand({ meta: { @@ -153,10 +123,7 @@ function handleCliError(error: unknown): never { } async function bootstrap(): Promise { - const rawArgs = normalizeQueryInvocatorRawArgs( - normalizeApiInvocatorRawArgs(process.argv.slice(2), ROOT_ARGS), - ROOT_ARGS, - ); + const rawArgs = normalizeCommandRawArgs(process.argv.slice(2), ROOT_ARGS); // Early parse only for --help, --version, and JSON error envelope before citty runs. const earlyContext = buildEarlyCliContext(rawArgs); applyTerminalColorFromContext(earlyContext); @@ -180,7 +147,7 @@ async function bootstrap(): Promise { } validateEnvironment(); - await runCommand(main, { rawArgs }); + await runCommandTree(main, { rawArgs }); await maybeShowUpdateNotice({ context: getCliContext(), commandName: resolveTopLevelCommandName(rawArgs), diff --git a/cli/src/commands/api.ts b/cli/src/commands/api.ts deleted file mode 100644 index 90f4e69..0000000 --- a/cli/src/commands/api.ts +++ /dev/null @@ -1,295 +0,0 @@ -import type { ArgsDef } from "citty"; -import { - getOpenapiSpecJson, - getOpenapiSpecYaml, - resolveOpenapiSpecFormat, -} from "@/lib/openapi-spec.ts"; -import { - API_HTTP_OPERATION, - apiHttpResultOutput, - resolveApiHttp, - type ApiHttpResult, - type ResolvedApiHttp, -} from "@/lib/api-http.ts"; -import { extractFieldArgs, extractRawFieldArgs } from "@/lib/api-body.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { defineHttpCommand, defineOutputCommand } from "@/lib/operation-command-builders.ts"; -import { optionalStringArg } from "@/lib/operation-codec.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { - isDelegatedSubCommand, - normalizePassthroughCommandRawArgs, - valueFlagsFor, -} from "@/lib/command-delegation.ts"; -import { noopPlan } from "@/lib/operation-effect.ts"; -import { withManagementFormatArg } from "@/lib/management-output.ts"; -import { readArgvFlagValue } from "@/lib/timeout-args.ts"; -import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/features/api/model.ts"; -import { formatApiOperationDetails, formatApiRoutes } from "@/features/api/render.ts"; - -const HTTP_METHOD_NAMES = ["GET", "POST", "PATCH", "DELETE", "PUT"] as const; -const API_COMMAND_NAMES = new Set(["spec", "routes", ...HTTP_METHOD_NAMES]); - -const API_HTTP_BASE_ARGS = { - method: { - type: "enum", - alias: "X", - description: "HTTP method override (default GET, or POST when fields/body are provided)", - options: [...HTTP_METHOD_NAMES], - }, - endpoint: { - type: "positional", - description: "Path under /rest/v1, e.g. /whoami", - required: false, - }, - "raw-field": { - type: "string", - alias: "f", - description: "String request parameter key=value (repeatable; gh api -f semantics)", - }, - field: { - type: "string", - alias: "F", - description: "Typed request parameter key=value (true, false, null, integers; repeatable)", - }, - body: { type: "string", description: "JSON body or @file" }, - input: { type: "string", description: "Alias for --body (file path or - for stdin)" }, - env: { type: "string", description: "Replace {environment_id} in the path" }, -} satisfies ArgsDef; - -const API_VALUE_FLAGS = valueFlagsFor(API_HTTP_BASE_ARGS); -const API_HTTP_ARGS = withManagementFormatArg(API_HTTP_BASE_ARGS); - -function isApiCommandName(value: string): boolean { - return API_COMMAND_NAMES.has(value) || API_COMMAND_NAMES.has(value.toUpperCase()); -} - -function isDelegatedApiCommand(rawArgs: readonly string[]): boolean { - return isDelegatedSubCommand(rawArgs, isApiCommandName, { - valueFlags: API_VALUE_FLAGS, - }); -} - -/** Citty treats endpoint paths as subcommand names unless we separate them with `--`. */ -export function normalizeApiInvocatorRawArgs( - rawArgs: readonly string[], - rootArgs: ArgsDef = {}, -): string[] { - return normalizePassthroughCommandRawArgs(rawArgs, { - commandName: "api", - rootArgs, - commandValueFlags: API_VALUE_FLAGS, - isReservedOperand: isApiCommandName, - }); -} - -function buildApiHttpArgs(args: Record, rawArgs: string[], method?: string) { - const rawFieldArgs = extractRawFieldArgs(rawArgs); - const fieldArgs = extractFieldArgs(rawArgs); - - return { - method: optionalStringArg(args, "method") ?? method, - endpoint: optionalStringArg(args, "endpoint"), - body: optionalStringArg(args, "body"), - input: optionalStringArg(args, "input"), - rawFields: rawFieldArgs.length > 0 ? rawFieldArgs : undefined, - typedFields: fieldArgs.length > 0 ? fieldArgs : undefined, - env: optionalStringArg(args, "env"), - format: optionalStringArg(args, "format") ?? readArgvFlagValue(rawArgs, "--format"), - }; -} - -type ApiInvokeInput = { - delegated: boolean; - request?: ResolvedApiHttp; -}; - -const HTTP_METHOD_EXAMPLES: Record<(typeof HTTP_METHOD_NAMES)[number], readonly string[]> = { - GET: ["altertable api /whoami", "altertable api GET /environments/production/connections"], - POST: [ - 'altertable api POST /service_accounts -f label="CI Bot"', - "altertable api POST /environments/production/databases -f name=Analytics", - ], - PATCH: [ - 'altertable api PATCH /environments/production/connections/conn_1 --body \'{"name":"Renamed"}\'', - ], - DELETE: ["altertable api DELETE /service_accounts/sa_abc123"], - PUT: ["altertable api PUT /path --body @payload.json"], -}; - -function createApiMethodCommand(method: string) { - const methodExamples = HTTP_METHOD_EXAMPLES[method as (typeof HTTP_METHOD_NAMES)[number]]; - - return defineHttpCommand({ - id: `api.${method.toLowerCase()}`, - plane: "management", - operation: API_HTTP_OPERATION, - mutates: method !== "GET", - output: "tabular", - meta: { - name: method, - description: `${method} request to the management REST API.`, - examples: methodExamples, - }, - args: API_HTTP_ARGS, - parse({ args, rawArgs }) { - return resolveApiHttp(buildApiHttpArgs(args, rawArgs, method)); - }, - present(result, { sink }) { - return apiHttpResultOutput(result, sink); - }, - }); -} - -function formatOperationDetails(operationId: string): string { - return formatApiOperationDetails(apiOperationDetails(operationId)); -} - -function operationDetailsJson(operationId: string): Record { - return apiOperationDetails(operationId); -} - -function apiSpecOutput(sink: OutputSink, options?: { format?: string }) { - const format = resolveOpenapiSpecFormat( - sink.json, - process.stdout.isTTY === true, - options?.format, - ); - - if (format === "json") { - return { kind: "raw_api" as const, body: getOpenapiSpecJson() }; - } - - return { kind: "human" as const, text: getOpenapiSpecYaml() }; -} - -export async function runApiSpecCommand( - sink: OutputSink, - options?: { format?: string }, -): Promise { - await writeCommandOutput(apiSpecOutput(sink, options), sink); -} - -function apiRoutesOutput(operationId?: string) { - if (operationId) { - return { - kind: "normalized" as const, - data: operationDetailsJson(operationId), - humanText: formatOperationDetails(operationId), - }; - } - - const operations = apiOperationsJson(); - const table = formatApiRoutes(apiRouteRows()); - return { - kind: "normalized" as const, - data: operations, - humanText: table, - pageHumanText: true, - }; -} - -export async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { - await writeCommandOutput(apiRoutesOutput(operationId), sink); -} - -const apiSpecCommand = defineOutputCommand({ - id: "api.spec", - capabilities: ["raw-stdout"], - output: "raw-api", - meta: { - name: "spec", - description: - "Print the bundled management OpenAPI specification (YAML in a terminal; JSON when piped or with --json).", - examples: ["altertable api spec", "altertable api spec --json"], - }, - args: { - format: { - type: "enum", - options: ["json", "yaml"], - description: - "Output format (default: yaml in a terminal, json when piped or with global --json)", - }, - }, - parse({ args }) { - return { format: optionalStringArg(args, "format") }; - }, - render(input, { sink }) { - return apiSpecOutput(sink, input); - }, -}); - -const apiRoutesCommand = defineOutputCommand({ - id: "api.routes", - capabilities: [], - output: "normalized", - meta: { - name: "routes", - description: "List management API paths and methods from the bundled OpenAPI spec.", - examples: ["altertable api routes", "altertable api routes createDatabase"], - }, - args: { - operation: { - type: "positional", - description: "Optional operationId to inspect, e.g. createDatabase", - required: false, - }, - }, - parse({ args }) { - return optionalStringArg(args, "operation"); - }, - render(operationId) { - return apiRoutesOutput(operationId); - }, -}); - -const apiMethodSubCommands = Object.fromEntries( - HTTP_METHOD_NAMES.map((method) => [method, createApiMethodCommand(method)]), -); - -export const apiCommand = defineOperationCommand({ - id: "api.invoke", - capabilities: ["management-http"], - catalog: { - effects: ["http"], - planes: ["management"], - output: "tabular", - }, - meta: { - name: "api", - commandGroup: "platform", - description: "Management REST API — HTTP invoker and OpenAPI spec.", - examples: [ - "altertable api /whoami", - "altertable api routes", - "altertable api GET /environments/production/connections", - 'altertable api POST /service_accounts -f label="CI Bot"', - ], - }, - args: API_HTTP_BASE_ARGS, - subCommands: { - spec: apiSpecCommand, - routes: apiRoutesCommand, - ...apiMethodSubCommands, - }, - parse({ args, rawArgs }) { - const delegated = isDelegatedApiCommand(rawArgs); - return { - delegated, - request: delegated ? undefined : resolveApiHttp(buildApiHttpArgs(args, rawArgs)), - }; - }, - run(input, context) { - if (input.delegated) { - return noopPlan(); - } - return API_HTTP_OPERATION.plan(input.request as ResolvedApiHttp, context); - }, - present(result, { sink }) { - if (result === undefined) { - return; - } - return apiHttpResultOutput(result, sink); - }, -}); diff --git a/cli/src/commands/api/delete.ts b/cli/src/commands/api/delete.ts new file mode 100644 index 0000000..c048664 --- /dev/null +++ b/cli/src/commands/api/delete.ts @@ -0,0 +1,3 @@ +import { createApiMethodCommand } from "@/commands/api/lib/command.ts"; + +export const apiDeleteCommand = createApiMethodCommand("DELETE"); diff --git a/cli/src/commands/api/get.ts b/cli/src/commands/api/get.ts new file mode 100644 index 0000000..bd7b7e6 --- /dev/null +++ b/cli/src/commands/api/get.ts @@ -0,0 +1,3 @@ +import { createApiMethodCommand } from "@/commands/api/lib/command.ts"; + +export const apiGetCommand = createApiMethodCommand("GET"); diff --git a/cli/tests/api.test.ts b/cli/src/commands/api/index.test.ts similarity index 80% rename from cli/tests/api.test.ts rename to cli/src/commands/api/index.test.ts index a1ba5ee..cdb49d4 100644 --- a/cli/tests/api.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -1,48 +1,15 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import type { ArgsDef, CommandDef } from "citty"; -import { runCommand } from "citty"; 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, - normalizeApiInvocatorRawArgs, - runApiRoutesCommand, - runApiSpecCommand, -} from "@/commands/api.ts"; +import { apiCommand, normalizeApiInvocatorRawArgs } from "@/commands/api/index.ts"; import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; import { setCliContext } from "@/context.ts"; -import { buildCompletionSpec, flattenTopLevelNames } from "@/lib/completion-spec.ts"; +import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; - -function createCaptureSink(json: boolean) { - const stdout: string[] = []; - const runtime = createCliRuntime({ debug: false, json, agent: false }); - runtime.output.writeRaw = (body) => { - stdout.push(body); - }; - runtime.output.writeHuman = (text) => { - stdout.push(text); - }; - runtime.output.writeJson = (data) => { - stdout.push(JSON.stringify(data)); - }; - return { sink: runtime.output, stdout }; -} - -async function runApiSpec(json: boolean, format?: string): Promise { - const { sink, stdout } = createCaptureSink(json); - await runApiSpecCommand(sink, { format }); - return stdout.join(""); -} - -async function runApiRoutes(json: boolean, operation?: string): Promise { - const { sink, stdout } = createCaptureSink(json); - await runApiRoutesCommand(sink, operation); - return stdout.join(""); -} +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { defineArgs, runCommandTree, type Command } from "@/lib/command.ts"; describe("api", () => { beforeEach(() => { @@ -54,15 +21,24 @@ describe("api", () => { }); test("api spec prints YAML containing Altertable Management API", async () => { - const output = await runApiSpec(false, "yaml"); + const result = await runCommandWithTestRuntime(["api", "spec", "--format", "yaml"], { + 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"); }); test("api spec with JSON context prints parseable JSON with openapi 3.1.0", async () => { - setCliContext({ debug: false, json: true, agent: false }); - const output = await runApiSpec(true); + const result = await runCommandWithTestRuntime(["api", "spec", "--format", "json"], { + 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"); @@ -89,7 +65,9 @@ describe("api", () => { setCliRuntime(runtime); try { - await runCommand(buildMainCommand(), { rawArgs: ["api", "spec", "--format", "yaml"] }); + await runCommandTree(buildMainCommand(), { + rawArgs: ["api", "spec", "--format", "yaml"], + }); } finally { setCliRuntime(previousRuntime); } @@ -108,7 +86,12 @@ describe("api", () => { }); test("api routes inspects one operation in human mode", async () => { - const output = await runApiRoutes(false, "createDatabase"); + const result = await runCommandWithTestRuntime(["api", "routes", "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"); @@ -116,7 +99,11 @@ describe("api", () => { }); test("api routes operation detail includes path parameters in JSON mode", async () => { - const output = await runApiRoutes(true, "createServiceAccountCredential"); + const result = await runCommandWithTestRuntime( + ["api", "routes", "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"]); @@ -124,7 +111,7 @@ describe("api", () => { test("buildMainCommand top-level names include api and exclude connections", () => { const spec = buildCompletionSpec(buildMainCommand()); - const topLevelNames = flattenTopLevelNames(spec); + const topLevelNames = spec.subcommands.map((command) => command.name); expect(topLevelNames).toContain("api"); expect(topLevelNames).not.toContain("connections"); expect(topLevelNames).not.toContain("service-accounts"); @@ -133,15 +120,15 @@ describe("api", () => { }); test("api command tree exposes spec and routes subcommands", () => { - const subCommands = apiCommand.subCommands as Record; + const subCommands = apiCommand.subCommands as Record; expect(subCommands.spec?.run).toBeDefined(); expect(subCommands.routes?.run).toBeDefined(); }); test("normalizeApiInvocatorRawArgs inserts -- before endpoint paths", () => { - const rootArgs = { + const rootArgs = defineArgs({ profile: { type: "string", description: "Use a named profile" }, - } satisfies ArgsDef; + }); expect(normalizeApiInvocatorRawArgs(["api", "/whoami"])).toEqual(["api", "--", "/whoami"]); expect(normalizeApiInvocatorRawArgs(["api", "GET", "/whoami"])).toEqual([ diff --git a/cli/src/commands/api/index.ts b/cli/src/commands/api/index.ts new file mode 100644 index 0000000..47c90ce --- /dev/null +++ b/cli/src/commands/api/index.ts @@ -0,0 +1,69 @@ +import type { CommandArgs } from "@/lib/command.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { executeApiHttp, apiHttpResultOutput } from "@/commands/api/lib/http.ts"; +import { API_HTTP_BASE_ARGS, resolveApiCommandRequest } from "@/commands/api/lib/command.ts"; +import { + isDelegatedSubCommand, + normalizePassthroughCommandRawArgs, +} from "@/lib/command-delegation.ts"; +import { API_VALUE_FLAGS } from "@/commands/api/lib/command.ts"; +import { apiGetCommand } from "@/commands/api/get.ts"; +import { apiPostCommand } from "@/commands/api/post.ts"; +import { apiPatchCommand } from "@/commands/api/patch.ts"; +import { apiDeleteCommand } from "@/commands/api/delete.ts"; +import { apiPutCommand } from "@/commands/api/put.ts"; +import { apiSpecCommand } from "@/commands/api/spec.ts"; +import { apiRoutesCommand } from "@/commands/api/routes.ts"; + +export const apiCommand = defineCommand({ + meta: { + name: "api", + commandGroup: "platform", + description: "Management REST API — HTTP invoker and OpenAPI spec.", + examples: [ + "altertable api /whoami", + "altertable api routes", + "altertable api GET /environments/production/connections", + 'altertable api POST /service_accounts -f label="CI Bot"', + ], + }, + args: API_HTTP_BASE_ARGS, + subCommands: { + DELETE: apiDeleteCommand, + GET: apiGetCommand, + PATCH: apiPatchCommand, + POST: apiPostCommand, + PUT: apiPutCommand, + routes: apiRoutesCommand, + spec: apiSpecCommand, + }, + async run({ args, rawArgs, execution, sink }) { + if (isDelegatedApiCommand(rawArgs)) return; + const result = await executeApiHttp(resolveApiCommandRequest(args, rawArgs), execution); + const output = apiHttpResultOutput(result, sink); + if (output) await writeCommandOutput(output, sink); + }, +}); + +const API_SUBCOMMAND_NAMES = new Set(Object.keys(apiCommand.subCommands ?? {})); + +export function normalizeApiInvocatorRawArgs( + rawArgs: readonly string[], + rootArgs: CommandArgs = {}, +): string[] { + return normalizePassthroughCommandRawArgs(rawArgs, { + commandName: "api", + rootArgs, + commandValueFlags: API_VALUE_FLAGS, + isReservedOperand: isApiCommandName, + }); +} + +function isApiCommandName(value: string): boolean { + return API_SUBCOMMAND_NAMES.has(value) || API_SUBCOMMAND_NAMES.has(value.toUpperCase()); +} + +function isDelegatedApiCommand(rawArgs: readonly string[]): boolean { + return isDelegatedSubCommand(rawArgs, isApiCommandName, { valueFlags: API_VALUE_FLAGS }); +} diff --git a/cli/src/lib/api-body.ts b/cli/src/commands/api/lib/body.ts similarity index 93% rename from cli/src/lib/api-body.ts rename to cli/src/commands/api/lib/body.ts index 7b1b3fa..dc3b403 100644 --- a/cli/src/lib/api-body.ts +++ b/cli/src/commands/api/lib/body.ts @@ -158,17 +158,6 @@ function parsedFieldsToRecord(fields: ParsedApiField[]): Record | string[] | string | undefined, -): string | undefined { - const parsedFields = parseRawFields(fields); - if (parsedFields.length === 0) { - return undefined; - } - - return JSON.stringify(parsedFieldsToRecord(parsedFields)); -} - export type ResolveApiBodyOptions = { method: string; body?: string; @@ -244,7 +233,3 @@ export function resolveApiRequestPayload( return { queryFields: [] }; } - -export function resolveApiBody(options: ResolveApiBodyOptions): string | undefined { - return resolveApiRequestPayload(options).body; -} diff --git a/cli/src/commands/api/lib/command.ts b/cli/src/commands/api/lib/command.ts new file mode 100644 index 0000000..dbcdab7 --- /dev/null +++ b/cli/src/commands/api/lib/command.ts @@ -0,0 +1,97 @@ +import { extractFieldArgs, extractRawFieldArgs } from "@/commands/api/lib/body.ts"; +import { executeApiHttp, apiHttpResultOutput, resolveApiHttp } from "@/commands/api/lib/http.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineArgs, defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { valueFlagsFor } from "@/lib/command-delegation.ts"; +import { readArgvFlagValue } from "@/lib/timeout-args.ts"; + +export const HTTP_METHOD_NAMES = ["GET", "POST", "PATCH", "DELETE", "PUT"] as const; + +export const API_HTTP_BASE_ARGS = defineArgs({ + method: { + type: "enum", + alias: "X", + description: "HTTP method override (default GET, or POST when fields/body are provided)", + options: [...HTTP_METHOD_NAMES], + }, + endpoint: { + type: "positional", + description: "Path under /rest/v1, e.g. /whoami", + required: false, + }, + "raw-field": { + type: "string", + alias: "f", + description: "String request parameter key=value (repeatable; gh api -f semantics)", + }, + field: { + type: "string", + alias: "F", + description: "Typed request parameter key=value (true, false, null, integers; repeatable)", + }, + body: { type: "string", description: "JSON body or @file" }, + input: { type: "string", description: "Alias for --body (file path or - for stdin)" }, + env: { type: "string", description: "Replace {environment_id} in the path" }, +}); + +export const API_VALUE_FLAGS = valueFlagsFor(API_HTTP_BASE_ARGS); +export const API_HTTP_ARGS = defineArgs({ + format: { + type: "enum", + description: "Output format: json, table, csv, or markdown", + options: ["json", "table", "csv", "markdown"], + }, + ...API_HTTP_BASE_ARGS, +}); + +export function resolveApiCommandRequest( + args: Record, + rawArgs: string[], + method?: string, +) { + const rawFields = extractRawFieldArgs(rawArgs); + const typedFields = extractFieldArgs(rawArgs); + return resolveApiHttp({ + method: optionalStringArg(args, "method") ?? method, + endpoint: optionalStringArg(args, "endpoint"), + body: optionalStringArg(args, "body"), + input: optionalStringArg(args, "input"), + rawFields: rawFields.length > 0 ? rawFields : undefined, + typedFields: typedFields.length > 0 ? typedFields : undefined, + env: optionalStringArg(args, "env"), + format: optionalStringArg(args, "format") ?? readArgvFlagValue(rawArgs, "--format"), + }); +} + +const METHOD_EXAMPLES: Record<(typeof HTTP_METHOD_NAMES)[number], readonly string[]> = { + GET: ["altertable api /whoami", "altertable api GET /environments/production/connections"], + POST: [ + 'altertable api POST /service_accounts -f label="CI Bot"', + "altertable api POST /environments/production/databases -f name=Analytics", + ], + PATCH: [ + 'altertable api PATCH /environments/production/connections/conn_1 --body \'{"name":"Renamed"}\'', + ], + DELETE: ["altertable api DELETE /service_accounts/sa_abc123"], + PUT: ["altertable api PUT /path --body @payload.json"], +}; + +export function createApiMethodCommand(method: (typeof HTTP_METHOD_NAMES)[number]) { + return defineCommand({ + meta: { + name: method, + description: `${method} request to the management REST API.`, + examples: METHOD_EXAMPLES[method], + }, + args: API_HTTP_ARGS, + async run({ args, rawArgs, execution, sink }) { + const result = await executeApiHttp( + resolveApiCommandRequest(args, rawArgs, method), + execution, + ); + const output = apiHttpResultOutput(result, sink); + if (output) await writeCommandOutput(output, sink); + }, + }); +} diff --git a/cli/tests/openapi-http-conformance.test.ts b/cli/src/commands/api/lib/http-conformance.test.ts similarity index 77% rename from cli/tests/openapi-http-conformance.test.ts rename to cli/src/commands/api/lib/http-conformance.test.ts index d131a0b..ac700d7 100644 --- a/cli/tests/openapi-http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -4,11 +4,9 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; import { setCliContext } from "@/context.ts"; -import { apiHttpOperationPlan } from "@/lib/api-http.ts"; +import { executeApiHttp, resolveApiHttp } from "@/commands/api/lib/http.ts"; import { createExecutionContext } from "@/lib/execution-context.ts"; -import { encodeManagementEndpoint } from "@/lib/management-transport.ts"; -import { runOperationPlan } from "@/lib/operation-effect.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; +import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; const PLACEHOLDER_VALUES: Record = { @@ -53,7 +51,7 @@ describe("openapi HTTP conformance", () => { expect(OPENAPI_OPERATIONS.length).toBe(35); }); - test("every operation is reachable via apiHttpOperationPlan", async () => { + test("every operation is reachable via executeApiHttp", async () => { const mocks = OPENAPI_OPERATIONS.map((operation) => { const endpoint = substituteOpenapiPath(operation.path); const encodedPath = encodeManagementEndpoint(endpoint); @@ -66,13 +64,7 @@ describe("openapi HTTP conformance", () => { writeFileSync(mockFile, JSON.stringify(mocks), "utf8"); const runtime = getCliRuntime(); - const context: OperationContext = { - args: {}, - rawArgs: [], - runtime, - sink: runtime.output, - execution: createExecutionContext(runtime), - }; + const execution = createExecutionContext(runtime); for (const operation of OPENAPI_OPERATIONS) { const endpoint = substituteOpenapiPath(operation.path); @@ -81,16 +73,13 @@ describe("openapi HTTP conformance", () => { ? { fields: ["label=default"] } : {}; - await runOperationPlan( - apiHttpOperationPlan( - { - method: operation.method, - endpoint, - ...bodyFields, - }, - context, - ), - context, + await executeApiHttp( + resolveApiHttp({ + method: operation.method, + endpoint, + ...bodyFields, + }), + execution, ); } }); diff --git a/cli/tests/api-http.test.ts b/cli/src/commands/api/lib/http.test.ts similarity index 85% rename from cli/tests/api-http.test.ts rename to cli/src/commands/api/lib/http.test.ts index 7559de2..155c377 100644 --- a/cli/tests/api-http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -3,17 +3,16 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setCliContext } from "@/context.ts"; -import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/lib/api-body.ts"; +import { readJsonBody, resolveApiRequestPayload } from "@/commands/api/lib/body.ts"; import { - apiHttpOperationPlan, apiHttpResultOutput, + executeApiHttp, normalizeApiEndpoint, + resolveApiHttp, type ApiHttpArgs, -} from "@/lib/api-http.ts"; +} from "@/commands/api/lib/http.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { createExecutionContext } from "@/lib/execution-context.ts"; -import { runOperationPlan } from "@/lib/operation-effect.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { ParseError } from "@/lib/errors.ts"; @@ -56,56 +55,55 @@ afterEach(() => { async function runApiOperation(args: ApiHttpArgs): Promise { const runtime = getCliRuntime(); - const context: OperationContext = { - args: {}, - rawArgs: [], - runtime, - sink: runtime.output, - execution: createExecutionContext(runtime), - }; - const result = await runOperationPlan(apiHttpOperationPlan(args, context), context); - const output = apiHttpResultOutput(result, context.sink); + const result = await executeApiHttp(resolveApiHttp(args), createExecutionContext(runtime)); + const output = apiHttpResultOutput(result, runtime.output); if (output) { - await writeCommandOutput(output, context.sink); + await writeCommandOutput(output, runtime.output); } } describe("api-body", () => { - test("buildBodyFromFields merges key=value pairs into JSON", () => { - const body = buildBodyFromFields(["label=CI Bot", "name=Analytics"]); - expect(JSON.parse(body ?? "")).toEqual({ label: "CI Bot", name: "Analytics" }); + test("builds a JSON body from request fields", () => { + const payload = resolveApiRequestPayload({ + method: "POST", + rawFields: ["label=CI Bot", "name=Analytics"], + }); + expect(JSON.parse(payload.body ?? "")).toEqual({ label: "CI Bot", name: "Analytics" }); }); - test("resolveApiBody returns body when only --body is provided", () => { - const body = resolveApiBody({ + test("keeps an explicit JSON request body", () => { + const payload = resolveApiRequestPayload({ method: "POST", body: '{"label":"raw"}', }); - expect(body).toBe('{"label":"raw"}'); + expect(payload).toEqual({ body: '{"label":"raw"}', queryFields: [] }); }); - test("resolveApiBody rejects mixing body and fields", () => { - const body = resolveApiBody({ + test("keeps fields in the query when an explicit POST body is provided", () => { + const payload = resolveApiRequestPayload({ method: "POST", body: '{"label":"raw"}', rawFields: ["label=flags"], }); - expect(body).toBe('{"label":"raw"}'); + expect(payload).toEqual({ + body: '{"label":"raw"}', + queryFields: [{ key: "label", value: "flags" }], + }); }); - test("resolveApiBody keeps fields out of GET request bodies", () => { - const body = resolveApiBody({ + test("keeps GET fields out of the request body", () => { + const payload = resolveApiRequestPayload({ method: "GET", rawFields: ["label=query"], }); - expect(body).toBeUndefined(); + expect(payload).toEqual({ queryFields: [{ key: "label", value: "query" }] }); }); - test("resolveApiBody rejects explicit body input for methods without request bodies", () => { + test("rejects explicit body input for methods without request bodies", () => { expect(() => - resolveApiBody({ + resolveApiRequestPayload({ method: "GET", body: '{"label":"bad"}', }), @@ -125,28 +123,28 @@ describe("api-body", () => { expect(() => readJsonBody(`@${filePath}`)).toThrow(ParseError); }); - test("resolveApiBody rejects invalid JSON from --input @file payloads", () => { + test("rejects invalid JSON from --input @file payloads", () => { const filePath = join(testHome, "invalid-input.json"); writeFileSync(filePath, "{not-json", "utf8"); expect(() => - resolveApiBody({ + resolveApiRequestPayload({ method: "POST", input: `@${filePath}`, }), ).toThrow(ParseError); }); - test("resolveApiBody returns valid --input @file payloads unchanged", () => { + test("returns valid --input @file payloads unchanged", () => { const filePath = join(testHome, "valid-input.json"); writeFileSync(filePath, '{"name":"from-input-file"}', "utf8"); - const body = resolveApiBody({ + const payload = resolveApiRequestPayload({ method: "POST", input: `@${filePath}`, }); - expect(body).toBe('{"name":"from-input-file"}'); + expect(payload).toEqual({ body: '{"name":"from-input-file"}', queryFields: [] }); }); }); @@ -164,7 +162,7 @@ describe("normalizeApiEndpoint", () => { }); }); -describe("apiHttpOperationPlan", () => { +describe("executeApiHttp", () => { test("GET writes generic tabular output in human mode", async () => { writeFileSync( mockFile, diff --git a/cli/src/lib/api-http.ts b/cli/src/commands/api/lib/http.ts similarity index 80% rename from cli/src/lib/api-http.ts rename to cli/src/commands/api/lib/http.ts index 30c5a01..ca324ce 100644 --- a/cli/src/lib/api-http.ts +++ b/cli/src/commands/api/lib/http.ts @@ -1,11 +1,11 @@ -import { resolveApiRequestPayload, type ParsedApiField } from "@/lib/api-body.ts"; +import { 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/lakehouse-client.ts"; +import { parseManagementOutputFormat } from "@/lib/tabular-result.ts"; import type { OutputSink } from "@/lib/runtime.ts"; -import { defineHttpOperation, type HttpOperationDescriptor } from "@/lib/http-operation.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; export type ApiHttpArgs = { method?: string; @@ -32,22 +32,26 @@ export type ApiHttpResult = { format?: string; }; -export const API_HTTP_OPERATION: HttpOperationDescriptor = - defineHttpOperation({ - id: "api.http", - request: (resolved) => ({ - plane: "management", - method: resolved.method, - endpoint: resolved.endpoint, - body: resolved.body, - contentType: resolved.body ? "application/json" : undefined, - }), - decode: (response, _context, resolved) => ({ - method: resolved.method, - response, - format: resolved.format, - }), - }); +export function buildApiHttpRequest(resolved: ResolvedApiHttp): HttpRequest { + return { + plane: "management", + method: resolved.method, + endpoint: resolved.endpoint, + body: resolved.body, + contentType: resolved.body ? "application/json" : undefined, + }; +} + +export async function executeApiHttp( + resolved: ResolvedApiHttp, + execution: ExecutionContext, +): Promise { + return { + method: resolved.method, + response: await sendHttp(buildApiHttpRequest(resolved), execution), + format: resolved.format, + }; +} export function normalizeApiEndpoint(endpoint: string, env?: string): string { const trimmed = endpoint.trim(); @@ -134,10 +138,6 @@ export function resolveApiHttp(args: ApiHttpArgs): ResolvedApiHttp { }; } -export function apiHttpOperationPlan(args: ApiHttpArgs, context: OperationContext) { - return API_HTTP_OPERATION.plan(resolveApiHttp(args), context); -} - export function apiHttpResultOutput( result: ApiHttpResult, sink: OutputSink, diff --git a/cli/src/features/api/model.ts b/cli/src/commands/api/lib/model.ts similarity index 100% rename from cli/src/features/api/model.ts rename to cli/src/commands/api/lib/model.ts diff --git a/cli/src/features/api/render.ts b/cli/src/commands/api/lib/render.ts similarity index 60% rename from cli/src/features/api/render.ts rename to cli/src/commands/api/lib/render.ts index f4d870c..1b71d8a 100644 --- a/cli/src/features/api/render.ts +++ b/cli/src/commands/api/lib/render.ts @@ -1,5 +1,5 @@ -import type { ApiOperationDetails, ApiRouteRow } from "@/features/api/model.ts"; -import { buildApiOperationDetailsView, buildApiRoutesView } from "@/features/api/views.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; @@ -14,11 +14,7 @@ export function formatApiOperationDetails(operation: ApiOperationDetails): strin }).join("\n"); } -export function formatApiRoutes(rows: readonly ApiRouteRow[]): string { - return renderApiRoutesTableSection(rows); -} - -export function renderApiRoutesTable( +export function formatApiRoutes( rows: readonly ApiRouteRow[], emptyMessage = "No operations found.", options: ApiRoutesRenderOptions = {}, @@ -30,10 +26,3 @@ export function renderApiRoutesTable( }), ); } - -export function renderApiRoutesTableSection( - rows: readonly ApiRouteRow[], - emptyMessage = "No operations found.", -): string { - return renderDocumentText(buildApiRoutesView(rows, { emptyMessage })); -} diff --git a/cli/tests/presentation.test.ts b/cli/src/commands/api/lib/views.test.ts similarity index 92% rename from cli/tests/presentation.test.ts rename to cli/src/commands/api/lib/views.test.ts index b1f3dc4..5443d43 100644 --- a/cli/tests/presentation.test.ts +++ b/cli/src/commands/api/lib/views.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { buildApiOperationDetailsView } from "@/features/api/views.ts"; +import { buildApiOperationDetailsView } from "@/commands/api/lib/views.ts"; import { renderDocumentText } from "@/ui/renderers/terminal.ts"; import { setTerminalColorMode } from "@/ui/terminal/styles.ts"; diff --git a/cli/src/features/api/views.ts b/cli/src/commands/api/lib/views.ts similarity index 95% rename from cli/src/features/api/views.ts rename to cli/src/commands/api/lib/views.ts index db1ea2b..48d8e5b 100644 --- a/cli/src/features/api/views.ts +++ b/cli/src/commands/api/lib/views.ts @@ -1,4 +1,4 @@ -import type { ApiOperationDetails, ApiRouteRow } from "@/features/api/model.ts"; +import type { ApiOperationDetails, ApiRouteRow } from "@/commands/api/lib/model.ts"; import { document, rows, diff --git a/cli/src/commands/api/patch.ts b/cli/src/commands/api/patch.ts new file mode 100644 index 0000000..7902fe0 --- /dev/null +++ b/cli/src/commands/api/patch.ts @@ -0,0 +1,3 @@ +import { createApiMethodCommand } from "@/commands/api/lib/command.ts"; + +export const apiPatchCommand = createApiMethodCommand("PATCH"); diff --git a/cli/src/commands/api/post.ts b/cli/src/commands/api/post.ts new file mode 100644 index 0000000..53ee539 --- /dev/null +++ b/cli/src/commands/api/post.ts @@ -0,0 +1,3 @@ +import { createApiMethodCommand } from "@/commands/api/lib/command.ts"; + +export const apiPostCommand = createApiMethodCommand("POST"); diff --git a/cli/src/commands/api/put.ts b/cli/src/commands/api/put.ts new file mode 100644 index 0000000..3465e4b --- /dev/null +++ b/cli/src/commands/api/put.ts @@ -0,0 +1,3 @@ +import { createApiMethodCommand } from "@/commands/api/lib/command.ts"; + +export const apiPutCommand = createApiMethodCommand("PUT"); diff --git a/cli/src/commands/api/routes.ts b/cli/src/commands/api/routes.ts new file mode 100644 index 0000000..405c628 --- /dev/null +++ b/cli/src/commands/api/routes.ts @@ -0,0 +1,43 @@ +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/commands/api/lib/model.ts"; +import { formatApiOperationDetails, formatApiRoutes } from "@/commands/api/lib/render.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; + +export const apiRoutesCommand = defineCommand({ + meta: { + name: "routes", + description: "List management API paths and methods from the bundled OpenAPI spec.", + examples: ["altertable api routes", "altertable api routes createDatabase"], + }, + args: { + operation: { + type: "positional", + description: "Optional operationId to inspect, e.g. createDatabase", + required: false, + }, + }, + async run({ args, sink }) { + await runApiRoutesCommand(sink, optionalStringArg(args, "operation")); + }, +}); + +async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { + const operation = operationId ? apiOperationDetails(operationId) : undefined; + await writeCommandOutput( + operation + ? { + kind: "normalized", + data: operation, + humanText: formatApiOperationDetails(operation), + } + : { + kind: "normalized", + data: apiOperationsJson(), + humanText: formatApiRoutes(apiRouteRows()), + pageHumanText: true, + }, + sink, + ); +} diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts new file mode 100644 index 0000000..f89f19f --- /dev/null +++ b/cli/src/commands/api/spec.ts @@ -0,0 +1,43 @@ +import { + getOpenapiSpecJson, + getOpenapiSpecYaml, + resolveOpenapiSpecFormat, +} from "@/lib/openapi-spec.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; + +export const apiSpecCommand = defineCommand({ + meta: { + name: "spec", + description: + "Print the bundled management OpenAPI specification (YAML in a terminal; JSON when piped or with --json).", + examples: ["altertable api spec", "altertable api spec --json"], + }, + args: { + format: { + type: "enum", + options: ["json", "yaml"], + description: + "Output format (default: yaml in a terminal, json when piped or with global --json)", + }, + }, + async run({ args, sink }) { + await runApiSpecCommand(sink, { format: optionalStringArg(args, "format") }); + }, +}); + +async function runApiSpecCommand(sink: OutputSink, options?: { format?: string }): Promise { + const format = resolveOpenapiSpecFormat( + sink.json, + process.stdout.isTTY === true, + options?.format, + ); + await writeCommandOutput( + format === "json" + ? { kind: "raw_api", body: getOpenapiSpecJson() } + : { kind: "human", text: getOpenapiSpecYaml() }, + sink, + ); +} diff --git a/cli/src/commands/append/index.test.ts b/cli/src/commands/append/index.test.ts new file mode 100644 index 0000000..6227426 --- /dev/null +++ b/cli/src/commands/append/index.test.ts @@ -0,0 +1,52 @@ +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"; + +let workspace: LakehouseTestWorkspace; + +beforeEach(() => { + 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}' }]); + + await runCommandWithTestRuntime([ + "append", + "--catalog", + "memory", + "--schema", + "main", + "--table", + "users", + "--data", + '{"id":1}', + "--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 }); + }); + + test("fetches append task status without run arguments", async () => { + const appendId = "11111111-2222-3333-4444-555555555555"; + workspace.writeMocks([ + { + urlPattern: `/tasks/${appendId}`, + method: "GET", + body: `{"task_id":"${appendId}","status":"completed"}`, + }, + ]); + + await runCommandWithTestRuntime(["append", "status", 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 new file mode 100644 index 0000000..be77f47 --- /dev/null +++ b/cli/src/commands/append/index.ts @@ -0,0 +1,22 @@ +import { defineCommand } from "@/lib/command.ts"; +import { appendRunCommand } from "@/commands/append/run.ts"; +import { appendStatusCommand } from "@/commands/append/status.ts"; +import { appendGroupArgs } from "@/commands/append/lib/args.ts"; + +export const appendCommand = defineCommand({ + meta: { + name: "append", + commandGroup: "ingest", + description: "Append JSON rows to a table.", + examples: [ + "altertable append --catalog db --schema public --table events --data '[{\"id\":1}]'", + "altertable append status ", + ], + }, + default: "run", + args: appendGroupArgs, + subCommands: { + run: appendRunCommand, + status: appendStatusCommand, + }, +}); diff --git a/cli/src/commands/append/lib/args.ts b/cli/src/commands/append/lib/args.ts new file mode 100644 index 0000000..96e1b5c --- /dev/null +++ b/cli/src/commands/append/lib/args.ts @@ -0,0 +1,19 @@ +import { defineArgs } from "@/lib/command.ts"; +import { lakehouseTableArgs } from "@/lib/lakehouse/args.ts"; + +export const appendRunArgs = defineArgs({ + ...lakehouseTableArgs, + data: { type: "string", description: "JSON object, array, or @file", required: true }, + sync: { + type: "boolean", + description: "Wait for the append operation to finish before returning", + }, +}); + +export const appendGroupArgs = defineArgs({ + ...appendRunArgs, + catalog: { ...appendRunArgs.catalog, required: false }, + schema: { ...appendRunArgs.schema, required: false }, + table: { ...appendRunArgs.table, required: false }, + data: { ...appendRunArgs.data, required: false }, +}); diff --git a/cli/src/commands/append/lib/data.test.ts b/cli/src/commands/append/lib/data.test.ts new file mode 100644 index 0000000..eba5dc6 --- /dev/null +++ b/cli/src/commands/append/lib/data.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { parseAppendJsonContent } from "@/commands/append/lib/data.ts"; +import { CliError } from "@/lib/errors.ts"; + +describe("parseAppendJsonContent", () => { + test("normalizes inline JSON objects", () => { + expect(parseAppendJsonContent('{ "id": 1 }')).toBe('{"id":1}'); + }); + + 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", + ); + }); +}); diff --git a/cli/src/commands/append/lib/data.ts b/cli/src/commands/append/lib/data.ts new file mode 100644 index 0000000..87ebfa6 --- /dev/null +++ b/cli/src/commands/append/lib/data.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { CliError } from "@/lib/errors.ts"; + +export function parseAppendJsonContent(dataArg: string): string { + let jsonContent = dataArg; + if (jsonContent.startsWith("@")) { + const filePath = jsonContent.slice(1); + try { + 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."); + } + + try { + return JSON.stringify(JSON.parse(jsonContent)); + } catch { + throw new CliError("Data must be valid JSON."); + } +} diff --git a/cli/src/commands/append/run.ts b/cli/src/commands/append/run.ts new file mode 100644 index 0000000..aaabfb5 --- /dev/null +++ b/cli/src/commands/append/run.ts @@ -0,0 +1,35 @@ +import { appendRunArgs } from "@/commands/append/lib/args.ts"; +import { parseAppendJsonContent } from "@/commands/append/lib/data.ts"; +import { buildLakehouseAppendRequest } from "@/lib/lakehouse-transport.ts"; +import { booleanArg, stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { sendHttp } from "@/lib/http-request.ts"; +import { startProgress } from "@/lib/progress.ts"; + +export const appendRunCommand = defineCommand({ + meta: { + name: "run", + description: "Append JSON rows to a table.", + }, + args: appendRunArgs, + async run({ args, execution, sink }) { + const sync = booleanArg(args, "sync"); + const request = buildLakehouseAppendRequest({ + catalog: stringArg(args, "catalog"), + schema: stringArg(args, "schema"), + table: stringArg(args, "table"), + jsonContent: parseAppendJsonContent(stringArg(args, "data")), + options: { sync }, + }); + 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); + } catch (error) { + progress?.fail(); + throw error; + } + }, +}); diff --git a/cli/src/commands/append/status.ts b/cli/src/commands/append/status.ts new file mode 100644 index 0000000..cf5a65d --- /dev/null +++ b/cli/src/commands/append/status.ts @@ -0,0 +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"; + +export const appendStatusCommand = defineCommand({ + meta: { + name: "status", + description: "Fetch status for an append operation.", + }, + args: { + "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"))}`, + retry: true, + }, + execution, + ); + await writeCommandOutput({ kind: "raw_api", body: response }, sink); + }, +}); diff --git a/cli/src/commands/catalogs.ts b/cli/src/commands/catalogs.ts deleted file mode 100644 index e7b4eea..0000000 --- a/cli/src/commands/catalogs.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { CliError } from "@/lib/errors.ts"; -import { requireManagementEnv } from "@/lib/auth.ts"; -import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { defineGroupCommand, defineHttpCommand } from "@/lib/operation-command-builders.ts"; -import { localPlan } from "@/lib/operation-effect.ts"; -import { formatCatalogsSummary, formatCatalogsTable } from "@/features/management/render.ts"; -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; -import { - fetchManagementCatalogRows, - managementCatalogCreateOperation, - type ManagementCatalogCreateInput, - type ManagementCatalogCreateResult, -} from "@/lib/management-operations.ts"; - -const catalogsCreateCommand = defineHttpCommand({ - id: "catalogs.create", - plane: "management", - operation: managementCatalogCreateOperation, - mutates: true, - output: "raw-api", - meta: { - name: "create", - description: "Create a catalog. Only the 'altertable' engine is supported.", - examples: ["altertable catalogs create --engine altertable --name Analytics"], - }, - args: { - engine: { - type: "enum", - description: "Catalog engine (only 'altertable' is supported)", - required: true, - options: ["altertable"], - }, - name: { type: "string", description: "Catalog name", required: true }, - }, - parse({ args, execution }): ManagementCatalogCreateInput { - if (args.engine !== "altertable") { - throw new CliError( - `Only the 'altertable' engine is supported (got '${String(args.engine)}').`, - ); - } - - const env = requireManagementEnv(execution.profile); - const name = String(args.name); - return { - env, - name, - body: buildCreateCatalogBody({ name }), - }; - }, - present(result: ManagementCatalogCreateResult) { - const data = parseApiJson(result.response) as { - database?: { slug?: string; name?: string; engine?: string }; - connection?: { slug?: string; name?: string; engine?: string }; - }; - const catalog = data.database ?? data.connection; - const name = catalog?.name ?? result.fallbackName; - const slug = catalog?.slug ?? ""; - const engine = catalog?.engine ?? "altertable"; - return { - kind: "raw_api", - body: result.response, - humanFormatter: () => - `Created catalog "${name}" (slug: ${slug}, engine: ${engine}, environment: ${result.env}).`, - }; - }, -}); - -const catalogsListCommand = defineOperationCommand({ - id: "catalogs.list", - capabilities: ["management-http"], - catalog: { effects: ["local", "http"], planes: ["management"], output: "normalized" }, - meta: { - name: "list", - description: "List catalogs in the current environment.", - examples: ["altertable catalogs list", "altertable --json catalogs list"], - }, - run(_input, context) { - const env = requireManagementEnv(context.execution.profile); - return localPlan(() => fetchManagementCatalogRows(env, context)); - }, - present(rows) { - const summary = formatCatalogsSummary(rows); - return { - kind: "normalized", - data: { - catalogs: rows, - ...(summary !== null ? { summary } : {}), - }, - humanText: formatCatalogsTable(rows), - metadataLines: - summary !== null ? ["", renderDisplayText([span(summary, "subtle")])] : undefined, - }; - }, -}); - -export const catalogsCommand = defineGroupCommand({ - meta: { - name: "catalogs", - commandGroup: "platform", - description: "Manage catalogs (databases and connections) in the current environment.", - examples: [ - "altertable catalogs list", - "altertable catalogs create --engine altertable --name Analytics", - ], - }, - subCommands: { - create: catalogsCreateCommand, - list: catalogsListCommand, - }, -}); diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts new file mode 100644 index 0000000..d557fdc --- /dev/null +++ b/cli/src/commands/catalogs/create.ts @@ -0,0 +1,48 @@ +import { CliError } from "@/lib/errors.ts"; +import { requireManagementEnv } from "@/lib/auth.ts"; +import { buildCatalogCreateRequest } from "@/commands/catalogs/lib/requests.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { sendHttp } from "@/lib/http-request.ts"; + +export const catalogsCreateCommand = defineCommand({ + meta: { + name: "create", + description: "Create a catalog. Only the 'altertable' engine is supported.", + examples: ["altertable catalogs create --engine altertable --name Analytics"], + }, + args: { + engine: { + type: "enum", + description: "Catalog engine (only 'altertable' is supported)", + required: true, + options: ["altertable"], + }, + name: { type: "string", description: "Catalog name", required: true }, + }, + async run({ args, execution, sink }) { + if (args.engine !== "altertable") { + throw new CliError( + `Only the 'altertable' engine is supported (got '${String(args.engine)}').`, + ); + } + const env = requireManagementEnv(execution.profile); + const fallbackName = String(args.name); + const response = await sendHttp(buildCatalogCreateRequest(env, fallbackName), execution); + await writeCommandOutput( + { + kind: "raw_api", + body: response, + humanFormatter(data) { + const parsed = data as { + database?: { slug?: string; name?: string; engine?: string }; + connection?: { slug?: string; name?: string; engine?: string }; + }; + const catalog = parsed.database ?? parsed.connection; + return `Created catalog "${catalog?.name ?? fallbackName}" (slug: ${catalog?.slug ?? ""}, engine: ${catalog?.engine ?? "altertable"}, environment: ${env}).`; + }, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/catalogs/index.ts b/cli/src/commands/catalogs/index.ts new file mode 100644 index 0000000..9fa678f --- /dev/null +++ b/cli/src/commands/catalogs/index.ts @@ -0,0 +1,19 @@ +import { defineCommand } from "@/lib/command.ts"; +import { catalogsCreateCommand } from "@/commands/catalogs/create.ts"; +import { catalogsListCommand } from "@/commands/catalogs/list.ts"; + +export const catalogsCommand = defineCommand({ + meta: { + name: "catalogs", + commandGroup: "platform", + description: "Manage catalogs (databases and connections) in the current environment.", + examples: [ + "altertable catalogs list", + "altertable catalogs create --engine altertable --name Analytics", + ], + }, + subCommands: { + create: catalogsCreateCommand, + list: catalogsListCommand, + }, +}); diff --git a/cli/src/commands/catalogs/lib/requests.ts b/cli/src/commands/catalogs/lib/requests.ts new file mode 100644 index 0000000..dcd8447 --- /dev/null +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -0,0 +1,11 @@ +import type { HttpRequest } from "@/lib/http-request.ts"; + +export function buildCatalogCreateRequest(env: string, name: string): HttpRequest { + return { + plane: "management", + method: "POST", + endpoint: `/environments/${env}/databases`, + body: JSON.stringify({ name, engine: "altertable" }), + contentType: "application/json", + }; +} diff --git a/cli/src/commands/catalogs/list.ts b/cli/src/commands/catalogs/list.ts new file mode 100644 index 0000000..b72764b --- /dev/null +++ b/cli/src/commands/catalogs/list.ts @@ -0,0 +1,32 @@ +import { requireManagementEnv } from "@/lib/auth.ts"; +import { fetchManagementCatalogRows } from "@/lib/management/catalogs.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { formatCatalogsSummary, formatCatalogsTable } from "@/lib/management/render.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; + +export const catalogsListCommand = defineCommand({ + meta: { + name: "list", + description: "List catalogs in the current environment.", + examples: ["altertable catalogs list", "altertable --json catalogs list"], + }, + async run({ execution, sink }) { + const rows = await fetchManagementCatalogRows( + requireManagementEnv(execution.profile), + execution, + ); + const summary = formatCatalogsSummary(rows); + await writeCommandOutput( + { + kind: "normalized", + data: { catalogs: rows, ...(summary !== null ? { summary } : {}) }, + humanText: formatCatalogsTable(rows), + metadataLines: + summary !== null ? ["", renderDisplayText([span(summary, "subtle")])] : undefined, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/completion.ts b/cli/src/commands/completion.ts deleted file mode 100644 index 6f30d2e..0000000 --- a/cli/src/commands/completion.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import type { CommandDef } from "citty"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { - defineGroupCommand, - defineLocalCommand, - defineOutputCommand, -} from "@/lib/operation-command-builders.ts"; -import { localPlan, noopPlan } from "@/lib/operation-effect.ts"; -import { CliError } from "@/lib/errors.ts"; -import { - defaultConfigurePrompts, - type ConfigurePrompts, -} from "@/lib/profile-configure-interactive.ts"; -import { document, rows, section, span, text, type DisplayText } from "@/ui/document.ts"; -import { renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { buildCompletionSpec } from "@/lib/completion-spec.ts"; -import { readEnv } from "@/lib/env.ts"; -import { - formatBashCompletion, - formatFishCompletion, - formatZshCompletion, -} from "@/lib/completion-format.ts"; - -type GetRootCommand = () => CommandDef; -type SupportedShell = "bash" | "zsh" | "fish"; -type InstallTarget = { - completionPath: string; - rcPath?: string; -}; -type InstallOptions = { - updateRc?: boolean; -}; -type InstallResult = { - shell: SupportedShell; - completionPath: string; - rcPath?: string; - rcUpdated: boolean; - startupAction: "updated" | "skipped" | "not-needed"; -}; -type CompletionInstallInput = { - explicitShell?: SupportedShell; - updateRc: boolean; -}; -type CompletionRootInput = - | { - kind: "delegated"; - } - | { - kind: "help"; - } - | { - kind: "install"; - shell?: SupportedShell; - updateRc: boolean; - }; -type CompletionRootOutput = - | { - kind: "help"; - } - | { - kind: "install"; - result: InstallResult; - } - | undefined; -type CompletionCommandOptions = { - prompts?: ConfigurePrompts; -}; -type CompletionPromptChoice = "install" | "install-bash" | "install-zsh" | "install-fish" | "help"; - -const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] 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"; -const COMPLETION_GUIDANCE = { - install: "altertable completion install", - installShell: "altertable completion install zsh", - manual: "altertable completion generate zsh", - docs: COMPLETION_DOCS_URL, -}; - -function isSupportedShell(shell: string): shell is SupportedShell { - return SUPPORTED_SHELLS.includes(shell as SupportedShell); -} - -function formatSupportedShells(): string { - return SUPPORTED_SHELLS.join(", "); -} - -function getShellName(shellPath: string): string { - return basename(shellPath).toLowerCase(); -} - -function resolveShell(shell: unknown): SupportedShell { - if (typeof shell === "string" && shell.length > 0) { - const explicitShell = getShellName(shell); - if (isSupportedShell(explicitShell)) { - return explicitShell; - } - throw new CliError(`Unsupported shell: ${shell}. Use ${formatSupportedShells()}.`); - } - - 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()}.`, - ); -} - -function envHome(): string { - return readEnv("HOME") ?? homedir(); -} - -function xdgDataHome(): string { - return readEnv("XDG_DATA_HOME") ?? join(envHome(), ".local", "share"); -} - -function xdgConfigHome(): string { - return readEnv("XDG_CONFIG_HOME") ?? join(envHome(), ".config"); -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function installTarget(shell: SupportedShell): InstallTarget { - if (shell === "bash") { - return { - completionPath: join(xdgDataHome(), "bash-completion", "completions", "altertable"), - rcPath: join(envHome(), ".bashrc"), - }; - } - - if (shell === "zsh") { - return { - completionPath: join(xdgDataHome(), "zsh", "site-functions", "_altertable"), - rcPath: join(envHome(), ".zshrc"), - }; - } - - return { - completionPath: join(xdgConfigHome(), "fish", "completions", "altertable.fish"), - }; -} - -function managedBlock(body: string): string { - return `${START_MARKER}\n${body.trimEnd()}\n${END_MARKER}\n`; -} - -function rcBlock(shell: SupportedShell, target: InstallTarget): string | undefined { - if (!target.rcPath) { - return undefined; - } - - if (shell === "bash") { - return managedBlock( - `if [ -f ${shellQuote(target.completionPath)} ]; then\n . ${shellQuote(target.completionPath)}\nfi`, - ); - } - - return managedBlock( - `fpath=(${shellQuote(dirname(target.completionPath))} $fpath)\nautoload -Uz compinit\ncompinit`, - ); -} - -async function readOptionalFile(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return ""; - } - throw error; - } -} - -function upsertManagedBlock(existing: string, block: string): string { - const start = existing.indexOf(START_MARKER); - const end = existing.indexOf(END_MARKER); - 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"; - } - - const prefix = existing.trimEnd(); - if (!prefix) { - return block; - } - return `${prefix}\n\n${block}`; -} - -function formatCompletionScript(shell: SupportedShell, rootCommand: CommandDef): string { - const spec = buildCompletionSpec(rootCommand); - 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 = {}, -): Promise { - const target = installTarget(shell); - await mkdir(dirname(target.completionPath), { recursive: true }); - await writeFile(target.completionPath, `${script.trimEnd()}\n`, "utf8"); - - const shouldUpdateRc = options.updateRc !== false; - const block = shouldUpdateRc ? rcBlock(shell, target) : undefined; - 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"); - return { - shell, - completionPath: target.completionPath, - rcPath: target.rcPath, - rcUpdated: true, - startupAction: "updated", - }; - } - - return { - shell, - completionPath: target.completionPath, - rcPath: target.rcPath, - rcUpdated: false, - startupAction: target.rcPath ? "skipped" : "not-needed", - }; -} - -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"; - 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."; - - return renderDocumentText( - document( - section( - 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)], - }, - ]), - ), - ), - { labelWidth: "Startup:".length }, - ); -} - -function formatCompletionHelpMessage(): string { - return renderDocumentText( - document( - section( - 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: "Docs:", - value: [span("Shell completion", "accent", COMPLETION_GUIDANCE.docs)], - }, - ]), - ), - ), - { labelWidth: "Install shell:".length }, - ); -} - -async function promptCompletionInput(prompts: ConfigurePrompts): Promise { - const selected = (await prompts.readSelect( - "Shell completion", - [ - { value: "install", label: "Install for current shell" }, - { value: "install-bash", label: "Install for bash" }, - { value: "install-zsh", label: "Install for zsh" }, - { value: "install-fish", label: "Install for fish" }, - { value: "help", label: "Show manual install commands" }, - ], - "install", - { leadingNewline: false }, - )) as CompletionPromptChoice; - - switch (selected) { - case "install": - return { kind: "install", updateRc: true }; - case "install-bash": - return { kind: "install", shell: "bash", updateRc: true }; - case "install-zsh": - return { kind: "install", shell: "zsh", updateRc: true }; - case "install-fish": - return { kind: "install", shell: "fish", updateRc: true }; - case "help": - return { kind: "help" }; - } -} - -function createShellCompletionCommand( - shell: SupportedShell, - getRootCommand: GetRootCommand, - id = `completion.${shell}`, -): CommandDef { - return defineOutputCommand({ - id, - capabilities: ["raw-stdout"], - output: "raw-api", - meta: { - name: shell, - description: `Generate ${shell} completion script.`, - }, - render() { - return { kind: "raw_api", body: formatCompletionScript(shell, getRootCommand()) }; - }, - }); -} - -function createInstallShellCommand( - shell: SupportedShell, - getRootCommand: GetRootCommand, -): CommandDef { - return defineLocalCommand({ - id: `completion.install.${shell}`, - mutates: true, - output: "human", - meta: { - name: shell, - description: `Install ${shell} completion.`, - }, - args: { - "no-rc": { - type: "boolean", - description: "Write the completion file without updating shell startup files.", - }, - }, - parse({ args, rawArgs }) { - return { - updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc"), - }; - }, - local(input) { - const script = formatCompletionScript(shell, getRootCommand()); - return installCompletion(shell, script, input); - }, - present(result, { sink }) { - if (sink.json) { - sink.writeJson(result); - return; - } - sink.writeHuman(formatInstallMessage(result)); - }, - }); -} - -function createGenerateCommand(getRootCommand: GetRootCommand): CommandDef { - return defineGroupCommand({ - meta: { - name: "generate", - description: "Generate a shell completion script.", - examples: [ - "altertable completion generate bash", - "altertable completion generate zsh > ~/.local/share/zsh/site-functions/_altertable", - ], - }, - subCommands: { - bash: createShellCompletionCommand("bash", getRootCommand, "completion.generate.bash"), - fish: createShellCompletionCommand("fish", getRootCommand, "completion.generate.fish"), - zsh: createShellCompletionCommand("zsh", getRootCommand, "completion.generate.zsh"), - }, - }); -} - -export function createCompletionCommand( - getRootCommand: GetRootCommand, - options: CompletionCommandOptions = {}, -): CommandDef { - const prompts = options.prompts ?? defaultConfigurePrompts; - const installCommand = defineOperationCommand({ - id: "completion.install", - capabilities: ["local-file-write"], - catalog: { effects: ["local", "value"], mutates: true, output: "human" }, - meta: { - name: "install", - description: "Install shell completion for the current shell.", - examples: [ - "altertable completion install", - "altertable completion install fish", - "altertable completion install zsh --no-rc", - ], - }, - subCommands: { - bash: createInstallShellCommand("bash", getRootCommand), - fish: createInstallShellCommand("fish", getRootCommand), - zsh: createInstallShellCommand("zsh", getRootCommand), - }, - args: { - "no-rc": { - type: "boolean", - description: "Write the completion file without updating shell startup files.", - }, - }, - parse({ args, rawArgs }) { - const explicitShell = rawArgs.slice(rawArgs.indexOf("install") + 1).find(isSupportedShell); - return { - explicitShell, - updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc"), - }; - }, - run(input) { - if (input.explicitShell) { - return noopPlan(); - } - - return localPlan(() => { - const shell = resolveShell(undefined); - const script = formatCompletionScript(shell, getRootCommand()); - return installCompletion(shell, script, { - updateRc: input.updateRc, - }); - }); - }, - present(result, { sink }) { - if (result === undefined) { - return; - } - if (sink.json) { - sink.writeJson(result); - return; - } - sink.writeHuman(formatInstallMessage(result)); - }, - }); - - return defineOperationCommand({ - id: "completion", - capabilities: ["raw-stdout", "local-file-write"], - catalog: { effects: ["local", "output", "value"], mutates: true, output: "human" }, - meta: { - name: "completion", - commandGroup: "platform", - 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", - ], - }, - subCommands: { - bash: createShellCompletionCommand("bash", getRootCommand), - fish: createShellCompletionCommand("fish", getRootCommand), - generate: createGenerateCommand(getRootCommand), - install: installCommand, - zsh: createShellCompletionCommand("zsh", getRootCommand), - }, - async parse({ rawArgs, runtime, sink }): Promise { - if (rawArgs.some((arg) => arg === "install" || arg === "generate" || isSupportedShell(arg))) { - return { kind: "delegated" }; - } - - if (process.stdin.isTTY === true && !runtime.context.agent && !sink.json) { - return await promptCompletionInput(prompts); - } - - return { kind: "help" }; - }, - run(action) { - if (action.kind === "delegated") { - return noopPlan(); - } - - if (action.kind === "help") { - return localPlan(() => ({ kind: "help" })); - } - - return localPlan(async () => { - const shell = action.shell ?? resolveShell(undefined); - const script = formatCompletionScript(shell, getRootCommand()); - const result = await installCompletion(shell, script, { - updateRc: action.updateRc, - }); - return { kind: "install", result }; - }); - }, - present(result, { sink }) { - if (result === undefined) { - return; - } - - if (result.kind === "install") { - if (sink.json) { - sink.writeJson(result.result); - return; - } - sink.writeHuman(formatInstallMessage(result.result)); - return; - } - - if (sink.json) { - sink.writeJson(COMPLETION_GUIDANCE); - return; - } - - sink.writeHuman(formatCompletionHelpMessage()); - }, - }); -} diff --git a/cli/src/commands/completion/bash.ts b/cli/src/commands/completion/bash.ts new file mode 100644 index 0000000..68d5d3b --- /dev/null +++ b/cli/src/commands/completion/bash.ts @@ -0,0 +1,14 @@ +import type { Command } from "@/lib/command.ts"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createBashCompletionCommand(getRootCommand: GetRootCommand): Command { + return createShellCompletionCommand("bash", getRootCommand); +} + +export function createBashInstallCommand(getRootCommand: GetRootCommand): Command { + return createInstallShellCommand("bash", getRootCommand); +} diff --git a/cli/src/commands/completion/fish.ts b/cli/src/commands/completion/fish.ts new file mode 100644 index 0000000..e9cba58 --- /dev/null +++ b/cli/src/commands/completion/fish.ts @@ -0,0 +1,14 @@ +import type { Command } from "@/lib/command.ts"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createFishCompletionCommand(getRootCommand: GetRootCommand): Command { + return createShellCompletionCommand("fish", getRootCommand); +} + +export function createFishInstallCommand(getRootCommand: GetRootCommand): Command { + return createInstallShellCommand("fish", getRootCommand); +} diff --git a/cli/src/commands/completion/generate.ts b/cli/src/commands/completion/generate.ts new file mode 100644 index 0000000..d05f690 --- /dev/null +++ b/cli/src/commands/completion/generate.ts @@ -0,0 +1,23 @@ +import { defineCommand, type Command } from "@/lib/command.ts"; +import { createBashCompletionCommand } from "@/commands/completion/bash.ts"; +import { createFishCompletionCommand } from "@/commands/completion/fish.ts"; +import { createZshCompletionCommand } from "@/commands/completion/zsh.ts"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; + +export function createGenerateCommand(getRootCommand: GetRootCommand): Command { + return defineCommand({ + meta: { + name: "generate", + description: "Generate a shell completion script.", + examples: [ + "altertable completion generate bash", + "altertable completion generate zsh > ~/.local/share/zsh/site-functions/_altertable", + ], + }, + subCommands: { + bash: createBashCompletionCommand(getRootCommand), + fish: createFishCompletionCommand(getRootCommand), + zsh: createZshCompletionCommand(getRootCommand), + }, + }); +} diff --git a/cli/tests/completion.test.ts b/cli/src/commands/completion/index.test.ts similarity index 80% rename from cli/tests/completion.test.ts rename to cli/src/commands/completion/index.test.ts index ce544ea..7b568c7 100644 --- a/cli/tests/completion.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -2,19 +2,20 @@ import { afterEach, 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 type { CommandDef } from "citty"; +import { defineCommand, defineRootCommand, runCommandTree, type Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; -import { createCompletionCommand } from "@/commands/completion.ts"; -import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; -import { buildCompletionSpec, flattenTopLevelNames } from "@/lib/completion-spec.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; +import { createCompletionCommand } from "@/commands/completion/index.ts"; +import type { Prompts } from "@/ui/prompts.ts"; +import { buildCompletionSpec } from "@/commands/completion/lib/spec.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", ); -const minimalRootCommand: CommandDef = { +const minimalRootCommand = defineCommand({ meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON responses" }, @@ -42,7 +43,7 @@ const minimalRootCommand: CommandDef = { }, completion: { meta: { name: "completion" } }, }, -}; +}); let testHome: string | undefined; @@ -58,7 +59,7 @@ function setTestHome(): string { return testHome; } -function createMockPrompts(selection: string): ConfigurePrompts { +function createMockPrompts(selection: string): Prompts { return { writePrompt() {}, readLine: async () => "", @@ -68,10 +69,7 @@ function createMockPrompts(selection: string): ConfigurePrompts { }; } -async function captureCompletionOutput( - run: () => void | Promise, - 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); @@ -82,58 +80,36 @@ async function captureCompletionOutput( return output.join(""); } +async function runCompletionCommand( + completionCommand: Command, + rawArgs: string[], + json = false, +): Promise { + const rootCommand = defineRootCommand({ + meta: { name: "altertable" }, + subCommands: { completion: completionCommand }, + }); + return await captureCompletionOutput(() => runCommandTree(rootCommand, { rawArgs }), json); +} + async function runCompletion( - getRootCommand: () => CommandDef, + getRootCommand: () => Command, shell?: string, - prompts?: ConfigurePrompts, + prompts?: Prompts, ): Promise { const completionCommand = createCompletionCommand(getRootCommand, { prompts }); - const supportedShells = new Set(["bash", "zsh", "fish"]); - const command = - shell === undefined || !supportedShells.has(shell) - ? completionCommand - : (completionCommand.subCommands as Record | undefined)?.[shell]; - if (!command?.run) { - throw new Error(`missing completion command for ${shell ?? "detected shell"}`); - } - const rawArgs = shell ? ["completion", shell] : ["completion"]; - return await captureCompletionOutput(() => - command.run?.({ args: command === completionCommand ? { shell } : {}, rawArgs } as never), - ); + return await runCompletionCommand(completionCommand, rawArgs); } async function runCompletionGenerate(shell: string): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); - const generateCommand = (completionCommand.subCommands as Record | undefined) - ?.generate; - const command = (generateCommand?.subCommands as Record | undefined)?.[shell]; - if (!command?.run) { - throw new Error("missing completion generate command"); - } - - return await captureCompletionOutput(() => - command.run?.({ - args: {}, - rawArgs: ["completion", "generate", shell], - } as never), - ); + return await runCompletionCommand(completionCommand, ["completion", "generate", shell]); } async function runCompletionParent(rawArgs: string[], json = false): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); - if (!completionCommand.run) { - throw new Error("missing completion command"); - } - - return await captureCompletionOutput( - () => - completionCommand.run?.({ - args: {}, - rawArgs, - } as never), - json, - ); + return await runCompletionCommand(completionCommand, rawArgs, json); } async function runCompletionParentJson(rawArgs: string[]): Promise { @@ -142,26 +118,11 @@ async function runCompletionParentJson(rawArgs: string[]): Promise { async function runCompletionInstall(shell?: string, noRc = false): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); - const installCommand = (completionCommand.subCommands as Record | undefined) - ?.install; - const command = - shell === undefined - ? installCommand - : (installCommand?.subCommands as Record | undefined)?.[shell]; - if (!command?.run) { - throw new Error("missing completion install command"); - } - const rawArgs = shell ? ["completion", "install", shell] : ["completion", "install"]; if (noRc) { rawArgs.push("--no-rc"); } - return await captureCompletionOutput(() => - command.run?.({ - args: shell === undefined ? { shell, "no-rc": noRc } : { "no-rc": noRc }, - rawArgs, - } as never), - ); + return await runCompletionCommand(completionCommand, rawArgs); } async function runInteractiveCompletion(selection: string, shellPath?: string): Promise { @@ -269,11 +230,6 @@ describe("completion command", () => { expect(output).toContain("altertable completion generate fish"); }); - test("parent completion does not print help after delegated subcommands", async () => { - const output = await runCompletionParent(["completion", "generate", "fish"]); - expect(output).toBe(""); - }); - test("bare completion shows guidance without dumping a script", async () => { const output = await runCompletion(() => minimalRootCommand); const visibleOutput = visibleTerminalText(output); @@ -352,7 +308,7 @@ describe("completion command", () => { test("integration root command top-level count matches registry", async () => { const spec = buildCompletionSpec(buildMainCommand()); const output = await runCompletion(buildMainCommand, "bash"); - const topLevelCount = flattenTopLevelNames(spec).length; + const topLevelCount = spec.subcommands.length; expect(topLevelCount).toBe(14); expect(output).toContain("completion"); expect(output).toContain("upgrade"); diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts new file mode 100644 index 0000000..19ddd6a --- /dev/null +++ b/cli/src/commands/completion/index.ts @@ -0,0 +1,73 @@ +import { defineCommand, type Command } from "@/lib/command.ts"; +import { defaultPrompts } from "@/ui/prompts.ts"; +import { createBashCompletionCommand } from "@/commands/completion/bash.ts"; +import { createFishCompletionCommand } from "@/commands/completion/fish.ts"; +import { createGenerateCommand } from "@/commands/completion/generate.ts"; +import { createInstallCommand } from "@/commands/completion/install.ts"; +import { createZshCompletionCommand } from "@/commands/completion/zsh.ts"; +import { + COMPLETION_GUIDANCE, + formatCompletionHelpMessage, + formatCompletionScript, + formatInstallMessage, + installCompletion, + isSupportedShell, + promptCompletionInput, + resolveShell, + type CompletionCommandOptions, + type CompletionRootInput, + type GetRootCommand, +} from "@/commands/completion/lib/completion.ts"; + +export function createCompletionCommand( + getRootCommand: GetRootCommand, + options: CompletionCommandOptions = {}, +): Command { + const prompts = options.prompts ?? defaultPrompts; + return defineCommand({ + meta: { + name: "completion", + commandGroup: "platform", + 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", + ], + }, + subCommands: { + bash: createBashCompletionCommand(getRootCommand), + fish: createFishCompletionCommand(getRootCommand), + generate: createGenerateCommand(getRootCommand), + install: createInstallCommand(getRootCommand), + zsh: createZshCompletionCommand(getRootCommand), + }, + async run({ rawArgs, runtime, sink }) { + if (rawArgs.some((arg) => arg === "install" || arg === "generate" || isSupportedShell(arg))) { + return; + } + + let action: CompletionRootInput; + if (process.stdin.isTTY === true && !runtime.context.agent && !sink.json) { + action = await promptCompletionInput(prompts); + } else { + action = { kind: "help" }; + } + + if (action.kind === "help") { + if (sink.json) sink.writeJson(COMPLETION_GUIDANCE); + else sink.writeHuman(formatCompletionHelpMessage()); + return; + } + + const shell = action.shell ?? resolveShell(undefined); + const result = await installCompletion( + shell, + formatCompletionScript(shell, getRootCommand()), + { updateRc: action.updateRc }, + ); + if (sink.json) sink.writeJson(result); + else sink.writeHuman(formatInstallMessage(result)); + }, + }); +} diff --git a/cli/src/commands/completion/install.ts b/cli/src/commands/completion/install.ts new file mode 100644 index 0000000..280c775 --- /dev/null +++ b/cli/src/commands/completion/install.ts @@ -0,0 +1,49 @@ +import { defineCommand, type Command } from "@/lib/command.ts"; +import { createBashInstallCommand } from "@/commands/completion/bash.ts"; +import { createFishInstallCommand } from "@/commands/completion/fish.ts"; +import { createZshInstallCommand } from "@/commands/completion/zsh.ts"; +import { + formatCompletionScript, + formatInstallMessage, + installCompletion, + isSupportedShell, + resolveShell, + type GetRootCommand, +} from "@/commands/completion/lib/completion.ts"; + +export function createInstallCommand(getRootCommand: GetRootCommand): Command { + return defineCommand({ + meta: { + name: "install", + description: "Install shell completion for the current shell.", + examples: [ + "altertable completion install", + "altertable completion install fish", + "altertable completion install zsh --no-rc", + ], + }, + subCommands: { + bash: createBashInstallCommand(getRootCommand), + fish: createFishInstallCommand(getRootCommand), + zsh: createZshInstallCommand(getRootCommand), + }, + args: { + "no-rc": { + type: "boolean", + description: "Write the completion file without updating shell startup files.", + }, + }, + async run({ args, rawArgs, sink }) { + const explicitShell = rawArgs.slice(rawArgs.indexOf("install") + 1).find(isSupportedShell); + if (explicitShell) return; + const shell = resolveShell(undefined); + const result = await installCompletion( + shell, + formatCompletionScript(shell, getRootCommand()), + { updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc") }, + ); + 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 new file mode 100644 index 0000000..13852d5 --- /dev/null +++ b/cli/src/commands/completion/lib/completion.ts @@ -0,0 +1,253 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +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 type { Prompts } from "@/ui/prompts.ts"; +import { document, rows, section, span, text, type DisplayText } from "@/ui/document.ts"; +import { renderDocumentText } from "@/ui/renderers/terminal.ts"; +import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; +import { + formatBashCompletion, + formatFishCompletion, + formatZshCompletion, +} from "@/commands/completion/lib/format.ts"; + +export type GetRootCommand = () => Command; +export type SupportedShell = "bash" | "zsh" | "fish"; +export type CompletionRootInput = + | { kind: "help" } + | { kind: "install"; shell?: SupportedShell; updateRc: boolean }; +export type CompletionCommandOptions = { prompts?: Prompts }; +export type InstallResult = { + shell: SupportedShell; + completionPath: string; + rcPath?: string; + rcUpdated: boolean; + startupAction: "updated" | "skipped" | "not-needed"; +}; + +type InstallTarget = { completionPath: string; rcPath?: string }; +type InstallOptions = { updateRc?: boolean }; +type CompletionPromptChoice = "install" | "install-bash" | "install-zsh" | "install-fish" | "help"; + +const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] 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", + docs: COMPLETION_DOCS_URL, +}; + +export function isSupportedShell(shell: string): shell is SupportedShell { + return SUPPORTED_SHELLS.includes(shell as SupportedShell); +} + +function formatSupportedShells(): string { + return SUPPORTED_SHELLS.join(", "); +} + +function getShellName(shellPath: string): string { + return basename(shellPath).toLowerCase(); +} + +export function resolveShell(shell: unknown): SupportedShell { + if (typeof shell === "string" && shell.length > 0) { + const explicitShell = getShellName(shell); + if (isSupportedShell(explicitShell)) return explicitShell; + throw new CliError(`Unsupported shell: ${shell}. Use ${formatSupportedShells()}.`); + } + + 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()}.`, + ); +} + +function envHome(): string { + return readEnv("HOME") ?? homedir(); +} + +function xdgDataHome(): string { + return readEnv("XDG_DATA_HOME") ?? join(envHome(), ".local", "share"); +} + +function xdgConfigHome(): string { + return readEnv("XDG_CONFIG_HOME") ?? join(envHome(), ".config"); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function installTarget(shell: SupportedShell): InstallTarget { + if (shell === "bash") { + return { + completionPath: join(xdgDataHome(), "bash-completion", "completions", "altertable"), + rcPath: join(envHome(), ".bashrc"), + }; + } + if (shell === "zsh") { + return { + completionPath: join(xdgDataHome(), "zsh", "site-functions", "_altertable"), + rcPath: join(envHome(), ".zshrc"), + }; + } + return { + completionPath: join(xdgConfigHome(), "fish", "completions", "altertable.fish"), + }; +} + +function managedBlock(body: string): string { + return `${START_MARKER}\n${body.trimEnd()}\n${END_MARKER}\n`; +} + +function rcBlock(shell: SupportedShell, target: InstallTarget): string | undefined { + if (!target.rcPath) return undefined; + if (shell === "bash") { + return managedBlock( + `if [ -f ${shellQuote(target.completionPath)} ]; then\n . ${shellQuote(target.completionPath)}\nfi`, + ); + } + return managedBlock( + `fpath=(${shellQuote(dirname(target.completionPath))} $fpath)\nautoload -Uz compinit\ncompinit`, + ); +} + +async function readOptionalFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return ""; + } + throw error; + } +} + +function upsertManagedBlock(existing: string, block: string): string { + const start = existing.indexOf(START_MARKER); + const end = existing.indexOf(END_MARKER); + 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"; + } + const prefix = existing.trimEnd(); + return prefix ? `${prefix}\n\n${block}` : block; +} + +export function formatCompletionScript(shell: SupportedShell, rootCommand: Command): string { + const spec = buildCompletionSpec(rootCommand); + 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 = {}, +): Promise { + const target = installTarget(shell); + await mkdir(dirname(target.completionPath), { recursive: true }); + 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"); + return { + shell, + completionPath: target.completionPath, + rcPath: target.rcPath, + rcUpdated: true, + startupAction: "updated", + }; + } + + return { + shell, + completionPath: target.completionPath, + rcPath: target.rcPath, + rcUpdated: false, + 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"; + 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."; + + return renderDocumentText( + document( + section( + 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)] }, + ]), + ), + ), + { labelWidth: "Startup:".length }, + ); +} + +export function formatCompletionHelpMessage(): string { + return renderDocumentText( + document( + section( + 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: "Docs:", + value: [span("Shell completion", "accent", COMPLETION_GUIDANCE.docs)], + }, + ]), + ), + ), + { labelWidth: "Install shell:".length }, + ); +} + +export async function promptCompletionInput(prompts: Prompts): Promise { + const selected = (await prompts.readSelect( + "Shell completion", + [ + { value: "install", label: "Install for current shell" }, + { value: "install-bash", label: "Install for bash" }, + { value: "install-zsh", label: "Install for zsh" }, + { value: "install-fish", label: "Install for fish" }, + { value: "help", label: "Show manual install commands" }, + ], + "install", + { leadingNewline: false }, + )) as CompletionPromptChoice; + + if (selected === "install") return { kind: "install", updateRc: true }; + if (selected === "install-bash") return { kind: "install", shell: "bash", updateRc: true }; + if (selected === "install-zsh") return { kind: "install", shell: "zsh", updateRc: true }; + if (selected === "install-fish") return { kind: "install", shell: "fish", updateRc: true }; + return { kind: "help" }; +} diff --git a/cli/src/lib/completion-format.ts b/cli/src/commands/completion/lib/format.ts similarity index 98% rename from cli/src/lib/completion-format.ts rename to cli/src/commands/completion/lib/format.ts index 991b717..3de529d 100644 --- a/cli/src/lib/completion-format.ts +++ b/cli/src/commands/completion/lib/format.ts @@ -1,5 +1,9 @@ -import type { CompletionContext, CompletionFlag, CompletionNode } from "@/lib/completion-spec.ts"; -import { collectCompletionContexts } from "@/lib/completion-spec.ts"; +import type { + CompletionContext, + CompletionFlag, + CompletionNode, +} from "@/commands/completion/lib/spec.ts"; +import { collectCompletionContexts } from "@/commands/completion/lib/spec.ts"; const FISH_BINARY_NAME = "altertable"; diff --git a/cli/src/commands/completion/lib/shell-command.ts b/cli/src/commands/completion/lib/shell-command.ts new file mode 100644 index 0000000..26e8ae4 --- /dev/null +++ b/cli/src/commands/completion/lib/shell-command.ts @@ -0,0 +1,48 @@ +import { defineCommand, type Command } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { + formatCompletionScript, + formatInstallMessage, + installCompletion, + type GetRootCommand, + type SupportedShell, +} from "@/commands/completion/lib/completion.ts"; + +export function createShellCompletionCommand( + shell: SupportedShell, + getRootCommand: GetRootCommand, +): Command { + return defineCommand({ + meta: { name: shell, description: `Generate ${shell} completion script.` }, + async run({ sink }) { + await writeCommandOutput( + { kind: "raw_api", body: formatCompletionScript(shell, getRootCommand()) }, + sink, + ); + }, + }); +} + +export function createInstallShellCommand( + shell: SupportedShell, + getRootCommand: GetRootCommand, +): Command { + return defineCommand({ + meta: { name: shell, description: `Install ${shell} completion.` }, + args: { + "no-rc": { + type: "boolean", + description: "Write the completion file without updating shell startup files.", + }, + }, + async run({ args, rawArgs, sink }) { + const result = await installCompletion( + shell, + formatCompletionScript(shell, getRootCommand()), + { updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc") }, + ); + if (sink.json) sink.writeJson(result); + else sink.writeHuman(formatInstallMessage(result)); + }, + }); +} diff --git a/cli/tests/completion-spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts similarity index 93% rename from cli/tests/completion-spec.test.ts rename to cli/src/commands/completion/lib/spec.test.ts index f434223..4db1e4e 100644 --- a/cli/tests/completion-spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test"; -import type { CommandDef } from "citty"; +import { defineCommand } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; -import { - buildCompletionSpec, - collectCompletionContexts, - flattenTopLevelNames, -} from "@/lib/completion-spec.ts"; +import { buildCompletionSpec, collectCompletionContexts } from "@/commands/completion/lib/spec.ts"; import { formatBashCompletion, formatBashFlagWordList, @@ -14,7 +10,7 @@ import { formatZshCompletion, groupCompletionContextsByTopLevel, mergeCompletionFlags, -} from "@/lib/completion-format.ts"; +} from "@/commands/completion/lib/format.ts"; function findNode(spec: ReturnType, name: string) { return spec.subcommands.find((node) => node.name === name); @@ -22,7 +18,7 @@ function findNode(spec: ReturnType, name: string) { describe("buildCompletionSpec", () => { test("walks a minimal fake tree", () => { - const root: CommandDef = { + const root = defineCommand({ meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON" }, @@ -41,7 +37,7 @@ describe("buildCompletionSpec", () => { }, }, }, - }; + }); const spec = buildCompletionSpec(root); expect(spec.flags.map((flag) => flag.name)).toEqual(["format", "json"]); @@ -53,27 +49,27 @@ describe("buildCompletionSpec", () => { }); test("skips nested commands without meta.name", () => { - const root: CommandDef = { + const root = defineCommand({ subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { description: "no name" } }, }, - }; + }); const spec = buildCompletionSpec(root); - expect(flattenTopLevelNames(spec)).toEqual(["visible"]); + expect(spec.subcommands.map((command) => command.name)).toEqual(["visible"]); }); test("skips commands marked hidden", () => { - const root: CommandDef = { + const root = defineCommand({ subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { name: "hidden", hidden: true } }, }, - }; + }); const spec = buildCompletionSpec(root); - expect(flattenTopLevelNames(spec)).toEqual(["visible"]); + expect(spec.subcommands.map((command) => command.name)).toEqual(["visible"]); }); test("real root command includes expected top-level and nested commands", () => { @@ -137,7 +133,7 @@ describe("buildCompletionSpec", () => { test("sorts subcommands alphabetically", () => { const spec = buildCompletionSpec(buildMainCommand()); - const names = flattenTopLevelNames(spec); + const names = spec.subcommands.map((command) => command.name); expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right))); }); }); diff --git a/cli/src/lib/completion-spec.ts b/cli/src/commands/completion/lib/spec.ts similarity index 83% rename from cli/src/lib/completion-spec.ts rename to cli/src/commands/completion/lib/spec.ts index c5568fe..b178bf3 100644 --- a/cli/src/lib/completion-spec.ts +++ b/cli/src/commands/completion/lib/spec.ts @@ -1,7 +1,7 @@ -import type { CommandDef } from "citty"; +import type { Command } from "@/lib/command.ts"; /** - * Static completion spec derived from the Citty command tree. + * Static completion spec derived from the command tree. * * Subcommand nesting is limited to two levels below the root command * (e.g. `altertable api connections`). Flag extraction applies to each visited @@ -40,7 +40,7 @@ type ArgDef = { options?: string[]; }; -function extractFlags(command: CommandDef): CompletionFlag[] { +function extractFlags(command: Command): CompletionFlag[] { const flags: CompletionFlag[] = []; const args = command.args ?? {}; @@ -69,7 +69,7 @@ function resolveAliases(value: unknown): string[] { return aliases.map(String); } -function resolveSubcommandNames(command: CommandDef): string[] { +function resolveSubcommandNames(command: Command): string[] { const meta = command.meta; if (meta && typeof meta === "object" && "hidden" in meta && meta.hidden) { return []; @@ -81,7 +81,7 @@ function resolveSubcommandNames(command: CommandDef): string[] { return []; } -function resolveMetaDescription(command: CommandDef): string | undefined { +function resolveMetaDescription(command: Command): string | undefined { const meta = command.meta; if (meta && typeof meta === "object" && "description" in meta && meta.description) { return String(meta.description); @@ -89,7 +89,7 @@ function resolveMetaDescription(command: CommandDef): string | undefined { return undefined; } -function resolveRootName(root: CommandDef): string { +function resolveRootName(root: Command): string { const meta = root.meta; if (meta && typeof meta === "object" && "name" in meta && meta.name) { return String(meta.name); @@ -97,19 +97,19 @@ function resolveRootName(root: CommandDef): string { return "altertable"; } -function walkSubcommands(subcommands: CommandDef["subCommands"], depth: number): CompletionNode[] { +function walkSubcommands(subcommands: Command["subCommands"], depth: number): CompletionNode[] { if (!subcommands) { return []; } return Object.values(subcommands) .flatMap((subcommand) => { - const command = subcommand as CommandDef; + const command = subcommand as Command; return resolveSubcommandNames(command).map((name) => walkCommand(name, command, depth)); }) .sort((left, right) => left.name.localeCompare(right.name)); } -function walkCommand(name: string, command: CommandDef, depth: number): CompletionNode { +function walkCommand(name: string, command: Command, depth: number): CompletionNode { const node: CompletionNode = { name, description: resolveMetaDescription(command), @@ -125,7 +125,7 @@ function walkCommand(name: string, command: CommandDef, depth: number): Completi return node; } -export function buildCompletionSpec(root: CommandDef): CompletionNode { +export function buildCompletionSpec(root: Command): CompletionNode { const spec: CompletionNode = { name: resolveRootName(root), description: resolveMetaDescription(root), @@ -138,7 +138,6 @@ export function buildCompletionSpec(root: CommandDef): CompletionNode { } spec.subcommands = walkSubcommands(root.subCommands, 1); - return spec; } @@ -163,7 +162,3 @@ export function collectCompletionContexts(root: CompletionNode): CompletionConte return contexts; } - -export function flattenTopLevelNames(spec: CompletionNode): string[] { - return spec.subcommands.map((node) => node.name); -} diff --git a/cli/src/commands/completion/zsh.ts b/cli/src/commands/completion/zsh.ts new file mode 100644 index 0000000..87fb1c4 --- /dev/null +++ b/cli/src/commands/completion/zsh.ts @@ -0,0 +1,14 @@ +import type { Command } from "@/lib/command.ts"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createZshCompletionCommand(getRootCommand: GetRootCommand): Command { + return createShellCompletionCommand("zsh", getRootCommand); +} + +export function createZshInstallCommand(getRootCommand: GetRootCommand): Command { + return createInstallShellCommand("zsh", getRootCommand); +} diff --git a/cli/src/commands/duckdb/index.test.ts b/cli/src/commands/duckdb/index.test.ts new file mode 100644 index 0000000..e2ccd29 --- /dev/null +++ b/cli/src/commands/duckdb/index.test.ts @@ -0,0 +1,85 @@ +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"; + +let workspace: LakehouseTestWorkspace; +let originalPath: string | undefined; + +beforeEach(() => { + workspace = createLakehouseTestWorkspace("duckdb-command"); + delete process.env.ALTERTABLE_API_BASE; + delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; + delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; + process.env.ALTERTABLE_CONFIG_HOME = workspace.home; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + configSet("api_key_env", "production", "default"); + secretSet("api-key", "atm_test", "default"); + secretSet("lakehouse/basic-token", Buffer.from("alice:s3cret").toString("base64"), "default"); + + const executable = workspace.writeFile( + "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 ?? ""}`; +}); + +afterEach(() => { + process.env.PATH = originalPath; + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; + workspace.cleanup(); +}); + +function writeDuckdbMocks(): void { + workspace.writeMocks([ + { urlPattern: "/query", method: "POST", body: '{"ok":true}' }, + { + urlPattern: "/environments/production/databases", + method: "GET", + body: JSON.stringify({ + databases: [ + { name: "Sales", slug: "sales", catalog: "sales" }, + { name: "Ops", slug: "ops", catalog: 'ops-"quoted' }, + ], + }), + }, + { + urlPattern: "/environments/production/connections", + method: "GET", + body: '{"connections":[]}', + }, + ]); +} + +describe("duckdb command", () => { + test("launches DuckDB with verified credentials and every catalog", async () => { + writeDuckdbMocks(); + + await runCommandWithTestRuntime(["duckdb"]); + + const sql = readFileSync(join(workspace.home, "duckdb.sql"), "utf8"); + 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("rejects a requested catalog that is not available", () => { + writeDuckdbMocks(); + + return expect(runCommandWithTestRuntime(["duckdb", "missing"])).rejects.toThrow( + 'Catalog "missing" not found', + ); + }); +}); diff --git a/cli/src/commands/duckdb.ts b/cli/src/commands/duckdb/index.ts similarity index 66% rename from cli/src/commands/duckdb.ts rename to cli/src/commands/duckdb/index.ts index a29f0a1..af739e0 100644 --- a/cli/src/commands/duckdb.ts +++ b/cli/src/commands/duckdb/index.ts @@ -6,11 +6,30 @@ import { } from "@/lib/auth.ts"; import { configureVerify } from "@/lib/profile-status.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; -import { optionalStringArg } from "@/lib/operation-codec.ts"; -import { fetchManagementCatalogRows } from "@/lib/management-operations.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { fetchManagementCatalogRows } from "@/lib/management/catalogs.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { readEnv } from "@/lib/env.ts"; + +export const duckdbCommand = defineCommand({ + meta: { + name: "duckdb", + commandGroup: "query", + description: "Open a DuckDB shell attached to lakehouse catalogs (all of them by default).", + examples: ["altertable duckdb", "altertable duckdb my_catalog"], + }, + args: { + catalog: { + type: "positional", + description: "Catalog to attach (defaults to all catalogs)", + required: false, + }, + }, + run: ({ args, execution }) => + runDuckdb({ catalog: optionalStringArg(args, "catalog") }, execution), +}); const LOGIN_PROMPT = "Log in with 'altertable login' to use altertable duckdb."; @@ -29,10 +48,7 @@ function attachStatement(credentials: LakehouseCredentials, catalog: string): st AS ${quoteIdentifier(catalog)} (TYPE ALTERTABLE);`; } -export function buildDuckdbAttachSnippet( - credentials: LakehouseCredentials, - catalogs: string[], -): string { +function buildDuckdbAttachSnippet(credentials: LakehouseCredentials, catalogs: string[]): string { return [ "INSTALL altertable FROM community;", "LOAD altertable;", @@ -41,10 +57,7 @@ export function buildDuckdbAttachSnippet( } // Attach the requested catalog (verified against the environment) or every available one. -export function selectCatalogsToAttach( - rows: CatalogRow[], - requested: string | undefined, -): string[] { +function selectCatalogsToAttach(rows: CatalogRow[], requested: string | undefined): string[] { const available = [ ...new Set(rows.map((row) => row.catalog).filter((catalog) => catalog.length > 0)), ]; @@ -64,55 +77,30 @@ export function selectCatalogsToAttach( type DuckdbInput = { catalog: string | undefined }; -async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise { - if (!Bun.which("duckdb")) { +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.", ); } - const verify = await configureVerify(["lakehouse"]); + const verify = await configureVerify(["lakehouse"], execution); if (!verify.verified.lakehouse) { throw new ConfigurationError(LOGIN_PROMPT); } - const credentials = getLoginLakehouseCredentials(context.execution.profile); + const credentials = getLoginLakehouseCredentials(execution.profile); if (!credentials) { throw new ConfigurationError(LOGIN_PROMPT); } - const rows = await fetchManagementCatalogRows( - requireManagementEnv(context.execution.profile), - context, - ); + const rows = await fetchManagementCatalogRows(requireManagementEnv(execution.profile), execution); const catalogs = selectCatalogsToAttach(rows, input.catalog); const snippet = buildDuckdbAttachSnippet(credentials, 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}`); } } - -export const duckdbCommand = defineLocalCommand({ - id: "duckdb", - output: "none", - capabilities: ["management-http"], - meta: { - name: "duckdb", - commandGroup: "query", - description: "Open a DuckDB shell attached to lakehouse catalogs (all of them by default).", - examples: ["altertable duckdb", "altertable duckdb my_catalog"], - }, - args: { - catalog: { - type: "positional", - description: "Catalog to attach (defaults to all catalogs)", - required: false, - }, - }, - parse({ args }) { - return { catalog: optionalStringArg(args, "catalog") }; - }, - local: (input, context) => runDuckdb(input, context), -}); diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts new file mode 100644 index 0000000..50644d1 --- /dev/null +++ b/cli/src/commands/index.ts @@ -0,0 +1,39 @@ +import type { Command, CommandArgs } 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 { catalogsCommand } from "@/commands/catalogs/index.ts"; +import { duckdbCommand } from "@/commands/duckdb/index.ts"; +import { appendCommand } from "@/commands/append/index.ts"; +import { queryCommand, normalizeQueryInvocatorRawArgs } 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, normalizeApiInvocatorRawArgs } from "@/commands/api/index.ts"; +import { createCompletionCommand } from "@/commands/completion/index.ts"; +import { updateCommand } from "@/commands/update/index.ts"; + +export function buildTopLevelCommands(getMainCommand: () => Command): Record { + return { + login: loginCommand, + logout: logoutCommand, + profile: profileCommand, + catalogs: catalogsCommand, + query: queryCommand, + schema: schemaCommand, + duckdb: duckdbCommand, + append: appendCommand, + upload: uploadCommand, + upsert: upsertCommand, + api: apiCommand, + update: updateCommand, + completion: createCompletionCommand(getMainCommand), + }; +} + +export function normalizeCommandRawArgs( + rawArgs: readonly string[], + rootArgs: CommandArgs, +): string[] { + return normalizeQueryInvocatorRawArgs(normalizeApiInvocatorRawArgs(rawArgs, rootArgs), rootArgs); +} diff --git a/cli/src/commands/lakehouse-args.ts b/cli/src/commands/lakehouse-args.ts deleted file mode 100644 index dc5e3ac..0000000 --- a/cli/src/commands/lakehouse-args.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { readFileSync } from "node:fs"; -import { isAgentMode } from "@/context.ts"; -import { asCliArgString } from "@/lib/cli-args.ts"; -import { CliError } from "@/lib/errors.ts"; -import { defaultDisplayOptions } from "@/lib/query-format.ts"; -import { resolvePagerOptions, type PagerMode, type PagerOptions } from "@/lib/pager.ts"; -import { parseTimeoutSeconds } from "@/lib/timeout-args.ts"; -import type { QueryDisplayOptions } from "@/lib/query-format.ts"; -import { parseQueryResultFormat, type QueryResultFormat } from "@/lib/lakehouse-client.ts"; -import { isQueryLayout, type QueryLayout } from "@/ui/layouts/query.ts"; - -const MIN_MAX_COLUMN_WIDTH = 8; -export const QUERY_RESULT_FORMAT_OPTIONS = ["human", "json", "csv", "markdown"] as const; -export 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; - -export type QueryOutputOptions = { - format: QueryResultFormat; - displayOptions: QueryDisplayOptions; - pagerOptions: PagerOptions; -}; - -function hasArgvFlag(rawArgs: readonly string[], flag: string): boolean { - return rawArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); -} - -export function validateAgentQueryFlags(rawArgs: readonly string[]): void { - if (!isAgentMode()) { - return; - } - - for (const flag of AGENT_INCOMPATIBLE_QUERY_FLAGS) { - if (hasArgvFlag(rawArgs, flag)) { - throw new CliError( - `${flag} cannot be used with --agent. Use --format json for machine-readable query output.`, - ); - } - } -} - -function parseQueryResultFormatFromArgs(args: Record): QueryResultFormat { - if (isAgentMode()) { - return "json"; - } - - const formatRaw = args.format !== undefined ? asCliArgString(args.format) : "human"; - return parseQueryResultFormat(formatRaw); -} - -export function parseQueryResultFormatArg( - args: Record, - rawArgs: readonly string[], -): QueryResultFormat { - validateAgentQueryFlags(rawArgs); - return parseQueryResultFormatFromArgs(args); -} - -export function parseQueryLayout(args: Record): QueryLayout { - const defaults = defaultDisplayOptions(); - if (args.layout === undefined) { - return defaults.layout; - } - - const layoutRaw = asCliArgString(args.layout); - if (!isQueryLayout(layoutRaw)) { - throw new CliError("--layout must be auto, table, or line."); - } - return layoutRaw; -} - -export function parseQueryDisplayOptions( - args: Record, - rawArgs: readonly string[], -): QueryDisplayOptions { - validateAgentQueryFlags(rawArgs); - return parseQueryDisplayOptionsFromArgs(args); -} - -function parseQueryDisplayOptionsFromArgs(args: Record): QueryDisplayOptions { - const defaults = defaultDisplayOptions(); - let maxColumnWidth = defaults.maxColumnWidth; - if (args["max-width"] !== undefined) { - const maxColWidthRaw = asCliArgString(args["max-width"]); - const parsedWidth = Number.parseInt(maxColWidthRaw, 10); - if (Number.isNaN(parsedWidth) || parsedWidth < MIN_MAX_COLUMN_WIDTH) { - throw new CliError(`--max-width must be an integer >= ${MIN_MAX_COLUMN_WIDTH}.`); - } - maxColumnWidth = parsedWidth; - } - - const columnsRaw = args.columns; - const columnsText = typeof columnsRaw === "string" ? columnsRaw.trim() : ""; - const columns = - columnsText.length > 0 - ? columnsText - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0) - : undefined; - - return { - ...defaults, - layout: parseQueryLayout(args), - maxColumnWidth, - columns, - }; -} - -function parsePagerOptionsFromArgs(args: Record): PagerOptions { - if (isAgentMode()) { - return { mode: "never" }; - } - - if (args.pager === undefined) { - return resolvePagerOptions(); - } - const pagerRaw = asCliArgString(args.pager); - if (!PAGER_MODES.has(pagerRaw as PagerMode)) { - throw new CliError("--pager must be auto, always, or never."); - } - return resolvePagerOptions(pagerRaw as PagerMode); -} - -export function parsePagerOptions( - args: Record, - rawArgs: readonly string[] = [], -): PagerOptions { - validateAgentQueryFlags(rawArgs); - return parsePagerOptionsFromArgs(args); -} - -export function parseQueryOutputOptions( - args: Record, - rawArgs: readonly string[], -): QueryOutputOptions { - validateAgentQueryFlags(rawArgs); - return { - format: parseQueryResultFormatFromArgs(args), - displayOptions: parseQueryDisplayOptionsFromArgs(args), - pagerOptions: parsePagerOptionsFromArgs(args), - }; -} - -export function parseRequestReadTimeoutMs(args: Record): number | undefined { - if (args["read-timeout"] === undefined) { - return undefined; - } - return parseTimeoutSeconds(args["read-timeout"], "--read-timeout"); -} - -export function parseAppendJsonContent(dataArg: string): string { - let jsonContent = dataArg; - if (jsonContent.startsWith("@")) { - const filePath = jsonContent.slice(1); - try { - jsonContent = readFileSync(filePath, "utf8"); - } catch { - throw new CliError(`File not found: ${filePath}`); - } - } - - const trimmed = jsonContent.replace(/\s/g, ""); - const firstChar = trimmed[0]; - if (firstChar !== "{" && firstChar !== "[") { - throw new CliError("Data must be a JSON object or array."); - } - - try { - return JSON.stringify(JSON.parse(jsonContent)); - } catch { - throw new CliError("Data must be valid JSON."); - } -} - -export function parseLakehouseFileContentType(format: string | undefined): string | undefined { - if (!format) { - return undefined; - } - - switch (format.toLowerCase()) { - case "csv": - return "text/csv"; - case "json": - return "application/json"; - case "parquet": - return "application/vnd.apache.parquet"; - default: - throw new CliError("--format must be one of: csv, json, parquet."); - } -} diff --git a/cli/src/commands/lakehouse/append.ts b/cli/src/commands/lakehouse/append.ts deleted file mode 100644 index b7f46d8..0000000 --- a/cli/src/commands/lakehouse/append.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ArgsDef } from "citty"; -import { booleanArg, stringArg } from "@/lib/operation-codec.ts"; -import { progressPlan } from "@/lib/operation-effect.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { defineGroupCommand, defineHttpCommand } from "@/lib/operation-command-builders.ts"; -import { parseAppendJsonContent } from "@/commands/lakehouse-args.ts"; -import { - lakehouseAppendOperation, - lakehouseAppendTaskOperation, -} from "@/lib/lakehouse-operations.ts"; - -const appendRunArgs = { - catalog: { type: "string", description: "Catalog name", required: true }, - schema: { type: "string", description: "Schema name", required: true }, - table: { type: "string", description: "Table name", required: true }, - data: { type: "string", description: "JSON object, array, or @file", required: true }, - sync: { - type: "boolean", - description: "Wait for the append operation to finish before returning", - }, -} satisfies ArgsDef; - -const appendGroupArgs = { - ...appendRunArgs, - catalog: { ...appendRunArgs.catalog, required: false }, - schema: { ...appendRunArgs.schema, required: false }, - table: { ...appendRunArgs.table, required: false }, - data: { ...appendRunArgs.data, required: false }, -} satisfies ArgsDef; - -const appendRowsCommand = defineOperationCommand({ - id: "lakehouse.append.run", - capabilities: ["lakehouse-http", "progress"], - catalog: { - effects: ["http", "progress"], - planes: ["lakehouse"], - mutates: true, - output: "raw-api", - }, - meta: { - name: "run", - description: "Append JSON rows to a table.", - }, - args: appendRunArgs, - parse({ args }) { - const catalog = stringArg(args, "catalog"); - const schema = stringArg(args, "schema"); - const table = stringArg(args, "table"); - const payload = parseAppendJsonContent(stringArg(args, "data")); - return { catalog, schema, table, payload, sync: booleanArg(args, "sync") }; - }, - run(input, context) { - const effect = lakehouseAppendOperation.effect(input, context); - return input.sync - ? progressPlan("Waiting for append to complete…", effect) - : lakehouseAppendOperation.plan(input, context); - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); - -const appendStatusCommand = defineHttpCommand({ - id: "lakehouse.append.status", - plane: "lakehouse", - operation: lakehouseAppendTaskOperation, - output: "raw-api", - meta: { - name: "status", - description: "Fetch status for an append operation.", - }, - args: { - "append-id": { - type: "positional", - description: "Append id returned by append", - required: true, - }, - }, - parse({ args }) { - return stringArg(args, "append-id"); - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); - -export const appendCommand = defineGroupCommand({ - meta: { - name: "append", - commandGroup: "ingest", - description: "Append JSON rows to a table.", - examples: [ - "altertable append --catalog db --schema public --table events --data '[{\"id\":1}]'", - "altertable append status ", - ], - }, - default: "run", - args: appendGroupArgs, - subCommands: { - run: appendRowsCommand, - status: appendStatusCommand, - }, -}); diff --git a/cli/src/commands/lakehouse/query.ts b/cli/src/commands/lakehouse/query.ts deleted file mode 100644 index ecdc03c..0000000 --- a/cli/src/commands/lakehouse/query.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { ArgsDef, CommandDef } from "citty"; -import { optionalStringArg, stringArg } from "@/lib/operation-codec.ts"; -import { CliError } from "@/lib/errors.ts"; -import { defineOperationCommand, type OperationContext } from "@/lib/operation-command.ts"; -import { defineGroupCommand, defineHttpCommand } from "@/lib/operation-command-builders.ts"; -import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; -import { - PAGER_MODE_OPTIONS, - parseQueryOutputOptions, - parseRequestReadTimeoutMs, - QUERY_RESULT_FORMAT_OPTIONS, -} from "@/commands/lakehouse-args.ts"; -import { QUERY_LAYOUT_OPTIONS } from "@/ui/layouts/query.ts"; -import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; -import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; -import { - type LakehouseQueryOperationInput, - lakehouseQueryCancelOperation, - lakehouseQueryOperation, - lakehouseQueryShowOperation, - lakehouseQueryStreamOperation, -} from "@/lib/lakehouse-operations.ts"; - -export const queryRunArgs = { - statement: { type: "positional", description: "SQL statement to run", required: false }, - format: { - type: "enum", - description: "Output format: human, json, csv, or markdown", - default: "human", - options: [...QUERY_RESULT_FORMAT_OPTIONS], - }, - layout: { - type: "enum", - description: "Human layout: auto, table, or line", - options: [...QUERY_LAYOUT_OPTIONS], - }, - "query-id": { type: "string", description: "Optional stable query id" }, - "session-id": { type: "string", description: "Optional session id" }, - columns: { - type: "string", - description: "Comma-separated columns to show", - }, - "max-width": { - type: "string", - description: "Maximum display width for table columns", - default: "32", - }, - pager: { - type: "enum", - description: "Pager mode for human output: auto, always, or never", - default: "auto", - options: [...PAGER_MODE_OPTIONS], - }, - "read-timeout": { - type: "string", - description: "Read timeout in seconds for this request (overrides global --read-timeout)", - }, -} satisfies ArgsDef; - -const queryGroupArgs = { - ...queryRunArgs, -} satisfies ArgsDef; - -const QUERY_VALUE_FLAGS = valueFlagsFor(queryRunArgs); - -export function normalizeQueryInvocatorRawArgs( - rawArgs: readonly string[], - rootArgs: ArgsDef = {}, -): string[] { - return normalizeDefaultSubCommandRawArgs(rawArgs, { - commandName: "query", - subCommand: "run", - rootArgs, - commandValueFlags: QUERY_VALUE_FLAGS, - isReservedOperand: (value) => QUERY_SUBCOMMAND_NAMES.has(value), - }); -} - -export type QueryRunInput = LakehouseQueryOperationInput & - ReturnType; - -export function planQueryRun(input: QueryRunInput, context: OperationContext) { - if (input.format === "json" || context.sink.json) { - return lakehouseQueryOperation.plan(input, context); - } - - return lakehouseQueryStreamOperation.plan(input, context); -} - -export async function presentQueryRun( - result: LakehouseQueryResult, - { sink }: OperationContext, - input: QueryRunInput, -): Promise { - await writeQueryOutput(result, input.format, input.displayOptions, input.pagerOptions, sink); -} - -const queryRunCommand = defineOperationCommand({ - id: "lakehouse.query.run", - capabilities: ["lakehouse-http", "streaming"], - catalog: { - effects: ["http", "http-stream"], - planes: ["lakehouse"], - output: "normalized", - }, - meta: { - name: "run", - description: "Run a SQL statement.", - examples: [ - 'altertable query "SELECT * FROM users LIMIT 10"', - 'altertable query "SELECT 1" --format json', - ], - }, - args: queryRunArgs, - parse({ args, rawArgs }) { - const statement = optionalStringArg(args, "statement"); - if (statement === undefined) { - throw new CliError('Provide a SQL statement, e.g. altertable query "SELECT 1".'); - } - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, rawArgs); - const readTimeoutMs = parseRequestReadTimeoutMs(args); - const httpOptions = readTimeoutMs !== undefined ? { readTimeoutMs } : undefined; - const queryId = optionalStringArg(args, "query-id"); - const sessionId = optionalStringArg(args, "session-id"); - return { statement, format, displayOptions, pagerOptions, httpOptions, queryId, sessionId }; - }, - run: planQueryRun, - present: presentQueryRun, -}); - -const queryShowCommand = defineHttpCommand({ - id: "lakehouse.query.show", - plane: "lakehouse", - operation: lakehouseQueryShowOperation, - output: "raw-api", - meta: { - 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 }, - }, - parse({ args }) { - return stringArg(args, "query-id"); - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); - -const queryCancelCommand = defineHttpCommand({ - id: "lakehouse.query.cancel", - plane: "lakehouse", - operation: lakehouseQueryCancelOperation, - mutates: true, - output: "raw-api", - meta: { - 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 }, - }, - parse({ args }) { - return { - queryId: stringArg(args, "query-id"), - sessionId: stringArg(args, "session-id"), - }; - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); - -const querySubCommands = { - run: queryRunCommand, - show: queryShowCommand, - cancel: queryCancelCommand, -} satisfies Record; - -const QUERY_SUBCOMMAND_NAMES = new Set(Object.keys(querySubCommands)); - -export const queryCommand = defineGroupCommand({ - meta: { - name: "query", - commandGroup: "query", - description: "Run SQL queries against the lakehouse.", - examples: [ - 'altertable query "SELECT * FROM users LIMIT 10"', - 'altertable query "SELECT 1" --format json', - "altertable query show ", - ], - }, - default: "run", - args: queryGroupArgs, - subCommands: querySubCommands, -}); diff --git a/cli/src/commands/lakehouse/upload.ts b/cli/src/commands/lakehouse/upload.ts deleted file mode 100644 index dcbe0a2..0000000 --- a/cli/src/commands/lakehouse/upload.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { statSync } from "node:fs"; -import { CliError } from "@/lib/errors.ts"; -import { enumArg, optionalStringArg, stringArg } from "@/lib/operation-codec.ts"; -import { httpEffect, scopedPlan } from "@/lib/operation-effect.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { - parseLakehouseFileContentType, - parseRequestReadTimeoutMs, -} from "@/commands/lakehouse-args.ts"; -import { createLakehouseUploadRequest } from "@/lib/lakehouse-transport.ts"; - -export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; -const UPLOAD_MODE_OPTIONS = ["create", "append", "overwrite"] as const; - -export function getUploadFileSizeBytes(filePath: string): number { - try { - const fileStat = statSync(filePath); - if (!fileStat.isFile()) { - throw new CliError(`File not found: ${filePath}`); - } - return fileStat.size; - } catch (error) { - if (error instanceof CliError) { - throw error; - } - throw new CliError(`File not found: ${filePath}`); - } -} - -export const uploadCommand = defineOperationCommand({ - id: "lakehouse.upload", - capabilities: ["lakehouse-http", "local-file-read", "progress"], - catalog: { - effects: ["scope", "http"], - planes: ["lakehouse"], - mutates: true, - output: "raw-api", - }, - meta: { - name: "upload", - commandGroup: "ingest", - description: "Upload a file to create, append to, or overwrite a table.", - examples: [ - "altertable upload --catalog db --schema public --table users --mode overwrite --format csv --file users.csv", - ], - }, - args: { - catalog: { type: "string", required: true }, - schema: { type: "string", required: true }, - table: { type: "string", required: true }, - mode: { - type: "enum", - description: "create, append, or overwrite", - required: true, - options: [...UPLOAD_MODE_OPTIONS], - }, - format: { - type: "enum", - description: "Optional file format hint for the Content-Type header", - options: [...LAKEHOUSE_FILE_FORMAT_OPTIONS], - }, - file: { type: "string", description: "Local file to upload", required: true }, - "read-timeout": { - type: "string", - description: "Read timeout in seconds for this request (overrides global --read-timeout)", - }, - }, - async parse({ args }) { - const mode = enumArg(args, "mode", UPLOAD_MODE_OPTIONS); - const filePath = stringArg(args, "file"); - const fileSizeBytes = getUploadFileSizeBytes(filePath); - - const readTimeoutMs = parseRequestReadTimeoutMs(args); - return { - catalog: stringArg(args, "catalog"), - schema: stringArg(args, "schema"), - table: stringArg(args, "table"), - mode, - filePath, - fileSizeBytes, - contentType: parseLakehouseFileContentType(optionalStringArg(args, "format")), - httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, - }; - }, - run(input) { - return scopedPlan(() => { - const scope = createLakehouseUploadRequest({ - catalog: input.catalog, - schema: input.schema, - table: input.table, - mode: input.mode, - filePath: input.filePath, - fileSizeBytes: input.fileSizeBytes, - contentType: input.contentType, - httpOptions: input.httpOptions, - }); - return { - effect: httpEffect(scope.request), - release: scope.release, - }; - }); - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); diff --git a/cli/src/commands/lakehouse/upsert.ts b/cli/src/commands/lakehouse/upsert.ts deleted file mode 100644 index eb57eee..0000000 --- a/cli/src/commands/lakehouse/upsert.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { optionalStringArg, stringArg } from "@/lib/operation-codec.ts"; -import { httpEffect, scopedPlan } from "@/lib/operation-effect.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { - parseLakehouseFileContentType, - parseRequestReadTimeoutMs, -} from "@/commands/lakehouse-args.ts"; -import { - getUploadFileSizeBytes, - LAKEHOUSE_FILE_FORMAT_OPTIONS, -} from "@/commands/lakehouse/upload.ts"; -import { createLakehouseUpsertRequest } from "@/lib/lakehouse-transport.ts"; - -export const upsertCommand = defineOperationCommand({ - id: "lakehouse.upsert", - capabilities: ["lakehouse-http", "local-file-read", "progress"], - catalog: { - effects: ["scope", "http"], - planes: ["lakehouse"], - mutates: true, - output: "raw-api", - }, - meta: { - name: "upsert", - commandGroup: "ingest", - description: "Upload a file and match existing rows by primary key.", - examples: [ - "altertable upsert --catalog db --schema public --table users --primary-key id --format csv --file users.csv", - ], - }, - args: { - catalog: { type: "string", required: true }, - schema: { type: "string", required: true }, - table: { type: "string", required: true }, - "primary-key": { - type: "string", - description: "Column name used to match existing rows", - required: true, - }, - format: { - type: "enum", - description: "Optional file format hint for the Content-Type header", - options: [...LAKEHOUSE_FILE_FORMAT_OPTIONS], - }, - file: { type: "string", description: "Local file to upload", required: true }, - "read-timeout": { - type: "string", - description: "Read timeout in seconds for this request (overrides global --read-timeout)", - }, - }, - async parse({ args }) { - const filePath = stringArg(args, "file"); - const fileSizeBytes = getUploadFileSizeBytes(filePath); - - const readTimeoutMs = parseRequestReadTimeoutMs(args); - return { - catalog: stringArg(args, "catalog"), - schema: stringArg(args, "schema"), - table: stringArg(args, "table"), - primaryKey: stringArg(args, "primary-key"), - filePath, - fileSizeBytes, - contentType: parseLakehouseFileContentType(optionalStringArg(args, "format")), - httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, - }; - }, - run(input) { - return scopedPlan(() => { - const scope = createLakehouseUpsertRequest({ - catalog: input.catalog, - schema: input.schema, - table: input.table, - primaryKey: input.primaryKey, - filePath: input.filePath, - fileSizeBytes: input.fileSizeBytes, - contentType: input.contentType, - httpOptions: input.httpOptions, - }); - return { - effect: httpEffect(scope.request), - release: scope.release, - }; - }); - }, - present(response) { - return { kind: "raw_api", body: response }; - }, -}); diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts new file mode 100644 index 0000000..fb95311 --- /dev/null +++ b/cli/src/commands/login/index.test.ts @@ -0,0 +1,241 @@ +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 { configGet } from "@/lib/config.ts"; +import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; +import { secretGet, secretSet } from "@/lib/secrets.ts"; +import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/profile-store.ts"; +import { createEmptyProfile, updateProfile } from "@/lib/profile/model.ts"; +import { createCliTestHarness, runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { delay } from "@/test-utils/time.ts"; +import { forceNoTerminalColorForTests } from "@/test-utils/terminal.ts"; + +const DEFAULT_WHOAMI = { + principal: { + type: "User", + name: "Test User", + email: "test.user@altertable.test", + slug: "test-user", + }, + organization: { name: "Altertable", slug: "altertable" }, + authentication_scope: "environment", + environment_slug: "production", +}; + +let testHome = ""; +let mockFile = ""; +let originalPath: string | undefined; +let stdinIsTty: PropertyDescriptor | undefined; + +function storedAccessToken(profileName: string): string { + return secretGet("oauth/access-token", profileName); +} + +beforeEach(() => { + 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_MOCK_HTTP_FILE = mockFile; + process.env.OSC_HYPERLINK = "0"; + originalPath = process.env.PATH; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + forceNoTerminalColorForTests(); + + for (const browserCommand of ["open", "xdg-open"]) { + const browserPath = join(testHome, browserCommand); + writeFileSync(browserPath, "#!/bin/sh\nexit 0\n"); + chmodSync(browserPath, 0o755); + } + process.env.PATH = `${testHome}:${originalPath ?? ""}`; +}); + +afterEach(() => { + if (stdinIsTty) Object.defineProperty(process.stdin, "isTTY", stdinIsTty); + rmSync(testHome, { recursive: true, force: true }); + process.env.PATH = originalPath; + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; + delete process.env.ALTERTABLE_MOCK_HTTP_FILE; + delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; + delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; + delete process.env.OSC_HYPERLINK; +}); + +async function completeBrowserLogin( + rawArgs: string[] = ["login"], + whoami: object = DEFAULT_WHOAMI, + accessToken = "access_token", +) { + writeFileSync( + mockFile, + JSON.stringify([ + { + urlPattern: "/oauth/token", + method: "POST", + body: JSON.stringify({ + access_token: accessToken, + token_type: "Bearer", + refresh_token: "refresh_token", + expires_in: 3600, + }), + }, + { + urlPattern: "/whoami", + method: "GET", + authPattern: accessToken, + body: JSON.stringify(whoami), + }, + ]), + ); + + 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.stderr.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"); + + 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); + 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", () => { + return expect( + 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 () => { + 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.stderr.join("\n")).toContain('using profile "default"'); + }); + + 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", + ); + + await completeBrowserLogin( + ["login"], + { + ...DEFAULT_WHOAMI, + organization: { name: "Org B", slug: "org-b" }, + }, + "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"); + + await completeBrowserLogin(); + + 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"); + + await completeBrowserLogin(); + + 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 () => { + storeOAuthTokens( + { access_token: "old_token", refresh_token: "old_refresh", expires_in: 3600 }, + "default", + ); + + await completeBrowserLogin( + ["login", "--replace-profile"], + { + ...DEFAULT_WHOAMI, + organization: { name: "Org B", slug: "org-b" }, + }, + "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 () => { + storeOAuthTokens( + { 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" }); + + await completeBrowserLogin( + ["login"], + { + ...DEFAULT_WHOAMI, + organization: { name: "Org B", slug: "org-b" }, + }, + "org_b_token", + ); + + 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"); + }); + + 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", + ]); + + 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 }); + return expect( + runCommandWithTestRuntime(["login", "--control-plane-url", "http://app.altertable.test"]), + ).rejects.toThrow(); + }); +}); diff --git a/cli/src/commands/login.ts b/cli/src/commands/login/index.ts similarity index 61% rename from cli/src/commands/login.ts rename to cli/src/commands/login/index.ts index 3f42c89..89c99ca 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login/index.ts @@ -1,36 +1,62 @@ -import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; +import { defineCommand } from "@/lib/command.ts"; import { 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 { resolveManagementApiBase, resolveOAuthBase } from "@/lib/config.ts"; +import { resolveManagementApiRoot } from "@/lib/config.ts"; import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { managementRequest } from "@/lib/management-transport.ts"; import { runLoginFlow, type TokenResponse } from "@/lib/oauth-flow.ts"; import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/features/management/render.ts"; -import { configureRunClear } from "@/lib/profile-configure-core.ts"; +import type { WhoamiResponse } from "@/lib/management/model.ts"; +import { formatWhoamiPrincipalLine } from "@/lib/management/render.ts"; import { assertNoEnvConfigMode, createEmptyProfile, deriveProfileName, - profileExists, profileHasAnyAuthConfigured, - resolveWorkingProfile, - setActiveProfile, updateProfile, -} from "@/features/profile/model.ts"; -import { readEnv, setEnv } from "@/lib/env.ts"; +} from "@/lib/profile/model.ts"; +import { profileExists, resolveWorkingProfile, setActiveProfile } from "@/lib/profile-store.ts"; import { span } from "@/ui/document.ts"; import { renderDisplayText } from "@/ui/terminal/styles.ts"; +export const loginCommand = defineCommand({ + meta: { + name: "login", + commandGroup: "platform", + description: "Sign in with your browser (OAuth) and store the session.", + examples: ["altertable login", "altertable login --replace-profile"], + }, + args: { + "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", + }, + "data-plane-url": { + type: "string", + description: + "Data-plane base URL saved to the profile only on successful login. Default: https://api.altertable.ai", + }, + "allow-insecure-http": { + type: "boolean", + description: + "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", + }, + }, + run: ({ args, sink }) => runLogin(args as LoginArgs, sink), +}); + function isInteractiveTerminal(): boolean { return process.stdin.isTTY; } -export function assertInteractiveLogin(): void { +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.\nFor headless setups use 'altertable profile --configure --api-key atm_xxx --env '.", @@ -38,10 +64,6 @@ export function assertInteractiveLogin(): void { } } -export function resolveWhoamiEnvironmentSlug(whoami: WhoamiResponse): string | undefined { - return whoami.environment_slug; -} - type LoginProfileMetadata = { environment: string; profileName: string; @@ -50,6 +72,13 @@ type LoginProfileMetadata = { type LoginProfileAction = LoginProfileMetadata["profileAction"]; +const LOGIN_PROFILE_MESSAGES = { + 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 { const organizationSlug = whoami.organization?.slug; return organizationSlug ? deriveProfileName(organizationSlug, environment) : fallback; @@ -92,11 +121,12 @@ function selectLoginProfile( return { profileName: targetProfile, profileAction }; } -export function storeLoginProfileMetadata( +function storeLoginProfileMetadata( whoami: WhoamiResponse, args: LoginArgs, + controlPlane: string, ): LoginProfileMetadata { - const environment = resolveWhoamiEnvironmentSlug(whoami); + const environment = whoami.environment_slug; // OAuth login must always return an environment. if (!environment) { @@ -123,47 +153,40 @@ export function storeLoginProfileMetadata( principalEmail: whoami.principal?.email, principalSlug: whoami.principal?.slug, ...(args["data-plane-url"] ? { dataPlane: args["data-plane-url"] } : {}), - ...(args["control-plane-url"] ? { controlPlane: args["control-plane-url"] } : {}), + controlPlane, }); return { environment, profileName, profileAction }; } -export type LoginArgs = { +type LoginArgs = { "data-plane-url"?: string; "control-plane-url"?: string; "allow-insecure-http"?: boolean; "replace-profile"?: boolean; }; -/** - * When `--control-plane-url` is passed, validate it and apply it to this login - * session only, up until the login is successful. - */ -export function applyControlPlaneOverride(args: LoginArgs): void { - const url = args["control-plane-url"]; - if (!url) { - return; - } - const envOverride = readEnv("ALTERTABLE_MANAGEMENT_API_BASE"); - if (envOverride && envOverride !== url) { - throw new ConfigurationError( - `ALTERTABLE_MANAGEMENT_API_BASE=${envOverride} overrides --control-plane-url=${url}. Unset the environment variable or make them match.`, - ); - } - if (args["allow-insecure-http"]) { - // resolveManagementApiRoot re-validates the URL on every read using this env - // var, so set it too — otherwise an http:// root would fail the very next - // read (whoami, and later commands in this process). - setEnv("ALTERTABLE_ALLOW_INSECURE_HTTP", "1"); - } - assertAllowedApiBase(url, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - setEnv("ALTERTABLE_MANAGEMENT_API_BASE", url); +function resolveLoginEndpoints( + args: LoginArgs, + profileName: string, +): { controlPlane: string; oauthBase: string; managementApiBase: string } { + const override = args["control-plane-url"]; + const controlPlane = override + ? override.replace(/\/$/, "") + : resolveManagementApiRoot(profileName); + assertAllowedApiBase(controlPlane, { + allowInsecureHttp: Boolean(args["allow-insecure-http"]), + }); + return { + controlPlane, + oauthBase: `${controlPlane}/oauth`, + managementApiBase: `${controlPlane}/rest/v1`, + }; } // Profile-free on purpose: the minted token — not the current profile's stored // auth, which may still be a different org's session — decides who we are. -export async function fetchLoginWhoami( +async function fetchLoginWhoami( oauthResponse: TokenResponse, managementApiBase: string, ): Promise { @@ -176,110 +199,35 @@ export async function fetchLoginWhoami( return JSON.parse(body) as WhoamiResponse; } -async function fetchCurrentProfileWhoami(): Promise { - return JSON.parse(await managementRequest("GET", "/whoami")) as WhoamiResponse; -} - -export function sameWhoamiContext(a: WhoamiResponse, b: WhoamiResponse): boolean { - return ( - a.principal?.type === b.principal?.type && - a.principal?.slug === b.principal?.slug && - a.principal?.email === b.principal?.email && - a.organization?.slug === b.organization?.slug && - a.environment_slug === b.environment_slug - ); -} - async function runLogin(args: LoginArgs, sink: OutputSink): Promise { assertNoEnvConfigMode(); assertInteractiveLogin(); - applyControlPlaneOverride(args); const currentProfile = resolveWorkingProfile(getCliContext().profile); - const oauthBase = resolveOAuthBase(currentProfile); - const managementApiBase = resolveManagementApiBase(currentProfile); + const { controlPlane, oauthBase, managementApiBase } = resolveLoginEndpoints( + args, + currentProfile, + ); // Past this point the flow is profile-free so it can't accidentally read another org's stored session. const oauthResponse = await runLoginFlow(sink, oauthBase); const whoami = await fetchLoginWhoami(oauthResponse, managementApiBase); // Login succeeded and we can now persist whoami metadata and any control-plane override to the profile so later commands target it. - const { environment, profileName, profileAction } = storeLoginProfileMetadata(whoami, args); + const { environment, profileName, profileAction } = storeLoginProfileMetadata( + whoami, + args, + controlPlane, + ); storeOAuthTokens(oauthResponse, profileName); refreshCliRuntimeContext(getCliContext()); - // Refresh whoami from the profile and check if the identity is the same as the one we just authenticated as. - const refreshedWhoami = await fetchCurrentProfileWhoami(); - if (!sameWhoamiContext(whoami, refreshedWhoami)) { - throw new Error( - "Login failed: the identity before and after profile persistence do not match.", - ); - } - const identity = formatWhoamiPrincipalLine(whoami); - const profileMessages = { - created: `created profile "${profileName}"`, - reused: `using existing profile "${profileName}"`, - replaced: `replaced current profile with "${profileName}"`, - unchanged: `using profile "${profileName}"`, - } satisfies Record; + const profileMessage = `${LOGIN_PROFILE_MESSAGES[profileAction]} "${profileName}"`; sink.writeMetadata([ renderDisplayText([ span("✓", "success"), - span( - ` Logged in (${identity}) — ${profileMessages[profileAction]}; environment "${environment}".`, - ), + span(` Logged in (${identity}) — ${profileMessage}; environment "${environment}".`), ]), ]); } - -export const loginCommand = defineLocalCommand({ - id: "login", - mutates: true, - localConfig: true, - output: "none", - meta: { - name: "login", - commandGroup: "platform", - description: "Sign in with your browser (OAuth) and store the session.", - examples: ["altertable login", "altertable login --replace-profile"], - }, - args: { - "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", - }, - "data-plane-url": { - type: "string", - description: - "Data-plane base URL saved to the profile only on successful login. Default: https://api.altertable.ai", - }, - "allow-insecure-http": { - type: "boolean", - description: - "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", - }, - }, - local: (_input, context) => runLogin(context.args as LoginArgs, context.sink), -}); - -export const logoutCommand = defineLocalCommand({ - id: "logout", - mutates: true, - localConfig: true, - output: "none", - meta: { - name: "logout", - commandGroup: "platform", - description: "Remove stored credentials and settings for all profiles.", - examples: ["altertable logout"], - }, - local: (_input, { sink }) => { - configureRunClear(sink); - }, -}); diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts new file mode 100644 index 0000000..6b0ee6f --- /dev/null +++ b/cli/src/commands/logout/index.ts @@ -0,0 +1,14 @@ +import { defineCommand } from "@/lib/command.ts"; +import { configureRunClear } from "@/lib/profile-configure-core.ts"; + +export const logoutCommand = defineCommand({ + meta: { + name: "logout", + commandGroup: "platform", + description: "Remove stored credentials and settings for all profiles.", + examples: ["altertable logout"], + }, + run({ sink }) { + configureRunClear(sink); + }, +}); diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile.ts deleted file mode 100644 index 4de200a..0000000 --- a/cli/src/commands/profile.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { asCliArgString } from "@/lib/cli-args.ts"; -import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; -import { CliError, ConfigurationError } from "@/lib/errors.ts"; -import type { ProfileInspect } from "@/features/profile/model.ts"; -import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { configureVerify } from "@/lib/profile-status.ts"; -import { - configureArgs, - runProfileConfigure, - type ConfigureCommandArgs, -} from "@/lib/profile-configure.ts"; -import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegation.ts"; -import { - defaultConfigurePrompts, - type ConfigurePrompts, -} from "@/lib/profile-configure-interactive.ts"; -import { defineLocalCommand, defineValueCommand } from "@/lib/operation-command-builders.ts"; -import { - buildProfileDirenvView, - buildProfileShellExportView, - profileStatusToJson, - profileSwitchOption, - type ProfileStatusResult, -} from "@/features/profile/views.ts"; -import { - formatProfileInspect, - formatProfileList, - formatProfileStatus, -} from "@/features/profile/render.ts"; -import { renderShellExportView } from "@/ui/shell/render.ts"; -import { - assertNoEnvConfigMode, - createEmptyProfile, - deleteProfile, - envConfigMode, - getActiveProfileName, - inspectProfile, - isFromEnvProfile, - listProfiles, - profileExists, - renameProfile, - setActiveProfile, -} from "@/features/profile/model.ts"; -import { refreshCliRuntimeContext } from "@/lib/runtime.ts"; - -function requireProfileName(name: unknown): string { - const trimmed = asCliArgString(name).trim(); - if (trimmed === "") { - throw new CliError("A profile name is required."); - } - return trimmed; -} - -function optionalArg(value: unknown): string | undefined { - const stringValue = asCliArgString(value).trim(); - return stringValue || undefined; -} - -function existingProfileName(name: string): string { - // `_from_env` is a valid read target (rendered from env vars) only while env - // config is actually in effect; it has no stored directory, so otherwise the - // name resolves to nothing. - const resolvable = isFromEnvProfile(name) ? envConfigMode() : profileExists(name); - if (!resolvable) { - throw new ConfigurationError(`Profile not found: ${name}`); - } - return name; -} - -// env/direnv export a selectable profile name; `_from_env` isn't one. -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.", - ); - } - return existingProfileName(name); -} - -function profileNameArgOrActive(args: Record): string { - return args.name ? requireProfileName(args.name) : getActiveProfileName(); -} - -function configuredVerificationPlanes(profile: ProfileInspect): ConfigureAuthPlane[] { - const planes: ConfigureAuthPlane[] = []; - if (profile.auth.management !== "none") { - planes.push("management"); - } - if (profile.auth.lakehouse !== "none") { - planes.push("lakehouse"); - } - return planes; -} - -export async function promptProfileSwitch( - prompts: ConfigurePrompts = defaultConfigurePrompts, -): Promise { - const profiles = listProfiles(); - const activeProfile = getActiveProfileName(); - return await prompts.readSelect( - "Switch profile", - profiles.map(profileSwitchOption), - activeProfile, - ); -} - -const profileListCommand = defineValueCommand({ - id: "profile.list", - capabilities: ["local-config"], - output: "normalized", - meta: { name: "list", description: "List configured profiles" }, - value() { - return listProfiles(); - }, - present(profiles) { - return { - kind: "normalized", - data: { profiles }, - humanText: formatProfileList(profiles), - }; - }, -}); - -const profileCreateCommand = defineLocalCommand({ - id: "profile.create", - mutates: true, - localConfig: true, - output: "normalized", - meta: { - name: "create", - description: "Create a profile and configure its credentials", - examples: [ - "altertable profile create acme_prod --api-key atm_xxx --env production", - "altertable profile create acme_staging --user alice --password secret", - "altertable profile create acme_prod", - ], - }, - args: { - name: { type: "positional", description: "Profile name", required: true }, - ...configureArgs, - }, - parse({ args }) { - return { - name: requireProfileName(args.name), - configure: args as ConfigureCommandArgs, - }; - }, - async local(input, context) { - assertNoEnvConfigMode(); - createEmptyProfile(input.name); - await runProfileConfigure(input.configure, context.sink, input.name); - setActiveProfile(input.name); - return inspectProfile(input.name); - }, - present(profile) { - return { - kind: "normalized", - data: { profile }, - humanText: formatProfileInspect(profile), - }; - }, -}); - -function profileShowTargetName(args: Record): string { - const explicit = optionalArg(args.name); - if (explicit) { - return explicit; - } - return getCliContext().profile ?? getActiveProfileName(); -} - -const profileShowCommand = defineLocalCommand<{ profileName: string }, ProfileInspect>({ - id: "profile.show", - localConfig: true, - output: "normalized", - meta: { - name: "show", - description: "Show a profile's stored identity, auth, and endpoints", - }, - args: { - name: { type: "string", description: "Profile name (default: active profile)" }, - }, - parse({ args }) { - return { profileName: existingProfileName(profileShowTargetName(args)) }; - }, - local(input) { - return inspectProfile(input.profileName); - }, - present(result) { - return { - kind: "normalized", - data: { profile: result }, - humanText: formatProfileInspect(result), - }; - }, -}); - -function createProfileUseCommand(id: string, name: string, hidden = false) { - return defineLocalCommand({ - id, - mutates: true, - localConfig: true, - output: "normalized", - meta: { name, description: "Set the active profile", hidden }, - args: { - name: { type: "positional", description: "Profile name", required: true }, - }, - parse({ args }) { - return requireProfileName(args.name); - }, - local(profileName) { - assertNoEnvConfigMode(); - setActiveProfile(profileName); - return profileName; - }, - present(profileName) { - return { - kind: "ack", - data: { active_profile: profileName }, - metadataMessage: `Active profile set to ${profileName}.`, - }; - }, - }); -} - -const profileUseCommand = createProfileUseCommand("profile.use", "use"); -const profileSwitchCommand = defineLocalCommand({ - id: "profile.switch", - mutates: true, - localConfig: true, - output: "normalized", - meta: { name: "switch", description: "Interactively switch the active profile" }, - args: { - name: { type: "positional", description: "Profile name", required: false }, - }, - parse({ args }) { - return args.name ? requireProfileName(args.name) : undefined; - }, - async local(profileName) { - assertNoEnvConfigMode(); - if (!profileName) { - 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); - return profileName; - }, - present(profileName) { - return { - kind: "ack", - data: { active_profile: profileName }, - metadataMessage: `Active profile set to ${profileName}.`, - }; - }, -}); - -const profileCurrentCommand = defineValueCommand({ - id: "profile.current", - capabilities: ["local-config"], - output: "normalized", - meta: { name: "current", description: "Show the active profile name" }, - value() { - return getActiveProfileName(); - }, - present(profileName) { - return { - kind: "normalized", - data: { active_profile: profileName }, - humanText: profileName, - }; - }, -}); - -const profileEnvCommand = defineValueCommand({ - id: "profile.env", - capabilities: ["local-config"], - output: "normalized", - meta: { name: "env", description: "Print shell exports for a profile" }, - args: { - name: { type: "positional", description: "Profile name (default: active profile)" }, - }, - parse({ args }) { - return requireStoredProfileForExport(profileNameArgOrActive(args)); - }, - value(profileName) { - return profileName; - }, - present(profileName) { - const view = buildProfileShellExportView(profileName); - return { - kind: "normalized", - data: { profile: profileName, env: view.env }, - humanText: renderShellExportView(view), - }; - }, -}); - -const profileDirenvCommand = defineValueCommand({ - id: "profile.direnv", - capabilities: ["local-config"], - output: "normalized", - meta: { name: "direnv", description: "Print a .envrc snippet for a profile" }, - args: { - name: { type: "positional", description: "Profile name (default: active profile)" }, - }, - parse({ args }) { - return requireStoredProfileForExport(profileNameArgOrActive(args)); - }, - value(profileName) { - return profileName; - }, - present(profileName) { - const view = buildProfileDirenvView(profileName); - return { - kind: "normalized", - data: { profile: profileName, env: view.env }, - humanText: renderShellExportView(view), - }; - }, -}); - -const profileStatusCommand = defineLocalCommand({ - id: "profile.status", - localConfig: true, - output: "normalized", - meta: { - name: "status", - description: "Verify stored credentials and show the profile", - }, - args: { - name: { type: "string", description: "Profile name (default: active profile)" }, - }, - parse({ args }) { - return existingProfileName(profileShowTargetName(args)); - }, - async local(profileName) { - const previous = getCliContext(); - try { - const next = { ...previous, profile: profileName }; - setCliContext(next); - refreshCliRuntimeContext(next); - const profile = inspectProfile(profileName); - const verification = await configureVerify(configuredVerificationPlanes(profile)); - return { profile, verification }; - } finally { - setCliContext(previous); - refreshCliRuntimeContext(previous); - } - }, - present(result) { - return { - kind: "normalized", - data: profileStatusToJson(result), - humanText: formatProfileStatus(result), - }; - }, -}); - -const profileRenameCommand = defineLocalCommand({ - id: "profile.rename", - mutates: true, - localConfig: true, - output: "normalized", - meta: { 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 }, - }, - parse({ args }) { - return { - from: requireProfileName(args.from), - to: requireProfileName(args.to), - }; - }, - local(input) { - assertNoEnvConfigMode(); - renameProfile(input.from, input.to); - return input; - }, - present(input) { - return { - kind: "ack", - data: { renamed: true, from: input.from, to: input.to }, - metadataMessage: `Renamed profile ${input.from} to ${input.to}.`, - }; - }, -}); - -const profileDeleteCommand = defineLocalCommand({ - id: "profile.delete", - mutates: true, - localConfig: true, - output: "normalized", - meta: { name: "delete", description: "Delete a profile" }, - args: { - name: { type: "positional", description: "Profile name", required: true }, - yes: { type: "boolean", description: "Confirm deletion" }, - }, - parse({ args }) { - if (!args.yes) { - throw new CliError("Pass --yes to delete a profile."); - } - return requireProfileName(args.name); - }, - local(profileName) { - assertNoEnvConfigMode(); - deleteProfile(profileName); - return profileName; - }, - present(profileName) { - return { - kind: "ack", - data: { deleted: profileName }, - metadataMessage: `Deleted profile ${profileName}.`, - }; - }, -}); - -const profileValueFlags = valueFlagsFor(configureArgs); - -/** True when citty already dispatched to a `profile ` for this invocation. */ -function profileSubcommandInvoked(rawArgs: readonly string[]): boolean { - return findFirstPositionalToken(rawArgs, { valueFlags: profileValueFlags }) !== undefined; -} - -/** Mimic citty's bare-command usage path so `altertable profile` still prints help. */ -function throwNoProfileCommand(): never { - const error = new Error("No command specified.") as Error & { code: string }; - error.name = "CLIError"; - error.code = "E_NO_COMMAND"; - throw error; -} - -export const profileCommand = defineLocalCommand, void>({ - id: "profile", - mutates: true, - localConfig: true, - output: "none", - meta: { - name: "profile", - commandGroup: "platform", - description: "Manage named profiles and stored credentials.", - examples: [ - "altertable profile show", - "altertable profile --configure", - "altertable profile --configure --api-key atm_xxx --env production", - "altertable profile --configure --scope lakehouse", - "altertable profile list", - "altertable profile create acme_prod --api-key atm_xxx --env production", - "altertable profile use acme_prod", - "altertable profile switch", - "altertable profile status", - 'eval "$(altertable profile env acme_staging)"', - "altertable --profile acme_staging profile show", - ], - }, - args: { - configure: { - type: "boolean", - description: - "Create or update stored credentials and settings (interactive wizard, or pass flags to set fields)", - }, - ...configureArgs, - }, - subCommands: { - create: profileCreateCommand, - list: profileListCommand, - show: profileShowCommand, - status: profileStatusCommand, - use: profileUseCommand, - switch: profileSwitchCommand, - current: profileCurrentCommand, - env: profileEnvCommand, - direnv: profileDirenvCommand, - rename: profileRenameCommand, - delete: profileDeleteCommand, - }, - parse({ args }) { - return args as Record; - }, - async local(args, context) { - if (args.configure) { - assertNoEnvConfigMode(); - await runProfileConfigure(args as ConfigureCommandArgs, context.sink); - return; - } - // A subcommand (e.g. `profile list`) already ran; nothing left for the parent. - if (profileSubcommandInvoked(context.rawArgs)) { - return; - } - throwNoProfileCommand(); - }, -}); diff --git a/cli/src/commands/profile/create.ts b/cli/src/commands/profile/create.ts new file mode 100644 index 0000000..d3c19e5 --- /dev/null +++ b/cli/src/commands/profile/create.ts @@ -0,0 +1,39 @@ +import { assertNoEnvConfigMode, createEmptyProfile, inspectProfile } from "@/lib/profile/model.ts"; +import { setActiveProfile } from "@/lib/profile-store.ts"; +import { formatProfileInspect } from "@/lib/profile/render.ts"; +import { + configureArgs, + runProfileConfigure, + type ConfigureCommandArgs, +} from "@/lib/profile-configure.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileCreateCommand = defineCommand({ + meta: { + name: "create", + description: "Create a profile and configure its credentials", + examples: [ + "altertable profile create acme_prod --api-key atm_xxx --env production", + "altertable profile create acme_staging --user alice --password secret", + "altertable profile create acme_prod", + ], + }, + args: { + name: { type: "positional", description: "Profile name", required: true }, + ...configureArgs, + }, + async run({ args, sink }) { + const name = requireProfileName(args.name); + assertNoEnvConfigMode(); + createEmptyProfile(name); + await runProfileConfigure(args as ConfigureCommandArgs, sink, name); + setActiveProfile(name); + const profile = inspectProfile(name); + await writeCommandOutput( + { kind: "normalized", data: { profile }, humanText: formatProfileInspect(profile) }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/current.ts b/cli/src/commands/profile/current.ts new file mode 100644 index 0000000..885695d --- /dev/null +++ b/cli/src/commands/profile/current.ts @@ -0,0 +1,14 @@ +import { getActiveProfileName } from "@/lib/profile-store.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileCurrentCommand = defineCommand({ + meta: { 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, + ); + }, +}); diff --git a/cli/src/commands/profile/delete.ts b/cli/src/commands/profile/delete.ts new file mode 100644 index 0000000..b77da7c --- /dev/null +++ b/cli/src/commands/profile/delete.ts @@ -0,0 +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"; + +export const profileDeleteCommand = defineCommand({ + meta: { name: "delete", description: "Delete a profile" }, + args: { + 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."); + const profileName = requireProfileName(args.name); + assertNoEnvConfigMode(); + deleteProfile(profileName); + await writeCommandOutput( + { + kind: "ack", + data: { deleted: profileName }, + metadataMessage: `Deleted profile ${profileName}.`, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/direnv.ts b/cli/src/commands/profile/direnv.ts new file mode 100644 index 0000000..ff02fcb --- /dev/null +++ b/cli/src/commands/profile/direnv.ts @@ -0,0 +1,25 @@ +import { buildProfileDirenvView } 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"; + +export const profileDirenvCommand = defineCommand({ + meta: { name: "direnv", description: "Print a .envrc snippet for a profile" }, + args: { name: { type: "positional", description: "Profile name (default: active profile)" } }, + async run({ args, sink }) { + const profileName = requireStoredProfileForExport(profileNameArgOrActive(args)); + const view = buildProfileDirenvView(profileName); + await writeCommandOutput( + { + kind: "normalized", + data: { profile: profileName, env: view.env }, + humanText: renderShellExportView(view), + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/env.ts b/cli/src/commands/profile/env.ts new file mode 100644 index 0000000..59c068e --- /dev/null +++ b/cli/src/commands/profile/env.ts @@ -0,0 +1,25 @@ +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"; + +export const profileEnvCommand = defineCommand({ + meta: { name: "env", description: "Print shell exports for a profile" }, + args: { name: { type: "positional", description: "Profile name (default: active profile)" } }, + async run({ args, sink }) { + const profileName = requireStoredProfileForExport(profileNameArgOrActive(args)); + const view = buildProfileShellExportView(profileName); + await writeCommandOutput( + { + kind: "normalized", + data: { profile: profileName, env: view.env }, + humanText: renderShellExportView(view), + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts new file mode 100644 index 0000000..c90c302 --- /dev/null +++ b/cli/src/commands/profile/index.ts @@ -0,0 +1,83 @@ +import { defineCommand } from "@/lib/command.ts"; +import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; +import { + configureArgs, + runProfileConfigure, + type ConfigureCommandArgs, +} from "@/lib/profile-configure.ts"; +import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegation.ts"; +import { profileCreateCommand } from "@/commands/profile/create.ts"; +import { profileCurrentCommand } from "@/commands/profile/current.ts"; +import { profileDeleteCommand } from "@/commands/profile/delete.ts"; +import { profileDirenvCommand } from "@/commands/profile/direnv.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 { profileUseCommand } from "@/commands/profile/use.ts"; + +export const profileCommand = defineCommand({ + meta: { + name: "profile", + commandGroup: "platform", + description: "Manage named profiles and stored credentials.", + examples: [ + "altertable profile show", + "altertable profile --configure", + "altertable profile --configure --api-key atm_xxx --env production", + "altertable profile --configure --scope lakehouse", + "altertable profile list", + "altertable profile create acme_prod --api-key atm_xxx --env production", + "altertable profile use acme_prod", + "altertable profile switch", + "altertable profile status", + 'eval "$(altertable profile env acme_staging)"', + "altertable --profile acme_staging profile show", + ], + }, + args: { + configure: { + type: "boolean", + description: + "Create or update stored credentials and settings (interactive wizard, or pass flags to set fields)", + }, + ...configureArgs, + }, + subCommands: { + create: profileCreateCommand, + list: profileListCommand, + show: profileShowCommand, + status: profileStatusCommand, + use: profileUseCommand, + switch: profileSwitchCommand, + current: profileCurrentCommand, + env: profileEnvCommand, + direnv: profileDirenvCommand, + rename: profileRenameCommand, + delete: profileDeleteCommand, + }, + async run({ args, rawArgs, sink }) { + if (args.configure) { + assertNoEnvConfigMode(); + await runProfileConfigure(args as ConfigureCommandArgs, sink); + return; + } + if (profileSubcommandInvoked(rawArgs)) return; + throwNoProfileCommand(); + }, +}); + +const profileValueFlags = valueFlagsFor(configureArgs); + +function profileSubcommandInvoked(rawArgs: readonly string[]): boolean { + return findFirstPositionalToken(rawArgs, { valueFlags: profileValueFlags }) !== undefined; +} + +function throwNoProfileCommand(): never { + 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 new file mode 100644 index 0000000..bc8f0d5 --- /dev/null +++ b/cli/src/commands/profile/lib/profile.ts @@ -0,0 +1,64 @@ +import { asCliArgString } from "@/lib/cli-args.ts"; +import { getCliContext } from "@/context.ts"; +import { CliError, ConfigurationError } from "@/lib/errors.ts"; +import type { ProfileInspect } from "@/lib/profile/model.ts"; +import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; +import { defaultPrompts, type Prompts } from "@/ui/prompts.ts"; +import { profileSwitchOption } from "@/lib/profile/views.ts"; +import { + envConfigMode, + getActiveProfileName, + isFromEnvProfile, + profileExists, +} from "@/lib/profile-store.ts"; +import { listProfiles } from "@/lib/profile/model.ts"; + +export function requireProfileName(name: unknown): string { + const trimmed = asCliArgString(name).trim(); + if (trimmed === "") throw new CliError("A profile name is required."); + return trimmed; +} + +export function optionalArg(value: unknown): string | undefined { + const stringValue = asCliArgString(value).trim(); + return stringValue || undefined; +} + +export function existingProfileName(name: string): string { + const resolvable = isFromEnvProfile(name) ? envConfigMode() : profileExists(name); + if (!resolvable) throw new ConfigurationError(`Profile not found: ${name}`); + return name; +} + +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.", + ); + } + return existingProfileName(name); +} + +export function profileNameArgOrActive(args: Record): string { + return args.name ? requireProfileName(args.name) : getActiveProfileName(); +} + +export function profileShowTargetName(args: Record): string { + return optionalArg(args.name) ?? getCliContext().profile ?? getActiveProfileName(); +} + +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"); + return planes; +} + +export async function promptProfileSwitch(prompts: Prompts = defaultPrompts): Promise { + const profiles = listProfiles(); + return prompts.readSelect( + "Switch profile", + profiles.map(profileSwitchOption), + getActiveProfileName(), + ); +} diff --git a/cli/src/commands/profile/list.ts b/cli/src/commands/profile/list.ts new file mode 100644 index 0000000..6ca1f94 --- /dev/null +++ b/cli/src/commands/profile/list.ts @@ -0,0 +1,15 @@ +import { listProfiles } from "@/lib/profile/model.ts"; +import { formatProfileList } from "@/lib/profile/render.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileListCommand = defineCommand({ + meta: { name: "list", description: "List configured profiles" }, + async run({ sink }) { + const profiles = listProfiles(); + await writeCommandOutput( + { kind: "normalized", data: { profiles }, humanText: formatProfileList(profiles) }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/rename.ts b/cli/src/commands/profile/rename.ts new file mode 100644 index 0000000..04a9c6e --- /dev/null +++ b/cli/src/commands/profile/rename.ts @@ -0,0 +1,26 @@ +import { assertNoEnvConfigMode, renameProfile } from "@/lib/profile/model.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileRenameCommand = defineCommand({ + meta: { 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 }, + }, + async run({ args, sink }) { + const from = requireProfileName(args.from); + const to = requireProfileName(args.to); + assertNoEnvConfigMode(); + renameProfile(from, to); + await writeCommandOutput( + { + kind: "ack", + data: { renamed: true, from, to }, + metadataMessage: `Renamed profile ${from} to ${to}.`, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/show.ts b/cli/src/commands/profile/show.ts new file mode 100644 index 0000000..96de8d8 --- /dev/null +++ b/cli/src/commands/profile/show.ts @@ -0,0 +1,17 @@ +import { inspectProfile } from "@/lib/profile/model.ts"; +import { formatProfileInspect } from "@/lib/profile/render.ts"; +import { existingProfileName, profileShowTargetName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileShowCommand = defineCommand({ + meta: { name: "show", description: "Show a profile's stored identity, auth, and endpoints" }, + args: { name: { type: "string", description: "Profile name (default: active profile)" } }, + async run({ args, sink }) { + const profile = inspectProfile(existingProfileName(profileShowTargetName(args))); + await writeCommandOutput( + { kind: "normalized", data: { profile }, humanText: formatProfileInspect(profile) }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts new file mode 100644 index 0000000..f694c53 --- /dev/null +++ b/cli/src/commands/profile/status.ts @@ -0,0 +1,33 @@ +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"; + +export const profileStatusCommand = defineCommand({ + meta: { name: "status", description: "Verify stored credentials and show the profile" }, + args: { name: { type: "string", description: "Profile name (default: active profile)" } }, + async run({ args, execution, sink }) { + const profileName = existingProfileName(profileShowTargetName(args)); + const profile = inspectProfile(profileName); + const verification = await configureVerify(configuredVerificationPlanes(profile), { + ...execution, + profile: profileName, + }); + const result = { profile, verification }; + await writeCommandOutput( + { + kind: "normalized", + data: profileStatusToJson(result), + humanText: formatProfileStatus(result), + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts new file mode 100644 index 0000000..b4558a8 --- /dev/null +++ b/cli/src/commands/profile/switch.ts @@ -0,0 +1,31 @@ +import { getCliContext, isJsonOutput } from "@/context.ts"; +import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; +import { setActiveProfile } from "@/lib/profile-store.ts"; +import { CliError } from "@/lib/errors.ts"; +import { promptProfileSwitch, requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileSwitchCommand = defineCommand({ + meta: { 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."); + } + profileName = await promptProfileSwitch(); + } + setActiveProfile(profileName); + await writeCommandOutput( + { + kind: "ack", + data: { active_profile: profileName }, + metadataMessage: `Active profile set to ${profileName}.`, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/profile/use.ts b/cli/src/commands/profile/use.ts new file mode 100644 index 0000000..80d1d06 --- /dev/null +++ b/cli/src/commands/profile/use.ts @@ -0,0 +1,23 @@ +import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; +import { setActiveProfile } from "@/lib/profile-store.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; + +export const profileUseCommand = defineCommand({ + meta: { name: "use", description: "Set the active profile" }, + args: { name: { type: "positional", description: "Profile name", required: true } }, + async run({ args, sink }) { + const profileName = requireProfileName(args.name); + assertNoEnvConfigMode(); + setActiveProfile(profileName); + await writeCommandOutput( + { + kind: "ack", + data: { active_profile: profileName }, + metadataMessage: `Active profile set to ${profileName}.`, + }, + sink, + ); + }, +}); diff --git a/cli/src/commands/query/cancel.ts b/cli/src/commands/query/cancel.ts new file mode 100644 index 0000000..589256f --- /dev/null +++ b/cli/src/commands/query/cancel.ts @@ -0,0 +1,26 @@ +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { buildLakehouseQueryCancelRequest } from "@/lib/lakehouse/query.ts"; +import { sendHttp } from "@/lib/http-request.ts"; + +export const queryCancelCommand = defineCommand({ + meta: { + 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 }, + }, + async run({ args, execution, sink }) { + const response = await sendHttp( + buildLakehouseQueryCancelRequest({ + queryId: stringArg(args, "query-id"), + sessionId: stringArg(args, "session-id"), + }), + execution, + ); + 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 new file mode 100644 index 0000000..423f0e0 --- /dev/null +++ b/cli/src/commands/query/index.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { normalizeQueryInvocatorRawArgs } from "@/commands/query/index.ts"; +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { + createLakehouseTestWorkspace, + type LakehouseTestWorkspace, +} from "@/test-utils/lakehouse.ts"; + +const QUERY_RESPONSE = ['{"statement":"SELECT 1"}', '["id"]', "[1]"].join("\n"); +let workspace: LakehouseTestWorkspace; + +beforeEach(() => { + workspace = createLakehouseTestWorkspace("query-command"); +}); + +afterEach(() => workspace.cleanup()); + +describe("query command", () => { + test("routes a bare SQL statement to the run command", () => { + expect(normalizeQueryInvocatorRawArgs(["query", "SELECT 1"])).toEqual([ + "query", + "run", + "SELECT 1", + ]); + expect(normalizeQueryInvocatorRawArgs(["query", "show", "query-id"])).toEqual([ + "query", + "show", + "query-id", + ]); + }); + + test("sends the statement and optional identifiers", async () => { + workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]); + + await runCommandWithTestRuntime( + normalizeQueryInvocatorRawArgs([ + "query", + "SELECT 1", + "--format", + "json", + "--query-id", + "query-1", + "--session-id", + "session-1", + ]), + ); + + expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({ + statement: "SELECT 1", + query_id: "query-1", + session_id: "session-1", + }); + }); + + 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}' }, + ]); + + 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`, + ); + }); + + 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}' }, + ]); + + await runCommandWithTestRuntime(["query", "cancel", queryId, "--session-id", "session-1"]); + + expect(workspace.readHttpLog()).toContain( + `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 new file mode 100644 index 0000000..7568ab2 --- /dev/null +++ b/cli/src/commands/query/index.ts @@ -0,0 +1,43 @@ +import type { CommandArgs } from "@/lib/command.ts"; +import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { queryRunArgs } from "@/commands/query/lib/args.ts"; +import { queryRunCommand } from "@/commands/query/run.ts"; +import { queryShowCommand } from "@/commands/query/show.ts"; +import { queryCancelCommand } from "@/commands/query/cancel.ts"; + +export const queryCommand = defineCommand({ + meta: { + name: "query", + commandGroup: "query", + description: "Run SQL queries against the lakehouse.", + examples: [ + 'altertable query "SELECT * FROM users LIMIT 10"', + 'altertable query "SELECT 1" --format json', + "altertable query show ", + ], + }, + default: "run", + args: queryRunArgs, + subCommands: { + run: queryRunCommand, + show: queryShowCommand, + cancel: queryCancelCommand, + }, +}); + +const QUERY_SUBCOMMAND_NAMES = new Set(Object.keys(queryCommand.subCommands ?? {})); +const QUERY_VALUE_FLAGS = valueFlagsFor(queryRunArgs); + +export function normalizeQueryInvocatorRawArgs( + rawArgs: readonly string[], + rootArgs: CommandArgs = {}, +): string[] { + return normalizeDefaultSubCommandRawArgs(rawArgs, { + commandName: "query", + subCommand: "run", + rootArgs, + commandValueFlags: QUERY_VALUE_FLAGS, + isReservedOperand: (value) => QUERY_SUBCOMMAND_NAMES.has(value), + }); +} diff --git a/cli/src/commands/query/lib/args.ts b/cli/src/commands/query/lib/args.ts new file mode 100644 index 0000000..948903e --- /dev/null +++ b/cli/src/commands/query/lib/args.ts @@ -0,0 +1,17 @@ +import { defineArgs } from "@/lib/command.ts"; +import { + queryDisplayArgs, + queryPagerArgs, + queryResultFormatArgs, +} from "@/lib/query-output-args.ts"; +import { requestReadTimeoutArgs } from "@/lib/timeout-args.ts"; + +export const queryRunArgs = defineArgs({ + statement: { type: "positional", description: "SQL statement to run", required: false }, + ...queryResultFormatArgs, + ...queryDisplayArgs, + "query-id": { type: "string", description: "Optional stable query id" }, + "session-id": { type: "string", description: "Optional session id" }, + ...queryPagerArgs, + ...requestReadTimeoutArgs, +}); diff --git a/cli/src/commands/query/run.ts b/cli/src/commands/query/run.ts new file mode 100644 index 0000000..44905ef --- /dev/null +++ b/cli/src/commands/query/run.ts @@ -0,0 +1,39 @@ +import { CliError } from "@/lib/errors.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeQueryOutput } from "@/lib/query-output.ts"; +import { queryRunArgs } from "@/commands/query/lib/args.ts"; +import { parseQueryOutputOptions } from "@/lib/query-output-args.ts"; +import { parseRequestReadTimeoutMs } from "@/lib/timeout-args.ts"; +import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; + +export const queryRunCommand = defineCommand({ + meta: { + name: "run", + description: "Run a SQL statement.", + examples: [ + 'altertable query "SELECT * FROM users LIMIT 10"', + 'altertable query "SELECT 1" --format json', + ], + }, + args: queryRunArgs, + async run({ args, rawArgs, execution, sink }) { + const statement = optionalStringArg(args, "statement"); + if (statement === undefined) { + throw new CliError('Provide a SQL statement, e.g. altertable query "SELECT 1".'); + } + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, { + agent: execution.cli.agent, + rawArgs, + }); + const readTimeoutMs = parseRequestReadTimeoutMs(args); + const input = { + statement, + queryId: optionalStringArg(args, "query-id"), + sessionId: optionalStringArg(args, "session-id"), + httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, + }; + const result = await executeLakehouseQuery(input, execution, format !== "json" && !sink.json); + await writeQueryOutput(result, format, sink, displayOptions, pagerOptions); + }, +}); diff --git a/cli/src/commands/query/show.ts b/cli/src/commands/query/show.ts new file mode 100644 index 0000000..6b6a830 --- /dev/null +++ b/cli/src/commands/query/show.ts @@ -0,0 +1,22 @@ +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { buildLakehouseQueryShowRequest } from "@/lib/lakehouse/query.ts"; +import { sendHttp } from "@/lib/http-request.ts"; + +export const queryShowCommand = defineCommand({ + meta: { + 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 }, + }, + async run({ args, execution, sink }) { + const response = await sendHttp( + buildLakehouseQueryShowRequest(stringArg(args, "query-id")), + execution, + ); + 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 new file mode 100644 index 0000000..f970585 --- /dev/null +++ b/cli/src/commands/schema/index.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; + +const SCHEMA_COLUMNS = [ + "schema_name", + "table_name", + "table_comment", + "column_name", + "data_type", + "is_nullable", + "table_type", + "comment", + "ordinal_position", +]; + +let testHome = ""; +let mockFile = ""; +let logFile = ""; + +beforeEach(() => { + 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"; +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_MOCK_HTTP_FILE; + delete process.env.ALTERTABLE_HTTP_LOG; + delete process.env.ALTERTABLE_API_BASE; + delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; + delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; +}); + +function writeSchemaResponse(rows: unknown[][]): void { + writeFileSync( + mockFile, + JSON.stringify([ + { + 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 () => { + writeSchemaResponse([]); + + await runCommandWithTestRuntime(["schema", "o'brien", "--format", "json"]); + + 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()"); + }); + + 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], + ]); + + const result = await runCommandWithTestRuntime(["schema", "analytics"], { + debug: false, + json: false, + agent: false, + }); + + 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"), + ); + }); + + 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"], + ]) { + try { + 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/lakehouse/schema.ts b/cli/src/commands/schema/index.ts similarity index 56% rename from cli/src/commands/lakehouse/schema.ts rename to cli/src/commands/schema/index.ts index fb8865d..2578e41 100644 --- a/cli/src/commands/lakehouse/schema.ts +++ b/cli/src/commands/schema/index.ts @@ -1,18 +1,51 @@ -import type { ArgsDef } from "citty"; -import { stringArg } from "@/lib/operation-codec.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/commands/lakehouse-args.ts"; -import { - planQueryRun, - presentQueryRun, - queryRunArgs, - type QueryRunInput, -} from "@/commands/lakehouse/query.ts"; +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { parseQueryOutputOptions } from "@/lib/query-output-args.ts"; +import { parseRequestReadTimeoutMs } from "@/lib/timeout-args.ts"; import { writePagedOutput } from "@/lib/pager.ts"; -import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; -import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; +import { writeQueryOutput } from "@/lib/query-output.ts"; +import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; +import { buildSchemaTreeView } from "@/commands/schema/lib/views.ts"; +import { schemaArgs } from "@/commands/schema/lib/args.ts"; +import { renderTreeText } from "@/ui/renderers/terminal.ts"; -export function buildSchemaStatement(catalog: string): string { +export const schemaCommand = defineCommand({ + meta: { + name: "schema", + commandGroup: "query", + description: "List schemas, tables, and columns for a catalog.", + examples: ["altertable schema my-catalog", "altertable schema my-catalog --format json"], + }, + args: schemaArgs, + async run({ args, rawArgs, execution, sink }) { + const catalog = stringArg(args, "catalog"); + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, { + agent: execution.cli.agent, + rawArgs, + }); + const readTimeoutMs = parseRequestReadTimeoutMs(args); + const queryInput = { + statement: buildSchemaStatement(catalog), + httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, + }; + const result = await executeLakehouseQuery( + queryInput, + execution, + format !== "json" && !sink.json, + ); + if (format !== "human" || sink.json) { + await writeQueryOutput(result, format, sink, displayOptions, pagerOptions); + return; + } + await writePagedOutput( + renderTreeText(buildSchemaTreeView(result, catalog)), + pagerOptions, + sink, + ); + }, +}); + +function buildSchemaStatement(catalog: string): string { const catSql = `'${catalog.replaceAll("'", "''")}'`; return `SELECT schema_name, table_name, table_comment, column_name, data_type, is_nullable, table_type, comment, ordinal_position FROM ( @@ -52,60 +85,3 @@ FROM ( ) ORDER BY table_name ASC NULLS FIRST, ordinal_position ASC`; } - -const schemaArgs = { - catalog: { type: "positional", description: "Catalog name", required: true }, - format: queryRunArgs.format, - columns: queryRunArgs.columns, - "max-width": queryRunArgs["max-width"], - pager: queryRunArgs.pager, - "read-timeout": queryRunArgs["read-timeout"], -} satisfies ArgsDef; - -type SchemaRunInput = QueryRunInput & { catalog: string }; - -export const schemaCommand = defineOperationCommand({ - id: "lakehouse.schema", - capabilities: ["lakehouse-http", "streaming"], - catalog: { - effects: ["http", "http-stream"], - planes: ["lakehouse"], - output: "normalized", - }, - meta: { - name: "schema", - commandGroup: "query", - description: "List schemas, tables, and columns for a catalog.", - examples: ["altertable schema my-catalog", "altertable schema my-catalog --format json"], - }, - args: schemaArgs, - parse({ args, rawArgs }) { - const catalog = stringArg(args, "catalog"); - // --layout is not supported: human output is always the schema tree. - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions( - { ...args, layout: undefined }, - rawArgs, - ); - const readTimeoutMs = parseRequestReadTimeoutMs(args); - return { - catalog, - statement: buildSchemaStatement(catalog), - format, - displayOptions, - pagerOptions, - httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, - }; - }, - run: planQueryRun, - async present(result, context, input) { - if (input.format !== "human" || context.sink.json) { - await presentQueryRun(result, context, input); - return; - } - await writePagedOutput( - formatSchemaTree(result, input.catalog), - input.pagerOptions, - context.sink, - ); - }, -}); diff --git a/cli/src/commands/schema/lib/args.ts b/cli/src/commands/schema/lib/args.ts new file mode 100644 index 0000000..c3f2c49 --- /dev/null +++ b/cli/src/commands/schema/lib/args.ts @@ -0,0 +1,10 @@ +import { defineArgs } from "@/lib/command.ts"; +import { queryPagerArgs, queryResultFormatArgs } from "@/lib/query-output-args.ts"; +import { requestReadTimeoutArgs } from "@/lib/timeout-args.ts"; + +export const schemaArgs = defineArgs({ + catalog: { type: "positional", description: "Catalog name", required: true }, + ...queryResultFormatArgs, + ...queryPagerArgs, + ...requestReadTimeoutArgs, +}); diff --git a/cli/src/features/lakehouse/schema/views.ts b/cli/src/commands/schema/lib/views.ts similarity index 98% rename from cli/src/features/lakehouse/schema/views.ts rename to cli/src/commands/schema/lib/views.ts index 738a38f..696e2ad 100644 --- a/cli/src/features/lakehouse/schema/views.ts +++ b/cli/src/commands/schema/lib/views.ts @@ -1,4 +1,4 @@ -import { getQueryColumnNames } from "@/lib/lakehouse-client.ts"; +import { getQueryColumnNames } from "@/lib/query-format.ts"; import type { LakehouseQueryResult, LakehouseRow } from "@/lib/lakehouse-ndjson.ts"; import type { TreeNode, TreeView } from "@/ui/layouts/tree.ts"; import { span, type DisplaySpan } from "@/ui/document.ts"; diff --git a/cli/src/commands/update.ts b/cli/src/commands/update/index.ts similarity index 97% rename from cli/src/commands/update.ts rename to cli/src/commands/update/index.ts index 6df9443..74b9a8d 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update/index.ts @@ -1,7 +1,7 @@ import { VERSION } from "@/version.ts"; import { asCliArgString } from "@/lib/cli-args.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; -import { defineAltertableCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { CliError } from "@/lib/errors.ts"; import { GLOBAL_ARGV_FLAGS_WITH_VALUE, isGlobalArgvFlag } from "@/lib/global-flags.ts"; import type { OutputSink } from "@/lib/runtime.ts"; @@ -18,6 +18,35 @@ import { type UpdateCheckResult, } from "@/lib/updater.ts"; +export const updateCommand = defineCommand({ + meta: { + 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).", + required: false, + }, + check: { + type: "boolean", + description: "Check for an update without installing it.", + }, + force: { + type: "boolean", + description: "Reinstall or downgrade to the selected release.", + }, + }, + async run({ args, rawArgs, sink }) { + validateUpdateArguments(rawArgs); + await executeUpdateCommand(args as UpdateCommandArgs, sink); + }, +}); + export type UpdateCommandArgs = { version?: string; check?: boolean; @@ -141,32 +170,3 @@ export async function executeUpdateCommand( sink, ); } - -export const updateCommand = defineAltertableCommand({ - meta: { - 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).", - required: false, - }, - check: { - type: "boolean", - description: "Check for an update without installing it.", - }, - force: { - type: "boolean", - description: "Reinstall or downgrade to the selected release.", - }, - }, - async run({ args, rawArgs, sink }) { - validateUpdateArguments(rawArgs); - await executeUpdateCommand(args as UpdateCommandArgs, sink); - }, -}); diff --git a/cli/src/commands/upload/index.test.ts b/cli/src/commands/upload/index.test.ts new file mode 100644 index 0000000..f3de615 --- /dev/null +++ b/cli/src/commands/upload/index.test.ts @@ -0,0 +1,69 @@ +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"; + +let workspace: LakehouseTestWorkspace; + +beforeEach(() => { + 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}' }]); + + await runCommandWithTestRuntime([ + "upload", + "--catalog", + "memory", + "--schema", + "main", + "--table", + "users", + "--format", + "csv", + "--mode", + "overwrite", + "--file", + uploadFile, + ]); + + 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"); + }); + + test("rejects missing files and directories", async () => { + const directory = workspace.createDirectory("directory"); + const baseArgs = [ + "upload", + "--catalog", + "memory", + "--schema", + "main", + "--table", + "users", + "--mode", + "overwrite", + "--file", + ]; + + for (const file of [`${workspace.home}/missing.csv`, directory]) { + try { + await runCommandWithTestRuntime([...baseArgs, file]); + 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"); + } + } + }); +}); diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts new file mode 100644 index 0000000..b71dba6 --- /dev/null +++ b/cli/src/commands/upload/index.ts @@ -0,0 +1,44 @@ +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 } from "@/lib/lakehouse/args.ts"; +import { parseRequestReadTimeoutMs } from "@/lib/timeout-args.ts"; +import { createLakehouseUploadRequest } from "@/lib/lakehouse-transport.ts"; +import { getFileSizeBytes } from "@/lib/lakehouse/file.ts"; +import { uploadArgs, UPLOAD_MODE_OPTIONS } from "@/commands/upload/lib/args.ts"; + +export const uploadCommand = defineCommand({ + meta: { + name: "upload", + commandGroup: "ingest", + description: "Upload a file to create, append to, or overwrite a table.", + examples: [ + "altertable upload --catalog db --schema public --table users --mode overwrite --format csv --file users.csv", + ], + }, + args: uploadArgs, + async run({ args, execution, sink }) { + const mode = enumArg(args, "mode", UPLOAD_MODE_OPTIONS); + const filePath = stringArg(args, "file"); + const fileSizeBytes = getFileSizeBytes(filePath); + + const readTimeoutMs = parseRequestReadTimeoutMs(args); + const scope = createLakehouseUploadRequest({ + catalog: stringArg(args, "catalog"), + schema: stringArg(args, "schema"), + table: stringArg(args, "table"), + mode, + filePath, + fileSizeBytes, + contentType: parseLakehouseFileContentType(optionalStringArg(args, "format")), + httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, + }); + try { + const response = await sendHttp(scope.request, execution); + 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 new file mode 100644 index 0000000..0eb4de1 --- /dev/null +++ b/cli/src/commands/upload/lib/args.ts @@ -0,0 +1,14 @@ +import { defineArgs } from "@/lib/command.ts"; +import { lakehouseFileArgs } from "@/lib/lakehouse/args.ts"; + +export const UPLOAD_MODE_OPTIONS = ["create", "append", "overwrite"] as const; + +export const uploadArgs = defineArgs({ + ...lakehouseFileArgs, + mode: { + type: "enum", + description: "create, append, or overwrite", + required: true, + options: [...UPLOAD_MODE_OPTIONS], + }, +}); diff --git a/cli/src/commands/upsert/index.test.ts b/cli/src/commands/upsert/index.test.ts new file mode 100644 index 0000000..2803b4f --- /dev/null +++ b/cli/src/commands/upsert/index.test.ts @@ -0,0 +1,43 @@ +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"; + +let workspace: LakehouseTestWorkspace; + +beforeEach(() => { + 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}' }]); + + await runCommandWithTestRuntime([ + "upsert", + "--catalog", + "memory", + "--schema", + "main", + "--table", + "users", + "--primary-key", + "id", + "--format", + "csv", + "--file", + uploadFile, + ]); + + 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).toMatch(/PAYLOAD=@(?:blob|stream)/); + }); +}); diff --git a/cli/src/commands/upsert/index.ts b/cli/src/commands/upsert/index.ts new file mode 100644 index 0000000..6ac0297 --- /dev/null +++ b/cli/src/commands/upsert/index.ts @@ -0,0 +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 } from "@/lib/lakehouse/args.ts"; +import { parseRequestReadTimeoutMs } from "@/lib/timeout-args.ts"; +import { getFileSizeBytes } from "@/lib/lakehouse/file.ts"; +import { createLakehouseUpsertRequest } from "@/lib/lakehouse-transport.ts"; +import { upsertArgs } from "@/commands/upsert/lib/args.ts"; + +export const upsertCommand = defineCommand({ + meta: { + name: "upsert", + commandGroup: "ingest", + description: "Upload a file and match existing rows by primary key.", + examples: [ + "altertable upsert --catalog db --schema public --table users --primary-key id --format csv --file users.csv", + ], + }, + args: upsertArgs, + async run({ args, execution, sink }) { + const filePath = stringArg(args, "file"); + const fileSizeBytes = getFileSizeBytes(filePath); + + const readTimeoutMs = parseRequestReadTimeoutMs(args); + const scope = createLakehouseUpsertRequest({ + catalog: stringArg(args, "catalog"), + schema: stringArg(args, "schema"), + table: stringArg(args, "table"), + primaryKey: stringArg(args, "primary-key"), + filePath, + fileSizeBytes, + contentType: parseLakehouseFileContentType(optionalStringArg(args, "format")), + httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, + }); + try { + const response = await sendHttp(scope.request, execution); + 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 new file mode 100644 index 0000000..49762ca --- /dev/null +++ b/cli/src/commands/upsert/lib/args.ts @@ -0,0 +1,11 @@ +import { defineArgs } from "@/lib/command.ts"; +import { lakehouseFileArgs } from "@/lib/lakehouse/args.ts"; + +export const upsertArgs = defineArgs({ + ...lakehouseFileArgs, + "primary-key": { + 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 b4f5796..f6319a3 100644 --- a/cli/src/context.ts +++ b/cli/src/context.ts @@ -1,30 +1,20 @@ 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"; -export type CliContext = { - debug: boolean; - json: boolean; - agent: boolean; - noColor?: boolean; - profile?: string; - connectTimeoutMs?: number; - readTimeoutMs?: number; -}; - -const PRE_RUNTIME_DEFAULT_CONTEXT: CliContext = { debug: false, json: false, agent: false }; +export type { CliContext } from "@/lib/cli-context.ts"; export function isJsonOutput(context: CliContext = getCliContext()): boolean { - return context.json || context.agent; -} - -export function isAgentMode(context: CliContext = getCliContext()): boolean { - return context.agent; + return contextUsesJsonOutput(context); } -let preRuntimeContext: CliContext = PRE_RUNTIME_DEFAULT_CONTEXT; - export function setCliContext(context: CliContext): void { - preRuntimeContext = context; + setBootstrapCliContext(context); if (isCliRuntimeReady()) { refreshCliRuntimeContext(context); } @@ -38,7 +28,7 @@ export function getBootstrapCliContext(): CliContext { if (isCliRuntimeReady()) { return getCliRuntime().context; } - return preRuntimeContext; + return getBootstrapCliContextState(); } export function getConnectTimeoutMs(): number { diff --git a/cli/src/features/lakehouse/schema/render.ts b/cli/src/features/lakehouse/schema/render.ts deleted file mode 100644 index 1d11814..0000000 --- a/cli/src/features/lakehouse/schema/render.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildSchemaTreeView } from "@/features/lakehouse/schema/views.ts"; -import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; -import { renderTreeText } from "@/ui/renderers/terminal.ts"; - -export function formatSchemaTree(result: LakehouseQueryResult, catalog: string): string { - return renderTreeText(buildSchemaTreeView(result, catalog)); -} diff --git a/cli/src/lib/operation-codec.ts b/cli/src/lib/args.ts similarity index 100% rename from cli/src/lib/operation-codec.ts rename to cli/src/lib/args.ts diff --git a/cli/tests/auth.test.ts b/cli/src/lib/auth.test.ts similarity index 97% rename from cli/tests/auth.test.ts rename to cli/src/lib/auth.test.ts index 910a9e1..823b559 100644 --- a/cli/tests/auth.test.ts +++ b/cli/src/lib/auth.test.ts @@ -9,7 +9,7 @@ import { } from "@/lib/auth.ts"; import { configSet } from "@/lib/config.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -import { resetSecretWarningsForTests, secretSet } from "@/lib/secrets.ts"; +import { secretSet } from "@/lib/secrets.ts"; let testHome = ""; const profileName = "default"; @@ -18,7 +18,6 @@ beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-auth-test-")); process.env.ALTERTABLE_CONFIG_HOME = testHome; process.env.ALTERTABLE_SECRET_BACKEND = "file"; - resetSecretWarningsForTests(); }); afterEach(() => { diff --git a/cli/src/lib/catalog-rows.ts b/cli/src/lib/catalog-rows.ts deleted file mode 100644 index 107ee10..0000000 --- a/cli/src/lib/catalog-rows.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; -import { formatCatalogsTable } from "@/features/management/render.ts"; - -type DatabaseSummary = { - name?: string; - slug?: string; - engine?: string; - catalog?: string; -}; - -type ConnectionSummary = { - name?: string; - slug?: string; - engine?: string; - catalog?: string; -}; - -export function buildCatalogRowsFromResponses( - databasesResponse: string, - connectionsResponse: string, -): CatalogRow[] { - const databases = parseApiJson(databasesResponse) as { databases?: DatabaseSummary[] }; - const connections = parseApiJson(connectionsResponse) as { connections?: ConnectionSummary[] }; - - const rows: CatalogRow[] = []; - for (const database of databases.databases ?? []) { - rows.push({ - type: "database", - name: database.name ?? "", - slug: database.slug ?? "", - engine: "altertable", - catalog: database.catalog ?? "", - }); - } - for (const connection of connections.connections ?? []) { - rows.push({ - type: "connection", - name: connection.name ?? "", - slug: connection.slug ?? "", - engine: connection.engine ?? "", - catalog: connection.catalog ?? "", - }); - } - return rows; -} - -export { formatCatalogsTable }; diff --git a/cli/src/lib/cli-context.ts b/cli/src/lib/cli-context.ts new file mode 100644 index 0000000..6809c7e --- /dev/null +++ b/cli/src/lib/cli-context.ts @@ -0,0 +1,23 @@ +export type CliContext = { + debug: boolean; + json: boolean; + agent: boolean; + noColor?: boolean; + profile?: string; + connectTimeoutMs?: number; + readTimeoutMs?: number; +}; + +let bootstrapContext: CliContext = { debug: false, json: false, agent: false }; + +export function isJsonOutput(context: CliContext): boolean { + return context.json || context.agent; +} + +export function setBootstrapCliContext(context: CliContext): void { + bootstrapContext = context; +} + +export function getBootstrapCliContextState(): CliContext { + return bootstrapContext; +} diff --git a/cli/tests/cli-session.test.ts b/cli/src/lib/cli-session.test.ts similarity index 92% rename from cli/tests/cli-session.test.ts rename to cli/src/lib/cli-session.test.ts index 888daa2..a3dfd73 100644 --- a/cli/tests/cli-session.test.ts +++ b/cli/src/lib/cli-session.test.ts @@ -5,7 +5,8 @@ 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, runWithCliRuntime } from "@/lib/runtime.ts"; +import { createCliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; let testHome = ""; diff --git a/cli/tests/command-delegation.test.ts b/cli/src/lib/command-delegation.test.ts similarity index 94% rename from cli/tests/command-delegation.test.ts rename to cli/src/lib/command-delegation.test.ts index 26b506a..33a6442 100644 --- a/cli/tests/command-delegation.test.ts +++ b/cli/src/lib/command-delegation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ArgsDef } from "citty"; +import { defineArgs } from "@/lib/command.ts"; import { findFirstPositionalToken, isDelegatedSubCommand, @@ -7,16 +7,16 @@ import { valueFlagsFor, } from "@/lib/command-delegation.ts"; -const rootArgs = { +const rootArgs = defineArgs({ profile: { type: "string", description: "Use a named profile" }, json: { type: "boolean", description: "JSON output" }, -} satisfies ArgsDef; +}); -const passthroughArgs = { +const passthroughArgs = defineArgs({ method: { type: "enum", alias: "X", options: ["GET", "POST"] }, field: { type: "string", alias: "f" }, verbose: { type: "boolean" }, -} satisfies ArgsDef; +}); describe("command delegation helpers", () => { test("valueFlagsFor extracts string and enum flags with aliases", () => { diff --git a/cli/src/lib/command-delegation.ts b/cli/src/lib/command-delegation.ts index afde0ea..ff752a1 100644 --- a/cli/src/lib/command-delegation.ts +++ b/cli/src/lib/command-delegation.ts @@ -1,4 +1,4 @@ -import type { ArgsDef } from "citty"; +import type { CommandArgs } from "@/lib/command.ts"; export type PositionalScanOptions = { valueFlags?: ReadonlySet; @@ -11,12 +11,12 @@ export type PositionalToken = { export type PassthroughCommandOptions = { commandName: string; - rootArgs?: ArgsDef; + rootArgs?: CommandArgs; commandValueFlags?: ReadonlySet; isReservedOperand: (value: string) => boolean; }; -export function valueFlagsFor(args: ArgsDef): ReadonlySet { +export function valueFlagsFor(args: CommandArgs): ReadonlySet { const flags = new Set(); for (const [name, definition] of Object.entries(args)) { if (definition.type !== "string" && definition.type !== "enum") { @@ -73,7 +73,7 @@ export function isDelegatedSubCommand( export type DefaultSubCommandOptions = { commandName: string; subCommand: string; - rootArgs?: ArgsDef; + rootArgs?: CommandArgs; commandValueFlags?: ReadonlySet; isReservedOperand: (value: string) => boolean; }; diff --git a/cli/src/lib/command-output.test.ts b/cli/src/lib/command-output.test.ts new file mode 100644 index 0000000..a46437b --- /dev/null +++ b/cli/src/lib/command-output.test.ts @@ -0,0 +1,118 @@ +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[]; + stderr: string[]; + sink: OutputSink; +} { + 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); + return { stdout, stderr, sink }; +} + +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); + expect(stdout).toEqual(['{"ok":true}']); + }); + + test("normalized emits envelope in json mode", async () => { + const { stdout, sink } = captureOutput(true); + await writeCommandOutput( + { + kind: "normalized", + data: { catalogs: [{ name: "db" }] }, + humanText: "table output", + }, + sink, + ); + expect(stdout).toEqual([JSON.stringify({ catalogs: [{ name: "db" }] }, null, 2)]); + }); + + 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)]); + }); + + 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)]); + }); + + 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"); + }); + + 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"], + }, + sink, + ); + expect(stdout).toEqual(["table output"]); + expect(stderr).toEqual(["", "0 catalogs"]); + }); + + test("normalized can opt human output into pager handling", async () => { + const { stdout, sink } = captureOutput(false); + await writeCommandOutput( + { + kind: "normalized", + data: { routes: [] }, + humanText: "wide route table", + pageHumanText: true, + }, + sink, + ); + expect(stdout).toEqual(["wide route table"]); + }); + + 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.", + }, + sink, + ); + expect(stdout).toEqual([JSON.stringify({ active_profile: "staging" }, null, 2)]); + }); + + test("ack writes metadata in human mode", async () => { + const previousNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = "1"; + const { stderr, sink } = captureOutput(false); + await writeCommandOutput( + { + kind: "ack", + data: { active_profile: "staging" }, + metadataMessage: "Active profile set to staging.", + }, + sink, + ); + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + 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 c66d274..5c5ef4c 100644 --- a/cli/src/lib/command-output.ts +++ b/cli/src/lib/command-output.ts @@ -1,11 +1,8 @@ -import { - parseManagementOutputFormat, - type ManagementOutputFormat, -} from "@/lib/lakehouse-client.ts"; +import type { ManagementOutputFormat } from "@/lib/tabular-result.ts"; import { renderManagementOutput } from "@/lib/management-output.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { resolvePagerOptions, writePagedOutput } from "@/lib/pager.ts"; -import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; import { span } from "@/ui/document.ts"; import { renderDisplayText } from "@/ui/terminal/styles.ts"; @@ -28,10 +25,7 @@ export type CommandOutputMode = | { kind: "deleted"; message: string; id?: string } | { kind: "ack"; data: Record; metadataMessage: string }; -export async function writeCommandOutput( - mode: CommandOutputMode, - sink: OutputSink = getOutputSink(), -): Promise { +export async function writeCommandOutput(mode: CommandOutputMode, sink: OutputSink): Promise { const json = sink.json; if (mode.kind === "deleted") { @@ -112,70 +106,3 @@ export async function writeCommandOutput( return; } } - -export async function writeManagementOutput( - body: string, - options?: { - format?: string; - humanFormatter?: (data: unknown) => string; - }, - sink: OutputSink = getOutputSink(), -): Promise { - const formatArg = options?.format; - const parsedFormat = - formatArg !== undefined && String(formatArg).length > 0 - ? parseManagementOutputFormat(String(formatArg)) - : undefined; - - await writeCommandOutput( - { - kind: "tabular", - body, - format: parsedFormat, - humanFormatter: options?.humanFormatter, - }, - sink, - ); -} - -export async function writeJsonOrRaw( - body: string, - formatter?: (data: unknown) => string, - sink: OutputSink = getOutputSink(), -): Promise { - await writeCommandOutput( - { - kind: "tabular", - body, - humanFormatter: formatter, - }, - sink, - ); -} - -export type LakehouseOutputOptions = { - humanFormatter?: (data: unknown) => string; - sink?: OutputSink; -}; - -export async function writeLakehouseCommandOutput( - body: string, - options?: LakehouseOutputOptions, -): Promise { - await writeCommandOutput( - { - kind: "raw_api", - body, - humanFormatter: options?.humanFormatter, - }, - options?.sink ?? getOutputSink(), - ); -} - -export async function writeDeleteSuccess( - message: string, - id?: string, - sink: OutputSink = getOutputSink(), -): Promise { - await writeCommandOutput({ kind: "deleted", message, id }, sink); -} diff --git a/cli/src/lib/command.test.ts b/cli/src/lib/command.test.ts new file mode 100644 index 0000000..ad0fad1 --- /dev/null +++ b/cli/src/lib/command.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { defineCommand, defineRootCommand, runCommandTree, type Command } 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 = { meta: { name: "leaf" } }; + + expect(defineCommand(definition)).toBe(definition); + }); + + test("defineRootCommand binds the assembled tree once", async () => { + const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + let receivedRuntime: unknown; + let executionIsStable = false; + const leaf = defineCommand({ + meta: { name: "leaf" }, + run(context) { + receivedRuntime = context.runtime; + executionIsStable = context.execution === context.execution; + }, + }); + const definition = { meta: { name: "root" }, subCommands: { leaf } }; + + const root = defineRootCommand(definition); + + expect(root).not.toBe(definition); + const subCommands = root.subCommands as Record | undefined; + expect(subCommands?.leaf).not.toBe(leaf); + await runWithCliRuntime(runtime, () => runCommandTree(root, { rawArgs: ["leaf"] })); + expect(receivedRuntime).toBe(runtime); + expect(executionIsStable).toBe(true); + }); +}); diff --git a/cli/src/lib/command-context.ts b/cli/src/lib/command.ts similarity index 76% rename from cli/src/lib/command-context.ts rename to cli/src/lib/command.ts index 73a0b3c..13feed2 100644 --- a/cli/src/lib/command-context.ts +++ b/cli/src/lib/command.ts @@ -1,6 +1,12 @@ import type { ArgsDef, CommandContext, CommandDef, CommandMeta, Resolvable } from "citty"; +export { runCommand as runCommandTree } from "citty"; import type { CliRuntime, OutputSink } from "@/lib/runtime.ts"; import { getCliRuntime } from "@/lib/runtime.ts"; +import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; + +export type Command = CommandDef; +export type CommandArg = import("citty").ArgDef; +export type CommandArgs = ArgsDef; export type AltertableCommandGroup = "platform" | "ingest" | "query"; @@ -13,6 +19,7 @@ export type AltertableCommandMeta = CommandMeta & { export type CommandRunContext = CommandContext & { runtime: CliRuntime; sink: OutputSink; + readonly execution: ExecutionContext; }; export type AltertableCommandDef = Omit< @@ -28,9 +35,22 @@ export type AltertableCommandDef = Omit< export type CommandTreeDef = AltertableCommandDef | CommandDef; +export function defineArgs(args: T): T { + return args; +} + function withCommandRuntime(context: CommandContext): CommandRunContext { const runtime = getCliRuntime(); - return { ...context, runtime, sink: runtime.output }; + let execution: ExecutionContext | undefined; + return { + ...context, + runtime, + sink: runtime.output, + get execution() { + execution ??= createExecutionContext(runtime); + return execution; + }, + }; } function bindCommandTree(def: CommandTreeDef): CommandDef { @@ -57,10 +77,10 @@ function bindCommandTree(def: CommandTreeDef): CommandDef return bound; } -export function defineAltertableCommand( +export function defineCommand( def: AltertableCommandDef, ): CommandDef { - return bindCommandTree(def); + return def as CommandDef; } /** Erases citty's inferred args generic so the root command fits heterogeneous trees. */ diff --git a/cli/tests/compiled-executable-path.test.ts b/cli/src/lib/compiled-executable-path.test.ts similarity index 97% rename from cli/tests/compiled-executable-path.test.ts rename to cli/src/lib/compiled-executable-path.test.ts index d662e38..06f73f0 100644 --- a/cli/tests/compiled-executable-path.test.ts +++ b/cli/src/lib/compiled-executable-path.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node: import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -const cliRoot = resolve(import.meta.dir, ".."); +const cliRoot = resolve(import.meta.dir, "../.."); const fixture = join(import.meta.dir, "fixtures", "compiled-executable-path.ts"); let testDirectory = ""; let executable = ""; diff --git a/cli/src/lib/config-files.ts b/cli/src/lib/config-files.ts index 9f227cc..9285bfa 100644 --- a/cli/src/lib/config-files.ts +++ b/cli/src/lib/config-files.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { readEnv } from "@/lib/env.ts"; @@ -123,11 +123,3 @@ export function kvUnset(filePath: string, key: string): void { // file does not exist } } - -export function chmodConfigFile(filePath: string): void { - try { - chmodSync(filePath, 0o600); - } catch { - // best effort - } -} diff --git a/cli/src/lib/config.test.ts b/cli/src/lib/config.test.ts new file mode 100644 index 0000000..e38ede9 --- /dev/null +++ b/cli/src/lib/config.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + configDir, + configFile, + configGet, + configSet, + getQueryDefaultLayout, + getQueryDefaultMaxColumnWidth, + getQueryDefaultPager, + kvGet, + kvSet, + resolveApiBase, + resolveManagementApiBase, +} from "@/lib/config.ts"; +import { profileConfigFile } from "@/lib/profile-store.ts"; + +let testHome = ""; +const profileName = "default"; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-config-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_API_BASE; + delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; + delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; +}); + +describe("config", () => { + test("stores key-value config in protected files", () => { + const filePath = join(testHome, "sample"); + + kvSet(filePath, "user", "alice"); + kvSet(filePath, "user", "bob"); + + expect(kvGet(filePath, "user")).toBe("bob"); + expect(statSync(filePath).mode & 0o777).toBe(0o600); + expect(configGet("missing", profileName)).toBe(""); + }); + + 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"); + + 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"); + expect(getQueryDefaultMaxColumnWidth()).toBe(48); + expect(getQueryDefaultLayout()).toBe("line"); + expect(getQueryDefaultPager()).toBe("always"); + + 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"); + + expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); + expect(kvGet(profileConfigFile("staging"), "query_layout")).toBe(""); + }); + + test("uses ALTERTABLE_CONFIG_HOME for profile config", () => { + expect(configDir()).toBe(testHome); + configSet("user", "x", profileName); + + const configFile = join(testHome, "profiles", "default", "config"); + expect(existsSync(configFile)).toBe(true); + expect(readFileSync(configFile, "utf8")).toContain("user=x"); + }); +}); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index e6d930b..ec52eda 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,6 +1,5 @@ import { configGet, configSet } from "@/lib/profile-store.ts"; import { - chmodConfigFile, configDir, configFile, credentialsFile, @@ -19,11 +18,6 @@ export function configGetGlobal(key: string): string { return kvGet(configFile(), key); } -export function configSetGlobal(key: string, value: string): void { - kvSet(configFile(), key, value); - chmodConfigFile(configFile()); -} - function resolveAllowInsecureHttp(): boolean { return readEnv("ALTERTABLE_ALLOW_INSECURE_HTTP") ?? false; } @@ -41,7 +35,7 @@ export function resolveApiBase(profileName: string): string { return normalized; } -function resolveManagementApiRoot(profileName: string): string { +export function resolveManagementApiRoot(profileName: string): string { let root = readEnv("ALTERTABLE_MANAGEMENT_API_BASE") ?? ""; if (!root) { root = configGet("management_api_base", profileName); diff --git a/cli/tests/early-bootstrap.test.ts b/cli/src/lib/early-bootstrap.test.ts similarity index 100% rename from cli/tests/early-bootstrap.test.ts rename to cli/src/lib/early-bootstrap.test.ts diff --git a/cli/src/lib/encode.ts b/cli/src/lib/encode.ts deleted file mode 100644 index f048e77..0000000 --- a/cli/src/lib/encode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function urlencode(value: string): string { - return encodeURIComponent(value); -} diff --git a/cli/tests/env.test.ts b/cli/src/lib/env.test.ts similarity index 94% rename from cli/tests/env.test.ts rename to cli/src/lib/env.test.ts index 33a12f2..205df40 100644 --- a/cli/tests/env.test.ts +++ b/cli/src/lib/env.test.ts @@ -95,12 +95,17 @@ describe("environment schema", () => { }); test("production modules access the environment only through env.ts", async () => { - const sourceRoot = join(import.meta.dir, "..", "src"); + 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)) { - if (relativePath === "lib/env.ts" || relativePath.startsWith("generated/")) { + if ( + relativePath === "lib/env.ts" || + relativePath.endsWith(".test.ts") || + relativePath.startsWith("generated/") || + relativePath.startsWith("test-utils/") + ) { continue; } const source = await readFile(join(sourceRoot, relativePath), "utf8"); diff --git a/cli/tests/errors.test.ts b/cli/src/lib/errors.test.ts similarity index 93% rename from cli/tests/errors.test.ts rename to cli/src/lib/errors.test.ts index 125b071..57dcafd 100644 --- a/cli/tests/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { createCliRuntime, runWithCliRuntime, writeRawIfJsonElseHuman } from "@/lib/runtime.ts"; import { CliError, ConfigurationError, @@ -17,18 +16,16 @@ import { errorCodeFromError, getCliExitCode, httpStatusMessage, - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, serializeCliError, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; +import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; import { forceNoTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; let terminalState: TerminalTestState; @@ -117,13 +114,6 @@ describe("transport error exit codes", () => { test("ParseError uses EXIT_VALIDATION", () => { expect(new ParseError("bad json").exitCode).toBe(EXIT_VALIDATION); }); - - test("writeRawIfJsonElseHuman throws ParseError on malformed JSON", () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - expect( - runWithCliRuntime(runtime, () => writeRawIfJsonElseHuman("not json", () => "")), - ).rejects.toThrow(ParseError); - }); }); describe("HttpError messages", () => { diff --git a/cli/src/lib/errors.ts b/cli/src/lib/errors.ts index e2c5aec..3c4bf01 100644 --- a/cli/src/lib/errors.ts +++ b/cli/src/lib/errors.ts @@ -15,9 +15,6 @@ * | 9 | EXIT_NETWORK | NetworkError, TimeoutError | * | 10 | EXIT_CONFIG | ConfigurationError | */ -import { span } from "@/ui/document.ts"; -import { renderDisplayText } from "@/ui/terminal/styles.ts"; - export const EXIT_SUCCESS = 0; export const EXIT_GENERIC = 1; export const EXIT_AUTH = 2; @@ -267,31 +264,6 @@ export function serializeCliError(error: unknown): CliErrorJson { }; } -export function renderCliErrorJson(error: unknown): string { - return JSON.stringify(serializeCliError(error)); -} - -export function renderCliError(error: unknown): string { - if (error instanceof CliError) { - return renderDisplayText([span("ERROR", "error"), span(` ${error.message}`)]); - } - if (error instanceof Error && error.name === "CLIError") { - return renderDisplayText([span("ERROR", "error"), span(` ${error.message}`)]); - } - return renderDisplayText([span("ERROR", "error"), span(" Unexpected error.")]); -} - -export function renderCliErrorDetails(details: string): string { - return details - .split(/\r\n|\r|\n/) - .map((line, index) => - index === 0 - ? renderDisplayText([span("ERROR", "error"), span(` ${line}`)]) - : renderDisplayText(line), - ) - .join("\n"); -} - export function getCliExitCode(error: unknown): number { if (error instanceof CliError) { return error.exitCode; diff --git a/cli/tests/fixtures/compiled-executable-path.ts b/cli/src/lib/fixtures/compiled-executable-path.ts similarity index 100% rename from cli/tests/fixtures/compiled-executable-path.ts rename to cli/src/lib/fixtures/compiled-executable-path.ts diff --git a/cli/tests/global-flags.test.ts b/cli/src/lib/global-flags.test.ts similarity index 93% rename from cli/tests/global-flags.test.ts rename to cli/src/lib/global-flags.test.ts index 3d8937c..8d0a224 100644 --- a/cli/tests/global-flags.test.ts +++ b/cli/src/lib/global-flags.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from "bun:test"; -import { isAgentMode, isJsonOutput, setCliContext } from "@/context.ts"; +import { isJsonOutput, setCliContext } from "@/context.ts"; import { parseGlobalFlags } from "@/lib/global-flags.ts"; import { createCliRuntime, getOutputSink, refreshCliRuntimeContext, - runWithCliRuntime, setCliRuntime, } from "@/lib/runtime.ts"; -import { renderCliErrorJson } from "@/lib/errors.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; import { CliError } from "@/lib/errors.ts"; +import { renderCliErrorJson } from "@/ui/error.ts"; describe("parseGlobalFlags", () => { test("parses --agent", () => { @@ -58,7 +58,6 @@ describe("agent output preset", () => { const runtime = createCliRuntime({ debug: false, json: false, agent: true }); runWithCliRuntime(runtime, () => { expect(isJsonOutput()).toBe(true); - expect(isAgentMode()).toBe(true); }); }); diff --git a/cli/tests/http-headers.test.ts b/cli/src/lib/http-headers.test.ts similarity index 100% rename from cli/tests/http-headers.test.ts rename to cli/src/lib/http-headers.test.ts diff --git a/cli/src/lib/http-operation.ts b/cli/src/lib/http-operation.ts deleted file mode 100644 index 941a913..0000000 --- a/cli/src/lib/http-operation.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - httpEffect, - httpStreamEffect, - operationPlan, - type OperationEffect, - type OperationPlan, -} from "@/lib/operation-effect.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import type { OperationHttpRequest } from "@/lib/operation-transport.ts"; - -type HttpOperationDefinition = { - id: string; - request: (input: TInput, context?: OperationContext) => OperationHttpRequest; - decode?: (body: string, context: OperationContext, input: TInput) => TResult | Promise; -}; - -type HttpStreamOperationDefinition = { - id: string; - request: (input: TInput, context?: OperationContext) => OperationHttpRequest; - decode: ( - stream: ReadableStream, - context: OperationContext, - input: TInput, - ) => TResult | Promise; -}; - -export type HttpOperationDescriptor = HttpOperationDefinition & { - effect: (input: TInput, context: OperationContext) => OperationEffect; - plan: (input: TInput, context: OperationContext) => OperationPlan; -}; - -export type HttpStreamOperationDescriptor = HttpStreamOperationDefinition< - TInput, - TResult -> & { - effect: (input: TInput, context: OperationContext) => OperationEffect; - plan: (input: TInput, context: OperationContext) => OperationPlan; -}; - -export function defineHttpOperation( - definition: HttpOperationDefinition, -): HttpOperationDescriptor { - const descriptor = { - ...definition, - effect(input: TInput, context: OperationContext): OperationEffect { - return httpOperationEffect(descriptor, input, context); - }, - plan(input: TInput, context: OperationContext): OperationPlan { - return httpOperationPlan(descriptor, input, context); - }, - }; - return descriptor; -} - -export function defineHttpStreamOperation( - definition: HttpStreamOperationDefinition, -): HttpStreamOperationDescriptor { - const descriptor = { - ...definition, - effect(input: TInput, context: OperationContext): OperationEffect { - return httpStreamOperationEffect(descriptor, input, context); - }, - plan(input: TInput, context: OperationContext): OperationPlan { - return httpStreamOperationPlan(descriptor, input, context); - }, - }; - return descriptor; -} - -export function httpOperationEffect( - descriptor: HttpOperationDescriptor, - input: TInput, - context: OperationContext, -): OperationEffect { - return httpEffect(descriptor.request(input, context), (body, operationContext) => - descriptor.decode ? descriptor.decode(body, operationContext, input) : (body as TResult), - ); -} - -export function httpStreamOperationEffect( - descriptor: HttpStreamOperationDescriptor, - input: TInput, - context: OperationContext, -): OperationEffect { - return httpStreamEffect(descriptor.request(input, context), (stream, operationContext) => - descriptor.decode(stream, operationContext, input), - ); -} - -export function httpOperationPlan( - descriptor: HttpOperationDescriptor, - input: TInput, - context: OperationContext, -): OperationPlan { - return operationPlan(httpOperationEffect(descriptor, input, context)); -} - -export function httpStreamOperationPlan( - descriptor: HttpStreamOperationDescriptor, - input: TInput, - context: OperationContext, -): OperationPlan { - return operationPlan(httpStreamOperationEffect(descriptor, input, context)); -} diff --git a/cli/src/lib/operation-transport.ts b/cli/src/lib/http-request.ts similarity index 88% rename from cli/src/lib/operation-transport.ts rename to cli/src/lib/http-request.ts index 0794506..89ee1d7 100644 --- a/cli/src/lib/operation-transport.ts +++ b/cli/src/lib/http-request.ts @@ -13,7 +13,7 @@ import { type PlaneUrlBuilder = (endpoint: string, context: ExecutionContext) => string; -export type OperationHttpRequest = { +export type HttpRequest = { plane: AuthPlane; method: string; endpoint: string; @@ -32,12 +32,12 @@ const PLANE_URL_BUILDERS = { `${resolveManagementApiBase(context.profile)}${encodeManagementEndpoint(endpoint)}`, } satisfies Record; -function resolvePlaneUrl(request: OperationHttpRequest, context: ExecutionContext): string { +function resolvePlaneUrl(request: HttpRequest, context: ExecutionContext): string { return PLANE_URL_BUILDERS[request.plane](request.endpoint, context); } async function resolveRequestAuthHeader( - request: OperationHttpRequest, + request: HttpRequest, context: ExecutionContext, ): Promise { if (request.plane === "management") { @@ -57,7 +57,7 @@ async function resolveRequestAuthHeader( } async function toHttpSendOptions( - request: OperationHttpRequest, + request: HttpRequest, context: ExecutionContext, ): Promise { return { @@ -76,7 +76,7 @@ async function toHttpSendOptions( } function isRecoverableLakehouseAuthFailure( - request: OperationHttpRequest, + request: HttpRequest, error: unknown, profileName: string, ): boolean { @@ -91,7 +91,7 @@ function isRecoverableLakehouseAuthFailure( } async function sendWithAuthRecovery( - request: OperationHttpRequest, + request: HttpRequest, context: ExecutionContext, send: (options: HttpSendOptions) => Promise, ): Promise { @@ -109,15 +109,12 @@ async function sendWithAuthRecovery( } } -export async function sendOperationHttp( - request: OperationHttpRequest, - context: ExecutionContext, -): Promise { +export async function sendHttp(request: HttpRequest, context: ExecutionContext): Promise { return sendWithAuthRecovery(request, context, httpSend); } -export async function sendOperationHttpStream( - request: OperationHttpRequest, +export async function sendHttpStream( + request: HttpRequest, context: ExecutionContext, ): Promise> { return sendWithAuthRecovery(request, context, httpSendStream); diff --git a/cli/tests/http.test.ts b/cli/src/lib/http.test.ts similarity index 98% rename from cli/tests/http.test.ts rename to cli/src/lib/http.test.ts index 4df1805..5416a9f 100644 --- a/cli/tests/http.test.ts +++ b/cli/src/lib/http.test.ts @@ -5,19 +5,17 @@ import { tmpdir } from "node:os"; import { setCliContext } from "@/context.ts"; import { CliError, HttpError, NetworkError, TimeoutError } from "@/lib/errors.ts"; import { - computeRetryDelayMs, getSharedDispatcher, httpSend, httpSendStream, - isRetryableMethod, - parseRetryAfterMs, redactAuthHeader, redactHeaderValue, - redactResponseBodyForDebug, resolveFetchTimeoutMs, resolveReadTimeoutMs, } from "@/lib/http.ts"; -import { delay } from "@tests/test-utils.ts"; +import { computeRetryDelayMs, isRetryableMethod, parseRetryAfterMs } from "@/lib/http-retry.ts"; +import { redactResponseBodyForDebug } from "@/lib/redact.ts"; +import { delay } from "@/test-utils/time.ts"; const fakeBearerKey = "atm_fake_test_key_for_redaction"; const fakeBasicToken = "fake_basic_token_value"; diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts index d031ccf..3926cad 100644 --- a/cli/src/lib/http.ts +++ b/cli/src/lib/http.ts @@ -18,9 +18,6 @@ import { import { DEFAULT_READ_TIMEOUT_MS, STREAM_READ_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; import { readEnv } from "@/lib/env.ts"; -export { computeRetryDelayMs, isRetryableMethod, parseRetryAfterMs } from "@/lib/http-retry.ts"; -export { redactResponseBodyForDebug } from "@/lib/redact.ts"; - export type HttpSendOptions = { method: string; url: string; diff --git a/cli/src/lib/lakehouse-operations.ts b/cli/src/lib/lakehouse-operations.ts deleted file mode 100644 index 427639d..0000000 --- a/cli/src/lib/lakehouse-operations.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { urlencode } from "@/lib/encode.ts"; -import { defineHttpOperation, defineHttpStreamOperation } from "@/lib/http-operation.ts"; -import { - parseLakehouseQueryResponse, - parseLakehouseQueryStream, - type LakehouseQueryResult, -} from "@/lib/lakehouse-ndjson.ts"; -import { buildLakehouseAppendRequest } from "@/lib/lakehouse-transport.ts"; -import { STREAM_READ_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; - -export type LakehouseQueryOperationInput = { - statement: string; - queryId?: string; - sessionId?: string; - httpOptions?: { readTimeoutMs?: number }; -}; - -export type LakehouseCancelOperationInput = { - queryId: string; - sessionId: string; -}; - -export type LakehouseAppendOperationInput = { - catalog: string; - schema: string; - table: string; - payload: string; - sync: boolean; -}; - -function buildLakehouseQueryPayload( - statement: string, - queryId?: string, - sessionId?: string, -): Record { - const payload: Record = { statement }; - if (queryId) { - payload.query_id = queryId; - } - if (sessionId) { - payload.session_id = sessionId; - } - return payload; -} - -async function collectLakehouseQueryStream( - stream: ReadableStream, -): Promise { - const parser = parseLakehouseQueryStream(stream); - while (true) { - const next = await parser.next(); - if (next.done) { - return next.value; - } - } -} - -export const lakehouseQueryOperation = defineHttpOperation< - LakehouseQueryOperationInput, - LakehouseQueryResult ->({ - id: "lakehouse.query.run.buffered", - request: (input) => ({ - plane: "lakehouse", - method: "POST", - endpoint: "/query", - body: JSON.stringify( - buildLakehouseQueryPayload(input.statement, input.queryId, input.sessionId), - ), - contentType: "application/json", - ...input.httpOptions, - }), - decode: (response) => parseLakehouseQueryResponse(response), -}); - -export const lakehouseQueryStreamOperation = defineHttpStreamOperation< - LakehouseQueryOperationInput, - LakehouseQueryResult ->({ - id: "lakehouse.query.run.stream", - request: (input) => ({ - plane: "lakehouse", - method: "POST", - endpoint: "/query", - body: JSON.stringify( - buildLakehouseQueryPayload(input.statement, input.queryId, input.sessionId), - ), - contentType: "application/json", - readTimeoutMs: input.httpOptions?.readTimeoutMs ?? STREAM_READ_TIMEOUT_MS, - retry: false, - ...input.httpOptions, - }), - decode: (stream) => collectLakehouseQueryStream(stream), -}); - -export const lakehouseQueryShowOperation = defineHttpOperation({ - id: "lakehouse.query.show", - request: (queryId) => ({ - plane: "lakehouse", - method: "GET", - endpoint: `/query/${urlencode(queryId)}`, - retry: true, - }), -}); - -export const lakehouseQueryCancelOperation = defineHttpOperation< - LakehouseCancelOperationInput, - string ->({ - id: "lakehouse.query.cancel", - request: (input) => { - const params = new URLSearchParams({ session_id: input.sessionId }); - return { - plane: "lakehouse", - method: "DELETE", - endpoint: `/query/${urlencode(input.queryId)}?${params.toString()}`, - }; - }, -}); - -export const lakehouseVerifyOperation = defineHttpOperation({ - id: "lakehouse.verify", - request: () => ({ - plane: "lakehouse", - method: "POST", - endpoint: "/query", - body: JSON.stringify(buildLakehouseQueryPayload("SELECT 1")), - contentType: "application/json", - }), -}); - -export const lakehouseAppendOperation = defineHttpOperation({ - id: "lakehouse.append.run", - request: (input) => - buildLakehouseAppendRequest({ - catalog: input.catalog, - schema: input.schema, - table: input.table, - jsonContent: input.payload, - options: { sync: input.sync }, - }), -}); - -export const lakehouseAppendTaskOperation = defineHttpOperation({ - id: "lakehouse.append.status", - request: (taskId) => ({ - plane: "lakehouse", - method: "GET", - endpoint: `/tasks/${urlencode(taskId)}`, - retry: true, - }), -}); diff --git a/cli/tests/lakehouse-provision.test.ts b/cli/src/lib/lakehouse-provision.test.ts similarity index 98% rename from cli/tests/lakehouse-provision.test.ts rename to cli/src/lib/lakehouse-provision.test.ts index 5b061a8..3e75b8d 100644 --- a/cli/tests/lakehouse-provision.test.ts +++ b/cli/src/lib/lakehouse-provision.test.ts @@ -5,7 +5,7 @@ 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 { sendOperationHttp } from "@/lib/operation-transport.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"; @@ -66,7 +66,7 @@ function writeMocks(credentialBody: string = CREDENTIAL_BODY): void { } async function sendLakehouseRequest(): Promise { - return sendOperationHttp( + return sendHttp( { plane: "lakehouse", method: "GET", endpoint: "/tables" }, createExecutionContext(getCliRuntime()), ); diff --git a/cli/src/lib/lakehouse-transport.ts b/cli/src/lib/lakehouse-transport.ts index d4b30f0..69e1f89 100644 --- a/cli/src/lib/lakehouse-transport.ts +++ b/cli/src/lib/lakehouse-transport.ts @@ -1,6 +1,6 @@ import { type HttpSendOptions } from "@/lib/http.ts"; import { createUploadProgressReporter, shouldShowProgress } from "@/lib/progress.ts"; -import type { OperationHttpRequest } from "@/lib/operation-transport.ts"; +import type { HttpRequest } from "@/lib/http-request.ts"; export type LakehouseAppendOptions = { sync?: boolean; @@ -37,28 +37,11 @@ export type LakehouseUpsertRequestInput = { }; export type LakehouseUploadRequestScope = { - request: OperationHttpRequest; + request: HttpRequest; release: () => void; }; -export function buildLakehouseQueryPayload( - statement: string, - queryId?: string, - sessionId?: string, -): Record { - const payload: Record = { statement }; - if (queryId) { - payload.query_id = queryId; - } - if (sessionId) { - payload.session_id = sessionId; - } - return payload; -} - -export function buildLakehouseAppendRequest( - input: LakehouseAppendRequestInput, -): OperationHttpRequest { +export function buildLakehouseAppendRequest(input: LakehouseAppendRequestInput): HttpRequest { const params = new URLSearchParams({ catalog: input.catalog, schema: input.schema, diff --git a/cli/src/lib/lakehouse/args.test.ts b/cli/src/lib/lakehouse/args.test.ts new file mode 100644 index 0000000..c365a6c --- /dev/null +++ b/cli/src/lib/lakehouse/args.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { CliError } from "@/lib/errors.ts"; +import { parseLakehouseFileContentType } from "@/lib/lakehouse/args.ts"; + +describe("parseLakehouseFileContentType", () => { + test("maps supported lakehouse file formats to content types", () => { + expect(parseLakehouseFileContentType(undefined)).toBeUndefined(); + expect(parseLakehouseFileContentType("csv")).toBe("text/csv"); + expect(parseLakehouseFileContentType("json")).toBe("application/json"); + expect(parseLakehouseFileContentType("parquet")).toBe("application/vnd.apache.parquet"); + }); + + test("rejects unknown lakehouse file formats", () => { + expect(() => parseLakehouseFileContentType("xml")).toThrow(CliError); + expect(() => parseLakehouseFileContentType("xml")).toThrow( + "--format must be one of: csv, json, parquet", + ); + }); +}); diff --git a/cli/src/lib/lakehouse/args.ts b/cli/src/lib/lakehouse/args.ts new file mode 100644 index 0000000..88a49da --- /dev/null +++ b/cli/src/lib/lakehouse/args.ts @@ -0,0 +1,37 @@ +import { defineArgs } from "@/lib/command.ts"; +import { CliError } from "@/lib/errors.ts"; +import { requestReadTimeoutArgs } from "@/lib/timeout-args.ts"; + +export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; + +export const lakehouseTableArgs = defineArgs({ + catalog: { type: "string", description: "Catalog name", required: true }, + schema: { type: "string", description: "Schema name", required: true }, + table: { type: "string", description: "Table name", required: true }, +}); + +export const lakehouseFileArgs = defineArgs({ + ...lakehouseTableArgs, + format: { + type: "enum", + description: "Optional file format hint for the Content-Type header", + options: [...LAKEHOUSE_FILE_FORMAT_OPTIONS], + }, + file: { type: "string", description: "Local file to upload", required: true }, + ...requestReadTimeoutArgs, +}); + +export function parseLakehouseFileContentType(format: string | undefined): string | undefined { + if (!format) return undefined; + + switch (format.toLowerCase()) { + case "csv": + return "text/csv"; + case "json": + return "application/json"; + case "parquet": + return "application/vnd.apache.parquet"; + default: + throw new CliError("--format must be one of: csv, json, parquet."); + } +} diff --git a/cli/src/lib/lakehouse/file.ts b/cli/src/lib/lakehouse/file.ts new file mode 100644 index 0000000..18f698c --- /dev/null +++ b/cli/src/lib/lakehouse/file.ts @@ -0,0 +1,13 @@ +import { statSync } from "node:fs"; +import { CliError } from "@/lib/errors.ts"; + +export function getFileSizeBytes(filePath: string): number { + try { + const fileStat = statSync(filePath); + if (!fileStat.isFile()) throw new CliError(`File not found: ${filePath}`); + return fileStat.size; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError(`File not found: ${filePath}`); + } +} diff --git a/cli/src/lib/lakehouse/query.test.ts b/cli/src/lib/lakehouse/query.test.ts new file mode 100644 index 0000000..9b17c2a --- /dev/null +++ b/cli/src/lib/lakehouse/query.test.ts @@ -0,0 +1,314 @@ +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 } from "@/lib/errors.ts"; +import { + csvEscapeCell, + renderQueryCsv, + renderQueryJson, + renderQueryOutputText, +} from "@/lib/query-output.ts"; +import { + parseLakehouseQueryResponse, + parseLakehouseQueryStream, + type LakehouseRow, +} from "@/lib/lakehouse-ndjson.ts"; +import { getQueryColumnNames } from "@/lib/query-format.ts"; +import { httpSendStream } from "@/lib/http.ts"; +import { createExecutionContext } from "@/lib/execution-context.ts"; +import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; +import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; + +const SAMPLE_NDJSON = [ + '{"statement":"SELECT 1","session_id":"abc","query_id":"def"}', + '["id","name"]', + '[1,"Alice"]', + '[2,"Bob"]', +].join("\n"); + +let testHome = ""; +let mockFile = ""; + +beforeEach(() => { + 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"; + setCliContext({ debug: false, json: false, agent: false }); + refreshCliRuntimeContext(getCliRuntime().context); +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_MOCK_HTTP_FILE; + delete process.env.ALTERTABLE_API_BASE; + delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; + delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; +}); + +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.rows).toEqual([ + [1, "Alice"], + [2, "Bob"], + ]); + }); + + 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"), + ); + + expect(result.columns).toEqual([ + { name: "id", type: "integer" }, + { name: "name", type: "varchar" }, + ]); + expect(result.rows).toEqual([{ id: 1, name: "Alice" }]); + }); + + test("treats a non-column second line as the first row", () => { + const result = parseLakehouseQueryResponse( + ['{"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 with line index for malformed JSON", () => { + const malformed = `${SAMPLE_NDJSON}\nnot-json`; + try { + parseLakehouseQueryResponse(malformed); + throw new Error("expected parse failure"); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + expect((error as ParseError).message).toContain("line 5"); + } + }); + + 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 () => { + writeFileSync( + mockFile, + JSON.stringify([ + { + urlPattern: "/query", + method: "POST", + chunked: true, + body: SAMPLE_NDJSON, + }, + ]), + ); + + const byteStream = await httpSendStream({ + method: "POST", + url: "https://example.com/query", + authHeader: "Authorization: Basic test", + body: '{"statement":"SELECT 1"}', + }); + + const rowValues: LakehouseRow[] = []; + const streamParser = parseLakehouseQueryStream(byteStream); + while (true) { + const next = await streamParser.next(); + if (next.done) { + expect(next.value.rows).toEqual([ + [1, "Alice"], + [2, "Bob"], + ]); + break; + } + rowValues.push(next.value); + } + + expect(rowValues).toEqual([ + [1, "Alice"], + [2, "Bob"], + ]); + }); + + test("throws ParseError when metadata line is incomplete across first chunk", async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"statement":"SELECT 1"')); + controller.close(); + }, + }); + + try { + const streamParser = parseLakehouseQueryStream(stream); + await streamParser.next(); + throw new Error("expected parse failure"); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + } + }); + + test("throws ParseError for empty stream", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + try { + const streamParser = parseLakehouseQueryStream(stream); + await streamParser.next(); + 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 () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(['{"statement":"SELECT 1"}', '{"id":1}', '{"id":2}'].join("\n")), + ); + controller.close(); + }, + }); + + const rowValues: LakehouseRow[] = []; + const streamParser = parseLakehouseQueryStream(stream); + while (true) { + const next = await streamParser.next(); + if (next.done) { + expect(next.value.columns).toEqual([]); + expect(next.value.rows).toEqual([{ id: 1 }, { id: 2 }]); + break; + } + rowValues.push(next.value); + } + + expect(rowValues).toEqual([{ id: 1 }, { id: 2 }]); + }); + + test("malformed row line includes line index", async () => { + const malformedBody = `${SAMPLE_NDJSON}\nnot-json`; + writeFileSync( + mockFile, + JSON.stringify([ + { + urlPattern: "/query", + method: "POST", + chunked: true, + body: malformedBody, + }, + ]), + ); + + const byteStream = await httpSendStream({ + method: "POST", + url: "https://example.com/query", + authHeader: "Authorization: Basic test", + body: '{"statement":"SELECT 1"}', + }); + + const streamParser = parseLakehouseQueryStream(byteStream); + await streamParser.next(); + await streamParser.next(); + try { + await streamParser.next(); + throw new Error("expected parse failure"); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + expect((error as ParseError).message).toContain("line 5"); + } + }); +}); + +describe("executeLakehouseQuery", () => { + test("returns the same result as parseLakehouseQueryResponse", async () => { + writeFileSync( + mockFile, + JSON.stringify([ + { + urlPattern: "/query", + method: "POST", + chunked: true, + body: SAMPLE_NDJSON, + }, + ]), + ); + + const runtime = getCliRuntime(); + const streamedResult = await executeLakehouseQuery( + { statement: "SELECT 1" }, + createExecutionContext(runtime), + true, + ); + const bufferedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); + + expect(streamedResult).toEqual(bufferedResult); + }); +}); + +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("renderQueryCsv outputs header and quoted values", () => { + const csv = renderQueryCsv(parsedResult); + expect(csv).toBe("id,name\n1,Alice\n2,Bob"); + }); + + 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"'); + }); + + 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.rows).toHaveLength(2); + }); + + test("getQueryColumnNames derives names from object rows", () => { + const names = getQueryColumnNames({ + metadata: {}, + columns: [], + rows: [{ id: 1, name: "Alice" }], + }); + expect(names).toEqual(["id", "name"]); + }); +}); diff --git a/cli/src/lib/lakehouse/query.ts b/cli/src/lib/lakehouse/query.ts new file mode 100644 index 0000000..e030371 --- /dev/null +++ b/cli/src/lib/lakehouse/query.ts @@ -0,0 +1,91 @@ +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { + parseLakehouseQueryResponse, + parseLakehouseQueryStream, + type LakehouseQueryResult, +} from "@/lib/lakehouse-ndjson.ts"; +import { sendHttp, sendHttpStream, type HttpRequest } from "@/lib/http-request.ts"; +import { STREAM_READ_TIMEOUT_MS } from "@/lib/transport-defaults.ts"; + +export type LakehouseQueryInput = { + statement: string; + queryId?: string; + sessionId?: string; + httpOptions?: { readTimeoutMs?: number }; +}; + +export type LakehouseCancelInput = { + queryId: string; + sessionId: string; +}; + +function buildQueryPayload(input: LakehouseQueryInput): Record { + const payload: Record = { statement: input.statement }; + if (input.queryId) payload.query_id = input.queryId; + if (input.sessionId) payload.session_id = input.sessionId; + return payload; +} + +export function buildLakehouseQueryRequest( + input: LakehouseQueryInput, + streaming: boolean, +): HttpRequest { + return { + plane: "lakehouse", + method: "POST", + endpoint: "/query", + body: JSON.stringify(buildQueryPayload(input)), + contentType: "application/json", + ...(streaming + ? { + readTimeoutMs: input.httpOptions?.readTimeoutMs ?? STREAM_READ_TIMEOUT_MS, + retry: false, + } + : {}), + ...input.httpOptions, + }; +} + +async function collectQueryStream( + stream: ReadableStream, +): Promise { + const parser = parseLakehouseQueryStream(stream); + while (true) { + const next = await parser.next(); + if (next.done) return next.value; + } +} + +export async function executeLakehouseQuery( + input: LakehouseQueryInput, + execution: ExecutionContext, + streaming: boolean, +): Promise { + const request = buildLakehouseQueryRequest(input, streaming); + if (streaming) { + return collectQueryStream(await sendHttpStream(request, execution)); + } + return parseLakehouseQueryResponse(await sendHttp(request, execution)); +} + +export function buildLakehouseQueryShowRequest(queryId: string): HttpRequest { + return { + plane: "lakehouse", + method: "GET", + endpoint: `/query/${encodeURIComponent(queryId)}`, + retry: true, + }; +} + +export function buildLakehouseQueryCancelRequest(input: LakehouseCancelInput): HttpRequest { + const params = new URLSearchParams({ session_id: input.sessionId }); + return { + plane: "lakehouse", + method: "DELETE", + endpoint: `/query/${encodeURIComponent(input.queryId)}?${params.toString()}`, + }; +} + +export function buildLakehouseVerifyRequest(): HttpRequest { + return buildLakehouseQueryRequest({ statement: "SELECT 1" }, false); +} diff --git a/cli/src/lib/management-endpoint.ts b/cli/src/lib/management-endpoint.ts index dfe34b4..c2e0be0 100644 --- a/cli/src/lib/management-endpoint.ts +++ b/cli/src/lib/management-endpoint.ts @@ -1,8 +1,8 @@ -import { urlencode } from "@/lib/encode.ts"; - function encodeManagementPath(path: string): string { const segments = path.split("/"); - return segments.map((segment) => (segment.length > 0 ? urlencode(segment) : "")).join("/"); + return segments + .map((segment) => (segment.length > 0 ? encodeURIComponent(segment) : "")) + .join("/"); } export function encodeManagementEndpoint(endpoint: string): string { diff --git a/cli/src/lib/management-operations.ts b/cli/src/lib/management-operations.ts deleted file mode 100644 index 1d806b9..0000000 --- a/cli/src/lib/management-operations.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { defineHttpOperation } from "@/lib/http-operation.ts"; -import { buildCatalogRowsFromResponses } from "@/lib/catalog-rows.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { runOperationEffect } from "@/lib/operation-effect.ts"; - -export type ManagementCatalogCreateInput = { - env: string; - name: string; - body: string; -}; - -export type ManagementCatalogCreateResult = { - response: string; - env: string; - fallbackName: string; -}; - -export const managementCatalogCreateOperation = defineHttpOperation< - ManagementCatalogCreateInput, - ManagementCatalogCreateResult ->({ - id: "management.catalogs.create", - request: (input) => ({ - plane: "management", - method: "POST", - endpoint: `/environments/${input.env}/databases`, - body: input.body, - contentType: "application/json", - }), - decode: (response, _context, input) => ({ - response, - env: input.env, - fallbackName: input.name, - }), -}); - -export const managementCatalogDatabasesOperation = defineHttpOperation({ - id: "management.catalogs.databases.list", - request: (env) => ({ - plane: "management", - method: "GET", - endpoint: `/environments/${env}/databases`, - }), -}); - -export const managementCatalogConnectionsOperation = defineHttpOperation({ - id: "management.catalogs.connections.list", - request: (env) => ({ - plane: "management", - method: "GET", - endpoint: `/environments/${env}/connections`, - }), -}); - -export function buildManagementCatalogRows(responses: unknown[]): CatalogRow[] { - const [databasesResponse, connectionsResponse] = responses; - return buildCatalogRowsFromResponses(String(databasesResponse), String(connectionsResponse)); -} - -export async function fetchManagementCatalogRows( - env: string, - context: OperationContext, -): Promise { - const databasesResponse = await runOperationEffect( - managementCatalogDatabasesOperation.effect(env, context), - context, - ); - const connectionsResponse = await runOperationEffect( - managementCatalogConnectionsOperation.effect(env, context), - context, - ); - return buildManagementCatalogRows([databasesResponse, connectionsResponse]); -} diff --git a/cli/tests/management-output.test.ts b/cli/src/lib/management-output.test.ts similarity index 82% rename from cli/tests/management-output.test.ts rename to cli/src/lib/management-output.test.ts index 7120ab6..2ab6094 100644 --- a/cli/tests/management-output.test.ts +++ b/cli/src/lib/management-output.test.ts @@ -1,6 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { writeManagementOutput } from "@/lib/command-output.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; import { extractManagementRows, renderManagementOutput } from "@/lib/management-output.ts"; describe("extractManagementRows", () => { @@ -79,19 +77,6 @@ describe("renderManagementOutput", () => { expect(JSON.parse(output)).toEqual(JSON.parse(listBody)); }); - test("writeManagementOutput defaults to table in human mode", async () => { - const stdout: string[] = []; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeHuman = (text) => { - stdout.push(text); - }; - await runWithCliRuntime(runtime, async () => { - await writeManagementOutput(listBody); - }); - expect(stdout[0]).toContain("id"); - expect(stdout[0]).toContain("Analytics"); - }); - test("table format redacts credential password values", () => { const credentialBody = JSON.stringify({ credential: { id: "cred_1", label: "default", username: "user_1" }, diff --git a/cli/src/lib/management-output.ts b/cli/src/lib/management-output.ts index b6c12d8..e63a19a 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,11 +1,10 @@ -import type { ArgsDef } from "citty"; -import { isJsonOutput } from "@/context.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { redactSensitiveJsonValue } from "@/lib/redact.ts"; -import { type ManagementOutputFormat } from "@/lib/lakehouse-client.ts"; -import { renderTabularOutput, type TabularResult } from "@/lib/tabular-result.ts"; - -export type { ManagementOutputFormat } from "@/lib/lakehouse-client.ts"; +import { + renderTabularOutput, + type ManagementOutputFormat, + type TabularResult, +} from "@/lib/tabular-result.ts"; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -16,8 +15,6 @@ export function extractManagementRows(data: unknown): Record[] return []; } - const shouldRedact = !isJsonOutput(); - const arrayValues = Object.values(data).filter(Array.isArray) as unknown[][]; if (arrayValues.length === 1) { const rows = arrayValues[0]; @@ -25,7 +22,7 @@ export function extractManagementRows(data: unknown): Record[] return []; } const plainRows = rows.filter(isPlainObject); - return shouldRedact ? plainRows.map(redactSensitiveRow) : plainRows; + return plainRows.map(redactSensitiveRow); } const nestedObjects = Object.entries(data).filter(([, value]) => isPlainObject(value)); @@ -34,21 +31,18 @@ export function extractManagementRows(data: unknown): Record[] if (!isPlainObject(nestedObject)) { return []; } - const row = shouldRedact ? redactSensitiveRow(nestedObject) : nestedObject; - return [row]; + return [redactSensitiveRow(nestedObject)]; } const row: Record = {}; for (const [key, value] of Object.entries(data)) { if (isPlainObject(value)) { for (const [nestedKey, nestedValue] of Object.entries(value)) { - row[nestedKey] = shouldRedact - ? redactSensitiveRowValue(nestedKey, nestedValue) - : nestedValue; + row[nestedKey] = redactSensitiveRowValue(nestedKey, nestedValue); } continue; } - row[key] = shouldRedact ? redactSensitiveRowValue(key, value) : value; + row[key] = redactSensitiveRowValue(key, value); } return Object.keys(row).length > 0 ? [row] : []; @@ -63,16 +57,6 @@ function redactSensitiveRowValue(key: string, value: unknown): unknown { return redacted[key]; } -export const MANAGEMENT_FORMAT_OPTIONS = ["json", "table", "csv", "markdown"] as const; - -export const MANAGEMENT_FORMAT_ARG = { - format: { - type: "enum" as const, - description: "Output format: json, table, csv, or markdown", - options: [...MANAGEMENT_FORMAT_OPTIONS], - }, -}; - function collectColumnNames(rows: Record[]): string[] { const columnNames = new Set(); for (const row of rows) { @@ -100,12 +84,3 @@ export function renderManagementOutput(body: string, format: ManagementOutputFor return renderTabularOutput(managementDataToTabularResult(data), format); } - -export function withManagementFormatArg( - args: T, -): T & typeof MANAGEMENT_FORMAT_ARG { - return { - ...MANAGEMENT_FORMAT_ARG, - ...args, - }; -} diff --git a/cli/src/lib/management-payloads.ts b/cli/src/lib/management-payloads.ts deleted file mode 100644 index 1986fbe..0000000 --- a/cli/src/lib/management-payloads.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function buildCreateCatalogBody(options: { name: string }): string { - return JSON.stringify({ name: options.name, engine: "altertable" }); -} diff --git a/cli/src/lib/management-transport.ts b/cli/src/lib/management-transport.ts deleted file mode 100644 index a8a3c7a..0000000 --- a/cli/src/lib/management-transport.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getCliRuntime } from "@/lib/runtime.ts"; -import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; -import { sendOperationHttp } from "@/lib/operation-transport.ts"; -export { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; - -export async function managementRequest( - method: string, - endpoint: string, - body?: string, - execution: ExecutionContext = createExecutionContext(getCliRuntime()), -): Promise { - return sendOperationHttp( - { - plane: "management", - method, - endpoint, - body, - contentType: body ? "application/json" : undefined, - }, - execution, - ); -} diff --git a/cli/tests/management-auth.test.ts b/cli/src/lib/management/auth.test.ts similarity index 100% rename from cli/tests/management-auth.test.ts rename to cli/src/lib/management/auth.test.ts diff --git a/cli/src/lib/management/catalogs.ts b/cli/src/lib/management/catalogs.ts new file mode 100644 index 0000000..b28bddf --- /dev/null +++ b/cli/src/lib/management/catalogs.ts @@ -0,0 +1,53 @@ +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; + slug?: string; + engine?: string; + catalog?: string; +}; + +function catalogListRequest(environment: string, kind: "databases" | "connections") { + return { + 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[] }; + + return [ + ...(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 ?? "", + })), + ]; +} + +export async function fetchManagementCatalogRows( + environment: string, + execution: ExecutionContext, +): Promise { + const databasesResponse = await sendHttp(catalogListRequest(environment, "databases"), execution); + const connectionsResponse = await sendHttp( + catalogListRequest(environment, "connections"), + execution, + ); + return parseCatalogRows(databasesResponse, connectionsResponse); +} diff --git a/cli/src/features/management/model.ts b/cli/src/lib/management/model.ts similarity index 100% rename from cli/src/features/management/model.ts rename to cli/src/lib/management/model.ts diff --git a/cli/tests/management-formatters.test.ts b/cli/src/lib/management/render.test.ts similarity index 89% rename from cli/tests/management-formatters.test.ts rename to cli/src/lib/management/render.test.ts index 36b1cd5..98e2cad 100644 --- a/cli/tests/management-formatters.test.ts +++ b/cli/src/lib/management/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { formatCatalogsSummary, formatCatalogsTable } from "@/features/management/render.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; +import { formatCatalogsSummary, formatCatalogsTable } from "@/lib/management/render.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; const sampleRows: CatalogRow[] = [ { diff --git a/cli/src/features/management/render.ts b/cli/src/lib/management/render.ts similarity index 89% rename from cli/src/features/management/render.ts rename to cli/src/lib/management/render.ts index af3bedb..3b7e640 100644 --- a/cli/src/features/management/render.ts +++ b/cli/src/lib/management/render.ts @@ -1,5 +1,5 @@ -import type { CatalogRow, WhoamiResponse } from "@/features/management/model.ts"; -import { buildCatalogsTableView } from "@/features/management/views.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 { diff --git a/cli/src/features/management/views.ts b/cli/src/lib/management/views.ts similarity index 92% rename from cli/src/features/management/views.ts rename to cli/src/lib/management/views.ts index 42bfdaf..fb8c7e8 100644 --- a/cli/src/features/management/views.ts +++ b/cli/src/lib/management/views.ts @@ -1,4 +1,4 @@ -import type { CatalogRow } from "@/features/management/model.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 { diff --git a/cli/src/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts new file mode 100644 index 0000000..2972e9f --- /dev/null +++ b/cli/src/lib/oauth-flow.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { configGet, resolveOAuthBase } from "@/lib/config.ts"; +import { setCliContext } from "@/context.ts"; +import { buildAuthorizeUrl, parseCallback, startLoopbackServer } from "@/lib/oauth-flow.ts"; +import { clearOAuthTokens, storeOAuthTokens } from "@/lib/oauth-profile.ts"; +import { secretGet } from "@/lib/secrets.ts"; + +const profileName = "default"; +let testHome = ""; + +function storedAccessToken(): string { + return secretGet("oauth/access-token", profileName); +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-oauth-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + setCliContext({ debug: false, json: false, agent: false }); +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; + 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"); + }); + + 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" }); + }); + + 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({ + ok: false, + message: "Authorization failed: access_denied — nope", + }); + }); + + 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"; + const url = new URL( + buildAuthorizeUrl( + { + redirectUri: "http://127.0.0.1:5000/callback", + challenge: "chal", + state: "st", + }, + resolveOAuthBase(profileName), + ), + ); + 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"); + }); +}); + +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"); + } finally { + server.close(); + } + }); + + test("serves callback errors as plain text", async () => { + const server = await startLoopbackServer("st"); + const settled = server.waitForCode().then( + () => "resolved", + () => "rejected", + ); + try { + const response = await fetch( + `${server.redirectUri}?error=access_denied&error_description=${encodeURIComponent("")}`, + ); + expect(response.status).toBe(400); + expect(response.headers.get("content-type")).toContain("text/plain"); + expect(await response.text()).toBe( + "Authorization failed: access_denied — ", + ); + expect(await settled).toBe("rejected"); + } finally { + server.close(); + } + }); + + 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); + } finally { + server.close(); + } + }); +}); + +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, + ); + }); + + 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(""); + }); +}); diff --git a/cli/tests/oauth-refresh.test.ts b/cli/src/lib/oauth-profile.test.ts similarity index 71% rename from cli/tests/oauth-refresh.test.ts rename to cli/src/lib/oauth-profile.test.ts index ad03d4e..c1bb703 100644 --- a/cli/tests/oauth-refresh.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -3,24 +3,25 @@ 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, - getStoredAccessToken, -} from "@/lib/oauth-profile.ts"; +import { ensureFreshAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.ts"; import { getManagementAuthHeader } from "@/lib/auth.ts"; -import { configGet, configSet, resolveManagementApiBase, resolveOAuthBase } from "@/lib/config.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 { managementRequest } from "@/lib/management-transport.ts"; -import { createCliRuntime, refreshCliRuntimeContext, runWithCliRuntime } from "@/lib/runtime.ts"; -import { fetchLoginWhoami, storeLoginProfileMetadata } from "@/commands/login.ts"; +import { createExecutionContext } from "@/lib/execution-context.ts"; +import { sendHttp } from "@/lib/http-request.ts"; +import { createCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; const profileName = "default"; let testHome = ""; let mockFile = ""; +function storedAccessToken(): string { + return secretGet("oauth/access-token", profileName); +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-oauth-refresh-")); mockFile = join(testHome, "mocks.json"); @@ -72,24 +73,24 @@ describe("exchangeCode", () => { describe("ensureFreshAccessToken", () => { test("no-op when not logged in via OAuth", async () => { await ensureFreshAccessToken(profileName); - expect(getStoredAccessToken(profileName)).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); await ensureFreshAccessToken(profileName); - expect(getStoredAccessToken(profileName)).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 () => { // 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); - const tokenBefore = getStoredAccessToken(profileName); + const tokenBefore = storedAccessToken(); process.env.ALTERTABLE_API_KEY = "atm_env"; await ensureFreshAccessToken(profileName); - expect(getStoredAccessToken(profileName)).toBe(tokenBefore); // tokens must not be cleared + expect(storedAccessToken()).toBe(tokenBefore); // tokens must not be cleared }); test("refreshes and persists when expired", async () => { @@ -107,7 +108,7 @@ describe("ensureFreshAccessToken", () => { configSet("oauth_expiry", String(Date.now() - 1000), profileName); await ensureFreshAccessToken(profileName); - expect(getStoredAccessToken(profileName)).toBe("acc2"); + expect(storedAccessToken()).toBe("acc2"); expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); }); @@ -134,7 +135,7 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(ConfigurationError); - expect(getStoredAccessToken(profileName)).toBe(""); + expect(storedAccessToken()).toBe(""); }); test("keeps tokens and rethrows when refresh returns 404 (wrong URL, not a dead session)", async () => { @@ -160,7 +161,7 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(HttpError); - expect(getStoredAccessToken(profileName)).toBe("acc"); // session preserved + expect(storedAccessToken()).toBe("acc"); // session preserved expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); }); @@ -179,7 +180,7 @@ describe("ensureFreshAccessToken", () => { caught = error; } expect(caught).toBeInstanceOf(NetworkError); - expect(getStoredAccessToken(profileName)).toBe("acc"); + expect(storedAccessToken()).toBe("acc"); expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); }); }); @@ -204,57 +205,14 @@ describe("management request auth resolution", () => { const runtime = createCliRuntime({ debug: false, json: false, agent: false }); await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); - await managementRequest("GET", "/whoami"); - }); - - expect(getStoredAccessToken(profileName)).toBe("acc3"); - expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); - }); - - // Regression: logging into org B while org A's session lives in `default`. - // whoami during login must use the freshly minted org B token — not org A's - // stored session — otherwise it identifies org A and the login wrongly derives - // an `org-a_*` profile instead of branching to org B. - test("whoami during login honors the freshly minted token, not the stored session", async () => { - // "Org A login" already landed in the default profile. - storeOAuthTokens( - { access_token: "org_a_token", refresh_token: "ref_a", expires_in: 3600 }, - profileName, - ); - configSet("organization_slug", "org-a", profileName); - - // The management server identifies the caller from its bearer token. - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/whoami", - method: "GET", - authPattern: "org_a_token", - body: '{"principal":{},"organization":{"slug":"org-a","name":"Org A"},"environment_slug":"production"}', - }, - { - urlPattern: "/whoami", - method: "GET", - authPattern: "org_b_token", - body: '{"principal":{},"organization":{"slug":"org-b","name":"Org B"},"environment_slug":"production"}', - }, - ]), - ); - - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - const metadata = await runWithCliRuntime(runtime, async () => { - refreshCliRuntimeContext(getCliContext()); - // The org B browser flow just minted this token. - const whoami = await fetchLoginWhoami( - { access_token: "org_b_token", refresh_token: "ref_b", expires_in: 3600 }, - resolveManagementApiBase(profileName), + await sendHttp( + { plane: "management", method: "GET", endpoint: "/whoami" }, + createExecutionContext(runtime), ); - return storeLoginProfileMetadata(whoami, {}); }); - // Logging into org B must branch to org B's profile, not org A's. - expect(metadata.profileName).toBe("org-b_production"); + expect(storedAccessToken()).toBe("acc3"); + expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); }); test("resolves the header live from the store, not a bootstrap cache", async () => { @@ -272,7 +230,10 @@ describe("management request auth resolution", () => { refreshCliRuntimeContext(getCliContext()); // Simulate an out-of-band token rotation (e.g. a prior refresh in this process). secretSet("oauth/access-token", "tok_b", profileName); - await managementRequest("GET", "/whoami"); + await sendHttp( + { plane: "management", method: "GET", endpoint: "/whoami" }, + createExecutionContext(runtime), + ); }); // The header reflects the rotated token, resolved live at send time. diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index f489206..8fc6567 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -18,10 +18,6 @@ export function storeOAuthTokens(oauthResponse: TokenResponse, profileName: stri configSet(OAUTH_EXPIRY_KEY, String(Date.now() + expiresInMs), profileName); } -export function getStoredAccessToken(profileName: string): string { - return secretGet(ACCESS_TOKEN_ACCOUNT, profileName); -} - export function clearOAuthTokens(profileName: string): void { secretDelete(ACCESS_TOKEN_ACCOUNT, profileName); secretDelete(REFRESH_TOKEN_ACCOUNT, profileName); diff --git a/cli/tests/openapi-spec.test.ts b/cli/src/lib/openapi-spec.test.ts similarity index 100% rename from cli/tests/openapi-spec.test.ts rename to cli/src/lib/openapi-spec.test.ts diff --git a/cli/src/lib/openapi-spec.ts b/cli/src/lib/openapi-spec.ts index f420a7c..211504e 100644 --- a/cli/src/lib/openapi-spec.ts +++ b/cli/src/lib/openapi-spec.ts @@ -1,3 +1,5 @@ +// 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"; diff --git a/cli/src/lib/operation-catalog.ts b/cli/src/lib/operation-catalog.ts deleted file mode 100644 index 8323299..0000000 --- a/cli/src/lib/operation-catalog.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AltertableCommandMeta } from "@/lib/command-context.ts"; -import type { AuthPlane } from "@/lib/errors.ts"; -import type { OperationEffectKind } from "@/lib/operation-effect.ts"; - -export type OperationCapability = - | "local-config" - | "local-file-read" - | "local-file-write" - | "management-http" - | "lakehouse-http" - | "streaming" - | "progress" - | "raw-stdout"; - -export type OperationCatalogEntry = { - id: string; - meta?: AltertableCommandMeta; - capabilities: readonly OperationCapability[]; - effects?: readonly OperationEffectKind[]; - planes?: readonly AuthPlane[]; - mutates?: boolean; - output?: "raw-api" | "normalized" | "human" | "tabular" | "none"; -}; - -export type OperationCatalogMetadata = Omit; - -const operationCatalog = new Map(); - -export function registerOperation(entry: OperationCatalogEntry): void { - operationCatalog.set(entry.id, entry); -} - -export function listOperations(): OperationCatalogEntry[] { - return [...operationCatalog.values()].sort((left, right) => left.id.localeCompare(right.id)); -} diff --git a/cli/src/lib/operation-command-builders.ts b/cli/src/lib/operation-command-builders.ts deleted file mode 100644 index 17491b7..0000000 --- a/cli/src/lib/operation-command-builders.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { CommandDef } from "citty"; -import { - defineOperationCommand, - type OperationContext, - type OperationPresenter, - type OperationSpec, -} from "@/lib/operation-command.ts"; -import type { CommandOutputMode } from "@/lib/command-output.ts"; -import type { AuthPlane } from "@/lib/errors.ts"; -import type { HttpOperationDescriptor } from "@/lib/http-operation.ts"; -import type { OperationCapability } from "@/lib/operation-catalog.ts"; -import { localPlan, outputPlan, valuePlan } from "@/lib/operation-effect.ts"; - -type CommandOutputShape = "raw-api" | "normalized" | "human" | "tabular" | "none"; - -type OperationCommandBase = Omit, "run"> & { - output: CommandOutputShape; -}; - -type GroupCommandSpec = Pick< - OperationSpec, - "id" | "capabilities" | "meta" | "args" | "default" | "subCommands" | "catalog" ->; - -type HttpCommandSpec = OperationCommandBase & { - plane: AuthPlane; - operation: HttpOperationDescriptor; - mutates?: boolean; -}; - -type LocalCommandSpec = OperationCommandBase & { - mutates?: boolean; - localConfig?: boolean; - readFile?: boolean; - writeFile?: boolean; - local: (input: TInput, context: OperationContext) => TResult | Promise; -}; - -type ValueCommandSpec = OperationCommandBase & { - value: (input: TInput, context: OperationContext) => TResult | Promise; -}; - -type OutputCommandSpec = Omit, "present"> & { - output: Exclude; - render: ( - input: TInput, - context: OperationContext, - ) => CommandOutputMode | Promise; -}; - -function httpCapability(plane: AuthPlane): OperationCapability { - return plane === "lakehouse" ? "lakehouse-http" : "management-http"; -} - -export function defineGroupCommand(spec: GroupCommandSpec): CommandDef { - return defineOperationCommand(spec); -} - -export function defineHttpCommand( - spec: HttpCommandSpec, -): CommandDef { - return defineOperationCommand({ - ...spec, - capabilities: spec.capabilities ?? [httpCapability(spec.plane)], - catalog: { - effects: ["http"], - planes: [spec.plane], - mutates: spec.mutates, - output: spec.output, - ...spec.catalog, - }, - run(input, context) { - return spec.operation.plan(input, context); - }, - }); -} - -export function defineLocalCommand( - spec: LocalCommandSpec, -): CommandDef { - const capabilities = [...(spec.capabilities ?? [])]; - if (spec.localConfig && !capabilities.includes("local-config")) { - capabilities.push("local-config"); - } - if (spec.readFile && !capabilities.includes("local-file-read")) { - capabilities.push("local-file-read"); - } - if ((spec.writeFile || spec.mutates) && !capabilities.includes("local-file-write")) { - capabilities.push("local-file-write"); - } - - return defineOperationCommand({ - ...spec, - capabilities, - catalog: { - effects: ["local"], - mutates: spec.mutates, - output: spec.output, - ...spec.catalog, - }, - run(input, context) { - return localPlan(() => spec.local(input, context)); - }, - }); -} - -export function defineValueCommand( - spec: ValueCommandSpec, -): CommandDef { - return defineOperationCommand({ - ...spec, - catalog: { - effects: ["value"], - output: spec.output, - ...spec.catalog, - }, - async run(input, context) { - return valuePlan(await spec.value(input, context)); - }, - }); -} - -export function defineOutputCommand(spec: OutputCommandSpec): CommandDef { - return defineOperationCommand({ - ...spec, - catalog: { - effects: ["output"], - output: spec.output, - ...spec.catalog, - }, - async run(input, context) { - return outputPlan(await spec.render(input, context)); - }, - }); -} - -export type { OperationPresenter }; diff --git a/cli/src/lib/operation-command.ts b/cli/src/lib/operation-command.ts deleted file mode 100644 index 8bbd2e4..0000000 --- a/cli/src/lib/operation-command.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { ArgsDef, CommandDef } from "citty"; -import { - defineAltertableCommand, - type AltertableCommandMeta, - type CommandRunContext, -} from "@/lib/command-context.ts"; -import { writeCommandOutput, type CommandOutputMode } from "@/lib/command-output.ts"; -import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; -import { - isOperationPlan, - operationPlan, - runOperationPlan, - type OperationPlan, -} from "@/lib/operation-effect.ts"; -import { registerOperation, type OperationCapability } from "@/lib/operation-catalog.ts"; -import type { OperationCatalogMetadata } from "@/lib/operation-catalog.ts"; -import type { OutputSink } from "@/lib/runtime.ts"; - -export type OperationContext = { - args: Record; - rawArgs: string[]; - runtime: CommandRunContext["runtime"]; - sink: OutputSink; - readonly execution: ExecutionContext; -}; - -export type OperationPresenter = ( - result: TResult, - context: OperationContext, - input: TInput, -) => CommandOutputMode | Promise | void; - -export type OperationSpec = { - id?: string; - capabilities?: readonly OperationCapability[]; - catalog?: OperationCatalogMetadata; - meta?: AltertableCommandMeta; - args?: ArgsDef; - default?: string; - subCommands?: Record; - parse?: (context: OperationContext) => TInput | Promise; - run?: ( - input: TInput, - context: OperationContext, - ) => OperationPlan | Promise>; - present?: OperationPresenter; -}; - -function createOperationContext(context: CommandRunContext): OperationContext { - let execution: ExecutionContext | undefined; - return { - args: context.args as Record, - rawArgs: context.rawArgs, - runtime: context.runtime, - sink: context.sink, - get execution() { - execution ??= createExecutionContext(context.runtime); - return execution; - }, - }; -} - -async function writePresentedOutput( - result: TResult, - input: TInput, - context: OperationContext, - present?: OperationPresenter, -): Promise { - const output = present ? await present(result, context, input) : undefined; - if (output) { - await writeCommandOutput(output, context.sink); - } -} - -export function defineOperationCommand( - spec: OperationSpec, -): CommandDef { - if (spec.id) { - registerOperation({ - id: spec.id, - meta: spec.meta, - capabilities: spec.capabilities ?? [], - ...spec.catalog, - }); - } - - const command = { - meta: spec.meta, - args: spec.args, - default: spec.default, - subCommands: spec.subCommands, - }; - - const run = spec.run; - if (!run) { - return defineAltertableCommand(command); - } - - return defineAltertableCommand({ - ...command, - async run(context: CommandRunContext) { - const operationContext = createOperationContext(context); - const input = spec.parse ? await spec.parse(operationContext) : (undefined as TInput); - const plannedResult = await run(input, operationContext); - const result = await runOperationPlan(plannedResult, operationContext); - await writePresentedOutput(result, input, operationContext, spec.present); - }, - }); -} - -export { isOperationPlan, operationPlan }; diff --git a/cli/src/lib/operation-effect.ts b/cli/src/lib/operation-effect.ts deleted file mode 100644 index f5e4cd3..0000000 --- a/cli/src/lib/operation-effect.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { OperationHttpRequest } from "@/lib/operation-transport.ts"; -import { sendOperationHttp, sendOperationHttpStream } from "@/lib/operation-transport.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { startProgress } from "@/lib/progress.ts"; -import { writeCommandOutput, type CommandOutputMode } from "@/lib/command-output.ts"; - -export type OperationScope = { - effect: OperationEffect; - release?: () => void | Promise; -}; - -export type OperationPlan = { - kind: "operation-plan"; - effect: OperationEffect; -}; - -export type OperationEffect = - | { - kind: "value"; - value: TResult; - } - | { - kind: "output"; - output: CommandOutputMode; - } - | { - kind: "local"; - run: (context: OperationContext) => TResult | Promise; - } - | { - kind: "http"; - request: OperationHttpRequest; - decode?: (body: string, context: OperationContext) => TResult | Promise; - } - | { - kind: "http-stream"; - request: OperationHttpRequest; - decode: ( - stream: ReadableStream, - context: OperationContext, - ) => TResult | Promise; - } - | { - kind: "all"; - effects: readonly OperationEffect[]; - combine: (results: unknown[], context: OperationContext) => TResult | Promise; - } - | { - kind: "progress"; - message: string; - effect: OperationEffect; - } - | { - kind: "scope"; - acquire: ( - context: OperationContext, - ) => OperationScope | Promise>; - }; - -export type OperationEffectKind = OperationEffect["kind"]; - -export function valueEffect(value: TResult): OperationEffect { - return { kind: "value", value }; -} - -export function outputEffect(output: CommandOutputMode): OperationEffect { - return { kind: "output", output }; -} - -export function localEffect( - run: (context: OperationContext) => TResult | Promise, -): OperationEffect { - return { kind: "local", run }; -} - -export function httpEffect( - request: OperationHttpRequest, - decode?: (body: string, context: OperationContext) => TResult | Promise, -): OperationEffect { - return { kind: "http", request, decode }; -} - -export function httpStreamEffect( - request: OperationHttpRequest, - decode: ( - stream: ReadableStream, - context: OperationContext, - ) => TResult | Promise, -): OperationEffect { - return { kind: "http-stream", request, decode }; -} - -export function allEffects( - effects: readonly OperationEffect[], - combine: (results: unknown[], context: OperationContext) => TResult | Promise, -): OperationEffect { - return { kind: "all", effects, combine }; -} - -export function progressEffect( - message: string, - effect: OperationEffect, -): OperationEffect { - return { kind: "progress", message, effect }; -} - -export function scopedEffect( - acquire: ( - context: OperationContext, - ) => OperationScope | Promise>, -): OperationEffect { - return { kind: "scope", acquire }; -} - -export function operationPlan(effect: OperationEffect): OperationPlan { - return { kind: "operation-plan", effect }; -} - -export function valuePlan(value: TResult): OperationPlan { - return operationPlan(valueEffect(value)); -} - -export function noopPlan(): OperationPlan { - return valuePlan(undefined as TResult); -} - -export function outputPlan(output: CommandOutputMode): OperationPlan { - return operationPlan(outputEffect(output)); -} - -export function localPlan( - run: (context: OperationContext) => TResult | Promise, -): OperationPlan { - return operationPlan(localEffect(run)); -} - -export function progressPlan( - message: string, - effect: OperationEffect, -): OperationPlan { - return operationPlan(progressEffect(message, effect)); -} - -export function scopedPlan( - acquire: ( - context: OperationContext, - ) => OperationScope | Promise>, -): OperationPlan { - return operationPlan(scopedEffect(acquire)); -} - -export function isOperationEffect(value: unknown): value is OperationEffect { - return ( - typeof value === "object" && - value !== null && - "kind" in value && - (value.kind === "value" || - value.kind === "http" || - value.kind === "output" || - value.kind === "local" || - value.kind === "http-stream" || - value.kind === "all" || - value.kind === "progress" || - value.kind === "scope") - ); -} - -export function isOperationPlan(value: unknown): value is OperationPlan { - return ( - typeof value === "object" && - value !== null && - "kind" in value && - value.kind === "operation-plan" && - "effect" in value && - isOperationEffect(value.effect) - ); -} - -type OperationEffectHandlers = { - [TKind in OperationEffect["kind"]]: ( - effect: Extract, - context: OperationContext, - ) => Promise; -}; - -const OPERATION_EFFECT_HANDLERS = { - value: async (effect) => effect.value, - output: async (effect, context) => { - await writeCommandOutput(effect.output, context.sink); - }, - local: async (effect, context) => effect.run(context), - http: async (effect, context) => { - const body = await sendOperationHttp(effect.request, context.execution); - return effect.decode ? await effect.decode(body, context) : body; - }, - "http-stream": async (effect, context) => { - const stream = await sendOperationHttpStream(effect.request, context.execution); - return effect.decode(stream, context); - }, - all: async (effect, context) => { - const results = await Promise.all( - effect.effects.map((nestedEffect) => runOperationEffect(nestedEffect, context)), - ); - return effect.combine(results, context); - }, - progress: async (effect, context) => { - const progress = startProgress(effect.message); - try { - const result = await runOperationEffect(effect.effect, context); - progress.done(); - return result; - } catch (error) { - progress.fail(); - throw error; - } - }, - scope: async (effect, context) => { - const scope = await effect.acquire(context); - try { - return await runOperationEffect(scope.effect, context); - } finally { - await scope.release?.(); - } - }, -} satisfies OperationEffectHandlers; - -export async function runOperationEffect( - effect: OperationEffect, - context: OperationContext, -): Promise { - return (await OPERATION_EFFECT_HANDLERS[effect.kind](effect as never, context)) as TResult; -} - -export function runOperationPlan( - plan: OperationPlan, - context: OperationContext, -): Promise { - return runOperationEffect(plan.effect, context); -} diff --git a/cli/tests/pager.test.ts b/cli/src/lib/pager.test.ts similarity index 95% rename from cli/tests/pager.test.ts rename to cli/src/lib/pager.test.ts index 2b870ce..2150d70 100644 --- a/cli/tests/pager.test.ts +++ b/cli/src/lib/pager.test.ts @@ -2,7 +2,7 @@ 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 { configSetGlobal } from "@/lib/config.ts"; +import { configFile, kvSet } from "@/lib/config.ts"; import { buildPagerEnv, resolvePagerOptions, shouldUsePager } from "@/lib/pager.ts"; describe("resolvePagerOptions", () => { @@ -19,17 +19,17 @@ describe("resolvePagerOptions", () => { }); test("config always forces pager when CLI flags unset", () => { - configSetGlobal("query_pager", "always"); + kvSet(configFile(), "query_pager", "always"); expect(resolvePagerOptions()).toEqual({ mode: "always" }); }); test("CLI pager mode wins over config always", () => { - configSetGlobal("query_pager", "always"); + kvSet(configFile(), "query_pager", "always"); expect(resolvePagerOptions("never")).toEqual({ mode: "never" }); }); test("config always triggers pager for short text on TTY", () => { - configSetGlobal("query_pager", "always"); + 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); diff --git a/cli/src/lib/pager.ts b/cli/src/lib/pager.ts index 178ff4a..dca1d50 100644 --- a/cli/src/lib/pager.ts +++ b/cli/src/lib/pager.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { getQueryDefaultPager } from "@/lib/config.ts"; -import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; import { getVisibleTextWidth } from "@/ui/terminal/styles.ts"; import { copyProcessEnv } from "@/lib/env.ts"; @@ -43,7 +43,7 @@ export function buildPagerEnv(env: NodeJS.ProcessEnv = copyProcessEnv()): NodeJS export async function writePagedOutput( text: string, options: PagerOptions, - sink: OutputSink = getOutputSink(), + sink: OutputSink, ): Promise { if (!shouldUsePager(text, options)) { sink.writeHuman(text); diff --git a/cli/tests/pluralize.test.ts b/cli/src/lib/pluralize.test.ts similarity index 100% rename from cli/tests/pluralize.test.ts rename to cli/src/lib/pluralize.test.ts diff --git a/cli/src/lib/profile-configure-core.test.ts b/cli/src/lib/profile-configure-core.test.ts new file mode 100644 index 0000000..14f3a93 --- /dev/null +++ b/cli/src/lib/profile-configure-core.test.ts @@ -0,0 +1,94 @@ +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 { configGet } from "@/lib/config.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"); +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-configure-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + 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" }); + + 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" }); + + 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 () => { + const mixedPlanes = await captureErrorMessage(() => + configureRunSet({ user: "u", password: "p", apiKey: "atm_x", env: "prod" }), + ); + const mixedLakehouseCredentials = await captureErrorMessage(() => + configureRunSet({ user: "u", password: "p", basicToken: "dG9rZW4=" }), + ); + + 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 () => { + const metadata: string[] = []; + const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + runtime.output.writeMetadata = (lines) => metadata.push(...lines); + + await runWithCliRuntime(runtime, () => + configureRunSet({ user: "alice", password: "test-password-value" }), + ); + + expect(metadata.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"); + + 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(""); + }); +}); diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index b913493..59eaa5e 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -1,7 +1,7 @@ import { unlinkSync, rmSync, existsSync } from "node:fs"; import { configFile, configGet, configSet, credentialsFile, kvUnset } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; -import { deriveProfileName, listProfiles } from "@/features/profile/model.ts"; +import { deriveProfileName, listProfiles } from "@/lib/profile/model.ts"; import { ensureProfileExists, profileConfigFile, @@ -89,13 +89,6 @@ function configureClearManagementCredentials(profileName: string): void { configSet("oauth_expiry", "", profileName); } -export function configureClearAll(profileName: string): void { - configureClearLakehouseCredentials(profileName); - configureClearManagementCredentials(profileName); - clearProfileEndpoint("api_base", profileName); - clearProfileEndpoint("management_api_base", profileName); -} - export async function configureRunSet( options: ConfigureOptions, sink: OutputSink = getOutputSink(), diff --git a/cli/tests/configure-credential-status.test.ts b/cli/src/lib/profile-configure-credential-status.test.ts similarity index 83% rename from cli/tests/configure-credential-status.test.ts rename to cli/src/lib/profile-configure-credential-status.test.ts index 47c15a1..f4e7e10 100644 --- a/cli/tests/configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -7,12 +7,11 @@ import { configureCredentialStatus, lakehousePlaneStatusDetail, managementPlaneStatusDetail, -} from "@/features/profile/model.ts"; -import { buildConfigureShowView } from "@/features/profile/views.ts"; +} from "@/lib/profile/model.ts"; import { formatConfigureAuthenticationLines, formatConfigureSessionSummary, -} from "@/features/profile/render.ts"; +} from "@/lib/profile/render.ts"; import { secretSet } from "@/lib/secrets.ts"; import { configSet } from "@/lib/config.ts"; @@ -194,43 +193,4 @@ describe("buildConfigureShowData", () => { }); expect(JSON.stringify(data)).not.toContain("atm_override"); }); - - test("configure show view separates summary and authentication sections", () => { - secretSet("api-key", "atm_test", profileName); - configSet("api_key_env", "production", profileName); - - const view = buildConfigureShowView(buildConfigureShowData(profileName)); - const [summary, authentication] = view.sections; - const [summaryRows] = summary?.blocks ?? []; - const [authenticationRows] = authentication?.blocks ?? []; - - expect(summaryRows?.kind).toBe("rows"); - if (summaryRows?.kind === "rows") { - expect(summaryRows.rows).toEqual( - expect.arrayContaining([ - { label: "Active profile:", value: "default" }, - { - label: "Data plane:", - value: [ - { - text: "https://api.altertable.ai", - style: "accent", - href: "https://api.altertable.ai", - }, - ], - }, - ]), - ); - } - - expect(authenticationRows?.kind).toBe("rows"); - if (authenticationRows?.kind === "rows") { - expect(authenticationRows.rows).toEqual( - expect.arrayContaining([ - { label: "Authentication:", value: "management API key" }, - { label: "environment:", value: "production", level: 1 }, - ]), - ); - } - }); }); diff --git a/cli/tests/configure-data-plane-url.test.ts b/cli/src/lib/profile-configure-data-plane.test.ts similarity index 87% rename from cli/tests/configure-data-plane-url.test.ts rename to cli/src/lib/profile-configure-data-plane.test.ts index 93f7655..ce60d82 100644 --- a/cli/tests/configure-data-plane-url.test.ts +++ b/cli/src/lib/profile-configure-data-plane.test.ts @@ -4,8 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getBootstrapCliContext } from "@/context.ts"; import { configGet } from "@/lib/config.ts"; -import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.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 = ""; @@ -22,10 +23,7 @@ beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-configure-url-test-")); }); -afterEach(async () => { - await runInTestHome(async () => { - configureClearAll(profileName); - }); +afterEach(() => { rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; delete process.env.ALTERTABLE_SECRET_BACKEND; diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index 38e79ba..0f9df1e 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -1,11 +1,3 @@ -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 { configGet } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { secretExists, secretGet } from "@/lib/secrets.ts"; @@ -14,10 +6,11 @@ import { configureCredentialStatus, lakehousePlaneStatusDetail, managementPlaneStatusDetail, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; import type { OutputSink } from "@/lib/runtime.ts"; import { span } from "@/ui/document.ts"; -import { ensurePromptColorAlignment, renderDisplayText } from "@/ui/terminal/styles.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import type { Prompts, SelectOption } from "@/ui/prompts.ts"; export type ConfigureWizardScope = "both" | "management" | "lakehouse"; @@ -26,175 +19,10 @@ export type ConfigureWizardOptions = { profile?: string; org?: string; allowInsecureHttp?: boolean; - prompts?: ConfigurePrompts; + prompts?: Prompts; sink?: OutputSink; }; -export type ConfigureSelectOption = { - value: string; - label: string; -}; - -export type ConfigureReadSelectOptions = { - leadingNewline?: boolean; -}; - -export type ConfigurePrompts = { - writePrompt(line: string): void; - readLine(prompt: string): Promise; - readPassword(prompt: string): Promise; - readSelect( - title: string, - options: ConfigureSelectOption[], - defaultValue?: string, - selectOptions?: ConfigureReadSelectOptions, - ): Promise; - readConfirm(prompt: string, defaultYes?: boolean): Promise; -}; - -export class ConfigurePromptCancelled extends CliError { - constructor() { - super("Configuration cancelled."); - } -} - -function writePromptLine(line: string): void { - process.stderr.write(line); -} - -async function readStdinLine(): Promise { - 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()); - } - - function onData(chunk: Buffer): void { - buffer += chunk.toString("utf8"); - if (buffer.includes("\n")) { - settle(); - } - } - - input.on("data", onData); - input.on("end", settle); - input.resume(); - }); -} - -async function readInteractiveLine(prompt: string): Promise { - if (!input.isTTY) { - writePromptLine(prompt); - return (await readStdinLine()).trim(); - } - ensurePromptColorAlignment(); - const result = await clackText({ - message: normalizePromptMessage(prompt), - input, - output, - }); - return resolvePromptResult(result); -} - -async function readHiddenPassword(prompt: string): Promise { - if (!input.isTTY) { - writePromptLine(prompt); - return (await readStdinLine()).trim(); - } - - ensurePromptColorAlignment(); - const result = await clackPassword({ - message: normalizePromptMessage(prompt), - input, - output, - }); - return resolvePromptResult(result); -} - -function normalizePromptMessage(prompt: string): string { - return prompt.trim().replace(/:\s*$/, ""); -} - -function resolvePromptResult(result: string | symbol): string { - if (isCancel(result)) { - throw new ConfigurePromptCancelled(); - } - return result; -} - -async function readSelect( - title: string, - options: ConfigureSelectOption[], - defaultValue?: string, - selectOptions: ConfigureReadSelectOptions = {}, -): Promise { - if (options.length === 0) { - throw new CliError("No options available."); - } - - const leadingNewline = selectOptions.leadingNewline !== false ? "\n" : ""; - - if (!input.isTTY) { - throw new CliError("Interactive select requires a TTY."); - } - - if (leadingNewline) { - writePromptLine(leadingNewline); - } - - ensurePromptColorAlignment(); - const result = await clackSelect({ - message: title, - options: options.map((option) => ({ - value: option.value, - label: option.label, - hint: option.value === defaultValue ? "default" : undefined, - })), - initialValue: defaultValue, - input, - output, - }); - return resolvePromptResult(result); -} - -async function readConfirm(prompt: string, defaultYes = true): Promise { - if (!input.isTTY) { - const answer = (await readStdinLine()).trim().toLowerCase(); - if (answer === "") { - return defaultYes; - } - return answer === "y" || answer === "yes"; - } - - ensurePromptColorAlignment(); - const result = await clackConfirm({ - message: normalizePromptMessage(prompt), - initialValue: defaultYes, - input, - output, - }); - if (isCancel(result)) { - throw new ConfigurePromptCancelled(); - } - return result; -} - -export const defaultConfigurePrompts: ConfigurePrompts = { - writePrompt: writePromptLine, - readLine: readInteractiveLine, - readPassword: readHiddenPassword, - readSelect, - readConfirm, -}; - const DEFAULT_CONTROL_PLANE_URL = "https://app.altertable.ai"; const DEFAULT_DATA_PLANE_URL = "https://api.altertable.ai"; const DEFAULT_SCOPE: ConfigureWizardScope = "both"; @@ -211,7 +39,7 @@ type PromptScopeSelection = "management" | "lakehouse" | "both"; type ScopePromptModel = { defaultValue: PromptScopeSelection; - options: ConfigureSelectOption[]; + options: SelectOption[]; }; function formatScopeSelectLabel(plane: "management" | "lakehouse", profileName: string): string { @@ -226,10 +54,7 @@ function formatScopeSelectLabel(plane: "management" | "lakehouse", profileName: return renderDisplayText([span(name, "strong"), span(` ${detail}`, "subtle")]); } -function toScopePromptOption( - value: PromptScopeSelection, - profileName: string, -): ConfigureSelectOption { +function toScopePromptOption(value: PromptScopeSelection, profileName: string): SelectOption { if (value === "both") { return { value, label: renderDisplayText([span("Both", "strong")]) }; } @@ -290,7 +115,7 @@ function parseScopeSelection(selection: string): ConfigureWizardScope { } export async function promptConfigureScope( - prompts: ConfigurePrompts, + prompts: Prompts, requestedScope: ConfigureWizardScope, profileName: string, ): Promise { @@ -323,7 +148,7 @@ export function planesForConfigureScope( } async function readLineWithDefault( - prompts: ConfigurePrompts, + prompts: Prompts, prompt: string, defaultValue: string, ): Promise { @@ -332,7 +157,7 @@ async function readLineWithDefault( } async function readRequiredPassword( - prompts: ConfigurePrompts, + prompts: Prompts, prompt: string, requiredMessage: string, ): Promise { @@ -344,7 +169,7 @@ async function readRequiredPassword( } type ReadSecretWithOptionalReuseArgs = { - prompts: ConfigurePrompts; + prompts: Prompts; secretKey: string; keepPrompt: string; passwordPrompt: string; @@ -367,7 +192,7 @@ async function readSecretWithOptionalReuse( } export async function collectManagementCredentials( - prompts: ConfigurePrompts, + prompts: Prompts, options: ConfigureWizardOptions, profileName: string, ): Promise<{ options: ConfigureOptions; org: string }> { @@ -444,7 +269,7 @@ export async function collectManagementCredentials( } export async function collectLakehouseCredentials( - prompts: ConfigurePrompts, + prompts: Prompts, options: ConfigureWizardOptions, profileName: string, ): Promise { diff --git a/cli/tests/profile-configure.test.ts b/cli/src/lib/profile-configure.test.ts similarity index 98% rename from cli/tests/profile-configure.test.ts rename to cli/src/lib/profile-configure.test.ts index 75de0d2..fe00099 100644 --- a/cli/tests/profile-configure.test.ts +++ b/cli/src/lib/profile-configure.test.ts @@ -2,13 +2,14 @@ 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 { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; +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, runWithCliRuntime } from "@/lib/runtime.ts"; +import { createCliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; import { ConfigurationError } from "@/lib/errors.ts"; const profileName = "default"; @@ -20,7 +21,7 @@ function createMockPrompts(responses: { confirms?: boolean[]; lines?: string[]; passwords?: string[]; -}): ConfigurePrompts { +}): Prompts { let selectIndex = 0; let confirmIndex = 0; let lineIndex = 0; diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index 18022c4..d73d780 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -1,10 +1,10 @@ -import type { ArgsDef } from "citty"; +import { defineArgs } from "@/lib/command.ts"; import { configureRunSet, withConfigureProfileContext, type ConfigureOptions, } from "@/lib/profile-configure-core.ts"; -import { formatConfigureSessionSummary } from "@/features/profile/render.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"; @@ -13,15 +13,14 @@ import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; import { collectLakehouseCredentials, collectManagementCredentials, - ConfigurePromptCancelled, - defaultConfigurePrompts, planesForConfigureScope, 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 "@/features/profile/model.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"; @@ -95,7 +94,7 @@ async function runConfigureWizardInCurrentProfile( profileName: string, ): Promise { const sink = options.sink ?? getOutputSink(); - const prompts = options.prompts ?? defaultConfigurePrompts; + const prompts = options.prompts ?? defaultPrompts; if (isJsonOutput(getCliContext())) { throw new ConfigurationError( @@ -148,7 +147,7 @@ async function runConfigureWizardInCurrentProfile( writeOutro(sink, configuredPlanes, targetProfile); } catch (error) { - if (error instanceof ConfigurePromptCancelled) { + if (error instanceof PromptCancelled) { sink.writeMetadata([renderDisplayText([span("Configuration cancelled.", "subtle")])]); throw error; } @@ -195,7 +194,7 @@ export type ConfigureCommandArgs = { }; /** Credential/endpoint flags shared by `profile --configure`; spread into the profile command args. */ -export const configureArgs = { +export const configureArgs = defineArgs({ user: { type: "string", description: "Lakehouse username (global)" }, password: { type: "string", description: "Lakehouse password (global)" }, "basic-token": { type: "string", description: "Pre-encoded HTTP Basic token" }, @@ -225,7 +224,7 @@ export const configureArgs = { options: ["management", "lakehouse"], description: "Limit the interactive wizard to one plane (default: both)", }, -} satisfies ArgsDef; +}); function buildConfigureOptions(args: ConfigureCommandArgs): ConfigureOptions { return { diff --git a/cli/tests/profile-status.test.ts b/cli/src/lib/profile-status.test.ts similarity index 87% rename from cli/tests/profile-status.test.ts rename to cli/src/lib/profile-status.test.ts index 5cbea8a..51b0971 100644 --- a/cli/tests/profile-status.test.ts +++ b/cli/src/lib/profile-status.test.ts @@ -5,7 +5,9 @@ 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, runWithCliRuntime } from "@/lib/runtime.ts"; +import { createCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { createExecutionContext } from "@/lib/execution-context.ts"; let testHome = ""; let mockFile = ""; @@ -49,7 +51,10 @@ describe("configureVerify", () => { await configureRunSet({ user: "alice", password: "secret" }); refreshCliRuntimeContext(runtime.context); - const result = await configureVerify(["management", "lakehouse"]); + const result = await configureVerify( + ["management", "lakehouse"], + createExecutionContext(runtime), + ); expect(result.verified.management).toBe(true); expect(result.verified.lakehouse).toBe(true); expect(result.errors).toHaveLength(0); @@ -69,7 +74,7 @@ describe("configureVerify", () => { await configureRunSet({ apiKey: "atm_bad", env: "prod" }); refreshCliRuntimeContext(runtime.context); - const result = await configureVerify(["management"]); + 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"); diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 3836d94..fb6e3f9 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -1,18 +1,11 @@ -import { getCliContext } from "@/context.ts"; import { configGet } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { defineHttpOperation, type HttpOperationDescriptor } from "@/lib/http-operation.ts"; -import { lakehouseVerifyOperation } from "@/lib/lakehouse-operations.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/features/management/render.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { runOperationPlan } from "@/lib/operation-effect.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 { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { resolveWorkingProfile } from "@/lib/profile-store.ts"; - -export { configureCredentialStatus } from "@/features/profile/model.ts"; export type ConfigureAuthPlane = "management" | "lakehouse"; @@ -28,16 +21,6 @@ export type ConfigureVerifyResult = { errors: ConfigureVerifyError[]; }; -const managementVerifyOperation = defineHttpOperation({ - id: "management.verify", - request: () => ({ - plane: "management", - method: "GET", - endpoint: "/whoami", - }), - decode: (body) => parseWhoamiPrincipalName(body), -}); - function parseWhoamiPrincipalName(body: string): string { try { const data = JSON.parse(body) as WhoamiResponse; @@ -57,7 +40,8 @@ function getErrorMessage(error: unknown): string { type ConfigureVerifier = { progressLabel: string; failureLabel: string; - operation: HttpOperationDescriptor; + request: () => HttpRequest; + parse?: (body: string) => string; successMessage: (result: string) => string; }; @@ -65,41 +49,34 @@ const CONFIGURE_VERIFY_PLANES = { management: { progressLabel: "Verifying management API key", failureLabel: "Management API key verification failed.", - operation: managementVerifyOperation, + request: () => ({ plane: "management", method: "GET", endpoint: "/whoami" }), + parse: parseWhoamiPrincipalName, successMessage: (principalLine) => `Management API key verified (${principalLine}).`, }, lakehouse: { progressLabel: "Verifying lakehouse credentials", failureLabel: "Lakehouse credentials verification failed.", - operation: lakehouseVerifyOperation, + request: buildLakehouseVerifyRequest, successMessage: () => "Lakehouse credentials verified.", }, } satisfies Record; -function createConfigureVerifyOperationContext(): OperationContext { - const runtime = getCliRuntime(); - return { - args: {}, - rawArgs: [], - runtime, - sink: runtime.output, - execution: createExecutionContext(runtime), - }; -} - -async function verifyPlane(plane: ConfigureAuthPlane, context: OperationContext): Promise { - const verifier = CONFIGURE_VERIFY_PLANES[plane]; - const result = await runOperationPlan(verifier.operation.plan(undefined, context), context); +async function verifyPlane( + plane: ConfigureAuthPlane, + execution: ExecutionContext, +): Promise { + const verifier: ConfigureVerifier = CONFIGURE_VERIFY_PLANES[plane]; + const response = await sendHttp(verifier.request(), execution); + const result = verifier.parse ? verifier.parse(response) : response; return verifier.successMessage(result); } export async function configureVerify( planes: ConfigureAuthPlane[], + execution: ExecutionContext, ): Promise { - refreshCliRuntimeContext(getCliContext()); - const result: ConfigureVerifyResult = { - profile: resolveWorkingProfile(getCliContext().profile), + profile: execution.profile, configured: [...planes], verified: { management: false, lakehouse: false }, errors: [], @@ -109,7 +86,7 @@ export async function configureVerify( const verifier = CONFIGURE_VERIFY_PLANES[plane]; const progress = startProgress(verifier.progressLabel); try { - const successMessage = await verifyPlane(plane, createConfigureVerifyOperationContext()); + const successMessage = await verifyPlane(plane, execution); progress.done(formatProgressStatus("success", successMessage)); result.verified[plane] = true; } catch (error) { diff --git a/cli/src/lib/profile-store.test.ts b/cli/src/lib/profile-store.test.ts new file mode 100644 index 0000000..e91103e --- /dev/null +++ b/cli/src/lib/profile-store.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { kvGet } from "@/lib/config.ts"; +import { + DEFAULT_PROFILE_NAME, + ensureProfileExists, + ensureProfilesLayout, + getActiveProfileName, + profileConfigFile, + profileExists, + resolveWorkingProfile, + setActiveProfile, +} from "@/lib/profile-store.ts"; + +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-profile-store-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_PROFILE; +}); + +describe("profile store", () => { + test("creates the default profile layout and active selection", () => { + ensureProfilesLayout(); + + expect(existsSync(profileConfigFile(DEFAULT_PROFILE_NAME))).toBe(false); + 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"; + + expect(resolveWorkingProfile("production")).toBe("production"); + expect(resolveWorkingProfile()).toBe("staging"); + + delete process.env.ALTERTABLE_PROFILE; + 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"); + + ensureProfileExists("staging"); + ensureProfileExists("prod-eu"); + expect(profileExists("staging")).toBe(true); + expect(profileExists("prod-eu")).toBe(true); + }); +}); diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts new file mode 100644 index 0000000..91a15fa --- /dev/null +++ b/cli/src/lib/profile/model.test.ts @@ -0,0 +1,161 @@ +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 { configureRunSet } from "@/lib/profile-configure-core.ts"; +import { + createEmptyProfile, + deleteProfile, + deriveProfileName, + inspectProfile, + listProfiles, + renameProfile, + updateProfile, +} from "@/lib/profile/model.ts"; +import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/profile-store.ts"; +import { secretGet } from "@/lib/secrets.ts"; +import { createFakeKeychain } from "@/test-utils/keychain.ts"; + +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-profile-model-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + setCliContext({ debug: false, json: false, agent: false }); +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + 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"); + }); + + 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", + endpoints: { + 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" }); + + 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"); + + const profiles = listProfiles(); + 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"); + + expect(() => deleteProfile("staging")).toThrow("Cannot delete the active profile"); + deleteProfile("default"); + + 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"; + const keychain = createFakeKeychain(); + keychain.store.secretSet("api-key", "atm_staging", "staging"); + keychain.store.secretSet("lakehouse/password", "lakehouse-secret", "staging"); + + 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( + keychain.calls.some( + (args) => + args.includes("delete-generic-password") && args.includes("profile/staging/api-key"), + ), + ).toBe(true); + }); + + 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/staging/api-key"); + + 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"); + }); + + test("renames profile config, active selection, and secrets", async () => { + await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); + setActiveProfile("staging"); + + renameProfile("staging", "acme_staging"); + + expect(profileExists("staging")).toBe(false); + expect(profileExists("acme_staging")).toBe(true); + expect(getActiveProfileName()).toBe("acme_staging"); + expect(secretGet("api-key", "acme_staging")).toBe("atm_staging"); + expect(secretGet("api-key", "staging")).toBe(""); + }); + + test("rolls back profile config and Keychain secrets after a rename failure", () => { + 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"); + keychain.failingWrites.add("profile/acme_staging/lakehouse/password"); + + expect(() => renameProfile("staging", "acme_staging", keychain.store)).toThrow( + "Failed to store secret in macOS keychain", + ); + + expect(profileExists("staging")).toBe(true); + expect(profileExists("acme_staging")).toBe(false); + expect(keychain.store.secretGet("api-key", "staging")).toBe("atm_staging"); + expect(keychain.store.secretGet("lakehouse/password", "staging")).toBe("lakehouse-secret"); + expect(keychain.store.secretGet("api-key", "acme_staging")).toBe(""); + }); +}); diff --git a/cli/src/features/profile/model.ts b/cli/src/lib/profile/model.ts similarity index 85% rename from cli/src/features/profile/model.ts rename to cli/src/lib/profile/model.ts index e891d68..73de57e 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -6,15 +6,14 @@ import { resolveApiBase, resolveManagementApiBase, } from "@/lib/config.ts"; -import { getCliContext } from "@/context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { CONFIG_ENV_NAMES, isSecretEnv, readEnv } from "@/lib/env.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; import { moveProfileSecrets, secretDelete, secretExists, secretStoreDisplay, + type SecretStore, } from "@/lib/secrets.ts"; import { assertSafeProfileName, @@ -31,24 +30,6 @@ import { profileDir, profileExists, readProfileConfigRecord, - resolveWorkingProfile, - setActiveProfile, -} from "@/lib/profile-store.ts"; - -export { - assertSafeProfileName, - DEFAULT_PROFILE_NAME, - ensureProfileExists, - ensureProfilesLayout, - envConfigMode, - FROM_ENV_PSEUDOPROFILE_NAME, - getActiveProfileName, - isFromEnvProfile, - profileConfigFile, - profileExists, - profilesDir, - readProfileConfigRecord, - resolveWorkingProfile, setActiveProfile, } from "@/lib/profile-store.ts"; @@ -60,6 +41,10 @@ const PROFILE_SECRET_ACCOUNTS = [ "oauth/refresh-token", ] as const; +type ProfileSecretStore = Pick; + +const PROFILE_SECRET_STORE: ProfileSecretStore = { moveProfileSecrets, secretDelete }; + type ProfileManagementAuth = "oauth" | "api_key" | "none"; type ProfileLakehouseAuth = "basic_token" | "username_password" | "none"; type ProfileAuth = { @@ -384,7 +369,11 @@ export function updateProfile(name: string, update: ProfileUpdate): ProfileInspe return inspectProfile(name); } -export function renameProfile(source: string, target: string): void { +export function renameProfile( + source: string, + target: string, + secrets: ProfileSecretStore = PROFILE_SECRET_STORE, +): void { assertSafeProfileName(source); assertSafeProfileName(target); ensureProfilesLayout(); @@ -402,7 +391,7 @@ export function renameProfile(source: string, target: string): void { const active = getActiveProfileName(); renameSync(profileDir(source), profileDir(target)); try { - moveProfileSecrets(source, target, [...PROFILE_SECRET_ACCOUNTS]); + secrets.moveProfileSecrets(source, target, [...PROFILE_SECRET_ACCOUNTS]); } catch (error) { if (profileExists(target) && !profileExists(source)) { renameSync(profileDir(target), profileDir(source)); @@ -415,7 +404,10 @@ export function renameProfile(source: string, target: string): void { } } -export function deleteProfile(name: string): void { +export function deleteProfile( + name: string, + secrets: ProfileSecretStore = PROFILE_SECRET_STORE, +): void { assertSafeProfileName(name); ensureProfilesLayout(); @@ -431,7 +423,7 @@ export function deleteProfile(name: string): void { } for (const account of PROFILE_SECRET_ACCOUNTS) { - secretDelete(account, name); + secrets.secretDelete(account, name); } rmSync(profileDir(name), { recursive: true, force: true }); @@ -541,15 +533,6 @@ function hasStoredLakehouseCredentials(profileName: string): boolean { ); } -/** - * The profile identity currently in effect. Normally the active stored profile, - * but env configuration isolates the identity to the reserved `_from_env` - * pseudo-profile (see `envConfigMode`). - */ -export function resolveActiveProfileName(): string { - return resolveWorkingProfile(getCliContext().profile); -} - /** The profile-configuring env vars currently set, with secrets masked. */ export function configuredEnvConfig(): Array<{ name: string; display: string }> { return CONFIG_ENV_NAMES.flatMap((name) => { @@ -728,70 +711,3 @@ export function lakehousePlaneStatusDetail(profileName: string): string | null { } return configGet("user", profileName) || "unknown"; } - -export type ActiveContext = { - profile: string; - environment?: string; - data_plane: string; - control_plane: string; - management: string | null; - lakehouse: string | null; - credentialStatus: ReturnType; - credentials: ConfigureShowData["credentials"]; - overrides: ConfigureShowData["overrides"]; - principal?: WhoamiResponse["principal"]; - organization?: WhoamiResponse["organization"]; -}; - -function resolveEnvironment(showData: ConfigureShowData): string | undefined { - const envOverride = readEnv("ALTERTABLE_ENV"); - if (envOverride && envOverride.length > 0) { - return envOverride; - } - const managementCredential = showData.credentials.management; - return managementCredential.configured ? managementCredential.environment : undefined; -} - -export function buildActiveContext(profileName: string): ActiveContext { - ensureProfileExists(profileName); - const showData = buildConfigureShowData(profileName); - const credentialStatus = configureCredentialStatus(profileName); - - return { - profile: showData.profile, - environment: resolveEnvironment(showData), - data_plane: showData.data_plane, - control_plane: showData.control_plane, - management: managementPlaneStatusDetail(profileName), - lakehouse: lakehousePlaneStatusDetail(profileName), - credentialStatus, - credentials: showData.credentials, - overrides: showData.overrides, - }; -} - -export function withAuthenticatedIdentity( - context: ActiveContext, - whoami: WhoamiResponse, -): ActiveContext { - return { - ...context, - principal: whoami.principal, - organization: whoami.organization, - }; -} - -export function activeContextToJson(context: ActiveContext): Record { - return { - profile: context.profile, - environment: context.environment ?? null, - data_plane: context.data_plane, - control_plane: context.control_plane, - management: context.management, - lakehouse: context.lakehouse, - credentials: context.credentials, - overrides: context.overrides, - ...(context.principal !== undefined ? { principal: context.principal } : {}), - ...(context.organization !== undefined ? { organization: context.organization } : {}), - }; -} diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts new file mode 100644 index 0000000..b8c71c5 --- /dev/null +++ b/cli/src/lib/profile/render.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import type { ProfileInspect } from "@/lib/profile/model.ts"; +import { formatProfileInspect, formatProfileStatus } from "@/lib/profile/render.ts"; + +const profile: ProfileInspect = { + name: "acme_prod", + active: true, + config_file: "/config/profiles/acme_prod/config", + organization: { slug: "acme" }, + principal: {}, + environment: "production", + endpoints: {}, + auth: { management: "api_key", lakehouse: "none" }, + status: "configured", + timestamps: {}, +}; + +describe("profile rendering", () => { + test("renders stored profile details without exposing credentials", () => { + const output = formatProfileInspect(profile); + + 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", () => { + const output = formatProfileStatus({ + profile, + verification: { + profile: profile.name, + 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"); + }); +}); diff --git a/cli/src/features/profile/render.ts b/cli/src/lib/profile/render.ts similarity index 63% rename from cli/src/features/profile/render.ts rename to cli/src/lib/profile/render.ts index 7a4fe51..f822ed9 100644 --- a/cli/src/features/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,25 +1,19 @@ import { - buildActiveContextDetailsView, - buildActiveContextSummaryView, buildProfileInspectView, buildProfileListView, buildProfileStatusView, configureAuthenticationRows, type ProfileStatusResult, -} from "@/features/profile/views.ts"; +} from "@/lib/profile/views.ts"; import { - buildActiveContext, buildConfigureShowData, - type ActiveContext, type ProfileInspect, type ProfileSummary, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { renderDocument, renderDocumentText, renderRows } from "@/ui/renderers/terminal.ts"; +import { renderDocumentText, renderRows } from "@/ui/renderers/terminal.ts"; import { nestedIndent, - padLeft, TERMINAL_INDENT, TERMINAL_LABEL_WIDTH, TERMINAL_NESTED_LABEL_WIDTH, @@ -71,27 +65,3 @@ export function formatConfigureSessionSummary( ): string[] { return formatConfigureAuthenticationLines(profileName, { planes: configuredPlanes }); } - -export function formatActiveContextSummary(context: ActiveContext): string { - const lines = renderDocument(buildActiveContextSummaryView(context)); - return `\n\n${padLeft(lines).join("\n")}`; -} - -export function formatActiveContextDetails(context: ActiveContext): string { - const lines = renderDocument(buildActiveContextDetailsView(context), { - indent: TERMINAL_INDENT, - labelWidth: TERMINAL_LABEL_WIDTH, - }); - return lines.join("\n"); -} - -export function tryFormatActiveContextSummary(profileName: string): string { - try { - return formatActiveContextSummary(buildActiveContext(profileName)); - } catch (error) { - if (error instanceof ConfigurationError) { - return ""; - } - throw error; - } -} diff --git a/cli/src/lib/profile/views.test.ts b/cli/src/lib/profile/views.test.ts new file mode 100644 index 0000000..20673bc --- /dev/null +++ b/cli/src/lib/profile/views.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import type { ProfileInspect, ProfileSummary } from "@/lib/profile/model.ts"; +import { + buildProfileDirenvView, + buildProfileInspectView, + buildProfileListView, + buildProfileShellExportView, + profileStatusToJson, +} from "@/lib/profile/views.ts"; + +const profile: ProfileInspect = { + name: "acme_prod", + active: true, + 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", + timestamps: {}, +}; + +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.table.emptyMessage).toBe("No profiles configured."); + } + }); + + 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.rows).toEqual( + expect.arrayContaining([ + { label: "Profile", value: "acme_prod (active)" }, + { label: "Organization", value: "acme" }, + { + label: "Data plane", + value: [ + { + text: "https://api.example.com", + style: "accent", + href: "https://api.example.com", + }, + ], + }, + ]), + ); + } + }); + + test("derives shell and direnv views from the same profile selection", () => { + expect(buildProfileShellExportView("acme_prod")).toEqual({ + env: { ALTERTABLE_PROFILE: "acme_prod" }, + }); + expect(buildProfileDirenvView("acme_prod")).toEqual({ + env: { ALTERTABLE_PROFILE: "acme_prod" }, + comments: ["Generated by: altertable profile direnv"], + }); + }); + + test("serializes profile status without changing its public shape", () => { + const json = profileStatusToJson({ + profile, + verification: { + profile: profile.name, + configured: ["management"], + verified: { management: true, lakehouse: false }, + errors: [], + }, + }); + + expect(json).toMatchObject({ + profile: { name: "acme_prod" }, + verification: { configured: ["management"] }, + }); + }); +}); diff --git a/cli/src/features/profile/views.ts b/cli/src/lib/profile/views.ts similarity index 59% rename from cli/src/features/profile/views.ts rename to cli/src/lib/profile/views.ts index 3a64dc4..2a26035 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -3,7 +3,7 @@ import { type ConfigureVerifyResult, formatConfigureVerifyRemediation, } from "@/lib/profile-status.ts"; -import type { ConfigureSelectOption } from "@/lib/profile-configure-interactive.ts"; +import type { SelectOption } from "@/ui/prompts.ts"; import { document, rows, @@ -14,20 +14,16 @@ import { type DisplayDocument, type DisplayRow, type DisplayText, - type DisplayTextStyle, } from "@/ui/document.ts"; import { TERMINAL_INDENT } from "@/ui/terminal/spacing.ts"; import type { - ActiveContext, - ConfigureCredentialStatus, ConfigureLakehouseCredential, ConfigureManagementCredential, ConfigureShowData, - ConfigureShowOverrides, ProfileInspect, ProfileSummary, -} from "@/features/profile/model.ts"; -import { isFromEnvProfile } from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; +import { isFromEnvProfile } from "@/lib/profile-store.ts"; import type { ShellExportView } from "@/ui/shell/model.ts"; const ACTIVE_PROFILE_MARK = "✓"; @@ -38,10 +34,6 @@ function linkedUrl(value: string): DisplayText { return /^https?:\/\//.test(value) ? [span(value, "accent", value)] : value; } -function notConfigured(): DisplayText { - return [span("not set", "muted")]; -} - function profileInspectRows(profile: ProfileInspect): DisplayRow[] { // The env pseudo-profile has no stored name/status; a heading line replaces // them (see buildProfileInspectView). @@ -155,7 +147,7 @@ export function buildProfileListView(profiles: readonly ProfileSummary[]): Displ ); } -export function profileSwitchOption(profile: ProfileSummary): ConfigureSelectOption { +export function profileSwitchOption(profile: ProfileSummary): SelectOption { const details = [ profile.active && "active", profile.organization && `org: ${profile.organization}`, @@ -237,10 +229,6 @@ export function profileStatusToJson(result: ProfileStatusResult): Record", "accent"), - span("') for management commands, then '"), - span("altertable profile --configure --scope lakehouse", "accent"), - span("' (or '"), - span("altertable profile --configure --user --password

", "accent"), - span("') for lakehouse queries."), - ], - ]; - } - - if (status.hasManagement && !status.hasLakehouse) { - return [ - [ - span(`${TERMINAL_INDENT}Hint: run '`), - span("altertable profile --configure --scope lakehouse", "accent"), - span("' or '"), - span("altertable profile --configure --user --password

", "accent"), - span("' for lakehouse query, upload, upsert, and append commands."), - ], - ]; - } - - if (status.hasLakehouse && !status.hasManagement) { - return [ - [ - span(`${TERMINAL_INDENT}Hint: run '`), - span("altertable profile --configure --scope management", "accent"), - span("' or '"), - span("altertable profile --configure --api-key atm_xxx --env ", "accent"), - span("' for profile show, catalogs, and other management commands."), - ], - ]; - } - - return []; -} - -export function buildConfigureShowView( - data: ConfigureShowData, - options: ConfigureAuthenticationViewOptions = {}, -): DisplayDocument { - const authentication = configureAuthenticationRows(data, options.planes); - const hints = configureSetupHintLines({ - hasManagement: data.credentials.management.configured, - hasLakehouse: data.credentials.lakehouse.configured, - }); - const overrides = configureOverrideRows(data.overrides); - - return document( - section(rows(configureSummaryRows(data))), - section( - rows(authentication), - ...(hints.length > 0 ? [text(hints)] : []), - ...(overrides.length > 0 ? [rows(overrides)] : []), - ), - ); -} - -type ContextSummaryRow = { - profile: string; - environment: string; - management: string; - lakehouse: string; -}; - -function formatConfiguredValue(detail: string | null | undefined): DisplayText { - if (detail === null || detail === undefined || detail.length === 0) { - return notConfigured(); - } - return detail; -} - -function plainStatus(detail: string | null | undefined): string { - if (detail === null || detail === undefined || detail.length === 0) { - return "not set"; - } - return detail; -} - -function formatStatusCell(value: string, style: DisplayTextStyle): DisplayText { - if (value === "not set") { - return notConfigured(); - } - return [span(value, style)]; -} - -function contextSummaryRow(context: ActiveContext): ContextSummaryRow { - return { - profile: context.profile, - environment: plainStatus(context.environment), - management: plainStatus(context.management), - lakehouse: plainStatus(context.lakehouse), - }; -} - -function identityRows(context: ActiveContext): DisplayRow[] { - if (context.principal !== undefined || context.organization !== undefined) { - const principal = context.principal ?? {}; - const organization = context.organization ?? {}; - const identity: DisplayRow[] = []; - if (principal.type === "ServiceAccount") { - identity.push({ - label: "Service account:", - value: `${principal.name ?? ""} (${principal.slug ?? ""})`, - }); - } else if (principal.email) { - identity.push({ label: "User:", value: `${principal.name ?? ""} <${principal.email}>` }); - } else if (principal.name) { - identity.push({ label: "User:", value: principal.name }); - } - if (organization.name || organization.slug) { - identity.push({ - label: "Organization:", - value: `${organization.name ?? ""} (${organization.slug ?? ""})`, - }); - } - return identity; - } - return []; -} - -function contextDetailRows(context: ActiveContext): DisplayRow[] { - return [ - { label: "Profile:", value: context.profile }, - { label: "Environment:", value: formatConfiguredValue(context.environment) }, - ...identityRows(context), - { label: "Data plane:", value: linkedUrl(context.data_plane) }, - { label: "Control plane:", value: linkedUrl(context.control_plane) }, - { label: "Lakehouse:", value: formatConfiguredValue(context.lakehouse) }, - ]; -} - -export function buildActiveContextSummaryView(context: ActiveContext): DisplayDocument { - const summaryBlocks = [ - table({ - rows: [contextSummaryRow(context)], - columns: [ - { - header: "PROFILE", - cell: (entry) => formatStatusCell(entry.profile, "strong"), - }, - { - header: "ENV", - cell: (entry) => formatStatusCell(entry.environment, "accent"), - }, - { - header: "MGMT", - cell: (entry) => formatStatusCell(entry.management, "muted"), - }, - { - header: "LAKEHOUSE", - cell: (entry) => formatStatusCell(entry.lakehouse, "string"), - flex: true, - }, - ], - }), - ...(!context.credentialStatus.hasManagement && !context.credentialStatus.hasLakehouse - ? [text([[span("Hint: run `"), span("altertable profile --configure", "accent"), span("`")]])] - : []), - ]; - - return document(section(...summaryBlocks)); -} - -export function buildActiveContextDetailsView(context: ActiveContext): DisplayDocument { - const hints = configureSetupHintLines(context.credentialStatus); - const overrides = configureOverrideRows(context.overrides); - const detailBlocks = [ - rows(contextDetailRows(context)), - ...(hints.length > 0 ? [text(["", ...hints])] : []), - ...(overrides.length > 0 ? [rows(overrides)] : []), - ]; - - return document(section(...detailBlocks)); -} diff --git a/cli/tests/progress.test.ts b/cli/src/lib/progress.test.ts similarity index 100% rename from cli/tests/progress.test.ts rename to cli/src/lib/progress.test.ts diff --git a/cli/tests/query-column-types.test.ts b/cli/src/lib/query-column-types.test.ts similarity index 100% rename from cli/tests/query-column-types.test.ts rename to cli/src/lib/query-column-types.test.ts diff --git a/cli/tests/query-format.test.ts b/cli/src/lib/query-format.test.ts similarity index 98% rename from cli/tests/query-format.test.ts rename to cli/src/lib/query-format.test.ts index 7a6701f..cef6d3c 100644 --- a/cli/tests/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { configSetGlobal } from "@/lib/config.ts"; -import { parseLakehouseQueryResponse } from "@/lib/lakehouse-client.ts"; +import { configFile, kvSet } from "@/lib/config.ts"; +import { parseLakehouseQueryResponse } from "@/lib/lakehouse-ndjson.ts"; import { defaultDisplayOptions, formatQueryCell, @@ -15,14 +15,14 @@ import { truncateText, truncateTextMiddle, } from "@/lib/query-format.ts"; -import { renderQueryCsv, renderQueryJson } from "@/lib/lakehouse-client.ts"; +import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; import { getVisibleTextWidth } from "@/ui/terminal/styles.ts"; import { forceTerminalColorForTests, restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; let terminalState: TerminalTestState | undefined; @@ -662,8 +662,8 @@ describe("defaultDisplayOptions", () => { }); test("merges config defaults when present", () => { - configSetGlobal("query_max_width", "24"); - configSetGlobal("query_layout", "table"); + kvSet(configFile(), "query_max_width", "24"); + kvSet(configFile(), "query_layout", "table"); const options = defaultDisplayOptions(); expect(options.maxColumnWidth).toBe(24); expect(options.layout).toBe("table"); diff --git a/cli/src/lib/query-output-args.test.ts b/cli/src/lib/query-output-args.test.ts new file mode 100644 index 0000000..4458e76 --- /dev/null +++ b/cli/src/lib/query-output-args.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { CliError } from "@/lib/errors.ts"; +import { parseQueryOutputOptions } 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", + }, + { agent: false, rawArgs: [] }, + ); + + expect(options).toMatchObject({ + format: "markdown", + displayOptions: { layout: "line", columns: ["id", "name"], maxColumnWidth: 24 }, + pagerOptions: { mode: "never" }, + }); + }); + + test("derives machine-readable output from agent context", () => { + expect(parseQueryOutputOptions({}, { agent: true, rawArgs: [] })).toMatchObject({ + format: "json", + pagerOptions: { mode: "never" }, + }); + }); + + test("rejects incompatible or invalid presentation settings", () => { + for (const run of [ + () => parseQueryOutputOptions({}, { agent: true, rawArgs: ["--layout", "table"] }), + () => + parseQueryOutputOptions( + { "max-width": "4" }, + { agent: false, rawArgs: ["--max-width", "4"] }, + ), + () => + parseQueryOutputOptions( + { pager: "sometimes" }, + { agent: false, rawArgs: ["--pager", "sometimes"] }, + ), + ]) { + expect(run).toThrow(CliError); + } + }); +}); diff --git a/cli/src/lib/query-output-args.ts b/cli/src/lib/query-output-args.ts new file mode 100644 index 0000000..046ae6e --- /dev/null +++ b/cli/src/lib/query-output-args.ts @@ -0,0 +1,129 @@ +import { asCliArgString } from "@/lib/cli-args.ts"; +import { defineArgs } from "@/lib/command.ts"; +import { CliError } from "@/lib/errors.ts"; +import { 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"; + +const MIN_MAX_COLUMN_WIDTH = 8; +const QUERY_RESULT_FORMAT_OPTIONS = ["human", "json", "csv", "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; + +export const queryResultFormatArgs = defineArgs({ + format: { + type: "enum", + description: "Output format: human, json, csv, or markdown", + default: "human", + options: [...QUERY_RESULT_FORMAT_OPTIONS], + }, +}); + +export const queryDisplayArgs = defineArgs({ + layout: { + 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", + }, +}); + +export const queryPagerArgs = defineArgs({ + pager: { + type: "enum", + description: "Pager mode for human output: auto, always, or never", + default: "auto", + options: [...PAGER_MODE_OPTIONS], + }, +}); + +export type QueryOutputOptions = { + format: QueryResultFormat; + displayOptions: QueryDisplayOptions; + pagerOptions: PagerOptions; +}; + +type ParseQueryOutputOptions = { + agent: boolean; + rawArgs: readonly string[]; +}; + +function hasArgvFlag(rawArgs: readonly string[], flag: string): boolean { + return rawArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); +} + +function validateAgentQueryFlags(options: ParseQueryOutputOptions): void { + if (!options.agent) return; + + for (const flag of AGENT_INCOMPATIBLE_QUERY_FLAGS) { + if (hasArgvFlag(options.rawArgs, flag)) { + throw new CliError( + `${flag} cannot be used with --agent. Use --format json for machine-readable query output.`, + ); + } + } +} + +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."); + return layout; +} + +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 (Number.isNaN(width) || width < 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 columns = columnsText + ? columnsText + .split(",") + .map((name) => name.trim()) + .filter(Boolean) + : undefined; + + return { ...defaults, layout: parseQueryLayout(args), maxColumnWidth, columns }; +} + +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."); + } + return resolvePagerOptions(pager as PagerMode); +} + +export function parseQueryOutputOptions( + args: Record, + options: ParseQueryOutputOptions, +): QueryOutputOptions { + validateAgentQueryFlags(options); + const format = options.agent + ? "json" + : parseQueryResultFormat(args.format === undefined ? "human" : asCliArgString(args.format)); + return { + format, + displayOptions: parseDisplayOptions(args), + pagerOptions: parsePagerOptions(args, options.agent), + }; +} diff --git a/cli/src/lib/lakehouse-client.ts b/cli/src/lib/query-output.ts similarity index 52% rename from cli/src/lib/lakehouse-client.ts rename to cli/src/lib/query-output.ts index 3716d01..88a388d 100644 --- a/cli/src/lib/lakehouse-client.ts +++ b/cli/src/lib/query-output.ts @@ -1,6 +1,4 @@ -import { isJsonOutput } from "@/context.ts"; -import { writeLakehouseCommandOutput, type LakehouseOutputOptions } from "@/lib/command-output.ts"; -import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; import { CliError } from "@/lib/errors.ts"; import { defaultDisplayOptions, @@ -12,39 +10,9 @@ import { } from "@/lib/query-format.ts"; import { resolvePagerOptions, writePagedOutput, type PagerOptions } from "@/lib/pager.ts"; -export { - buildLakehouseAppendRequest, - buildLakehouseQueryPayload, - createLakehouseUploadRequest, - createLakehouseUpsertRequest, - type LakehouseAppendRequestInput, - type LakehouseUploadRequestInput, - type LakehouseUploadRequestScope, - type LakehouseUpsertRequestInput, -} from "@/lib/lakehouse-transport.ts"; - -export { - parseLakehouseQueryResponse, - parseLakehouseQueryStream, - type LakehouseColumn, - type LakehouseQueryResult, - type LakehouseQueryStreamResult, - type LakehouseRow, -} from "@/lib/lakehouse-ndjson.ts"; - -export { getQueryColumnNames } from "@/lib/query-format.ts"; -export type { QueryDisplayOptions } from "@/lib/query-format.ts"; - export type QueryResultFormat = "human" | "json" | "csv" | "markdown"; -export type ManagementOutputFormat = "json" | "table" | "csv" | "markdown"; const QUERY_RESULT_FORMATS = new Set(["human", "json", "csv", "markdown"]); -const MANAGEMENT_OUTPUT_FORMATS = new Set([ - "json", - "table", - "csv", - "markdown", -]); export function parseQueryResultFormat(format: string): QueryResultFormat { if (!QUERY_RESULT_FORMATS.has(format as QueryResultFormat)) { @@ -53,21 +21,6 @@ export function parseQueryResultFormat(format: string): QueryResultFormat { return format as QueryResultFormat; } -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.`); - } - return format as ManagementOutputFormat; -} - -export function renderQueryTable( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, - options?: Partial, -): string { - const displayOptions = { ...defaultDisplayOptions(), layout: "table" as const, ...options }; - return renderQueryHumanOutput(result, displayOptions); -} - export function csvEscapeCell(value: unknown): string { const text = formatQueryCellRaw(value); if (/[",\n\r]/.test(text)) { @@ -103,19 +56,12 @@ export function renderQueryJson( return JSON.stringify(result, null, 2); } -export async function writeLakehouseOutput( - body: string, - options?: LakehouseOutputOptions, -): Promise { - await writeLakehouseCommandOutput(body, options); -} - export function renderQueryOutputText( result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, format: QueryResultFormat, displayOptions?: QueryDisplayOptions, ): string { - if (isJsonOutput() || format === "json") { + if (format === "json") { return renderQueryJson(result); } if (format === "csv") { @@ -128,31 +74,14 @@ export function renderQueryOutputText( return renderQueryHumanOutput(result, displayOptions ?? defaultDisplayOptions()); } -export function renderManagementTabularOutput( - result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, - format: ManagementOutputFormat, -): string { - if (format === "json") { - return renderQueryJson(result); - } - if (format === "csv") { - return renderQueryCsv(result); - } - if (format === "markdown") { - const columnNames = getQueryColumnNames(result); - return renderQueryMarkdown(result, columnNames, defaultDisplayOptions()); - } - return renderQueryHumanOutput(result, { ...defaultDisplayOptions(), layout: "table" }); -} - export async function writeQueryOutput( result: import("./lakehouse-ndjson.ts").LakehouseQueryResult, format: QueryResultFormat, + sink: OutputSink, displayOptions?: QueryDisplayOptions, pagerOptions?: PagerOptions, - sink: OutputSink = getOutputSink(), ): Promise { - const outputText = renderQueryOutputText(result, format, displayOptions); + const outputText = renderQueryOutputText(result, sink.json ? "json" : format, displayOptions); const usePager = format === "human" && !sink.json; if (usePager) { diff --git a/cli/tests/redact.test.ts b/cli/src/lib/redact.test.ts similarity index 100% rename from cli/tests/redact.test.ts rename to cli/src/lib/redact.test.ts diff --git a/cli/tests/relative-time.test.ts b/cli/src/lib/relative-time.test.ts similarity index 100% rename from cli/tests/relative-time.test.ts rename to cli/src/lib/relative-time.test.ts diff --git a/cli/src/lib/runtime.ts b/cli/src/lib/runtime.ts index 27c34f5..4233e7b 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,7 +1,5 @@ -import type { CliContext } from "@/context.ts"; -import { getBootstrapCliContext, isJsonOutput } from "@/context.ts"; import { existsSync } from "node:fs"; -import { writeLakehouseCommandOutput } from "@/lib/command-output.ts"; +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"; @@ -62,7 +60,7 @@ export function createCliRuntime(context: CliContext): CliRuntime { export function getCliRuntime(): CliRuntime { if (!cliRuntime) { - cliRuntime = createCliRuntime(getBootstrapCliContext()); + cliRuntime = createCliRuntime(getBootstrapCliContextState()); } return cliRuntime; } @@ -71,24 +69,6 @@ export function setCliRuntime(runtime: CliRuntime): void { cliRuntime = runtime; } -export function runWithCliRuntime(runtime: CliRuntime, fn: () => T): T { - const previous = cliRuntime; - cliRuntime = runtime; - try { - const result = fn(); - if (result instanceof Promise) { - return result.finally(() => { - cliRuntime = previous; - }) as T; - } - cliRuntime = previous; - return result; - } catch (error) { - cliRuntime = previous; - throw error; - } -} - export function refreshCliRuntimeContext(context: CliContext): void { applyTerminalColorFromContext(context); const runtime = getCliRuntime(); @@ -105,10 +85,3 @@ export function refreshCliRuntimeContext(context: CliContext): void { export function getOutputSink(): OutputSink { return getCliRuntime().output; } - -export async function writeRawIfJsonElseHuman( - rawBody: string, - humanFormatter?: (parsed: unknown) => string, -): Promise { - await writeLakehouseCommandOutput(rawBody, { humanFormatter }); -} diff --git a/cli/src/lib/secrets.test.ts b/cli/src/lib/secrets.test.ts new file mode 100644 index 0000000..991c201 --- /dev/null +++ b/cli/src/lib/secrets.test.ts @@ -0,0 +1,62 @@ +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 { createFakeKeychain } from "@/test-utils/keychain.ts"; +import { secretExists, secretGet, secretSet } from "@/lib/secrets.ts"; + +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "altertable-secrets-test-")); + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; +}); + +describe("secretSet", () => { + test("stores and reads secrets from the file backend", () => { + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + + secretSet("api-key", "atm_test", "default"); + + 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", () => { + const keychain = createFakeKeychain(); + + 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"); + }); +}); + +describe("secretDelete", () => { + test("fails closed when Keychain deletion fails", () => { + const keychain = createFakeKeychain(); + const account = "profile/default/api-key"; + keychain.store.secretSet("api-key", "atm_test", "default"); + keychain.failingDeletes.add(account); + + 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"); + }); + + test("accepts an already absent Keychain item", () => { + const keychain = createFakeKeychain(); + + expect(() => keychain.store.secretDelete("api-key", "default")).not.toThrow(); + }); +}); diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index aad494c..103e24b 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,15 +1,47 @@ -import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process"; -import { chmodSync, statSync } from "node:fs"; +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 { assertSafeProfileName, isFromEnvProfile } from "@/lib/profile-store.ts"; import { readEnv } from "@/lib/env.ts"; +import { assertSafeProfileName, isFromEnvProfile } from "@/lib/profile-store.ts"; type SecretBackend = "macos" | "file"; -let fileBackendWarned = false; +// `security` returns errSecItemNotFound (-25300) modulo 256 as its process status. +const KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44; + +type SecretProcessResult = { + status: number | null; + stdout: string | Buffer; + error?: Error; +}; + +type SecretProcess = { + platform: NodeJS.Platform; + spawnSync(command: string, args: string[], options: SpawnSyncOptions): SecretProcessResult; +}; + +export type SecretSetOptions = { + fromArgv?: boolean; +}; + +export type SecretStore = { + 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; + moveProfileSecrets(sourceProfile: string, targetProfile: string, keys: readonly string[]): void; + secretStoreDisplay(): string; +}; + +const SYSTEM_SECRET_PROCESS: SecretProcess = { + platform: process.platform, + spawnSync(command, args, options) { + return spawnSync(command, args, options) as unknown as SecretProcessResult; + }, +}; function fileMode(filePath: string): string { try { @@ -29,9 +61,7 @@ function requireSafeCredentialsFile(): void { } const mode = fileMode(filePath); - if (!mode) { - return; - } + if (!mode) return; const modeNumber = Number.parseInt(mode, 8); if (modeNumber & 0o077) { @@ -41,169 +71,204 @@ function requireSafeCredentialsFile(): void { } } -function warnFileBackendOnce(): void { - if (!fileBackendWarned) { +function resolveSecretKey(key: string, profileName: string): string { + assertSafeProfileName(profileName); + return `profile/${profileName}/${key}`; +} + +export function createSecretStore( + secretProcess: SecretProcess = SYSTEM_SECRET_PROCESS, +): SecretStore { + let fileBackendWarned = false; + + function warnFileBackendOnce(): void { + if (fileBackendWarned) return; logWarn( `No macOS keychain; storing secrets at ${credentialsFile()} (chmod 600). Prefer environment variables in CI.`, ); fileBackendWarned = true; } -} -function hasSecurityCommand(): boolean { - const result = runSpawnSync("security", ["help"], { stdio: "ignore" }); - return result.status === 0 || result.error === undefined; -} + function hasSecurityCommand(): boolean { + 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") { + function secretBackend(): SecretBackend { + 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.", + ); + } + if (secretProcess.platform === "darwin" && hasSecurityCommand()) return "macos"; return "file"; } - if (override === "keychain") { - if (process.platform === "darwin" && hasSecurityCommand()) { - return "macos"; + + function secretSetMacos(storageAccount: string, value: string): void { + const result = secretProcess.spawnSync( + "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("ALTERTABLE_SECRET_BACKEND=keychain but the macOS keychain is unavailable."); } - if (process.platform === "darwin" && hasSecurityCommand()) { - return "macos"; + + function secretSet( + account: string, + value: string, + profileName: string, + 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 = resolveSecretKey(account, profileName); + const backend = secretBackend(); + if (backend === "macos" && !options?.fromArgv) { + secretSetMacos(storageAccount, value); + return; + } + + const filePath = credentialsFile(); + kvSet(filePath, storageAccount, value); + chmodSync(filePath, 0o600); + if (backend !== "macos") warnFileBackendOnce(); } - return "file"; -} -function resolveSecretKey(key: string, profileName: string): string { - assertSafeProfileName(profileName); - return `profile/${profileName}/${key}`; -} + function secretGet(key: string, profileName: string): string { + if (isFromEnvProfile(profileName)) return ""; -export type SecretSetOptions = { - fromArgv?: boolean; -}; + const secretKey = resolveSecretKey(key, profileName); + const backend = secretBackend(); + if (backend === "macos") { + const result = secretProcess.spawnSync( + "security", + ["find-generic-password", "-s", "altertable", "-a", secretKey, "-w"], + { encoding: "utf8" }, + ); + const keychainValue = result.status === 0 ? String(result.stdout).trim() : ""; + if (keychainValue !== "") return keychainValue; + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey); + } -type SpawnSyncFn = ( - command: string, - args: string[], - options: SpawnSyncOptions, -) => SpawnSyncReturns; + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey); + } -let spawnSyncOverride: SpawnSyncFn | undefined; + function secretExists(key: string, profileName: string): boolean { + if (isFromEnvProfile(profileName)) return false; -export function setSpawnSyncForTests(fn: SpawnSyncFn | undefined): void { - spawnSyncOverride = fn; -} + const secretKey = resolveSecretKey(key, profileName); + const backend = secretBackend(); + if (backend === "macos") { + const result = secretProcess.spawnSync( + "security", + ["find-generic-password", "-s", "altertable", "-a", secretKey], + { stdio: "ignore" }, + ); + if (result.status === 0) return true; + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey) !== ""; + } -function runSpawnSync( - command: string, - args: string[], - options: SpawnSyncOptions, -): SpawnSyncReturns { - const spawnFn = spawnSyncOverride ?? spawnSync; - return spawnFn(command, args, options); -} + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey) !== ""; + } + + function secretDelete(key: string, profileName: string): void { + if (isFromEnvProfile(profileName)) return; + + const secretKey = resolveSecretKey(key, profileName); + if (secretBackend() === "macos") { + const result = secretProcess.spawnSync( + "security", + ["delete-generic-password", "-s", "altertable", "-a", secretKey], + { stdio: "ignore" }, + ); + if (result.status !== 0 && result.status !== KEYCHAIN_ITEM_NOT_FOUND_STATUS) { + throw new CliError(`Failed to delete secret from macOS keychain (${secretKey}).`); + } + kvUnset(credentialsFile(), secretKey); + return; + } -function secretSetMacos(storageAccount: string, value: string): void { - const result = runSpawnSync( - "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}).`); + kvUnset(credentialsFile(), secretKey); } + + function moveProfileSecrets( + sourceProfile: string, + targetProfile: string, + keys: readonly string[], + ): void { + const prepared: Array<{ key: string; targetValue: string }> = []; + + try { + for (const key of keys) { + const sourceValue = secretGet(key, sourceProfile); + if (sourceValue.length === 0) continue; + prepared.push({ key, targetValue: secretGet(key, targetProfile) }); + secretSet(key, sourceValue, targetProfile); + } + } catch (error) { + for (const entry of prepared.toReversed()) { + try { + if (entry.targetValue.length > 0) { + secretSet(entry.key, entry.targetValue, targetProfile); + } else { + secretDelete(entry.key, targetProfile); + } + } catch { + // Best-effort rollback; preserve the original failure for the caller. + } + } + throw error; + } + + for (const { key } of prepared) secretDelete(key, sourceProfile); + } + + function secretStoreDisplay(): string { + return secretBackend() === "macos" ? "MacOS keychain" : credentialsFile(); + } + + return { + secretSet, + secretGet, + secretExists, + secretDelete, + moveProfileSecrets, + secretStoreDisplay, + }; } +const systemSecretStore = createSecretStore(); + export function secretSet( account: string, value: string, profileName: string, options?: SecretSetOptions, ): void { - // Env config isolates to `_from_env`, which has no store. Profile-mutating - // commands are refused in env mode; internal caches (e.g. a provisioned - // lakehouse token) are used in-process only — dropping them is correct. - if (isFromEnvProfile(profileName)) { - return; - } - const storageAccount = resolveSecretKey(account, profileName); - const backend = secretBackend(); - if (backend === "macos" && !options?.fromArgv) { - secretSetMacos(storageAccount, value); - return; - } - - const filePath = credentialsFile(); - kvSet(filePath, storageAccount, value); - chmodSync(filePath, 0o600); - if (backend !== "macos") { - warnFileBackendOnce(); - } + systemSecretStore.secretSet(account, value, profileName, options); } export function secretGet(key: string, profileName: string): string { - // The env pseudo-profile has no stored secrets; auth reads env vars directly. - if (isFromEnvProfile(profileName)) { - return ""; - } - const secretKey = resolveSecretKey(key, profileName); - const backend = secretBackend(); - if (backend === "macos") { - const result = runSpawnSync( - "security", - ["find-generic-password", "-s", "altertable", "-a", secretKey, "-w"], - { encoding: "utf8" }, - ); - const stdout = result.stdout; - const keychainValue = result.status === 0 ? String(stdout).trim() : ""; - if (keychainValue !== "") { - return keychainValue; - } - requireSafeCredentialsFile(); - return kvGet(credentialsFile(), secretKey); - } - - requireSafeCredentialsFile(); - return kvGet(credentialsFile(), secretKey); + return systemSecretStore.secretGet(key, profileName); } export function secretExists(key: string, profileName: string): boolean { - if (isFromEnvProfile(profileName)) { - return false; - } - const secretKey = resolveSecretKey(key, profileName); - const backend = secretBackend(); - if (backend === "macos") { - const result = runSpawnSync( - "security", - ["find-generic-password", "-s", "altertable", "-a", secretKey], - { stdio: "ignore" }, - ); - if (result.status === 0) { - return true; - } - requireSafeCredentialsFile(); - return kvGet(credentialsFile(), secretKey) !== ""; - } - - requireSafeCredentialsFile(); - return kvGet(credentialsFile(), secretKey) !== ""; + return systemSecretStore.secretExists(key, profileName); } export function secretDelete(key: string, profileName: string): void { - if (isFromEnvProfile(profileName)) { - return; - } - const secretKey = resolveSecretKey(key, profileName); - const backend = secretBackend(); - if (backend === "macos") { - runSpawnSync("security", ["delete-generic-password", "-s", "altertable", "-a", secretKey], { - stdio: "ignore", - }); - kvUnset(credentialsFile(), secretKey); - return; - } - - kvUnset(credentialsFile(), secretKey); + systemSecretStore.secretDelete(key, profileName); } export function moveProfileSecrets( @@ -211,45 +276,9 @@ export function moveProfileSecrets( targetProfile: string, keys: readonly string[], ): void { - const prepared: Array<{ key: string; targetValue: string }> = []; - - try { - for (const key of keys) { - const sourceValue = secretGet(key, sourceProfile); - if (sourceValue.length === 0) { - continue; - } - prepared.push({ - key, - targetValue: secretGet(key, targetProfile), - }); - secretSet(key, sourceValue, targetProfile); - } - } catch (error) { - for (const entry of prepared.toReversed()) { - try { - if (entry.targetValue.length > 0) { - secretSet(entry.key, entry.targetValue, targetProfile); - } else { - secretDelete(entry.key, targetProfile); - } - } catch { - // Best-effort rollback; preserve the original failure for the caller. - } - } - throw error; - } - - for (const { key } of prepared) { - secretDelete(key, sourceProfile); - } + systemSecretStore.moveProfileSecrets(sourceProfile, targetProfile, keys); } export function secretStoreDisplay(): string { - return secretBackend() === "macos" ? "MacOS keychain" : credentialsFile(); -} - -// Reset warning flag for tests -export function resetSecretWarningsForTests(): void { - fileBackendWarned = false; + return systemSecretStore.secretStoreDisplay(); } diff --git a/cli/tests/stream-lines.test.ts b/cli/src/lib/stream-lines.test.ts similarity index 100% rename from cli/tests/stream-lines.test.ts rename to cli/src/lib/stream-lines.test.ts diff --git a/cli/src/lib/tabular-result.ts b/cli/src/lib/tabular-result.ts index 51ca03f..0586c41 100644 --- a/cli/src/lib/tabular-result.ts +++ b/cli/src/lib/tabular-result.ts @@ -1,20 +1,43 @@ +import { CliError } from "@/lib/errors.ts"; +import { renderQueryCsv, renderQueryJson } from "@/lib/query-output.ts"; import { - renderManagementTabularOutput, - type ManagementOutputFormat, -} from "@/lib/lakehouse-client.ts"; + defaultDisplayOptions, + getQueryColumnNames, + renderQueryHumanOutput, + renderQueryMarkdown, +} from "@/lib/query-format.ts"; + +export type ManagementOutputFormat = "json" | "table" | "csv" | "markdown"; + +const MANAGEMENT_OUTPUT_FORMATS = new Set([ + "json", + "table", + "csv", + "markdown", +]); export type TabularResult = { columns: string[]; rows: Record[]; }; +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.`); + } + return format as ManagementOutputFormat; +} + export function renderTabularOutput(result: TabularResult, format: ManagementOutputFormat): string { - return renderManagementTabularOutput( - { - metadata: {}, - columns: result.columns, - rows: result.rows, - }, - format, - ); + 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(), + ); + } + return renderQueryHumanOutput(queryResult, { ...defaultDisplayOptions(), layout: "table" }); } diff --git a/cli/src/lib/timeout-args.ts b/cli/src/lib/timeout-args.ts index 215a640..f8a0fef 100644 --- a/cli/src/lib/timeout-args.ts +++ b/cli/src/lib/timeout-args.ts @@ -1,4 +1,12 @@ import { CliError } from "@/lib/errors.ts"; +import { defineArgs } from "@/lib/command.ts"; + +export const requestReadTimeoutArgs = defineArgs({ + "read-timeout": { + type: "string", + description: "Read timeout in seconds for this request (overrides global --read-timeout)", + }, +}); export function parseTimeoutSeconds(value: unknown, flagName: string): number { const text = String(value).trim(); @@ -24,3 +32,8 @@ export function readArgvFlagValue(argv: readonly string[], flagName: string): st } return undefined; } + +export function parseRequestReadTimeoutMs(args: Record): number | undefined { + if (args["read-timeout"] === undefined) return undefined; + return parseTimeoutSeconds(args["read-timeout"], "--read-timeout"); +} diff --git a/cli/tests/updater.test.ts b/cli/src/lib/updater.test.ts similarity index 98% rename from cli/tests/updater.test.ts rename to cli/src/lib/updater.test.ts index 5bb0ffe..9f86a18 100644 --- a/cli/tests/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { runCommand } from "citty"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -10,10 +9,12 @@ import { executeUpdateCommand, type UpdateCommandDependencies, updateCommand, -} from "@/commands/update.ts"; +} from "@/commands/update/index.ts"; import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; import { VERSION } from "@/version.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +import { runCommandTree } from "@/lib/command.ts"; +import { configFile, kvSet } from "@/lib/config.ts"; import { resolveProcessExecutablePath } from "@/lib/executable-path.ts"; import { checkForUpdate, @@ -27,7 +28,6 @@ import { getUpdateCheckInterval, installCliUpdate, installGitHubBinaryRelease, - isNativeCompiledInstall, maybeShowUpdateNotice, packageReleaseUrl, parseChecksums, @@ -35,17 +35,16 @@ import { recommendedInstallCommand, releaseAssetName, releaseUrlForSource, - resolveCurrentExecutablePath, resolveUpdateSource, - setUpdateCheckInterval, shouldRunAutomaticUpdateCheck, - UpdaterConfig, verifySha256, } from "@/lib/updater.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.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 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}`; @@ -136,7 +135,7 @@ async function runUpdateCommand(rawArgs: string[]): Promise { - await runCommand(buildMainCommand(), { rawArgs }); + await runCommandTree(buildMainCommand(), { rawArgs }); }); return output; @@ -341,10 +340,6 @@ describe("binary self-update", () => { argv: ["/usr/local/bin/altertable"], }).kind, ).toBe("native-binary"); - expect( - isNativeCompiledInstall("/usr/local/bin/altertable", ["/usr/local/bin/altertable"]), - ).toBe(true); - expect(resolveCurrentExecutablePath()).toBeTruthy(); }); test("resolves relative compiled executable paths from the original invocation", () => { @@ -618,7 +613,7 @@ describe("automatic update checks", () => { }); test("honors interval policy and environment opt-out", () => { - setUpdateCheckInterval("never"); + kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, "never"); expect(getUpdateCheckInterval()).toBe("never"); expect( shouldRunAutomaticUpdateCheck({ @@ -629,7 +624,7 @@ describe("automatic update checks", () => { }), ).toBe(false); - setUpdateCheckInterval("daily"); + kvSet(configFile(), UpdaterConfig.configKeys.checkInterval, "daily"); process.env.ALTERTABLE_NO_UPDATE_CHECK = "1"; expect( shouldRunAutomaticUpdateCheck({ diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 9f1a943..7e0920d 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -6,8 +6,7 @@ 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, configSetGlobal } from "@/lib/config.ts"; -import { urlencode } from "@/lib/encode.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"; @@ -30,20 +29,6 @@ import { type UpdateSource, } from "@/lib/updater-config.ts"; -export { - UpdaterInstallationKind, - UpdaterCheckIntervals, - UpdaterInstallMethod, - UpdaterConfig, - type InstallationKind, - type InstallManager, - type ReleasePlatform, - type ResolvedUpdateInstallMethod, - type UpdateCheckInterval, - type UpdateInstallMethod, - type UpdateSource, -} from "@/lib/updater-config.ts"; - export type ReleaseInfo = { version: string; source: UpdateSource; @@ -291,10 +276,6 @@ export function getUpdateCheckInterval(): UpdateCheckInterval { return fromConfig ?? UpdaterConfig.defaults.checkInterval; } -export function setUpdateCheckInterval(interval: UpdateCheckInterval): void { - configSetGlobal(UpdaterConfig.configKeys.checkInterval, interval); -} - export function resolveUpdateSource(source?: UpdateSource): UpdateSource { return source ?? readEnv("ALTERTABLE_UPDATE_SOURCE") ?? UpdaterConfig.defaults.source; } @@ -384,7 +365,7 @@ export function releaseUrlForSource(source: UpdateSource, version: string): stri function appendEncodedUrlPath(url: URL, ...rawSegments: string[]): void { const baseSegments = url.pathname.split("/").filter((segment) => segment.length > 0); - url.pathname = [...baseSegments, ...rawSegments.map(urlencode)] + url.pathname = [...baseSegments, ...rawSegments.map(encodeURIComponent)] .filter((segment) => segment.length > 0) .join("/") .replace(/^/, "/"); @@ -759,20 +740,6 @@ export function detectCurrentInstallation( }; } -export function resolveCurrentExecutablePath(): string { - return detectCurrentInstallation().executablePath; -} - -export function isNativeCompiledInstall( - executablePath: string = process.execPath, - argv: readonly string[] = process.argv, -): boolean { - return ( - detectCurrentInstallation({ execPath: executablePath, argv }).kind === - UpdaterInstallationKind.nativeBinary - ); -} - function resolveInstallMethodForInstallation( requestedMethod: UpdateInstallMethod, installation: CurrentInstallation, diff --git a/cli/src/lib/url-policy.test.ts b/cli/src/lib/url-policy.test.ts new file mode 100644 index 0000000..2d70447 --- /dev/null +++ b/cli/src/lib/url-policy.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { assertAllowedApiBase } from "@/lib/url-policy.ts"; + +describe("assertAllowedApiBase", () => { + test("allows HTTPS and local HTTP endpoints", () => { + expect(() => assertAllowedApiBase("https://api.altertable.ai")).not.toThrow(); + expect(() => assertAllowedApiBase("http://localhost:15000")).not.toThrow(); + expect(() => assertAllowedApiBase("http://127.0.0.1:8080")).not.toThrow(); + }); + + 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(); + }); +}); diff --git a/cli/tests/usage.test.ts b/cli/src/lib/usage.test.ts similarity index 92% rename from cli/tests/usage.test.ts rename to cli/src/lib/usage.test.ts index 86b6bdd..1979793 100644 --- a/cli/tests/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -4,10 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildMainCommand } from "@/cli.ts"; import { getBootstrapCliContext } from "@/context.ts"; -import { defineAltertableCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { renderAltertableUsage, resolveSubCommandForUsage } from "@/lib/usage.ts"; -import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime, runWithCliRuntime, setCliRuntime } from "@/lib/runtime.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"; @@ -16,7 +17,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; describe("renderAltertableUsage", () => { test("groups root commands before global flags in a custom help panel", async () => { @@ -83,7 +84,7 @@ describe("renderAltertableUsage", () => { }); test("renders command usage with shared sections and examples", async () => { - const command = defineAltertableCommand({ + const command = defineCommand({ meta: { name: "demo", description: "Demo command.", @@ -139,7 +140,7 @@ describe("renderAltertableUsage", () => { }); test("hides advanced subcommands from summary usage", async () => { - const command = defineAltertableCommand({ + const command = defineCommand({ meta: { name: "demo", description: "Demo command." }, subCommands: { visible: { meta: { name: "visible", description: "Visible command" } }, @@ -175,11 +176,6 @@ describe("renderAltertableUsage active context", () => { afterEach(() => { restoreTerminalState(terminalState); - runWithCliRuntime(createCliRuntime(getBootstrapCliContext()), () => { - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - configureClearAll("default"); - }); rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; delete process.env.ALTERTABLE_SECRET_BACKEND; diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 3d63cc9..c91e554 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,5 +1,5 @@ -import type { ArgDef, ArgsDef, CommandDef } from "citty"; -import type { AltertableCommandGroup, AltertableCommandMeta } from "@/lib/command-context.ts"; +import type { Command, CommandArg, CommandArgs } from "@/lib/command.ts"; +import type { AltertableCommandGroup, AltertableCommandMeta } from "@/lib/command.ts"; import { span, type DisplaySpan } from "@/ui/document.ts"; import { getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; import { HELP_FLAGS, VERSION_FLAGS } from "@/lib/early-bootstrap.ts"; @@ -54,7 +54,7 @@ async function resolveValue(input: T | (() => T) | (() => Promise) | Promi return await input; } -function isValueFlag(flag: string, argsDef: ArgsDef): boolean { +function isValueFlag(flag: string, argsDef: CommandArgs): boolean { const name = flag.replace(/^-{1,2}/, ""); const normalized = camelCase(name); for (const [key, definition] of Object.entries(argsDef)) { @@ -76,7 +76,7 @@ function isValueFlag(flag: string, argsDef: ArgsDef): boolean { return false; } -function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number { +function findSubCommandIndex(rawArgs: string[], argsDef: CommandArgs): number { for (let index = 0; index < rawArgs.length; index += 1) { const arg = rawArgs[index]; if (arg === undefined) { @@ -99,10 +99,10 @@ function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number { async function findSubCommand( subCommands: Record< string, - CommandDef | (() => CommandDef) | (() => Promise) | Promise + Command | (() => Command) | (() => Promise) | Promise >, name: string | undefined, -): Promise { +): Promise { if (!name) { return undefined; } @@ -120,10 +120,10 @@ async function findSubCommand( } export async function resolveSubCommandForUsage( - command: CommandDef, + command: Command, rawArgs: string[], - parent?: CommandDef, -): Promise<[CommandDef, CommandDef | undefined]> { + parent?: Command, +): Promise<[Command, Command | undefined]> { const subCommands = await resolveValue(command.subCommands); if (subCommands && Object.keys(subCommands).length > 0) { const subCommandArgIndex = findSubCommandIndex(rawArgs, await resolveValue(command.args ?? {})); @@ -136,11 +136,11 @@ export async function resolveSubCommandForUsage( return [command, parent]; } -async function resolveCommandMeta(command: CommandDef): Promise { +async function resolveCommandMeta(command: Command): Promise { return (await resolveValue(command.meta ?? {})) as AltertableCommandMeta; } -async function resolveCommandExamples(command: CommandDef): Promise { +async function resolveCommandExamples(command: Command): Promise { const meta = await resolveCommandMeta(command); return meta.examples ?? []; } @@ -150,7 +150,7 @@ type VisibleSubCommand = { meta: AltertableCommandMeta; }; -async function visibleSubCommands(command: CommandDef): Promise { +async function visibleSubCommands(command: Command): Promise { const subCommands = await resolveValue(command.subCommands); if (!subCommands || Object.keys(subCommands).length === 0) { return []; @@ -278,7 +278,7 @@ function formatHelpGuidance(commandName: string): string[] { }); } -function valueHint(name: string, definition: ArgDef): string | undefined { +function valueHint(name: string, definition: CommandArg): string | undefined { if (definition.type === "enum" && definition.options?.length) { return definition.valueHint ?? definition.options.join("|"); } @@ -288,7 +288,7 @@ function valueHint(name: string, definition: ArgDef): string | undefined { return undefined; } -function flagLabel(name: string, definition: ArgsDef[string]): string { +function flagLabel(name: string, definition: CommandArgs[string]): string { const aliases = ("alias" in definition ? toArray(definition.alias) : []).map( (alias) => `-${alias}`, ); @@ -298,11 +298,11 @@ function flagLabel(name: string, definition: ArgsDef[string]): string { return [...aliases, `${longFlag}${value}`].join(", "); } -function positionalLabel(name: string, definition: ArgDef): string { +function positionalLabel(name: string, definition: CommandArg): string { return (definition.valueHint ?? name).toUpperCase(); } -function argumentDescription(definition: ArgDef): string { +function argumentDescription(definition: CommandArg): string { const required = definition.default === undefined && (definition.type === "positional" @@ -328,7 +328,7 @@ function usageCommandName(meta: AltertableCommandMeta, parentMeta?: AltertableCo function usageTokens( commandName: string, - args: ArgsDef, + args: CommandArgs, subCommands: readonly VisibleSubCommand[], ): string { const positionalArgs = Object.entries(args) @@ -350,7 +350,7 @@ function usageTokens( .join(" "); } -async function renderCommandUsage(command: CommandDef, parent?: CommandDef): Promise { +async function renderCommandUsage(command: Command, parent?: Command): Promise { const meta = await resolveCommandMeta(command); const parentMeta = parent ? await resolveCommandMeta(parent) : undefined; const args = await resolveValue(command.args ?? {}); @@ -416,7 +416,7 @@ async function renderCommandUsage(command: CommandDef, parent?: CommandDef): Pro return lines.join("\n"); } -async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta): Promise { +async function renderRootUsage(command: Command, meta: AltertableCommandMeta): Promise { const args = await resolveValue(command.args ?? {}); const groupedEntries: Record = { platform: [], @@ -486,10 +486,7 @@ async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta) return lines.join("\n"); } -export async function renderAltertableUsage( - command: CommandDef, - parent?: CommandDef, -): Promise { +export async function renderAltertableUsage(command: Command, parent?: Command): Promise { const meta = await resolveCommandMeta(command); if (parent === undefined && meta.name === "altertable") { return renderRootUsage(command, meta); @@ -498,14 +495,11 @@ export async function renderAltertableUsage( return renderCommandUsage(command, parent); } -export async function showAltertableUsage(command: CommandDef, parent?: CommandDef): Promise { +export async function showAltertableUsage(command: Command, parent?: Command): Promise { console.log(`${await renderAltertableUsage(command, parent)}\n`); } -export async function showCommandExamplesForArgs( - root: CommandDef, - rawArgs: string[], -): Promise { +export async function showCommandExamplesForArgs(root: Command, rawArgs: string[]): Promise { const [command] = await resolveSubCommandForUsage(root, rawArgs); const section = formatCommandExamplesSection(await resolveCommandExamples(command)); if (section.length === 0) { diff --git a/cli/src/release-manifest.ts b/cli/src/release-manifest.ts index 9dc8228..8194ab7 100644 --- a/cli/src/release-manifest.ts +++ b/cli/src/release-manifest.ts @@ -52,7 +52,6 @@ export const RELEASE_TARGETS = [ ] as const satisfies readonly ReleaseTarget[]; export type ReleasePlatform = (typeof RELEASE_TARGETS)[number]["platform"]; -export type ReleaseBunTarget = (typeof RELEASE_TARGETS)[number]["bunTarget"]; export type ReleaseAssetName = (typeof RELEASE_TARGETS)[number]["asset"]; export const RELEASE_PLATFORM_CONFIG: Readonly< @@ -63,30 +62,4 @@ export const RELEASE_PLATFORM_CONFIG: Readonly< ) as Record, ); -export const RELEASE_BUNDLE_ASSET = "altertable-cli.js"; export const RELEASE_CHECKSUMS_ASSET = "checksums.txt"; -export const RELEASE_METADATA_ASSET = "release-manifest.json"; - -export function findReleaseTargetByBunTarget( - bunTarget: string, -): (typeof RELEASE_TARGETS)[number] | undefined { - return RELEASE_TARGETS.find((target) => target.bunTarget === bunTarget); -} - -export function findReleaseTargetByPlatform( - platform: string, -): (typeof RELEASE_TARGETS)[number] | undefined { - return RELEASE_TARGETS.find((target) => target.platform === platform); -} - -export function releaseCiMatrix(): { - include: Array<{ target: ReleaseBunTarget; artifact: ReleaseAssetName; runner: string }>; -} { - return { - include: RELEASE_TARGETS.map((target) => ({ - target: target.bunTarget, - artifact: target.asset, - runner: target.runner, - })), - }; -} diff --git a/cli/src/test-utils/cli.ts b/cli/src/test-utils/cli.ts new file mode 100644 index 0000000..4b99a40 --- /dev/null +++ b/cli/src/test-utils/cli.ts @@ -0,0 +1,43 @@ +import { buildMainCommand } from "@/cli.ts"; +import type { CliContext } from "@/context.ts"; +import { runCommandTree } from "@/lib/command.ts"; +import { createCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; + +export type CliTestHarness = { + runtime: CliRuntime; + stdout: string[]; + stderr: string[]; + run(rawArgs: string[]): Promise; +}; + +export function createCliTestHarness( + 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.writeHuman = (text) => stdout.push(text); + runtime.output.writeMetadata = (lines) => stderr.push(...lines); + + return { + runtime, + stdout, + stderr, + async run(rawArgs) { + await runWithCliRuntime(runtime, () => runCommandTree(buildMainCommand(), { rawArgs })); + }, + }; +} + +export async function runCommandWithTestRuntime( + rawArgs: string[], + context: CliContext = { debug: false, json: true, agent: false }, +): Promise { + const harness = createCliTestHarness(context); + await harness.run(rawArgs); + return harness; +} diff --git a/cli/src/test-utils/keychain.ts b/cli/src/test-utils/keychain.ts new file mode 100644 index 0000000..4ee721c --- /dev/null +++ b/cli/src/test-utils/keychain.ts @@ -0,0 +1,50 @@ +import { createSecretStore, type SecretStore } from "@/lib/secrets.ts"; + +type FakeKeychain = { + store: SecretStore; + calls: string[][]; + failingWrites: Set; + failingDeletes: Set; +}; + +function argumentAfter(args: string[], flag: string): string { + const index = args.indexOf(flag); + return index >= 0 ? (args[index + 1] ?? "") : ""; +} + +export function createFakeKeychain(): FakeKeychain { + const calls: string[][] = []; + const values = new Map(); + const failingWrites = new Set(); + const failingDeletes = new Set(); + + const store = createSecretStore({ + platform: "darwin", + spawnSync(_command, args) { + calls.push([...args]); + 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("") }; + } + if (args.includes("find-generic-password")) { + const value = values.get(account); + return value === undefined + ? { status: 1, 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("") }; + values.delete(account); + return { status: 0, stdout: Buffer.from("") }; + } + return { status: 1, stdout: Buffer.from("") }; + }, + }); + + return { store, calls, failingWrites, failingDeletes }; +} diff --git a/cli/src/test-utils/lakehouse.ts b/cli/src/test-utils/lakehouse.ts new file mode 100644 index 0000000..0bb3c4d --- /dev/null +++ b/cli/src/test-utils/lakehouse.ts @@ -0,0 +1,70 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { setCliContext } from "@/context.ts"; +import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; + +export type MockHttpResponse = { + urlPattern: string; + method?: string; + status?: number; + body: string; + chunked?: boolean; +}; + +export type LakehouseTestWorkspace = { + home: string; + writeMocks(responses: MockHttpResponse[]): void; + writeFile(name: string, content: string): string; + createDirectory(name: string): string; + readHttpLog(): string; + readPayloads(): string[]; + cleanup(): void; +}; + +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"); + 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"; + setCliContext({ debug: false, json: false, agent: false }); + refreshCliRuntimeContext(getCliRuntime().context); + + return { + home, + writeMocks(responses) { + writeFileSync(mockFile, JSON.stringify(responses)); + }, + writeFile(fileName, content) { + const path = join(home, fileName); + writeFileSync(path, content); + return path; + }, + createDirectory(directoryName) { + const path = join(home, directoryName); + mkdirSync(path); + return path; + }, + readHttpLog() { + return readFileSync(logFile, "utf8"); + }, + readPayloads() { + return readFileSync(logFile, "utf8") + .split("\n") + .filter((line) => line.startsWith("PAYLOAD=")) + .map((line) => line.slice("PAYLOAD=".length)); + }, + cleanup() { + rmSync(home, { recursive: true, force: true }); + delete process.env.ALTERTABLE_MOCK_HTTP_FILE; + delete process.env.ALTERTABLE_HTTP_LOG; + delete process.env.ALTERTABLE_API_BASE; + delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; + delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; + }, + }; +} diff --git a/cli/src/test-utils/runtime.ts b/cli/src/test-utils/runtime.ts new file mode 100644 index 0000000..f4fdf07 --- /dev/null +++ b/cli/src/test-utils/runtime.ts @@ -0,0 +1,17 @@ +import { getCliRuntime, setCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; + +export function runWithCliRuntime(runtime: CliRuntime, run: () => T): T { + const previousRuntime = getCliRuntime(); + setCliRuntime(runtime); + try { + const result = run(); + if (result instanceof Promise) { + return result.finally(() => setCliRuntime(previousRuntime)) as T; + } + setCliRuntime(previousRuntime); + return result; + } catch (error) { + setCliRuntime(previousRuntime); + throw error; + } +} diff --git a/cli/tests/terminal-test-utils.ts b/cli/src/test-utils/terminal.ts similarity index 100% rename from cli/tests/terminal-test-utils.ts rename to cli/src/test-utils/terminal.ts diff --git a/cli/tests/test-utils.ts b/cli/src/test-utils/time.ts similarity index 100% rename from cli/tests/test-utils.ts rename to cli/src/test-utils/time.ts diff --git a/cli/src/ui/error.ts b/cli/src/ui/error.ts new file mode 100644 index 0000000..8e579f8 --- /dev/null +++ b/cli/src/ui/error.ts @@ -0,0 +1,25 @@ +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")) { + return renderDisplayText([span("ERROR", "error"), span(` ${error.message}`)]); + } + return renderDisplayText([span("ERROR", "error"), span(" Unexpected error.")]); +} + +export function renderCliErrorDetails(details: string): string { + return details + .split(/\r\n|\r|\n/) + .map((line, index) => + index === 0 + ? renderDisplayText([span("ERROR", "error"), span(` ${line}`)]) + : renderDisplayText(line), + ) + .join("\n"); +} diff --git a/cli/tests/tree-layout.test.ts b/cli/src/ui/layouts/tree.test.ts similarity index 100% rename from cli/tests/tree-layout.test.ts rename to cli/src/ui/layouts/tree.test.ts diff --git a/cli/src/ui/prompts.ts b/cli/src/ui/prompts.ts new file mode 100644 index 0000000..eab9f3f --- /dev/null +++ b/cli/src/ui/prompts.ts @@ -0,0 +1,141 @@ +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"; + +export type SelectOption = { value: string; label: string }; +export type ReadSelectOptions = { leadingNewline?: boolean }; + +export type Prompts = { + writePrompt(line: string): void; + readLine(prompt: string): Promise; + readPassword(prompt: string): Promise; + readSelect( + title: string, + options: SelectOption[], + defaultValue?: string, + selectOptions?: ReadSelectOptions, + ): Promise; + readConfirm(prompt: string, defaultYes?: boolean): Promise; +}; + +export class PromptCancelled extends CliError { + constructor() { + super("Prompt cancelled."); + } +} + +function writePromptLine(line: string): void { + process.stderr.write(line); +} + +async function readStdinLine(): Promise { + 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()); + } + + function onData(chunk: Buffer): void { + buffer += chunk.toString("utf8"); + if (buffer.includes("\n")) settle(); + } + + input.on("data", onData); + input.on("end", settle); + input.resume(); + }); +} + +function normalizePromptMessage(prompt: string): string { + return prompt.trim().replace(/:\s*$/, ""); +} + +function resolvePromptResult(result: string | symbol): string { + if (isCancel(result)) throw new PromptCancelled(); + return result; +} + +async function readInteractiveLine(prompt: string): Promise { + if (!input.isTTY) { + writePromptLine(prompt); + return (await readStdinLine()).trim(); + } + ensurePromptColorAlignment(); + return resolvePromptResult( + await clackText({ message: normalizePromptMessage(prompt), input, output }), + ); +} + +async function readHiddenPassword(prompt: string): Promise { + if (!input.isTTY) { + writePromptLine(prompt); + return (await readStdinLine()).trim(); + } + ensurePromptColorAlignment(); + return resolvePromptResult( + await clackPassword({ message: normalizePromptMessage(prompt), input, output }), + ); +} + +async function readSelect( + title: string, + options: SelectOption[], + defaultValue?: string, + 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"); + + ensurePromptColorAlignment(); + return resolvePromptResult( + await clackSelect({ + message: title, + options: options.map((option) => ({ + ...option, + hint: option.value === defaultValue ? "default" : undefined, + })), + initialValue: defaultValue, + input, + output, + }), + ); +} + +async function readConfirm(prompt: string, defaultYes = true): Promise { + if (!input.isTTY) { + const answer = (await readStdinLine()).trim().toLowerCase(); + return answer === "" ? defaultYes : answer === "y" || answer === "yes"; + } + + ensurePromptColorAlignment(); + const result = await clackConfirm({ + message: normalizePromptMessage(prompt), + initialValue: defaultYes, + input, + output, + }); + if (isCancel(result)) throw new PromptCancelled(); + return result; +} + +export const defaultPrompts: Prompts = { + writePrompt: writePromptLine, + readLine: readInteractiveLine, + readPassword: readHiddenPassword, + readSelect, + readConfirm, +}; diff --git a/cli/src/ui/shell/render.test.ts b/cli/src/ui/shell/render.test.ts new file mode 100644 index 0000000..85b398c --- /dev/null +++ b/cli/src/ui/shell/render.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +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 direnv"], + }), + ).toBe('# Generated by: altertable profile direnv\nexport ALTERTABLE_PROFILE="acme_prod"'); + }); +}); diff --git a/cli/src/ui/terminal/spacing.ts b/cli/src/ui/terminal/spacing.ts index 0184de8..9350051 100644 --- a/cli/src/ui/terminal/spacing.ts +++ b/cli/src/ui/terminal/spacing.ts @@ -5,7 +5,3 @@ export const TERMINAL_NESTED_LABEL_WIDTH = 14; export function nestedIndent(indent: string = TERMINAL_INDENT): string { return `${indent}${TERMINAL_INDENT}`; } - -export function padLeft(lines: readonly string[], padding: string = TERMINAL_INDENT): string[] { - return lines.flatMap((line) => line.split("\n").map((segment) => `${padding}${segment}`)); -} diff --git a/cli/tests/terminal-style.test.ts b/cli/src/ui/terminal/styles.test.ts similarity index 98% rename from cli/tests/terminal-style.test.ts rename to cli/src/ui/terminal/styles.test.ts index 66392c0..c816453 100644 --- a/cli/tests/terminal-style.test.ts +++ b/cli/src/ui/terminal/styles.test.ts @@ -7,7 +7,6 @@ import { applyTerminalColorFromContext, renderDisplayText, } from "@/ui/terminal/styles.ts"; -import { padLeft } from "@/ui/terminal/spacing.ts"; import { span } from "@/ui/document.ts"; const originalNoColor = process.env.NO_COLOR; @@ -231,10 +230,6 @@ describe("terminal-style", () => { expect(getVisibleTextWidth(flag)).toBe(4); }); - test("padLeft indents multi-line terminal output", () => { - expect(padLeft(["A\nB", "C"], " ")).toEqual([" A", " B", " C"]); - }); - test("renders semantic links as OSC 8 hyperlinks when supported", () => { enableTerminalColorForTests(); process.env.OSC_HYPERLINK = "1"; diff --git a/cli/tests/table-format.test.ts b/cli/src/ui/terminal/table.test.ts similarity index 95% rename from cli/tests/table-format.test.ts rename to cli/src/ui/terminal/table.test.ts index 2f960fb..ba08de3 100644 --- a/cli/tests/table-format.test.ts +++ b/cli/src/ui/terminal/table.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { renderFixedTable } from "@/ui/terminal/table.ts"; -import { renderApiRoutesTable, renderApiRoutesTableSection } from "@/features/api/render.ts"; +import { formatApiRoutes } from "@/commands/api/lib/render.ts"; import { setTerminalColorMode, getVisibleTextWidth } from "@/ui/terminal/styles.ts"; import { span } from "@/ui/document.ts"; @@ -132,9 +132,9 @@ describe("renderFixedTable", () => { }); }); -describe("renderApiRoutesTable", () => { +describe("formatApiRoutes", () => { test("renders method, path, operation, and summary columns", () => { - const output = renderApiRoutesTable([ + const output = formatApiRoutes([ { method: "POST", path: "/environments", @@ -157,7 +157,7 @@ describe("renderApiRoutesTable", () => { process.env.ALTERTABLE_COLOR = "always"; setTerminalColorMode("always"); Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - const output = renderApiRoutesTable([ + const output = formatApiRoutes([ { method: "DELETE", path: "/items", @@ -169,7 +169,7 @@ describe("renderApiRoutesTable", () => { }); test("keeps long routes on one horizontally scrollable row", () => { - const output = renderApiRoutesTable( + const output = formatApiRoutes( [ { method: "POST", @@ -192,7 +192,7 @@ describe("renderApiRoutesTable", () => { }); test("does not truncate long paths on very narrow terminals", () => { - const output = renderApiRoutesTable( + const output = formatApiRoutes( [ { method: "DELETE", @@ -213,7 +213,7 @@ describe("renderApiRoutesTable", () => { }); test("inserts a blank line between routes with different path roots", () => { - const output = renderApiRoutesTable([ + const output = formatApiRoutes([ { method: "GET", path: "/environments/{id}", @@ -239,7 +239,7 @@ describe("renderApiRoutesTable", () => { }); test("wraps route list without a section title", () => { - const output = renderApiRoutesTableSection([ + const output = formatApiRoutes([ { method: "GET", path: "/whoami", diff --git a/cli/tests/active-context.test.ts b/cli/tests/active-context.test.ts deleted file mode 100644 index 68a7a16..0000000 --- a/cli/tests/active-context.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -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 { - activeContextToJson, - buildActiveContext, - withAuthenticatedIdentity, -} from "@/features/profile/model.ts"; -import { - buildActiveContextDetailsView, - buildActiveContextSummaryView, -} from "@/features/profile/views.ts"; -import { - formatActiveContextDetails, - formatActiveContextSummary, - tryFormatActiveContextSummary, -} from "@/features/profile/render.ts"; -import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; - -const profileName = "default"; - -let testHome = ""; - -function runInTestHome(run: () => T | Promise): T | Promise { - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - delete process.env.ALTERTABLE_API_KEY; - delete process.env.ALTERTABLE_ENV; - delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; - delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; - - const runtime = createCliRuntime(getBootstrapCliContext()); - return runWithCliRuntime(runtime, run); -} - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-active-context-test-")); -}); - -afterEach(async () => { - await runInTestHome(async () => { - configureClearAll(profileName); - }); - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; - delete process.env.ALTERTABLE_SECRET_BACKEND; -}); - -describe("active context formatters", () => { - test("summary shows profile and credential gaps when unconfigured", async () => { - await runInTestHome(async () => { - configureClearAll(profileName); - const summary = formatActiveContextSummary(buildActiveContext(profileName)); - expect(summary).not.toContain("CONTEXT"); - expect(summary).toContain("PROFILE"); - expect(summary).toMatch(/\n PROFILE/); - expect(summary).toContain("not set"); - expect(summary).toContain("altertable profile --configure"); - }); - }); - - test("details include authenticated identity when present", async () => { - await runInTestHome(async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - const context = buildActiveContext(profileName); - const details = formatActiveContextDetails( - withAuthenticatedIdentity(context, { - principal: { type: "User", name: "Jane Doe", email: "jane@x.io" }, - organization: { name: "Acme", slug: "acme" }, - }), - ); - expect(details).not.toContain("CONTEXT\n"); - expect(details).toContain("Profile:"); - expect(details).toContain("Environment:"); - expect(details).toContain("production"); - expect(details).toContain("Jane Doe "); - expect(details).toContain("Acme (acme)"); - }); - }); - - test("summary view describes the context as a table", async () => { - await runInTestHome(async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - - const view = buildActiveContextSummaryView(buildActiveContext(profileName)); - const [summarySection] = view.sections; - const [summaryBlock] = summarySection?.blocks ?? []; - - expect(summaryBlock?.kind).toBe("table"); - if (summaryBlock?.kind === "table") { - expect(summaryBlock.table.columns.map((column) => column.header)).toEqual([ - "PROFILE", - "ENV", - "MGMT", - "LAKEHOUSE", - ]); - expect(summaryBlock.table.rows).toEqual([ - { - profile: "default", - environment: "production", - management: "production", - lakehouse: "not set", - }, - ]); - const [entry] = summaryBlock.table.rows; - expect(summaryBlock.table.columns.map((column) => column.cell(entry))).toEqual([ - [{ text: "default", style: "strong" }], - [{ text: "production", style: "accent" }], - [{ text: "production", style: "muted" }], - [{ text: "not set", style: "muted" }], - ]); - } - }); - }); - - test("details view keeps identity and endpoint rows declarative", async () => { - await runInTestHome(async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - const view = buildActiveContextDetailsView( - withAuthenticatedIdentity(buildActiveContext(profileName), { - principal: { type: "User", name: "Alex Doe", email: "alex@example.com" }, - organization: { name: "Acme", slug: "acme" }, - }), - ); - const [detailSection] = view.sections; - const [detailBlock] = detailSection?.blocks ?? []; - - expect(detailBlock?.kind).toBe("rows"); - if (detailBlock?.kind === "rows") { - expect(detailBlock.rows).toEqual( - expect.arrayContaining([ - { label: "User:", value: "Alex Doe " }, - { label: "Organization:", value: "Acme (acme)" }, - { - label: "Data plane:", - value: [ - { - text: "https://api.altertable.ai", - style: "accent", - href: "https://api.altertable.ai", - }, - ], - }, - ]), - ); - } - }); - }); - - test("json output keeps principal for scripting compatibility", async () => { - await runInTestHome(async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - const context = withAuthenticatedIdentity(buildActiveContext(profileName), { - principal: { type: "User", name: "Jane Doe", email: "jane@x.io" }, - organization: { name: "Acme", slug: "acme" }, - }); - const json = activeContextToJson(context); - expect(json.profile).toBe("default"); - expect(json.environment).toBe("production"); - expect((json.principal as { email?: string }).email).toBe("jane@x.io"); - }); - }); - - test("tryFormatActiveContextSummary renders an empty profile", async () => { - await runInTestHome(async () => { - configureClearAll(profileName); - const summary = tryFormatActiveContextSummary("staging"); - expect(summary).toContain("PROFILE"); - expect(summary).toContain("staging"); - expect(summary).toContain("not set"); - }); - }); -}); diff --git a/cli/tests/cli-test-runtime.ts b/cli/tests/cli-test-runtime.ts deleted file mode 100644 index 2a06a2f..0000000 --- a/cli/tests/cli-test-runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { runCommand } from "citty"; -import { buildMainCommand } from "@/cli.ts"; -import type { CliContext } from "@/context.ts"; -import { createCliRuntime, runWithCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; - -export function createCliTestRuntime( - context: CliContext = { debug: false, json: true, agent: false }, -): CliRuntime { - const runtime = createCliRuntime(context); - runtime.output.writeStderr = () => {}; - runtime.output.writeJson = () => {}; - runtime.output.writeRaw = () => {}; - runtime.output.writeHuman = () => {}; - runtime.output.writeMetadata = () => {}; - return runtime; -} - -export async function runCommandWithTestRuntime( - rawArgs: string[], - context: CliContext = { debug: false, json: true, agent: false }, -): Promise { - await runWithCliRuntime(createCliTestRuntime(context), () => - runCommand(buildMainCommand(), { rawArgs }), - ); -} diff --git a/cli/tests/commands-duckdb.test.ts b/cli/tests/commands-duckdb.test.ts deleted file mode 100644 index bd9c4e3..0000000 --- a/cli/tests/commands-duckdb.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; - -function catalogRow(catalog: string): CatalogRow { - return { type: "database", name: catalog, slug: catalog, engine: "altertable", catalog }; -} - -describe("buildDuckdbAttachSnippet", () => { - test("embeds credentials and catalog into the ATTACH connection string", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ["sales"]); - expect(snippet).toContain("INSTALL altertable FROM community;"); - expect(snippet).toContain("LOAD altertable;"); - expect(snippet).toContain("'user=alice password=s3cret catalog=sales'"); - expect(snippet).toContain('AS "sales" (TYPE ALTERTABLE);'); - }); - - test("emits INSTALL/LOAD once and one ATTACH per catalog", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, [ - "sales", - "ops", - ]); - expect(snippet.match(/INSTALL altertable FROM community;/g)).toHaveLength(1); - expect(snippet.match(/ATTACH/g)).toHaveLength(2); - expect(snippet).toContain('AS "sales" (TYPE ALTERTABLE);'); - expect(snippet).toContain('AS "ops" (TYPE ALTERTABLE);'); - }); - - test("quotes the catalog identifier so a hyphen is not parsed as subtraction", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ["my-catalog"]); - expect(snippet).toContain('AS "my-catalog" (TYPE ALTERTABLE);'); - }); - - test("escapes single quotes so a value cannot break out of the SQL string", () => { - const snippet = buildDuckdbAttachSnippet({ user: "a'b", password: "p'w" }, ["c'at"]); - expect(snippet).toContain("'user=a''b password=p''w catalog=c''at'"); - }); - - test("escapes double quotes in the catalog identifier", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ['we"ird']); - expect(snippet).toContain('AS "we""ird" (TYPE ALTERTABLE);'); - }); -}); - -describe("selectCatalogsToAttach", () => { - const rows = [catalogRow("sales"), catalogRow("ops"), catalogRow("")]; - - test("returns every non-empty catalog when none is requested", () => { - expect(selectCatalogsToAttach(rows, undefined)).toEqual(["sales", "ops"]); - }); - - test("deduplicates repeated catalog values", () => { - expect(selectCatalogsToAttach([catalogRow("sales"), catalogRow("sales")], undefined)).toEqual([ - "sales", - ]); - }); - - test("returns only the requested catalog when it exists", () => { - expect(selectCatalogsToAttach(rows, "ops")).toEqual(["ops"]); - }); - - test("throws when the requested catalog is not available", () => { - expect(() => selectCatalogsToAttach(rows, "missing")).toThrow(/not found/); - }); - - test("throws when there are no catalogs to attach", () => { - expect(() => selectCatalogsToAttach([], undefined)).toThrow(/No catalogs found/); - }); -}); diff --git a/cli/tests/commands-lakehouse.test.ts b/cli/tests/commands-lakehouse.test.ts deleted file mode 100644 index 0bb7de1..0000000 --- a/cli/tests/commands-lakehouse.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { CliError } from "@/lib/errors.ts"; -import { - parseAppendJsonContent, - parsePagerOptions, - parseQueryDisplayOptions, - parseQueryOutputOptions, - parseQueryLayout, - parseQueryResultFormatArg, - parseLakehouseFileContentType, -} from "@/commands/lakehouse-args.ts"; -import { parseQueryResultFormat } from "@/lib/lakehouse-client.ts"; -import { buildSchemaStatement, schemaCommand } from "@/commands/lakehouse/schema.ts"; -import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; -import { setCliContext } from "@/context.ts"; -import { - forceNoTerminalColorForTests, - forceTerminalColorForTests, - restoreTerminalState, - snapshotTerminalState, - type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; - -describe("parseQueryDisplayOptions", () => { - test("parses human layout values", () => { - const options = parseQueryDisplayOptions({ layout: "line" }, []); - expect(options.layout).toBe("line"); - }); - - test("parses max width", () => { - const options = parseQueryDisplayOptions({ "max-width": "24" }, []); - expect(options.maxColumnWidth).toBe(24); - }); -}); - -describe("parseQueryLayout", () => { - test("parses auto, table, and line", () => { - expect(parseQueryLayout({ layout: "auto" })).toBe("auto"); - expect(parseQueryLayout({ layout: "table" })).toBe("table"); - expect(parseQueryLayout({ layout: "line" })).toBe("line"); - }); - - test("rejects unknown layout values", () => { - expect(() => parseQueryLayout({ layout: "expanded" })).toThrow(CliError); - }); -}); - -describe("parseQueryResultFormat", () => { - test("parses query result formats", () => { - expect(parseQueryResultFormat("human")).toBe("human"); - expect(parseQueryResultFormat("json")).toBe("json"); - expect(parseQueryResultFormat("csv")).toBe("csv"); - expect(parseQueryResultFormat("markdown")).toBe("markdown"); - }); - - test("rejects unknown query result formats", () => { - expect(() => parseQueryResultFormat("duckbox")).toThrow(CliError); - }); -}); - -describe("parseQueryResultFormatArg", () => { - test("defaults to json when --agent is set", () => { - setCliContext({ debug: false, json: false, agent: true }); - expect(parseQueryResultFormatArg({}, [])).toBe("json"); - setCliContext({ debug: false, json: false, agent: false }); - }); - - test("rejects human-only flags with --agent", () => { - setCliContext({ debug: false, json: false, agent: true }); - expect(() => parseQueryResultFormatArg({}, ["--layout", "table"])).toThrow(CliError); - expect(() => parseQueryResultFormatArg({}, ["--pager", "never"])).toThrow(CliError); - expect(() => parseQueryResultFormatArg({}, ["--max-width", "32"])).toThrow(CliError); - setCliContext({ debug: false, json: false, agent: false }); - }); -}); - -describe("parsePagerOptions", () => { - test("parses pager enum values", () => { - expect(parsePagerOptions({ pager: "never" })).toEqual({ mode: "never" }); - }); - - test("rejects unknown pager values", () => { - expect(() => parsePagerOptions({ pager: "sometimes" })).toThrow(CliError); - }); - - test("forces never pager in agent mode", () => { - setCliContext({ debug: false, json: false, agent: true }); - expect(parsePagerOptions({})).toEqual({ mode: "never" }); - setCliContext({ debug: false, json: false, agent: false }); - }); -}); - -describe("parseQueryOutputOptions", () => { - test("composes query output settings from one validation pass", () => { - const options = parseQueryOutputOptions( - { format: "markdown", layout: "line", "max-width": "24", pager: "never" }, - [], - ); - expect(options.format).toBe("markdown"); - expect(options.displayOptions.layout).toBe("line"); - expect(options.displayOptions.maxColumnWidth).toBe(24); - expect(options.pagerOptions).toEqual({ mode: "never" }); - }); -}); - -describe("buildSchemaStatement", () => { - test("interpolates the catalog as a SQL string literal at every filter site", () => { - const statement = buildSchemaStatement("analytics"); - expect(statement.match(/database_name = 'analytics'/g)).toHaveLength(3); - expect(statement).toContain("duckdb_schemas()"); - expect(statement).toContain("duckdb_tables()"); - expect(statement).toContain("duckdb_views()"); - }); - - test("escapes single quotes in the catalog name", () => { - const statement = buildSchemaStatement("o'brien"); - expect(statement).toContain("database_name = 'o''brien'"); - expect(statement).not.toContain("'o'brien'"); - }); -}); - -describe("schemaCommand", () => { - test("does not expose --layout (human output is always the tree)", () => { - expect(Object.keys(schemaCommand.args ?? {})).not.toContain("layout"); - }); -}); - -describe("formatSchemaTree", () => { - const columns = [ - { name: "schema_name", type: "VARCHAR" }, - { name: "table_name", type: "VARCHAR" }, - { name: "table_comment", type: "VARCHAR" }, - { name: "column_name", type: "VARCHAR" }, - { name: "data_type", type: "VARCHAR" }, - { name: "is_nullable", type: "VARCHAR" }, - { name: "table_type", type: "VARCHAR" }, - { name: "comment", type: "VARCHAR" }, - { name: "ordinal_position", type: "INTEGER" }, - ]; - - const sampleResult = { - metadata: {}, - columns, - rows: [ - ["hello", null, null, null, null, null, null, null, 0], - ["main", null, null, null, null, null, null, null, 0], - ["main", "hello", null, "id", "INTEGER", "YES", "BASE TABLE", null, 1], - ["main", "hello", null, "name", "VARCHAR", "YES", "BASE TABLE", null, 2], - ["main", "hello", null, "created_at", "TIMESTAMP", "YES", "BASE TABLE", null, 3], - ["main", "test", null, "id", "DECIMAL(18,3)", "YES", "BASE TABLE", null, 1], - ], - }; - - let terminalState: TerminalTestState; - - beforeAll(() => { - terminalState = snapshotTerminalState(); - forceNoTerminalColorForTests(); - }); - - afterAll(() => { - restoreTerminalState(terminalState); - }); - - test("cascades catalog, schema, table, and columns with types", () => { - expect(formatSchemaTree(sampleResult, "test_post_role")).toBe( - [ - "Schemas and tables for test_post_role", - "├── hello", - "│ └── ", - "└── main", - " ├── hello", - " │ ├── id INTEGER", - " │ ├── name VARCHAR", - " │ └── created_at TIMESTAMP", - " └── test", - " └── id DECIMAL(18,3)", - ].join("\n"), - ); - }); - - test("annotates views, non-nullable columns, and comments", () => { - const tree = formatSchemaTree( - { - metadata: {}, - columns, - rows: [["main", "v", "user view", "id", "INTEGER", "NO", "VIEW", "primary key", 1]], - }, - "demo", - ); - - expect(tree).toBe( - [ - "Schemas and tables for demo", - "└── main", - " └── v (VIEW) — user view", - " └── id INTEGER NOT NULL — primary key", - ].join("\n"), - ); - }); - - test("reports when the catalog has no schemas", () => { - expect(formatSchemaTree({ metadata: {}, columns, rows: [] }, "empty")).toBe( - ["Schemas and tables for empty", "└── "].join("\n"), - ); - }); - - test("colorizes schema, table, and type labels when color is enabled", () => { - forceTerminalColorForTests(); - try { - const tree = formatSchemaTree(sampleResult, "test_post_role"); - expect(tree).toContain("\u001b[1mSchemas and tables for test_post_role\u001b[22m"); - expect(tree).toContain("\u001b[96mmain\u001b[39m"); - expect(tree).toContain("\u001b[1mtest\u001b[22m"); - expect(tree).toContain("\u001b[33mINTEGER\u001b[39m"); - expect(tree).toContain("\u001b[90m\u001b[39m"); - } finally { - forceNoTerminalColorForTests(); - } - }); -}); - -describe("parseAppendJsonContent", () => { - test("rejects data not starting with object or array", () => { - expect(() => parseAppendJsonContent("not-json")).toThrow(CliError); - }); - - test("rejects @missing.json file paths", () => { - expect(() => parseAppendJsonContent("@/no/such/missing.json")).toThrow(CliError); - expect(() => parseAppendJsonContent("@/no/such/missing.json")).toThrow(/File not found/); - }); - - test("accepts inline JSON objects", () => { - expect(parseAppendJsonContent('{"id":1}')).toBe('{"id":1}'); - }); -}); - -describe("parseLakehouseFileContentType", () => { - test("maps supported lakehouse file formats to content types", () => { - expect(parseLakehouseFileContentType(undefined)).toBeUndefined(); - expect(parseLakehouseFileContentType("csv")).toBe("text/csv"); - expect(parseLakehouseFileContentType("json")).toBe("application/json"); - expect(parseLakehouseFileContentType("parquet")).toBe("application/vnd.apache.parquet"); - }); - - test("rejects unknown lakehouse file formats", () => { - expect(() => parseLakehouseFileContentType("xml")).toThrow(CliError); - expect(() => parseLakehouseFileContentType("xml")).toThrow( - "--format must be one of: csv, json, parquet", - ); - }); -}); diff --git a/cli/tests/commands-output.test.ts b/cli/tests/commands-output.test.ts deleted file mode 100644 index 395cff3..0000000 --- a/cli/tests/commands-output.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - writeCommandOutput, - writeDeleteSuccess, - writeJsonOrRaw, - writeManagementOutput, -} from "@/lib/command-output.ts"; -import { writeLakehouseOutput } from "@/lib/lakehouse-client.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; - -function captureOutput(json: boolean): { - stdout: string[]; - stderr: string[]; - runtime: ReturnType; -} { - const stdout: string[] = []; - const stderr: string[] = []; - const runtime = createCliRuntime({ debug: false, json, agent: false }); - runtime.output.writeJson = (data) => { - stdout.push(JSON.stringify(data, null, 2)); - }; - runtime.output.writeRaw = (body) => { - stdout.push(body); - }; - runtime.output.writeHuman = (text) => { - stdout.push(text); - }; - runtime.output.writeMetadata = (lines) => { - stderr.push(...lines); - }; - return { stdout, stderr, runtime }; -} - -describe("writeCommandOutput", () => { - test("raw_api emits verbatim body in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ kind: "raw_api", body: '{"ok":true}' }); - }); - expect(stdout).toEqual(['{"ok":true}']); - }); - - test("normalized emits envelope in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ - kind: "normalized", - data: { catalogs: [{ name: "db" }] }, - humanText: "table output", - }); - }); - expect(stdout).toEqual([JSON.stringify({ catalogs: [{ name: "db" }] }, null, 2)]); - }); - - test("human emits text envelope in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ kind: "human", text: "configure show text" }); - }); - expect(stdout).toEqual([JSON.stringify({ text: "configure show text" }, null, 2)]); - }); - - test("deleted emits json success object", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeDeleteSuccess("Deleted item.", "item_1"); - }); - expect(stdout).toEqual([JSON.stringify({ deleted: true, id: "item_1" }, null, 2)]); - }); - - test("tabular defaults to table in human mode", async () => { - const listBody = JSON.stringify({ - databases: [{ id: "db_1", name: "Analytics" }], - }); - const { stdout, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeManagementOutput(listBody); - }); - expect(stdout[0]).toContain("id"); - expect(stdout[0]).toContain("Analytics"); - }); - - test("normalized writes metadata lines in human mode", async () => { - const { stdout, stderr, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ - kind: "normalized", - data: { catalogs: [], summary: "0 catalogs" }, - humanText: "table output", - metadataLines: ["", "0 catalogs"], - }); - }); - expect(stdout).toEqual(["table output"]); - expect(stderr).toEqual(["", "0 catalogs"]); - }); - - test("normalized can opt human output into pager handling", async () => { - const { stdout, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ - kind: "normalized", - data: { routes: [] }, - humanText: "wide route table", - pageHumanText: true, - }); - }); - expect(stdout).toEqual(["wide route table"]); - }); - - test("ack emits json data in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ - kind: "ack", - data: { active_profile: "staging" }, - metadataMessage: "Active profile set to staging.", - }); - }); - expect(stdout).toEqual([JSON.stringify({ active_profile: "staging" }, null, 2)]); - }); - - test("ack writes metadata in human mode", async () => { - const previousNoColor = process.env.NO_COLOR; - process.env.NO_COLOR = "1"; - const { stderr, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ - kind: "ack", - data: { active_profile: "staging" }, - metadataMessage: "Active profile set to staging.", - }); - }); - if (previousNoColor === undefined) { - delete process.env.NO_COLOR; - } else { - process.env.NO_COLOR = previousNoColor; - } - expect(stderr).toEqual(["Active profile set to staging."]); - }); -}); - -describe("writeJsonOrRaw", () => { - test("writes raw API body in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeJsonOrRaw('{"principal":{"name":"Jane"}}', (data) => String(data)); - }); - expect(stdout).toEqual(['{"principal":{"name":"Jane"}}']); - }); -}); - -describe("writeLakehouseOutput", () => { - test("writes raw API body in json mode", async () => { - const { stdout, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeLakehouseOutput('{"ok":true}'); - }); - expect(stdout).toEqual(['{"ok":true}']); - }); - - test("calls human formatter when not in json mode", async () => { - const { stdout, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeLakehouseOutput('{"ok":true}', { humanFormatter: () => "human" }); - }); - expect(stdout).toEqual(["human"]); - }); -}); diff --git a/cli/tests/config.test.ts b/cli/tests/config.test.ts deleted file mode 100644 index e79871d..0000000 --- a/cli/tests/config.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, statSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - configDir, - configGet, - configSet, - configSetGlobal, - getQueryDefaultMaxColumnWidth, - getQueryDefaultLayout, - getQueryDefaultPager, - kvGet, - kvSet, - resolveApiBase, - resolveManagementApiBase, -} from "@/lib/config.ts"; -import { - secretGet, - secretSet, - secretExists, - resetSecretWarningsForTests, - setSpawnSyncForTests, -} from "@/lib/secrets.ts"; -import { CliError } from "@/lib/errors.ts"; -import { assertAllowedApiBase } from "@/lib/url-policy.ts"; -import { configureRunClear, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { setCliContext } from "@/context.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; - -let testHome = ""; -const profileName = "default"; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-cli-test-")); - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - resetSecretWarningsForTests(); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; - delete process.env.ALTERTABLE_SECRET_BACKEND; - delete process.env.ALTERTABLE_API_BASE; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_ENV; - delete process.env.ALTERTABLE_API_KEY; -}); - -describe("config", () => { - test("kv round-trip", () => { - const filePath = join(testHome, "sample"); - kvSet(filePath, "user", "alice"); - expect(kvGet(filePath, "user")).toBe("alice"); - kvSet(filePath, "user", "bob"); - expect(kvGet(filePath, "user")).toBe("bob"); - expect(configGet("missing", profileName)).toBe(""); - }); - - test("kvSet writes temp files with mode 0600", () => { - const filePath = join(testHome, "mode-check"); - kvSet(filePath, "secret", "value"); - const mode = statSync(filePath).mode & 0o777; - expect(mode).toBe(0o600); - }); - - test("resolve api bases with defaults", () => { - 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"); - }); - - test("env vars beat stored config", () => { - configSet("api_base", "http://localhost:1111", profileName); - process.env.ALTERTABLE_API_BASE = "http://localhost:2222"; - expect(resolveApiBase(profileName)).toBe("http://localhost:2222"); - }); - - test("resolveApiBase rejects insecure env override without allow flag", () => { - process.env.ALTERTABLE_API_BASE = "http://192.168.1.5"; - expect(() => resolveApiBase(profileName)).toThrow(CliError); - delete process.env.ALTERTABLE_API_BASE; - }); - - test("resolveApiBase allows insecure env override with ALTERTABLE_ALLOW_INSECURE_HTTP", () => { - process.env.ALTERTABLE_API_BASE = "http://192.168.1.5"; - process.env.ALTERTABLE_ALLOW_INSECURE_HTTP = "true"; - expect(resolveApiBase(profileName)).toBe("http://192.168.1.5"); - delete process.env.ALTERTABLE_API_BASE; - delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; - }); - - test("reads query display defaults from config", () => { - configSetGlobal("query_max_width", "48"); - configSetGlobal("query_layout", "line"); - expect(getQueryDefaultMaxColumnWidth()).toBe(48); - expect(getQueryDefaultLayout()).toBe("line"); - }); - - test("ignores invalid query config values", () => { - configSetGlobal("query_max_width", "4"); - configSetGlobal("query_layout", "invalid"); - expect(getQueryDefaultMaxColumnWidth()).toBeUndefined(); - expect(getQueryDefaultLayout()).toBeUndefined(); - }); - - test("reads query_pager config", () => { - configSetGlobal("query_pager", "always"); - expect(getQueryDefaultPager()).toBe("always"); - }); - - test("ignores unknown query_pager values", () => { - configSetGlobal("query_pager", "sometimes"); - expect(getQueryDefaultPager()).toBeUndefined(); - }); - - test("query_pager respects ALTERTABLE_CONFIG_HOME", () => { - configSetGlobal("query_pager", "never"); - expect(getQueryDefaultPager()).toBe("never"); - }); -}); - -describe("secrets", () => { - test("stores and reads file-backed secrets", () => { - secretSet("api-key", "atm_test", profileName); - expect(secretGet("api-key", profileName)).toBe("atm_test"); - expect(secretExists("api-key", profileName)).toBe(true); - }); -}); - -describe("profile --configure credential accumulation", () => { - test("preserves management credentials when updating lakehouse credentials", async () => { - await configureRunSet({ apiKey: "atm_test", env: "development" }); - await configureRunSet({ user: "alice", password: "lakehouse-secret" }); - - expect(secretGet("api-key", profileName)).toBe("atm_test"); - expect(configGet("api_key_env", profileName)).toBe("development"); - expect(secretGet("lakehouse/password", profileName)).toBe("lakehouse-secret"); - expect(configGet("user", profileName)).toBe("alice"); - }); - - test("accumulates both planes across separate configure invocations", async () => { - await configureRunSet({ - user: "alice", - password: "lakehouse-secret", - }); - await configureRunSet({ - apiKey: "atm_test", - env: "development", - }); - - expect(secretGet("api-key", profileName)).toBe("atm_test"); - expect(configGet("api_key_env", profileName)).toBe("development"); - expect(secretGet("lakehouse/password", profileName)).toBe("lakehouse-secret"); - expect(configGet("user", profileName)).toBe("alice"); - }); -}); - -describe("profile --configure validation", () => { - test("rejects mixing lakehouse and management credentials in one invocation", async () => { - return expect( - configureRunSet({ user: "u", password: "p", apiKey: "atm_x", env: "prod" }), - ).rejects.toThrow(CliError); - }); - - test("rejects mixing lakehouse and management credentials with expected message", async () => { - return expect( - configureRunSet({ user: "u", password: "p", apiKey: "atm_x", env: "prod" }), - ).rejects.toThrow(/single authentication mechanism per configure invocation/); - }); - - test("rejects two lakehouse authentication mechanisms in one invocation", async () => { - return expect( - configureRunSet({ user: "u", password: "p", basicToken: "dG9rZW4=" }), - ).rejects.toThrow(CliError); - }); - - test("rejects two lakehouse authentication mechanisms with expected message", async () => { - return expect( - configureRunSet({ user: "u", password: "p", basicToken: "dG9rZW4=" }), - ).rejects.toThrow(/single lakehouse authentication mechanism/); - }); -}); - -describe("configureRunClear", () => { - test("configureRunClear removes root config and credentials files", async () => { - await configureRunSet({ user: "alice", password: "lakehouse-secret" }); - configureRunClear(); - expect(existsSync(join(testHome, "config"))).toBe(false); - expect(existsSync(join(testHome, "credentials"))).toBe(false); - expect(existsSync(join(testHome, "profiles"))).toBe(false); - }); - - test("configureRunClear removes secrets for all profiles", 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"); - secretSet("lakehouse/password", "lake-prod", "prod"); - - configureRunClear(); - setCliContext({ debug: false, json: false, agent: false }); - - expect(secretGet("api-key", "staging")).toBe(""); - expect(secretGet("api-key", "prod")).toBe(""); - expect(secretGet("lakehouse/password", "staging")).toBe(""); - expect(secretGet("lakehouse/password", "prod")).toBe(""); - }); -}); - -describe("config dir", () => { - test("uses ALTERTABLE_CONFIG_HOME", () => { - expect(configDir()).toBe(testHome); - configSet("user", "x", profileName); - expect(existsSync(join(testHome, "profiles", "default", "config"))).toBe(true); - expect(readFileSync(join(testHome, "profiles", "default", "config"), "utf8")).toContain( - "user=x", - ); - }); -}); - -describe("url policy", () => { - test("allows localhost HTTP without flag", () => { - expect(() => assertAllowedApiBase("http://localhost:15000")).not.toThrow(); - expect(() => assertAllowedApiBase("http://127.0.0.1:8080")).not.toThrow(); - }); - - test("allows HTTPS for any host", () => { - expect(() => assertAllowedApiBase("https://api.altertable.ai")).not.toThrow(); - }); - - test("rejects non-localhost HTTP without flag", () => { - expect(() => assertAllowedApiBase("http://192.168.1.5:8080")).toThrow(CliError); - }); - - test("allows non-localhost HTTP with allowInsecureHttp", () => { - expect(() => - assertAllowedApiBase("http://192.168.1.5:8080", { allowInsecureHttp: true }), - ).not.toThrow(); - }); -}); - -describe("profile --configure argv secrets", () => { - test("warns when password is passed on argv", async () => { - const stderr: string[] = []; - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - runtime.output.writeMetadata = (lines) => { - stderr.push(...lines); - }; - - await runWithCliRuntime(runtime, async () => { - await configureRunSet({ user: "alice", password: "test-password-value" }); - }); - - expect(stderr.some((line) => line.includes("--password-stdin"))).toBe(true); - }); - - test("argv secrets skip keychain write spawn", () => { - const spawnCalls: string[][] = []; - setSpawnSyncForTests((_command, args) => { - spawnCalls.push([...args]); - return { - status: 0, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - }); - - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: "darwin" }); - - try { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; - secretSet("lakehouse/password", "test-password-value", profileName, { fromArgv: true }); - const keychainWrites = spawnCalls.filter((args) => args.includes("add-generic-password")); - expect(keychainWrites).toHaveLength(0); - expect(secretGet("lakehouse/password", profileName)).toBe("test-password-value"); - } finally { - if (platformDescriptor) { - Object.defineProperty(process, "platform", platformDescriptor); - } - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - setSpawnSyncForTests(undefined); - } - }); -}); diff --git a/cli/tests/lakehouse.test.ts b/cli/tests/lakehouse.test.ts deleted file mode 100644 index 3e94e0a..0000000 --- a/cli/tests/lakehouse.test.ts +++ /dev/null @@ -1,674 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -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 { ParseError } from "@/lib/errors.ts"; -import { - buildLakehouseQueryPayload, - csvEscapeCell, - getQueryColumnNames, - parseLakehouseQueryResponse, - parseLakehouseQueryStream, - renderQueryCsv, - renderQueryJson, - renderQueryTable, - type LakehouseQueryResult, - type LakehouseRow, -} from "@/lib/lakehouse-client.ts"; -import { httpSendStream } from "@/lib/http.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { httpStreamEffect, runOperationEffect } from "@/lib/operation-effect.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; -import { normalizeQueryInvocatorRawArgs } from "@/commands/lakehouse/query.ts"; - -const SAMPLE_NDJSON = [ - '{"statement":"SELECT 1","session_id":"abc","query_id":"def"}', - '["id","name"]', - '[1,"Alice"]', - '[2,"Bob"]', -].join("\n"); - -let testHome = ""; -let mockFile = ""; -let logFile = ""; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-lakehouse-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"; - setCliContext({ debug: false, json: false, agent: false }); - refreshCliRuntimeContext(getCliRuntime().context); -}); - -function createOperationContext(): OperationContext { - const runtime = getCliRuntime(); - return { - args: {}, - rawArgs: [], - runtime, - sink: runtime.output, - execution: createExecutionContext(runtime), - }; -} - -async function collectLakehouseQueryStream( - stream: ReadableStream, -): Promise { - const parser = parseLakehouseQueryStream(stream); - while (true) { - const next = await parser.next(); - if (next.done) { - return next.value; - } - } -} - -function readLoggedPayloads(): string[] { - return readFileSync(logFile, "utf8") - .split("\n") - .filter((line) => line.startsWith("PAYLOAD=")) - .map((line) => line.slice("PAYLOAD=".length)); -} - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_MOCK_HTTP_FILE; - delete process.env.ALTERTABLE_HTTP_LOG; - delete process.env.ALTERTABLE_API_BASE; - delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; -}); - -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.rows).toEqual([ - [1, "Alice"], - [2, "Bob"], - ]); - }); - - 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"), - ); - - expect(result.columns).toEqual([ - { name: "id", type: "integer" }, - { name: "name", type: "varchar" }, - ]); - expect(result.rows).toEqual([{ id: 1, name: "Alice" }]); - }); - - test("treats a non-column second line as the first row", () => { - const result = parseLakehouseQueryResponse( - ['{"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 with line index for malformed JSON", () => { - const malformed = `${SAMPLE_NDJSON}\nnot-json`; - try { - parseLakehouseQueryResponse(malformed); - throw new Error("expected parse failure"); - } catch (error) { - expect(error).toBeInstanceOf(ParseError); - expect((error as ParseError).message).toContain("line 5"); - } - }); - - 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 () => { - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/query", - method: "POST", - chunked: true, - body: SAMPLE_NDJSON, - }, - ]), - ); - - const byteStream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Basic test", - body: '{"statement":"SELECT 1"}', - }); - - const rowValues: LakehouseRow[] = []; - const streamParser = parseLakehouseQueryStream(byteStream); - while (true) { - const next = await streamParser.next(); - if (next.done) { - expect(next.value.rows).toEqual([ - [1, "Alice"], - [2, "Bob"], - ]); - break; - } - rowValues.push(next.value); - } - - expect(rowValues).toEqual([ - [1, "Alice"], - [2, "Bob"], - ]); - }); - - test("throws ParseError when metadata line is incomplete across first chunk", async () => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('{"statement":"SELECT 1"')); - controller.close(); - }, - }); - - try { - const streamParser = parseLakehouseQueryStream(stream); - await streamParser.next(); - throw new Error("expected parse failure"); - } catch (error) { - expect(error).toBeInstanceOf(ParseError); - } - }); - - test("throws ParseError for empty stream", async () => { - const stream = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - - try { - const streamParser = parseLakehouseQueryStream(stream); - await streamParser.next(); - 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 () => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode(['{"statement":"SELECT 1"}', '{"id":1}', '{"id":2}'].join("\n")), - ); - controller.close(); - }, - }); - - const rowValues: LakehouseRow[] = []; - const streamParser = parseLakehouseQueryStream(stream); - while (true) { - const next = await streamParser.next(); - if (next.done) { - expect(next.value.columns).toEqual([]); - expect(next.value.rows).toEqual([{ id: 1 }, { id: 2 }]); - break; - } - rowValues.push(next.value); - } - - expect(rowValues).toEqual([{ id: 1 }, { id: 2 }]); - }); - - test("malformed row line includes line index", async () => { - const malformedBody = `${SAMPLE_NDJSON}\nnot-json`; - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/query", - method: "POST", - chunked: true, - body: malformedBody, - }, - ]), - ); - - const byteStream = await httpSendStream({ - method: "POST", - url: "https://example.com/query", - authHeader: "Authorization: Basic test", - body: '{"statement":"SELECT 1"}', - }); - - const streamParser = parseLakehouseQueryStream(byteStream); - await streamParser.next(); - await streamParser.next(); - try { - await streamParser.next(); - throw new Error("expected parse failure"); - } catch (error) { - expect(error).toBeInstanceOf(ParseError); - expect((error as ParseError).message).toContain("line 5"); - } - }); -}); - -describe("lakehouse query stream effect", () => { - test("returns the same result as parseLakehouseQueryResponse", async () => { - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/query", - method: "POST", - chunked: true, - body: SAMPLE_NDJSON, - }, - ]), - ); - - const streamedResult = await runOperationEffect( - httpStreamEffect( - { - plane: "lakehouse", - method: "POST", - endpoint: "/query", - body: JSON.stringify(buildLakehouseQueryPayload("SELECT 1")), - contentType: "application/json", - retry: false, - }, - collectLakehouseQueryStream, - ), - createOperationContext(), - ); - const bufferedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); - - expect(streamedResult).toEqual(bufferedResult); - }); -}); - -describe("query renderers", () => { - const parsedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); - - test("renderQueryTable prints a padded table", () => { - const table = renderQueryTable(parsedResult); - expect(table).toContain("id"); - expect(table).toContain("name"); - expect(table).toContain("Alice"); - expect(table).toContain("Bob"); - }); - - test("renderQueryCsv outputs header and quoted values", () => { - const csv = renderQueryCsv(parsedResult); - expect(csv).toBe("id,name\n1,Alice\n2,Bob"); - }); - - 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"'); - }); - - 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.rows).toHaveLength(2); - }); - - test("getQueryColumnNames derives names from object rows", () => { - const names = getQueryColumnNames({ - metadata: {}, - columns: [], - rows: [{ id: 1, name: "Alice" }], - }); - expect(names).toEqual(["id", "name"]); - }); -}); - -describe("normalizeQueryInvocatorRawArgs", () => { - test("routes a bare SQL statement to the run subcommand", () => { - expect(normalizeQueryInvocatorRawArgs(["query", "SELECT 1"])).toEqual([ - "query", - "run", - "SELECT 1", - ]); - }); - - test("keeps flags citty-parsed on either side of the statement", () => { - expect(normalizeQueryInvocatorRawArgs(["query", "--format", "json", "SELECT 1"])).toEqual([ - "query", - "run", - "--format", - "json", - "SELECT 1", - ]); - expect(normalizeQueryInvocatorRawArgs(["query", "SELECT 1", "--format", "json"])).toEqual([ - "query", - "run", - "SELECT 1", - "--format", - "json", - ]); - }); - - test("leaves real subcommands, flag-only invocations, and other commands untouched", () => { - expect(normalizeQueryInvocatorRawArgs(["query", "show", "abc"])).toEqual([ - "query", - "show", - "abc", - ]); - expect(normalizeQueryInvocatorRawArgs(["query", "--format", "json"])).toEqual([ - "query", - "--format", - "json", - ]); - expect(normalizeQueryInvocatorRawArgs(["schema", "analytics"])).toEqual([ - "schema", - "analytics", - ]); - }); -}); - -describe("lakehouse command HTTP behavior", () => { - test("query command sends optional query and session identifiers", async () => { - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/query", - method: "POST", - body: SAMPLE_NDJSON, - }, - ]), - ); - - await runCommandWithTestRuntime( - normalizeQueryInvocatorRawArgs([ - "query", - "SELECT 1", - "--format", - "json", - "--query-id", - "query-1", - "--session-id", - "session-1", - ]), - ); - - expect(JSON.parse(readLoggedPayloads()[0] ?? "")).toEqual({ - statement: "SELECT 1", - query_id: "query-1", - session_id: "session-1", - }); - }); - - test("query runs a positional SQL statement without --statement", async () => { - writeFileSync( - mockFile, - JSON.stringify([{ urlPattern: "/query", method: "POST", body: SAMPLE_NDJSON }]), - ); - - await runCommandWithTestRuntime( - normalizeQueryInvocatorRawArgs(["query", "SELECT 1", "--format", "json"]), - ); - - expect(JSON.parse(readLoggedPayloads()[0] ?? "")).toEqual({ statement: "SELECT 1" }); - }); - - test("query subcommands dispatch without requiring --statement", async () => { - const queryId = "11111111-2222-3333-4444-555555555555"; - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: `/query/${queryId}`, - method: "GET", - body: '{"uuid":"11111111-2222-3333-4444-555555555555"}', - }, - { - urlPattern: `/query/${queryId}`, - method: "DELETE", - body: '{"cancelled":true}', - }, - ]), - ); - - await runCommandWithTestRuntime(["query", "show", queryId]); - await runCommandWithTestRuntime(["query", "cancel", queryId, "--session-id", "session-1"]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("METHOD=GET"); - expect(logContent).toContain(`URL=https://example.com/query/${queryId}`); - expect(logContent).toContain("METHOD=DELETE"); - expect(logContent).toContain(`URL=https://example.com/query/${queryId}?session_id=session-1`); - }); - - test("append --sync sends sync=true query param", async () => { - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/append", - method: "POST", - body: '{"ok":true}', - }, - ]), - ); - - await runCommandWithTestRuntime([ - "append", - "--catalog", - "memory", - "--schema", - "main", - "--table", - "users", - "--data", - '{"id":1}', - "--sync", - ]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("URL=https://example.com/append?"); - expect(logContent).toContain("sync=true"); - expect(JSON.parse(readLoggedPayloads()[0] ?? "")).toEqual({ id: 1 }); - }); - - test("append status calls /tasks/{append_id}", async () => { - const appendId = "11111111-2222-3333-4444-555555555555"; - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: `/tasks/${appendId}`, - method: "GET", - body: '{"task_id":"11111111-2222-3333-4444-555555555555","status":"completed"}', - }, - ]), - ); - - await runCommandWithTestRuntime(["append", "status", appendId]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain(`URL=https://example.com/tasks/${appendId}`); - }); - - test("upload command sends mode without exposing file contents", async () => { - const uploadFile = join(testHome, "data.csv"); - writeFileSync(uploadFile, "id,name\n1,Alice", "utf8"); - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/upload", - method: "POST", - body: '{"ok":true}', - }, - ]), - ); - - await runCommandWithTestRuntime([ - "upload", - "--catalog", - "memory", - "--schema", - "main", - "--table", - "users", - "--format", - "csv", - "--mode", - "overwrite", - "--file", - uploadFile, - ]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("URL=https://example.com/upload?"); - expect(logContent).toContain("mode=overwrite"); - expect(logContent).not.toContain("format=csv"); - expect(logContent).not.toContain("primary_key=id"); - expect(logContent).toContain("PAYLOAD=@blob"); - expect(logContent).not.toContain("id,name"); - }); - - test("upsert command sends primary key without upload mode", async () => { - const uploadFile = join(testHome, "data.csv"); - writeFileSync(uploadFile, "id,name\n1,Alice", "utf8"); - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: "/upsert", - method: "POST", - body: '{"ok":true}', - }, - ]), - ); - - await runCommandWithTestRuntime([ - "upsert", - "--catalog", - "memory", - "--schema", - "main", - "--table", - "users", - "--primary-key", - "id", - "--format", - "csv", - "--file", - uploadFile, - ]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain("URL=https://example.com/upsert?"); - expect(logContent).toContain("primary_key=id"); - expect(logContent).not.toContain("mode=upsert"); - expect(logContent).toMatch(/PAYLOAD=@(?:blob|stream)/); - }); - - test("upload command rejects missing files and directories", async () => { - const directoryPath = join(testHome, "upload-directory"); - mkdirSync(directoryPath); - - try { - await runCommandWithTestRuntime([ - "upload", - "--catalog", - "memory", - "--schema", - "main", - "--table", - "users", - "--format", - "csv", - "--mode", - "overwrite", - "--file", - join(testHome, "missing.csv"), - ]); - throw new Error("expected missing file failure"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("File not found"); - } - - try { - await runCommandWithTestRuntime([ - "upload", - "--catalog", - "memory", - "--schema", - "main", - "--table", - "users", - "--format", - "csv", - "--mode", - "overwrite", - "--file", - directoryPath, - ]); - throw new Error("expected directory failure"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("File not found"); - } - }); - - test("cancel URL-encodes query id in path", async () => { - const queryId = "query/id+special"; - const encodedQueryId = encodeURIComponent(queryId); - writeFileSync( - mockFile, - JSON.stringify([ - { - urlPattern: `/query/${encodedQueryId}`, - method: "DELETE", - body: '{"cancelled":true}', - }, - ]), - ); - - await runCommandWithTestRuntime(["query", "cancel", queryId, "--session-id", "session-1"]); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain(`URL=https://example.com/query/${encodedQueryId}`); - expect(logContent).toContain("session_id=session-1"); - }); -}); diff --git a/cli/tests/management-payloads.test.ts b/cli/tests/management-payloads.test.ts deleted file mode 100644 index 734c10d..0000000 --- a/cli/tests/management-payloads.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; -import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/lib/api-body.ts"; -import { CliError, ParseError } from "@/lib/errors.ts"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -describe("management payload builders", () => { - test("buildCreateCatalogBody includes altertable engine", () => { - const body = buildCreateCatalogBody({ name: "My Cat" }); - expect(JSON.parse(body)).toEqual({ name: "My Cat", engine: "altertable" }); - }); -}); - -describe("api-body helpers", () => { - test("readJsonBody reads inline JSON and @file payloads", () => { - const tempDir = mkdtempSync(join(tmpdir(), "altertable-payload-test-")); - const filePath = join(tempDir, "body.json"); - writeFileSync(filePath, '{"name":"from-file"}', "utf8"); - - expect(readJsonBody('{"name":"inline"}')).toBe('{"name":"inline"}'); - expect(readJsonBody(`@${filePath}`)).toBe('{"name":"from-file"}'); - - rmSync(tempDir, { recursive: true, force: true }); - }); - - test("readJsonBody throws for missing @file paths", () => { - expect(() => readJsonBody("@/no/such/file.json")).toThrow(CliError); - }); - - test("readJsonBody throws ParseError for invalid inline JSON", () => { - expect(() => readJsonBody("{not-json")).toThrow(ParseError); - }); - - test("buildBodyFromFields merges repeatable fields", () => { - const body = buildBodyFromFields(["label=ops", "name=analytics"]); - expect(JSON.parse(body ?? "")).toEqual({ label: "ops", name: "analytics" }); - }); - - test("resolveApiBody builds from fields for POST", () => { - const body = resolveApiBody({ - method: "POST", - fields: ["label=ops"], - }); - expect(JSON.parse(body ?? "")).toEqual({ label: "ops" }); - }); - - test("resolveApiBody prefers explicit body when fields are also present", () => { - const body = resolveApiBody({ - method: "POST", - body: '{"label":"raw"}', - fields: ["label=ops"], - }); - - expect(body).toBe('{"label":"raw"}'); - }); -}); diff --git a/cli/tests/management-transport.test.ts b/cli/tests/management-transport.test.ts deleted file mode 100644 index eaae801..0000000 --- a/cli/tests/management-transport.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 { managementRequest } from "@/lib/management-transport.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; - -let testHome = ""; -let mockFile = ""; -let logFile = ""; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-mgmt-client-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"; - setCliContext({ debug: false, json: false, agent: false }); - refreshCliRuntimeContext(getCliRuntime().context); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_MOCK_HTTP_FILE; - delete process.env.ALTERTABLE_HTTP_LOG; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_API_KEY; -}); - -describe("managementRequest", () => { - 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: "{}", - }, - ]), - ); - - await managementRequest("GET", `/service_accounts/${id}`); - - const logContent = readFileSync(logFile, "utf8"); - expect(logContent).toContain( - `URL=https://app.example.com/rest/v1/service_accounts/${encodedId}`, - ); - }); -}); diff --git a/cli/tests/oauth.test.ts b/cli/tests/oauth.test.ts deleted file mode 100644 index 55ca7d2..0000000 --- a/cli/tests/oauth.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -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 { resolveOAuthBase } from "@/lib/config.ts"; -import { configGet } from "@/lib/config.ts"; -import { setCliContext } from "@/context.ts"; -import { parseCallback, buildAuthorizeUrl, startLoopbackServer } from "@/lib/oauth-flow.ts"; -import { storeOAuthTokens, getStoredAccessToken, clearOAuthTokens } from "@/lib/oauth-profile.ts"; -import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/profile-store.ts"; -import { createEmptyProfile } from "@/features/profile/model.ts"; -import { secretSet } from "@/lib/secrets.ts"; - -const profileName = "default"; -let testHome = ""; -const TEST_PRINCIPAL = { - type: "User", - name: "Test User", - email: "test.user@altertable.test", -} as const; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-oauth-test-")); - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - setCliContext({ debug: false, json: false, agent: false }); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; - delete process.env.ALTERTABLE_SECRET_BACKEND; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; - delete process.env.ALTERTABLE_API_KEY; - delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; - delete process.env.ALTERTABLE_LAKEHOUSE_USERNAME; - delete process.env.ALTERTABLE_LAKEHOUSE_PASSWORD; -}); - -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"); - }); -}); - -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", () => { - const outcome = parseCallback("/callback?code=abc&state=other", "st"); - expect(outcome.ok).toBe(false); - }); - - test("surfaces provider errors", () => { - const outcome = parseCallback("/callback?error=access_denied&error_description=nope", "st"); - expect(outcome).toEqual({ ok: false, message: "Authorization failed: access_denied — nope" }); - }); - - test("rejects a missing code", () => { - expect(parseCallback("/callback?state=st", "st").ok).toBe(false); - }); -}); - -describe("buildAuthorizeUrl", () => { - test("includes PKCE, state 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", - }, - resolveOAuthBase(profileName), - ), - ); - 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"); - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - }); -}); - -describe("loopback server", () => { - test("resolves the code from a valid callback", async () => { - const server = await startLoopbackServer("st"); - try { - const res = await fetch(`${server.redirectUri}?code=abc&state=st`); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toContain("text/plain"); - expect(await server.waitForCode()).toBe("abc"); - } finally { - server.close(); - } - }); - - test("serves the callback error as plain text (no HTML reflection)", async () => { - const server = await startLoopbackServer("st"); - const settled = server.waitForCode().then( - () => "resolved", - () => "rejected", - ); - try { - const res = await fetch( - `${server.redirectUri}?error=access_denied&error_description=${encodeURIComponent("")}`, - ); - expect(res.status).toBe(400); - expect(res.headers.get("content-type")).toContain("text/plain"); - // The message is shown verbatim, but as plain text it cannot execute. - expect(await res.text()).toBe("Authorization failed: access_denied — "); - expect(await settled).toBe("rejected"); - } finally { - server.close(); - } - }); - - test("ignores non-callback paths (e.g. favicon)", async () => { - const server = await startLoopbackServer("st"); - try { - const port = new URL(server.redirectUri).port; - const res = await fetch(`http://127.0.0.1:${port}/favicon.ico`); - expect(res.status).toBe(404); - } finally { - server.close(); - } - }); -}); - -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(getStoredAccessToken(profileName)).toBe("acc"); - const expiry = Number.parseInt(configGet("oauth_expiry", profileName), 10); - expect(expiry).toBeGreaterThanOrEqual(before + 3600 * 1000); - }); - - test("clear removes tokens and expiry", () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - clearOAuthTokens(profileName); - expect(getStoredAccessToken(profileName)).toBe(""); - expect(configGet("oauth_expiry", profileName)).toBe(""); - }); -}); - -import { - assertInteractiveLogin, - resolveWhoamiEnvironmentSlug, - applyControlPlaneOverride, - sameWhoamiContext, - storeLoginProfileMetadata, -} from "@/commands/login.ts"; -import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/features/profile/model.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { configureRunClear } from "@/lib/profile-configure-core.ts"; -import { getOutputSink } from "@/lib/runtime.ts"; - -describe("resolveWhoamiEnvironment", () => { - test("reads the flat environment_slug field", () => { - expect( - resolveWhoamiEnvironmentSlug({ - principal: { type: "User", name: "Sylvain", email: "sylvain@altertable.ai" }, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", - }), - ).toBe("production"); - }); - - test("returns undefined when no environment is scoped", () => { - expect(resolveWhoamiEnvironmentSlug({ principal: {}, organization: {} })).toBeUndefined(); - }); -}); - -describe("sameWhoamiContext", () => { - const WHOAMI = { - principal: { type: "User", name: "Sylvain", email: "sylvain@altertable.ai", slug: "sylvain" }, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", - } as const; - - test("true for the same identity and context", () => { - // A cosmetic display-name change is not an identity change. - expect( - sameWhoamiContext(WHOAMI, { ...WHOAMI, principal: { ...WHOAMI.principal, name: "S." } }), - ).toBe(true); - }); - - test("false when the principal changes", () => { - expect( - sameWhoamiContext(WHOAMI, { - ...WHOAMI, - principal: { ...WHOAMI.principal, slug: "mallory", email: "mallory@altertable.ai" }, - }), - ).toBe(false); - }); - - test("false when the organization changes", () => { - expect(sameWhoamiContext(WHOAMI, { ...WHOAMI, organization: { slug: "other" } })).toBe(false); - }); - - test("false when the environment changes", () => { - expect(sameWhoamiContext(WHOAMI, { ...WHOAMI, environment_slug: "staging" })).toBe(false); - }); -}); - -describe("login profile metadata", () => { - const WHOAMI = { - principal: TEST_PRINCIPAL, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", - } as const; - - // `altertable login` on a fresh install signs into the unauthenticated `default` - // profile and stores the whoami identity there, rather than deriving a new one. - test("lands in the current default profile when it is unauthenticated", () => { - const metadata = storeLoginProfileMetadata(WHOAMI, {}); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - - expect(metadata).toEqual({ - environment: "production", - profileName: "default", - profileAction: "unchanged", - }); - expect(getActiveProfileName()).toBe("default"); - expect(profileExists("altertable_production")).toBe(false); - expect(configGet("api_key_env", profileName)).toBe("production"); - expect(configGet("organization_slug", profileName)).toBe("altertable"); - expect(configGet("organization_name", profileName)).toBe("Altertable"); - expect(configGet("principal_name", profileName)).toBe("Test User"); - expect(configGet("principal_email", profileName)).toBe("test.user@altertable.test"); - expect(getStoredAccessToken(profileName)).toBe("acc"); - }); - - // A second `altertable login` must not clobber the now-authenticated `default`; - // it derives and switches to a fresh profile instead. - test("creates a separate profile when the current one is already authenticated", () => { - storeLoginProfileMetadata(WHOAMI, {}); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - - const metadata = storeLoginProfileMetadata(WHOAMI, {}); - - expect(metadata).toEqual({ - environment: "production", - profileName: "altertable_production", - profileAction: "created", - }); - expect(profileExists("default")).toBe(true); - expect(profileExists("altertable_production")).toBe(true); - expect(getActiveProfileName()).toBe("altertable_production"); - }); - - // `altertable profile create new` (which switches to `new`) followed by - // `altertable login` signs into `new`, since it has not been configured yet. - test("lands in a freshly created, unconfigured profile", () => { - createEmptyProfile("new"); - setActiveProfile("new"); - - const metadata = storeLoginProfileMetadata(WHOAMI, {}); - - expect(metadata).toEqual({ - environment: "production", - profileName: "new", - profileAction: "unchanged", - }); - expect(getActiveProfileName()).toBe("new"); - expect(profileExists("altertable_production")).toBe(false); - }); - - // Lakehouse-only credentials still count as "authenticated" — a login must not - // overwrite them, so it branches to a new profile. - test("treats a profile with only lakehouse credentials as authenticated", () => { - secretSet("lakehouse/password", "s_lake", profileName); - - const metadata = storeLoginProfileMetadata(WHOAMI, {}); - - expect(metadata).toEqual({ - environment: "production", - profileName: "altertable_production", - profileAction: "created", - }); - expect(getActiveProfileName()).toBe("altertable_production"); - }); - - // Credentials supplied via environment variables aren't a stored profile; the - // active identity is the reserved `_from_env` pseudo-profile. - test("resolves the active identity to reserved _from_env when a credential env var is set", () => { - expect(resolveActiveProfileName()).toBe("default"); - - process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(resolveActiveProfileName()).toBe("_from_env"); - delete process.env.ALTERTABLE_API_KEY; - - process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "dG9rZW4="; - expect(resolveActiveProfileName()).toBe("_from_env"); - delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; - - process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "u"; - process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "p"; - expect(resolveActiveProfileName()).toBe("_from_env"); - }); - - // Environment credentials pin the identity, so stored-profile controls (login, - // switching) are locked while they are set. - test("locks stored-profile controls while the CLI is configured through the environment", () => { - expect(() => assertNoEnvConfigMode()).not.toThrow(); - - process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(() => assertNoEnvConfigMode()).toThrow(ConfigurationError); - }); - - test("_from_env is a reserved profile name that cannot be created", () => { - expect(() => createEmptyProfile("_from_env")).toThrow(); - }); - - test("can replace the current profile login session", () => { - const metadata = storeLoginProfileMetadata( - { - principal: TEST_PRINCIPAL, - organization: { name: "Altertable", slug: "altertable" }, - authentication_scope: "environment", - environment_slug: "production", - }, - { "replace-profile": true }, - ); - - expect(metadata).toEqual({ - environment: "production", - profileName: "default", - profileAction: "replaced", - }); - expect(profileExists("default")).toBe(true); - expect(profileExists("altertable_production")).toBe(false); - expect(getActiveProfileName()).toBe("default"); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - expect(getStoredAccessToken(profileName)).toBe("acc"); - }); - - test("stores endpoint overrides after successful login metadata is available", () => { - storeLoginProfileMetadata( - { - principal: {}, - organization: { name: "Altertable", slug: "altertable" }, - environment_slug: "production", - }, - { - "control-plane-url": "https://app.altertable.test", - "data-plane-url": "https://api.altertable.test", - }, - ); - - expect(configGet("management_api_base", profileName)).toBe("https://app.altertable.test"); - expect(configGet("api_base", profileName)).toBe("https://api.altertable.test"); - }); -}); - -describe("login --control-plane-url", () => { - test("stores the control-plane root so OAuth targets it", () => { - applyControlPlaneOverride({ "control-plane-url": "https://app.altertable.test" }); - expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.test/oauth"); - }); - - test("no-op without the flag (keeps the default)", () => { - applyControlPlaneOverride({}); - expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.ai/oauth"); - }); - - test("rejects non-localhost http without --allow-insecure-http", () => { - expect(() => - applyControlPlaneOverride({ "control-plane-url": "http://app.altertable.test" }), - ).toThrow(); - }); - - test("allows non-localhost http with --allow-insecure-http", () => { - applyControlPlaneOverride({ - "control-plane-url": "http://app.altertable.test", - "allow-insecure-http": true, - }); - expect(resolveOAuthBase(profileName)).toBe("http://app.altertable.test/oauth"); - }); - - test("errors when ALTERTABLE_MANAGEMENT_API_BASE conflicts with the flag", () => { - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://other.test"; - expect(() => - applyControlPlaneOverride({ "control-plane-url": "https://app.altertable.test" }), - ).toThrow(ConfigurationError); - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - }); -}); - -describe("login interactivity guard", () => { - test("refuses --json / --agent mode", () => { - setCliContext({ debug: false, json: true, agent: false }); - expect(() => assertInteractiveLogin()).toThrow(ConfigurationError); - setCliContext({ debug: false, json: false, agent: false }); - }); - - test("refuses when stdin is not a TTY", () => { - // bun:test runs with a non-TTY stdin, so the guard throws here too. - setCliContext({ debug: false, json: false, agent: false }); - expect(() => assertInteractiveLogin()).toThrow(ConfigurationError); - }); -}); - -describe("clear removes OAuth credentials", () => { - test("configureRunClear wipes stored OAuth tokens", () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); - expect(getStoredAccessToken(profileName)).toBe("acc"); - configureRunClear(getOutputSink()); - expect(getStoredAccessToken(profileName)).toBe(""); - }); -}); diff --git a/cli/tests/operation-catalog.test.ts b/cli/tests/operation-catalog.test.ts deleted file mode 100644 index 8b936e1..0000000 --- a/cli/tests/operation-catalog.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildMainCommand } from "@/cli.ts"; -import { listOperations } from "@/lib/operation-catalog.ts"; - -describe("operation catalog", () => { - test("records command capabilities as platform data", () => { - buildMainCommand(); - - const operations = listOperations(); - const byId = new Map(operations.map((operation) => [operation.id, operation])); - - expect(byId.get("api.get")?.capabilities).toEqual(["management-http"]); - expect(byId.get("api.get")?.effects).toEqual(["http"]); - expect(byId.get("api.get")?.planes).toEqual(["management"]); - expect(byId.get("api.get")?.output).toBe("tabular"); - expect(byId.get("lakehouse.query.run")?.capabilities).toEqual(["lakehouse-http", "streaming"]); - expect(byId.get("lakehouse.query.run")?.effects).toEqual(["http", "http-stream"]); - expect(byId.get("lakehouse.query.run")?.planes).toEqual(["lakehouse"]); - expect(byId.get("lakehouse.upload")?.capabilities).toEqual([ - "lakehouse-http", - "local-file-read", - "progress", - ]); - expect(byId.get("lakehouse.upload")?.effects).toEqual(["scope", "http"]); - expect(byId.get("lakehouse.upload")?.mutates).toBe(true); - expect(byId.get("lakehouse.upsert")?.capabilities).toEqual([ - "lakehouse-http", - "local-file-read", - "progress", - ]); - expect(byId.get("lakehouse.upsert")?.effects).toEqual(["scope", "http"]); - expect(byId.get("lakehouse.upsert")?.mutates).toBe(true); - expect(byId.get("catalogs.list")?.effects).toEqual(["local", "http"]); - expect(byId.get("catalogs.list")?.planes).toEqual(["management"]); - expect(byId.get("completion.install")?.capabilities).toEqual(["local-file-write"]); - expect(byId.get("profile")?.effects).toEqual(["local"]); - expect(byId.get("profile")?.mutates).toBe(true); - }); -}); diff --git a/cli/tests/operation-command.test.ts b/cli/tests/operation-command.test.ts deleted file mode 100644 index 59cb3fe..0000000 --- a/cli/tests/operation-command.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { setCliContext } from "@/context.ts"; -import { defineOutputCommand } from "@/lib/operation-command-builders.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; - -let testHome = ""; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-operation-command-test-")); - process.env.ALTERTABLE_CONFIG_HOME = testHome; - setCliContext({ debug: false, json: false, agent: false }); - refreshCliRuntimeContext(getCliRuntime().context); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; -}); - -describe("operation commands", () => { - test("output commands render without resolving execution context", async () => { - const runtime = getCliRuntime(); - const output: string[] = []; - runtime.output.writeHuman = (text) => { - output.push(text); - }; - - const command = defineOutputCommand({ - id: "test.output", - output: "human", - meta: { name: "test-output" }, - render() { - return { kind: "human", text: "rendered" }; - }, - }); - - await command.run?.({ args: {}, rawArgs: ["test-output"] } as never); - - expect(output).toEqual(["rendered"]); - expect(existsSync(join(testHome, "config"))).toBe(false); - expect(existsSync(join(testHome, "profiles"))).toBe(false); - }); -}); diff --git a/cli/tests/operation-effect.test.ts b/cli/tests/operation-effect.test.ts deleted file mode 100644 index 68b2bce..0000000 --- a/cli/tests/operation-effect.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -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 { setCliContext } from "@/context.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; -import { - allEffects, - httpEffect, - httpStreamEffect, - isOperationPlan, - localEffect, - operationPlan, - outputEffect, - progressEffect, - runOperationEffect, - runOperationPlan, - scopedEffect, - valueEffect, -} from "@/lib/operation-effect.ts"; -import { defineHttpOperation, httpOperationEffect } from "@/lib/http-operation.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; -import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; - -let testHome = ""; -let mockFile = ""; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-operation-effect-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"; - setCliContext({ debug: false, json: false, agent: false }); - refreshCliRuntimeContext(getCliRuntime().context); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_MOCK_HTTP_FILE; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_API_KEY; -}); - -function createOperationContext(): OperationContext { - const runtime = getCliRuntime(); - return { - args: {}, - rawArgs: [], - runtime, - sink: runtime.output, - execution: createExecutionContext(runtime), - }; -} - -describe("operation effects", () => { - test("runs operation plans and identifies plan data", async () => { - const plan = operationPlan(valueEffect("planned")); - expect(isOperationPlan(plan)).toBe(true); - const result = await runOperationPlan(plan, createOperationContext()); - expect(result).toBe("planned"); - }); - - test("runs output effects through the output sink", async () => { - const runtime = getCliRuntime(); - const output: string[] = []; - runtime.output.writeHuman = (text) => { - output.push(text); - }; - - await runOperationEffect( - outputEffect({ kind: "human", text: "hello" }), - createOperationContext(), - ); - - expect(output).toEqual(["hello"]); - }); - - test("runs local effects inside the operation interpreter", async () => { - const result = await runOperationEffect( - localEffect(() => ({ saved: true })), - createOperationContext(), - ); - - expect(result).toEqual({ saved: true }); - }); - - test("returns value effects without transport", async () => { - const result = await runOperationEffect(valueEffect("done"), createOperationContext()); - expect(result).toBe("done"); - }); - - test("runs HTTP effects through operation transport", async () => { - writeFileSync( - mockFile, - JSON.stringify([{ urlPattern: "/whoami", method: "GET", body: '{"ok":true}' }]), - ); - - const result = await runOperationEffect( - httpEffect( - { - plane: "management", - method: "GET", - endpoint: "/whoami", - }, - (body) => JSON.parse(body) as { ok: boolean }, - ), - createOperationContext(), - ); - - expect(result).toEqual({ ok: true }); - }); - - test("runs HTTP operation descriptors as composable effects", async () => { - writeFileSync( - mockFile, - JSON.stringify([ - { urlPattern: "/one", method: "GET", body: '{"value":1}' }, - { urlPattern: "/two", method: "GET", body: '{"value":2}' }, - ]), - ); - - const readValueOperation = defineHttpOperation({ - id: "test.read-value", - request: (endpoint) => ({ - plane: "management", - method: "GET", - endpoint, - }), - decode: (body) => (JSON.parse(body) as { value: number }).value, - }); - - const context = createOperationContext(); - const result = await runOperationEffect( - allEffects( - [ - httpOperationEffect(readValueOperation, "/one", context), - httpOperationEffect(readValueOperation, "/two", context), - ], - (values) => values.reduce((sum, value) => sum + Number(value), 0), - ), - context, - ); - - expect(result).toBe(3); - }); - - test("runs stream effects and decodes the stream", async () => { - writeFileSync( - mockFile, - JSON.stringify([{ urlPattern: "/events", method: "GET", chunked: true, body: "a\nb\n" }]), - ); - - const result = await runOperationEffect( - httpStreamEffect( - { - plane: "management", - method: "GET", - endpoint: "/events", - }, - async (stream) => { - const text = await new Response(stream).text(); - return text.trim().split("\n"); - }, - ), - createOperationContext(), - ); - - expect(result).toEqual(["a", "b"]); - }); - - test("combines parallel effects", async () => { - const result = await runOperationEffect( - allEffects([valueEffect(1), valueEffect(2)], (values) => - values.reduce((sum, value) => sum + Number(value), 0), - ), - createOperationContext(), - ); - - expect(result).toBe(3); - }); - - test("wraps nested effects with progress", async () => { - const result = await runOperationEffect( - progressEffect("Working", valueEffect("ok")), - createOperationContext(), - ); - expect(result).toBe("ok"); - }); - - test("releases scoped effects after success and failure", async () => { - const releases: string[] = []; - const result = await runOperationEffect( - scopedEffect(() => ({ - effect: valueEffect("ok"), - release: () => { - releases.push("success"); - }, - })), - createOperationContext(), - ); - expect(result).toBe("ok"); - - writeFileSync(mockFile, "[]", "utf8"); - try { - await runOperationEffect( - scopedEffect(() => ({ - effect: httpEffect({ - plane: "management", - method: "GET", - endpoint: "/missing", - }), - release: () => { - releases.push("failure"); - }, - })), - createOperationContext(), - ); - throw new Error("expected scoped effect failure"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("No mock HTTP response"); - } - - expect(releases).toEqual(["success", "failure"]); - }); -}); diff --git a/cli/tests/profile.test.ts b/cli/tests/profile.test.ts deleted file mode 100644 index e270251..0000000 --- a/cli/tests/profile.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { configGet, configSet, configSetGlobal, kvGet } from "@/lib/config.ts"; -import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { - resetSecretWarningsForTests, - secretGet, - secretSet, - setSpawnSyncForTests, -} from "@/lib/secrets.ts"; -import { - DEFAULT_PROFILE_NAME, - createEmptyProfile, - deleteProfile, - deriveProfileName, - ensureProfilesLayout, - getActiveProfileName, - inspectProfile, - listProfiles, - profileConfigFile, - profileExists, - renameProfile, - resolveWorkingProfile, - setActiveProfile, - updateProfile, -} from "@/features/profile/model.ts"; -import { promptProfileSwitch } from "@/commands/profile.ts"; -import { ConfigurationError, CliError } from "@/lib/errors.ts"; -import { - buildProfileDirenvView, - buildProfileInspectView, - buildProfileListView, - buildProfileShellExportView, - profileStatusToJson, - type ProfileStatusResult, -} from "@/features/profile/views.ts"; -import { formatProfileInspect, formatProfileStatus } from "@/features/profile/render.ts"; -import { runProfileConfigure } from "@/lib/profile-configure.ts"; -import { setCliContext, getCliContext } from "@/context.ts"; -import { createCliTestRuntime, runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; -import { renderShellExportView } from "@/ui/shell/render.ts"; - -const profileName = "default"; - -let testHome = ""; - -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-profile-test-")); - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - resetSecretWarningsForTests(); - setCliContext({ debug: false, json: false, agent: false }); -}); - -afterEach(() => { - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; - delete process.env.ALTERTABLE_SECRET_BACKEND; - delete process.env.ALTERTABLE_PROFILE; -}); - -describe("profiles layout", () => { - test("creates profiles/default and sets active_profile on first access", () => { - ensureProfilesLayout(); - - expect(existsSync(profileConfigFile(DEFAULT_PROFILE_NAME))).toBe(false); - expect(existsSync(join(testHome, "profiles", "default"))).toBe(true); - expect(kvGet(join(testHome, "config"), "active_profile")).toBe(DEFAULT_PROFILE_NAME); - }); -}); - -describe("resolveWorkingProfile", () => { - test("prefers CLI context profile over active profile", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - - setActiveProfile("default"); - setCliContext({ debug: false, json: false, agent: false, profile: "staging" }); - expect(resolveWorkingProfile(getCliContext().profile)).toBe("staging"); - }); - - test("prefers ALTERTABLE_PROFILE over active profile", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - setActiveProfile("default"); - process.env.ALTERTABLE_PROFILE = "staging"; - expect(resolveWorkingProfile()).toBe("staging"); - }); - - test("returns active profile by default", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - expect(resolveWorkingProfile()).toBe(DEFAULT_PROFILE_NAME); - expect(getActiveProfileName()).toBe(DEFAULT_PROFILE_NAME); - }); -}); - -describe("profile storage", () => { - test("derives safe org_env profile names", () => { - expect(deriveProfileName("Acme Inc.", "Production")).toBe("acme-inc_production"); - expect(deriveProfileName("acme", "prod/eu")).toBe("acme_prod-eu"); - }); - - test("profile --configure writes credentials to an explicit profile", async () => { - await configureRunSet({ profile: "acme_production", apiKey: "atm_prod", env: "Production" }); - - expect(profileExists("acme_production")).toBe(true); - setCliContext({ debug: false, json: false, agent: false, profile: "acme_production" }); - expect(configGet("api_key_env", "acme_production")).toBe("Production"); - expect(secretGet("api-key", "acme_production")).toBe("atm_prod"); - expect( - listProfiles().find((profile) => profile.name === "acme_production")?.management_auth, - ).toBe("api-key"); - }); - - test("createEmptyProfile and updateProfile manage metadata without credentials", () => { - createEmptyProfile("acme_prod"); - const created = updateProfile("acme_prod", { - organizationSlug: "acme", - organizationName: "Acme Inc", - environment: "production", - dataPlane: "https://api.example.com", - controlPlane: "https://app.example.com", - }); - - expect(created.name).toBe("acme_prod"); - expect(created.status).toBe("partial"); - expect(created.organization.slug).toBe("acme"); - expect(created.environment).toBe("production"); - - expect(inspectProfile("acme_prod").endpoints.data_plane).toBe("https://api.example.com"); - }); - - test("profile create configures and switches to the created profile", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - - await runCommandWithTestRuntime([ - "profile", - "create", - "acme_stage", - "--api-key", - "atm_stage", - "--env", - "staging", - ]); - - expect(getActiveProfileName()).toBe("acme_stage"); - expect(secretGet("api-key", "acme_stage")).toBe("atm_stage"); - }); - - test("inspectProfile reports profile-scoped auth status", async () => { - await configureRunSet({ profile: "acme_prod", apiKey: "atm_prod", env: "production" }); - await configureRunSet({ profile: "acme_prod", user: "alice", password: "secret" }); - const lakehouseExpiry = Date.now() + 60 * 60 * 1000; - setCliContext({ debug: false, json: false, agent: false, profile: "acme_prod" }); - configSet("lakehouse_credential_expiry", String(lakehouseExpiry), "acme_prod"); - - const profile = inspectProfile("acme_prod"); - expect(profile.status).toBe("configured"); - expect(profile.auth.management).toBe("api_key"); - expect(profile.auth.lakehouse).toBe("username_password"); - expect(profile.timestamps.created_at).toBeTruthy(); - expect(profile.timestamps.lakehouse_expires_at).toBe(new Date(lakehouseExpiry).toISOString()); - }); - - test("profile --configure creates implicit profile directories", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); - expect(profileExists("staging")).toBe(true); - setCliContext({ debug: false, json: false, agent: false, profile: "staging" }); - expect(configGet("api_key_env", "staging")).toBe("staging"); - expect(secretGet("api-key", "staging")).toBe("atm_staging"); - }); - - test("listProfiles marks the active profile", 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); - }); - - test("profile list view describes table columns declaratively", async () => { - await configureRunSet({ profile: "acme_prod", apiKey: "atm_prod", env: "prod" }); - const view = buildProfileListView(listProfiles()); - const [section] = view.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.table.emptyMessage).toBe("No profiles configured."); - } - }); - - test("profile inspect view exposes display rows", () => { - createEmptyProfile("acme_prod"); - updateProfile("acme_prod", { - organizationSlug: "acme", - environment: "production", - dataPlane: "https://api.example.com", - }); - - const view = buildProfileInspectView(inspectProfile("acme_prod")); - const [section] = view.sections; - const [block] = section?.blocks ?? []; - - expect(block?.kind).toBe("rows"); - if (block?.kind === "rows") { - expect(block.rows).toEqual( - expect.arrayContaining([ - { label: "Profile", value: "acme_prod" }, - { label: "Organization", value: "acme" }, - { - label: "Data plane", - value: [ - { - text: "https://api.example.com", - style: "accent", - href: "https://api.example.com", - }, - ], - }, - ]), - ); - } - }); - - test("profile shell views share the same export data", () => { - expect(buildProfileShellExportView("acme_prod")).toEqual({ - env: { ALTERTABLE_PROFILE: "acme_prod" }, - }); - expect(buildProfileDirenvView("acme_prod")).toEqual({ - env: { ALTERTABLE_PROFILE: "acme_prod" }, - comments: ["Generated by: altertable profile direnv"], - }); - expect(renderShellExportView(buildProfileShellExportView("acme_prod"))).toBe( - 'export ALTERTABLE_PROFILE="acme_prod"', - ); - expect(renderShellExportView(buildProfileDirenvView("acme_prod"))).toBe( - '# Generated by: altertable profile direnv\nexport ALTERTABLE_PROFILE="acme_prod"', - ); - }); - - test("global query defaults stay in root config across profiles", async () => { - configSetGlobal("query_layout", "line"); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); - expect(kvGet(profileConfigFile("staging"), "query_layout")).toBe(""); - }); - - test("deleteProfile refuses to delete the active 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"); - }); - - test("deleteProfile allows deleting the last inactive profile", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - setActiveProfile("staging"); - - deleteProfile("default"); - - expect(profileExists("default")).toBe(false); - expect(profileExists("staging")).toBe(true); - expect(() => deleteProfile("staging")).toThrow("Cannot delete the active profile"); - }); - - test("deleteProfile removes secrets via secretDelete including keychain", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - setActiveProfile("default"); - - const spawnCalls: string[][] = []; - setSpawnSyncForTests((_command, args) => { - spawnCalls.push([...args]); - return { - status: 0, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - }); - - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: "darwin" }); - - try { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; - secretSet("api-key", "atm_b", "staging"); - deleteProfile("staging"); - const keychainDeletes = spawnCalls.filter((args) => args.includes("delete-generic-password")); - expect(keychainDeletes.length).toBeGreaterThan(0); - expect(profileExists("staging")).toBe(false); - } finally { - if (platformDescriptor) { - Object.defineProperty(process, "platform", platformDescriptor); - } - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - setSpawnSyncForTests(undefined); - } - }); - - test("renameProfile moves profile config, active profile, and secrets", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); - setActiveProfile("staging"); - - renameProfile("staging", "acme_staging"); - - expect(profileExists("staging")).toBe(false); - expect(profileExists("acme_staging")).toBe(true); - expect(getActiveProfileName()).toBe("acme_staging"); - expect(secretGet("api-key", "acme_staging")).toBe("atm_staging"); - expect(secretGet("api-key", "staging")).toBe(""); - }); - - test("renameProfile restores the profile directory if keychain secret migration fails", () => { - createEmptyProfile("staging"); - const spawnCalls: string[][] = []; - setSpawnSyncForTests((_command, args) => { - spawnCalls.push([...args]); - const account = args[args.indexOf("-a") + 1] ?? ""; - if (args.includes("help")) { - return { - status: 0, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - } - if (args.includes("find-generic-password")) { - if (account === "profile/staging/api-key") { - return { - status: 0, - stdout: Buffer.from("atm_staging"), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from("atm_staging"), Buffer.from("")], - }; - } - if (account === "profile/staging/lakehouse/password") { - return { - status: 0, - stdout: Buffer.from("secret"), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from("secret"), Buffer.from("")], - }; - } - return { - status: 1, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - } - if ( - args.includes("add-generic-password") && - account === "profile/acme_staging/lakehouse/password" - ) { - return { - status: 1, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - } - return { - status: 0, - stdout: Buffer.from(""), - stderr: Buffer.from(""), - pid: 0, - signal: null, - output: [null, Buffer.from(""), Buffer.from("")], - }; - }); - - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: "darwin" }); - - try { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; - expect(() => renameProfile("staging", "acme_staging")).toThrow( - "Failed to store secret in macOS keychain", - ); - expect(profileExists("staging")).toBe(true); - expect(profileExists("acme_staging")).toBe(false); - expect( - spawnCalls.some( - (args) => - args.includes("delete-generic-password") && - args.includes("profile/acme_staging/api-key"), - ), - ).toBe(true); - } finally { - if (platformDescriptor) { - Object.defineProperty(process, "platform", platformDescriptor); - } - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - setSpawnSyncForTests(undefined); - } - }); - - test("rejects path traversal profile names", () => { - expect(() => resolveWorkingProfile("../../outside")).toThrow(ConfigurationError); - expect(() => setActiveProfile("..")).toThrow(ConfigurationError); - expect(() => deleteProfile("foo/bar")).toThrow(ConfigurationError); - }); - - test("allows valid profile names", async () => { - await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); - await configureRunSet({ profile: "prod-eu", apiKey: "atm_prod", env: "prod" }); - expect(profileExists("staging")).toBe(true); - expect(profileExists("prod-eu")).toBe(true); - }); - - test("promptProfileSwitch selects from configured profiles", async () => { - await configureRunSet({ apiKey: "atm_a", env: "prod" }); - await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); - - const selected = await promptProfileSwitch({ - writePrompt() {}, - readLine: async () => "", - readPassword: async () => "", - readConfirm: async () => true, - readSelect: async (title, options, defaultValue) => { - expect(title).toBe("Switch profile"); - expect(defaultValue).toBe("default"); - expect(options.map((option) => option.value)).toContain("staging"); - expect(options.find((option) => option.value === "staging")?.label).toContain( - "env: staging", - ); - return "staging"; - }, - }); - - expect(selected).toBe("staging"); - }); -}); - -describe("profile --configure dispatch", () => { - test("flag-based configure writes credentials to the active profile", async () => { - const sink = createCliTestRuntime({ debug: false, json: false, agent: false }).output; - - await runProfileConfigure({ "api-key": "atm_flagkey", env: "staging" }, sink); - - expect(secretGet("api-key", profileName)).toBe("atm_flagkey"); - expect(configGet("api_key_env", profileName)).toBe("staging"); - }); - - test("rejects --scope combined with credential flags", async () => { - const sink = createCliTestRuntime({ debug: false, json: false, agent: false }).output; - - let caught: unknown; - try { - await runProfileConfigure({ "api-key": "atm_x", env: "prod", scope: "management" }, sink); - } catch (error) { - caught = error; - } - - expect(caught).toBeInstanceOf(CliError); - expect(secretGet("api-key", profileName)).toBe(""); - }); -}); - -describe("profile show view", () => { - test("renders the stored profile's auth, environment, and config file", async () => { - await configureRunSet({ apiKey: "atm_show", env: "production" }); - - const output = formatProfileInspect(inspectProfile(getActiveProfileName())); - - 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).not.toContain("atm_show"); - }); - - test("marks an unconfigured profile as empty with no auth", () => { - const output = formatProfileInspect(inspectProfile(getActiveProfileName())); - - expect(output).toContain("Status"); - expect(output).toContain("empty"); - expect(output).toContain("none"); - }); -}); - -describe("profile status view", () => { - function statusResult(verification: ProfileStatusResult["verification"]): ProfileStatusResult { - return { profile: inspectProfile(getActiveProfileName()), verification }; - } - - test("renders the inspect view plus a verification section", async () => { - await configureRunSet({ apiKey: "atm_status", env: "production" }); - - const output = formatProfileStatus( - statusResult({ - profile: getActiveProfileName(), - 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"); - }); - - test("json envelope includes the profile and verification result", async () => { - await configureRunSet({ apiKey: "atm_status", env: "production" }); - - const json = profileStatusToJson( - statusResult({ - profile: getActiveProfileName(), - configured: ["management"], - verified: { management: true, lakehouse: false }, - errors: [], - }), - ); - - expect((json.verification as { configured: string[] }).configured).toEqual(["management"]); - expect((json.profile as { name: string }).name).toBe(getActiveProfileName()); - }); -}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 360371a..15a2ab5 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,8 +1,7 @@ { "compilerOptions": { "paths": { - "@/*": ["./src/*"], - "@tests/*": ["./tests/*"] + "@/*": ["./src/*"] }, "lib": ["ESNext"], "target": "ESNext", @@ -18,5 +17,5 @@ "noUncheckedIndexedAccess": true, "types": ["bun"] }, - "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "scripts/**/*.ts"] } diff --git a/scripts/verify.sh b/scripts/verify.sh index b26b1af..747a9cb 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -54,6 +54,7 @@ run_step "generate (openapi)" bun run generate run_step "openapi drift check" git diff --exit-code src/generated/openapi-types.ts src/generated/openapi-operations.ts run_step "unit tests with coverage" bun run test:coverage 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