From 795b7dfa51ecc9a24461f00ba00e7eda7d6d94d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 3 Sep 2026 12:30:39 +0200 Subject: [PATCH 1/3] fix(cli): expose unhealthy diagnostics through exit status Let completed commands return an explicit process status so health checks can preserve their full stdout reports without throwing or emitting error envelopes. Make doctor and profile status return 1 for unhealthy results across human, JSON, and agent output. Keep profile show observational while adding setup guidance and structured next steps for empty or partial profiles. --- CHANGELOG.md | 1 + README.md | 13 +-- cli/src/cli.ts | 17 +-- cli/src/commands/doctor/index.test.ts | 2 + cli/src/commands/doctor/index.ts | 2 + cli/src/commands/doctor/lib/runner.test.ts | 16 +++ cli/src/commands/profile/show.ts | 14 ++- cli/src/commands/profile/status.ts | 5 + cli/src/lib/command-parser.ts | 12 ++- cli/src/lib/command.test.ts | 17 +++ cli/src/lib/command.ts | 8 +- cli/src/lib/profile/render.test.ts | 13 ++- cli/src/lib/profile/render.ts | 5 + cli/src/lib/profile/views.test.ts | 22 ++++ cli/src/lib/profile/views.ts | 33 ++++++ cli/src/test-utils/cli.ts | 7 +- tests/doctor.test.ts | 18 +++- tests/profile-diagnostics.test.ts | 118 +++++++++++++++++++++ tests/profile.test.ts | 7 ++ 19 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 tests/profile-diagnostics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd0e3f..ff29e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Simplify `altertable update` to install by default, add `--check` mode, preserve inherited global flags, resolve compiled self-update paths safely, and verify source-checkout updates through the package manager's global binary. +- Return exit code `1` for unhealthy `doctor` and `profile status` reports while preserving complete stdout output, and add actionable next steps to empty or partial `profile show` results. ## [1.2.0](https://github.com/altertable-ai/altertable-cli/compare/v1.1.0...v1.2.0) (2026-07-15) diff --git a/README.md b/README.md index 6fb426e..9cb4e58 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ Profile selection precedence: `--profile` flag → `ALTERTABLE_PROFILE` env var | Global root `config` | Active profile and display/update preferences such as query layout, query width, and update checks | | Profile-specific config | Credentials metadata, endpoint overrides, organization/principal metadata, and credential expiry timestamps | -`profile status` runs live credential verification and then renders `profile show` (identity and credential details, including OAuth and auto-provisioned lakehouse credential expiry when present) followed by the verification result. `profile show --config` additionally prints the config dir, profile config file, and secret store paths. +`profile status` runs live credential verification and then renders `profile show` (identity and credential details, including OAuth and auto-provisioned lakehouse credential expiry when present) followed by the verification result. It exits `0` when at least one credential is configured and every configured credential verifies, and `1` when no credentials are configured or any verification fails. The complete report remains on stdout in human, `--json`, and `--agent` modes. `profile show` is observational and always exits `0` after a successful read; empty or partial profiles include setup guidance in human output and a structured `next_steps` array in JSON output. `profile show --config` additionally prints the config dir, profile config file, and secret store paths. ### Credential precedence @@ -438,8 +438,9 @@ altertable --json doctor `--offline` validates only local configuration and credential presence. Network checks use the global `--connect-timeout` and `--read-timeout` values. Doctor findings do not refresh OAuth tokens, provision lakehouse credentials, or modify -profile files. A completed diagnostic exits successfully even when its report is -unhealthy; scripts should inspect the JSON `healthy` field. +profile files. A completed diagnostic exits `0` when healthy (warnings alone are +successful) and `1` when any check fails. Human, `--json`, and `--agent` modes all +write the complete report to stdout, including unhealthy reports. ### Shell completion @@ -508,7 +509,7 @@ Stream endpoints (lakehouse query streams) treat `--read-timeout 0` as unlimited ## Scripting -Use `--json` or `--agent` for machine-readable output. On failure the error is a JSON object on stderr; stdout remains empty. +Use `--json` or `--agent` for machine-readable output. Command errors are JSON objects on stderr and leave stdout empty. Completed health checks are different: unhealthy `doctor` and `profile status` reports remain on stdout and exit `1` without an error envelope. ### Output tiers @@ -516,7 +517,7 @@ With `--json`, success stdout follows one of three contracts: 1. **Raw API** — verbatim API response body (most `api *` commands). 2. **Normalized query** — `{ metadata, columns, rows }` from `query --json` or `query --agent` (stable scripting contract). -3. **CLI envelope** — CLI-shaped objects such as `{ catalogs: [...] }` from `catalogs --json`, `{ profiles: [...] }` from `profile list --json`, or `{ cli_config, profile, details }` from `profile show --json`. +3. **CLI envelope** — CLI-shaped objects such as `{ catalogs: [...] }` from `catalogs --json`, `{ profiles: [...] }` from `profile list --json`, or `{ profile, next_steps }` from `profile show --json`. Human mode defaults management list/get output to tables unless `--format` is set. @@ -525,7 +526,7 @@ Human mode defaults management list/get output to tables unless `--format` is se | Code | Meaning | | ---- | ------------------------------------------ | | `0` | Success | -| `1` | Usage, validation, or unexpected CLI error | +| `1` | Unhealthy check or general CLI error | | `2` | Authentication failed (HTTP 401) | | `3` | Permission denied (HTTP 403) | | `4` | Not found (HTTP 404) | diff --git a/cli/src/cli.ts b/cli/src/cli.ts index f22200a..3deeeec 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -114,7 +114,7 @@ const initialContext = getBootstrapCliContext(); applyTerminalColorFromContext(initialContext); setCliRuntime(createCliRuntime(initialContext)); -function handleCliError(error: unknown): never { +function handleCliError(error: unknown): number { const context = getCliContext(); if (isJsonOutput(context)) { console.error(renderCliErrorJson(error)); @@ -130,10 +130,10 @@ function handleCliError(error: unknown): never { console.error(error.stack); } - process.exit(getCliExitCode(error)); + return getCliExitCode(error); } -async function bootstrap(): Promise { +async function bootstrap(): Promise { const rawArgs = process.argv.slice(2); try { @@ -152,12 +152,12 @@ async function bootstrap(): Promise { context: getCliContext(), commandName: (await resolveCommandSelection(main, rawArgs)).commandPath.at(0) ?? "help", }); - process.exit(EXIT_SUCCESS); + return EXIT_SUCCESS; } if (earlyExit?.id === "version") { console.log(VERSION); - return; + return EXIT_SUCCESS; } validateEnvironment(); @@ -166,6 +166,7 @@ async function bootstrap(): Promise { context: getCliContext(), commandName: selection.commandPath.at(0), }); + return selection.exitCode; } catch (error) { const showExamplesOnHumanOutput = !isJsonOutput(getCliContext()); @@ -177,10 +178,12 @@ async function bootstrap(): Promise { } else if (showExamplesOnHumanOutput && shouldShowCommandExamplesOnError(error)) { await showCommandExamplesForArgs(main, rawArgs); } - handleCliError(error); + return handleCliError(error); } } if (import.meta.main) { - void bootstrap(); + void bootstrap().then((exitCode) => { + process.exitCode = exitCode; + }); } diff --git a/cli/src/commands/doctor/index.test.ts b/cli/src/commands/doctor/index.test.ts index 1c070ee..54d5d20 100644 --- a/cli/src/commands/doctor/index.test.ts +++ b/cli/src/commands/doctor/index.test.ts @@ -53,6 +53,7 @@ describe("doctor command", () => { profile: "default", summary: { passed: 3, failed: 2, skipped: 2 }, }); + expect(harness.exitCode).toBe(1); expect(await Bun.file(join(testHome, "config")).exists()).toBe(false); expect(await Bun.file(join(testHome, "profiles", "default", "config")).exists()).toBe(false); }); @@ -82,6 +83,7 @@ describe("doctor command", () => { healthy: true, summary: { passed: 7, failed: 0, skipped: 0 }, }); + expect(harness.exitCode).toBe(0); expect(report.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 40b320b..7f8c0c5 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -4,6 +4,7 @@ import { createDoctorChecks } from "@/commands/doctor/lib/checks.ts"; import { formatDoctorReport } from "@/commands/doctor/lib/render.ts"; import { runDoctorChecks } from "@/commands/doctor/lib/runner.ts"; import { createDiagnosticExecutionContext } from "@/lib/execution-context.ts"; +import { EXIT_GENERIC, EXIT_SUCCESS } from "@/lib/errors.ts"; export const doctorCommand = defineCommand({ metadata: { @@ -31,5 +32,6 @@ export const doctorCommand = defineCommand({ }, sink, ); + return { exitCode: report.healthy ? EXIT_SUCCESS : EXIT_GENERIC }; }, }); diff --git a/cli/src/commands/doctor/lib/runner.test.ts b/cli/src/commands/doctor/lib/runner.test.ts index a30d3c6..5a39768 100644 --- a/cli/src/commands/doctor/lib/runner.test.ts +++ b/cli/src/commands/doctor/lib/runner.test.ts @@ -91,6 +91,22 @@ describe("runDoctorChecks", () => { expect(report.checks[0]).toMatchObject({ status: "skipped", message: "Offline mode." }); }); + test("keeps warning-only reports healthy", async () => { + const report = await runDoctorChecks( + [ + { + id: "warning", + label: "Warning", + run: () => ({ status: "warn", message: "Degraded but usable." }), + }, + ], + createDoctorContext(), + ); + + expect(report.healthy).toBe(true); + expect(report.summary).toEqual({ passed: 0, warnings: 1, failed: 0, skipped: 0 }); + }); + test("runs independent checks concurrently and preserves report order", async () => { const started: string[] = []; let finishFirst!: () => void; diff --git a/cli/src/commands/profile/show.ts b/cli/src/commands/profile/show.ts index cdcea89..394ef61 100644 --- a/cli/src/commands/profile/show.ts +++ b/cli/src/commands/profile/show.ts @@ -1,5 +1,6 @@ import { inspectProfile } from "@/lib/profile/model.ts"; -import { formatProfileInspect } from "@/lib/profile/render.ts"; +import { formatProfileInspectResult } from "@/lib/profile/render.ts"; +import { profileInspectToJson } from "@/lib/profile/views.ts"; import { existingProfileName, profileShowTargetName } from "@/commands/profile/lib/profile.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; @@ -13,10 +14,17 @@ export const profileShowCommand = defineCommand({ required: false, }, }, - async run({ args, sink }) { + async run({ args, runtime, sink }) { const profile = inspectProfile(existingProfileName(profileShowTargetName(args))); await writeCommandOutput( - { kind: "normalized", data: { profile }, humanText: formatProfileInspect(profile) }, + { + kind: "normalized", + data: profileInspectToJson(profile), + humanText: formatProfileInspectResult( + profile, + !sink.json && !runtime.context.agent && process.stdin.isTTY === true, + ), + }, sink, ); }, diff --git a/cli/src/commands/profile/status.ts b/cli/src/commands/profile/status.ts index 4423187..3318512 100644 --- a/cli/src/commands/profile/status.ts +++ b/cli/src/commands/profile/status.ts @@ -9,6 +9,7 @@ import { } from "@/commands/profile/lib/profile.ts"; import { defineCommand } from "@/lib/command.ts"; import { writeCommandOutput } from "@/lib/command-output.ts"; +import { EXIT_GENERIC, EXIT_SUCCESS } from "@/lib/errors.ts"; export const profileStatusCommand = defineCommand({ metadata: { name: "status", description: "Verify stored credentials and show the profile" }, @@ -35,5 +36,9 @@ export const profileStatusCommand = defineCommand({ }, sink, ); + const healthy = + verification.configured.length > 0 && + verification.configured.every((plane) => verification.verified[plane]); + return { exitCode: healthy ? EXIT_SUCCESS : EXIT_GENERIC }; }, }); diff --git a/cli/src/lib/command-parser.ts b/cli/src/lib/command-parser.ts index 7ab3033..c5d32b5 100644 --- a/cli/src/lib/command-parser.ts +++ b/cli/src/lib/command-parser.ts @@ -5,8 +5,10 @@ import { type CommandArgument, type CommandArguments, type CommandMetadata, + type CommandRunResult, } from "@/lib/command.ts"; import { createExecutionContext, type ExecutionContext } from "@/lib/execution-context.ts"; +import { EXIT_SUCCESS } from "@/lib/errors.ts"; import { getCliRuntime } from "@/lib/runtime.ts"; type ParsedArgumentValue = string | boolean | string[] | undefined; @@ -44,6 +46,8 @@ export type CommandSelection = { commandPath: string[]; }; +export type CommandExecutionResult = CommandSelection & CommandRunResult; + export class CommandParseError extends Error { code: string; @@ -390,19 +394,21 @@ function createRunContext(command: Command, args: ParsedArguments, rawArgs: read export async function executeCommand( root: Command, rawArgs: readonly string[], -): Promise { +): Promise { const invocation = await selectInvocation(root, rawArgs, "strict"); const parsed = parseInvocationArguments(invocation, rawArgs); + let runResult: void | CommandRunResult = undefined; if (root.run) { - await root.run(createRunContext(root, parsed, rawArgs)); + runResult = await root.run(createRunContext(root, parsed, rawArgs)); } const selected = invocation.command.definition; if (selected !== root && selected.run) { - await selected.run(createRunContext(selected, parsed, rawArgs)); + runResult = await selected.run(createRunContext(selected, parsed, rawArgs)); } return { command: selected, ...(invocation.parent ? { parent: invocation.parent } : {}), commandPath: invocation.commandPath, + exitCode: runResult?.exitCode ?? EXIT_SUCCESS, }; } diff --git a/cli/src/lib/command.test.ts b/cli/src/lib/command.test.ts index f54e7f5..8becf68 100644 --- a/cli/src/lib/command.test.ts +++ b/cli/src/lib/command.test.ts @@ -32,6 +32,23 @@ describe("command composition", () => { expect(executionIsStable).toBe(true); }); + test("returns a completed command's requested exit code", async () => { + const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const root = defineCommand({ + subcommands: { + check: defineCommand({ + run() { + return { exitCode: 1 }; + }, + }), + }, + }); + + const result = await runWithCliRuntime(runtime, () => executeCommand(root, ["check"])); + + expect(result.exitCode).toBe(1); + }); + test("parses global and command flags in every position", async () => { const runtime = createCliRuntime({ debug: false, json: false, agent: false }); const received: Array> = []; diff --git a/cli/src/lib/command.ts b/cli/src/lib/command.ts index 808e921..43ae644 100644 --- a/cli/src/lib/command.ts +++ b/cli/src/lib/command.ts @@ -57,6 +57,10 @@ export type CommandRunContext = { readonly execution: ExecutionContext; }; +export type CommandRunResult = { + exitCode: number; +}; + export type CommandDefinition = { metadata?: Resolvable; args?: Resolvable; @@ -67,7 +71,9 @@ export type CommandDefinition = { * (for example a keyword that is both a statement and a subcommand name). */ soleDirectOperands?: readonly string[]; - run?: (context: CommandRunContext) => void | Promise; + run?: ( + context: CommandRunContext, + ) => void | CommandRunResult | Promise; }; export type Command = CommandDefinition; diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts index b8c71c5..b05e115 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { ProfileInspect } from "@/lib/profile/model.ts"; -import { formatProfileInspect, formatProfileStatus } from "@/lib/profile/render.ts"; +import { + formatProfileInspect, + formatProfileInspectResult, + formatProfileStatus, +} from "@/lib/profile/render.ts"; const profile: ProfileInspect = { name: "acme_prod", @@ -42,4 +46,11 @@ describe("profile rendering", () => { expect(output).toContain("Management:"); expect(output).toContain("verified"); }); + + test("renders guidance for an empty profile", () => { + const output = formatProfileInspectResult({ ...profile, status: "empty" }); + + expect(output).toContain("Next steps:"); + expect(output).toContain("altertable profile configure"); + }); }); diff --git a/cli/src/lib/profile/render.ts b/cli/src/lib/profile/render.ts index f822ed9..3ecf139 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,5 +1,6 @@ import { buildProfileInspectView, + buildProfileInspectResultView, buildProfileListView, buildProfileStatusView, configureAuthenticationRows, @@ -23,6 +24,10 @@ export function formatProfileInspect(profile: ProfileInspect): string { return renderDocumentText(buildProfileInspectView(profile)); } +export function formatProfileInspectResult(profile: ProfileInspect, interactive = true): string { + return renderDocumentText(buildProfileInspectResultView(profile, interactive)); +} + export function formatProfileStatus(result: ProfileStatusResult): string { return renderDocumentText(buildProfileStatusView(result)); } diff --git a/cli/src/lib/profile/views.test.ts b/cli/src/lib/profile/views.test.ts index fbdc475..a214ea8 100644 --- a/cli/src/lib/profile/views.test.ts +++ b/cli/src/lib/profile/views.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test"; import type { ProfileInspect, ProfileSummary } from "@/lib/profile/model.ts"; import { buildProfileInspectView, + buildProfileInspectResultView, buildProfileListView, buildProfileShellExportView, profileStatusToJson, + profileInspectToJson, } from "@/lib/profile/views.ts"; const profile: ProfileInspect = { @@ -74,6 +76,26 @@ describe("profile views", () => { }); }); + test.each(["empty", "partial"] as const)( + "adds actionable next steps to %s profile inspection", + (status) => { + const incompleteProfile = { ...profile, status }; + const view = buildProfileInspectResultView(incompleteProfile); + + expect(view.sections.at(-1)?.blocks).toEqual([ + { kind: "text", lines: ["Next steps:", "Run: altertable profile configure"] }, + ]); + expect(profileInspectToJson(incompleteProfile)).toMatchObject({ + profile: { status }, + next_steps: [expect.stringContaining("--password-stdin")], + }); + }, + ); + + test("omits next steps from configured profile inspection", () => { + expect(profileInspectToJson(profile)).toMatchObject({ next_steps: [] }); + }); + test("serializes profile status without changing its public shape", () => { const json = profileStatusToJson({ profile, diff --git a/cli/src/lib/profile/views.ts b/cli/src/lib/profile/views.ts index 5fd8239..708a403 100644 --- a/cli/src/lib/profile/views.ts +++ b/cli/src/lib/profile/views.ts @@ -79,6 +79,39 @@ export function buildProfileInspectView(profile: ProfileInspect): DisplayDocumen return document(section(rows(profileInspectRows(profile)))); } +export function profileInspectNextSteps(profile: ProfileInspect, interactive = true): string[] { + if (profile.status === "configured") return []; + if (interactive) return ["Run: altertable profile configure"]; + + const nextSteps: string[] = []; + if (profile.auth.management === "none") { + nextSteps.push( + `Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env `, + ); + } + if (profile.auth.lakehouse === "none") { + nextSteps.push( + `Run: printf '%s' "$PASSWORD" | altertable profile configure --user --password-stdin`, + ); + } + return nextSteps.length > 0 ? nextSteps : ["Run: altertable profile configure"]; +} + +export function buildProfileInspectResultView( + profile: ProfileInspect, + interactive = true, +): DisplayDocument { + const nextSteps = profileInspectNextSteps(profile, interactive); + return document( + ...buildProfileInspectView(profile).sections, + ...(nextSteps.length > 0 ? [section(text(["Next steps:", ...nextSteps]))] : []), + ); +} + +export function profileInspectToJson(profile: ProfileInspect): Record { + return { profile, next_steps: profileInspectNextSteps(profile, false) }; +} + function formatProfilePrincipal(profile: ProfileInspect): string { if (profile.principal.email) { return profile.principal.name diff --git a/cli/src/test-utils/cli.ts b/cli/src/test-utils/cli.ts index acd1e4f..54f4ba7 100644 --- a/cli/src/test-utils/cli.ts +++ b/cli/src/test-utils/cli.ts @@ -8,6 +8,7 @@ export type CliTestHarness = { runtime: CliRuntime; stdout: string[]; stderr: string[]; + exitCode: number; run(rawArgs: string[]): Promise; }; @@ -30,8 +31,12 @@ export function createCliTestHarness( runtime, stdout, stderr, + exitCode: 0, async run(rawArgs) { - await runWithCliRuntime(runtime, () => executeCommand(buildMainCommand(), rawArgs)); + const result = await runWithCliRuntime(runtime, () => + executeCommand(buildMainCommand(), rawArgs), + ); + this.exitCode = result.exitCode; }, }; } diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 5cdb639..a406653 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -22,10 +22,10 @@ describe("altertable doctor", () => { await workspace.resetNetwork(); }); - test("reports missing credentials as findings instead of a command error", async () => { + test("reports missing credentials on stdout with an unhealthy exit status", async () => { const result = await workspace.runCommand("altertable --json doctor --offline"); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); expect(result.stderr).toBe(""); const report = JSON.parse(result.stdout); expect(report).toMatchObject({ @@ -106,11 +106,23 @@ describe("altertable doctor", () => { test("renders an actionable human report", async () => { const result = await workspace.runCommand("altertable doctor --offline"); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); expect(result.stdout).toContain("ALTERTABLE CLI DOCTOR"); expect(result.stdout).toContain("Management auth"); expect(result.stdout).toContain("altertable profile configure --scope management"); expect(result.stdout).toContain("Result: unhealthy"); expect(result.stdout).not.toContain("undefined"); }); + + test("uses the same unhealthy report contract in agent mode", async () => { + const result = await workspace.runCommand("altertable --agent doctor --offline"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + healthy: false, + summary: { failed: 2 }, + }); + }); }); diff --git a/tests/profile-diagnostics.test.ts b/tests/profile-diagnostics.test.ts new file mode 100644 index 0000000..9cce24f --- /dev/null +++ b/tests/profile-diagnostics.test.ts @@ -0,0 +1,118 @@ +import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; +import { jsonMock } from "./mock-http.ts"; + +describe("profile diagnostic reports", () => { + let workspace: TestWorkspace; + + beforeAll(async () => { + workspace = await createTestWorkspace({ + ALTERTABLE_API_KEY: undefined, + ALTERTABLE_ENV: undefined, + ALTERTABLE_LAKEHOUSE_PASSWORD: undefined, + ALTERTABLE_LAKEHOUSE_USERNAME: undefined, + }); + }); + + beforeEach(async () => { + await workspace.resetConfig(); + await workspace.resetNetwork(); + }); + + test("profile show keeps empty profiles successful and actionable", async () => { + const human = await workspace.runCommand("altertable profile show"); + const json = await workspace.runCommand("altertable --json profile show"); + const agent = await workspace.runCommand("altertable --agent profile show"); + + expect(human.exitCode).toBe(0); + expect(human.stderr).toBe(""); + expect(human.stdout).toContain("Status"); + expect(human.stdout).toContain("empty"); + expect(human.stdout).toContain("Next steps:"); + expect(human.stdout).toContain("altertable profile configure"); + for (const result of [json, agent]) { + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + profile: { status: "empty" }, + next_steps: [ + expect.stringContaining("--api-key-stdin"), + expect.stringContaining("--password-stdin"), + ], + }); + } + }); + + test("profile show guides partially configured profiles", async () => { + const configured = await workspace.runCommand( + "altertable profile configure --data-plane-url http://localhost:15000", + ); + const result = await workspace.runCommand("altertable --json profile show"); + + expect(configured.exitCode).toBe(0); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + profile: { status: "partial" }, + next_steps: [ + expect.stringContaining("--api-key-stdin"), + expect.stringContaining("--password-stdin"), + ], + }); + }); + + test.each([ + ["human", "altertable profile status"], + ["json", "altertable --json profile status"], + ["agent", "altertable --agent profile status"], + ])( + "profile status returns an unhealthy %s report when no credentials exist", + async (mode, command) => { + const result = await workspace.runCommand(command); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + if (mode === "human") { + expect(result.stdout).toContain("Verification:"); + expect(result.stdout).toContain("no credentials configured"); + } else { + expect(JSON.parse(result.stdout)).toMatchObject({ + profile: { status: "empty" }, + verification: { configured: [], errors: [] }, + }); + } + }, + ); + + test.each([ + ["human", "altertable profile status"], + ["json", "altertable --json profile status"], + ["agent", "altertable --agent profile status"], + ])( + "profile status returns an unhealthy %s report when verification fails", + async (mode, command) => { + const configured = await workspace.runCommand( + "altertable profile configure --api-key atm_bad --env production", + ); + await workspace.setupMockHttp([jsonMock("GET", "/whoami", { error: "invalid key" }, 401)]); + + const result = await workspace.runCommand(command); + + expect(configured.exitCode).toBe(0); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + if (mode === "human") { + expect(result.stdout).toContain("Verification:"); + expect(result.stdout).toContain("failed (management)"); + } else { + expect(JSON.parse(result.stdout)).toMatchObject({ + profile: { auth: { management: "api_key" } }, + verification: { + configured: ["management"], + verified: { management: false }, + errors: [{ plane: "management" }], + }, + }); + } + }, + ); +}); diff --git a/tests/profile.test.ts b/tests/profile.test.ts index 69a1134..6756a4f 100644 --- a/tests/profile.test.ts +++ b/tests/profile.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; +import { jsonMock } from "./mock-http.ts"; describe("profile switching", () => { let workspace: TestWorkspace; @@ -64,6 +65,12 @@ describe("profile switching", () => { ) ).exitCode, ).toBe(0); + await workspace.setupMockHttp([ + jsonMock("GET", "/whoami", { + principal: { type: "User", name: "Jane", email: "jane@example.com" }, + organization: { name: "Globex", slug: "globex" }, + }), + ]); let result = await workspace.runCommand("altertable profile status globex_dev"); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("globex_dev"); From b54d69c835ef7a747576665fce29471f054db1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 3 Sep 2026 15:23:39 +0200 Subject: [PATCH 2/3] fix(profile): remove unused inspection renderer --- cli/src/lib/profile/render.test.ts | 3 +-- cli/src/lib/profile/render.ts | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts index b05e115..6f4f1f6 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ProfileInspect } from "@/lib/profile/model.ts"; import { - formatProfileInspect, formatProfileInspectResult, formatProfileStatus, } from "@/lib/profile/render.ts"; @@ -21,7 +20,7 @@ const profile: ProfileInspect = { describe("profile rendering", () => { test("renders stored profile details without exposing credentials", () => { - const output = formatProfileInspect(profile); + const output = formatProfileInspectResult(profile); expect(output).toContain("Management auth"); expect(output).toContain("api_key"); diff --git a/cli/src/lib/profile/render.ts b/cli/src/lib/profile/render.ts index 3ecf139..d0f3581 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,5 +1,4 @@ import { - buildProfileInspectView, buildProfileInspectResultView, buildProfileListView, buildProfileStatusView, @@ -20,10 +19,6 @@ import { TERMINAL_NESTED_LABEL_WIDTH, } from "@/ui/terminal/spacing.ts"; -export function formatProfileInspect(profile: ProfileInspect): string { - return renderDocumentText(buildProfileInspectView(profile)); -} - export function formatProfileInspectResult(profile: ProfileInspect, interactive = true): string { return renderDocumentText(buildProfileInspectResultView(profile, interactive)); } From 4d77417d9ae3e6949aaa370f660e0f9bdb6effb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 3 Sep 2026 16:08:42 +0200 Subject: [PATCH 3/3] style(profile): format render test imports --- cli/src/lib/profile/render.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/src/lib/profile/render.test.ts b/cli/src/lib/profile/render.test.ts index 6f4f1f6..1ed7644 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ProfileInspect } from "@/lib/profile/model.ts"; -import { - formatProfileInspectResult, - formatProfileStatus, -} from "@/lib/profile/render.ts"; +import { formatProfileInspectResult, formatProfileStatus } from "@/lib/profile/render.ts"; const profile: ProfileInspect = { name: "acme_prod",