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..1ed7644 100644 --- a/cli/src/lib/profile/render.test.ts +++ b/cli/src/lib/profile/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ProfileInspect } from "@/lib/profile/model.ts"; -import { formatProfileInspect, formatProfileStatus } from "@/lib/profile/render.ts"; +import { formatProfileInspectResult, formatProfileStatus } from "@/lib/profile/render.ts"; const profile: ProfileInspect = { name: "acme_prod", @@ -17,7 +17,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"); @@ -42,4 +42,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..d0f3581 100644 --- a/cli/src/lib/profile/render.ts +++ b/cli/src/lib/profile/render.ts @@ -1,5 +1,5 @@ import { - buildProfileInspectView, + buildProfileInspectResultView, buildProfileListView, buildProfileStatusView, configureAuthenticationRows, @@ -19,8 +19,8 @@ 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)); } export function formatProfileStatus(result: ProfileStatusResult): string { 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");