Skip to content
13 changes: 12 additions & 1 deletion packages/runtime-node/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@
/**
* `npx @autter/runtime-node doctor` — see doctor.ts.
*/
import { SDK_IDENTITY } from "./compat.js";
import { DOCTOR_USAGE, formatDoctorReport, parseDoctorArgs, runDoctor } from "./doctor.js";

/** `autter-runtime --version` / `-v` / `version`: the installed SDK's name and version. */
export function versionLine(): string {
return `${SDK_IDENTITY.name} ${SDK_IDENTITY.version}`;
}

async function main(argv: string[]): Promise<number> {
const [command, ...rest] = argv;
if (command === "--version" || command === "-v" || command === "version") {
console.log(versionLine());
return 0;
}
if (command !== "doctor") {
console.log(DOCTOR_USAGE);
return command === undefined || command === "--help" || command === "-h" ? 0 : 64;
Expand All @@ -20,7 +30,8 @@ async function main(argv: string[]): Promise<number> {
return 64;
}
const report = await runDoctor(parsed);
console.log(parsed.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report));
const output = parsed.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report, { quiet: parsed.quiet });
if (output) (report.exitCode === 0 ? console.log : console.error)(output);
return report.exitCode;
}

Expand Down
29 changes: 28 additions & 1 deletion packages/runtime-node/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface DoctorOptions {
key?: string;
features?: string[];
json?: boolean;
/** Print nothing when compatible, and only the problems otherwise (CI). */
quiet?: boolean;
cwd?: string;
timeoutMs?: number;
}
Expand Down Expand Up @@ -161,7 +163,8 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<DoctorRepo
};
}

export function formatDoctorReport(report: DoctorReport): string {
export function formatDoctorReport(report: DoctorReport, options: { quiet?: boolean } = {}): string {
if (options.quiet) return formatQuietDoctorReport(report);
const lines: string[] = [];
lines.push(`Autter Runtime doctor`);
lines.push(` endpoint ${report.endpoint}`);
Expand Down Expand Up @@ -211,7 +214,25 @@ export function formatDoctorReport(report: DoctorReport): string {
return lines.join("\n");
}

/** One line per problem, or nothing at all when everything is compatible. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [verified] Simulation failed: Quiet doctor reports mismatch, key, and unreachable-ingester failures — Risk: 75/100

Ran: node '.autter/scratch/doctor-quiet-harness.mjs'

The 503 key-check case exits nonzero and reports the storage-unavailable problem rather than only a generic incompatible status.: expected The 503 invocation exits nonzero and stderr identifies storage unavailable without relying only on a generic incompatible message.; observed {"exitCode":1,"stdout":"","stderr":"autter doctor: incompatible (exit 1)"}

Confirmed changed-code cause: runDoctor classifies a POST /v1/traces 503 as key="storage_unavailable" and exitCode=1, but formatQuietDoctorReport only renders rejected/client_key keys, feature mismatches, and issues. With a compatible /v1/compat response and no other problems, its lines remain empty and it falls through to "autter doctor: incompatible (exit 1)". The CLI correctly sends this nonzero report to stderr, so the missing diagnostic is specifically in quiet report formatting.

🛠 AI fix prompt (copy & paste into your coding agent)
In formatQuietDoctorReport, add a diagnostic for report.key === "storage_unavailable" (for example, "autter doctor: storage unavailable (503)") before the generic fallback. Add a quiet-formatting regression test for a report with key="storage_unavailable", exitCode=1, compatible features, and no issues; preferably also cover the CLI's stderr behavior for that report.

Flagged by Autter security & observability checks.

function formatQuietDoctorReport(report: DoctorReport): string {
if (report.exitCode === 0) return "";
const lines: string[] = [];
if (!report.ingester && report.exitCode === 2) {
lines.push(`autter doctor: ingester unreachable at ${report.endpoint} (${report.ingesterError ?? "unknown error"})`);
}
if (report.key === "rejected") lines.push("autter doctor: ingest key rejected (401)");
if (report.key === "client_key") lines.push("autter doctor: a client/browser key was given; server SDKs need a server key");
for (const feature of report.features) {
if (feature.status === "incompatible") lines.push(`autter doctor: feature ${feature.id} is incompatible`);
}
for (const issue of report.issues) lines.push(`autter doctor: ${issue.message}`);
if (lines.length === 0) lines.push(`autter doctor: incompatible (exit ${report.exitCode})`);
return lines.join("\n");
}

export const DOCTOR_USAGE = `Usage: npx @autter/runtime-node doctor [options]
npx @autter/runtime-node --version

Checks that your Autter SDKs, ingester and ClickHouse schema versions match.

Expand All @@ -220,6 +241,7 @@ Options:
--key <key> Also verify an ingest key (default: $AUTTER_RUNTIME_KEY)
--features <a,b> Only check these features (${COMPAT_MANIFEST.features.map((f) => f.id).join(", ")})
--json Machine-readable output
-q, --quiet Print nothing when compatible, only the problems otherwise
--timeout <ms> Per-request timeout (default 5000)

Exit codes: 0 compatible, 1 mismatch or rejected key, 2 ingester unreachable.`;
Expand All @@ -246,6 +268,10 @@ export function parseDoctorArgs(argv: string[], env: NodeJS.ProcessEnv = process
case "--json":
options.json = true;
break;
case "-q":
case "--quiet":
options.quiet = true;
break;
case "--timeout": {
const ms = Number(value());
if (!Number.isFinite(ms) || ms <= 0) return { error: "--timeout needs a positive number of ms" };
Expand All @@ -256,6 +282,7 @@ export function parseDoctorArgs(argv: string[], env: NodeJS.ProcessEnv = process
return { error: `unknown option ${arg}` };
}
}
if (options.json && options.quiet) return { error: "--json and --quiet cannot be combined" };
if (!options.key && env.AUTTER_RUNTIME_KEY) options.key = env.AUTTER_RUNTIME_KEY;
return options;
}
8 changes: 7 additions & 1 deletion packages/runtime-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,10 @@ export {
type IngesterCompatInfo,
type SdkIdentity,
} from "./compat.js";
export { runDoctor, type DoctorOptions, type DoctorReport } from "./doctor.js";
export {
formatDoctorReport,
parseDoctorArgs,
runDoctor,
type DoctorOptions,
type DoctorReport,
} from "./doctor.js";
59 changes: 59 additions & 0 deletions packages/runtime-node/test/doctor-quiet.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { formatDoctorReport, parseDoctorArgs } from "../dist/index.js";

const compatible = {
endpoint: "https://otlp.example.test",
sdks: [{ name: "@autter/runtime-node", version: "1.5.0" }],
ingester: { version: "1.5.0", schema: null },
features: [{ id: "logs", title: "Logs", status: "ok" }],
issues: [],
exitCode: 0,
};

test("--quiet and -q are parsed, and cannot be combined with --json", () => {
assert.deepEqual(parseDoctorArgs(["--quiet"], {}), { quiet: true });
assert.deepEqual(parseDoctorArgs(["-q", "--endpoint", "http://x"], {}), { quiet: true, endpoint: "http://x" });
assert.deepEqual(parseDoctorArgs(["--json", "--quiet"], {}), { error: "--json and --quiet cannot be combined" });
});

test("a quiet report is empty when everything is compatible", () => {
assert.equal(formatDoctorReport(compatible, { quiet: true }), "");
assert.match(formatDoctorReport(compatible), /Compatible\./);
});

test("a quiet report lists only the problems", () => {
const broken = {
...compatible,
key: "rejected",
features: [{ id: "traces", title: "Traces", status: "incompatible" }],
issues: [{ message: "ingester 1.3.0 is older than the SDK expects" }],
exitCode: 1,
};
assert.equal(
formatDoctorReport(broken, { quiet: true }),
[
"autter doctor: ingest key rejected (401)",
"autter doctor: feature traces is incompatible",
"autter doctor: ingester 1.3.0 is older than the SDK expects",
].join("\n"),
);
const unreachable = { ...compatible, ingester: null, ingesterError: "ECONNREFUSED", features: [], exitCode: 2 };
assert.equal(
formatDoctorReport(unreachable, { quiet: true }),
"autter doctor: ingester unreachable at https://otlp.example.test (ECONNREFUSED)",
);
});

test("the CLI prints its version and exits 0", async () => {
const { spawnSync } = await import("node:child_process");
const { fileURLToPath } = await import("node:url");
const { readFileSync } = await import("node:fs");
const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
for (const flag of ["--version", "-v", "version"]) {
const run = spawnSync(process.execPath, [cli, flag], { encoding: "utf8" });
assert.equal(run.status, 0, flag);
assert.equal(run.stdout.trim(), `@autter/runtime-node ${version}`);
}
});
Loading