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 @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Make first-run authentication guidance safe for agents and non-interactive shells, and keep configuration errors concise and readable.
- 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.

Expand Down
55 changes: 53 additions & 2 deletions cli/src/commands/doctor/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { configFile, credentialsFile, kvSet } from "@/lib/config.ts";

let testHome = "";
let mockFile = "";
let stdinIsTty: PropertyDescriptor | undefined;

const VALID_WHOAMI = {
principal: {
Expand All @@ -34,9 +35,15 @@ beforeEach(() => {
process.env.ALTERTABLE_CONFIG_HOME = testHome;
process.env.ALTERTABLE_SECRET_BACKEND = "file";
process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile;
stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
});

afterEach(() => {
if (stdinIsTty) {
Object.defineProperty(process.stdin, "isTTY", stdinIsTty);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
rmSync(testHome, { recursive: true, force: true });
delete process.env.ALTERTABLE_CONFIG_HOME;
delete process.env.ALTERTABLE_SECRET_BACKEND;
Expand Down Expand Up @@ -188,9 +195,21 @@ describe("doctor command", () => {
}),
]),
);
const remediation = report.checks.flatMap(
(check: { remediation?: string[] }) => check.remediation ?? [],
);
expect(remediation).toContain(
`Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env <name>`,
);
expect(remediation).toContain(
`Run: printf '%s' "$PASSWORD" | altertable profile configure --user <username> --password-stdin`,
);
expect(remediation).not.toContain("Run: altertable login");
expect(remediation.every((line: string) => !line.includes("--scope"))).toBe(true);
});

test("renders remediation in human output", async () => {
test("renders non-interactive remediation when stdin is not a TTY", async () => {
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
const harness = await runCommandWithTestRuntime(["doctor", "--offline"], {
debug: false,
json: false,
Expand All @@ -199,7 +218,39 @@ describe("doctor command", () => {
});

expect(harness.stdout[0]).toContain("ALTERTABLE CLI DOCTOR");
expect(harness.stdout[0]).toContain("altertable profile configure --scope management");
expect(harness.stdout[0]).toContain("--api-key-stdin --env <name>");
expect(harness.stdout[0]).toContain("--user <username> --password-stdin");
expect(harness.stdout[0]).toContain("ALTERTABLE_API_KEY and ALTERTABLE_ENV");
expect(harness.stdout[0]).not.toContain("altertable login");
expect(harness.stdout[0]).not.toContain("--scope");
expect(harness.stdout[0]).toContain("Result: unhealthy");
});

test("preserves login-first remediation for an interactive human terminal", async () => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
const harness = await runCommandWithTestRuntime(["doctor", "--offline"], {
debug: false,
json: false,
agent: false,
noColor: true,
});

expect(harness.stdout[0]).toContain("Run: altertable login");
expect(harness.stdout[0]).toContain("altertable profile configure --scope management");
expect(harness.stdout[0]).toContain("altertable profile configure --scope lakehouse");
});

test("uses non-interactive remediation for agent output even with a TTY", async () => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
const harness = await runCommandWithTestRuntime(["--agent", "doctor", "--offline"]);
const report = JSON.parse(harness.stdout[0] ?? "");
const remediation = report.checks.flatMap(
(check: { remediation?: string[] }) => check.remediation ?? [],
);

expect(remediation).toContain(
`Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env <name>`,
);
expect(remediation).not.toContain("Run: altertable login");
});
});
1 change: 1 addition & 0 deletions cli/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const doctorCommand = defineCommand({
const report = await runDoctorChecks(createDoctorChecks(), {
execution: createDiagnosticExecutionContext(runtime),
offline: args.offline === true,
interactive: !runtime.context.json && !runtime.context.agent && process.stdin.isTTY === true,
});
await writeCommandOutput(
{
Expand Down
60 changes: 44 additions & 16 deletions cli/src/commands/doctor/lib/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,50 @@ function validateLakehouseProbeResponse(body: string): void {
}
}

function checkManagementCredentialPresence(auth: ProfileAuth): DoctorCheckOutcome {
function managementCredentialRemediation(context: DoctorCheckContext): string[] {
if (context.interactive) {
return ["Run: altertable login", "Or run: altertable profile configure --scope management"];
}
return [
`Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env <name>`,
"Or set: ALTERTABLE_API_KEY and ALTERTABLE_ENV",
];
}

function lakehouseCredentialRemediation(context: DoctorCheckContext): string[] {
if (context.interactive) {
return ["Run: altertable login", "Or run: altertable profile configure --scope lakehouse"];
}
return [
`Run: printf '%s' "$PASSWORD" | altertable profile configure --user <username> --password-stdin`,
"Or set: ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME and ALTERTABLE_LAKEHOUSE_PASSWORD",
];
}

function checkManagementCredentialPresence(
auth: ProfileAuth,
context: DoctorCheckContext,
): DoctorCheckOutcome {
if (auth.management === "none") {
return failOutcome("No management credentials configured.", "configuration_error", [
"Run: altertable login",
"Or run: altertable profile configure --scope management",
]);
return failOutcome(
"No management credentials configured.",
"configuration_error",
managementCredentialRemediation(context),
);
}
return passOutcome(`Configured (${auth.management}).`, { auth: auth.management });
}

function checkLakehouseCredentialPresence(auth: ProfileAuth): DoctorCheckOutcome {
function checkLakehouseCredentialPresence(
auth: ProfileAuth,
context: DoctorCheckContext,
): DoctorCheckOutcome {
if (auth.lakehouse === "none") {
return failOutcome("No lakehouse credentials configured.", "configuration_error", [
"Run: altertable login",
"Or run: altertable profile configure --scope lakehouse",
]);
return failOutcome(
"No lakehouse credentials configured.",
"configuration_error",
lakehouseCredentialRemediation(context),
);
}
return passOutcome(`Configured (${auth.lakehouse}).`, { auth: auth.lakehouse });
}
Expand Down Expand Up @@ -141,7 +169,7 @@ export function createDoctorChecks(): DoctorCheck[] {
id: "management.credentials",
label: "Management auth",
requires: ["credentials.store"],
run: () => checkManagementCredentialPresence(requireProfileAuth()),
run: (context) => checkManagementCredentialPresence(requireProfileAuth(), context),
},
{
id: "management.api",
Expand All @@ -162,16 +190,16 @@ export function createDoctorChecks(): DoctorCheck[] {
const identity = formatManagementIdentity(body);
return passOutcome(`${endpoint} · ${identity}`, { endpoint, identity });
},
remediation: () => [
remediation: (context) => [
"Check the control-plane URL and management credentials.",
"Run: altertable profile configure --scope management",
...managementCredentialRemediation(context),
],
},
{
id: "lakehouse.credentials",
label: "Lakehouse auth",
requires: ["credentials.store"],
run: () => checkLakehouseCredentialPresence(requireProfileAuth()),
run: (context) => checkLakehouseCredentialPresence(requireProfileAuth(), context),
},
{
id: "lakehouse.api",
Expand All @@ -187,9 +215,9 @@ export function createDoctorChecks(): DoctorCheck[] {
validateLakehouseProbeResponse(body);
return passOutcome(`${endpoint} · SELECT 1 succeeded.`, { endpoint });
},
remediation: () => [
remediation: (context) => [
"Check the data-plane URL and lakehouse credentials.",
"Run: altertable profile configure --scope lakehouse",
...lakehouseCredentialRemediation(context),
],
},
];
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/doctor/lib/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type DoctorReport = {
export type DoctorCheckContext = {
execution: ExecutionContext;
offline: boolean;
interactive: boolean;
};

export type DoctorCheckOutcome = Omit<DoctorCheckResult, "id" | "label">;
Expand All @@ -41,5 +42,5 @@ export type DoctorCheck = {
requires?: readonly string[];
skip?: (context: DoctorCheckContext) => string | undefined;
run: (context: DoctorCheckContext) => DoctorCheckOutcome | Promise<DoctorCheckOutcome>;
remediation?: (error: unknown, context: DoctorCheckContext) => string[];
remediation?: (context: DoctorCheckContext) => string[];
};
6 changes: 5 additions & 1 deletion cli/src/commands/doctor/lib/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runDoctorChecks } from "@/commands/doctor/lib/runner.ts";
function createDoctorContext(offline = false): DoctorCheckContext {
return {
offline,
interactive: false,
execution: {
profile: "test",
cli: { debug: false, json: true, agent: false },
Expand Down Expand Up @@ -46,7 +47,10 @@ describe("runDoctorChecks", () => {
run() {
throw new ConfigurationError("Missing.");
},
remediation: () => ["Configure it."],
remediation: (context) => {
expect(context.execution.profile).toBe("test");
return ["Configure it."];
},
},
{
id: "dependent",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/doctor/lib/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async function runDoctorCheck(
code: serialized.code,
http_status: serialized.status,
details: serialized.details,
remediation: check.remediation?.(error, context),
remediation: check.remediation?.(context),
duration_ms: Math.round(performance.now() - startedAt),
};
}
Expand Down
5 changes: 4 additions & 1 deletion cli/src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ function isInteractiveTerminal(): boolean {
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 <name>'.",
"altertable login needs an interactive terminal with a browser and does not support --json or --agent.\n" +
"For headless setups, pipe a management key:\n" +
` printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env <name>\n` +
"Or set ALTERTABLE_API_KEY and ALTERTABLE_ENV.",
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/lib/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("auth", () => {
test("getLakehouseAuthHeader throws ConfigurationError when credentials are missing", () => {
expect(() => getLakehouseAuthHeader(profileName)).toThrow(ConfigurationError);
expect(() => getLakehouseAuthHeader(profileName)).toThrow(
"No credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).",
"No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).",
);
});

Expand Down
2 changes: 1 addition & 1 deletion cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function resolveLakehouseCredential(profileName: string): LakehouseCreden
return credential;
}
throw new ConfigurationError(
"No credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).",
"No lakehouse credentials. Run 'altertable login', 'altertable profile configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).",
);
}

Expand Down
22 changes: 15 additions & 7 deletions cli/src/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,26 @@ afterEach(() => {

describe("errors", () => {
test("renderCliError formats CliError", () => {
expect(renderCliError(new CliError("x"))).toBe("ERROR x");
expect(renderCliError(new CliError("x"))).toBe("[ERROR] x");
});

test("renderCliErrorDetails preserves trusted line structure and sanitizes each line", () => {
const rendered = renderCliErrorDetails("First line\nRun this\u001b]0;spoofed\u0007");
test("renderCliError preserves trusted line structure and sanitizes each line", () => {
const rendered = renderCliError(
new ConfigurationError("First line\nRun this\u001b]0;spoofed\u0007"),
);

expect(rendered).toBe("ERROR First line\nRun this\\x1b]0;spoofed\\x07");
expect(rendered).toBe("[ERROR] First line\nRun this\\x1b]0;spoofed\\x07");
expect(rendered).not.toContain("\\x0a");
expect(rendered).not.toContain("\u001b");
expect(rendered).not.toContain("\u0007");
});

test("renderCliErrorDetails prefixes the first line with the documented marker", () => {
expect(renderCliErrorDetails("First detail\nSecond detail")).toBe(
"[ERROR] First detail\nSecond detail",
);
});

test("ConfigurationError uses EXIT_CONFIG", () => {
const error = new ConfigurationError("missing config");
expect(error.exitCode).toBe(EXIT_CONFIG);
Expand All @@ -60,14 +68,14 @@ describe("errors", () => {

test("unknown errors render without stack traces", () => {
const rendered = renderCliError(new TypeError("secret internal detail"));
expect(rendered).toBe("ERROR Unexpected error.");
expect(rendered).toBe("[ERROR] Unexpected error.");
expect(rendered).not.toContain("secret internal detail");
expect(rendered).not.toContain("TypeError");
});

test("shouldShowCommandExamplesOnError is true for usage CliErrors", () => {
test("shouldShowCommandExamplesOnError is true only for usage CliErrors", () => {
expect(shouldShowCommandExamplesOnError(new CliError("Endpoint path is required."))).toBe(true);
expect(shouldShowCommandExamplesOnError(new ConfigurationError("Not configured."))).toBe(true);
expect(shouldShowCommandExamplesOnError(new ConfigurationError("Not configured."))).toBe(false);
});

test("shouldShowCommandExamplesOnError is false for transport and HTTP errors", () => {
Expand Down
2 changes: 1 addition & 1 deletion cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,5 +290,5 @@ export function shouldShowCommandExamplesOnError(error: unknown): boolean {
) {
return false;
}
return error.exitCode === EXIT_GENERIC || error.exitCode === EXIT_CONFIG;
return error.exitCode === EXIT_GENERIC;
}
2 changes: 1 addition & 1 deletion cli/src/lib/profile-configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function writeOutro(
export function configureNonTtyErrorMessage(): string {
return (
"Interactive configure requires a TTY. Examples:\n" +
" altertable profile configure --api-key atm_xxx --env production\n" +
` printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env production\n` +
" printf '%s' \"$PASS\" | altertable profile configure --user alice --password-stdin"
);
}
Expand Down
6 changes: 3 additions & 3 deletions cli/src/ui/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ export function renderCliErrorJson(error: unknown): string {

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 renderCliErrorDetails(error.message);
}
return renderDisplayText([span("ERROR", "error"), span(" Unexpected error.")]);
return renderCliErrorDetails("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([span("[ERROR]", "error"), span(` ${line}`)])
: renderDisplayText(line),
)
.join("\n");
Expand Down
13 changes: 12 additions & 1 deletion tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,19 @@ describe("altertable doctor", () => {
id: "management.credentials",
status: "fail",
code: "configuration_error",
remediation: [
`Run: printf '%s' "$KEY" | altertable profile configure --api-key-stdin --env <name>`,
"Or set: ALTERTABLE_API_KEY and ALTERTABLE_ENV",
],
}),
expect.objectContaining({
id: "lakehouse.credentials",
status: "fail",
code: "configuration_error",
remediation: [
`Run: printf '%s' "$PASSWORD" | altertable profile configure --user <username> --password-stdin`,
"Or set: ALTERTABLE_BASIC_AUTH_TOKEN or ALTERTABLE_LAKEHOUSE_USERNAME and ALTERTABLE_LAKEHOUSE_PASSWORD",
],
}),
]),
);
Expand Down Expand Up @@ -110,7 +118,10 @@ describe("altertable doctor", () => {
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("--api-key-stdin --env <name>");
expect(result.stdout).toContain("--user <username> --password-stdin");
expect(result.stdout).not.toContain("altertable login");
expect(result.stdout).not.toContain("--scope");
expect(result.stdout).toContain("Result: unhealthy");
expect(result.stdout).not.toContain("undefined");
});
Expand Down
Loading