From a4a212f9e1ea9c46dc3b0644b1dd6ed6e610fea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 11:46:39 +0200 Subject: [PATCH 01/22] refactor(cli): isolate product command registration Move command assembly and argument normalization out of the bootstrap so command-surface changes have a single owner. Prepare Knip to recognize colocated unit tests under src. --- cli/knip.json | 2 +- cli/src/cli.ts | 36 +++--------------------------------- cli/src/commands/index.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 34 deletions(-) create mode 100644 cli/src/commands/index.ts diff --git a/cli/knip.json b/cli/knip.json index 82713e8..a82d2be 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["tests/**/*.test.ts", "scripts/**/*.ts"], + "entry": ["src/**/*.test.ts", "tests/**/*.test.ts", "scripts/**/*.ts"], "project": ["src/**/*.ts", "tests/**/*.test.ts", "scripts/**/*.ts"], "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, diff --git a/cli/src/cli.ts b/cli/src/cli.ts index aff886c..f010587 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -10,18 +10,7 @@ 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, @@ -86,23 +75,7 @@ export function resolveTopLevelCommandName(rawArgs: readonly string[]): string | 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, - }; + const topLevelCommands = buildTopLevelCommands(() => mainCommand); mainCommand = defineRootCommand({ meta: { @@ -153,10 +126,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); diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts new file mode 100644 index 0000000..ad91af1 --- /dev/null +++ b/cli/src/commands/index.ts @@ -0,0 +1,37 @@ +import type { ArgsDef, CommandDef } from "citty"; +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"; + +export function buildTopLevelCommands( + getMainCommand: () => CommandDef, +): 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: ArgsDef): string[] { + return normalizeQueryInvocatorRawArgs(normalizeApiInvocatorRawArgs(rawArgs, rootArgs), rootArgs); +} From 7497abcfcaf51020dd5e481e52f5db4a1d403b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 11:48:26 +0200 Subject: [PATCH 02/22] refactor(cli): give every command an owned directory Make the command surface visible from the filesystem by moving top-level commands into named directories. Move shared lakehouse argument parsing out of the command tree so non-command modules cannot be mistaken for commands. --- cli/src/commands/{api.ts => api/index.ts} | 0 .../{lakehouse/append.ts => append/index.ts} | 2 +- .../{catalogs.ts => catalogs/index.ts} | 0 .../{completion.ts => completion/index.ts} | 0 .../commands/{duckdb.ts => duckdb/index.ts} | 0 cli/src/commands/index.ts | 25 ++++++++++--------- cli/src/commands/{login.ts => login/index.ts} | 0 cli/src/commands/logout/index.ts | 1 + .../commands/{profile.ts => profile/index.ts} | 0 .../{lakehouse/query.ts => query/index.ts} | 2 +- .../{lakehouse/schema.ts => schema/index.ts} | 4 +-- .../commands/{update.ts => update/index.ts} | 0 .../{lakehouse/upload.ts => upload/index.ts} | 2 +- .../{lakehouse/upsert.ts => upsert/index.ts} | 4 +-- .../lakehouse/args.ts} | 0 cli/tests/api.test.ts | 2 +- cli/tests/commands-duckdb.test.ts | 2 +- cli/tests/commands-lakehouse.test.ts | 4 +-- cli/tests/completion.test.ts | 2 +- cli/tests/lakehouse.test.ts | 2 +- cli/tests/oauth-refresh.test.ts | 2 +- cli/tests/oauth.test.ts | 2 +- cli/tests/profile.test.ts | 2 +- 23 files changed, 30 insertions(+), 28 deletions(-) rename cli/src/commands/{api.ts => api/index.ts} (100%) rename cli/src/commands/{lakehouse/append.ts => append/index.ts} (97%) rename cli/src/commands/{catalogs.ts => catalogs/index.ts} (100%) rename cli/src/commands/{completion.ts => completion/index.ts} (100%) rename cli/src/commands/{duckdb.ts => duckdb/index.ts} (100%) rename cli/src/commands/{login.ts => login/index.ts} (100%) create mode 100644 cli/src/commands/logout/index.ts rename cli/src/commands/{profile.ts => profile/index.ts} (100%) rename cli/src/commands/{lakehouse/query.ts => query/index.ts} (99%) rename cli/src/commands/{lakehouse/schema.ts => schema/index.ts} (98%) rename cli/src/commands/{update.ts => update/index.ts} (100%) rename cli/src/commands/{lakehouse/upload.ts => upload/index.ts} (98%) rename cli/src/commands/{lakehouse/upsert.ts => upsert/index.ts} (97%) rename cli/src/{commands/lakehouse-args.ts => lib/lakehouse/args.ts} (100%) diff --git a/cli/src/commands/api.ts b/cli/src/commands/api/index.ts similarity index 100% rename from cli/src/commands/api.ts rename to cli/src/commands/api/index.ts diff --git a/cli/src/commands/lakehouse/append.ts b/cli/src/commands/append/index.ts similarity index 97% rename from cli/src/commands/lakehouse/append.ts rename to cli/src/commands/append/index.ts index b7f46d8..666a6dc 100644 --- a/cli/src/commands/lakehouse/append.ts +++ b/cli/src/commands/append/index.ts @@ -3,7 +3,7 @@ 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 { parseAppendJsonContent } from "@/lib/lakehouse/args.ts"; import { lakehouseAppendOperation, lakehouseAppendTaskOperation, diff --git a/cli/src/commands/catalogs.ts b/cli/src/commands/catalogs/index.ts similarity index 100% rename from cli/src/commands/catalogs.ts rename to cli/src/commands/catalogs/index.ts diff --git a/cli/src/commands/completion.ts b/cli/src/commands/completion/index.ts similarity index 100% rename from cli/src/commands/completion.ts rename to cli/src/commands/completion/index.ts diff --git a/cli/src/commands/duckdb.ts b/cli/src/commands/duckdb/index.ts similarity index 100% rename from cli/src/commands/duckdb.ts rename to cli/src/commands/duckdb/index.ts diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index ad91af1..9cf2367 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,16 +1,17 @@ import type { ArgsDef, CommandDef } from "citty"; -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 { 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: () => CommandDef, diff --git a/cli/src/commands/login.ts b/cli/src/commands/login/index.ts similarity index 100% rename from cli/src/commands/login.ts rename to cli/src/commands/login/index.ts diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts new file mode 100644 index 0000000..f3964d3 --- /dev/null +++ b/cli/src/commands/logout/index.ts @@ -0,0 +1 @@ +export { logoutCommand } from "@/commands/login/index.ts"; diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile/index.ts similarity index 100% rename from cli/src/commands/profile.ts rename to cli/src/commands/profile/index.ts diff --git a/cli/src/commands/lakehouse/query.ts b/cli/src/commands/query/index.ts similarity index 99% rename from cli/src/commands/lakehouse/query.ts rename to cli/src/commands/query/index.ts index ecdc03c..200580e 100644 --- a/cli/src/commands/lakehouse/query.ts +++ b/cli/src/commands/query/index.ts @@ -9,7 +9,7 @@ import { parseQueryOutputOptions, parseRequestReadTimeoutMs, QUERY_RESULT_FORMAT_OPTIONS, -} from "@/commands/lakehouse-args.ts"; +} from "@/lib/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"; diff --git a/cli/src/commands/lakehouse/schema.ts b/cli/src/commands/schema/index.ts similarity index 98% rename from cli/src/commands/lakehouse/schema.ts rename to cli/src/commands/schema/index.ts index fb8865d..1708e02 100644 --- a/cli/src/commands/lakehouse/schema.ts +++ b/cli/src/commands/schema/index.ts @@ -1,13 +1,13 @@ 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 { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; import { planQueryRun, presentQueryRun, queryRunArgs, type QueryRunInput, -} from "@/commands/lakehouse/query.ts"; +} from "@/commands/query/index.ts"; import { writePagedOutput } from "@/lib/pager.ts"; import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; diff --git a/cli/src/commands/update.ts b/cli/src/commands/update/index.ts similarity index 100% rename from cli/src/commands/update.ts rename to cli/src/commands/update/index.ts diff --git a/cli/src/commands/lakehouse/upload.ts b/cli/src/commands/upload/index.ts similarity index 98% rename from cli/src/commands/lakehouse/upload.ts rename to cli/src/commands/upload/index.ts index dcbe0a2..b07c461 100644 --- a/cli/src/commands/lakehouse/upload.ts +++ b/cli/src/commands/upload/index.ts @@ -6,7 +6,7 @@ import { defineOperationCommand } from "@/lib/operation-command.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs, -} from "@/commands/lakehouse-args.ts"; +} from "@/lib/lakehouse/args.ts"; import { createLakehouseUploadRequest } from "@/lib/lakehouse-transport.ts"; export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; diff --git a/cli/src/commands/lakehouse/upsert.ts b/cli/src/commands/upsert/index.ts similarity index 97% rename from cli/src/commands/lakehouse/upsert.ts rename to cli/src/commands/upsert/index.ts index eb57eee..eda9328 100644 --- a/cli/src/commands/lakehouse/upsert.ts +++ b/cli/src/commands/upsert/index.ts @@ -4,11 +4,11 @@ import { defineOperationCommand } from "@/lib/operation-command.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs, -} from "@/commands/lakehouse-args.ts"; +} from "@/lib/lakehouse/args.ts"; import { getUploadFileSizeBytes, LAKEHOUSE_FILE_FORMAT_OPTIONS, -} from "@/commands/lakehouse/upload.ts"; +} from "@/commands/upload/index.ts"; import { createLakehouseUpsertRequest } from "@/lib/lakehouse-transport.ts"; export const upsertCommand = defineOperationCommand({ diff --git a/cli/src/commands/lakehouse-args.ts b/cli/src/lib/lakehouse/args.ts similarity index 100% rename from cli/src/commands/lakehouse-args.ts rename to cli/src/lib/lakehouse/args.ts diff --git a/cli/tests/api.test.ts b/cli/tests/api.test.ts index a1ba5ee..5446203 100644 --- a/cli/tests/api.test.ts +++ b/cli/tests/api.test.ts @@ -10,7 +10,7 @@ import { normalizeApiInvocatorRawArgs, runApiRoutesCommand, runApiSpecCommand, -} from "@/commands/api.ts"; +} 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"; diff --git a/cli/tests/commands-duckdb.test.ts b/cli/tests/commands-duckdb.test.ts index bd9c4e3..443147c 100644 --- a/cli/tests/commands-duckdb.test.ts +++ b/cli/tests/commands-duckdb.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb.ts"; +import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb/index.ts"; import type { CatalogRow } from "@/features/management/model.ts"; function catalogRow(catalog: string): CatalogRow { diff --git a/cli/tests/commands-lakehouse.test.ts b/cli/tests/commands-lakehouse.test.ts index 0bb7de1..b7cfcd4 100644 --- a/cli/tests/commands-lakehouse.test.ts +++ b/cli/tests/commands-lakehouse.test.ts @@ -8,9 +8,9 @@ import { parseQueryLayout, parseQueryResultFormatArg, parseLakehouseFileContentType, -} from "@/commands/lakehouse-args.ts"; +} from "@/lib/lakehouse/args.ts"; import { parseQueryResultFormat } from "@/lib/lakehouse-client.ts"; -import { buildSchemaStatement, schemaCommand } from "@/commands/lakehouse/schema.ts"; +import { buildSchemaStatement, schemaCommand } from "@/commands/schema/index.ts"; import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; import { setCliContext } from "@/context.ts"; import { diff --git a/cli/tests/completion.test.ts b/cli/tests/completion.test.ts index ce544ea..1fe9660 100644 --- a/cli/tests/completion.test.ts +++ b/cli/tests/completion.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CommandDef } from "citty"; import { buildMainCommand } from "@/cli.ts"; -import { createCompletionCommand } from "@/commands/completion.ts"; +import { createCompletionCommand } from "@/commands/completion/index.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"; diff --git a/cli/tests/lakehouse.test.ts b/cli/tests/lakehouse.test.ts index 3e94e0a..3adf17a 100644 --- a/cli/tests/lakehouse.test.ts +++ b/cli/tests/lakehouse.test.ts @@ -22,7 +22,7 @@ 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"; +import { normalizeQueryInvocatorRawArgs } from "@/commands/query/index.ts"; const SAMPLE_NDJSON = [ '{"statement":"SELECT 1","session_id":"abc","query_id":"def"}', diff --git a/cli/tests/oauth-refresh.test.ts b/cli/tests/oauth-refresh.test.ts index ad03d4e..60c846c 100644 --- a/cli/tests/oauth-refresh.test.ts +++ b/cli/tests/oauth-refresh.test.ts @@ -15,7 +15,7 @@ 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 { fetchLoginWhoami, storeLoginProfileMetadata } from "@/commands/login/index.ts"; const profileName = "default"; let testHome = ""; diff --git a/cli/tests/oauth.test.ts b/cli/tests/oauth.test.ts index 55ca7d2..0589f76 100644 --- a/cli/tests/oauth.test.ts +++ b/cli/tests/oauth.test.ts @@ -161,7 +161,7 @@ import { applyControlPlaneOverride, sameWhoamiContext, storeLoginProfileMetadata, -} from "@/commands/login.ts"; +} from "@/commands/login/index.ts"; import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/features/profile/model.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; diff --git a/cli/tests/profile.test.ts b/cli/tests/profile.test.ts index e270251..75b57c3 100644 --- a/cli/tests/profile.test.ts +++ b/cli/tests/profile.test.ts @@ -26,7 +26,7 @@ import { setActiveProfile, updateProfile, } from "@/features/profile/model.ts"; -import { promptProfileSwitch } from "@/commands/profile.ts"; +import { promptProfileSwitch } from "@/commands/profile/index.ts"; import { ConfigurationError, CliError } from "@/lib/errors.ts"; import { buildProfileDirenvView, From 02a243c9a89e3035197f3d4ebbb3c1d96c7c2fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 12:00:21 +0200 Subject: [PATCH 03/22] refactor(cli): execute commands without operation framework Keep declarative HTTP request values as the future dry-run seam while removing the effect interpreter, operation catalog, and generic command builders. Leaf command modules now own their parsing, request execution, and presentation flow. --- cli/src/commands/api/delete.ts | 3 + cli/src/commands/api/get.ts | 3 + cli/src/commands/api/index.ts | 293 ++---------- cli/src/commands/api/lib/command.ts | 101 ++++ cli/src/commands/api/patch.ts | 3 + cli/src/commands/api/post.ts | 3 + cli/src/commands/api/put.ts | 3 + cli/src/commands/api/routes.ts | 42 ++ cli/src/commands/api/spec.ts | 46 ++ cli/src/commands/append/index.ts | 93 +--- cli/src/commands/append/lib/args.ts | 20 + cli/src/commands/append/run.ts | 35 ++ cli/src/commands/append/status.ts | 31 ++ cli/src/commands/catalogs/create.ts | 52 ++ cli/src/commands/catalogs/index.ts | 102 +--- cli/src/commands/catalogs/list.ts | 32 ++ cli/src/commands/completion/index.ts | 147 ++---- cli/src/commands/duckdb/index.ts | 28 +- cli/src/commands/login/index.ts | 27 +- cli/src/commands/logout/index.ts | 15 +- cli/src/commands/profile/create.ts | 43 ++ cli/src/commands/profile/current.ts | 14 + cli/src/commands/profile/delete.ts | 27 ++ cli/src/commands/profile/direnv.ts | 25 + cli/src/commands/profile/env.ts | 25 + cli/src/commands/profile/index.ts | 448 +----------------- cli/src/commands/profile/lib/profile.ts | 69 +++ cli/src/commands/profile/list.ts | 15 + cli/src/commands/profile/rename.ts | 26 + cli/src/commands/profile/show.ts | 17 + cli/src/commands/profile/status.ts | 41 ++ cli/src/commands/profile/switch.ts | 30 ++ cli/src/commands/profile/use.ts | 22 + cli/src/commands/query/cancel.ts | 26 + cli/src/commands/query/index.ts | 183 +------ cli/src/commands/query/run.ts | 38 ++ cli/src/commands/query/show.ts | 22 + cli/src/commands/schema/index.ts | 52 +- cli/src/commands/upload/index.ts | 53 +-- cli/src/commands/upsert/index.ts | 58 +-- cli/src/lib/api-http.ts | 44 +- cli/src/lib/{operation-codec.ts => args.ts} | 0 cli/src/lib/catalogs/requests.ts | 33 ++ cli/src/lib/command-context.ts | 15 +- cli/src/lib/http-operation.ts | 104 ---- ...operation-transport.ts => http-request.ts} | 21 +- cli/src/lib/lakehouse-operations.ts | 152 ------ cli/src/lib/lakehouse-transport.ts | 8 +- cli/src/lib/lakehouse/args.ts | 39 +- cli/src/lib/lakehouse/query.ts | 92 ++++ cli/src/lib/management-operations.ts | 74 --- cli/src/lib/management-transport.ts | 4 +- cli/src/lib/operation-catalog.ts | 35 -- cli/src/lib/operation-command-builders.ts | 137 ------ cli/src/lib/operation-command.ts | 111 ----- cli/src/lib/operation-effect.ts | 239 ---------- cli/src/lib/profile-status.ts | 49 +- cli/tests/api-http.test.ts | 20 +- cli/tests/lakehouse-provision.test.ts | 4 +- cli/tests/lakehouse.test.ts | 48 +- cli/tests/openapi-http-conformance.test.ts | 31 +- cli/tests/operation-catalog.test.ts | 39 -- cli/tests/operation-command.test.ts | 46 -- cli/tests/operation-effect.test.ts | 227 --------- 64 files changed, 1251 insertions(+), 2634 deletions(-) create mode 100644 cli/src/commands/api/delete.ts create mode 100644 cli/src/commands/api/get.ts create mode 100644 cli/src/commands/api/lib/command.ts create mode 100644 cli/src/commands/api/patch.ts create mode 100644 cli/src/commands/api/post.ts create mode 100644 cli/src/commands/api/put.ts create mode 100644 cli/src/commands/api/routes.ts create mode 100644 cli/src/commands/api/spec.ts create mode 100644 cli/src/commands/append/lib/args.ts create mode 100644 cli/src/commands/append/run.ts create mode 100644 cli/src/commands/append/status.ts create mode 100644 cli/src/commands/catalogs/create.ts create mode 100644 cli/src/commands/catalogs/list.ts create mode 100644 cli/src/commands/profile/create.ts create mode 100644 cli/src/commands/profile/current.ts create mode 100644 cli/src/commands/profile/delete.ts create mode 100644 cli/src/commands/profile/direnv.ts create mode 100644 cli/src/commands/profile/env.ts create mode 100644 cli/src/commands/profile/lib/profile.ts create mode 100644 cli/src/commands/profile/list.ts create mode 100644 cli/src/commands/profile/rename.ts create mode 100644 cli/src/commands/profile/show.ts create mode 100644 cli/src/commands/profile/status.ts create mode 100644 cli/src/commands/profile/switch.ts create mode 100644 cli/src/commands/profile/use.ts create mode 100644 cli/src/commands/query/cancel.ts create mode 100644 cli/src/commands/query/run.ts create mode 100644 cli/src/commands/query/show.ts rename cli/src/lib/{operation-codec.ts => args.ts} (100%) create mode 100644 cli/src/lib/catalogs/requests.ts delete mode 100644 cli/src/lib/http-operation.ts rename cli/src/lib/{operation-transport.ts => http-request.ts} (88%) delete mode 100644 cli/src/lib/lakehouse-operations.ts create mode 100644 cli/src/lib/lakehouse/query.ts delete mode 100644 cli/src/lib/management-operations.ts delete mode 100644 cli/src/lib/operation-catalog.ts delete mode 100644 cli/src/lib/operation-command-builders.ts delete mode 100644 cli/src/lib/operation-command.ts delete mode 100644 cli/src/lib/operation-effect.ts delete mode 100644 cli/tests/operation-catalog.test.ts delete mode 100644 cli/tests/operation-command.test.ts delete mode 100644 cli/tests/operation-effect.test.ts 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/src/commands/api/index.ts b/cli/src/commands/api/index.ts index 90f4e69..06eec49 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -1,77 +1,26 @@ 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 { defineCommand } from "@/lib/command-context.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; +import { executeApiHttp, apiHttpResultOutput } from "@/lib/api-http.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; + API_HTTP_BASE_ARGS, + isDelegatedApiCommand, + isApiCommandName, + resolveApiCommandRequest, +} from "@/commands/api/lib/command.ts"; +import { 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 { runApiSpecCommand } from "@/commands/api/spec.ts"; +export { runApiRoutesCommand } from "@/commands/api/routes.ts"; -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 = {}, @@ -84,178 +33,7 @@ export function normalizeApiInvocatorRawArgs( }); } -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", - }, +export const apiCommand = defineCommand({ meta: { name: "api", commandGroup: "platform", @@ -269,27 +47,18 @@ export const apiCommand = defineOperationCommand(); - } - return API_HTTP_OPERATION.plan(input.request as ResolvedApiHttp, context); + spec: apiSpecCommand, }, - present(result, { sink }) { - if (result === undefined) { - return; - } - return apiHttpResultOutput(result, sink); + 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); }, }); diff --git a/cli/src/commands/api/lib/command.ts b/cli/src/commands/api/lib/command.ts new file mode 100644 index 0000000..349d38d --- /dev/null +++ b/cli/src/commands/api/lib/command.ts @@ -0,0 +1,101 @@ +import type { ArgsDef } from "citty"; +import { extractFieldArgs, extractRawFieldArgs } from "@/lib/api-body.ts"; +import { executeApiHttp, apiHttpResultOutput, resolveApiHttp } from "@/lib/api-http.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { isDelegatedSubCommand, valueFlagsFor } from "@/lib/command-delegation.ts"; +import { withManagementFormatArg } from "@/lib/management-output.ts"; +import { readArgvFlagValue } from "@/lib/timeout-args.ts"; + +export const HTTP_METHOD_NAMES = ["GET", "POST", "PATCH", "DELETE", "PUT"] as const; +const API_COMMAND_NAMES = new Set(["spec", "routes", ...HTTP_METHOD_NAMES]); + +export 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; + +export const API_VALUE_FLAGS = valueFlagsFor(API_HTTP_BASE_ARGS); +export const API_HTTP_ARGS = withManagementFormatArg(API_HTTP_BASE_ARGS); + +export function isApiCommandName(value: string): boolean { + return API_COMMAND_NAMES.has(value) || API_COMMAND_NAMES.has(value.toUpperCase()); +} + +export function isDelegatedApiCommand(rawArgs: readonly string[]): boolean { + return isDelegatedSubCommand(rawArgs, isApiCommandName, { valueFlags: API_VALUE_FLAGS }); +} + +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/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..4ba2fe4 --- /dev/null +++ b/cli/src/commands/api/routes.ts @@ -0,0 +1,42 @@ +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/features/api/model.ts"; +import { formatApiOperationDetails, formatApiRoutes } from "@/features/api/render.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; + +export async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { + await writeCommandOutput( + operationId + ? { + kind: "normalized", + data: apiOperationDetails(operationId), + humanText: formatApiOperationDetails(apiOperationDetails(operationId)), + } + : { + kind: "normalized", + data: apiOperationsJson(), + humanText: formatApiRoutes(apiRouteRows()), + pageHumanText: true, + }, + sink, + ); +} + +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")); + }, +}); diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts new file mode 100644 index 0000000..e2be28e --- /dev/null +++ b/cli/src/commands/api/spec.ts @@ -0,0 +1,46 @@ +import { + getOpenapiSpecJson, + getOpenapiSpecYaml, + resolveOpenapiSpecFormat, +} from "@/lib/openapi-spec.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import type { OutputSink } from "@/lib/runtime.ts"; + +export 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, + ); +} + +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") }); + }, +}); diff --git a/cli/src/commands/append/index.ts b/cli/src/commands/append/index.ts index 666a6dc..57697d3 100644 --- a/cli/src/commands/append/index.ts +++ b/cli/src/commands/append/index.ts @@ -1,90 +1,9 @@ -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 "@/lib/lakehouse/args.ts"; -import { - lakehouseAppendOperation, - lakehouseAppendTaskOperation, -} from "@/lib/lakehouse-operations.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { appendRunCommand } from "@/commands/append/run.ts"; +import { appendStatusCommand } from "@/commands/append/status.ts"; +import { appendGroupArgs } from "@/commands/append/lib/args.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({ +export const appendCommand = defineCommand({ meta: { name: "append", commandGroup: "ingest", @@ -97,7 +16,7 @@ export const appendCommand = defineGroupCommand({ default: "run", args: appendGroupArgs, subCommands: { - run: appendRowsCommand, + 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..bf9c48a --- /dev/null +++ b/cli/src/commands/append/lib/args.ts @@ -0,0 +1,20 @@ +import type { ArgsDef } from "citty"; + +export 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; + +export 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; diff --git a/cli/src/commands/append/run.ts b/cli/src/commands/append/run.ts new file mode 100644 index 0000000..bb3f921 --- /dev/null +++ b/cli/src/commands/append/run.ts @@ -0,0 +1,35 @@ +import { appendRunArgs } from "@/commands/append/lib/args.ts"; +import { parseAppendJsonContent } from "@/lib/lakehouse/args.ts"; +import { buildLakehouseAppendRequest } from "@/lib/lakehouse-transport.ts"; +import { booleanArg, stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.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..e98c166 --- /dev/null +++ b/cli/src/commands/append/status.ts @@ -0,0 +1,31 @@ +import { urlencode } from "@/lib/encode.ts"; +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.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/${urlencode(stringArg(args, "append-id"))}`, + retry: true, + }, + execution, + ); + await writeCommandOutput({ kind: "raw_api", body: response }, sink); + }, +}); diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts new file mode 100644 index 0000000..c1406ee --- /dev/null +++ b/cli/src/commands/catalogs/create.ts @@ -0,0 +1,52 @@ +import { CliError } from "@/lib/errors.ts"; +import { requireManagementEnv } from "@/lib/auth.ts"; +import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; +import { buildCatalogCreateRequest } from "@/lib/catalogs/requests.ts"; +import { defineCommand } from "@/lib/command-context.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, buildCreateCatalogBody({ name: 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 index e7b4eea..109a37b 100644 --- a/cli/src/commands/catalogs/index.ts +++ b/cli/src/commands/catalogs/index.ts @@ -1,102 +1,8 @@ -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"; +import { defineCommand } from "@/lib/command-context.ts"; +import { catalogsCreateCommand } from "@/commands/catalogs/create.ts"; +import { catalogsListCommand } from "@/commands/catalogs/list.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({ +export const catalogsCommand = defineCommand({ meta: { name: "catalogs", commandGroup: "platform", diff --git a/cli/src/commands/catalogs/list.ts b/cli/src/commands/catalogs/list.ts new file mode 100644 index 0000000..d2d15c8 --- /dev/null +++ b/cli/src/commands/catalogs/list.ts @@ -0,0 +1,32 @@ +import { requireManagementEnv } from "@/lib/auth.ts"; +import { fetchManagementCatalogRows } from "@/lib/catalogs/requests.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { formatCatalogsSummary, formatCatalogsTable } from "@/features/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/index.ts b/cli/src/commands/completion/index.ts index 6f30d2e..987227e 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -2,13 +2,8 @@ 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 { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; import { CliError } from "@/lib/errors.ts"; import { defaultConfigurePrompts, @@ -40,14 +35,7 @@ type InstallResult = { rcUpdated: boolean; startupAction: "updated" | "skipped" | "not-needed"; }; -type CompletionInstallInput = { - explicitShell?: SupportedShell; - updateRc: boolean; -}; type CompletionRootInput = - | { - kind: "delegated"; - } | { kind: "help"; } @@ -56,15 +44,6 @@ type CompletionRootInput = shell?: SupportedShell; updateRc: boolean; }; -type CompletionRootOutput = - | { - kind: "help"; - } - | { - kind: "install"; - result: InstallResult; - } - | undefined; type CompletionCommandOptions = { prompts?: ConfigurePrompts; }; @@ -323,18 +302,17 @@ async function promptCompletionInput(prompts: ConfigurePrompts): Promise({ - id: "completion.install", - capabilities: ["local-file-write"], - catalog: { effects: ["local", "value"], mutates: true, output: "human" }, + const installCommand = defineCommand({ meta: { name: "install", description: "Install shell completion for the current shell.", @@ -423,30 +390,14 @@ export function createCompletionCommand( description: "Write the completion file without updating shell startup files.", }, }, - parse({ args, rawArgs }) { + async run({ args, rawArgs, sink }) { const explicitShell = rawArgs.slice(rawArgs.indexOf("install") + 1).find(isSupportedShell); - return { - explicitShell, + if (explicitShell) return; + const shell = resolveShell(undefined); + const script = formatCompletionScript(shell, getRootCommand()); + const result = await installCompletion(shell, script, { 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; @@ -455,10 +406,7 @@ export function createCompletionCommand( }, }); - return defineOperationCommand({ - id: "completion", - capabilities: ["raw-stdout", "local-file-write"], - catalog: { effects: ["local", "output", "value"], mutates: true, output: "human" }, + return defineCommand({ meta: { name: "completion", commandGroup: "platform", @@ -476,55 +424,30 @@ export function createCompletionCommand( install: installCommand, zsh: createShellCompletionCommand("zsh", getRootCommand), }, - async parse({ rawArgs, runtime, sink }): Promise { + async run({ rawArgs, runtime, sink }) { if (rawArgs.some((arg) => arg === "install" || arg === "generate" || isSupportedShell(arg))) { - return { kind: "delegated" }; + return; } + let action: CompletionRootInput; 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(); + action = await promptCompletionInput(prompts); + } else { + action = { kind: "help" }; } if (action.kind === "help") { - return localPlan(() => ({ kind: "help" })); - } - - return localPlan(async () => { + if (sink.json) sink.writeJson(COMPLETION_GUIDANCE); + else sink.writeHuman(formatCompletionHelpMessage()); + } else { 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(result); + else sink.writeHuman(formatInstallMessage(result)); } - - if (sink.json) { - sink.writeJson(COMPLETION_GUIDANCE); - return; - } - - sink.writeHuman(formatCompletionHelpMessage()); }, }); } diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index a29f0a1..7a615ea 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -6,11 +6,11 @@ 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 { defineCommand } from "@/lib/command-context.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { fetchManagementCatalogRows } from "@/lib/catalogs/requests.ts"; import type { CatalogRow } from "@/features/management/model.ts"; -import type { OperationContext } from "@/lib/operation-command.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; const LOGIN_PROMPT = "Log in with 'altertable login' to use altertable duckdb."; @@ -64,7 +64,7 @@ export function selectCatalogsToAttach( type DuckdbInput = { catalog: string | undefined }; -async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise { +async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promise { if (!Bun.which("duckdb")) { throw new ConfigurationError( "duckdb is not installed. Install it from https://duckdb.org/install/ and try again.", @@ -76,15 +76,12 @@ async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise 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); @@ -94,10 +91,7 @@ async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise } } -export const duckdbCommand = defineLocalCommand({ - id: "duckdb", - output: "none", - capabilities: ["management-http"], +export const duckdbCommand = defineCommand({ meta: { name: "duckdb", commandGroup: "query", @@ -111,8 +105,6 @@ export const duckdbCommand = defineLocalCommand({ required: false, }, }, - parse({ args }) { - return { catalog: optionalStringArg(args, "catalog") }; - }, - local: (input, context) => runDuckdb(input, context), + run: ({ args, execution }) => + runDuckdb({ catalog: optionalStringArg(args, "catalog") }, execution), }); diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index 3f42c89..56d4202 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -1,4 +1,4 @@ -import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; +import { defineCommand } from "@/lib/command-context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; import { assertAllowedApiBase } from "@/lib/url-policy.ts"; @@ -11,7 +11,6 @@ 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 { assertNoEnvConfigMode, createEmptyProfile, @@ -233,11 +232,7 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { ]); } -export const loginCommand = defineLocalCommand({ - id: "login", - mutates: true, - localConfig: true, - output: "none", +export const loginCommand = defineCommand({ meta: { name: "login", commandGroup: "platform", @@ -265,21 +260,5 @@ export const loginCommand = defineLocalCommand({ 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); - }, + run: ({ args, sink }) => runLogin(args as LoginArgs, sink), }); diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts index f3964d3..c78c6d6 100644 --- a/cli/src/commands/logout/index.ts +++ b/cli/src/commands/logout/index.ts @@ -1 +1,14 @@ -export { logoutCommand } from "@/commands/login/index.ts"; +import { defineCommand } from "@/lib/command-context.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/create.ts b/cli/src/commands/profile/create.ts new file mode 100644 index 0000000..3a05944 --- /dev/null +++ b/cli/src/commands/profile/create.ts @@ -0,0 +1,43 @@ +import { + assertNoEnvConfigMode, + createEmptyProfile, + inspectProfile, + setActiveProfile, +} from "@/features/profile/model.ts"; +import { formatProfileInspect } from "@/features/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-context.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..27504ef --- /dev/null +++ b/cli/src/commands/profile/current.ts @@ -0,0 +1,14 @@ +import { getActiveProfileName } from "@/features/profile/model.ts"; +import { defineCommand } from "@/lib/command-context.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..cbfc453 --- /dev/null +++ b/cli/src/commands/profile/delete.ts @@ -0,0 +1,27 @@ +import { assertNoEnvConfigMode, deleteProfile } from "@/features/profile/model.ts"; +import { CliError } from "@/lib/errors.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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..2ecf224 --- /dev/null +++ b/cli/src/commands/profile/direnv.ts @@ -0,0 +1,25 @@ +import { buildProfileDirenvView } from "@/features/profile/views.ts"; +import { + profileNameArgOrActive, + requireStoredProfileForExport, +} from "@/commands/profile/lib/profile.ts"; +import { renderShellExportView } from "@/ui/shell/render.ts"; +import { defineCommand } from "@/lib/command-context.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..5720b17 --- /dev/null +++ b/cli/src/commands/profile/env.ts @@ -0,0 +1,25 @@ +import { buildProfileShellExportView } from "@/features/profile/views.ts"; +import { + profileNameArgOrActive, + requireStoredProfileForExport, +} from "@/commands/profile/lib/profile.ts"; +import { renderShellExportView } from "@/ui/shell/render.ts"; +import { defineCommand } from "@/lib/command-context.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 index 4de200a..4613909 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -1,431 +1,31 @@ -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 { defineCommand } from "@/lib/command-context.ts"; +import { assertNoEnvConfigMode } from "@/features/profile/model.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}.`, - }; - }, -}); +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 { promptProfileSwitch } from "@/commands/profile/lib/profile.ts"; 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"; @@ -433,11 +33,7 @@ function throwNoProfileCommand(): never { throw error; } -export const profileCommand = defineLocalCommand, void>({ - id: "profile", - mutates: true, - localConfig: true, - output: "none", +export const profileCommand = defineCommand({ meta: { name: "profile", commandGroup: "platform", @@ -477,19 +73,13 @@ export const profileCommand = defineLocalCommand, void>( rename: profileRenameCommand, delete: profileDeleteCommand, }, - parse({ args }) { - return args as Record; - }, - async local(args, context) { + async run({ args, rawArgs, sink }) { 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)) { + await runProfileConfigure(args as ConfigureCommandArgs, sink); return; } + if (profileSubcommandInvoked(rawArgs)) return; throwNoProfileCommand(); }, }); diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts new file mode 100644 index 0000000..849e571 --- /dev/null +++ b/cli/src/commands/profile/lib/profile.ts @@ -0,0 +1,69 @@ +import { asCliArgString } from "@/lib/cli-args.ts"; +import { getCliContext } 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 { + defaultConfigurePrompts, + type ConfigurePrompts, +} from "@/lib/profile-configure-interactive.ts"; +import { profileSwitchOption } from "@/features/profile/views.ts"; +import { + envConfigMode, + getActiveProfileName, + isFromEnvProfile, + listProfiles, + profileExists, +} from "@/features/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: ConfigurePrompts = defaultConfigurePrompts, +): 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..9a701e8 --- /dev/null +++ b/cli/src/commands/profile/list.ts @@ -0,0 +1,15 @@ +import { listProfiles } from "@/features/profile/model.ts"; +import { formatProfileList } from "@/features/profile/render.ts"; +import { defineCommand } from "@/lib/command-context.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..0ded2f4 --- /dev/null +++ b/cli/src/commands/profile/rename.ts @@ -0,0 +1,26 @@ +import { assertNoEnvConfigMode, renameProfile } from "@/features/profile/model.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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..de28bb8 --- /dev/null +++ b/cli/src/commands/profile/show.ts @@ -0,0 +1,17 @@ +import { inspectProfile } from "@/features/profile/model.ts"; +import { formatProfileInspect } from "@/features/profile/render.ts"; +import { existingProfileName, profileShowTargetName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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..6ea9a79 --- /dev/null +++ b/cli/src/commands/profile/status.ts @@ -0,0 +1,41 @@ +import { getCliContext, setCliContext } from "@/context.ts"; +import { inspectProfile } from "@/features/profile/model.ts"; +import { formatProfileStatus } from "@/features/profile/render.ts"; +import { profileStatusToJson } from "@/features/profile/views.ts"; +import { configureVerify } from "@/lib/profile-status.ts"; +import { refreshCliRuntimeContext } from "@/lib/runtime.ts"; +import { + configuredVerificationPlanes, + existingProfileName, + profileShowTargetName, +} from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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, sink }) { + const profileName = existingProfileName(profileShowTargetName(args)); + const previous = getCliContext(); + try { + const next = { ...previous, profile: profileName }; + setCliContext(next); + refreshCliRuntimeContext(next); + const profile = inspectProfile(profileName); + const verification = await configureVerify(configuredVerificationPlanes(profile)); + const result = { profile, verification }; + await writeCommandOutput( + { + kind: "normalized", + data: profileStatusToJson(result), + humanText: formatProfileStatus(result), + }, + sink, + ); + } finally { + setCliContext(previous); + refreshCliRuntimeContext(previous); + } + }, +}); diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts new file mode 100644 index 0000000..6291a5c --- /dev/null +++ b/cli/src/commands/profile/switch.ts @@ -0,0 +1,30 @@ +import { getCliContext, isJsonOutput } from "@/context.ts"; +import { assertNoEnvConfigMode, setActiveProfile } from "@/features/profile/model.ts"; +import { CliError } from "@/lib/errors.ts"; +import { promptProfileSwitch, requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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..fe27e98 --- /dev/null +++ b/cli/src/commands/profile/use.ts @@ -0,0 +1,22 @@ +import { assertNoEnvConfigMode, setActiveProfile } from "@/features/profile/model.ts"; +import { requireProfileName } from "@/commands/profile/lib/profile.ts"; +import { defineCommand } from "@/lib/command-context.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..7060a72 --- /dev/null +++ b/cli/src/commands/query/cancel.ts @@ -0,0 +1,26 @@ +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.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.ts b/cli/src/commands/query/index.ts index 200580e..e134808 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,66 +1,12 @@ -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 type { ArgsDef } from "citty"; import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; -import { - PAGER_MODE_OPTIONS, - parseQueryOutputOptions, - parseRequestReadTimeoutMs, - QUERY_RESULT_FORMAT_OPTIONS, -} from "@/lib/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; +import { defineCommand } from "@/lib/command-context.ts"; +import { queryRunArgs } from "@/lib/lakehouse/args.ts"; +import { queryRunCommand } from "@/commands/query/run.ts"; +import { queryShowCommand } from "@/commands/query/show.ts"; +import { queryCancelCommand } from "@/commands/query/cancel.ts"; +const QUERY_SUBCOMMAND_NAMES = new Set(["run", "show", "cancel"]); const QUERY_VALUE_FLAGS = valueFlagsFor(queryRunArgs); export function normalizeQueryInvocatorRawArgs( @@ -76,112 +22,7 @@ export function normalizeQueryInvocatorRawArgs( }); } -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({ +export const queryCommand = defineCommand({ meta: { name: "query", commandGroup: "query", @@ -193,6 +34,10 @@ export const queryCommand = defineGroupCommand({ ], }, default: "run", - args: queryGroupArgs, - subCommands: querySubCommands, + args: queryRunArgs, + subCommands: { + run: queryRunCommand, + show: queryShowCommand, + cancel: queryCancelCommand, + }, }); diff --git a/cli/src/commands/query/run.ts b/cli/src/commands/query/run.ts new file mode 100644 index 0000000..268a698 --- /dev/null +++ b/cli/src/commands/query/run.ts @@ -0,0 +1,38 @@ +import { CliError } from "@/lib/errors.ts"; +import { optionalStringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; +import { + parseQueryOutputOptions, + parseRequestReadTimeoutMs, + queryRunArgs, +} from "@/lib/lakehouse/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, 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, displayOptions, pagerOptions, sink); + }, +}); diff --git a/cli/src/commands/query/show.ts b/cli/src/commands/query/show.ts new file mode 100644 index 0000000..2e0de8c --- /dev/null +++ b/cli/src/commands/query/show.ts @@ -0,0 +1,22 @@ +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.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.ts b/cli/src/commands/schema/index.ts index 1708e02..625aff7 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,15 +1,14 @@ import type { ArgsDef } from "citty"; -import { stringArg } from "@/lib/operation-codec.ts"; -import { defineOperationCommand } from "@/lib/operation-command.ts"; -import { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; +import { stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; import { - planQueryRun, - presentQueryRun, + parseQueryOutputOptions, + parseRequestReadTimeoutMs, queryRunArgs, - type QueryRunInput, -} from "@/commands/query/index.ts"; +} from "@/lib/lakehouse/args.ts"; import { writePagedOutput } from "@/lib/pager.ts"; -import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; +import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; +import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; export function buildSchemaStatement(catalog: string): string { @@ -62,16 +61,7 @@ const schemaArgs = { "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", - }, +export const schemaCommand = defineCommand({ meta: { name: "schema", commandGroup: "query", @@ -79,7 +69,7 @@ export const schemaCommand = defineOperationCommand { - 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 }; + 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/index.ts b/cli/src/commands/upsert/index.ts index eda9328..4c92c9d 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -1,25 +1,12 @@ -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 "@/lib/lakehouse/args.ts"; -import { - getUploadFileSizeBytes, - LAKEHOUSE_FILE_FORMAT_OPTIONS, -} from "@/commands/upload/index.ts"; +import { optionalStringArg, stringArg } from "@/lib/args.ts"; +import { defineCommand } from "@/lib/command-context.ts"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { sendHttp } from "@/lib/http-request.ts"; +import { parseLakehouseFileContentType, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; +import { getUploadFileSizeBytes, LAKEHOUSE_FILE_FORMAT_OPTIONS } from "@/commands/upload/index.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", - }, +export const upsertCommand = defineCommand({ meta: { name: "upsert", commandGroup: "ingest", @@ -48,12 +35,12 @@ export const upsertCommand = defineOperationCommand({ description: "Read timeout in seconds for this request (overrides global --read-timeout)", }, }, - async parse({ args }) { + async run({ args, execution, sink }) { const filePath = stringArg(args, "file"); const fileSizeBytes = getUploadFileSizeBytes(filePath); const readTimeoutMs = parseRequestReadTimeoutMs(args); - return { + const scope = createLakehouseUpsertRequest({ catalog: stringArg(args, "catalog"), schema: stringArg(args, "schema"), table: stringArg(args, "table"), @@ -62,27 +49,12 @@ export const upsertCommand = defineOperationCommand({ 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 }; + try { + const response = await sendHttp(scope.request, execution); + await writeCommandOutput({ kind: "raw_api", body: response }, sink); + } finally { + scope.release(); + } }, }); diff --git a/cli/src/lib/api-http.ts b/cli/src/lib/api-http.ts index 30c5a01..7e8eb53 100644 --- a/cli/src/lib/api-http.ts +++ b/cli/src/lib/api-http.ts @@ -4,8 +4,8 @@ import { CliError } from "@/lib/errors.ts"; import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; import { parseManagementOutputFormat } from "@/lib/lakehouse-client.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/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/src/lib/catalogs/requests.ts b/cli/src/lib/catalogs/requests.ts new file mode 100644 index 0000000..6d4cc2e --- /dev/null +++ b/cli/src/lib/catalogs/requests.ts @@ -0,0 +1,33 @@ +import { buildCatalogRowsFromResponses } from "@/lib/catalog-rows.ts"; +import type { CatalogRow } from "@/features/management/model.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; + +export function buildCatalogCreateRequest(env: string, body: string): HttpRequest { + return { + plane: "management", + method: "POST", + endpoint: `/environments/${env}/databases`, + body, + contentType: "application/json", + }; +} + +function buildCatalogListRequest(env: string, kind: "databases" | "connections") { + return { + plane: "management" as const, + method: "GET", + endpoint: `/environments/${env}/${kind}`, + }; +} + +export async function fetchManagementCatalogRows( + env: string, + execution: ExecutionContext, +): Promise { + const [databasesResponse, connectionsResponse] = await Promise.all([ + sendHttp(buildCatalogListRequest(env, "databases"), execution), + sendHttp(buildCatalogListRequest(env, "connections"), execution), + ]); + return buildCatalogRowsFromResponses(databasesResponse, connectionsResponse); +} diff --git a/cli/src/lib/command-context.ts b/cli/src/lib/command-context.ts index 73a0b3c..d20676a 100644 --- a/cli/src/lib/command-context.ts +++ b/cli/src/lib/command-context.ts @@ -1,6 +1,7 @@ import type { ArgsDef, CommandContext, CommandDef, CommandMeta, Resolvable } 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 AltertableCommandGroup = "platform" | "ingest" | "query"; @@ -13,6 +14,7 @@ export type AltertableCommandMeta = CommandMeta & { export type CommandRunContext = CommandContext & { runtime: CliRuntime; sink: OutputSink; + readonly execution: ExecutionContext; }; export type AltertableCommandDef = Omit< @@ -30,7 +32,16 @@ export type CommandTreeDef = AltertableCommandDef(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 { @@ -63,6 +74,8 @@ export function defineAltertableCommand( return bindCommandTree(def); } +export const defineCommand = defineAltertableCommand; + /** Erases citty's inferred args generic so the root command fits heterogeneous trees. */ export type RootCommandDef = Omit, "meta"> & { meta?: Resolvable; 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/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/src/lib/lakehouse-transport.ts b/cli/src/lib/lakehouse-transport.ts index d4b30f0..73df7a1 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,7 +37,7 @@ export type LakehouseUpsertRequestInput = { }; export type LakehouseUploadRequestScope = { - request: OperationHttpRequest; + request: HttpRequest; release: () => void; }; @@ -56,9 +56,7 @@ export function buildLakehouseQueryPayload( 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.ts b/cli/src/lib/lakehouse/args.ts index dc5e3ac..bd63d53 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import type { ArgsDef } from "citty"; import { isAgentMode } from "@/context.ts"; import { asCliArgString } from "@/lib/cli-args.ts"; import { CliError } from "@/lib/errors.ts"; @@ -7,12 +8,48 @@ import { resolvePagerOptions, type PagerMode, type PagerOptions } from "@/lib/pa 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"; +import { isQueryLayout, QUERY_LAYOUT_OPTIONS, 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; +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 PAGER_MODES = new Set(PAGER_MODE_OPTIONS); const AGENT_INCOMPATIBLE_QUERY_FLAGS = ["--layout", "--pager", "--max-width"] as const; diff --git a/cli/src/lib/lakehouse/query.ts b/cli/src/lib/lakehouse/query.ts new file mode 100644 index 0000000..fd3f7b4 --- /dev/null +++ b/cli/src/lib/lakehouse/query.ts @@ -0,0 +1,92 @@ +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { urlencode } from "@/lib/encode.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/${urlencode(queryId)}`, + retry: true, + }; +} + +export function buildLakehouseQueryCancelRequest(input: LakehouseCancelInput): HttpRequest { + const params = new URLSearchParams({ session_id: input.sessionId }); + return { + plane: "lakehouse", + method: "DELETE", + endpoint: `/query/${urlencode(input.queryId)}?${params.toString()}`, + }; +} + +export function buildLakehouseVerifyRequest(): HttpRequest { + return buildLakehouseQueryRequest({ statement: "SELECT 1" }, false); +} 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/src/lib/management-transport.ts b/cli/src/lib/management-transport.ts index a8a3c7a..f919f99 100644 --- a/cli/src/lib/management-transport.ts +++ b/cli/src/lib/management-transport.ts @@ -1,6 +1,6 @@ import { getCliRuntime } from "@/lib/runtime.ts"; import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; -import { sendOperationHttp } from "@/lib/operation-transport.ts"; +import { sendHttp } from "@/lib/http-request.ts"; export { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; export async function managementRequest( @@ -9,7 +9,7 @@ export async function managementRequest( body?: string, execution: ExecutionContext = createExecutionContext(getCliRuntime()), ): Promise { - return sendOperationHttp( + return sendHttp( { plane: "management", method, 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/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 3836d94..3712e8c 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -1,13 +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 { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; +import { buildLakehouseVerifyRequest } from "@/lib/lakehouse/query.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 { 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"; @@ -28,16 +26,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 +45,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,31 +54,25 @@ 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); } @@ -109,7 +92,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, createExecutionContext(getCliRuntime())); progress.done(formatProgressStatus("success", successMessage)); result.verified[plane] = true; } catch (error) { diff --git a/cli/tests/api-http.test.ts b/cli/tests/api-http.test.ts index 7559de2..52da503 100644 --- a/cli/tests/api-http.test.ts +++ b/cli/tests/api-http.test.ts @@ -5,15 +5,14 @@ import { tmpdir } from "node:os"; import { setCliContext } from "@/context.ts"; import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/lib/api-body.ts"; import { - apiHttpOperationPlan, apiHttpResultOutput, + executeApiHttp, normalizeApiEndpoint, + resolveApiHttp, type ApiHttpArgs, } from "@/lib/api-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,17 +55,10 @@ 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); } } @@ -164,7 +156,7 @@ describe("normalizeApiEndpoint", () => { }); }); -describe("apiHttpOperationPlan", () => { +describe("executeApiHttp", () => { test("GET writes generic tabular output in human mode", async () => { writeFileSync( mockFile, diff --git a/cli/tests/lakehouse-provision.test.ts b/cli/tests/lakehouse-provision.test.ts index 5b061a8..3e75b8d 100644 --- a/cli/tests/lakehouse-provision.test.ts +++ b/cli/tests/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/tests/lakehouse.test.ts b/cli/tests/lakehouse.test.ts index 3adf17a..c6be9a0 100644 --- a/cli/tests/lakehouse.test.ts +++ b/cli/tests/lakehouse.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from "node:os"; import { setCliContext } from "@/context.ts"; import { ParseError } from "@/lib/errors.ts"; import { - buildLakehouseQueryPayload, csvEscapeCell, getQueryColumnNames, parseLakehouseQueryResponse, @@ -13,13 +12,11 @@ import { 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 { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; import { normalizeQueryInvocatorRawArgs } from "@/commands/query/index.ts"; @@ -48,29 +45,6 @@ beforeEach(() => { 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") @@ -282,7 +256,7 @@ describe("parseLakehouseQueryStream", () => { }); }); -describe("lakehouse query stream effect", () => { +describe("executeLakehouseQuery", () => { test("returns the same result as parseLakehouseQueryResponse", async () => { writeFileSync( mockFile, @@ -296,19 +270,11 @@ describe("lakehouse query stream effect", () => { ]), ); - 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 runtime = getCliRuntime(); + const streamedResult = await executeLakehouseQuery( + { statement: "SELECT 1" }, + createExecutionContext(runtime), + true, ); const bufferedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); diff --git a/cli/tests/openapi-http-conformance.test.ts b/cli/tests/openapi-http-conformance.test.ts index d131a0b..e5da6a7 100644 --- a/cli/tests/openapi-http-conformance.test.ts +++ b/cli/tests/openapi-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 "@/lib/api-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 { 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/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"]); - }); -}); From 0f1bac672ffdc085296fe9b6b5b1875ef988d8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 12:08:38 +0200 Subject: [PATCH 04/22] refactor(cli): colocate command code and unit tests Make command directories reveal the public command tree, place family-private implementation under commands//lib, and reserve top-level lib for shared code. Move Bun unit tests beside their subjects and keep only black-box tests at the repository root. --- cli/bunfig.toml | 2 +- cli/knip.json | 4 +- cli/{tests => scripts}/release.test.ts | 0 cli/{tests => scripts}/script-output.test.ts | 0 cli/src/cli.ts | 2 +- .../commands/api/index.test.ts} | 4 +- cli/src/commands/api/index.ts | 4 +- .../api-body.ts => commands/api/lib/body.ts} | 0 cli/src/commands/api/lib/command.ts | 6 +- .../api/lib/http-conformance.test.ts} | 2 +- .../commands/api/lib/http.test.ts} | 4 +- .../api-http.ts => commands/api/lib/http.ts} | 2 +- .../api => commands/api/lib}/model.ts | 0 .../api => commands/api/lib}/render.ts | 4 +- .../commands/api/lib/views.test.ts} | 2 +- .../api => commands/api/lib}/views.ts | 2 +- cli/src/commands/api/routes.ts | 6 +- cli/src/commands/api/spec.ts | 2 +- cli/src/commands/append/index.ts | 2 +- cli/src/commands/append/run.ts | 2 +- cli/src/commands/append/status.ts | 2 +- cli/src/commands/catalogs/create.ts | 4 +- cli/src/commands/catalogs/index.ts | 2 +- .../catalogs/lib}/requests.ts | 4 +- cli/src/commands/catalogs/list.ts | 6 +- cli/src/commands/completion/bash.ts | 14 + cli/src/commands/completion/fish.ts | 14 + cli/src/commands/completion/generate.ts | 24 + .../commands/completion/index.test.ts} | 2 +- cli/src/commands/completion/index.ts | 447 ++---------------- cli/src/commands/completion/install.ts | 50 ++ cli/src/commands/completion/lib/completion.ts | 255 ++++++++++ .../completion/lib/format.ts} | 8 +- .../commands/completion/lib/shell-command.ts | 49 ++ .../commands/completion/lib/spec.test.ts} | 4 +- .../completion/lib/spec.ts} | 0 cli/src/commands/completion/zsh.ts | 14 + .../commands/duckdb/index.test.ts} | 2 +- cli/src/commands/duckdb/index.ts | 6 +- cli/src/commands/login/index.ts | 8 +- cli/src/commands/logout/index.ts | 2 +- cli/src/commands/profile/create.ts | 6 +- cli/src/commands/profile/current.ts | 4 +- cli/src/commands/profile/delete.ts | 4 +- cli/src/commands/profile/direnv.ts | 4 +- cli/src/commands/profile/env.ts | 4 +- cli/src/commands/profile/index.ts | 4 +- cli/src/commands/profile/lib/profile.ts | 6 +- cli/src/commands/profile/list.ts | 6 +- cli/src/commands/profile/rename.ts | 4 +- cli/src/commands/profile/show.ts | 6 +- cli/src/commands/profile/status.ts | 8 +- cli/src/commands/profile/switch.ts | 4 +- cli/src/commands/profile/use.ts | 4 +- cli/src/commands/query/cancel.ts | 2 +- cli/src/commands/query/index.ts | 2 +- cli/src/commands/query/run.ts | 2 +- cli/src/commands/query/show.ts | 2 +- cli/src/commands/schema/index.ts | 4 +- .../schema => commands/schema/lib}/render.ts | 2 +- .../schema => commands/schema/lib}/views.ts | 0 cli/src/commands/update/index.ts | 4 +- cli/src/commands/upload/index.ts | 2 +- cli/src/commands/upsert/index.ts | 2 +- cli/{tests => src/lib}/auth.test.ts | 0 .../lib/{catalog-rows.ts => catalogs/rows.ts} | 4 +- cli/{tests => src/lib}/cli-session.test.ts | 0 .../lib}/command-delegation.test.ts | 0 .../lib/command-output.test.ts} | 0 .../lib/{command-context.ts => command.ts} | 4 +- .../lib}/compiled-executable-path.test.ts | 2 +- cli/{tests => src/lib}/config.test.ts | 0 .../lib}/early-bootstrap.test.ts | 0 cli/{tests => src/lib}/env.test.ts | 9 +- cli/{tests => src/lib}/errors.test.ts | 2 +- .../lib}/fixtures/compiled-executable-path.ts | 0 cli/{tests => src/lib}/global-flags.test.ts | 0 cli/{tests => src/lib}/http-headers.test.ts | 0 cli/{tests => src/lib}/http.test.ts | 2 +- cli/src/lib/lakehouse-client.ts | 1 - .../lib}/lakehouse-provision.test.ts | 0 cli/src/lib/lakehouse-transport.ts | 15 - .../lib/lakehouse/args.test.ts} | 4 +- .../lib/lakehouse/query.test.ts} | 2 +- .../lib}/management-output.test.ts | 0 .../lib}/management-payloads.test.ts | 2 +- .../lib}/management-transport.test.ts | 0 .../lib/management/auth.test.ts} | 0 cli/src/{features => lib}/management/model.ts | 0 .../lib/management/render.test.ts} | 4 +- .../{features => lib}/management/render.ts | 4 +- cli/src/{features => lib}/management/views.ts | 2 +- .../lib/oauth-flow.test.ts} | 4 +- .../lib/oauth-profile.test.ts} | 0 cli/{tests => src/lib}/openapi-spec.test.ts | 0 cli/src/lib/openapi-spec.ts | 2 + cli/{tests => src/lib}/pager.test.ts | 0 cli/{tests => src/lib}/pluralize.test.ts | 0 cli/src/lib/profile-configure-core.ts | 2 +- ...ofile-configure-credential-status.test.ts} | 6 +- .../lib/profile-configure-data-plane.test.ts} | 0 cli/src/lib/profile-configure-interactive.ts | 2 +- .../lib}/profile-configure.test.ts | 0 cli/src/lib/profile-configure.ts | 4 +- cli/{tests => src/lib}/profile-status.test.ts | 0 cli/src/lib/profile-status.ts | 6 +- .../lib/profile}/active-context.test.ts | 6 +- .../lib/profile/model.test.ts} | 11 +- cli/src/{features => lib}/profile/model.ts | 2 +- cli/src/{features => lib}/profile/render.ts | 4 +- cli/src/{features => lib}/profile/views.ts | 4 +- cli/{tests => src/lib}/progress.test.ts | 0 .../lib}/query-column-types.test.ts | 0 cli/{tests => src/lib}/query-format.test.ts | 2 +- cli/{tests => src/lib}/redact.test.ts | 0 cli/{tests => src/lib}/relative-time.test.ts | 0 cli/{tests => src/lib}/stream-lines.test.ts | 0 cli/{tests => src/lib}/updater.test.ts | 2 +- cli/{tests => src/lib}/usage.test.ts | 8 +- cli/src/lib/usage.ts | 2 +- .../test-support}/cli-test-runtime.ts | 0 .../test-support}/terminal-test-utils.ts | 0 cli/{tests => src/test-support}/test-utils.ts | 0 .../ui/layouts/tree.test.ts} | 0 .../ui/terminal/styles.test.ts} | 0 .../ui/terminal/table.test.ts} | 2 +- cli/tsconfig.json | 5 +- 127 files changed, 611 insertions(+), 573 deletions(-) rename cli/{tests => scripts}/release.test.ts (100%) rename cli/{tests => scripts}/script-output.test.ts (100%) rename cli/{tests/api.test.ts => src/commands/api/index.test.ts} (98%) rename cli/src/{lib/api-body.ts => commands/api/lib/body.ts} (100%) rename cli/{tests/openapi-http-conformance.test.ts => src/commands/api/lib/http-conformance.test.ts} (97%) rename cli/{tests/api-http.test.ts => src/commands/api/lib/http.test.ts} (99%) rename cli/src/{lib/api-http.ts => commands/api/lib/http.ts} (99%) rename cli/src/{features/api => commands/api/lib}/model.ts (100%) rename cli/src/{features/api => commands/api/lib}/render.ts (90%) rename cli/{tests/presentation.test.ts => src/commands/api/lib/views.test.ts} (92%) rename cli/src/{features/api => commands/api/lib}/views.ts (95%) rename cli/src/{lib/catalogs => commands/catalogs/lib}/requests.ts (87%) create mode 100644 cli/src/commands/completion/bash.ts create mode 100644 cli/src/commands/completion/fish.ts create mode 100644 cli/src/commands/completion/generate.ts rename cli/{tests/completion.test.ts => src/commands/completion/index.test.ts} (99%) create mode 100644 cli/src/commands/completion/install.ts create mode 100644 cli/src/commands/completion/lib/completion.ts rename cli/src/{lib/completion-format.ts => commands/completion/lib/format.ts} (98%) create mode 100644 cli/src/commands/completion/lib/shell-command.ts rename cli/{tests/completion-spec.test.ts => src/commands/completion/lib/spec.test.ts} (98%) rename cli/src/{lib/completion-spec.ts => commands/completion/lib/spec.ts} (100%) create mode 100644 cli/src/commands/completion/zsh.ts rename cli/{tests/commands-duckdb.test.ts => src/commands/duckdb/index.test.ts} (97%) rename cli/src/{features/lakehouse/schema => commands/schema/lib}/render.ts (79%) rename cli/src/{features/lakehouse/schema => commands/schema/lib}/views.ts (100%) rename cli/{tests => src/lib}/auth.test.ts (100%) rename cli/src/lib/{catalog-rows.ts => catalogs/rows.ts} (89%) rename cli/{tests => src/lib}/cli-session.test.ts (100%) rename cli/{tests => src/lib}/command-delegation.test.ts (100%) rename cli/{tests/commands-output.test.ts => src/lib/command-output.test.ts} (100%) rename cli/src/lib/{command-context.ts => command.ts} (95%) rename cli/{tests => src/lib}/compiled-executable-path.test.ts (97%) rename cli/{tests => src/lib}/config.test.ts (100%) rename cli/{tests => src/lib}/early-bootstrap.test.ts (100%) rename cli/{tests => src/lib}/env.test.ts (94%) rename cli/{tests => src/lib}/errors.test.ts (99%) rename cli/{tests => src/lib}/fixtures/compiled-executable-path.ts (100%) rename cli/{tests => src/lib}/global-flags.test.ts (100%) rename cli/{tests => src/lib}/http-headers.test.ts (100%) rename cli/{tests => src/lib}/http.test.ts (99%) rename cli/{tests => src/lib}/lakehouse-provision.test.ts (100%) rename cli/{tests/commands-lakehouse.test.ts => src/lib/lakehouse/args.test.ts} (98%) rename cli/{tests/lakehouse.test.ts => src/lib/lakehouse/query.test.ts} (99%) rename cli/{tests => src/lib}/management-output.test.ts (100%) rename cli/{tests => src/lib}/management-payloads.test.ts (98%) rename cli/{tests => src/lib}/management-transport.test.ts (100%) rename cli/{tests/management-auth.test.ts => src/lib/management/auth.test.ts} (100%) rename cli/src/{features => lib}/management/model.ts (100%) rename cli/{tests/management-formatters.test.ts => src/lib/management/render.test.ts} (89%) rename cli/src/{features => lib}/management/render.ts (89%) rename cli/src/{features => lib}/management/views.ts (92%) rename cli/{tests/oauth.test.ts => src/lib/oauth-flow.test.ts} (99%) rename cli/{tests/oauth-refresh.test.ts => src/lib/oauth-profile.test.ts} (100%) rename cli/{tests => src/lib}/openapi-spec.test.ts (100%) rename cli/{tests => src/lib}/pager.test.ts (100%) rename cli/{tests => src/lib}/pluralize.test.ts (100%) rename cli/{tests/configure-credential-status.test.ts => src/lib/profile-configure-credential-status.test.ts} (98%) rename cli/{tests/configure-data-plane-url.test.ts => src/lib/profile-configure-data-plane.test.ts} (100%) rename cli/{tests => src/lib}/profile-configure.test.ts (100%) rename cli/{tests => src/lib}/profile-status.test.ts (100%) rename cli/{tests => src/lib/profile}/active-context.test.ts (98%) rename cli/{tests/profile.test.ts => src/lib/profile/model.test.ts} (98%) rename cli/src/{features => lib}/profile/model.ts (99%) rename cli/src/{features => lib}/profile/render.ts (97%) rename cli/src/{features => lib}/profile/views.ts (99%) rename cli/{tests => src/lib}/progress.test.ts (100%) rename cli/{tests => src/lib}/query-column-types.test.ts (100%) rename cli/{tests => src/lib}/query-format.test.ts (99%) rename cli/{tests => src/lib}/redact.test.ts (100%) rename cli/{tests => src/lib}/relative-time.test.ts (100%) rename cli/{tests => src/lib}/stream-lines.test.ts (100%) rename cli/{tests => src/lib}/updater.test.ts (99%) rename cli/{tests => src/lib}/usage.test.ts (97%) rename cli/{tests => src/test-support}/cli-test-runtime.ts (100%) rename cli/{tests => src/test-support}/terminal-test-utils.ts (100%) rename cli/{tests => src/test-support}/test-utils.ts (100%) rename cli/{tests/tree-layout.test.ts => src/ui/layouts/tree.test.ts} (100%) rename cli/{tests/terminal-style.test.ts => src/ui/terminal/styles.test.ts} (100%) rename cli/{tests/table-format.test.ts => src/ui/terminal/table.test.ts} (99%) 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 a82d2be..de37ee2 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/**/*.test.ts", "tests/**/*.test.ts", "scripts/**/*.ts"], - "project": ["src/**/*.ts", "tests/**/*.test.ts", "scripts/**/*.ts"], + "entry": ["src/**/*.test.ts", "scripts/**/*.test.ts", "scripts/**/*.ts"], + "project": ["src/**/*.ts", "scripts/**/*.ts"], "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, "ignoreDependencies": ["undici"], diff --git a/cli/tests/release.test.ts b/cli/scripts/release.test.ts similarity index 100% rename from cli/tests/release.test.ts rename to cli/scripts/release.test.ts diff --git a/cli/tests/script-output.test.ts b/cli/scripts/script-output.test.ts similarity index 100% rename from cli/tests/script-output.test.ts rename to cli/scripts/script-output.test.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index f010587..2ef1f48 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -21,7 +21,7 @@ import { renderCliErrorJson, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; -import { defineRootCommand } from "@/lib/command-context.ts"; +import { defineRootCommand } from "@/lib/command.ts"; import { resolveSubCommandForUsage, showAltertableUsage, diff --git a/cli/tests/api.test.ts b/cli/src/commands/api/index.test.ts similarity index 98% rename from cli/tests/api.test.ts rename to cli/src/commands/api/index.test.ts index 5446203..17cf381 100644 --- a/cli/tests/api.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -13,9 +13,9 @@ import { } 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, flattenTopLevelNames } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; +import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; function createCaptureSink(json: boolean) { const stdout: string[] = []; diff --git a/cli/src/commands/api/index.ts b/cli/src/commands/api/index.ts index 06eec49..7f9c788 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -1,7 +1,7 @@ import type { ArgsDef } from "citty"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; -import { executeApiHttp, apiHttpResultOutput } from "@/lib/api-http.ts"; +import { executeApiHttp, apiHttpResultOutput } from "@/commands/api/lib/http.ts"; import { API_HTTP_BASE_ARGS, isDelegatedApiCommand, diff --git a/cli/src/lib/api-body.ts b/cli/src/commands/api/lib/body.ts similarity index 100% rename from cli/src/lib/api-body.ts rename to cli/src/commands/api/lib/body.ts diff --git a/cli/src/commands/api/lib/command.ts b/cli/src/commands/api/lib/command.ts index 349d38d..db6c185 100644 --- a/cli/src/commands/api/lib/command.ts +++ b/cli/src/commands/api/lib/command.ts @@ -1,8 +1,8 @@ import type { ArgsDef } from "citty"; -import { extractFieldArgs, extractRawFieldArgs } from "@/lib/api-body.ts"; -import { executeApiHttp, apiHttpResultOutput, resolveApiHttp } from "@/lib/api-http.ts"; +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 { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { isDelegatedSubCommand, valueFlagsFor } from "@/lib/command-delegation.ts"; import { withManagementFormatArg } from "@/lib/management-output.ts"; diff --git a/cli/tests/openapi-http-conformance.test.ts b/cli/src/commands/api/lib/http-conformance.test.ts similarity index 97% rename from cli/tests/openapi-http-conformance.test.ts rename to cli/src/commands/api/lib/http-conformance.test.ts index e5da6a7..e3594ac 100644 --- a/cli/tests/openapi-http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; import { setCliContext } from "@/context.ts"; -import { executeApiHttp, resolveApiHttp } from "@/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 { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; diff --git a/cli/tests/api-http.test.ts b/cli/src/commands/api/lib/http.test.ts similarity index 99% rename from cli/tests/api-http.test.ts rename to cli/src/commands/api/lib/http.test.ts index 52da503..1a96e7f 100644 --- a/cli/tests/api-http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -3,14 +3,14 @@ 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 { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/commands/api/lib/body.ts"; import { 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 { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; diff --git a/cli/src/lib/api-http.ts b/cli/src/commands/api/lib/http.ts similarity index 99% rename from cli/src/lib/api-http.ts rename to cli/src/commands/api/lib/http.ts index 7e8eb53..ddee330 100644 --- a/cli/src/lib/api-http.ts +++ b/cli/src/commands/api/lib/http.ts @@ -1,4 +1,4 @@ -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"; 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 90% rename from cli/src/features/api/render.ts rename to cli/src/commands/api/lib/render.ts index f4d870c..3f51a19 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; 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/routes.ts b/cli/src/commands/api/routes.ts index 4ba2fe4..55e9797 100644 --- a/cli/src/commands/api/routes.ts +++ b/cli/src/commands/api/routes.ts @@ -1,8 +1,8 @@ import { optionalStringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; -import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/features/api/model.ts"; -import { formatApiOperationDetails, formatApiRoutes } from "@/features/api/render.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 async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts index e2be28e..1bc0123 100644 --- a/cli/src/commands/api/spec.ts +++ b/cli/src/commands/api/spec.ts @@ -4,7 +4,7 @@ import { resolveOpenapiSpecFormat, } from "@/lib/openapi-spec.ts"; import { optionalStringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import type { OutputSink } from "@/lib/runtime.ts"; diff --git a/cli/src/commands/append/index.ts b/cli/src/commands/append/index.ts index 57697d3..be77f47 100644 --- a/cli/src/commands/append/index.ts +++ b/cli/src/commands/append/index.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "@/lib/command-context.ts"; +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"; diff --git a/cli/src/commands/append/run.ts b/cli/src/commands/append/run.ts index bb3f921..f9e0e29 100644 --- a/cli/src/commands/append/run.ts +++ b/cli/src/commands/append/run.ts @@ -2,7 +2,7 @@ import { appendRunArgs } from "@/commands/append/lib/args.ts"; import { parseAppendJsonContent } from "@/lib/lakehouse/args.ts"; import { buildLakehouseAppendRequest } from "@/lib/lakehouse-transport.ts"; import { booleanArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.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"; diff --git a/cli/src/commands/append/status.ts b/cli/src/commands/append/status.ts index e98c166..2101208 100644 --- a/cli/src/commands/append/status.ts +++ b/cli/src/commands/append/status.ts @@ -1,6 +1,6 @@ import { urlencode } from "@/lib/encode.ts"; import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { sendHttp } from "@/lib/http-request.ts"; diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts index c1406ee..86b1586 100644 --- a/cli/src/commands/catalogs/create.ts +++ b/cli/src/commands/catalogs/create.ts @@ -1,8 +1,8 @@ import { CliError } from "@/lib/errors.ts"; import { requireManagementEnv } from "@/lib/auth.ts"; import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; -import { buildCatalogCreateRequest } from "@/lib/catalogs/requests.ts"; -import { defineCommand } from "@/lib/command-context.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"; diff --git a/cli/src/commands/catalogs/index.ts b/cli/src/commands/catalogs/index.ts index 109a37b..9fa678f 100644 --- a/cli/src/commands/catalogs/index.ts +++ b/cli/src/commands/catalogs/index.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { catalogsCreateCommand } from "@/commands/catalogs/create.ts"; import { catalogsListCommand } from "@/commands/catalogs/list.ts"; diff --git a/cli/src/lib/catalogs/requests.ts b/cli/src/commands/catalogs/lib/requests.ts similarity index 87% rename from cli/src/lib/catalogs/requests.ts rename to cli/src/commands/catalogs/lib/requests.ts index 6d4cc2e..0bafffb 100644 --- a/cli/src/lib/catalogs/requests.ts +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -1,5 +1,5 @@ -import { buildCatalogRowsFromResponses } from "@/lib/catalog-rows.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; +import { buildCatalogRowsFromResponses } from "@/lib/catalogs/rows.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; import type { ExecutionContext } from "@/lib/execution-context.ts"; import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; diff --git a/cli/src/commands/catalogs/list.ts b/cli/src/commands/catalogs/list.ts index d2d15c8..b37d47f 100644 --- a/cli/src/commands/catalogs/list.ts +++ b/cli/src/commands/catalogs/list.ts @@ -1,8 +1,8 @@ import { requireManagementEnv } from "@/lib/auth.ts"; -import { fetchManagementCatalogRows } from "@/lib/catalogs/requests.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; -import { formatCatalogsSummary, formatCatalogsTable } from "@/features/management/render.ts"; +import { formatCatalogsSummary, formatCatalogsTable } from "@/lib/management/render.ts"; import { span } from "@/ui/document.ts"; import { renderDisplayText } from "@/ui/terminal/styles.ts"; diff --git a/cli/src/commands/completion/bash.ts b/cli/src/commands/completion/bash.ts new file mode 100644 index 0000000..82ea574 --- /dev/null +++ b/cli/src/commands/completion/bash.ts @@ -0,0 +1,14 @@ +import type { CommandDef } from "citty"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createBashCompletionCommand(getRootCommand: GetRootCommand): CommandDef { + return createShellCompletionCommand("bash", getRootCommand); +} + +export function createBashInstallCommand(getRootCommand: GetRootCommand): CommandDef { + 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..aad7ee9 --- /dev/null +++ b/cli/src/commands/completion/fish.ts @@ -0,0 +1,14 @@ +import type { CommandDef } from "citty"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createFishCompletionCommand(getRootCommand: GetRootCommand): CommandDef { + return createShellCompletionCommand("fish", getRootCommand); +} + +export function createFishInstallCommand(getRootCommand: GetRootCommand): CommandDef { + 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..1e4998d --- /dev/null +++ b/cli/src/commands/completion/generate.ts @@ -0,0 +1,24 @@ +import type { CommandDef } from "citty"; +import { defineCommand } 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): CommandDef { + 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 99% rename from cli/tests/completion.test.ts rename to cli/src/commands/completion/index.test.ts index 1fe9660..5ff75f4 100644 --- a/cli/tests/completion.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -6,7 +6,7 @@ import type { CommandDef } from "citty"; import { buildMainCommand } from "@/cli.ts"; import { createCompletionCommand } from "@/commands/completion/index.ts"; import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; -import { buildCompletionSpec, flattenTopLevelNames } from "@/lib/completion-spec.ts"; +import { buildCompletionSpec, flattenTopLevelNames } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; const TERMINAL_CONTROL_PATTERN = new RegExp( diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts index 987227e..98fe1d7 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -1,411 +1,30 @@ -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 { defineCommand } from "@/lib/command-context.ts"; -import { writeCommandOutput } from "@/lib/command-output.ts"; -import { CliError } from "@/lib/errors.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { defaultConfigurePrompts } from "@/lib/profile-configure-interactive.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 { - 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 CompletionRootInput = - | { - kind: "help"; - } - | { - kind: "install"; - shell?: SupportedShell; - updateRc: boolean; - }; -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, -): CommandDef { - return defineCommand({ - meta: { - name: shell, - description: `Generate ${shell} completion script.`, - }, - async run({ sink }) { - await writeCommandOutput( - { kind: "raw_api", body: formatCompletionScript(shell, getRootCommand()) }, - sink, - ); - }, - }); -} - -function createInstallShellCommand( - shell: SupportedShell, - getRootCommand: GetRootCommand, -): CommandDef { - 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 script = formatCompletionScript(shell, getRootCommand()); - const result = await installCompletion(shell, script, { - updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc"), - }); - if (sink.json) { - sink.writeJson(result); - return; - } - sink.writeHuman(formatInstallMessage(result)); - }, - }); -} - -function createGenerateCommand(getRootCommand: GetRootCommand): CommandDef { - 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: createShellCompletionCommand("bash", getRootCommand), - fish: createShellCompletionCommand("fish", getRootCommand), - zsh: createShellCompletionCommand("zsh", getRootCommand), - }, - }); -} + 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 = {}, ): CommandDef { const prompts = options.prompts ?? defaultConfigurePrompts; - const installCommand = 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: 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.", - }, - }, - async run({ args, rawArgs, sink }) { - const explicitShell = rawArgs.slice(rawArgs.indexOf("install") + 1).find(isSupportedShell); - if (explicitShell) return; - const shell = resolveShell(undefined); - const script = formatCompletionScript(shell, getRootCommand()); - const result = await installCompletion(shell, script, { - updateRc: args["no-rc"] !== true && !rawArgs.includes("--no-rc"), - }); - if (sink.json) { - sink.writeJson(result); - return; - } - sink.writeHuman(formatInstallMessage(result)); - }, - }); - return defineCommand({ meta: { name: "completion", @@ -418,11 +37,11 @@ export function createCompletionCommand( ], }, subCommands: { - bash: createShellCompletionCommand("bash", getRootCommand), - fish: createShellCompletionCommand("fish", getRootCommand), + bash: createBashCompletionCommand(getRootCommand), + fish: createFishCompletionCommand(getRootCommand), generate: createGenerateCommand(getRootCommand), - install: installCommand, - zsh: createShellCompletionCommand("zsh", getRootCommand), + install: createInstallCommand(getRootCommand), + zsh: createZshCompletionCommand(getRootCommand), }, async run({ rawArgs, runtime, sink }) { if (rawArgs.some((arg) => arg === "install" || arg === "generate" || isSupportedShell(arg))) { @@ -439,15 +58,19 @@ export function createCompletionCommand( if (action.kind === "help") { if (sink.json) sink.writeJson(COMPLETION_GUIDANCE); else sink.writeHuman(formatCompletionHelpMessage()); - } else { - const shell = action.shell ?? resolveShell(undefined); - const script = formatCompletionScript(shell, getRootCommand()); - const result = await installCompletion(shell, script, { - updateRc: action.updateRc, - }); - if (sink.json) sink.writeJson(result); - else sink.writeHuman(formatInstallMessage(result)); + 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)); }, }); } + +export { installCompletion } from "@/commands/completion/lib/completion.ts"; diff --git a/cli/src/commands/completion/install.ts b/cli/src/commands/completion/install.ts new file mode 100644 index 0000000..4c29899 --- /dev/null +++ b/cli/src/commands/completion/install.ts @@ -0,0 +1,50 @@ +import type { CommandDef } from "citty"; +import { defineCommand } 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): CommandDef { + 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..ad635af --- /dev/null +++ b/cli/src/commands/completion/lib/completion.ts @@ -0,0 +1,255 @@ +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 { CliError } from "@/lib/errors.ts"; +import { readEnv } from "@/lib/env.ts"; +import 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 "@/commands/completion/lib/spec.ts"; +import { + formatBashCompletion, + formatFishCompletion, + formatZshCompletion, +} from "@/commands/completion/lib/format.ts"; + +export type GetRootCommand = () => CommandDef; +export type SupportedShell = "bash" | "zsh" | "fish"; +export type CompletionRootInput = + | { kind: "help" } + | { kind: "install"; shell?: SupportedShell; updateRc: boolean }; +export type CompletionCommandOptions = { prompts?: ConfigurePrompts }; +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: 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 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: 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; + + 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..d8f034f --- /dev/null +++ b/cli/src/commands/completion/lib/shell-command.ts @@ -0,0 +1,49 @@ +import type { CommandDef } from "citty"; +import { defineCommand } 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, +): CommandDef { + 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, +): CommandDef { + 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 98% rename from cli/tests/completion-spec.test.ts rename to cli/src/commands/completion/lib/spec.test.ts index f434223..4c8ddb4 100644 --- a/cli/tests/completion-spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -5,7 +5,7 @@ import { buildCompletionSpec, collectCompletionContexts, flattenTopLevelNames, -} from "@/lib/completion-spec.ts"; +} from "@/commands/completion/lib/spec.ts"; import { formatBashCompletion, formatBashFlagWordList, @@ -14,7 +14,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); diff --git a/cli/src/lib/completion-spec.ts b/cli/src/commands/completion/lib/spec.ts similarity index 100% rename from cli/src/lib/completion-spec.ts rename to cli/src/commands/completion/lib/spec.ts diff --git a/cli/src/commands/completion/zsh.ts b/cli/src/commands/completion/zsh.ts new file mode 100644 index 0000000..34dbcf0 --- /dev/null +++ b/cli/src/commands/completion/zsh.ts @@ -0,0 +1,14 @@ +import type { CommandDef } from "citty"; +import type { GetRootCommand } from "@/commands/completion/lib/completion.ts"; +import { + createInstallShellCommand, + createShellCompletionCommand, +} from "@/commands/completion/lib/shell-command.ts"; + +export function createZshCompletionCommand(getRootCommand: GetRootCommand): CommandDef { + return createShellCompletionCommand("zsh", getRootCommand); +} + +export function createZshInstallCommand(getRootCommand: GetRootCommand): CommandDef { + return createInstallShellCommand("zsh", getRootCommand); +} diff --git a/cli/tests/commands-duckdb.test.ts b/cli/src/commands/duckdb/index.test.ts similarity index 97% rename from cli/tests/commands-duckdb.test.ts rename to cli/src/commands/duckdb/index.test.ts index 443147c..2b8c562 100644 --- a/cli/tests/commands-duckdb.test.ts +++ b/cli/src/commands/duckdb/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb/index.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; function catalogRow(catalog: string): CatalogRow { return { type: "database", name: catalog, slug: catalog, engine: "altertable", catalog }; diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index 7a615ea..3fe4613 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -6,10 +6,10 @@ import { } from "@/lib/auth.ts"; import { configureVerify } from "@/lib/profile-status.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { optionalStringArg } from "@/lib/args.ts"; -import { fetchManagementCatalogRows } from "@/lib/catalogs/requests.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; +import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; import type { ExecutionContext } from "@/lib/execution-context.ts"; const LOGIN_PROMPT = "Log in with 'altertable login' to use altertable duckdb."; diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index 56d4202..668abac 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "@/lib/command-context.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"; @@ -9,8 +9,8 @@ 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 type { WhoamiResponse } from "@/lib/management/model.ts"; +import { formatWhoamiPrincipalLine } from "@/lib/management/render.ts"; import { assertNoEnvConfigMode, createEmptyProfile, @@ -20,7 +20,7 @@ import { resolveWorkingProfile, setActiveProfile, updateProfile, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; import { readEnv, setEnv } from "@/lib/env.ts"; import { span } from "@/ui/document.ts"; import { renderDisplayText } from "@/ui/terminal/styles.ts"; diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts index c78c6d6..6b0ee6f 100644 --- a/cli/src/commands/logout/index.ts +++ b/cli/src/commands/logout/index.ts @@ -1,4 +1,4 @@ -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; export const logoutCommand = defineCommand({ diff --git a/cli/src/commands/profile/create.ts b/cli/src/commands/profile/create.ts index 3a05944..29e54ee 100644 --- a/cli/src/commands/profile/create.ts +++ b/cli/src/commands/profile/create.ts @@ -3,15 +3,15 @@ import { createEmptyProfile, inspectProfile, setActiveProfile, -} from "@/features/profile/model.ts"; -import { formatProfileInspect } from "@/features/profile/render.ts"; +} from "@/lib/profile/model.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-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileCreateCommand = defineCommand({ diff --git a/cli/src/commands/profile/current.ts b/cli/src/commands/profile/current.ts index 27504ef..b3e509c 100644 --- a/cli/src/commands/profile/current.ts +++ b/cli/src/commands/profile/current.ts @@ -1,5 +1,5 @@ -import { getActiveProfileName } from "@/features/profile/model.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { getActiveProfileName } from "@/lib/profile/model.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileCurrentCommand = defineCommand({ diff --git a/cli/src/commands/profile/delete.ts b/cli/src/commands/profile/delete.ts index cbfc453..b77da7c 100644 --- a/cli/src/commands/profile/delete.ts +++ b/cli/src/commands/profile/delete.ts @@ -1,7 +1,7 @@ -import { assertNoEnvConfigMode, deleteProfile } from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, deleteProfile } from "@/lib/profile/model.ts"; import { CliError } from "@/lib/errors.ts"; import { requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileDeleteCommand = defineCommand({ diff --git a/cli/src/commands/profile/direnv.ts b/cli/src/commands/profile/direnv.ts index 2ecf224..ff02fcb 100644 --- a/cli/src/commands/profile/direnv.ts +++ b/cli/src/commands/profile/direnv.ts @@ -1,10 +1,10 @@ -import { buildProfileDirenvView } from "@/features/profile/views.ts"; +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-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileDirenvCommand = defineCommand({ diff --git a/cli/src/commands/profile/env.ts b/cli/src/commands/profile/env.ts index 5720b17..59c068e 100644 --- a/cli/src/commands/profile/env.ts +++ b/cli/src/commands/profile/env.ts @@ -1,10 +1,10 @@ -import { buildProfileShellExportView } from "@/features/profile/views.ts"; +import { buildProfileShellExportView } from "@/lib/profile/views.ts"; import { profileNameArgOrActive, requireStoredProfileForExport, } from "@/commands/profile/lib/profile.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileEnvCommand = defineCommand({ diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts index 4613909..7ea865c 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -1,5 +1,5 @@ -import { defineCommand } from "@/lib/command-context.ts"; -import { assertNoEnvConfigMode } from "@/features/profile/model.ts"; +import { defineCommand } from "@/lib/command.ts"; +import { assertNoEnvConfigMode } from "@/lib/profile/model.ts"; import { configureArgs, runProfileConfigure, diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts index 849e571..2b487df 100644 --- a/cli/src/commands/profile/lib/profile.ts +++ b/cli/src/commands/profile/lib/profile.ts @@ -1,20 +1,20 @@ import { asCliArgString } from "@/lib/cli-args.ts"; import { getCliContext } from "@/context.ts"; import { CliError, ConfigurationError } from "@/lib/errors.ts"; -import type { ProfileInspect } from "@/features/profile/model.ts"; +import type { ProfileInspect } from "@/lib/profile/model.ts"; import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; import { defaultConfigurePrompts, type ConfigurePrompts, } from "@/lib/profile-configure-interactive.ts"; -import { profileSwitchOption } from "@/features/profile/views.ts"; +import { profileSwitchOption } from "@/lib/profile/views.ts"; import { envConfigMode, getActiveProfileName, isFromEnvProfile, listProfiles, profileExists, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; export function requireProfileName(name: unknown): string { const trimmed = asCliArgString(name).trim(); diff --git a/cli/src/commands/profile/list.ts b/cli/src/commands/profile/list.ts index 9a701e8..6ca1f94 100644 --- a/cli/src/commands/profile/list.ts +++ b/cli/src/commands/profile/list.ts @@ -1,6 +1,6 @@ -import { listProfiles } from "@/features/profile/model.ts"; -import { formatProfileList } from "@/features/profile/render.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { listProfiles } from "@/lib/profile/model.ts"; +import { formatProfileList } from "@/lib/profile/render.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileListCommand = defineCommand({ diff --git a/cli/src/commands/profile/rename.ts b/cli/src/commands/profile/rename.ts index 0ded2f4..04a9c6e 100644 --- a/cli/src/commands/profile/rename.ts +++ b/cli/src/commands/profile/rename.ts @@ -1,6 +1,6 @@ -import { assertNoEnvConfigMode, renameProfile } from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, renameProfile } from "@/lib/profile/model.ts"; import { requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileRenameCommand = defineCommand({ diff --git a/cli/src/commands/profile/show.ts b/cli/src/commands/profile/show.ts index de28bb8..96de8d8 100644 --- a/cli/src/commands/profile/show.ts +++ b/cli/src/commands/profile/show.ts @@ -1,7 +1,7 @@ -import { inspectProfile } from "@/features/profile/model.ts"; -import { formatProfileInspect } from "@/features/profile/render.ts"; +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-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileShowCommand = defineCommand({ diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts index 6ea9a79..90c8dac 100644 --- a/cli/src/commands/profile/status.ts +++ b/cli/src/commands/profile/status.ts @@ -1,7 +1,7 @@ import { getCliContext, setCliContext } from "@/context.ts"; -import { inspectProfile } from "@/features/profile/model.ts"; -import { formatProfileStatus } from "@/features/profile/render.ts"; -import { profileStatusToJson } from "@/features/profile/views.ts"; +import { inspectProfile } from "@/lib/profile/model.ts"; +import { formatProfileStatus } from "@/lib/profile/render.ts"; +import { profileStatusToJson } from "@/lib/profile/views.ts"; import { configureVerify } from "@/lib/profile-status.ts"; import { refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { @@ -9,7 +9,7 @@ import { existingProfileName, profileShowTargetName, } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileStatusCommand = defineCommand({ diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts index 6291a5c..470c15f 100644 --- a/cli/src/commands/profile/switch.ts +++ b/cli/src/commands/profile/switch.ts @@ -1,8 +1,8 @@ import { getCliContext, isJsonOutput } from "@/context.ts"; -import { assertNoEnvConfigMode, setActiveProfile } from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, setActiveProfile } from "@/lib/profile/model.ts"; import { CliError } from "@/lib/errors.ts"; import { promptProfileSwitch, requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileSwitchCommand = defineCommand({ diff --git a/cli/src/commands/profile/use.ts b/cli/src/commands/profile/use.ts index fe27e98..41ca825 100644 --- a/cli/src/commands/profile/use.ts +++ b/cli/src/commands/profile/use.ts @@ -1,6 +1,6 @@ -import { assertNoEnvConfigMode, setActiveProfile } from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, setActiveProfile } from "@/lib/profile/model.ts"; import { requireProfileName } from "@/commands/profile/lib/profile.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; export const profileUseCommand = defineCommand({ diff --git a/cli/src/commands/query/cancel.ts b/cli/src/commands/query/cancel.ts index 7060a72..589256f 100644 --- a/cli/src/commands/query/cancel.ts +++ b/cli/src/commands/query/cancel.ts @@ -1,5 +1,5 @@ import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.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"; diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index e134808..57b291f 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,6 +1,6 @@ import type { ArgsDef } from "citty"; import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { queryRunArgs } from "@/lib/lakehouse/args.ts"; import { queryRunCommand } from "@/commands/query/run.ts"; import { queryShowCommand } from "@/commands/query/show.ts"; diff --git a/cli/src/commands/query/run.ts b/cli/src/commands/query/run.ts index 268a698..47548cf 100644 --- a/cli/src/commands/query/run.ts +++ b/cli/src/commands/query/run.ts @@ -1,6 +1,6 @@ import { CliError } from "@/lib/errors.ts"; import { optionalStringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; import { parseQueryOutputOptions, diff --git a/cli/src/commands/query/show.ts b/cli/src/commands/query/show.ts index 2e0de8c..6b6a830 100644 --- a/cli/src/commands/query/show.ts +++ b/cli/src/commands/query/show.ts @@ -1,5 +1,5 @@ import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.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"; diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index 625aff7..518f1e0 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,6 +1,6 @@ import type { ArgsDef } from "citty"; import { stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { parseQueryOutputOptions, parseRequestReadTimeoutMs, @@ -9,7 +9,7 @@ import { import { writePagedOutput } from "@/lib/pager.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; -import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; +import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; export function buildSchemaStatement(catalog: string): string { const catSql = `'${catalog.replaceAll("'", "''")}'`; diff --git a/cli/src/features/lakehouse/schema/render.ts b/cli/src/commands/schema/lib/render.ts similarity index 79% rename from cli/src/features/lakehouse/schema/render.ts rename to cli/src/commands/schema/lib/render.ts index 1d11814..5dbc87c 100644 --- a/cli/src/features/lakehouse/schema/render.ts +++ b/cli/src/commands/schema/lib/render.ts @@ -1,4 +1,4 @@ -import { buildSchemaTreeView } from "@/features/lakehouse/schema/views.ts"; +import { buildSchemaTreeView } from "@/commands/schema/lib/views.ts"; import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; import { renderTreeText } from "@/ui/renderers/terminal.ts"; diff --git a/cli/src/features/lakehouse/schema/views.ts b/cli/src/commands/schema/lib/views.ts similarity index 100% rename from cli/src/features/lakehouse/schema/views.ts rename to cli/src/commands/schema/lib/views.ts diff --git a/cli/src/commands/update/index.ts b/cli/src/commands/update/index.ts index 6df9443..4b60c2b 100644 --- a/cli/src/commands/update/index.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"; @@ -142,7 +142,7 @@ export async function executeUpdateCommand( ); } -export const updateCommand = defineAltertableCommand({ +export const updateCommand = defineCommand({ meta: { name: "update", alias: ["upgrade"], diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts index 248349a..66440c2 100644 --- a/cli/src/commands/upload/index.ts +++ b/cli/src/commands/upload/index.ts @@ -1,7 +1,7 @@ import { statSync } from "node:fs"; import { CliError } from "@/lib/errors.ts"; import { enumArg, optionalStringArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { sendHttp } from "@/lib/http-request.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; diff --git a/cli/src/commands/upsert/index.ts b/cli/src/commands/upsert/index.ts index 4c92c9d..a459bd8 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -1,5 +1,5 @@ import { optionalStringArg, stringArg } from "@/lib/args.ts"; -import { defineCommand } from "@/lib/command-context.ts"; +import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { sendHttp } from "@/lib/http-request.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; diff --git a/cli/tests/auth.test.ts b/cli/src/lib/auth.test.ts similarity index 100% rename from cli/tests/auth.test.ts rename to cli/src/lib/auth.test.ts diff --git a/cli/src/lib/catalog-rows.ts b/cli/src/lib/catalogs/rows.ts similarity index 89% rename from cli/src/lib/catalog-rows.ts rename to cli/src/lib/catalogs/rows.ts index 107ee10..15e248f 100644 --- a/cli/src/lib/catalog-rows.ts +++ b/cli/src/lib/catalogs/rows.ts @@ -1,6 +1,6 @@ import { parseApiJson } from "@/lib/parse-api-json.ts"; -import type { CatalogRow } from "@/features/management/model.ts"; -import { formatCatalogsTable } from "@/features/management/render.ts"; +import type { CatalogRow } from "@/lib/management/model.ts"; +import { formatCatalogsTable } from "@/lib/management/render.ts"; type DatabaseSummary = { name?: string; diff --git a/cli/tests/cli-session.test.ts b/cli/src/lib/cli-session.test.ts similarity index 100% rename from cli/tests/cli-session.test.ts rename to cli/src/lib/cli-session.test.ts diff --git a/cli/tests/command-delegation.test.ts b/cli/src/lib/command-delegation.test.ts similarity index 100% rename from cli/tests/command-delegation.test.ts rename to cli/src/lib/command-delegation.test.ts diff --git a/cli/tests/commands-output.test.ts b/cli/src/lib/command-output.test.ts similarity index 100% rename from cli/tests/commands-output.test.ts rename to cli/src/lib/command-output.test.ts diff --git a/cli/src/lib/command-context.ts b/cli/src/lib/command.ts similarity index 95% rename from cli/src/lib/command-context.ts rename to cli/src/lib/command.ts index d20676a..4d8e2a4 100644 --- a/cli/src/lib/command-context.ts +++ b/cli/src/lib/command.ts @@ -68,14 +68,12 @@ function bindCommandTree(def: CommandTreeDef): CommandDef return bound; } -export function defineAltertableCommand( +export function defineCommand( def: AltertableCommandDef, ): CommandDef { return bindCommandTree(def); } -export const defineCommand = defineAltertableCommand; - /** Erases citty's inferred args generic so the root command fits heterogeneous trees. */ export type RootCommandDef = Omit, "meta"> & { meta?: Resolvable; 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/tests/config.test.ts b/cli/src/lib/config.test.ts similarity index 100% rename from cli/tests/config.test.ts rename to cli/src/lib/config.test.ts 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/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..9b80bc1 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-support/") + ) { 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 99% rename from cli/tests/errors.test.ts rename to cli/src/lib/errors.test.ts index 125b071..ed5cdfa 100644 --- a/cli/tests/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -28,7 +28,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-support/terminal-test-utils.ts"; let terminalState: TerminalTestState; 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 100% rename from cli/tests/global-flags.test.ts rename to cli/src/lib/global-flags.test.ts 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/tests/http.test.ts b/cli/src/lib/http.test.ts similarity index 99% rename from cli/tests/http.test.ts rename to cli/src/lib/http.test.ts index 4df1805..46ad8cf 100644 --- a/cli/tests/http.test.ts +++ b/cli/src/lib/http.test.ts @@ -17,7 +17,7 @@ import { resolveFetchTimeoutMs, resolveReadTimeoutMs, } from "@/lib/http.ts"; -import { delay } from "@tests/test-utils.ts"; +import { delay } from "@/test-support/test-utils.ts"; const fakeBearerKey = "atm_fake_test_key_for_redaction"; const fakeBasicToken = "fake_basic_token_value"; diff --git a/cli/src/lib/lakehouse-client.ts b/cli/src/lib/lakehouse-client.ts index 3716d01..6a40910 100644 --- a/cli/src/lib/lakehouse-client.ts +++ b/cli/src/lib/lakehouse-client.ts @@ -14,7 +14,6 @@ import { resolvePagerOptions, writePagedOutput, type PagerOptions } from "@/lib/ export { buildLakehouseAppendRequest, - buildLakehouseQueryPayload, createLakehouseUploadRequest, createLakehouseUpsertRequest, type LakehouseAppendRequestInput, diff --git a/cli/tests/lakehouse-provision.test.ts b/cli/src/lib/lakehouse-provision.test.ts similarity index 100% rename from cli/tests/lakehouse-provision.test.ts rename to cli/src/lib/lakehouse-provision.test.ts diff --git a/cli/src/lib/lakehouse-transport.ts b/cli/src/lib/lakehouse-transport.ts index 73df7a1..69e1f89 100644 --- a/cli/src/lib/lakehouse-transport.ts +++ b/cli/src/lib/lakehouse-transport.ts @@ -41,21 +41,6 @@ export type LakehouseUploadRequestScope = { 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): HttpRequest { const params = new URLSearchParams({ catalog: input.catalog, diff --git a/cli/tests/commands-lakehouse.test.ts b/cli/src/lib/lakehouse/args.test.ts similarity index 98% rename from cli/tests/commands-lakehouse.test.ts rename to cli/src/lib/lakehouse/args.test.ts index b7cfcd4..ff79114 100644 --- a/cli/tests/commands-lakehouse.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -11,7 +11,7 @@ import { } from "@/lib/lakehouse/args.ts"; import { parseQueryResultFormat } from "@/lib/lakehouse-client.ts"; import { buildSchemaStatement, schemaCommand } from "@/commands/schema/index.ts"; -import { formatSchemaTree } from "@/features/lakehouse/schema/render.ts"; +import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; import { setCliContext } from "@/context.ts"; import { forceNoTerminalColorForTests, @@ -19,7 +19,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-support/terminal-test-utils.ts"; describe("parseQueryDisplayOptions", () => { test("parses human layout values", () => { diff --git a/cli/tests/lakehouse.test.ts b/cli/src/lib/lakehouse/query.test.ts similarity index 99% rename from cli/tests/lakehouse.test.ts rename to cli/src/lib/lakehouse/query.test.ts index c6be9a0..e2592ce 100644 --- a/cli/tests/lakehouse.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -18,7 +18,7 @@ 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"; -import { runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; +import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; import { normalizeQueryInvocatorRawArgs } from "@/commands/query/index.ts"; const SAMPLE_NDJSON = [ diff --git a/cli/tests/management-output.test.ts b/cli/src/lib/management-output.test.ts similarity index 100% rename from cli/tests/management-output.test.ts rename to cli/src/lib/management-output.test.ts diff --git a/cli/tests/management-payloads.test.ts b/cli/src/lib/management-payloads.test.ts similarity index 98% rename from cli/tests/management-payloads.test.ts rename to cli/src/lib/management-payloads.test.ts index 734c10d..2872b5c 100644 --- a/cli/tests/management-payloads.test.ts +++ b/cli/src/lib/management-payloads.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; -import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/lib/api-body.ts"; +import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/commands/api/lib/body.ts"; import { CliError, ParseError } from "@/lib/errors.ts"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/cli/tests/management-transport.test.ts b/cli/src/lib/management-transport.test.ts similarity index 100% rename from cli/tests/management-transport.test.ts rename to cli/src/lib/management-transport.test.ts 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/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/tests/oauth.test.ts b/cli/src/lib/oauth-flow.test.ts similarity index 99% rename from cli/tests/oauth.test.ts rename to cli/src/lib/oauth-flow.test.ts index 0589f76..c472251 100644 --- a/cli/tests/oauth.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -8,7 +8,7 @@ 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 { createEmptyProfile } from "@/lib/profile/model.ts"; import { secretSet } from "@/lib/secrets.ts"; const profileName = "default"; @@ -162,7 +162,7 @@ import { sameWhoamiContext, storeLoginProfileMetadata, } from "@/commands/login/index.ts"; -import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/lib/profile/model.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; import { getOutputSink } from "@/lib/runtime.ts"; diff --git a/cli/tests/oauth-refresh.test.ts b/cli/src/lib/oauth-profile.test.ts similarity index 100% rename from cli/tests/oauth-refresh.test.ts rename to cli/src/lib/oauth-profile.test.ts 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/tests/pager.test.ts b/cli/src/lib/pager.test.ts similarity index 100% rename from cli/tests/pager.test.ts rename to cli/src/lib/pager.test.ts 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.ts b/cli/src/lib/profile-configure-core.ts index b913493..c3366a9 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, diff --git a/cli/tests/configure-credential-status.test.ts b/cli/src/lib/profile-configure-credential-status.test.ts similarity index 98% rename from cli/tests/configure-credential-status.test.ts rename to cli/src/lib/profile-configure-credential-status.test.ts index 47c15a1..346343f 100644 --- a/cli/tests/configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -7,12 +7,12 @@ import { configureCredentialStatus, lakehousePlaneStatusDetail, managementPlaneStatusDetail, -} from "@/features/profile/model.ts"; -import { buildConfigureShowView } from "@/features/profile/views.ts"; +} from "@/lib/profile/model.ts"; +import { buildConfigureShowView } from "@/lib/profile/views.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"; diff --git a/cli/tests/configure-data-plane-url.test.ts b/cli/src/lib/profile-configure-data-plane.test.ts similarity index 100% rename from cli/tests/configure-data-plane-url.test.ts rename to cli/src/lib/profile-configure-data-plane.test.ts diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index 38e79ba..0b7688b 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -14,7 +14,7 @@ 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"; diff --git a/cli/tests/profile-configure.test.ts b/cli/src/lib/profile-configure.test.ts similarity index 100% rename from cli/tests/profile-configure.test.ts rename to cli/src/lib/profile-configure.test.ts diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index 18022c4..cc4f423 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -4,7 +4,7 @@ import { 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"; @@ -21,7 +21,7 @@ import { type ConfigureWizardScope, } from "@/lib/profile-configure-interactive.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"; diff --git a/cli/tests/profile-status.test.ts b/cli/src/lib/profile-status.test.ts similarity index 100% rename from cli/tests/profile-status.test.ts rename to cli/src/lib/profile-status.test.ts diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 3712e8c..3a5e84e 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -3,14 +3,14 @@ import { configGet } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; import { buildLakehouseVerifyRequest } from "@/lib/lakehouse/query.ts"; -import type { WhoamiResponse } from "@/features/management/model.ts"; -import { formatWhoamiPrincipalLine } from "@/features/management/render.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 { configureCredentialStatus } from "@/lib/profile/model.ts"; export type ConfigureAuthPlane = "management" | "lakehouse"; diff --git a/cli/tests/active-context.test.ts b/cli/src/lib/profile/active-context.test.ts similarity index 98% rename from cli/tests/active-context.test.ts rename to cli/src/lib/profile/active-context.test.ts index 68a7a16..1886b11 100644 --- a/cli/tests/active-context.test.ts +++ b/cli/src/lib/profile/active-context.test.ts @@ -7,16 +7,16 @@ import { activeContextToJson, buildActiveContext, withAuthenticatedIdentity, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; import { buildActiveContextDetailsView, buildActiveContextSummaryView, -} from "@/features/profile/views.ts"; +} from "@/lib/profile/views.ts"; import { formatActiveContextDetails, formatActiveContextSummary, tryFormatActiveContextSummary, -} from "@/features/profile/render.ts"; +} from "@/lib/profile/render.ts"; import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; diff --git a/cli/tests/profile.test.ts b/cli/src/lib/profile/model.test.ts similarity index 98% rename from cli/tests/profile.test.ts rename to cli/src/lib/profile/model.test.ts index 75b57c3..2a6f8da 100644 --- a/cli/tests/profile.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -25,7 +25,7 @@ import { resolveWorkingProfile, setActiveProfile, updateProfile, -} from "@/features/profile/model.ts"; +} from "@/lib/profile/model.ts"; import { promptProfileSwitch } from "@/commands/profile/index.ts"; import { ConfigurationError, CliError } from "@/lib/errors.ts"; import { @@ -35,11 +35,14 @@ import { buildProfileShellExportView, profileStatusToJson, type ProfileStatusResult, -} from "@/features/profile/views.ts"; -import { formatProfileInspect, formatProfileStatus } from "@/features/profile/render.ts"; +} from "@/lib/profile/views.ts"; +import { formatProfileInspect, formatProfileStatus } from "@/lib/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 { + createCliTestRuntime, + runCommandWithTestRuntime, +} from "@/test-support/cli-test-runtime.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; const profileName = "default"; diff --git a/cli/src/features/profile/model.ts b/cli/src/lib/profile/model.ts similarity index 99% rename from cli/src/features/profile/model.ts rename to cli/src/lib/profile/model.ts index e891d68..491d7f4 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -9,7 +9,7 @@ import { 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 type { WhoamiResponse } from "@/lib/management/model.ts"; import { moveProfileSecrets, secretDelete, diff --git a/cli/src/features/profile/render.ts b/cli/src/lib/profile/render.ts similarity index 97% rename from cli/src/features/profile/render.ts rename to cli/src/lib/profile/render.ts index 7a4fe51..6829c0a 100644 --- a/cli/src/features/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -6,14 +6,14 @@ import { 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"; diff --git a/cli/src/features/profile/views.ts b/cli/src/lib/profile/views.ts similarity index 99% rename from cli/src/features/profile/views.ts rename to cli/src/lib/profile/views.ts index 3a64dc4..e1943af 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -26,8 +26,8 @@ import type { 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/model.ts"; import type { ShellExportView } from "@/ui/shell/model.ts"; const ACTIVE_PROFILE_MARK = "✓"; 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 99% rename from cli/tests/query-format.test.ts rename to cli/src/lib/query-format.test.ts index 7a6701f..f1dc22f 100644 --- a/cli/tests/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -22,7 +22,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-support/terminal-test-utils.ts"; let terminalState: TerminalTestState | undefined; 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/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/tests/updater.test.ts b/cli/src/lib/updater.test.ts similarity index 99% rename from cli/tests/updater.test.ts rename to cli/src/lib/updater.test.ts index 3c6fb6d..9c634d9 100644 --- a/cli/tests/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -45,7 +45,7 @@ import { import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; let testHome = ""; -const packageJsonPath = resolve(import.meta.dir, "../package.json"); +const packageJsonPath = resolve(import.meta.dir, "../../package.json"); const UPDATE_TEST_VERSION = "1.2.3"; const LINUX_X64_ASSET = "altertable-linux-x64"; const LINUX_X64_DOWNLOAD_URL = `https://download.example/${LINUX_X64_ASSET}`; diff --git a/cli/tests/usage.test.ts b/cli/src/lib/usage.test.ts similarity index 97% rename from cli/tests/usage.test.ts rename to cli/src/lib/usage.test.ts index 86b6bdd..90d4a9f 100644 --- a/cli/tests/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -4,7 +4,7 @@ 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"; @@ -16,7 +16,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; +} from "@/test-support/terminal-test-utils.ts"; describe("renderAltertableUsage", () => { test("groups root commands before global flags in a custom help panel", async () => { @@ -83,7 +83,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 +139,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" } }, diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 3d63cc9..643239e 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 { 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"; diff --git a/cli/tests/cli-test-runtime.ts b/cli/src/test-support/cli-test-runtime.ts similarity index 100% rename from cli/tests/cli-test-runtime.ts rename to cli/src/test-support/cli-test-runtime.ts diff --git a/cli/tests/terminal-test-utils.ts b/cli/src/test-support/terminal-test-utils.ts similarity index 100% rename from cli/tests/terminal-test-utils.ts rename to cli/src/test-support/terminal-test-utils.ts diff --git a/cli/tests/test-utils.ts b/cli/src/test-support/test-utils.ts similarity index 100% rename from cli/tests/test-utils.ts rename to cli/src/test-support/test-utils.ts 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/tests/terminal-style.test.ts b/cli/src/ui/terminal/styles.test.ts similarity index 100% rename from cli/tests/terminal-style.test.ts rename to cli/src/ui/terminal/styles.test.ts diff --git a/cli/tests/table-format.test.ts b/cli/src/ui/terminal/table.test.ts similarity index 99% rename from cli/tests/table-format.test.ts rename to cli/src/ui/terminal/table.test.ts index 2f960fb..91db3ee 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 { renderApiRoutesTable, renderApiRoutesTableSection } from "@/commands/api/lib/render.ts"; import { setTerminalColorMode, getVisibleTextWidth } from "@/ui/terminal/styles.ts"; import { span } from "@/ui/document.ts"; 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"] } From 99999f0f03588e0b5ba9d7cc3dc7a0b3fc14d789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 12:08:46 +0200 Subject: [PATCH 05/22] docs(cli): codify the colocated command architecture Document direct command execution, declarative HTTP requests as the dry-run seam, narrow library ownership, centralized registration, and colocated unit tests. --- AGENTS.md | 2 +- CONTRIBUTING.md | 20 +++++----- DEVELOPMENT.md | 2 +- cli/AGENTS.md | 104 ++++++++++++++++++++++++------------------------ 4 files changed, 66 insertions(+), 62 deletions(-) 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..dbd9601 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,24 +32,26 @@ 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`. +- Define commands with `defineCommand` and 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..c071956 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -41,7 +41,8 @@ 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). +- **`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. Accepts `sink` as the last argument; lib callers may omit it (defaults to `getOutputSink()`). - Helpers: `writeManagementOutput`, `writeLakehouseOutput`, `writeJsonOrRaw` (thin wrappers around `writeCommandOutput`). - **When to use `sink` directly**: bespoke output (custom tables, metadata messages) — `sink.writeJson`, `sink.writeHuman`, `sink.writeMetadata`. @@ -78,39 +79,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/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/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/` | Shared management identity and catalog presentation | +| `lib/catalogs/rows.ts` | Catalog rows shared by `catalogs` and `duckdb` | +| `commands/catalogs/lib/requests.ts` | Declarative catalog request builders and execution | +| `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 +137,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 +176,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: `cli/src/lib/lakehouse/query.test.ts` sets `ALTERTABLE_MOCK_HTTP_FILE`. Root black-box tests use `tests/helpers.ts`. ## Invariants (do not break) @@ -200,7 +202,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 From 16a18ee2c2c657be3fb2c012b93af81f30de933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 13:22:42 +0200 Subject: [PATCH 06/22] refactor(cli): standardize command module boundaries Declare each command immediately after imports so reviewers see its public shape before implementation details. Keep Citty behind lib/command.ts, derive delegated command names from the registered tree, and document the boundary for future commands. --- CONTRIBUTING.md | 5 +- cli/AGENTS.md | 3 + cli/src/cli.ts | 13 ++- cli/src/commands/api/index.test.ts | 11 +-- cli/src/commands/api/index.ts | 52 +++++++----- cli/src/commands/api/lib/command.ts | 18 +---- cli/src/commands/api/routes.ts | 37 ++++----- cli/src/commands/api/spec.ts | 34 ++++---- cli/src/commands/completion/bash.ts | 6 +- cli/src/commands/completion/fish.ts | 6 +- cli/src/commands/completion/generate.ts | 5 +- cli/src/commands/completion/index.test.ts | 16 ++-- cli/src/commands/completion/index.ts | 5 +- cli/src/commands/completion/install.ts | 5 +- cli/src/commands/completion/lib/completion.ts | 6 +- .../commands/completion/lib/shell-command.ts | 7 +- cli/src/commands/completion/lib/spec.test.ts | 8 +- cli/src/commands/completion/lib/spec.ts | 21 +++-- cli/src/commands/completion/zsh.ts | 6 +- cli/src/commands/duckdb/index.ts | 36 ++++----- cli/src/commands/index.ts | 11 +-- cli/src/commands/login/index.ts | 80 +++++++++---------- cli/src/commands/profile/index.ts | 30 +++---- cli/src/commands/query/index.ts | 34 ++++---- cli/src/commands/update/index.ts | 58 +++++++------- cli/src/lib/command-delegation.test.ts | 6 +- cli/src/lib/command-delegation.ts | 8 +- cli/src/lib/command.ts | 9 +++ cli/src/lib/management-output.ts | 4 +- cli/src/lib/profile-configure.ts | 6 +- cli/src/lib/updater.test.ts | 6 +- cli/src/lib/usage.ts | 48 +++++------ cli/src/test-support/cli-test-runtime.ts | 4 +- 33 files changed, 304 insertions(+), 300 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd9601..98feb9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,10 @@ Use these boundaries when adding or changing 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. -- Define commands with `defineCommand` and register top-level commands in `cli/src/commands/index.ts`. +- 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. 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. diff --git a/cli/AGENTS.md b/cli/AGENTS.md index c071956..e6871db 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -51,6 +51,9 @@ bun test "$PWD"/tests/integration.e2e.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 diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 2ef1f48..383417f 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, @@ -21,7 +20,7 @@ import { renderCliErrorJson, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; -import { defineRootCommand } from "@/lib/command.ts"; +import { defineArgs, defineRootCommand, runCommandTree, type Command } from "@/lib/command.ts"; import { resolveSubCommandForUsage, showAltertableUsage, @@ -41,7 +40,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: { @@ -64,7 +63,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); @@ -72,8 +71,8 @@ export function resolveTopLevelCommandName(rawArgs: readonly string[]): string | return findFirstPositionalToken(rawArgs, { valueFlags: ROOT_VALUE_FLAGS })?.value; } -export function buildMainCommand(): CommandDef { - let mainCommand: CommandDef; +export function buildMainCommand(): Command { + let mainCommand: Command; const topLevelCommands = buildTopLevelCommands(() => mainCommand); @@ -150,7 +149,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/index.test.ts b/cli/src/commands/api/index.test.ts index 17cf381..4be2aca 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -1,6 +1,4 @@ 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"; @@ -16,6 +14,7 @@ import { setCliContext } from "@/context.ts"; import { buildCompletionSpec, flattenTopLevelNames } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; +import { runCommandTree, type Command, type CommandArgs } from "@/lib/command.ts"; function createCaptureSink(json: boolean) { const stdout: string[] = []; @@ -89,7 +88,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); } @@ -133,7 +134,7 @@ 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(); }); @@ -141,7 +142,7 @@ describe("api", () => { test("normalizeApiInvocatorRawArgs inserts -- before endpoint paths", () => { const rootArgs = { profile: { type: "string", description: "Use a named profile" }, - } satisfies ArgsDef; + } satisfies CommandArgs; 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 index 7f9c788..f6a076a 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -1,14 +1,12 @@ -import type { ArgsDef } from "citty"; +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 { - API_HTTP_BASE_ARGS, - isDelegatedApiCommand, - isApiCommandName, - resolveApiCommandRequest, -} from "@/commands/api/lib/command.ts"; -import { normalizePassthroughCommandRawArgs } from "@/lib/command-delegation.ts"; + 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"; @@ -18,21 +16,6 @@ import { apiPutCommand } from "@/commands/api/put.ts"; import { apiSpecCommand } from "@/commands/api/spec.ts"; import { apiRoutesCommand } from "@/commands/api/routes.ts"; -export { runApiSpecCommand } from "@/commands/api/spec.ts"; -export { runApiRoutesCommand } from "@/commands/api/routes.ts"; - -export function normalizeApiInvocatorRawArgs( - rawArgs: readonly string[], - rootArgs: ArgsDef = {}, -): string[] { - return normalizePassthroughCommandRawArgs(rawArgs, { - commandName: "api", - rootArgs, - commandValueFlags: API_VALUE_FLAGS, - isReservedOperand: isApiCommandName, - }); -} - export const apiCommand = defineCommand({ meta: { name: "api", @@ -62,3 +45,28 @@ export const apiCommand = defineCommand({ 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 }); +} + +export { runApiSpecCommand } from "@/commands/api/spec.ts"; +export { runApiRoutesCommand } from "@/commands/api/routes.ts"; diff --git a/cli/src/commands/api/lib/command.ts b/cli/src/commands/api/lib/command.ts index db6c185..cf7b7d1 100644 --- a/cli/src/commands/api/lib/command.ts +++ b/cli/src/commands/api/lib/command.ts @@ -1,17 +1,15 @@ -import type { ArgsDef } from "citty"; 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 { defineCommand } from "@/lib/command.ts"; +import { defineArgs, defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; -import { isDelegatedSubCommand, valueFlagsFor } from "@/lib/command-delegation.ts"; +import { valueFlagsFor } from "@/lib/command-delegation.ts"; import { withManagementFormatArg } from "@/lib/management-output.ts"; import { readArgvFlagValue } from "@/lib/timeout-args.ts"; export const HTTP_METHOD_NAMES = ["GET", "POST", "PATCH", "DELETE", "PUT"] as const; -const API_COMMAND_NAMES = new Set(["spec", "routes", ...HTTP_METHOD_NAMES]); -export const API_HTTP_BASE_ARGS = { +export const API_HTTP_BASE_ARGS = defineArgs({ method: { type: "enum", alias: "X", @@ -36,19 +34,11 @@ export const API_HTTP_BASE_ARGS = { 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; +}); export const API_VALUE_FLAGS = valueFlagsFor(API_HTTP_BASE_ARGS); export const API_HTTP_ARGS = withManagementFormatArg(API_HTTP_BASE_ARGS); -export function isApiCommandName(value: string): boolean { - return API_COMMAND_NAMES.has(value) || API_COMMAND_NAMES.has(value.toUpperCase()); -} - -export function isDelegatedApiCommand(rawArgs: readonly string[]): boolean { - return isDelegatedSubCommand(rawArgs, isApiCommandName, { valueFlags: API_VALUE_FLAGS }); -} - export function resolveApiCommandRequest( args: Record, rawArgs: string[], diff --git a/cli/src/commands/api/routes.ts b/cli/src/commands/api/routes.ts index 55e9797..343a94c 100644 --- a/cli/src/commands/api/routes.ts +++ b/cli/src/commands/api/routes.ts @@ -5,24 +5,6 @@ import { apiOperationDetails, apiOperationsJson, apiRouteRows } from "@/commands import { formatApiOperationDetails, formatApiRoutes } from "@/commands/api/lib/render.ts"; import type { OutputSink } from "@/lib/runtime.ts"; -export async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { - await writeCommandOutput( - operationId - ? { - kind: "normalized", - data: apiOperationDetails(operationId), - humanText: formatApiOperationDetails(apiOperationDetails(operationId)), - } - : { - kind: "normalized", - data: apiOperationsJson(), - humanText: formatApiRoutes(apiRouteRows()), - pageHumanText: true, - }, - sink, - ); -} - export const apiRoutesCommand = defineCommand({ meta: { name: "routes", @@ -40,3 +22,22 @@ export const apiRoutesCommand = defineCommand({ await runApiRoutesCommand(sink, optionalStringArg(args, "operation")); }, }); + +export 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 index 1bc0123..48cfabc 100644 --- a/cli/src/commands/api/spec.ts +++ b/cli/src/commands/api/spec.ts @@ -8,23 +8,6 @@ import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import type { OutputSink } from "@/lib/runtime.ts"; -export 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, - ); -} - export const apiSpecCommand = defineCommand({ meta: { name: "spec", @@ -44,3 +27,20 @@ export const apiSpecCommand = defineCommand({ await runApiSpecCommand(sink, { format: optionalStringArg(args, "format") }); }, }); + +export 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/completion/bash.ts b/cli/src/commands/completion/bash.ts index 82ea574..68d5d3b 100644 --- a/cli/src/commands/completion/bash.ts +++ b/cli/src/commands/completion/bash.ts @@ -1,14 +1,14 @@ -import type { CommandDef } from "citty"; +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): CommandDef { +export function createBashCompletionCommand(getRootCommand: GetRootCommand): Command { return createShellCompletionCommand("bash", getRootCommand); } -export function createBashInstallCommand(getRootCommand: GetRootCommand): CommandDef { +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 index aad7ee9..e9cba58 100644 --- a/cli/src/commands/completion/fish.ts +++ b/cli/src/commands/completion/fish.ts @@ -1,14 +1,14 @@ -import type { CommandDef } from "citty"; +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): CommandDef { +export function createFishCompletionCommand(getRootCommand: GetRootCommand): Command { return createShellCompletionCommand("fish", getRootCommand); } -export function createFishInstallCommand(getRootCommand: GetRootCommand): CommandDef { +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 index 1e4998d..d05f690 100644 --- a/cli/src/commands/completion/generate.ts +++ b/cli/src/commands/completion/generate.ts @@ -1,11 +1,10 @@ -import type { CommandDef } from "citty"; -import { defineCommand } from "@/lib/command.ts"; +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): CommandDef { +export function createGenerateCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ meta: { name: "generate", diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index 5ff75f4..1d2749a 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -2,7 +2,7 @@ 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 type { Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { createCompletionCommand } from "@/commands/completion/index.ts"; import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; @@ -14,7 +14,7 @@ const TERMINAL_CONTROL_PATTERN = new RegExp( "g", ); -const minimalRootCommand: CommandDef = { +const minimalRootCommand: Command = { meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON responses" }, @@ -83,7 +83,7 @@ async function captureCompletionOutput( } async function runCompletion( - getRootCommand: () => CommandDef, + getRootCommand: () => Command, shell?: string, prompts?: ConfigurePrompts, ): Promise { @@ -92,7 +92,7 @@ async function runCompletion( const command = shell === undefined || !supportedShells.has(shell) ? completionCommand - : (completionCommand.subCommands as Record | undefined)?.[shell]; + : (completionCommand.subCommands as Record | undefined)?.[shell]; if (!command?.run) { throw new Error(`missing completion command for ${shell ?? "detected shell"}`); } @@ -105,9 +105,9 @@ async function runCompletion( async function runCompletionGenerate(shell: string): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); - const generateCommand = (completionCommand.subCommands as Record | undefined) + const generateCommand = (completionCommand.subCommands as Record | undefined) ?.generate; - const command = (generateCommand?.subCommands as Record | undefined)?.[shell]; + const command = (generateCommand?.subCommands as Record | undefined)?.[shell]; if (!command?.run) { throw new Error("missing completion generate command"); } @@ -142,12 +142,12 @@ 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) + const installCommand = (completionCommand.subCommands as Record | undefined) ?.install; const command = shell === undefined ? installCommand - : (installCommand?.subCommands as Record | undefined)?.[shell]; + : (installCommand?.subCommands as Record | undefined)?.[shell]; if (!command?.run) { throw new Error("missing completion install command"); } diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts index 98fe1d7..3deca14 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -1,5 +1,4 @@ -import type { CommandDef } from "citty"; -import { defineCommand } from "@/lib/command.ts"; +import { defineCommand, type Command } from "@/lib/command.ts"; import { defaultConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; import { createBashCompletionCommand } from "@/commands/completion/bash.ts"; import { createFishCompletionCommand } from "@/commands/completion/fish.ts"; @@ -23,7 +22,7 @@ import { export function createCompletionCommand( getRootCommand: GetRootCommand, options: CompletionCommandOptions = {}, -): CommandDef { +): Command { const prompts = options.prompts ?? defaultConfigurePrompts; return defineCommand({ meta: { diff --git a/cli/src/commands/completion/install.ts b/cli/src/commands/completion/install.ts index 4c29899..280c775 100644 --- a/cli/src/commands/completion/install.ts +++ b/cli/src/commands/completion/install.ts @@ -1,5 +1,4 @@ -import type { CommandDef } from "citty"; -import { defineCommand } from "@/lib/command.ts"; +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"; @@ -12,7 +11,7 @@ import { type GetRootCommand, } from "@/commands/completion/lib/completion.ts"; -export function createInstallCommand(getRootCommand: GetRootCommand): CommandDef { +export function createInstallCommand(getRootCommand: GetRootCommand): Command { return defineCommand({ meta: { name: "install", diff --git a/cli/src/commands/completion/lib/completion.ts b/cli/src/commands/completion/lib/completion.ts index ad635af..852e622 100644 --- a/cli/src/commands/completion/lib/completion.ts +++ b/cli/src/commands/completion/lib/completion.ts @@ -1,7 +1,7 @@ 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 type { Command } from "@/lib/command.ts"; import { CliError } from "@/lib/errors.ts"; import { readEnv } from "@/lib/env.ts"; import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; @@ -14,7 +14,7 @@ import { formatZshCompletion, } from "@/commands/completion/lib/format.ts"; -export type GetRootCommand = () => CommandDef; +export type GetRootCommand = () => Command; export type SupportedShell = "bash" | "zsh" | "fish"; export type CompletionRootInput = | { kind: "help" } @@ -143,7 +143,7 @@ function upsertManagedBlock(existing: string, block: string): string { return prefix ? `${prefix}\n\n${block}` : block; } -export function formatCompletionScript(shell: SupportedShell, rootCommand: CommandDef): string { +export function formatCompletionScript(shell: SupportedShell, rootCommand: Command): string { const spec = buildCompletionSpec(rootCommand); if (shell === "bash") return formatBashCompletion(spec); if (shell === "zsh") return formatZshCompletion(spec); diff --git a/cli/src/commands/completion/lib/shell-command.ts b/cli/src/commands/completion/lib/shell-command.ts index d8f034f..26e8ae4 100644 --- a/cli/src/commands/completion/lib/shell-command.ts +++ b/cli/src/commands/completion/lib/shell-command.ts @@ -1,5 +1,4 @@ -import type { CommandDef } from "citty"; -import { defineCommand } from "@/lib/command.ts"; +import { defineCommand, type Command } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { formatCompletionScript, @@ -12,7 +11,7 @@ import { export function createShellCompletionCommand( shell: SupportedShell, getRootCommand: GetRootCommand, -): CommandDef { +): Command { return defineCommand({ meta: { name: shell, description: `Generate ${shell} completion script.` }, async run({ sink }) { @@ -27,7 +26,7 @@ export function createShellCompletionCommand( export function createInstallShellCommand( shell: SupportedShell, getRootCommand: GetRootCommand, -): CommandDef { +): Command { return defineCommand({ meta: { name: shell, description: `Install ${shell} completion.` }, args: { diff --git a/cli/src/commands/completion/lib/spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts index 4c8ddb4..9e54395 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { CommandDef } from "citty"; +import type { Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { buildCompletionSpec, @@ -22,7 +22,7 @@ function findNode(spec: ReturnType, name: string) { describe("buildCompletionSpec", () => { test("walks a minimal fake tree", () => { - const root: CommandDef = { + const root: Command = { meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON" }, @@ -53,7 +53,7 @@ describe("buildCompletionSpec", () => { }); test("skips nested commands without meta.name", () => { - const root: CommandDef = { + const root: Command = { subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { description: "no name" } }, @@ -65,7 +65,7 @@ describe("buildCompletionSpec", () => { }); test("skips commands marked hidden", () => { - const root: CommandDef = { + const root: Command = { subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { name: "hidden", hidden: true } }, diff --git a/cli/src/commands/completion/lib/spec.ts b/cli/src/commands/completion/lib/spec.ts index c5568fe..9582ed0 100644 --- a/cli/src/commands/completion/lib/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; } diff --git a/cli/src/commands/completion/zsh.ts b/cli/src/commands/completion/zsh.ts index 34dbcf0..87fb1c4 100644 --- a/cli/src/commands/completion/zsh.ts +++ b/cli/src/commands/completion/zsh.ts @@ -1,14 +1,14 @@ -import type { CommandDef } from "citty"; +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): CommandDef { +export function createZshCompletionCommand(getRootCommand: GetRootCommand): Command { return createShellCompletionCommand("zsh", getRootCommand); } -export function createZshInstallCommand(getRootCommand: GetRootCommand): CommandDef { +export function createZshInstallCommand(getRootCommand: GetRootCommand): Command { return createInstallShellCommand("zsh", getRootCommand); } diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index 3fe4613..e3ea357 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -12,6 +12,24 @@ import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.ts" import type { CatalogRow } from "@/lib/management/model.ts"; import type { ExecutionContext } from "@/lib/execution-context.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."; function escapeSql(value: string): string { @@ -90,21 +108,3 @@ async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promi throw new ConfigurationError(`Failed to launch duckdb: ${result.error.message}`); } } - -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), -}); diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 9cf2367..50644d1 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -1,4 +1,4 @@ -import type { ArgsDef, CommandDef } from "citty"; +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"; @@ -13,9 +13,7 @@ import { apiCommand, normalizeApiInvocatorRawArgs } from "@/commands/api/index.t import { createCompletionCommand } from "@/commands/completion/index.ts"; import { updateCommand } from "@/commands/update/index.ts"; -export function buildTopLevelCommands( - getMainCommand: () => CommandDef, -): Record { +export function buildTopLevelCommands(getMainCommand: () => Command): Record { return { login: loginCommand, logout: logoutCommand, @@ -33,6 +31,9 @@ export function buildTopLevelCommands( }; } -export function normalizeCommandRawArgs(rawArgs: readonly string[], rootArgs: ArgsDef): string[] { +export function normalizeCommandRawArgs( + rawArgs: readonly string[], + rootArgs: CommandArgs, +): string[] { return normalizeQueryInvocatorRawArgs(normalizeApiInvocatorRawArgs(rawArgs, rootArgs), rootArgs); } diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index 668abac..33d2f1c 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -25,6 +25,37 @@ import { readEnv, setEnv } from "@/lib/env.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; } @@ -49,6 +80,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; @@ -216,49 +254,11 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { } 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 = 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), -}); diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts index 7ea865c..06329d7 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -18,21 +18,6 @@ import { profileStatusCommand } from "@/commands/profile/status.ts"; import { profileSwitchCommand } from "@/commands/profile/switch.ts"; import { profileUseCommand } from "@/commands/profile/use.ts"; -export { promptProfileSwitch } from "@/commands/profile/lib/profile.ts"; - -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; -} - export const profileCommand = defineCommand({ meta: { name: "profile", @@ -83,3 +68,18 @@ export const profileCommand = defineCommand({ 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; +} + +export { promptProfileSwitch } from "@/commands/profile/lib/profile.ts"; diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index 57b291f..59829c7 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,4 +1,4 @@ -import type { ArgsDef } from "citty"; +import type { CommandArgs } from "@/lib/command.ts"; import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; import { defineCommand } from "@/lib/command.ts"; import { queryRunArgs } from "@/lib/lakehouse/args.ts"; @@ -6,22 +6,6 @@ import { queryRunCommand } from "@/commands/query/run.ts"; import { queryShowCommand } from "@/commands/query/show.ts"; import { queryCancelCommand } from "@/commands/query/cancel.ts"; -const QUERY_SUBCOMMAND_NAMES = new Set(["run", "show", "cancel"]); -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 const queryCommand = defineCommand({ meta: { name: "query", @@ -41,3 +25,19 @@ export const queryCommand = defineCommand({ 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/update/index.ts b/cli/src/commands/update/index.ts index 4b60c2b..74b9a8d 100644 --- a/cli/src/commands/update/index.ts +++ b/cli/src/commands/update/index.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 = 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); - }, -}); diff --git a/cli/src/lib/command-delegation.test.ts b/cli/src/lib/command-delegation.test.ts index 26b506a..e371c4c 100644 --- a/cli/src/lib/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 type { CommandArgs } from "@/lib/command.ts"; import { findFirstPositionalToken, isDelegatedSubCommand, @@ -10,13 +10,13 @@ import { const rootArgs = { profile: { type: "string", description: "Use a named profile" }, json: { type: "boolean", description: "JSON output" }, -} satisfies ArgsDef; +} satisfies CommandArgs; const passthroughArgs = { method: { type: "enum", alias: "X", options: ["GET", "POST"] }, field: { type: "string", alias: "f" }, verbose: { type: "boolean" }, -} satisfies ArgsDef; +} satisfies CommandArgs; 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.ts b/cli/src/lib/command.ts index 4d8e2a4..758f045 100644 --- a/cli/src/lib/command.ts +++ b/cli/src/lib/command.ts @@ -1,8 +1,13 @@ 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"; export type AltertableCommandMeta = CommandMeta & { @@ -30,6 +35,10 @@ 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(); let execution: ExecutionContext | undefined; diff --git a/cli/src/lib/management-output.ts b/cli/src/lib/management-output.ts index b6c12d8..b2167b6 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,4 +1,4 @@ -import type { ArgsDef } from "citty"; +import type { CommandArgs } from "@/lib/command.ts"; import { isJsonOutput } from "@/context.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { redactSensitiveJsonValue } from "@/lib/redact.ts"; @@ -101,7 +101,7 @@ export function renderManagementOutput(body: string, format: ManagementOutputFor return renderTabularOutput(managementDataToTabularResult(data), format); } -export function withManagementFormatArg( +export function withManagementFormatArg( args: T, ): T & typeof MANAGEMENT_FORMAT_ARG { return { diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index cc4f423..91a1566 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -1,4 +1,4 @@ -import type { ArgsDef } from "citty"; +import { defineArgs } from "@/lib/command.ts"; import { configureRunSet, withConfigureProfileContext, @@ -195,7 +195,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 +225,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/src/lib/updater.test.ts b/cli/src/lib/updater.test.ts index 9c634d9..828df7e 100644 --- a/cli/src/lib/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,11 @@ 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 { resolveProcessExecutablePath } from "@/lib/executable-path.ts"; import { checkForUpdate, @@ -136,7 +136,7 @@ async function runUpdateCommand(rawArgs: string[]): Promise { - await runCommand(buildMainCommand(), { rawArgs }); + await runCommandTree(buildMainCommand(), { rawArgs }); }); return output; diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 643239e..c91e554 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,4 +1,4 @@ -import type { ArgDef, ArgsDef, CommandDef } from "citty"; +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"; @@ -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/test-support/cli-test-runtime.ts b/cli/src/test-support/cli-test-runtime.ts index 2a06a2f..7fb1a3c 100644 --- a/cli/src/test-support/cli-test-runtime.ts +++ b/cli/src/test-support/cli-test-runtime.ts @@ -1,6 +1,6 @@ -import { runCommand } from "citty"; import { buildMainCommand } from "@/cli.ts"; import type { CliContext } from "@/context.ts"; +import { runCommandTree } from "@/lib/command.ts"; import { createCliRuntime, runWithCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; export function createCliTestRuntime( @@ -20,6 +20,6 @@ export async function runCommandWithTestRuntime( context: CliContext = { debug: false, json: true, agent: false }, ): Promise { await runWithCliRuntime(createCliTestRuntime(context), () => - runCommand(buildMainCommand(), { rawArgs }), + runCommandTree(buildMainCommand(), { rawArgs }), ); } From b6fe6dd442a66c5bae7c65dce29b31eaa48823a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 13:23:13 +0200 Subject: [PATCH 07/22] refactor(cli): derive shared lakehouse command data Use shared table, file, timeout, and format argument definitions for append, upload, upsert, and schema. Move append-only parsing and schema tests to their command families, share file inspection without cross-command imports, and remove stale duplicate API body tests. --- cli/src/commands/append/lib/args.ts | 15 +-- cli/src/commands/append/lib/data.test.ts | 19 +++ cli/src/commands/append/lib/data.ts | 25 ++++ cli/src/commands/append/run.ts | 2 +- cli/src/commands/schema/index.test.ts | 22 ++++ cli/src/commands/schema/index.ts | 83 +++++------- cli/src/commands/schema/lib/args.ts | 11 ++ cli/src/commands/schema/lib/render.test.ts | 104 +++++++++++++++ cli/src/commands/upload/index.ts | 46 +------ cli/src/commands/upload/lib/args.ts | 14 ++ cli/src/commands/upsert/index.ts | 26 +--- cli/src/commands/upsert/lib/args.ts | 11 ++ cli/src/lib/lakehouse/args.test.ts | 144 +-------------------- cli/src/lib/lakehouse/args.ts | 58 ++++----- cli/src/lib/lakehouse/file.ts | 13 ++ cli/src/lib/management-payloads.test.ts | 49 ------- 16 files changed, 297 insertions(+), 345 deletions(-) create mode 100644 cli/src/commands/append/lib/data.test.ts create mode 100644 cli/src/commands/append/lib/data.ts create mode 100644 cli/src/commands/schema/index.test.ts create mode 100644 cli/src/commands/schema/lib/args.ts create mode 100644 cli/src/commands/schema/lib/render.test.ts create mode 100644 cli/src/commands/upload/lib/args.ts create mode 100644 cli/src/commands/upsert/lib/args.ts create mode 100644 cli/src/lib/lakehouse/file.ts diff --git a/cli/src/commands/append/lib/args.ts b/cli/src/commands/append/lib/args.ts index bf9c48a..96e1b5c 100644 --- a/cli/src/commands/append/lib/args.ts +++ b/cli/src/commands/append/lib/args.ts @@ -1,20 +1,19 @@ -import type { ArgsDef } from "citty"; +import { defineArgs } from "@/lib/command.ts"; +import { lakehouseTableArgs } from "@/lib/lakehouse/args.ts"; -export 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 }, +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", }, -} satisfies ArgsDef; +}); -export const appendGroupArgs = { +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 }, -} satisfies ArgsDef; +}); 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 index f9e0e29..aaabfb5 100644 --- a/cli/src/commands/append/run.ts +++ b/cli/src/commands/append/run.ts @@ -1,5 +1,5 @@ import { appendRunArgs } from "@/commands/append/lib/args.ts"; -import { parseAppendJsonContent } from "@/lib/lakehouse/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"; diff --git a/cli/src/commands/schema/index.test.ts b/cli/src/commands/schema/index.test.ts new file mode 100644 index 0000000..91f86ff --- /dev/null +++ b/cli/src/commands/schema/index.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { buildSchemaStatement, schemaCommand } from "@/commands/schema/index.ts"; + +describe("schemaCommand", () => { + test("keeps human output on the schema tree by omitting --layout", () => { + expect(Object.keys(schemaCommand.args ?? {})).not.toContain("layout"); + }); + + test("builds one catalog-scoped query for schemas, tables, and views", () => { + 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 the catalog SQL string literal", () => { + const statement = buildSchemaStatement("o'brien"); + expect(statement).toContain("database_name = 'o''brien'"); + expect(statement).not.toContain("'o'brien'"); + }); +}); diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index 518f1e0..33ee326 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,15 +1,44 @@ -import type { ArgsDef } from "citty"; import { stringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; -import { - parseQueryOutputOptions, - parseRequestReadTimeoutMs, - queryRunArgs, -} from "@/lib/lakehouse/args.ts"; +import { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; import { writePagedOutput } from "@/lib/pager.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; +import { schemaArgs } from "@/commands/schema/lib/args.ts"; + +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"); + // --layout is not supported: human output is always the schema tree. + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions( + { ...args, layout: undefined }, + 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, displayOptions, pagerOptions, sink); + return; + } + await writePagedOutput(formatSchemaTree(result, catalog), pagerOptions, sink); + }, +}); export function buildSchemaStatement(catalog: string): string { const catSql = `'${catalog.replaceAll("'", "''")}'`; @@ -51,45 +80,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; - -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"); - // --layout is not supported: human output is always the schema tree. - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions( - { ...args, layout: undefined }, - 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, displayOptions, pagerOptions, sink); - return; - } - await writePagedOutput(formatSchemaTree(result, catalog), pagerOptions, 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..13ceb35 --- /dev/null +++ b/cli/src/commands/schema/lib/args.ts @@ -0,0 +1,11 @@ +import { defineArgs } from "@/lib/command.ts"; +import { queryRunArgs } from "@/lib/lakehouse/args.ts"; + +export const schemaArgs = defineArgs({ + 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"], +}); diff --git a/cli/src/commands/schema/lib/render.test.ts b/cli/src/commands/schema/lib/render.test.ts new file mode 100644 index 0000000..98347ac --- /dev/null +++ b/cli/src/commands/schema/lib/render.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; +import { + forceNoTerminalColorForTests, + forceTerminalColorForTests, + restoreTerminalState, + snapshotTerminalState, + type TerminalTestState, +} from "@/test-support/terminal-test-utils.ts"; + +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], + ], +}; + +describe("formatSchemaTree", () => { + 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 an empty catalog", () => { + expect(formatSchemaTree({ metadata: {}, columns, rows: [] }, "empty")).toBe( + ["Schemas and tables for empty", "└── "].join("\n"), + ); + }); + + test("preserves semantic color roles", () => { + 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(); + } + }); +}); diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts index 66440c2..396ac61 100644 --- a/cli/src/commands/upload/index.ts +++ b/cli/src/commands/upload/index.ts @@ -1,29 +1,11 @@ -import { statSync } from "node:fs"; -import { CliError } from "@/lib/errors.ts"; import { enumArg, optionalStringArg, stringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { sendHttp } from "@/lib/http-request.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs } from "@/lib/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}`); - } -} +import { getFileSizeBytes } from "@/lib/lakehouse/file.ts"; +import { uploadArgs, UPLOAD_MODE_OPTIONS } from "@/commands/upload/lib/args.ts"; export const uploadCommand = defineCommand({ meta: { @@ -34,31 +16,11 @@ export const uploadCommand = defineCommand({ "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)", - }, - }, + args: uploadArgs, async run({ args, execution, sink }) { const mode = enumArg(args, "mode", UPLOAD_MODE_OPTIONS); const filePath = stringArg(args, "file"); - const fileSizeBytes = getUploadFileSizeBytes(filePath); + const fileSizeBytes = getFileSizeBytes(filePath); const readTimeoutMs = parseRequestReadTimeoutMs(args); const scope = createLakehouseUploadRequest({ 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.ts b/cli/src/commands/upsert/index.ts index a459bd8..1be889d 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -3,8 +3,9 @@ import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; import { sendHttp } from "@/lib/http-request.ts"; import { parseLakehouseFileContentType, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; -import { getUploadFileSizeBytes, LAKEHOUSE_FILE_FORMAT_OPTIONS } from "@/commands/upload/index.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: { @@ -15,29 +16,10 @@ export const upsertCommand = defineCommand({ "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)", - }, - }, + args: upsertArgs, async run({ args, execution, sink }) { const filePath = stringArg(args, "file"); - const fileSizeBytes = getUploadFileSizeBytes(filePath); + const fileSizeBytes = getFileSizeBytes(filePath); const readTimeoutMs = parseRequestReadTimeoutMs(args); const scope = createLakehouseUpsertRequest({ 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/lib/lakehouse/args.test.ts b/cli/src/lib/lakehouse/args.test.ts index ff79114..06eafed 100644 --- a/cli/src/lib/lakehouse/args.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -1,7 +1,6 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { CliError } from "@/lib/errors.ts"; import { - parseAppendJsonContent, parsePagerOptions, parseQueryDisplayOptions, parseQueryOutputOptions, @@ -10,16 +9,7 @@ import { parseLakehouseFileContentType, } from "@/lib/lakehouse/args.ts"; import { parseQueryResultFormat } from "@/lib/lakehouse-client.ts"; -import { buildSchemaStatement, schemaCommand } from "@/commands/schema/index.ts"; -import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; import { setCliContext } from "@/context.ts"; -import { - forceNoTerminalColorForTests, - forceTerminalColorForTests, - restoreTerminalState, - snapshotTerminalState, - type TerminalTestState, -} from "@/test-support/terminal-test-utils.ts"; describe("parseQueryDisplayOptions", () => { test("parses human layout values", () => { @@ -103,138 +93,6 @@ describe("parseQueryOutputOptions", () => { }); }); -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(); diff --git a/cli/src/lib/lakehouse/args.ts b/cli/src/lib/lakehouse/args.ts index bd63d53..97c0579 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,5 +1,4 @@ -import { readFileSync } from "node:fs"; -import type { ArgsDef } from "citty"; +import { defineArgs } from "@/lib/command.ts"; import { isAgentMode } from "@/context.ts"; import { asCliArgString } from "@/lib/cli-args.ts"; import { CliError } from "@/lib/errors.ts"; @@ -13,8 +12,30 @@ import { isQueryLayout, QUERY_LAYOUT_OPTIONS, type QueryLayout } from "@/ui/layo 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; +export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; +const requestReadTimeoutArg = { + type: "string", + description: "Read timeout in seconds for this request (overrides global --read-timeout)", +} 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 }, + "read-timeout": requestReadTimeoutArg, +}); -export const queryRunArgs = { +export const queryRunArgs = defineArgs({ statement: { type: "positional", description: "SQL statement to run", required: false }, format: { type: "enum", @@ -44,11 +65,8 @@ export const queryRunArgs = { default: "auto", options: [...PAGER_MODE_OPTIONS], }, - "read-timeout": { - type: "string", - description: "Read timeout in seconds for this request (overrides global --read-timeout)", - }, -} satisfies ArgsDef; + "read-timeout": requestReadTimeoutArg, +}); const PAGER_MODES = new Set(PAGER_MODE_OPTIONS); const AGENT_INCOMPATIBLE_QUERY_FLAGS = ["--layout", "--pager", "--max-width"] as const; @@ -187,30 +205,6 @@ export function parseRequestReadTimeoutMs(args: Record): number 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; 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/management-payloads.test.ts b/cli/src/lib/management-payloads.test.ts index 2872b5c..08257e2 100644 --- a/cli/src/lib/management-payloads.test.ts +++ b/cli/src/lib/management-payloads.test.ts @@ -1,10 +1,5 @@ import { describe, expect, test } from "bun:test"; import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; -import { buildBodyFromFields, readJsonBody, resolveApiBody } from "@/commands/api/lib/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", () => { @@ -12,47 +7,3 @@ describe("management payload builders", () => { 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"}'); - }); -}); From 308fafbd837ae5cd1eee50c092742a595a7e7e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 13:23:21 +0200 Subject: [PATCH 08/22] fix(cli): preserve catalog list request failures Fetch databases before connections so a database HTTP error cannot be masked by a racing secondary request. This also makes the request order and early-exit behavior explicit. --- cli/src/commands/catalogs/lib/requests.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/catalogs/lib/requests.ts b/cli/src/commands/catalogs/lib/requests.ts index 0bafffb..4f2695b 100644 --- a/cli/src/commands/catalogs/lib/requests.ts +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -25,9 +25,10 @@ export async function fetchManagementCatalogRows( env: string, execution: ExecutionContext, ): Promise { - const [databasesResponse, connectionsResponse] = await Promise.all([ - sendHttp(buildCatalogListRequest(env, "databases"), execution), - sendHttp(buildCatalogListRequest(env, "connections"), execution), - ]); + const databasesResponse = await sendHttp(buildCatalogListRequest(env, "databases"), execution); + const connectionsResponse = await sendHttp( + buildCatalogListRequest(env, "connections"), + execution, + ); return buildCatalogRowsFromResponses(databasesResponse, connectionsResponse); } From 532e311d5a773ba15122ca67925f216c91e08e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 14:31:10 +0200 Subject: [PATCH 09/22] test(cli): build fixtures through command helpers Use defineArgs and defineCommand for test fixtures so tests exercise the same Citty abstraction boundary as production command definitions. --- cli/src/commands/api/index.test.ts | 6 +++--- cli/src/commands/completion/index.test.ts | 6 +++--- cli/src/commands/completion/lib/spec.test.ts | 14 +++++++------- cli/src/lib/command-delegation.test.ts | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/src/commands/api/index.test.ts b/cli/src/commands/api/index.test.ts index 4be2aca..5040d09 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -14,7 +14,7 @@ import { setCliContext } from "@/context.ts"; import { buildCompletionSpec, flattenTopLevelNames } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; -import { runCommandTree, type Command, type CommandArgs } from "@/lib/command.ts"; +import { defineArgs, runCommandTree, type Command } from "@/lib/command.ts"; function createCaptureSink(json: boolean) { const stdout: string[] = []; @@ -140,9 +140,9 @@ describe("api", () => { }); test("normalizeApiInvocatorRawArgs inserts -- before endpoint paths", () => { - const rootArgs = { + const rootArgs = defineArgs({ profile: { type: "string", description: "Use a named profile" }, - } satisfies CommandArgs; + }); expect(normalizeApiInvocatorRawArgs(["api", "/whoami"])).toEqual(["api", "--", "/whoami"]); expect(normalizeApiInvocatorRawArgs(["api", "GET", "/whoami"])).toEqual([ diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index 1d2749a..35f096a 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -2,7 +2,7 @@ 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 { Command } from "@/lib/command.ts"; +import { defineCommand, type Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { createCompletionCommand } from "@/commands/completion/index.ts"; import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; @@ -14,7 +14,7 @@ const TERMINAL_CONTROL_PATTERN = new RegExp( "g", ); -const minimalRootCommand: Command = { +const minimalRootCommand = defineCommand({ meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON responses" }, @@ -42,7 +42,7 @@ const minimalRootCommand: Command = { }, completion: { meta: { name: "completion" } }, }, -}; +}); let testHome: string | undefined; diff --git a/cli/src/commands/completion/lib/spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts index 9e54395..5f0fba9 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { Command } from "@/lib/command.ts"; +import { defineCommand } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { buildCompletionSpec, @@ -22,7 +22,7 @@ function findNode(spec: ReturnType, name: string) { describe("buildCompletionSpec", () => { test("walks a minimal fake tree", () => { - const root: Command = { + const root = defineCommand({ meta: { name: "altertable" }, args: { json: { type: "boolean", description: "Output raw JSON" }, @@ -41,7 +41,7 @@ describe("buildCompletionSpec", () => { }, }, }, - }; + }); const spec = buildCompletionSpec(root); expect(spec.flags.map((flag) => flag.name)).toEqual(["format", "json"]); @@ -53,24 +53,24 @@ describe("buildCompletionSpec", () => { }); test("skips nested commands without meta.name", () => { - const root: Command = { + const root = defineCommand({ subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { description: "no name" } }, }, - }; + }); const spec = buildCompletionSpec(root); expect(flattenTopLevelNames(spec)).toEqual(["visible"]); }); test("skips commands marked hidden", () => { - const root: Command = { + const root = defineCommand({ subCommands: { visible: { meta: { name: "visible" } }, hidden: { meta: { name: "hidden", hidden: true } }, }, - }; + }); const spec = buildCompletionSpec(root); expect(flattenTopLevelNames(spec)).toEqual(["visible"]); diff --git a/cli/src/lib/command-delegation.test.ts b/cli/src/lib/command-delegation.test.ts index e371c4c..33a6442 100644 --- a/cli/src/lib/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 { CommandArgs } from "@/lib/command.ts"; +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 CommandArgs; +}); -const passthroughArgs = { +const passthroughArgs = defineArgs({ method: { type: "enum", alias: "X", options: ["GET", "POST"] }, field: { type: "string", alias: "f" }, verbose: { type: "boolean" }, -} satisfies CommandArgs; +}); describe("command delegation helpers", () => { test("valueFlagsFor extracts string and enum flags with aliases", () => { From ee8250dc825a14699e40a0a615e717d38fcee190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 15:20:23 +0200 Subject: [PATCH 10/22] test(cli): cover commands through the public runtime Rename shared test helpers to test-utils and add one harness that executes the real command tree while capturing user-visible output.\n\nMove query, append, upload, upsert, schema, DuckDB, API, and login coverage beside their commands. Replace helper-level login and schema tests with behavioral scenarios, and keep command-only helpers private.\n\nThe command-level login coverage also fixes control-plane overrides so they flow through the login session without mutating environment-backed profile selection. --- cli/AGENTS.md | 3 +- cli/knip.json | 2 +- cli/src/commands/api/index.test.ts | 64 ++-- cli/src/commands/api/index.ts | 3 - .../commands/api/lib/http-conformance.test.ts | 2 +- cli/src/commands/api/routes.ts | 2 +- cli/src/commands/api/spec.ts | 5 +- cli/src/commands/append/index.test.ts | 52 +++ cli/src/commands/duckdb/index.test.ts | 126 ++++--- cli/src/commands/duckdb/index.ts | 16 +- cli/src/commands/login/index.test.ts | 168 +++++++++ cli/src/commands/login/index.ts | 85 ++--- cli/src/commands/query/index.test.ts | 85 +++++ cli/src/commands/schema/index.test.ts | 112 +++++- cli/src/commands/schema/lib/render.test.ts | 104 ------ cli/src/commands/upload/index.test.ts | 69 ++++ cli/src/commands/upsert/index.test.ts | 43 +++ cli/src/lib/env.test.ts | 2 +- cli/src/lib/errors.test.ts | 2 +- cli/src/lib/http.test.ts | 8 +- cli/src/lib/lakehouse/query.test.ts | 340 +----------------- cli/src/lib/management-payloads.test.ts | 9 - cli/src/lib/oauth-flow.test.ts | 333 ++--------------- cli/src/lib/oauth-profile.test.ts | 49 +-- cli/src/lib/profile/model.test.ts | 89 +---- cli/src/lib/query-format.test.ts | 4 +- cli/src/lib/updater.test.ts | 2 +- cli/src/lib/usage.test.ts | 2 +- cli/src/test-support/cli-test-runtime.ts | 25 -- cli/src/test-utils/cli.ts | 42 +++ cli/src/test-utils/lakehouse.ts | 70 ++++ .../terminal.ts} | 0 .../test-utils.ts => test-utils/time.ts} | 0 33 files changed, 806 insertions(+), 1112 deletions(-) create mode 100644 cli/src/commands/append/index.test.ts create mode 100644 cli/src/commands/login/index.test.ts create mode 100644 cli/src/commands/query/index.test.ts delete mode 100644 cli/src/commands/schema/lib/render.test.ts create mode 100644 cli/src/commands/upload/index.test.ts create mode 100644 cli/src/commands/upsert/index.test.ts delete mode 100644 cli/src/lib/management-payloads.test.ts delete mode 100644 cli/src/test-support/cli-test-runtime.ts create mode 100644 cli/src/test-utils/cli.ts create mode 100644 cli/src/test-utils/lakehouse.ts rename cli/src/{test-support/terminal-test-utils.ts => test-utils/terminal.ts} (100%) rename cli/src/{test-support/test-utils.ts => test-utils/time.ts} (100%) diff --git a/cli/AGENTS.md b/cli/AGENTS.md index e6871db..5f67cdd 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -86,6 +86,7 @@ bun test "$PWD"/tests/integration.e2e.ts | `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` | | `src/**/*.test.ts` | Unit tests colocated beside their subject | @@ -196,7 +197,7 @@ Test env vars: `ALTERTABLE_CONFIG_HOME`, `ALTERTABLE_SECRET_BACKEND=file`, `ALTE Lakehouse endpoint coverage: [DEVELOPMENT.md spec conformance table](../DEVELOPMENT.md#cli-spec-conformance-lakehouse). -Example mock HTTP test pattern: `cli/src/lib/lakehouse/query.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) diff --git a/cli/knip.json b/cli/knip.json index de37ee2..26adbc1 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -5,5 +5,5 @@ "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, "ignoreDependencies": ["undici"], - "ignoreBinaries": ["less", "duckdb"] + "ignoreBinaries": ["less"] } diff --git a/cli/src/commands/api/index.test.ts b/cli/src/commands/api/index.test.ts index 5040d09..4b76602 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -3,46 +3,14 @@ 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/index.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 "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; import { defineArgs, runCommandTree, type Command } from "@/lib/command.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(""); -} - describe("api", () => { beforeEach(() => { setCliContext({ debug: false, json: false, agent: false }); @@ -53,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"); @@ -109,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"); @@ -117,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"]); diff --git a/cli/src/commands/api/index.ts b/cli/src/commands/api/index.ts index f6a076a..47c90ce 100644 --- a/cli/src/commands/api/index.ts +++ b/cli/src/commands/api/index.ts @@ -67,6 +67,3 @@ function isApiCommandName(value: string): boolean { function isDelegatedApiCommand(rawArgs: readonly string[]): boolean { return isDelegatedSubCommand(rawArgs, isApiCommandName, { valueFlags: API_VALUE_FLAGS }); } - -export { runApiSpecCommand } from "@/commands/api/spec.ts"; -export { runApiRoutesCommand } from "@/commands/api/routes.ts"; diff --git a/cli/src/commands/api/lib/http-conformance.test.ts b/cli/src/commands/api/lib/http-conformance.test.ts index e3594ac..ac700d7 100644 --- a/cli/src/commands/api/lib/http-conformance.test.ts +++ b/cli/src/commands/api/lib/http-conformance.test.ts @@ -6,7 +6,7 @@ import { OPENAPI_OPERATIONS } from "@/generated/openapi-operations.ts"; import { setCliContext } from "@/context.ts"; import { executeApiHttp, resolveApiHttp } from "@/commands/api/lib/http.ts"; import { createExecutionContext } from "@/lib/execution-context.ts"; -import { encodeManagementEndpoint } from "@/lib/management-transport.ts"; +import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; const PLACEHOLDER_VALUES: Record = { diff --git a/cli/src/commands/api/routes.ts b/cli/src/commands/api/routes.ts index 343a94c..405c628 100644 --- a/cli/src/commands/api/routes.ts +++ b/cli/src/commands/api/routes.ts @@ -23,7 +23,7 @@ export const apiRoutesCommand = defineCommand({ }, }); -export async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { +async function runApiRoutesCommand(sink: OutputSink, operationId?: string): Promise { const operation = operationId ? apiOperationDetails(operationId) : undefined; await writeCommandOutput( operation diff --git a/cli/src/commands/api/spec.ts b/cli/src/commands/api/spec.ts index 48cfabc..f89f19f 100644 --- a/cli/src/commands/api/spec.ts +++ b/cli/src/commands/api/spec.ts @@ -28,10 +28,7 @@ export const apiSpecCommand = defineCommand({ }, }); -export async function runApiSpecCommand( - sink: OutputSink, - options?: { format?: string }, -): Promise { +async function runApiSpecCommand(sink: OutputSink, options?: { format?: string }): Promise { const format = resolveOpenapiSpecFormat( sink.json, process.stdout.isTTY === true, 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/duckdb/index.test.ts b/cli/src/commands/duckdb/index.test.ts index 2b8c562..e2ccd29 100644 --- a/cli/src/commands/duckdb/index.test.ts +++ b/cli/src/commands/duckdb/index.test.ts @@ -1,69 +1,85 @@ -import { describe, expect, test } from "bun:test"; -import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb/index.ts"; -import type { CatalogRow } from "@/lib/management/model.ts"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { configSet } from "@/lib/config.ts"; +import { secretSet } from "@/lib/secrets.ts"; +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; +import { + createLakehouseTestWorkspace, + type LakehouseTestWorkspace, +} from "@/test-utils/lakehouse.ts"; -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);'); - }); +let workspace: LakehouseTestWorkspace; +let originalPath: string | undefined; - 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);'); - }); +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"); - 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'"); - }); + 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 ?? ""}`; +}); - 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);'); - }); +afterEach(() => { + process.env.PATH = originalPath; + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; + workspace.cleanup(); }); -describe("selectCatalogsToAttach", () => { - const rows = [catalogRow("sales"), catalogRow("ops"), catalogRow("")]; +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":[]}', + }, + ]); +} - test("returns every non-empty catalog when none is requested", () => { - expect(selectCatalogsToAttach(rows, undefined)).toEqual(["sales", "ops"]); - }); +describe("duckdb command", () => { + test("launches DuckDB with verified credentials and every catalog", async () => { + writeDuckdbMocks(); - test("deduplicates repeated catalog values", () => { - expect(selectCatalogsToAttach([catalogRow("sales"), catalogRow("sales")], undefined)).toEqual([ - "sales", - ]); - }); + await runCommandWithTestRuntime(["duckdb"]); - test("returns only the requested catalog when it exists", () => { - expect(selectCatalogsToAttach(rows, "ops")).toEqual(["ops"]); + 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("throws when the requested catalog is not available", () => { - expect(() => selectCatalogsToAttach(rows, "missing")).toThrow(/not found/); - }); + test("rejects a requested catalog that is not available", () => { + writeDuckdbMocks(); - test("throws when there are no catalogs to attach", () => { - expect(() => selectCatalogsToAttach([], undefined)).toThrow(/No catalogs found/); + return expect(runCommandWithTestRuntime(["duckdb", "missing"])).rejects.toThrow( + 'Catalog "missing" not found', + ); }); }); diff --git a/cli/src/commands/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index e3ea357..e4e6e01 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -11,6 +11,7 @@ import { optionalStringArg } from "@/lib/args.ts"; import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.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: { @@ -47,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;", @@ -59,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)), ]; @@ -83,7 +78,8 @@ export function selectCatalogsToAttach( type DuckdbInput = { catalog: string | undefined }; async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promise { - if (!Bun.which("duckdb")) { + 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.", ); @@ -103,7 +99,7 @@ async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promi 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}`); } diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts new file mode 100644 index 0000000..8b8928e --- /dev/null +++ b/cli/src/commands/login/index.test.ts @@ -0,0 +1,168 @@ +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 { getStoredAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.ts"; +import { getActiveProfileName, profileExists } from "@/lib/profile-store.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; + +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(getStoredAccessToken("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(getStoredAccessToken("default")).toBe("org_a_token"); + expect(getActiveProfileName()).toBe("org-b_production"); + expect(getStoredAccessToken("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/index.ts b/cli/src/commands/login/index.ts index 33d2f1c..5c111c3 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -6,7 +6,6 @@ import { refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts"; import { httpSend } from "@/lib/http.ts"; import { resolveManagementApiBase, resolveOAuthBase } 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 "@/lib/management/model.ts"; @@ -15,13 +14,10 @@ import { assertNoEnvConfigMode, createEmptyProfile, deriveProfileName, - profileExists, profileHasAnyAuthConfigured, - resolveWorkingProfile, - setActiveProfile, updateProfile, } from "@/lib/profile/model.ts"; -import { readEnv, setEnv } from "@/lib/env.ts"; +import { profileExists, resolveWorkingProfile, setActiveProfile } from "@/lib/profile-store.ts"; import { span } from "@/ui/document.ts"; import { renderDisplayText } from "@/ui/terminal/styles.ts"; @@ -60,7 +56,7 @@ 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 '.", @@ -68,10 +64,6 @@ export function assertInteractiveLogin(): void { } } -export function resolveWhoamiEnvironmentSlug(whoami: WhoamiResponse): string | undefined { - return whoami.environment_slug; -} - type LoginProfileMetadata = { environment: string; profileName: string; @@ -129,11 +121,8 @@ function selectLoginProfile( return { profileName: targetProfile, profileAction }; } -export function storeLoginProfileMetadata( - whoami: WhoamiResponse, - args: LoginArgs, -): LoginProfileMetadata { - const environment = resolveWhoamiEnvironmentSlug(whoami); +function storeLoginProfileMetadata(whoami: WhoamiResponse, args: LoginArgs): LoginProfileMetadata { + const environment = whoami.environment_slug; // OAuth login must always return an environment. if (!environment) { @@ -166,41 +155,33 @@ export function storeLoginProfileMetadata( 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"); +function resolveLoginEndpoints( + args: LoginArgs, + profileName: string, +): { oauthBase: string; managementApiBase: string } { + const override = args["control-plane-url"]; + if (!override) { + return { + oauthBase: resolveOAuthBase(profileName), + managementApiBase: resolveManagementApiBase(profileName), + }; } - assertAllowedApiBase(url, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - setEnv("ALTERTABLE_MANAGEMENT_API_BASE", url); + + const root = override.replace(/\/$/, ""); + assertAllowedApiBase(root, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); + return { oauthBase: `${root}/oauth`, managementApiBase: `${root}/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 { @@ -213,28 +194,12 @@ 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 { 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); @@ -245,14 +210,6 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { 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 profileMessage = `${LOGIN_PROFILE_MESSAGES[profileAction]} "${profileName}"`; sink.writeMetadata([ 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/schema/index.test.ts b/cli/src/commands/schema/index.test.ts index 91f86ff..ad1434c 100644 --- a/cli/src/commands/schema/index.test.ts +++ b/cli/src/commands/schema/index.test.ts @@ -1,22 +1,104 @@ -import { describe, expect, test } from "bun:test"; -import { buildSchemaStatement, schemaCommand } from "@/commands/schema/index.ts"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; -describe("schemaCommand", () => { - test("keeps human output on the schema tree by omitting --layout", () => { - expect(Object.keys(schemaCommand.args ?? {})).not.toContain("layout"); +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("builds one catalog-scoped query for schemas, tables, and views", () => { - 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("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("escapes the catalog SQL string literal", () => { - const statement = buildSchemaStatement("o'brien"); - expect(statement).toContain("database_name = 'o''brien'"); - expect(statement).not.toContain("'o'brien'"); + test("rejects query layout flags because human output is always the tree", () => { + const result = runCommandWithTestRuntime(["schema", "analytics", "--layout", "line"]); + return expect(result).rejects.toThrow(); }); }); diff --git a/cli/src/commands/schema/lib/render.test.ts b/cli/src/commands/schema/lib/render.test.ts deleted file mode 100644 index 98347ac..0000000 --- a/cli/src/commands/schema/lib/render.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; -import { - forceNoTerminalColorForTests, - forceTerminalColorForTests, - restoreTerminalState, - snapshotTerminalState, - type TerminalTestState, -} from "@/test-support/terminal-test-utils.ts"; - -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], - ], -}; - -describe("formatSchemaTree", () => { - 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 an empty catalog", () => { - expect(formatSchemaTree({ metadata: {}, columns, rows: [] }, "empty")).toBe( - ["Schemas and tables for empty", "└── "].join("\n"), - ); - }); - - test("preserves semantic color roles", () => { - 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(); - } - }); -}); 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/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/lib/env.test.ts b/cli/src/lib/env.test.ts index 9b80bc1..205df40 100644 --- a/cli/src/lib/env.test.ts +++ b/cli/src/lib/env.test.ts @@ -104,7 +104,7 @@ test("production modules access the environment only through env.ts", async () = relativePath === "lib/env.ts" || relativePath.endsWith(".test.ts") || relativePath.startsWith("generated/") || - relativePath.startsWith("test-support/") + relativePath.startsWith("test-utils/") ) { continue; } diff --git a/cli/src/lib/errors.test.ts b/cli/src/lib/errors.test.ts index ed5cdfa..2e369c5 100644 --- a/cli/src/lib/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -28,7 +28,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-support/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; let terminalState: TerminalTestState; diff --git a/cli/src/lib/http.test.ts b/cli/src/lib/http.test.ts index 46ad8cf..5416a9f 100644 --- a/cli/src/lib/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 "@/test-support/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/lakehouse/query.test.ts b/cli/src/lib/lakehouse/query.test.ts index e2592ce..36181d2 100644 --- a/cli/src/lib/lakehouse/query.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -1,25 +1,25 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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, - getQueryColumnNames, - parseLakehouseQueryResponse, - parseLakehouseQueryStream, renderQueryCsv, renderQueryJson, renderQueryTable, - type LakehouseRow, } from "@/lib/lakehouse-client.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"; -import { runCommandWithTestRuntime } from "@/test-support/cli-test-runtime.ts"; -import { normalizeQueryInvocatorRawArgs } from "@/commands/query/index.ts"; const SAMPLE_NDJSON = [ '{"statement":"SELECT 1","session_id":"abc","query_id":"def"}', @@ -30,14 +30,11 @@ const SAMPLE_NDJSON = [ 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"; @@ -45,17 +42,9 @@ beforeEach(() => { refreshCliRuntimeContext(getCliRuntime().context); }); -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; @@ -323,318 +312,3 @@ describe("query renderers", () => { 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/src/lib/management-payloads.test.ts b/cli/src/lib/management-payloads.test.ts deleted file mode 100644 index 08257e2..0000000 --- a/cli/src/lib/management-payloads.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; - -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" }); - }); -}); diff --git a/cli/src/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts index c472251..6ad44c1 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -1,23 +1,14 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +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 { resolveOAuthBase } from "@/lib/config.ts"; -import { configGet } from "@/lib/config.ts"; +import { configGet, resolveOAuthBase } 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 "@/lib/profile/model.ts"; -import { secretSet } from "@/lib/secrets.ts"; +import { buildAuthorizeUrl, parseCallback, startLoopbackServer } from "@/lib/oauth-flow.ts"; +import { clearOAuthTokens, getStoredAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.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-")); @@ -31,11 +22,6 @@ afterEach(() => { 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", () => { @@ -55,13 +41,14 @@ describe("parseCallback", () => { }); test("rejects a state mismatch", () => { - const outcome = parseCallback("/callback?code=abc&state=other", "st"); - expect(outcome.ok).toBe(false); + expect(parseCallback("/callback?code=abc&state=other", "st").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" }); + expect(parseCallback("/callback?error=access_denied&error_description=nope", "st")).toEqual({ + ok: false, + message: "Authorization failed: access_denied — nope", + }); }); test("rejects a missing code", () => { @@ -70,7 +57,7 @@ describe("parseCallback", () => { }); describe("buildAuthorizeUrl", () => { - test("includes PKCE, state and client id", () => { + test("includes PKCE, state, scope, and client id", () => { process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; const url = new URL( buildAuthorizeUrl( @@ -89,7 +76,6 @@ describe("buildAuthorizeUrl", () => { 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; }); }); @@ -97,41 +83,41 @@ 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"); + 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 the callback error as plain text (no HTML reflection)", async () => { + test("serves callback errors as plain text", async () => { const server = await startLoopbackServer("st"); const settled = server.waitForCode().then( () => "resolved", () => "rejected", ); try { - const res = await fetch( + const response = 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(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 (e.g. favicon)", async () => { + test("ignores non-callback paths", 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); + expect((await fetch(`http://127.0.0.1:${port}/favicon.ico`)).status).toBe(404); } finally { server.close(); } @@ -143,8 +129,9 @@ describe("token storage", () => { 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); + expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThanOrEqual( + before + 3600 * 1000, + ); }); test("clear removes tokens and expiry", () => { @@ -154,273 +141,3 @@ describe("token storage", () => { expect(configGet("oauth_expiry", profileName)).toBe(""); }); }); - -import { - assertInteractiveLogin, - resolveWhoamiEnvironmentSlug, - applyControlPlaneOverride, - sameWhoamiContext, - storeLoginProfileMetadata, -} from "@/commands/login/index.ts"; -import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/lib/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/src/lib/oauth-profile.test.ts b/cli/src/lib/oauth-profile.test.ts index 60c846c..cf32d7b 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -9,13 +9,12 @@ import { getStoredAccessToken, } 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/index.ts"; const profileName = "default"; let testHome = ""; @@ -211,52 +210,6 @@ describe("management request auth resolution", () => { 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), - ); - 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"); - }); - test("resolves the header live from the store, not a bootstrap cache", async () => { storeOAuthTokens({ access_token: "tok_a", refresh_token: "r", expires_in: 3600 }, profileName); diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index 2a6f8da..af7295b 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -11,23 +11,24 @@ import { setSpawnSyncForTests, } from "@/lib/secrets.ts"; import { - DEFAULT_PROFILE_NAME, createEmptyProfile, deleteProfile, deriveProfileName, - ensureProfilesLayout, - getActiveProfileName, inspectProfile, listProfiles, + renameProfile, + updateProfile, +} from "@/lib/profile/model.ts"; +import { + DEFAULT_PROFILE_NAME, + ensureProfilesLayout, + getActiveProfileName, profileConfigFile, profileExists, - renameProfile, resolveWorkingProfile, setActiveProfile, - updateProfile, -} from "@/lib/profile/model.ts"; -import { promptProfileSwitch } from "@/commands/profile/index.ts"; -import { ConfigurationError, CliError } from "@/lib/errors.ts"; +} from "@/lib/profile-store.ts"; +import { ConfigurationError } from "@/lib/errors.ts"; import { buildProfileDirenvView, buildProfileInspectView, @@ -37,16 +38,9 @@ import { type ProfileStatusResult, } from "@/lib/profile/views.ts"; import { formatProfileInspect, formatProfileStatus } from "@/lib/profile/render.ts"; -import { runProfileConfigure } from "@/lib/profile-configure.ts"; import { setCliContext, getCliContext } from "@/context.ts"; -import { - createCliTestRuntime, - runCommandWithTestRuntime, -} from "@/test-support/cli-test-runtime.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; -const profileName = "default"; - let testHome = ""; beforeEach(() => { @@ -135,23 +129,6 @@ describe("profile storage", () => { 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" }); @@ -440,54 +417,6 @@ describe("profile storage", () => { 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", () => { diff --git a/cli/src/lib/query-format.test.ts b/cli/src/lib/query-format.test.ts index f1dc22f..da6434b 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -3,7 +3,7 @@ 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 { parseLakehouseQueryResponse } from "@/lib/lakehouse-ndjson.ts"; import { defaultDisplayOptions, formatQueryCell, @@ -22,7 +22,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-support/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; let terminalState: TerminalTestState | undefined; diff --git a/cli/src/lib/updater.test.ts b/cli/src/lib/updater.test.ts index 828df7e..e09df26 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -39,9 +39,9 @@ import { resolveUpdateSource, setUpdateCheckInterval, shouldRunAutomaticUpdateCheck, - UpdaterConfig, verifySha256, } from "@/lib/updater.ts"; +import { UpdaterConfig } from "@/lib/updater-config.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; let testHome = ""; diff --git a/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 90d4a9f..0b8d678 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -16,7 +16,7 @@ import { restoreTerminalState, snapshotTerminalState, type TerminalTestState, -} from "@/test-support/terminal-test-utils.ts"; +} from "@/test-utils/terminal.ts"; describe("renderAltertableUsage", () => { test("groups root commands before global flags in a custom help panel", async () => { diff --git a/cli/src/test-support/cli-test-runtime.ts b/cli/src/test-support/cli-test-runtime.ts deleted file mode 100644 index 7fb1a3c..0000000 --- a/cli/src/test-support/cli-test-runtime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { buildMainCommand } from "@/cli.ts"; -import type { CliContext } from "@/context.ts"; -import { runCommandTree } from "@/lib/command.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), () => - runCommandTree(buildMainCommand(), { rawArgs }), - ); -} diff --git a/cli/src/test-utils/cli.ts b/cli/src/test-utils/cli.ts new file mode 100644 index 0000000..4835c7f --- /dev/null +++ b/cli/src/test-utils/cli.ts @@ -0,0 +1,42 @@ +import { buildMainCommand } from "@/cli.ts"; +import type { CliContext } from "@/context.ts"; +import { runCommandTree } from "@/lib/command.ts"; +import { createCliRuntime, runWithCliRuntime, type CliRuntime } from "@/lib/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/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-support/terminal-test-utils.ts b/cli/src/test-utils/terminal.ts similarity index 100% rename from cli/src/test-support/terminal-test-utils.ts rename to cli/src/test-utils/terminal.ts diff --git a/cli/src/test-support/test-utils.ts b/cli/src/test-utils/time.ts similarity index 100% rename from cli/src/test-support/test-utils.ts rename to cli/src/test-utils/time.ts From e0e4b798b2ec45bf17a13df35b7a64dff6dc0ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 15:20:50 +0200 Subject: [PATCH 11/22] refactor(cli): make shared dependencies explicit Remove accidental re-export facades from the HTTP, lakehouse, profile, updater, and management modules so callers import the module that owns each value.\n\nInline single-use payload, schema-rendering, and URL-encoding wrappers. Keep catalog request construction colocated with catalog commands and remove dead command exports, leaving shared libraries for behavior used by multiple command families. --- cli/src/commands/append/status.ts | 3 +-- cli/src/commands/catalogs/create.ts | 6 +---- cli/src/commands/catalogs/lib/requests.ts | 4 ++-- cli/src/commands/completion/index.ts | 2 -- cli/src/commands/profile/create.ts | 8 ++----- cli/src/commands/profile/current.ts | 2 +- cli/src/commands/profile/index.ts | 2 -- cli/src/commands/profile/lib/profile.ts | 4 ++-- cli/src/commands/profile/switch.ts | 3 ++- cli/src/commands/profile/use.ts | 3 ++- cli/src/commands/schema/index.ts | 11 ++++++--- cli/src/commands/schema/lib/render.ts | 7 ------ cli/src/commands/schema/lib/views.ts | 2 +- cli/src/lib/catalogs/rows.ts | 3 --- cli/src/lib/encode.ts | 3 --- cli/src/lib/http.ts | 3 --- cli/src/lib/lakehouse-client.ts | 22 ------------------ cli/src/lib/lakehouse/query.ts | 5 ++-- cli/src/lib/management-endpoint.ts | 6 ++--- cli/src/lib/management-output.ts | 2 -- cli/src/lib/management-payloads.ts | 3 --- cli/src/lib/management-transport.ts | 1 - cli/src/lib/profile-status.ts | 2 -- cli/src/lib/profile/model.ts | 28 ----------------------- cli/src/lib/profile/views.ts | 2 +- cli/src/lib/updater.ts | 17 +------------- 26 files changed, 29 insertions(+), 125 deletions(-) delete mode 100644 cli/src/commands/schema/lib/render.ts delete mode 100644 cli/src/lib/encode.ts delete mode 100644 cli/src/lib/management-payloads.ts diff --git a/cli/src/commands/append/status.ts b/cli/src/commands/append/status.ts index 2101208..cf5a65d 100644 --- a/cli/src/commands/append/status.ts +++ b/cli/src/commands/append/status.ts @@ -1,4 +1,3 @@ -import { urlencode } from "@/lib/encode.ts"; import { stringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; @@ -21,7 +20,7 @@ export const appendStatusCommand = defineCommand({ { plane: "lakehouse", method: "GET", - endpoint: `/tasks/${urlencode(stringArg(args, "append-id"))}`, + endpoint: `/tasks/${encodeURIComponent(stringArg(args, "append-id"))}`, retry: true, }, execution, diff --git a/cli/src/commands/catalogs/create.ts b/cli/src/commands/catalogs/create.ts index 86b1586..d557fdc 100644 --- a/cli/src/commands/catalogs/create.ts +++ b/cli/src/commands/catalogs/create.ts @@ -1,6 +1,5 @@ import { CliError } from "@/lib/errors.ts"; import { requireManagementEnv } from "@/lib/auth.ts"; -import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; import { buildCatalogCreateRequest } from "@/commands/catalogs/lib/requests.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; @@ -29,10 +28,7 @@ export const catalogsCreateCommand = defineCommand({ } const env = requireManagementEnv(execution.profile); const fallbackName = String(args.name); - const response = await sendHttp( - buildCatalogCreateRequest(env, buildCreateCatalogBody({ name: fallbackName })), - execution, - ); + const response = await sendHttp(buildCatalogCreateRequest(env, fallbackName), execution); await writeCommandOutput( { kind: "raw_api", diff --git a/cli/src/commands/catalogs/lib/requests.ts b/cli/src/commands/catalogs/lib/requests.ts index 4f2695b..ce7ad81 100644 --- a/cli/src/commands/catalogs/lib/requests.ts +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -3,12 +3,12 @@ import type { CatalogRow } from "@/lib/management/model.ts"; import type { ExecutionContext } from "@/lib/execution-context.ts"; import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; -export function buildCatalogCreateRequest(env: string, body: string): HttpRequest { +export function buildCatalogCreateRequest(env: string, name: string): HttpRequest { return { plane: "management", method: "POST", endpoint: `/environments/${env}/databases`, - body, + body: JSON.stringify({ name, engine: "altertable" }), contentType: "application/json", }; } diff --git a/cli/src/commands/completion/index.ts b/cli/src/commands/completion/index.ts index 3deca14..1f134b6 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -71,5 +71,3 @@ export function createCompletionCommand( }, }); } - -export { installCompletion } from "@/commands/completion/lib/completion.ts"; diff --git a/cli/src/commands/profile/create.ts b/cli/src/commands/profile/create.ts index 29e54ee..d3c19e5 100644 --- a/cli/src/commands/profile/create.ts +++ b/cli/src/commands/profile/create.ts @@ -1,9 +1,5 @@ -import { - assertNoEnvConfigMode, - createEmptyProfile, - inspectProfile, - setActiveProfile, -} from "@/lib/profile/model.ts"; +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, diff --git a/cli/src/commands/profile/current.ts b/cli/src/commands/profile/current.ts index b3e509c..885695d 100644 --- a/cli/src/commands/profile/current.ts +++ b/cli/src/commands/profile/current.ts @@ -1,4 +1,4 @@ -import { getActiveProfileName } from "@/lib/profile/model.ts"; +import { getActiveProfileName } from "@/lib/profile-store.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; diff --git a/cli/src/commands/profile/index.ts b/cli/src/commands/profile/index.ts index 06329d7..c90c302 100644 --- a/cli/src/commands/profile/index.ts +++ b/cli/src/commands/profile/index.ts @@ -81,5 +81,3 @@ function throwNoProfileCommand(): never { error.code = "E_NO_COMMAND"; throw error; } - -export { promptProfileSwitch } from "@/commands/profile/lib/profile.ts"; diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts index 2b487df..b8c727b 100644 --- a/cli/src/commands/profile/lib/profile.ts +++ b/cli/src/commands/profile/lib/profile.ts @@ -12,9 +12,9 @@ import { envConfigMode, getActiveProfileName, isFromEnvProfile, - listProfiles, profileExists, -} from "@/lib/profile/model.ts"; +} from "@/lib/profile-store.ts"; +import { listProfiles } from "@/lib/profile/model.ts"; export function requireProfileName(name: unknown): string { const trimmed = asCliArgString(name).trim(); diff --git a/cli/src/commands/profile/switch.ts b/cli/src/commands/profile/switch.ts index 470c15f..b4558a8 100644 --- a/cli/src/commands/profile/switch.ts +++ b/cli/src/commands/profile/switch.ts @@ -1,5 +1,6 @@ import { getCliContext, isJsonOutput } from "@/context.ts"; -import { assertNoEnvConfigMode, setActiveProfile } from "@/lib/profile/model.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"; diff --git a/cli/src/commands/profile/use.ts b/cli/src/commands/profile/use.ts index 41ca825..80d1d06 100644 --- a/cli/src/commands/profile/use.ts +++ b/cli/src/commands/profile/use.ts @@ -1,4 +1,5 @@ -import { assertNoEnvConfigMode, setActiveProfile } from "@/lib/profile/model.ts"; +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"; diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index 33ee326..4dde376 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -4,8 +4,9 @@ import { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/lib/lakeho import { writePagedOutput } from "@/lib/pager.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; -import { formatSchemaTree } from "@/commands/schema/lib/render.ts"; +import { buildSchemaTreeView } from "@/commands/schema/lib/views.ts"; import { schemaArgs } from "@/commands/schema/lib/args.ts"; +import { renderTreeText } from "@/ui/renderers/terminal.ts"; export const schemaCommand = defineCommand({ meta: { @@ -36,11 +37,15 @@ export const schemaCommand = defineCommand({ await writeQueryOutput(result, format, displayOptions, pagerOptions, sink); return; } - await writePagedOutput(formatSchemaTree(result, catalog), pagerOptions, sink); + await writePagedOutput( + renderTreeText(buildSchemaTreeView(result, catalog)), + pagerOptions, + sink, + ); }, }); -export function buildSchemaStatement(catalog: string): string { +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 ( diff --git a/cli/src/commands/schema/lib/render.ts b/cli/src/commands/schema/lib/render.ts deleted file mode 100644 index 5dbc87c..0000000 --- a/cli/src/commands/schema/lib/render.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildSchemaTreeView } from "@/commands/schema/lib/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/commands/schema/lib/views.ts b/cli/src/commands/schema/lib/views.ts index 738a38f..696e2ad 100644 --- a/cli/src/commands/schema/lib/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/lib/catalogs/rows.ts b/cli/src/lib/catalogs/rows.ts index 15e248f..9d76d5d 100644 --- a/cli/src/lib/catalogs/rows.ts +++ b/cli/src/lib/catalogs/rows.ts @@ -1,6 +1,5 @@ import { parseApiJson } from "@/lib/parse-api-json.ts"; import type { CatalogRow } from "@/lib/management/model.ts"; -import { formatCatalogsTable } from "@/lib/management/render.ts"; type DatabaseSummary = { name?: string; @@ -44,5 +43,3 @@ export function buildCatalogRowsFromResponses( } return rows; } - -export { formatCatalogsTable }; 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/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-client.ts b/cli/src/lib/lakehouse-client.ts index 6a40910..0237733 100644 --- a/cli/src/lib/lakehouse-client.ts +++ b/cli/src/lib/lakehouse-client.ts @@ -12,28 +12,6 @@ import { } from "@/lib/query-format.ts"; import { resolvePagerOptions, writePagedOutput, type PagerOptions } from "@/lib/pager.ts"; -export { - buildLakehouseAppendRequest, - 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"; diff --git a/cli/src/lib/lakehouse/query.ts b/cli/src/lib/lakehouse/query.ts index fd3f7b4..e030371 100644 --- a/cli/src/lib/lakehouse/query.ts +++ b/cli/src/lib/lakehouse/query.ts @@ -1,5 +1,4 @@ import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { urlencode } from "@/lib/encode.ts"; import { parseLakehouseQueryResponse, parseLakehouseQueryStream, @@ -73,7 +72,7 @@ export function buildLakehouseQueryShowRequest(queryId: string): HttpRequest { return { plane: "lakehouse", method: "GET", - endpoint: `/query/${urlencode(queryId)}`, + endpoint: `/query/${encodeURIComponent(queryId)}`, retry: true, }; } @@ -83,7 +82,7 @@ export function buildLakehouseQueryCancelRequest(input: LakehouseCancelInput): H return { plane: "lakehouse", method: "DELETE", - endpoint: `/query/${urlencode(input.queryId)}?${params.toString()}`, + endpoint: `/query/${encodeURIComponent(input.queryId)}?${params.toString()}`, }; } 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-output.ts b/cli/src/lib/management-output.ts index b2167b6..db36756 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -5,8 +5,6 @@ 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"; - function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } 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 index f919f99..f4dec33 100644 --- a/cli/src/lib/management-transport.ts +++ b/cli/src/lib/management-transport.ts @@ -1,7 +1,6 @@ import { getCliRuntime } from "@/lib/runtime.ts"; import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; import { sendHttp } from "@/lib/http-request.ts"; -export { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; export async function managementRequest( method: string, diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 3a5e84e..e835ec5 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -10,8 +10,6 @@ import { formatProgressStatus, startProgress } from "@/lib/progress.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { resolveWorkingProfile } from "@/lib/profile-store.ts"; -export { configureCredentialStatus } from "@/lib/profile/model.ts"; - export type ConfigureAuthPlane = "management" | "lakehouse"; export type ConfigureVerifyError = { diff --git a/cli/src/lib/profile/model.ts b/cli/src/lib/profile/model.ts index 491d7f4..79d6c74 100644 --- a/cli/src/lib/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -6,7 +6,6 @@ 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 "@/lib/management/model.ts"; @@ -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"; @@ -541,15 +522,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) => { diff --git a/cli/src/lib/profile/views.ts b/cli/src/lib/profile/views.ts index e1943af..aa65f98 100644 --- a/cli/src/lib/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -27,7 +27,7 @@ import type { ProfileInspect, ProfileSummary, } from "@/lib/profile/model.ts"; -import { isFromEnvProfile } from "@/lib/profile/model.ts"; +import { isFromEnvProfile } from "@/lib/profile-store.ts"; import type { ShellExportView } from "@/ui/shell/model.ts"; const ACTIVE_PROFILE_MARK = "✓"; diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 9f1a943..a248af4 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -7,7 +7,6 @@ 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 { 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; @@ -384,7 +369,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(/^/, "/"); From 1cc9d4fa09eec56961664c7c39e2a1d2dd523b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 15:40:19 +0200 Subject: [PATCH 12/22] refactor(cli): remove test-only output APIs Keep writeCommandOutput as the single command-output composition point and require output sinks explicitly. Remove compatibility wrappers and the unused management transport that were referenced only by their own tests.\n\nMake query rendering and management row extraction independent of ambient output context. This removes six dependency cycles while preserving command behavior. --- cli/src/commands/query/run.ts | 2 +- cli/src/commands/schema/index.ts | 2 +- cli/src/lib/command-output.test.ts | 150 ++++++++--------------- cli/src/lib/command-output.ts | 79 +----------- cli/src/lib/errors.test.ts | 8 -- cli/src/lib/lakehouse-client.ts | 17 +-- cli/src/lib/management-output.test.ts | 15 --- cli/src/lib/management-output.ts | 14 +-- cli/src/lib/management-transport.test.ts | 55 --------- cli/src/lib/management-transport.ts | 21 ---- cli/src/lib/oauth-profile.test.ts | 13 +- cli/src/lib/pager.ts | 4 +- cli/src/lib/runtime.ts | 8 -- 13 files changed, 75 insertions(+), 313 deletions(-) delete mode 100644 cli/src/lib/management-transport.test.ts delete mode 100644 cli/src/lib/management-transport.ts diff --git a/cli/src/commands/query/run.ts b/cli/src/commands/query/run.ts index 47548cf..bd2c272 100644 --- a/cli/src/commands/query/run.ts +++ b/cli/src/commands/query/run.ts @@ -33,6 +33,6 @@ export const queryRunCommand = defineCommand({ httpOptions: readTimeoutMs !== undefined ? { readTimeoutMs } : undefined, }; const result = await executeLakehouseQuery(input, execution, format !== "json" && !sink.json); - await writeQueryOutput(result, format, displayOptions, pagerOptions, sink); + await writeQueryOutput(result, format, sink, displayOptions, pagerOptions); }, }); diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index 4dde376..6a8c6b1 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -34,7 +34,7 @@ export const schemaCommand = defineCommand({ format !== "json" && !sink.json, ); if (format !== "human" || sink.json) { - await writeQueryOutput(result, format, displayOptions, pagerOptions, sink); + await writeQueryOutput(result, format, sink, displayOptions, pagerOptions); return; } await writePagedOutput( diff --git a/cli/src/lib/command-output.test.ts b/cli/src/lib/command-output.test.ts index 395cff3..a46437b 100644 --- a/cli/src/lib/command-output.test.ts +++ b/cli/src/lib/command-output.test.ts @@ -1,168 +1,118 @@ 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"; +import { writeCommandOutput } from "@/lib/command-output.ts"; +import { createCliRuntime, type OutputSink } from "@/lib/runtime.ts"; function captureOutput(json: boolean): { stdout: string[]; stderr: string[]; - runtime: ReturnType; + sink: OutputSink; } { 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 }; + 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, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ kind: "raw_api", body: '{"ok":true}' }); - }); + 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, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ + 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, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ kind: "human", text: "configure show text" }); - }); + 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, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeDeleteSuccess("Deleted item.", "item_1"); - }); + 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 listBody = JSON.stringify({ - databases: [{ id: "db_1", name: "Analytics" }], - }); - const { stdout, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeManagementOutput(listBody); - }); + 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, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ + 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, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ + 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, runtime } = captureOutput(true); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ + 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, runtime } = captureOutput(false); - await runWithCliRuntime(runtime, async () => { - await writeCommandOutput({ + const { stderr, sink } = captureOutput(false); + 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; - } + }, + sink, + ); + 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/src/lib/command-output.ts b/cli/src/lib/command-output.ts index c66d274..c3f1e1d 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/lakehouse-client.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/errors.test.ts b/cli/src/lib/errors.test.ts index 2e369c5..cd388d2 100644 --- a/cli/src/lib/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, @@ -117,13 +116,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/lakehouse-client.ts b/cli/src/lib/lakehouse-client.ts index 0237733..9286dba 100644 --- a/cli/src/lib/lakehouse-client.ts +++ b/cli/src/lib/lakehouse-client.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, @@ -80,19 +78,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") { @@ -125,11 +116,11 @@ export function renderManagementTabularOutput( 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/src/lib/management-output.test.ts b/cli/src/lib/management-output.test.ts index 7120ab6..2ab6094 100644 --- a/cli/src/lib/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 db36756..a7309d3 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,5 +1,4 @@ import type { CommandArgs } from "@/lib/command.ts"; -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"; @@ -14,8 +13,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]; @@ -23,7 +20,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)); @@ -32,21 +29,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] : []; diff --git a/cli/src/lib/management-transport.test.ts b/cli/src/lib/management-transport.test.ts deleted file mode 100644 index eaae801..0000000 --- a/cli/src/lib/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/src/lib/management-transport.ts b/cli/src/lib/management-transport.ts deleted file mode 100644 index f4dec33..0000000 --- a/cli/src/lib/management-transport.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getCliRuntime } from "@/lib/runtime.ts"; -import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp } from "@/lib/http-request.ts"; - -export async function managementRequest( - method: string, - endpoint: string, - body?: string, - execution: ExecutionContext = createExecutionContext(getCliRuntime()), -): Promise { - return sendHttp( - { - plane: "management", - method, - endpoint, - body, - contentType: body ? "application/json" : undefined, - }, - execution, - ); -} diff --git a/cli/src/lib/oauth-profile.test.ts b/cli/src/lib/oauth-profile.test.ts index cf32d7b..4001b3c 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -13,7 +13,8 @@ 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 { createExecutionContext } from "@/lib/execution-context.ts"; +import { sendHttp } from "@/lib/http-request.ts"; import { createCliRuntime, refreshCliRuntimeContext, runWithCliRuntime } from "@/lib/runtime.ts"; const profileName = "default"; @@ -203,7 +204,10 @@ describe("management request auth resolution", () => { const runtime = createCliRuntime({ debug: false, json: false, agent: false }); await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); - await managementRequest("GET", "/whoami"); + await sendHttp( + { plane: "management", method: "GET", endpoint: "/whoami" }, + createExecutionContext(runtime), + ); }); expect(getStoredAccessToken(profileName)).toBe("acc3"); @@ -225,7 +229,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/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/src/lib/runtime.ts b/cli/src/lib/runtime.ts index 27c34f5..55aec92 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,7 +1,6 @@ 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 { createCliSession, type CliSession } from "@/lib/cli-session.ts"; import { configFile } from "@/lib/config.ts"; import { profilesDir } from "@/lib/profile-store.ts"; @@ -105,10 +104,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 }); -} From dfb9dc9082902c2d4843bad507367660302f77a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 15:42:19 +0200 Subject: [PATCH 13/22] refactor(cli): compose arguments by semantic ownership Keep lakehouse resource arguments separate from query presentation policy. Move request timeout ownership to timeout-args, keep query invocation arguments with the query command, and pass agent context explicitly when resolving output behavior.\n\nSchema now composes only the format, pager, and timeout fragments it supports, rejecting query-only layout, columns, and max-width flags instead of accepting ineffective options. --- cli/src/commands/query/index.ts | 2 +- cli/src/commands/query/lib/args.ts | 17 +++ cli/src/commands/query/run.ts | 13 +- cli/src/commands/schema/index.test.ts | 16 ++- cli/src/commands/schema/index.ts | 10 +- cli/src/commands/schema/lib/args.ts | 11 +- cli/src/commands/upload/index.ts | 3 +- cli/src/commands/upsert/index.ts | 3 +- cli/src/lib/lakehouse/args.test.ts | 93 +------------ cli/src/lib/lakehouse/args.ts | 192 +------------------------- cli/src/lib/query-output-args.test.ts | 49 +++++++ cli/src/lib/query-output-args.ts | 129 +++++++++++++++++ cli/src/lib/timeout-args.ts | 13 ++ 13 files changed, 247 insertions(+), 304 deletions(-) create mode 100644 cli/src/commands/query/lib/args.ts create mode 100644 cli/src/lib/query-output-args.test.ts create mode 100644 cli/src/lib/query-output-args.ts diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index 59829c7..7568ab2 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,7 +1,7 @@ import type { CommandArgs } from "@/lib/command.ts"; import { normalizeDefaultSubCommandRawArgs, valueFlagsFor } from "@/lib/command-delegation.ts"; import { defineCommand } from "@/lib/command.ts"; -import { queryRunArgs } from "@/lib/lakehouse/args.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"; 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 index bd2c272..9b6fe24 100644 --- a/cli/src/commands/query/run.ts +++ b/cli/src/commands/query/run.ts @@ -2,11 +2,9 @@ import { CliError } from "@/lib/errors.ts"; import { optionalStringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; -import { - parseQueryOutputOptions, - parseRequestReadTimeoutMs, - queryRunArgs, -} from "@/lib/lakehouse/args.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({ @@ -24,7 +22,10 @@ export const queryRunCommand = defineCommand({ if (statement === undefined) { throw new CliError('Provide a SQL statement, e.g. altertable query "SELECT 1".'); } - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, rawArgs); + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, { + agent: execution.cli.agent, + rawArgs, + }); const readTimeoutMs = parseRequestReadTimeoutMs(args); const input = { statement, diff --git a/cli/src/commands/schema/index.test.ts b/cli/src/commands/schema/index.test.ts index ad1434c..f970585 100644 --- a/cli/src/commands/schema/index.test.ts +++ b/cli/src/commands/schema/index.test.ts @@ -97,8 +97,18 @@ describe("schema command", () => { ); }); - test("rejects query layout flags because human output is always the tree", () => { - const result = runCommandWithTestRuntime(["schema", "analytics", "--layout", "line"]); - return expect(result).rejects.toThrow(); + 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/schema/index.ts b/cli/src/commands/schema/index.ts index 6a8c6b1..f5b98c1 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -1,6 +1,7 @@ import { stringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; -import { parseQueryOutputOptions, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.ts"; +import { parseQueryOutputOptions } from "@/lib/query-output-args.ts"; +import { parseRequestReadTimeoutMs } from "@/lib/timeout-args.ts"; import { writePagedOutput } from "@/lib/pager.ts"; import { writeQueryOutput } from "@/lib/lakehouse-client.ts"; import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts"; @@ -18,11 +19,10 @@ export const schemaCommand = defineCommand({ args: schemaArgs, async run({ args, rawArgs, execution, sink }) { const catalog = stringArg(args, "catalog"); - // --layout is not supported: human output is always the schema tree. - const { format, displayOptions, pagerOptions } = parseQueryOutputOptions( - { ...args, layout: undefined }, + const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, { + agent: execution.cli.agent, rawArgs, - ); + }); const readTimeoutMs = parseRequestReadTimeoutMs(args); const queryInput = { statement: buildSchemaStatement(catalog), diff --git a/cli/src/commands/schema/lib/args.ts b/cli/src/commands/schema/lib/args.ts index 13ceb35..c3f2c49 100644 --- a/cli/src/commands/schema/lib/args.ts +++ b/cli/src/commands/schema/lib/args.ts @@ -1,11 +1,10 @@ import { defineArgs } from "@/lib/command.ts"; -import { queryRunArgs } from "@/lib/lakehouse/args.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 }, - format: queryRunArgs.format, - columns: queryRunArgs.columns, - "max-width": queryRunArgs["max-width"], - pager: queryRunArgs.pager, - "read-timeout": queryRunArgs["read-timeout"], + ...queryResultFormatArgs, + ...queryPagerArgs, + ...requestReadTimeoutArgs, }); diff --git a/cli/src/commands/upload/index.ts b/cli/src/commands/upload/index.ts index 396ac61..b71dba6 100644 --- a/cli/src/commands/upload/index.ts +++ b/cli/src/commands/upload/index.ts @@ -2,7 +2,8 @@ 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, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.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"; diff --git a/cli/src/commands/upsert/index.ts b/cli/src/commands/upsert/index.ts index 1be889d..6ac0297 100644 --- a/cli/src/commands/upsert/index.ts +++ b/cli/src/commands/upsert/index.ts @@ -2,7 +2,8 @@ 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, parseRequestReadTimeoutMs } from "@/lib/lakehouse/args.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"; diff --git a/cli/src/lib/lakehouse/args.test.ts b/cli/src/lib/lakehouse/args.test.ts index 06eafed..c365a6c 100644 --- a/cli/src/lib/lakehouse/args.test.ts +++ b/cli/src/lib/lakehouse/args.test.ts @@ -1,97 +1,6 @@ import { describe, expect, test } from "bun:test"; import { CliError } from "@/lib/errors.ts"; -import { - parsePagerOptions, - parseQueryDisplayOptions, - parseQueryOutputOptions, - parseQueryLayout, - parseQueryResultFormatArg, - parseLakehouseFileContentType, -} from "@/lib/lakehouse/args.ts"; -import { parseQueryResultFormat } from "@/lib/lakehouse-client.ts"; -import { setCliContext } from "@/context.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" }); - }); -}); +import { parseLakehouseFileContentType } from "@/lib/lakehouse/args.ts"; describe("parseLakehouseFileContentType", () => { test("maps supported lakehouse file formats to content types", () => { diff --git a/cli/src/lib/lakehouse/args.ts b/cli/src/lib/lakehouse/args.ts index 97c0579..88a49da 100644 --- a/cli/src/lib/lakehouse/args.ts +++ b/cli/src/lib/lakehouse/args.ts @@ -1,22 +1,8 @@ import { defineArgs } from "@/lib/command.ts"; -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, QUERY_LAYOUT_OPTIONS, type QueryLayout } from "@/ui/layouts/query.ts"; +import { requestReadTimeoutArgs } from "@/lib/timeout-args.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; export const LAKEHOUSE_FILE_FORMAT_OPTIONS = ["csv", "json", "parquet"] as const; -const requestReadTimeoutArg = { - type: "string", - description: "Read timeout in seconds for this request (overrides global --read-timeout)", -} as const; export const lakehouseTableArgs = defineArgs({ catalog: { type: "string", description: "Catalog name", required: true }, @@ -32,183 +18,11 @@ export const lakehouseFileArgs = defineArgs({ options: [...LAKEHOUSE_FILE_FORMAT_OPTIONS], }, file: { type: "string", description: "Local file to upload", required: true }, - "read-timeout": requestReadTimeoutArg, + ...requestReadTimeoutArgs, }); -export const queryRunArgs = defineArgs({ - 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": requestReadTimeoutArg, -}); - -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 parseLakehouseFileContentType(format: string | undefined): string | undefined { - if (!format) { - return undefined; - } + if (!format) return undefined; switch (format.toLowerCase()) { case "csv": 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..b885303 --- /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/lakehouse-client.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/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"); +} From f5180c7b0e8a173bff5df68a75533c194a612b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:01:08 +0200 Subject: [PATCH 14/22] refactor(cli): clarify shared runtime boundaries Move prompts, error presentation, catalog loading, query output, and release tooling to their semantic owners. Pass execution context explicitly during profile verification and remove dead profile presentation and test-only production APIs.\n\nAdd a production Knip gate and eliminate all circular dependencies so runtime ownership stays reviewable as commands evolve. --- cli/AGENTS.md | 14 +- cli/knip.json | 4 +- cli/package.json | 3 +- cli/scripts/release-manifest.ts | 29 +++ cli/scripts/release.test.ts | 2 +- cli/scripts/release.ts | 2 +- cli/scripts/script-output.test.ts | 2 +- cli/src/cli.ts | 4 +- cli/src/commands/api/index.test.ts | 4 +- cli/src/commands/api/lib/body.ts | 15 -- cli/src/commands/api/lib/command.ts | 10 +- cli/src/commands/api/lib/http.test.ts | 46 ++-- cli/src/commands/api/lib/http.ts | 2 +- cli/src/commands/api/lib/render.ts | 13 +- cli/src/commands/catalogs/lib/requests.ts | 25 +- cli/src/commands/catalogs/list.ts | 2 +- cli/src/commands/completion/index.test.ts | 13 +- cli/src/commands/completion/index.ts | 4 +- cli/src/commands/completion/lib/completion.ts | 8 +- cli/src/commands/completion/lib/spec.test.ts | 12 +- cli/src/commands/completion/lib/spec.ts | 4 - cli/src/commands/duckdb/index.ts | 4 +- cli/src/commands/login/index.test.ts | 13 +- cli/src/commands/profile/lib/profile.ts | 9 +- cli/src/commands/profile/status.ts | 38 ++- cli/src/commands/query/run.ts | 2 +- cli/src/commands/schema/index.ts | 2 +- cli/src/context.ts | 30 +-- cli/src/lib/auth.test.ts | 3 +- cli/src/lib/catalogs/rows.ts | 45 ---- cli/src/lib/cli-context.ts | 23 ++ cli/src/lib/cli-session.test.ts | 3 +- cli/src/lib/command-output.ts | 2 +- cli/src/lib/config.test.ts | 44 +--- cli/src/lib/errors.test.ts | 4 +- cli/src/lib/errors.ts | 28 --- cli/src/lib/global-flags.test.ts | 7 +- cli/src/lib/lakehouse/query.test.ts | 8 +- cli/src/lib/management-output.ts | 27 +- cli/src/lib/management/catalogs.ts | 53 ++++ cli/src/lib/oauth-flow.test.ts | 11 +- cli/src/lib/oauth-profile.test.ts | 31 +-- cli/src/lib/oauth-profile.ts | 4 - cli/src/lib/profile-configure-core.ts | 7 - ...rofile-configure-credential-status.test.ts | 40 --- .../lib/profile-configure-data-plane.test.ts | 10 +- cli/src/lib/profile-configure-interactive.ts | 197 +-------------- cli/src/lib/profile-configure.test.ts | 7 +- cli/src/lib/profile-configure.ts | 7 +- cli/src/lib/profile-status.test.ts | 11 +- cli/src/lib/profile-status.ts | 12 +- cli/src/lib/profile/active-context.test.ts | 177 ------------- cli/src/lib/profile/model.test.ts | 140 +---------- cli/src/lib/profile/model.ts | 68 ----- cli/src/lib/profile/render.ts | 32 +-- cli/src/lib/profile/views.ts | 232 +----------------- cli/src/lib/query-format.test.ts | 2 +- cli/src/lib/query-output-args.ts | 2 +- .../{lakehouse-client.ts => query-output.ts} | 39 --- cli/src/lib/runtime.ts | 23 +- cli/src/lib/secrets.ts | 20 +- cli/src/lib/tabular-result.ts | 45 +++- cli/src/lib/updater.test.ts | 9 +- cli/src/lib/updater.ts | 14 -- cli/src/lib/usage.test.ts | 10 +- cli/src/release-manifest.ts | 27 -- cli/src/test-utils/cli.ts | 3 +- cli/src/test-utils/runtime.ts | 17 ++ cli/src/ui/error.ts | 25 ++ cli/src/ui/prompts.ts | 141 +++++++++++ cli/src/ui/terminal/spacing.ts | 4 - cli/src/ui/terminal/styles.test.ts | 5 - cli/src/ui/terminal/table.test.ts | 16 +- scripts/verify.sh | 1 + 74 files changed, 535 insertions(+), 1417 deletions(-) create mode 100644 cli/scripts/release-manifest.ts delete mode 100644 cli/src/lib/catalogs/rows.ts create mode 100644 cli/src/lib/cli-context.ts create mode 100644 cli/src/lib/management/catalogs.ts delete mode 100644 cli/src/lib/profile/active-context.test.ts rename cli/src/lib/{lakehouse-client.ts => query-output.ts} (68%) create mode 100644 cli/src/test-utils/runtime.ts create mode 100644 cli/src/ui/error.ts create mode 100644 cli/src/ui/prompts.ts diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 5f67cdd..13bc653 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -43,11 +43,10 @@ bun test "$PWD"/tests/integration.e2e.ts - **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`): `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. Accepts `sink` as the last argument; lib callers may omit it (defaults to `getOutputSink()`). -- Helpers: `writeManagementOutput`, `writeLakehouseOutput`, `writeJsonOrRaw` (thin wrappers around `writeCommandOutput`). +- **`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 @@ -107,12 +106,11 @@ bun test "$PWD"/tests/integration.e2e.ts | `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/lakehouse-client.ts` | Lakehouse HTTP + query rendering | +| `lib/query-output.ts` | Shared query output formats and sink dispatch | | `lib/http.ts` | Shared HTTP transport, logging, mock file support | -| `lib/management-transport.ts` | Management API HTTP transport | -| `lib/management/` | Shared management identity and catalog presentation | -| `lib/catalogs/rows.ts` | Catalog rows shared by `catalogs` and `duckdb` | -| `commands/catalogs/lib/requests.ts` | Declarative catalog request builders and execution | +| `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 | diff --git a/cli/knip.json b/cli/knip.json index 26adbc1..5491bb2 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/**/*.test.ts", "scripts/**/*.test.ts", "scripts/**/*.ts"], - "project": ["src/**/*.ts", "scripts/**/*.ts"], + "entry": ["src/cli.ts!", "src/**/*.test.ts", "scripts/**/*.test.ts", "scripts/**/*.ts"], + "project": ["src/**/*.ts!", "!src/test-utils/**!", "scripts/**/*.ts"], "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, "ignoreDependencies": ["undici"], diff --git a/cli/package.json b/cli/package.json index e573550..81fd9f3 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/scripts/release.test.ts b/cli/scripts/release.test.ts index e5d411a..9e89bb0 100644 --- a/cli/scripts/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/scripts/script-output.test.ts b/cli/scripts/script-output.test.ts index 68fdb1f..ab23e36 100644 --- a/cli/scripts/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 383417f..78572d6 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -15,11 +15,9 @@ import { EXIT_SUCCESS, getCliExitCode, isCittyCliError, - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; +import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; import { defineArgs, defineRootCommand, runCommandTree, type Command } from "@/lib/command.ts"; import { resolveSubCommandForUsage, diff --git a/cli/src/commands/api/index.test.ts b/cli/src/commands/api/index.test.ts index 4b76602..cdb49d4 100644 --- a/cli/src/commands/api/index.test.ts +++ b/cli/src/commands/api/index.test.ts @@ -6,7 +6,7 @@ import { buildMainCommand } from "@/cli.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 "@/commands/completion/lib/spec.ts"; +import { buildCompletionSpec } from "@/commands/completion/lib/spec.ts"; import { createCliRuntime, getCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; import { runCommandWithTestRuntime } from "@/test-utils/cli.ts"; import { defineArgs, runCommandTree, type Command } from "@/lib/command.ts"; @@ -111,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"); diff --git a/cli/src/commands/api/lib/body.ts b/cli/src/commands/api/lib/body.ts index 7b1b3fa..dc3b403 100644 --- a/cli/src/commands/api/lib/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 index cf7b7d1..dbcdab7 100644 --- a/cli/src/commands/api/lib/command.ts +++ b/cli/src/commands/api/lib/command.ts @@ -4,7 +4,6 @@ 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 { withManagementFormatArg } from "@/lib/management-output.ts"; import { readArgvFlagValue } from "@/lib/timeout-args.ts"; export const HTTP_METHOD_NAMES = ["GET", "POST", "PATCH", "DELETE", "PUT"] as const; @@ -37,7 +36,14 @@ export const API_HTTP_BASE_ARGS = defineArgs({ }); export const API_VALUE_FLAGS = valueFlagsFor(API_HTTP_BASE_ARGS); -export const API_HTTP_ARGS = withManagementFormatArg(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, diff --git a/cli/src/commands/api/lib/http.test.ts b/cli/src/commands/api/lib/http.test.ts index 1a96e7f..155c377 100644 --- a/cli/src/commands/api/lib/http.test.ts +++ b/cli/src/commands/api/lib/http.test.ts @@ -3,7 +3,7 @@ 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 "@/commands/api/lib/body.ts"; +import { readJsonBody, resolveApiRequestPayload } from "@/commands/api/lib/body.ts"; import { apiHttpResultOutput, executeApiHttp, @@ -63,41 +63,47 @@ async function runApiOperation(args: ApiHttpArgs): Promise { } 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"}', }), @@ -117,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: [] }); }); }); diff --git a/cli/src/commands/api/lib/http.ts b/cli/src/commands/api/lib/http.ts index ddee330..ca324ce 100644 --- a/cli/src/commands/api/lib/http.ts +++ b/cli/src/commands/api/lib/http.ts @@ -2,7 +2,7 @@ import { resolveApiRequestPayload, type ParsedApiField } from "@/commands/api/li 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 type { ExecutionContext } from "@/lib/execution-context.ts"; import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; diff --git a/cli/src/commands/api/lib/render.ts b/cli/src/commands/api/lib/render.ts index 3f51a19..1b71d8a 100644 --- a/cli/src/commands/api/lib/render.ts +++ b/cli/src/commands/api/lib/render.ts @@ -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/src/commands/catalogs/lib/requests.ts b/cli/src/commands/catalogs/lib/requests.ts index ce7ad81..dcd8447 100644 --- a/cli/src/commands/catalogs/lib/requests.ts +++ b/cli/src/commands/catalogs/lib/requests.ts @@ -1,7 +1,4 @@ -import { buildCatalogRowsFromResponses } from "@/lib/catalogs/rows.ts"; -import type { CatalogRow } from "@/lib/management/model.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; -import { sendHttp, type HttpRequest } from "@/lib/http-request.ts"; +import type { HttpRequest } from "@/lib/http-request.ts"; export function buildCatalogCreateRequest(env: string, name: string): HttpRequest { return { @@ -12,23 +9,3 @@ export function buildCatalogCreateRequest(env: string, name: string): HttpReques contentType: "application/json", }; } - -function buildCatalogListRequest(env: string, kind: "databases" | "connections") { - return { - plane: "management" as const, - method: "GET", - endpoint: `/environments/${env}/${kind}`, - }; -} - -export async function fetchManagementCatalogRows( - env: string, - execution: ExecutionContext, -): Promise { - const databasesResponse = await sendHttp(buildCatalogListRequest(env, "databases"), execution); - const connectionsResponse = await sendHttp( - buildCatalogListRequest(env, "connections"), - execution, - ); - return buildCatalogRowsFromResponses(databasesResponse, connectionsResponse); -} diff --git a/cli/src/commands/catalogs/list.ts b/cli/src/commands/catalogs/list.ts index b37d47f..b72764b 100644 --- a/cli/src/commands/catalogs/list.ts +++ b/cli/src/commands/catalogs/list.ts @@ -1,5 +1,5 @@ import { requireManagementEnv } from "@/lib/auth.ts"; -import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.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"; diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index 35f096a..e6dc3d1 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -5,9 +5,10 @@ import { join } from "node:path"; import { defineCommand, type Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { createCompletionCommand } from "@/commands/completion/index.ts"; -import type { ConfigurePrompts } from "@/lib/profile-configure-interactive.ts"; -import { buildCompletionSpec, flattenTopLevelNames } from "@/commands/completion/lib/spec.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.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`, @@ -58,7 +59,7 @@ function setTestHome(): string { return testHome; } -function createMockPrompts(selection: string): ConfigurePrompts { +function createMockPrompts(selection: string): Prompts { return { writePrompt() {}, readLine: async () => "", @@ -85,7 +86,7 @@ async function captureCompletionOutput( async function runCompletion( getRootCommand: () => Command, shell?: string, - prompts?: ConfigurePrompts, + prompts?: Prompts, ): Promise { const completionCommand = createCompletionCommand(getRootCommand, { prompts }); const supportedShells = new Set(["bash", "zsh", "fish"]); @@ -352,7 +353,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 index 1f134b6..19ddd6a 100644 --- a/cli/src/commands/completion/index.ts +++ b/cli/src/commands/completion/index.ts @@ -1,5 +1,5 @@ import { defineCommand, type Command } from "@/lib/command.ts"; -import { defaultConfigurePrompts } from "@/lib/profile-configure-interactive.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"; @@ -23,7 +23,7 @@ export function createCompletionCommand( getRootCommand: GetRootCommand, options: CompletionCommandOptions = {}, ): Command { - const prompts = options.prompts ?? defaultConfigurePrompts; + const prompts = options.prompts ?? defaultPrompts; return defineCommand({ meta: { name: "completion", diff --git a/cli/src/commands/completion/lib/completion.ts b/cli/src/commands/completion/lib/completion.ts index 852e622..13852d5 100644 --- a/cli/src/commands/completion/lib/completion.ts +++ b/cli/src/commands/completion/lib/completion.ts @@ -4,7 +4,7 @@ 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 { ConfigurePrompts } from "@/lib/profile-configure-interactive.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"; @@ -19,7 +19,7 @@ export type SupportedShell = "bash" | "zsh" | "fish"; export type CompletionRootInput = | { kind: "help" } | { kind: "install"; shell?: SupportedShell; updateRc: boolean }; -export type CompletionCommandOptions = { prompts?: ConfigurePrompts }; +export type CompletionCommandOptions = { prompts?: Prompts }; export type InstallResult = { shell: SupportedShell; completionPath: string; @@ -231,9 +231,7 @@ export function formatCompletionHelpMessage(): string { ); } -export async function promptCompletionInput( - prompts: ConfigurePrompts, -): Promise { +export async function promptCompletionInput(prompts: Prompts): Promise { const selected = (await prompts.readSelect( "Shell completion", [ diff --git a/cli/src/commands/completion/lib/spec.test.ts b/cli/src/commands/completion/lib/spec.test.ts index 5f0fba9..4db1e4e 100644 --- a/cli/src/commands/completion/lib/spec.test.ts +++ b/cli/src/commands/completion/lib/spec.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test"; import { defineCommand } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; -import { - buildCompletionSpec, - collectCompletionContexts, - flattenTopLevelNames, -} from "@/commands/completion/lib/spec.ts"; +import { buildCompletionSpec, collectCompletionContexts } from "@/commands/completion/lib/spec.ts"; import { formatBashCompletion, formatBashFlagWordList, @@ -61,7 +57,7 @@ describe("buildCompletionSpec", () => { }); const spec = buildCompletionSpec(root); - expect(flattenTopLevelNames(spec)).toEqual(["visible"]); + expect(spec.subcommands.map((command) => command.name)).toEqual(["visible"]); }); test("skips commands marked hidden", () => { @@ -73,7 +69,7 @@ describe("buildCompletionSpec", () => { }); 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/commands/completion/lib/spec.ts b/cli/src/commands/completion/lib/spec.ts index 9582ed0..b178bf3 100644 --- a/cli/src/commands/completion/lib/spec.ts +++ b/cli/src/commands/completion/lib/spec.ts @@ -162,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/duckdb/index.ts b/cli/src/commands/duckdb/index.ts index e4e6e01..af739e0 100644 --- a/cli/src/commands/duckdb/index.ts +++ b/cli/src/commands/duckdb/index.ts @@ -8,7 +8,7 @@ import { configureVerify } from "@/lib/profile-status.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { defineCommand } from "@/lib/command.ts"; import { optionalStringArg } from "@/lib/args.ts"; -import { fetchManagementCatalogRows } from "@/commands/catalogs/lib/requests.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"; @@ -85,7 +85,7 @@ async function runDuckdb(input: DuckdbInput, execution: ExecutionContext): Promi ); } - const verify = await configureVerify(["lakehouse"]); + const verify = await configureVerify(["lakehouse"], execution); if (!verify.verified.lakehouse) { throw new ConfigurationError(LOGIN_PROMPT); } diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts index 8b8928e..108ece4 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -3,7 +3,8 @@ 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 { getStoredAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.ts"; +import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; +import { secretGet } from "@/lib/secrets.ts"; import { getActiveProfileName, profileExists } from "@/lib/profile-store.ts"; import { createCliTestHarness, runCommandWithTestRuntime } from "@/test-utils/cli.ts"; import { delay } from "@/test-utils/time.ts"; @@ -26,6 +27,10 @@ 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"); @@ -122,7 +127,7 @@ describe("login command", () => { 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(getStoredAccessToken("default")).toBe("access_token"); + expect(storedAccessToken("default")).toBe("access_token"); expect(cli.stderr.join("\n")).toContain('using profile "default"'); }); @@ -141,9 +146,9 @@ describe("login command", () => { "org_b_token", ); - expect(getStoredAccessToken("default")).toBe("org_a_token"); + expect(storedAccessToken("default")).toBe("org_a_token"); expect(getActiveProfileName()).toBe("org-b_production"); - expect(getStoredAccessToken("org-b_production")).toBe("org_b_token"); + expect(storedAccessToken("org-b_production")).toBe("org_b_token"); }); test("stores endpoint overrides only after a successful login", async () => { diff --git a/cli/src/commands/profile/lib/profile.ts b/cli/src/commands/profile/lib/profile.ts index b8c727b..bc8f0d5 100644 --- a/cli/src/commands/profile/lib/profile.ts +++ b/cli/src/commands/profile/lib/profile.ts @@ -3,10 +3,7 @@ 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 { - defaultConfigurePrompts, - type ConfigurePrompts, -} from "@/lib/profile-configure-interactive.ts"; +import { defaultPrompts, type Prompts } from "@/ui/prompts.ts"; import { profileSwitchOption } from "@/lib/profile/views.ts"; import { envConfigMode, @@ -57,9 +54,7 @@ export function configuredVerificationPlanes(profile: ProfileInspect): Configure return planes; } -export async function promptProfileSwitch( - prompts: ConfigurePrompts = defaultConfigurePrompts, -): Promise { +export async function promptProfileSwitch(prompts: Prompts = defaultPrompts): Promise { const profiles = listProfiles(); return prompts.readSelect( "Switch profile", diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts index 90c8dac..f694c53 100644 --- a/cli/src/commands/profile/status.ts +++ b/cli/src/commands/profile/status.ts @@ -1,9 +1,7 @@ -import { getCliContext, setCliContext } from "@/context.ts"; import { inspectProfile } from "@/lib/profile/model.ts"; import { formatProfileStatus } from "@/lib/profile/render.ts"; import { profileStatusToJson } from "@/lib/profile/views.ts"; import { configureVerify } from "@/lib/profile-status.ts"; -import { refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { configuredVerificationPlanes, existingProfileName, @@ -15,27 +13,21 @@ 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, sink }) { + async run({ args, execution, sink }) { const profileName = existingProfileName(profileShowTargetName(args)); - const previous = getCliContext(); - try { - const next = { ...previous, profile: profileName }; - setCliContext(next); - refreshCliRuntimeContext(next); - const profile = inspectProfile(profileName); - const verification = await configureVerify(configuredVerificationPlanes(profile)); - const result = { profile, verification }; - await writeCommandOutput( - { - kind: "normalized", - data: profileStatusToJson(result), - humanText: formatProfileStatus(result), - }, - sink, - ); - } finally { - setCliContext(previous); - refreshCliRuntimeContext(previous); - } + 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/query/run.ts b/cli/src/commands/query/run.ts index 9b6fe24..44905ef 100644 --- a/cli/src/commands/query/run.ts +++ b/cli/src/commands/query/run.ts @@ -1,7 +1,7 @@ import { CliError } from "@/lib/errors.ts"; import { optionalStringArg } from "@/lib/args.ts"; import { defineCommand } from "@/lib/command.ts"; -import { writeQueryOutput } from "@/lib/lakehouse-client.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"; diff --git a/cli/src/commands/schema/index.ts b/cli/src/commands/schema/index.ts index f5b98c1..2578e41 100644 --- a/cli/src/commands/schema/index.ts +++ b/cli/src/commands/schema/index.ts @@ -3,7 +3,7 @@ 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 { writeQueryOutput } from "@/lib/lakehouse-client.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"; 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/lib/auth.test.ts b/cli/src/lib/auth.test.ts index 910a9e1..823b559 100644 --- a/cli/src/lib/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/catalogs/rows.ts b/cli/src/lib/catalogs/rows.ts deleted file mode 100644 index 9d76d5d..0000000 --- a/cli/src/lib/catalogs/rows.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { parseApiJson } from "@/lib/parse-api-json.ts"; -import type { CatalogRow } from "@/lib/management/model.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; -} 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/src/lib/cli-session.test.ts b/cli/src/lib/cli-session.test.ts index 888daa2..a3dfd73 100644 --- a/cli/src/lib/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/src/lib/command-output.ts b/cli/src/lib/command-output.ts index c3f1e1d..5c5ef4c 100644 --- a/cli/src/lib/command-output.ts +++ b/cli/src/lib/command-output.ts @@ -1,4 +1,4 @@ -import 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"; diff --git a/cli/src/lib/config.test.ts b/cli/src/lib/config.test.ts index e79871d..509b39b 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -15,18 +15,13 @@ import { resolveApiBase, resolveManagementApiBase, } from "@/lib/config.ts"; -import { - secretGet, - secretSet, - secretExists, - resetSecretWarningsForTests, - setSpawnSyncForTests, -} from "@/lib/secrets.ts"; +import { secretGet, secretSet, secretExists } 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"; +import { createCliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; let testHome = ""; const profileName = "default"; @@ -35,7 +30,6 @@ beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-cli-test-")); process.env.ALTERTABLE_CONFIG_HOME = testHome; process.env.ALTERTABLE_SECRET_BACKEND = "file"; - resetSecretWarningsForTests(); }); afterEach(() => { @@ -257,36 +251,4 @@ describe("profile --configure argv secrets", () => { 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/src/lib/errors.test.ts b/cli/src/lib/errors.test.ts index cd388d2..57dcafd 100644 --- a/cli/src/lib/errors.test.ts +++ b/cli/src/lib/errors.test.ts @@ -16,12 +16,10 @@ import { errorCodeFromError, getCliExitCode, httpStatusMessage, - renderCliError, - renderCliErrorDetails, - renderCliErrorJson, serializeCliError, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; +import { renderCliError, renderCliErrorDetails, renderCliErrorJson } from "@/ui/error.ts"; import { forceNoTerminalColorForTests, restoreTerminalState, 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/src/lib/global-flags.test.ts b/cli/src/lib/global-flags.test.ts index 3d8937c..8d0a224 100644 --- a/cli/src/lib/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/src/lib/lakehouse/query.test.ts b/cli/src/lib/lakehouse/query.test.ts index 36181d2..9b17c2a 100644 --- a/cli/src/lib/lakehouse/query.test.ts +++ b/cli/src/lib/lakehouse/query.test.ts @@ -8,8 +8,8 @@ import { csvEscapeCell, renderQueryCsv, renderQueryJson, - renderQueryTable, -} from "@/lib/lakehouse-client.ts"; + renderQueryOutputText, +} from "@/lib/query-output.ts"; import { parseLakehouseQueryResponse, parseLakehouseQueryStream, @@ -274,8 +274,8 @@ describe("executeLakehouseQuery", () => { describe("query renderers", () => { const parsedResult = parseLakehouseQueryResponse(SAMPLE_NDJSON); - test("renderQueryTable prints a padded table", () => { - const table = renderQueryTable(parsedResult); + 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"); diff --git a/cli/src/lib/management-output.ts b/cli/src/lib/management-output.ts index a7309d3..e63a19a 100644 --- a/cli/src/lib/management-output.ts +++ b/cli/src/lib/management-output.ts @@ -1,8 +1,10 @@ -import type { CommandArgs } from "@/lib/command.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"; +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); @@ -55,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) { @@ -92,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/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/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts index 6ad44c1..952a2e2 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -5,11 +5,16 @@ 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, getStoredAccessToken, storeOAuthTokens } from "@/lib/oauth-profile.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; @@ -128,7 +133,7 @@ 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"); + expect(storedAccessToken()).toBe("acc"); expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThanOrEqual( before + 3600 * 1000, ); @@ -137,7 +142,7 @@ describe("token storage", () => { test("clear removes tokens and expiry", () => { storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); clearOAuthTokens(profileName); - expect(getStoredAccessToken(profileName)).toBe(""); + expect(storedAccessToken()).toBe(""); expect(configGet("oauth_expiry", profileName)).toBe(""); }); }); diff --git a/cli/src/lib/oauth-profile.test.ts b/cli/src/lib/oauth-profile.test.ts index 4001b3c..c1bb703 100644 --- a/cli/src/lib/oauth-profile.test.ts +++ b/cli/src/lib/oauth-profile.test.ts @@ -3,11 +3,7 @@ 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, resolveOAuthBase } from "@/lib/config.ts"; import { secretGet, secretSet } from "@/lib/secrets.ts"; @@ -15,12 +11,17 @@ import { setCliContext, getCliContext } from "@/context.ts"; import { ConfigurationError, HttpError, NetworkError } from "@/lib/errors.ts"; import { createExecutionContext } from "@/lib/execution-context.ts"; import { sendHttp } from "@/lib/http-request.ts"; -import { createCliRuntime, refreshCliRuntimeContext, runWithCliRuntime } from "@/lib/runtime.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"); }); }); @@ -210,7 +211,7 @@ describe("management request auth resolution", () => { ); }); - expect(getStoredAccessToken(profileName)).toBe("acc3"); + expect(storedAccessToken()).toBe("acc3"); expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); }); 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/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index c3366a9..59eaa5e 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -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/src/lib/profile-configure-credential-status.test.ts b/cli/src/lib/profile-configure-credential-status.test.ts index 346343f..f4e7e10 100644 --- a/cli/src/lib/profile-configure-credential-status.test.ts +++ b/cli/src/lib/profile-configure-credential-status.test.ts @@ -8,7 +8,6 @@ import { lakehousePlaneStatusDetail, managementPlaneStatusDetail, } from "@/lib/profile/model.ts"; -import { buildConfigureShowView } from "@/lib/profile/views.ts"; import { formatConfigureAuthenticationLines, formatConfigureSessionSummary, @@ -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/src/lib/profile-configure-data-plane.test.ts b/cli/src/lib/profile-configure-data-plane.test.ts index 93f7655..ce60d82 100644 --- a/cli/src/lib/profile-configure-data-plane.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 0b7688b..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"; @@ -17,7 +9,8 @@ import { } 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/src/lib/profile-configure.test.ts b/cli/src/lib/profile-configure.test.ts index 75de0d2..fe00099 100644 --- a/cli/src/lib/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 91a1566..d73d780 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -13,13 +13,12 @@ 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 "@/lib/profile/model.ts"; import { document, section, span, text, type DisplayText } from "@/ui/document.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; } diff --git a/cli/src/lib/profile-status.test.ts b/cli/src/lib/profile-status.test.ts index 5cbea8a..51b0971 100644 --- a/cli/src/lib/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 e835ec5..fb6e3f9 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -1,14 +1,11 @@ -import { getCliContext } from "@/context.ts"; import { configGet } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; -import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.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 type ConfigureAuthPlane = "management" | "lakehouse"; @@ -76,11 +73,10 @@ async function verifyPlane( 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: [], @@ -90,7 +86,7 @@ export async function configureVerify( const verifier = CONFIGURE_VERIFY_PLANES[plane]; const progress = startProgress(verifier.progressLabel); try { - const successMessage = await verifyPlane(plane, createExecutionContext(getCliRuntime())); + const successMessage = await verifyPlane(plane, execution); progress.done(formatProgressStatus("success", successMessage)); result.verified[plane] = true; } catch (error) { diff --git a/cli/src/lib/profile/active-context.test.ts b/cli/src/lib/profile/active-context.test.ts deleted file mode 100644 index 1886b11..0000000 --- a/cli/src/lib/profile/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 "@/lib/profile/model.ts"; -import { - buildActiveContextDetailsView, - buildActiveContextSummaryView, -} from "@/lib/profile/views.ts"; -import { - formatActiveContextDetails, - formatActiveContextSummary, - tryFormatActiveContextSummary, -} from "@/lib/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/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index af7295b..0dc200e 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -4,12 +4,7 @@ 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 { secretGet } from "@/lib/secrets.ts"; import { createEmptyProfile, deleteProfile, @@ -47,7 +42,6 @@ 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 }); }); @@ -260,43 +254,6 @@ describe("profile storage", () => { 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"); @@ -310,101 +267,6 @@ describe("profile storage", () => { 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); diff --git a/cli/src/lib/profile/model.ts b/cli/src/lib/profile/model.ts index 79d6c74..a21d3fe 100644 --- a/cli/src/lib/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -8,7 +8,6 @@ import { } from "@/lib/config.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { CONFIG_ENV_NAMES, isSecretEnv, readEnv } from "@/lib/env.ts"; -import type { WhoamiResponse } from "@/lib/management/model.ts"; import { moveProfileSecrets, secretDelete, @@ -700,70 +699,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.ts b/cli/src/lib/profile/render.ts index 6829c0a..f822ed9 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,6 +1,4 @@ import { - buildActiveContextDetailsView, - buildActiveContextSummaryView, buildProfileInspectView, buildProfileListView, buildProfileStatusView, @@ -8,18 +6,14 @@ import { type ProfileStatusResult, } from "@/lib/profile/views.ts"; import { - buildActiveContext, buildConfigureShowData, - type ActiveContext, type ProfileInspect, type ProfileSummary, } 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.ts b/cli/src/lib/profile/views.ts index aa65f98..2a26035 100644 --- a/cli/src/lib/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,16 +14,12 @@ 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 "@/lib/profile/model.ts"; @@ -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/src/lib/query-format.test.ts b/cli/src/lib/query-format.test.ts index da6434b..e5655d7 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.test.ts @@ -15,7 +15,7 @@ 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, diff --git a/cli/src/lib/query-output-args.ts b/cli/src/lib/query-output-args.ts index b885303..046ae6e 100644 --- a/cli/src/lib/query-output-args.ts +++ b/cli/src/lib/query-output-args.ts @@ -1,7 +1,7 @@ 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/lakehouse-client.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"; diff --git a/cli/src/lib/lakehouse-client.ts b/cli/src/lib/query-output.ts similarity index 68% rename from cli/src/lib/lakehouse-client.ts rename to cli/src/lib/query-output.ts index 9286dba..88a388d 100644 --- a/cli/src/lib/lakehouse-client.ts +++ b/cli/src/lib/query-output.ts @@ -11,15 +11,8 @@ import { import { resolvePagerOptions, writePagedOutput, type PagerOptions } from "@/lib/pager.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)) { @@ -28,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)) { @@ -96,23 +74,6 @@ 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, diff --git a/cli/src/lib/runtime.ts b/cli/src/lib/runtime.ts index 55aec92..4233e7b 100644 --- a/cli/src/lib/runtime.ts +++ b/cli/src/lib/runtime.ts @@ -1,6 +1,5 @@ -import type { CliContext } from "@/context.ts"; -import { getBootstrapCliContext, isJsonOutput } from "@/context.ts"; import { existsSync } from "node:fs"; +import { getBootstrapCliContextState, isJsonOutput, type CliContext } from "@/lib/cli-context.ts"; import { createCliSession, type CliSession } from "@/lib/cli-session.ts"; import { configFile } from "@/lib/config.ts"; import { profilesDir } from "@/lib/profile-store.ts"; @@ -61,7 +60,7 @@ export function createCliRuntime(context: CliContext): CliRuntime { export function getCliRuntime(): CliRuntime { if (!cliRuntime) { - cliRuntime = createCliRuntime(getBootstrapCliContext()); + cliRuntime = createCliRuntime(getBootstrapCliContextState()); } return cliRuntime; } @@ -70,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(); diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index aad494c..abf2d7f 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -81,25 +81,12 @@ export type SecretSetOptions = { fromArgv?: boolean; }; -type SpawnSyncFn = ( - command: string, - args: string[], - options: SpawnSyncOptions, -) => SpawnSyncReturns; - -let spawnSyncOverride: SpawnSyncFn | undefined; - -export function setSpawnSyncForTests(fn: SpawnSyncFn | undefined): void { - spawnSyncOverride = fn; -} - function runSpawnSync( command: string, args: string[], options: SpawnSyncOptions, ): SpawnSyncReturns { - const spawnFn = spawnSyncOverride ?? spawnSync; - return spawnFn(command, args, options); + return spawnSync(command, args, options) as SpawnSyncReturns; } function secretSetMacos(storageAccount: string, value: string): void { @@ -248,8 +235,3 @@ export function moveProfileSecrets( export function secretStoreDisplay(): string { return secretBackend() === "macos" ? "MacOS keychain" : credentialsFile(); } - -// Reset warning flag for tests -export function resetSecretWarningsForTests(): void { - fileBackendWarned = false; -} 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/updater.test.ts b/cli/src/lib/updater.test.ts index e09df26..ba3d39d 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -27,7 +27,6 @@ import { getUpdateCheckInterval, installCliUpdate, installGitHubBinaryRelease, - isNativeCompiledInstall, maybeShowUpdateNotice, packageReleaseUrl, parseChecksums, @@ -35,14 +34,14 @@ import { recommendedInstallCommand, releaseAssetName, releaseUrlForSource, - resolveCurrentExecutablePath, resolveUpdateSource, setUpdateCheckInterval, shouldRunAutomaticUpdateCheck, verifySha256, } from "@/lib/updater.ts"; import { UpdaterConfig } from "@/lib/updater-config.ts"; -import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; +import { createCliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; let testHome = ""; const packageJsonPath = resolve(import.meta.dir, "../../package.json"); @@ -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", () => { diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index a248af4..8d5a25c 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -744,20 +744,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/usage.test.ts b/cli/src/lib/usage.test.ts index 0b8d678..1979793 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -6,8 +6,9 @@ import { buildMainCommand } from "@/cli.ts"; import { getBootstrapCliContext } from "@/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"; @@ -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/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 index 4835c7f..4b99a40 100644 --- a/cli/src/test-utils/cli.ts +++ b/cli/src/test-utils/cli.ts @@ -1,7 +1,8 @@ import { buildMainCommand } from "@/cli.ts"; import type { CliContext } from "@/context.ts"; import { runCommandTree } from "@/lib/command.ts"; -import { createCliRuntime, runWithCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; +import { createCliRuntime, type CliRuntime } from "@/lib/runtime.ts"; +import { runWithCliRuntime } from "@/test-utils/runtime.ts"; export type CliTestHarness = { runtime: CliRuntime; 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/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/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/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/src/ui/terminal/styles.test.ts b/cli/src/ui/terminal/styles.test.ts index 66392c0..c816453 100644 --- a/cli/src/ui/terminal/styles.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/src/ui/terminal/table.test.ts b/cli/src/ui/terminal/table.test.ts index 91db3ee..ba08de3 100644 --- a/cli/src/ui/terminal/table.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 "@/commands/api/lib/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/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 From 82166737604868ece8bd60ca1f9230811252d738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:32:03 +0200 Subject: [PATCH 15/22] fix(auth): persist the authenticated control plane on login Carry the resolved control-plane root through browser login and save it on the selected profile. Reused derived profiles can no longer retain a stale endpoint after a successful authentication. --- cli/src/commands/login/index.test.ts | 26 ++++++++++++++++ cli/src/commands/login/index.ts | 44 ++++++++++++++++++---------- cli/src/lib/config.ts | 2 +- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts index 108ece4..98c119e 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -6,6 +6,7 @@ import { configGet } from "@/lib/config.ts"; import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; import { secretGet } from "@/lib/secrets.ts"; import { getActiveProfileName, profileExists } 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"; @@ -151,6 +152,31 @@ describe("login command", () => { expect(storedAccessToken("org-b_production")).toBe("org_b_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", diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts index 5c111c3..89c99ca 100644 --- a/cli/src/commands/login/index.ts +++ b/cli/src/commands/login/index.ts @@ -4,7 +4,7 @@ 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 { runLoginFlow, type TokenResponse } from "@/lib/oauth-flow.ts"; import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; @@ -121,7 +121,11 @@ function selectLoginProfile( return { profileName: targetProfile, profileAction }; } -function storeLoginProfileMetadata(whoami: WhoamiResponse, args: LoginArgs): LoginProfileMetadata { +function storeLoginProfileMetadata( + whoami: WhoamiResponse, + args: LoginArgs, + controlPlane: string, +): LoginProfileMetadata { const environment = whoami.environment_slug; // OAuth login must always return an environment. @@ -149,7 +153,7 @@ function storeLoginProfileMetadata(whoami: WhoamiResponse, args: LoginArgs): Log 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 }; @@ -165,18 +169,19 @@ type LoginArgs = { function resolveLoginEndpoints( args: LoginArgs, profileName: string, -): { oauthBase: string; managementApiBase: string } { +): { controlPlane: string; oauthBase: string; managementApiBase: string } { const override = args["control-plane-url"]; - if (!override) { - return { - oauthBase: resolveOAuthBase(profileName), - managementApiBase: resolveManagementApiBase(profileName), - }; - } - - const root = override.replace(/\/$/, ""); - assertAllowedApiBase(root, { allowInsecureHttp: Boolean(args["allow-insecure-http"]) }); - return { oauthBase: `${root}/oauth`, managementApiBase: `${root}/rest/v1` }; + 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 @@ -199,14 +204,21 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { assertInteractiveLogin(); const currentProfile = resolveWorkingProfile(getCliContext().profile); - const { oauthBase, managementApiBase } = resolveLoginEndpoints(args, 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()); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index e6d930b..090de50 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -41,7 +41,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); From de2a98e981a7bc4d1ca0e1f6efc2a5d8c6eb6eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:32:12 +0200 Subject: [PATCH 16/22] refactor(secrets): inject the system credential adapter Route Keychain process access through a narrow SecretStore factory while preserving the existing top-level API. Profile rename and delete now accept only the secret capabilities they need, enabling macOS behavior tests without test-only production exports. --- cli/src/lib/profile/model.ts | 20 +- cli/src/lib/secrets.test.ts | 42 ++++ cli/src/lib/secrets.ts | 353 ++++++++++++++++++--------------- cli/src/test-utils/keychain.ts | 46 +++++ 4 files changed, 301 insertions(+), 160 deletions(-) create mode 100644 cli/src/lib/secrets.test.ts create mode 100644 cli/src/test-utils/keychain.ts diff --git a/cli/src/lib/profile/model.ts b/cli/src/lib/profile/model.ts index a21d3fe..73de57e 100644 --- a/cli/src/lib/profile/model.ts +++ b/cli/src/lib/profile/model.ts @@ -13,6 +13,7 @@ import { secretDelete, secretExists, secretStoreDisplay, + type SecretStore, } from "@/lib/secrets.ts"; import { assertSafeProfileName, @@ -40,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 = { @@ -364,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(); @@ -382,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)); @@ -395,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(); @@ -411,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 }); diff --git a/cli/src/lib/secrets.test.ts b/cli/src/lib/secrets.test.ts new file mode 100644 index 0000000..0a20e40 --- /dev/null +++ b/cli/src/lib/secrets.test.ts @@ -0,0 +1,42 @@ +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"); + }); +}); diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index abf2d7f..27abadb 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,15 +1,44 @@ -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; +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 +58,7 @@ function requireSafeCredentialsFile(): void { } const mode = fileMode(filePath); - if (!mode) { - return; - } + if (!mode) return; const modeNumber = Number.parseInt(mode, 8); if (modeNumber & 0o077) { @@ -41,156 +68,201 @@ 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}).`); + } + } + + 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; } - throw new CliError("ALTERTABLE_SECRET_BACKEND=keychain but the macOS keychain is unavailable."); + + const filePath = credentialsFile(); + kvSet(filePath, storageAccount, value); + chmodSync(filePath, 0o600); + if (backend !== "macos") warnFileBackendOnce(); } - if (process.platform === "darwin" && hasSecurityCommand()) { - return "macos"; + + function secretGet(key: string, profileName: string): string { + if (isFromEnvProfile(profileName)) return ""; + + 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); + } + + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey); } - return "file"; -} -function resolveSecretKey(key: string, profileName: string): string { - assertSafeProfileName(profileName); - return `profile/${profileName}/${key}`; -} + function secretExists(key: string, profileName: string): boolean { + if (isFromEnvProfile(profileName)) return false; -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], + { stdio: "ignore" }, + ); + if (result.status === 0) return true; + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey) !== ""; + } -function runSpawnSync( - command: string, - args: string[], - options: SpawnSyncOptions, -): SpawnSyncReturns { - return spawnSync(command, args, options) as SpawnSyncReturns; -} + requireSafeCredentialsFile(); + return kvGet(credentialsFile(), secretKey) !== ""; + } + + function secretDelete(key: string, profileName: string): void { + if (isFromEnvProfile(profileName)) 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}).`); + const secretKey = resolveSecretKey(key, profileName); + if (secretBackend() === "macos") { + secretProcess.spawnSync( + "security", + ["delete-generic-password", "-s", "altertable", "-a", secretKey], + { stdio: "ignore" }, + ); + kvUnset(credentialsFile(), secretKey); + return; + } + + 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( @@ -198,40 +270,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(); + return systemSecretStore.secretStoreDisplay(); } diff --git a/cli/src/test-utils/keychain.ts b/cli/src/test-utils/keychain.ts new file mode 100644 index 0000000..54d63b2 --- /dev/null +++ b/cli/src/test-utils/keychain.ts @@ -0,0 +1,46 @@ +import { createSecretStore, type SecretStore } from "@/lib/secrets.ts"; + +type FakeKeychain = { + store: SecretStore; + calls: string[][]; + failingWrites: 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 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")) { + values.delete(account); + return { status: 0, stdout: Buffer.from("") }; + } + return { status: 1, stdout: Buffer.from("") }; + }, + }); + + return { store, calls, failingWrites }; +} From 2906263ac5e94cea9ea7a7b6b8f68648db791359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:32:21 +0200 Subject: [PATCH 17/22] refactor(cli): bind command runtime once at the root Keep defineCommand as a typed declarative identity and apply runtime, sink, and execution dependencies only when assembling the root tree. Completion tests now execute through that same public command boundary instead of calling handlers directly. --- cli/src/commands/completion/index.test.ts | 81 +++++------------------ cli/src/lib/command.test.ts | 35 ++++++++++ cli/src/lib/command.ts | 2 +- 3 files changed, 54 insertions(+), 64 deletions(-) create mode 100644 cli/src/lib/command.test.ts diff --git a/cli/src/commands/completion/index.test.ts b/cli/src/commands/completion/index.test.ts index e6dc3d1..7b568c7 100644 --- a/cli/src/commands/completion/index.test.ts +++ b/cli/src/commands/completion/index.test.ts @@ -2,7 +2,7 @@ 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 { defineCommand, type Command } from "@/lib/command.ts"; +import { defineCommand, defineRootCommand, runCommandTree, type Command } from "@/lib/command.ts"; import { buildMainCommand } from "@/cli.ts"; import { createCompletionCommand } from "@/commands/completion/index.ts"; import type { Prompts } from "@/ui/prompts.ts"; @@ -69,10 +69,7 @@ function createMockPrompts(selection: string): Prompts { }; } -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); @@ -83,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: () => Command, shell?: string, 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 { @@ -143,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 { @@ -270,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); 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.ts b/cli/src/lib/command.ts index 758f045..13feed2 100644 --- a/cli/src/lib/command.ts +++ b/cli/src/lib/command.ts @@ -80,7 +80,7 @@ function bindCommandTree(def: CommandTreeDef): CommandDef 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. */ From 9ea198cdcef0ecf78c5de8e89b4cd980ebe70a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:32:33 +0200 Subject: [PATCH 18/22] test(cli): colocate profile and config behavior Split umbrella suites by module ownership, keep profile model coverage focused on state transitions, and move representation and renderer contracts beside their subjects. Consolidate duplicate assertions while retaining Keychain deletion and rename rollback coverage. --- cli/src/lib/config.test.ts | 209 ++----------- cli/src/lib/profile-configure-core.test.ts | 94 ++++++ cli/src/lib/profile-store.test.ts | 62 ++++ cli/src/lib/profile/model.test.ts | 323 ++++----------------- cli/src/lib/profile/render.test.ts | 45 +++ cli/src/lib/profile/views.test.ts | 98 +++++++ cli/src/lib/url-policy.test.ts | 17 ++ cli/src/ui/shell/render.test.ts | 13 + 8 files changed, 419 insertions(+), 442 deletions(-) create mode 100644 cli/src/lib/profile-configure-core.test.ts create mode 100644 cli/src/lib/profile-store.test.ts create mode 100644 cli/src/lib/profile/render.test.ts create mode 100644 cli/src/lib/profile/views.test.ts create mode 100644 cli/src/lib/url-policy.test.ts create mode 100644 cli/src/ui/shell/render.test.ts diff --git a/cli/src/lib/config.test.ts b/cli/src/lib/config.test.ts index 509b39b..7e7d4ca 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -1,254 +1,101 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, statSync, existsSync } from "node:fs"; -import { join } from "node:path"; +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, configGet, configSet, configSetGlobal, - getQueryDefaultMaxColumnWidth, getQueryDefaultLayout, + getQueryDefaultMaxColumnWidth, getQueryDefaultPager, kvGet, kvSet, resolveApiBase, resolveManagementApiBase, } from "@/lib/config.ts"; -import { secretGet, secretSet, secretExists } 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 } from "@/lib/runtime.ts"; -import { runWithCliRuntime } from "@/test-utils/runtime.ts"; +import { profileConfigFile } from "@/lib/profile-store.ts"; let testHome = ""; const profileName = "default"; beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), "altertable-cli-test-")); + testHome = mkdtempSync(join(tmpdir(), "altertable-config-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; delete process.env.ALTERTABLE_API_BASE; delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_ENV; - delete process.env.ALTERTABLE_API_KEY; + delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; }); describe("config", () => { - test("kv round-trip", () => { + test("stores key-value config in protected files", () => { 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(statSync(filePath).mode & 0o777).toBe(0o600); 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", () => { + 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"); - }); - 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", () => { + 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(CliError); - delete process.env.ALTERTABLE_API_BASE; - }); + expect(() => resolveApiBase(profileName)).toThrow("Insecure HTTP URL"); - 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", () => { + test("reads valid query display defaults and ignores invalid values", () => { configSetGlobal("query_max_width", "48"); configSetGlobal("query_layout", "line"); + configSetGlobal("query_pager", "always"); expect(getQueryDefaultMaxColumnWidth()).toBe(48); expect(getQueryDefaultLayout()).toBe("line"); - }); + expect(getQueryDefaultPager()).toBe("always"); - test("ignores invalid query config values", () => { configSetGlobal("query_max_width", "4"); configSetGlobal("query_layout", "invalid"); + configSetGlobal("query_pager", "sometimes"); 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 }); + test("keeps global query defaults outside profile config", () => { + configSetGlobal("query_layout", "line"); + configSet("api_key_env", "staging", "staging"); - expect(secretGet("api-key", "staging")).toBe(""); - expect(secretGet("api-key", "prod")).toBe(""); - expect(secretGet("lakehouse/password", "staging")).toBe(""); - expect(secretGet("lakehouse/password", "prod")).toBe(""); + expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); + expect(kvGet(profileConfigFile("staging"), "query_layout")).toBe(""); }); -}); -describe("config dir", () => { - test("uses ALTERTABLE_CONFIG_HOME", () => { + test("uses ALTERTABLE_CONFIG_HOME for profile config", () => { 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); + 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/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-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 index 0dc200e..e5d1eb5 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { configGet, configSet, configSetGlobal, kvGet } from "@/lib/config.ts"; +import { join } from "node:path"; +import { setCliContext } from "@/context.ts"; import { configureRunSet } from "@/lib/profile-configure-core.ts"; -import { secretGet } from "@/lib/secrets.ts"; import { createEmptyProfile, deleteProfile, @@ -14,32 +13,14 @@ import { renameProfile, updateProfile, } from "@/lib/profile/model.ts"; -import { - DEFAULT_PROFILE_NAME, - ensureProfilesLayout, - getActiveProfileName, - profileConfigFile, - profileExists, - resolveWorkingProfile, - setActiveProfile, -} from "@/lib/profile-store.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { - buildProfileDirenvView, - buildProfileInspectView, - buildProfileListView, - buildProfileShellExportView, - profileStatusToJson, - type ProfileStatusResult, -} from "@/lib/profile/views.ts"; -import { formatProfileInspect, formatProfileStatus } from "@/lib/profile/render.ts"; -import { setCliContext, getCliContext } from "@/context.ts"; -import { renderShellExportView } from "@/ui/shell/render.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-test-")); + 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 }); @@ -49,65 +30,17 @@ 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", () => { +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("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", () => { + test("creates and updates profile metadata without credentials", () => { createEmptyProfile("acme_prod"); - const created = updateProfile("acme_prod", { + const profile = updateProfile("acme_prod", { organizationSlug: "acme", organizationName: "Acme Inc", environment: "production", @@ -115,38 +48,29 @@ describe("profile storage", () => { 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"); + 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("inspectProfile reports profile-scoped auth status", async () => { + 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 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.auth).toEqual({ management: "api_key", lakehouse: "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 () => { + 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"); @@ -156,105 +80,39 @@ describe("profile storage", () => { 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 () => { + 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); - expect(() => deleteProfile("staging")).toThrow("Cannot delete the active profile"); }); - test("renameProfile moves profile config, active profile, and secrets", async () => { + test("deletes stored Keychain secrets before removing a profile", () => { + process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + createEmptyProfile("staging"); + 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("renames profile config, active selection, and secrets", async () => { await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); setActiveProfile("staging"); @@ -267,79 +125,22 @@ describe("profile storage", () => { expect(secretGet("api-key", "staging")).toBe(""); }); - 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); - }); -}); - -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())); + test("rolls back profile config and Keychain secrets after a rename failure", () => { + process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + createEmptyProfile("staging"); + 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(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(() => renameProfile("staging", "acme_staging", keychain.store)).toThrow( + "Failed to store secret in macOS keychain", ); - 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()); + 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/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/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/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/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"'); + }); +}); From 10fb362e3abb06100da75220cd3ae39ce05b7e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:48:03 +0200 Subject: [PATCH 19/22] fix(secrets): fail closed on Keychain deletion errors Treat a missing macOS Keychain item as an idempotent delete, but propagate every other security command failure before removing file fallback or profile state. Cover both the SecretStore contract and profile deletion behavior. --- cli/src/lib/profile/model.test.ts | 15 +++++++++++++++ cli/src/lib/secrets.test.ts | 20 ++++++++++++++++++++ cli/src/lib/secrets.ts | 8 +++++++- cli/src/test-utils/keychain.ts | 6 +++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index e5d1eb5..6fa1e54 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -112,6 +112,21 @@ describe("profile model", () => { ).toBe(true); }); + test("keeps the profile when Keychain secret deletion fails", () => { + process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; + createEmptyProfile("staging"); + 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"); diff --git a/cli/src/lib/secrets.test.ts b/cli/src/lib/secrets.test.ts index 0a20e40..991c201 100644 --- a/cli/src/lib/secrets.test.ts +++ b/cli/src/lib/secrets.test.ts @@ -40,3 +40,23 @@ describe("secretSet", () => { 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 27abadb..103e24b 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -9,6 +9,9 @@ import { assertSafeProfileName, isFromEnvProfile } from "@/lib/profile-store.ts" type SecretBackend = "macos" | "file"; +// `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; @@ -184,11 +187,14 @@ export function createSecretStore( const secretKey = resolveSecretKey(key, profileName); if (secretBackend() === "macos") { - secretProcess.spawnSync( + 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; } diff --git a/cli/src/test-utils/keychain.ts b/cli/src/test-utils/keychain.ts index 54d63b2..4ee721c 100644 --- a/cli/src/test-utils/keychain.ts +++ b/cli/src/test-utils/keychain.ts @@ -4,6 +4,7 @@ type FakeKeychain = { store: SecretStore; calls: string[][]; failingWrites: Set; + failingDeletes: Set; }; function argumentAfter(args: string[], flag: string): string { @@ -15,6 +16,7 @@ export function createFakeKeychain(): FakeKeychain { const calls: string[][] = []; const values = new Map(); const failingWrites = new Set(); + const failingDeletes = new Set(); const store = createSecretStore({ platform: "darwin", @@ -35,6 +37,8 @@ export function createFakeKeychain(): FakeKeychain { : { 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("") }; } @@ -42,5 +46,5 @@ export function createFakeKeychain(): FakeKeychain { }, }); - return { store, calls, failingWrites }; + return { store, calls, failingWrites, failingDeletes }; } From 7cef337adff49929a30cf8a9a9527203afe441ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 16:48:12 +0200 Subject: [PATCH 20/22] test(auth): restore login profile behavior coverage Exercise --replace-profile, login into a fresh empty profile, and lakehouse-only profile preservation through the browser-login command surface. Verify OAuth clearing removes both stored tokens as well as expiry metadata. --- cli/src/commands/login/index.test.ts | 46 ++++++++++++++++++++++++++-- cli/src/lib/oauth-flow.test.ts | 1 + 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts index 98c119e..fb95311 100644 --- a/cli/src/commands/login/index.test.ts +++ b/cli/src/commands/login/index.test.ts @@ -4,8 +4,8 @@ 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 } from "@/lib/secrets.ts"; -import { getActiveProfileName, profileExists } from "@/lib/profile-store.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"; @@ -152,6 +152,48 @@ describe("login command", () => { 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 }, diff --git a/cli/src/lib/oauth-flow.test.ts b/cli/src/lib/oauth-flow.test.ts index 952a2e2..2972e9f 100644 --- a/cli/src/lib/oauth-flow.test.ts +++ b/cli/src/lib/oauth-flow.test.ts @@ -143,6 +143,7 @@ describe("token storage", () => { 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(""); }); }); From 4e4805ea905e9b8ad9e3464da7a5503e4c0a2c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 17:11:07 +0200 Subject: [PATCH 21/22] chore(cli): align dead-code checks after rebase Track dynamically compiled colocated fixtures as Knip entries after moving tests into src. Remove the updater interval setter and global config writer that became test-only when the simplified update command from main was integrated; tests now exercise the underlying config-file boundary directly. --- cli/knip.json | 8 +++++++- cli/src/lib/config-files.ts | 10 +--------- cli/src/lib/config.test.ts | 16 ++++++++-------- cli/src/lib/config.ts | 6 ------ cli/src/lib/pager.test.ts | 8 ++++---- cli/src/lib/query-format.test.ts | 6 +++--- cli/src/lib/updater.test.ts | 6 +++--- cli/src/lib/updater.ts | 6 +----- 8 files changed, 27 insertions(+), 39 deletions(-) diff --git a/cli/knip.json b/cli/knip.json index 5491bb2..e5e0b41 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,6 +1,12 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/cli.ts!", "src/**/*.test.ts", "scripts/**/*.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, 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 index 7e7d4ca..e38ede9 100644 --- a/cli/src/lib/config.test.ts +++ b/cli/src/lib/config.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { configDir, + configFile, configGet, configSet, - configSetGlobal, getQueryDefaultLayout, getQueryDefaultMaxColumnWidth, getQueryDefaultPager, @@ -67,23 +67,23 @@ describe("config", () => { }); test("reads valid query display defaults and ignores invalid values", () => { - configSetGlobal("query_max_width", "48"); - configSetGlobal("query_layout", "line"); - configSetGlobal("query_pager", "always"); + 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"); - configSetGlobal("query_max_width", "4"); - configSetGlobal("query_layout", "invalid"); - configSetGlobal("query_pager", "sometimes"); + kvSet(configFile(), "query_max_width", "4"); + kvSet(configFile(), "query_layout", "invalid"); + kvSet(configFile(), "query_pager", "sometimes"); expect(getQueryDefaultMaxColumnWidth()).toBeUndefined(); expect(getQueryDefaultLayout()).toBeUndefined(); expect(getQueryDefaultPager()).toBeUndefined(); }); test("keeps global query defaults outside profile config", () => { - configSetGlobal("query_layout", "line"); + kvSet(configFile(), "query_layout", "line"); configSet("api_key_env", "staging", "staging"); expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 090de50..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; } diff --git a/cli/src/lib/pager.test.ts b/cli/src/lib/pager.test.ts index 2b870ce..2150d70 100644 --- a/cli/src/lib/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/query-format.test.ts b/cli/src/lib/query-format.test.ts index e5655d7..cef6d3c 100644 --- a/cli/src/lib/query-format.test.ts +++ b/cli/src/lib/query-format.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 { parseLakehouseQueryResponse } from "@/lib/lakehouse-ndjson.ts"; import { defaultDisplayOptions, @@ -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/updater.test.ts b/cli/src/lib/updater.test.ts index ba3d39d..17b2aad 100644 --- a/cli/src/lib/updater.test.ts +++ b/cli/src/lib/updater.test.ts @@ -14,6 +14,7 @@ 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, @@ -35,7 +36,6 @@ import { releaseAssetName, releaseUrlForSource, resolveUpdateSource, - setUpdateCheckInterval, shouldRunAutomaticUpdateCheck, verifySha256, } from "@/lib/updater.ts"; @@ -613,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({ @@ -624,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 8d5a25c..7e0920d 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -6,7 +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 { 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"; @@ -276,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; } From c26c8fa90eb7a7df2717ea510b8731770b0b2621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 16 Jul 2026 19:12:19 +0200 Subject: [PATCH 22/22] test(profile): initialize keychain backend before profile setup --- cli/src/lib/profile/model.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/lib/profile/model.test.ts b/cli/src/lib/profile/model.test.ts index 6fa1e54..91a15fa 100644 --- a/cli/src/lib/profile/model.test.ts +++ b/cli/src/lib/profile/model.test.ts @@ -93,8 +93,8 @@ describe("profile model", () => { }); test("deletes stored Keychain secrets before removing a profile", () => { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; 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"); @@ -113,8 +113,8 @@ describe("profile model", () => { }); test("keeps the profile when Keychain secret deletion fails", () => { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; 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"); @@ -141,8 +141,8 @@ describe("profile model", () => { }); test("rolls back profile config and Keychain secrets after a rename failure", () => { - process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; 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");