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
17 changes: 17 additions & 0 deletions docs/SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ rather than emit a bundle that silently lacks its core.
The container-name filter defaults to the `ship-` prefix (the `app:` name in
`teploy.yml`); `--out` can point anywhere writable.

## Checking a fresh install: `teploy-ship doctor`

`teploy-ship doctor [--json] [--out <file>]` checks prerequisites before first
use: Node version, a writable state directory with enough free space, the web
port, `SHIP_WEB_TOKEN` and a model credential (presence only, values never
printed), the `NUCLEUS_URL` shape, store connectivity, and clock sanity.

Every check reports `pass`, `fail` or `unknown`; **unknown is not pass**. The
verdict is `ready` only when all pass, `not-ready` on any fail, `incomplete`
when nothing failed but something could not be established. Exit code is 0 only
for `ready`. Passing the `store-url` shape check does not mean the store was
reached: `store-connectivity` stays `unknown` until a live check answers (use
`teploy-ship preflight`). `--out` writes the redacted JSON, suitable to attach
to a support request. The restore side is `src/restore-readiness.ts`, which
compares backup and restored history snapshots and reports `unverified`
whenever it did not actually compare; it is not yet exposed as a command.

## The support policy shape

Hand the `.tgz` to the vendor. The bundle is safe by construction — whitelist
Expand Down
1 change: 1 addition & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const COMMAND_FLAGS: Record<string, FlagSpec> = {
},
web: { boolean: ["dev"], value: ["port", "token", "model"] },
support: { value: ["out", "log-lines", "days"] },
doctor: { boolean: ["json"], value: ["out"] },
eval: { boolean: ["critic", "settle"], value: ["model", "suite", "repeats"] },
};

Expand Down
21 changes: 21 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { NucleusCodeIndex } from "./code-index.js";
import type { CodeSearch } from "./code-index.js";
import { startWorker } from "./worker.js";
import { assembleSupportBundle, defaultDocker } from "./support.js";
import { defaultProbes, formatDoctor, renderDoctor, runDoctor } from "./install-doctor.js";
import { costUSD, isPricedModel } from "./pricing.js";
import { defaultRetryPolicy, withRetry, withCallTimeout, modelTimeoutFromEnv } from "./provider.js";
import { builtinSuite } from "./tasks.js";
Expand Down Expand Up @@ -197,6 +198,7 @@ Usage:
teploy-ship web serve the runs dashboard (browser approve/deny)
[--port N] [--token <t>] token also via SHIP_WEB_TOKEN (required)
[--dev] vite dev server instead of the built app
teploy-ship doctor [--json] [--out f] check this machine is ready (pass|fail|unknown; unknown is not pass)
teploy-ship support assemble a REDACTED diagnostic bundle (see docs/SUPPORT.md)
[--out DIR] [--log-lines N] what a vendor needs: versions, safe config keys,
[--days N] bounded logs, run-state summaries. Credentials
Expand Down Expand Up @@ -2375,6 +2377,23 @@ async function supportCommand(rest: string[]): Promise<void> {
else process.stderr.write(`${yellow("no tar created")} — the bundle directory above is complete; archive it yourself.\n`);
}

// ---------------------------------------------------------------------------
// doctor — S19 install-readiness checks (all logic in src/install-doctor.ts)
// ---------------------------------------------------------------------------

/** Exit 0 only for "ready"; 1 for not-ready or incomplete (unknown is not pass). */
async function doctorCommand(rest: string[]): Promise<void> {
const args = parseArgs(rest, COMMAND_FLAGS.doctor);
const port = Number(process.env.SHIP_WEB_PORT ?? process.env.PORT ?? 7460);
const report = await runDoctor(
defaultProbes({ env: process.env, stateDir: stateDir(), webPort: Number.isInteger(port) ? port : 7460, nodeVersion: process.version }),
);
const rendered = renderDoctor(report);
if (typeof args.flags.out === "string") writeFileSync(args.flags.out, rendered.json);
process.stdout.write(args.flags.json === true ? rendered.json : formatDoctor(report));
if (report.verdict !== "ready") process.exitCode = 1;
}

// ---------------------------------------------------------------------------

async function main(): Promise<void> {
Expand Down Expand Up @@ -2430,6 +2449,8 @@ async function main(): Promise<void> {
return webCommand(rest);
case "support":
return supportCommand(rest);
case "doctor":
return doctorCommand(rest);
case "eval":
return evalCommand(rest);
default:
Expand Down
176 changes: 176 additions & 0 deletions src/install-doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";

import { MAX_CLOCK_SKEW_MS, defaultProbes, formatDoctor, renderDoctor, runDoctor } from "./install-doctor.js";
import type { DoctorProbes } from "./install-doctor.js";

// Fake secrets assembled at runtime so no secret-shaped literal sits in source.
const FAKE_WEB_TOKEN = "webtok" + "-" + "Zq9".repeat(8);
const FAKE_MODEL_KEY = "sk" + "-" + "c3".repeat(20);
const FAKE_DB_PASSWORD = "hunter" + "2".repeat(6) + "pw";
const FAKE_GH = "ghp" + "_" + "d4".repeat(20);

const NOW = Date.UTC(2026, 9, 4, 12, 0, 0);

function allGood(over: Partial<DoctorProbes> = {}): DoctorProbes {
return {
nodeVersion: "v22.1.0",
env: { SHIP_WEB_TOKEN: FAKE_WEB_TOKEN, ANTHROPIC_API_KEY: FAKE_MODEL_KEY, NUCLEUS_URL: `postgres://ship:${FAKE_DB_PASSWORD}@db.internal:5432/ship` },
stateDir: "/var/lib/ship",
webPort: 7460,
writeProbe: async () => {},
freeMb: async () => 50_000,
portState: async () => "free",
storeReachable: async () => true,
nowMs: NOW,
referenceMs: async () => NOW + 1000,
...over,
};
}

const byId = (r: Awaited<ReturnType<typeof runDoctor>>, id: string) => r.checks.find((c) => c.id === id)!;

test("every probe answering well is the only way to be ready", async () => {
const r = await runDoctor(allGood());
assert.equal(r.verdict, "ready");
assert.equal(r.counts.fail + r.counts.unknown, 0);
});

test("unknown is not pass: no probes at all is incomplete, never ready", async () => {
const r = await runDoctor({ nodeVersion: "v22.0.0", env: { SHIP_WEB_TOKEN: "x", ANTHROPIC_API_KEY: "y" }, stateDir: "/x", webPort: 1, nowMs: NOW });
assert.equal(r.verdict, "incomplete");
for (const id of ["state-dir-writable", "state-dir-space", "web-port", "store-connectivity", "clock"]) {
assert.equal(byId(r, id).status, "unknown", id);
assert.ok(byId(r, id).remedy);
}
});

test("a throwing free-space or port probe is unknown, not pass", async () => {
const r = await runDoctor(
allGood({
freeMb: async () => {
throw new Error("boom");
},
portState: async () => {
throw new Error("boom");
},
referenceMs: async () => {
throw new Error("boom");
},
}),
);
assert.equal(byId(r, "state-dir-space").status, "unknown");
assert.equal(byId(r, "web-port").status, "unknown");
assert.equal(byId(r, "clock").status, "unknown");
assert.equal(r.verdict, "incomplete");
});

test("each failure mode is reported as fail with a remedy", async () => {
const r = await runDoctor(
allGood({
nodeVersion: "v18.19.0",
writeProbe: async () => {
throw new Error("EACCES");
},
freeMb: async () => 10,
portState: async () => "in-use",
env: { NUCLEUS_URL: "mysql://h/db" },
storeReachable: async () => false,
referenceMs: async () => NOW + MAX_CLOCK_SKEW_MS + 1,
}),
);
for (const id of ["node-version", "state-dir-writable", "state-dir-space", "web-port", "env-web-token", "env-model-credential", "store-url", "store-connectivity", "clock"]) {
assert.equal(byId(r, id).status, "fail", id);
assert.ok(byId(r, id).remedy, `${id} remedy`);
}
assert.equal(r.verdict, "not-ready");
});

test("an unset clock fails regardless of any reference", async () => {
const r = await runDoctor(allGood({ nowMs: 0, referenceMs: async () => 0 }));
assert.equal(byId(r, "clock").status, "fail");
});

test("store url shape passing does not imply connectivity passing", async () => {
const r = await runDoctor(allGood({ storeReachable: undefined }));
assert.equal(byId(r, "store-url").status, "pass");
assert.equal(byId(r, "store-connectivity").status, "unknown");
assert.equal(r.verdict, "incomplete");
});

test("no NUCLEUS_URL is a valid local install but connectivity stays unknown", async () => {
const r = await runDoctor(allGood({ env: { SHIP_WEB_TOKEN: "t", AI_GATEWAY_URL: "https://gw.example" } }));
assert.equal(byId(r, "store-url").status, "pass");
assert.equal(byId(r, "store-connectivity").status, "unknown");
});

test("empty / whitespace env values count as missing", async () => {
const r = await runDoctor(allGood({ env: { SHIP_WEB_TOKEN: " ", ANTHROPIC_API_KEY: "" } }));
assert.equal(byId(r, "env-web-token").status, "fail");
assert.equal(byId(r, "env-model-credential").status, "fail");
});

test("NEGATIVE CONTROL: planted secrets never appear in JSON or text output", async () => {
const leaky = allGood({
// an error that quotes a credential, and an unparseable URL carrying one
writeProbe: async () => {
throw new Error(`write failed using ${FAKE_GH} at postgres://ship:${FAKE_DB_PASSWORD}@h/db`);
},
});
const r = await runDoctor(leaky);
const json = renderDoctor(r);
const text = formatDoctor(r);
for (const secret of [FAKE_WEB_TOKEN, FAKE_MODEL_KEY, FAKE_DB_PASSWORD, FAKE_GH]) {
assert.ok(!json.json.includes(secret), `json leaked ${secret.slice(0, 6)}`);
assert.ok(!text.includes(secret), `text leaked ${secret.slice(0, 6)}`);
}
assert.ok(json.redactions >= 1, "the gate actually fired on the leaky error");
assert.doesNotThrow(() => JSON.parse(json.json));
});

test("env values are never in the report even before redaction", async () => {
const raw = JSON.stringify(await runDoctor(allGood()));
for (const secret of [FAKE_WEB_TOKEN, FAKE_MODEL_KEY, FAKE_DB_PASSWORD]) assert.ok(!raw.includes(secret));
});

test("unparseable NUCLEUS_URL detail does not echo the input", async () => {
const r = await runDoctor(allGood({ env: { SHIP_WEB_TOKEN: "t", ANTHROPIC_API_KEY: "k", NUCLEUS_URL: `://${FAKE_DB_PASSWORD}` } }));
assert.equal(byId(r, "store-url").status, "fail");
assert.ok(!JSON.stringify(r).includes(FAKE_DB_PASSWORD));
});

test("real probes: writable temp dir passes, scratch file is removed, port probe sees a listener", async () => {
const dir = mkdtempSync(join(tmpdir(), "doctor-"));
const srv = createServer();
await new Promise<void>((res) => srv.listen(0, "127.0.0.1", res));
const busy = (srv.address() as { port: number }).port;
try {
const base = { env: {}, stateDir: dir, webPort: busy, nodeVersion: process.version };
const p = defaultProbes(base);
const r = await runDoctor(p);
assert.equal(byId(r, "state-dir-writable").status, "pass");
assert.deepEqual(readdirSync(dir), []);
assert.equal(byId(r, "web-port").status, "fail");
assert.ok(["pass", "fail"].includes(byId(r, "state-dir-space").status), "statfs gave a real answer");
} finally {
srv.close();
rmSync(dir, { recursive: true, force: true });
}
});

test("real write probe against an unwritable path fails", async () => {
const dir = mkdtempSync(join(tmpdir(), "doctor-"));
try {
// A regular file as the parent makes mkdir fail with ENOTDIR on any platform and for root.
writeFileSync(join(dir, "afile"), "x");
const p = defaultProbes({ env: {}, stateDir: join(dir, "afile", "ship"), webPort: 0, nodeVersion: process.version });
const r = await runDoctor(p);
assert.equal(byId(r, "state-dir-writable").status, "fail");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
Loading
Loading