Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -508,15 +509,15 @@ 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

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.

Expand All @@ -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) |
Expand Down
17 changes: 10 additions & 7 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -130,10 +130,10 @@ function handleCliError(error: unknown): never {
console.error(error.stack);
}

process.exit(getCliExitCode(error));
return getCliExitCode(error);
}

async function bootstrap(): Promise<void> {
async function bootstrap(): Promise<number> {
const rawArgs = process.argv.slice(2);

try {
Expand All @@ -152,12 +152,12 @@ async function bootstrap(): Promise<void> {
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();
Expand All @@ -166,6 +166,7 @@ async function bootstrap(): Promise<void> {
context: getCliContext(),
commandName: selection.commandPath.at(0),
});
return selection.exitCode;
} catch (error) {
const showExamplesOnHumanOutput = !isJsonOutput(getCliContext());

Expand All @@ -177,10 +178,12 @@ async function bootstrap(): Promise<void> {
} 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;
});
}
2 changes: 2 additions & 0 deletions cli/src/commands/doctor/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions cli/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -31,5 +32,6 @@ export const doctorCommand = defineCommand({
},
sink,
);
return { exitCode: report.healthy ? EXIT_SUCCESS : EXIT_GENERIC };
},
});
16 changes: 16 additions & 0 deletions cli/src/commands/doctor/lib/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions cli/src/commands/profile/show.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
);
},
Expand Down
5 changes: 5 additions & 0 deletions cli/src/commands/profile/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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 };
},
});
12 changes: 9 additions & 3 deletions cli/src/lib/command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +46,8 @@ export type CommandSelection = {
commandPath: string[];
};

export type CommandExecutionResult = CommandSelection & CommandRunResult;

export class CommandParseError extends Error {
code: string;

Expand Down Expand Up @@ -390,19 +394,21 @@ function createRunContext(command: Command, args: ParsedArguments, rawArgs: read
export async function executeCommand(
root: Command,
rawArgs: readonly string[],
): Promise<CommandSelection> {
): Promise<CommandExecutionResult> {
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,
};
}
17 changes: 17 additions & 0 deletions cli/src/lib/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
Expand Down
8 changes: 7 additions & 1 deletion cli/src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export type CommandRunContext<T extends CommandArguments = CommandArguments> = {
readonly execution: ExecutionContext;
};

export type CommandRunResult = {
exitCode: number;
};

export type CommandDefinition<T extends CommandArguments = CommandArguments> = {
metadata?: Resolvable<CommandMetadata | undefined>;
args?: Resolvable<T>;
Expand All @@ -67,7 +71,9 @@ export type CommandDefinition<T extends CommandArguments = CommandArguments> = {
* (for example a keyword that is both a statement and a subcommand name).
*/
soleDirectOperands?: readonly string[];
run?: (context: CommandRunContext<T>) => void | Promise<void>;
run?: (
context: CommandRunContext<T>,
) => void | CommandRunResult | Promise<void | CommandRunResult>;
};

export type Command = CommandDefinition<any>;
Expand Down
11 changes: 9 additions & 2 deletions cli/src/lib/profile/render.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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");
Expand All @@ -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");
});
});
6 changes: 3 additions & 3 deletions cli/src/lib/profile/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
buildProfileInspectView,
buildProfileInspectResultView,
buildProfileListView,
buildProfileStatusView,
configureAuthenticationRows,
Expand All @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions cli/src/lib/profile/views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Loading