@shipd-ai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plan.js ADDED
@@ -0,0 +1,367 @@
1
+ import { isPrecheckKey, precheckStageId, } from "./contract.js";
2
+ import { errorMessage } from "./errors.js";
3
+ import { formatDuration } from "./format.js";
4
+ import { precheckVerdict } from "./report.js";
5
+ export const DEFAULT_POLL_MS = 15_000;
6
+ /** The longest worker timeout. */
7
+ export const DEFAULT_MAX_STEP_MS = 3 * 60 * 60 * 1000;
8
+ function sleep(ms, signal) {
9
+ if (signal?.aborted)
10
+ return Promise.resolve();
11
+ return new Promise((resolve) => {
12
+ const done = () => {
13
+ clearTimeout(timer);
14
+ signal?.removeEventListener("abort", done);
15
+ resolve();
16
+ };
17
+ const timer = setTimeout(done, ms);
18
+ signal?.addEventListener("abort", done, { once: true });
19
+ });
20
+ }
21
+ const TERMINAL = new Set([
22
+ "completed",
23
+ "failed",
24
+ "skipped",
25
+ "error",
26
+ "cancelled",
27
+ ]);
28
+ function isTerminal(record) {
29
+ return TERMINAL.has(record.status);
30
+ }
31
+ /** The step nothing depends on. With several, the last declared wins. */
32
+ export function sinkStepId(check) {
33
+ const depended = new Set(check.steps.flatMap((s) => s.after));
34
+ const sinks = check.steps.filter((s) => !depended.has(s.id));
35
+ const last = sinks[sinks.length - 1] ?? check.steps[check.steps.length - 1];
36
+ return last?.id ?? "run";
37
+ }
38
+ function completedOutputs(steps) {
39
+ const out = {};
40
+ for (const [id, record] of Object.entries(steps)) {
41
+ if (record.status === "completed")
42
+ out[id] = record.output;
43
+ }
44
+ return out;
45
+ }
46
+ function finish(check, partial, startedAt, controls) {
47
+ const result = {
48
+ key: check.key,
49
+ label: check.label,
50
+ kind: check.kind,
51
+ durationMs: Date.now() - startedAt,
52
+ ...partial,
53
+ };
54
+ controls.onEvent?.({ type: "check:end", checkKey: check.key, result });
55
+ return result;
56
+ }
57
+ async function runStatelessPrecheck(api, bundle, check, startedAt, controls) {
58
+ const stageId = precheckStageId(check.key);
59
+ controls.onEvent?.({ type: "step:start", checkKey: check.key, stepId: "run", immediate: false });
60
+ try {
61
+ const stage = await api.runPrecheckStage(bundle, stageId);
62
+ const verdict = precheckVerdict(stage.status);
63
+ const skipped = verdict === "skipped";
64
+ const reason = skipped
65
+ ? stage.missingFields?.length
66
+ ? `missing fields: ${stage.missingFields.join(", ")}`
67
+ : "stage skipped"
68
+ : undefined;
69
+ const step = {
70
+ status: "completed",
71
+ output: stage,
72
+ verdict,
73
+ startedAt,
74
+ finishedAt: Date.now(),
75
+ reason,
76
+ };
77
+ controls.onEvent?.({
78
+ type: "step:end",
79
+ checkKey: check.key,
80
+ stepId: "run",
81
+ status: "completed",
82
+ verdict,
83
+ });
84
+ return finish(check, {
85
+ status: skipped ? "skipped" : "completed",
86
+ verdict,
87
+ output: stage,
88
+ reason,
89
+ steps: { run: step },
90
+ }, startedAt, controls);
91
+ }
92
+ catch (error) {
93
+ const message = errorMessage(error);
94
+ controls.onEvent?.({
95
+ type: "step:end",
96
+ checkKey: check.key,
97
+ stepId: "run",
98
+ status: "error",
99
+ error: message,
100
+ });
101
+ return finish(check, {
102
+ status: "error",
103
+ error: message,
104
+ steps: { run: { status: "error", error: message, startedAt, finishedAt: Date.now() } },
105
+ }, startedAt, controls);
106
+ }
107
+ }
108
+ /** Run one check against a Harbor bundle without writing anything to the quest. */
109
+ export async function executeStatelessCheck(api, bundle, check, options, controls) {
110
+ const startedAt = Date.now();
111
+ controls.onEvent?.({ type: "check:start", checkKey: check.key });
112
+ if (check.kind === "precheck" || isPrecheckKey(check.key)) {
113
+ return runStatelessPrecheck(api, bundle, check, startedAt, controls);
114
+ }
115
+ const steps = {};
116
+ for (const step of check.steps)
117
+ steps[step.id] = { status: "pending" };
118
+ const { signal } = controls;
119
+ const endStep = (stepId, record) => {
120
+ steps[stepId] = { ...steps[stepId], ...record, finishedAt: Date.now() };
121
+ controls.onEvent?.({
122
+ type: "step:end",
123
+ checkKey: check.key,
124
+ stepId,
125
+ status: record.status,
126
+ verdict: record.verdict,
127
+ error: record.error,
128
+ reason: record.reason,
129
+ });
130
+ };
131
+ const startStep = async (stepId) => {
132
+ const now = Date.now();
133
+ steps[stepId] = { status: "running", startedAt: now };
134
+ try {
135
+ const started = await api.startStep(bundle, check.key, stepId, completedOutputs(steps), options);
136
+ if (started.kind === "immediate") {
137
+ controls.onEvent?.({ type: "step:start", checkKey: check.key, stepId, immediate: true });
138
+ endStep(stepId, { status: "completed", output: started.output });
139
+ return;
140
+ }
141
+ steps[stepId] = { status: "running", startedAt: now, handle: started.handle };
142
+ controls.onEvent?.({
143
+ type: "step:start",
144
+ checkKey: check.key,
145
+ stepId,
146
+ handle: started.handle,
147
+ immediate: false,
148
+ });
149
+ }
150
+ catch (error) {
151
+ endStep(stepId, { status: "error", error: errorMessage(error) });
152
+ }
153
+ };
154
+ const pollStep = async (stepId) => {
155
+ const record = steps[stepId];
156
+ if (record.status !== "running" || !record.handle)
157
+ return;
158
+ const elapsed = Date.now() - (record.startedAt ?? Date.now());
159
+ if (elapsed >= controls.maxStepMs) {
160
+ // Keep the handle so `shipd checks status` / `cancel` still work on it.
161
+ endStep(stepId, {
162
+ status: "error",
163
+ error: `timed out after ${formatDuration(controls.maxStepMs)}`,
164
+ });
165
+ return;
166
+ }
167
+ try {
168
+ const status = await api.getStep(record.handle);
169
+ if (status.status === "running")
170
+ return;
171
+ if (status.status === "completed") {
172
+ endStep(stepId, { status: "completed", output: status.output, verdict: status.verdict });
173
+ return;
174
+ }
175
+ endStep(stepId, {
176
+ status: "failed",
177
+ error: status.error,
178
+ output: status.output,
179
+ verdict: status.verdict,
180
+ });
181
+ }
182
+ catch (error) {
183
+ endStep(stepId, { status: "error", error: errorMessage(error) });
184
+ }
185
+ };
186
+ const cancelInFlight = async () => {
187
+ const running = Object.entries(steps).filter(([, r]) => r.status === "running");
188
+ const leave = controls.onAbort?.() === "leave";
189
+ await Promise.all(running.map(async ([stepId, record]) => {
190
+ if (record.handle && !leave) {
191
+ try {
192
+ await api.cancelStep(record.handle);
193
+ }
194
+ catch {
195
+ // Best effort: the handle is still in the report for a manual cancel.
196
+ }
197
+ }
198
+ endStep(stepId, {
199
+ status: "cancelled",
200
+ reason: leave ? "interrupted; left running (handle kept in the report)" : "cancelled",
201
+ });
202
+ }));
203
+ for (const [stepId, record] of Object.entries(steps)) {
204
+ if (record.status === "pending")
205
+ endStep(stepId, { status: "skipped", reason: "cancelled" });
206
+ }
207
+ };
208
+ let cancelled = false;
209
+ while (true) {
210
+ if (signal?.aborted) {
211
+ cancelled = true;
212
+ await cancelInFlight();
213
+ break;
214
+ }
215
+ // Schedule: every pending step whose dependencies are all terminal.
216
+ const toStart = [];
217
+ let progressed = false;
218
+ for (const step of check.steps) {
219
+ if (steps[step.id].status !== "pending")
220
+ continue;
221
+ if (!step.after.every((dep) => steps[dep] && isTerminal(steps[dep])))
222
+ continue;
223
+ const broken = step.after.find((dep) => steps[dep].status !== "completed");
224
+ if (broken) {
225
+ endStep(step.id, { status: "skipped", reason: `upstream failed: ${broken}` });
226
+ progressed = true;
227
+ continue;
228
+ }
229
+ toStart.push(step.id);
230
+ }
231
+ if (toStart.length > 0) {
232
+ progressed = true;
233
+ await Promise.all(toStart.map(startStep));
234
+ }
235
+ const running = Object.keys(steps).filter((id) => steps[id].status === "running");
236
+ if (running.length > 0) {
237
+ await Promise.all(running.map(pollStep));
238
+ progressed = true;
239
+ }
240
+ const allTerminal = Object.values(steps).every(isTerminal);
241
+ if (allTerminal)
242
+ break;
243
+ if (!progressed) {
244
+ // Nothing can start and nothing is running: the plan references steps
245
+ // that do not exist. Report rather than spin forever.
246
+ for (const [stepId, record] of Object.entries(steps)) {
247
+ if (record.status === "pending") {
248
+ endStep(stepId, { status: "error", error: "unresolvable step dependencies" });
249
+ }
250
+ }
251
+ break;
252
+ }
253
+ const stillRunning = Object.values(steps).some((r) => r.status === "running");
254
+ if (stillRunning)
255
+ await sleep(controls.pollMs, signal);
256
+ }
257
+ const sink = steps[sinkStepId(check)];
258
+ const entries = Object.entries(steps);
259
+ if (cancelled || entries.some(([, r]) => r.status === "cancelled")) {
260
+ return finish(check, { status: "cancelled", reason: "cancelled", steps }, startedAt, controls);
261
+ }
262
+ if (sink?.status === "completed") {
263
+ return finish(check, { status: "completed", verdict: sink.verdict, output: sink.output, steps }, startedAt, controls);
264
+ }
265
+ const failed = entries.find(([, r]) => r.status === "failed");
266
+ if (failed) {
267
+ const [stepId, record] = failed;
268
+ const prefix = check.steps.length > 1 ? `${stepId}: ` : "";
269
+ return finish(check, {
270
+ status: "failed",
271
+ verdict: record.verdict,
272
+ output: record.output,
273
+ error: `${prefix}${record.error ?? "step failed"}`,
274
+ steps,
275
+ }, startedAt, controls);
276
+ }
277
+ const errored = entries.find(([, r]) => r.status === "error");
278
+ const [stepId, record] = errored ?? ["", { status: "error" }];
279
+ const prefix = check.steps.length > 1 && stepId ? `${stepId}: ` : "";
280
+ return finish(check, { status: "error", error: `${prefix}${record.error ?? "step did not complete"}`, steps }, startedAt, controls);
281
+ }
282
+ /** Run one check live on an existing task; the quest writes its normal rows. */
283
+ export async function executeLiveCheck(api, task, check, options, controls) {
284
+ const startedAt = Date.now();
285
+ controls.onEvent?.({ type: "check:start", checkKey: check.key });
286
+ const { signal } = controls;
287
+ const step = (record) => ({
288
+ trigger: { status: "pending", startedAt, finishedAt: Date.now(), ...record },
289
+ });
290
+ let started;
291
+ try {
292
+ started = await api.runLiveCheck(task.versionId, task.problemId, check.key, options);
293
+ }
294
+ catch (error) {
295
+ const message = errorMessage(error);
296
+ return finish(check, { status: "error", error: message, steps: step({ status: "error", error: message }) }, startedAt, controls);
297
+ }
298
+ controls.onEvent?.({
299
+ type: "live:started",
300
+ checkKey: check.key,
301
+ started: started.started,
302
+ detail: started.detail,
303
+ });
304
+ if (!started.started) {
305
+ const reason = started.detail ?? "the quest did not start this check";
306
+ return finish(check, { status: "skipped", reason, steps: step({ status: "skipped", reason }) }, startedAt, controls);
307
+ }
308
+ let nudged = false;
309
+ while (true) {
310
+ if (signal?.aborted) {
311
+ const reason = "interrupted; the check keeps running in the product";
312
+ return finish(check, { status: "cancelled", reason, steps: step({ status: "cancelled", reason }) }, startedAt, controls);
313
+ }
314
+ if (Date.now() - startedAt >= controls.maxStepMs) {
315
+ const error = `timed out after ${formatDuration(controls.maxStepMs)}`;
316
+ return finish(check, { status: "error", error, steps: step({ status: "error", error }) }, startedAt, controls);
317
+ }
318
+ let live;
319
+ try {
320
+ live = await api.getLiveCheck(task.versionId, check.key);
321
+ }
322
+ catch (error) {
323
+ const message = errorMessage(error);
324
+ return finish(check, { status: "error", error: message, steps: step({ status: "error", error: message }) }, startedAt, controls);
325
+ }
326
+ if (live.status === "running") {
327
+ if (live.nudgeSynthesis && !nudged) {
328
+ nudged = true;
329
+ controls.onEvent?.({ type: "live:nudge", checkKey: check.key });
330
+ try {
331
+ await api.nudgeSynthesis(task.versionId);
332
+ }
333
+ catch {
334
+ // The product's own scheduler will pick it up; keep polling.
335
+ }
336
+ }
337
+ await sleep(controls.pollMs, signal);
338
+ continue;
339
+ }
340
+ const output = live.output ?? undefined;
341
+ const jobIds = live.jobIds;
342
+ if (live.status === "completed") {
343
+ return finish(check, {
344
+ status: "completed",
345
+ verdict: live.verdict,
346
+ output,
347
+ jobIds,
348
+ steps: step({ status: "completed", output, verdict: live.verdict }),
349
+ }, startedAt, controls);
350
+ }
351
+ const error = live.error ?? "check failed";
352
+ return finish(check, {
353
+ status: "failed",
354
+ verdict: live.verdict,
355
+ output,
356
+ error,
357
+ jobIds,
358
+ steps: step({ status: "failed", output, verdict: live.verdict, error }),
359
+ }, startedAt, controls);
360
+ }
361
+ }
362
+ /** Run the selected checks concurrently and return one result per check. */
363
+ export function runChecks(api, target, checks, options, controls) {
364
+ return Promise.all(checks.map((check) => target.kind === "harbor"
365
+ ? executeStatelessCheck(api, target.bundle, check, options, controls)
366
+ : executeLiveCheck(api, target.task, check, options, controls)));
367
+ }
@@ -0,0 +1,24 @@
1
+ declare const VERSION: string, PACKAGE_NAME: string;
2
+ export { VERSION, PACKAGE_NAME };
3
+ export declare const DEFAULT_QUEST = "olympus";
4
+ export declare function knownQuests(): string[];
5
+ /** `SHIPD_<QUEST>_URL` override, or NODE_ENV=development → localhost. */
6
+ export declare function questBaseUrl(quest: string): string;
7
+ /** The page that mints the CLI token for the browser login flow. */
8
+ export declare function questAuthUrl(quest: string): string;
9
+ export interface QuestConfig {
10
+ convexUrl: string;
11
+ minCliVersion: string;
12
+ fetchedAt: string;
13
+ }
14
+ /** Load the quest's CLI config (cached 5 minutes) or fetch it from the quest. */
15
+ export declare function questConfig(quest: string): Promise<QuestConfig>;
16
+ /** The quest's Convex deployment URL, with `SHIPD_<QUEST>_CONVEX_URL` override. */
17
+ export declare function questConvexUrl(quest: string): Promise<string>;
18
+ /** Compare semver strings numerically (e.g. "0.10.0" > "0.9.0"). */
19
+ export declare function semverGt(a: string, b: string): boolean;
20
+ /**
21
+ * Warn (never block) when this CLI is below the quest's minimum or behind the
22
+ * latest npm release. Set SHIPD_NO_UPDATE_CHECK=1 to skip.
23
+ */
24
+ export declare function checkVersion(quest?: string): Promise<void>;
package/dist/quests.js ADDED
@@ -0,0 +1,181 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { resolve } from "node:path";
4
+ import { ensureShipdDir, shipdDir } from "./auth.js";
5
+ import { UsageError } from "./errors.js";
6
+ const require = createRequire(import.meta.url);
7
+ const { version: VERSION, name: PACKAGE_NAME } = require("../package.json");
8
+ export { VERSION, PACKAGE_NAME };
9
+ // A quest entry is just a base URL. Everything else (the Convex deployment,
10
+ // the minimum CLI version) comes from the quest's `/api/cli/config`.
11
+ const REGISTRY = {
12
+ olympus: {
13
+ prod: "https://shipd.ai/quests/olympus",
14
+ dev: "http://localhost:3006/quests/olympus",
15
+ },
16
+ };
17
+ export const DEFAULT_QUEST = "olympus";
18
+ export function knownQuests() {
19
+ return Object.keys(REGISTRY);
20
+ }
21
+ function envKey(quest, suffix) {
22
+ return `SHIPD_${quest.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_${suffix}`;
23
+ }
24
+ /** `SHIPD_<QUEST>_URL` override, or NODE_ENV=development → localhost. */
25
+ export function questBaseUrl(quest) {
26
+ const override = process.env[envKey(quest, "URL")];
27
+ if (override)
28
+ return override.replace(/\/+$/, "");
29
+ const entry = REGISTRY[quest];
30
+ if (!entry) {
31
+ throw new UsageError(`Unknown quest "${quest}". Known quests: ${knownQuests().join(", ")}`);
32
+ }
33
+ return process.env.NODE_ENV === "development" ? entry.dev : entry.prod;
34
+ }
35
+ /** The page that mints the CLI token for the browser login flow. */
36
+ export function questAuthUrl(quest) {
37
+ return `${questBaseUrl(quest)}/cli-auth`;
38
+ }
39
+ const CONFIG_TTL_MS = 5 * 60 * 1000;
40
+ const VERSION_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
41
+ function configCachePath(quest) {
42
+ return resolve(shipdDir(), `config-${quest}.json`);
43
+ }
44
+ function isValidQuestConfig(value) {
45
+ if (!value || typeof value !== "object" || Array.isArray(value))
46
+ return false;
47
+ const candidate = value;
48
+ return (typeof candidate.convexUrl === "string" &&
49
+ candidate.convexUrl.length > 0 &&
50
+ typeof candidate.minCliVersion === "string" &&
51
+ candidate.minCliVersion.length > 0 &&
52
+ typeof candidate.fetchedAt === "string" &&
53
+ candidate.fetchedAt.length > 0);
54
+ }
55
+ function readFreshCache(path, ttlMs, isValid) {
56
+ if (!existsSync(path))
57
+ return null;
58
+ try {
59
+ const cached = JSON.parse(readFileSync(path, "utf-8"));
60
+ if (!isValid(cached))
61
+ return null;
62
+ const age = Date.now() - new Date(cached.fetchedAt).getTime();
63
+ return age < ttlMs ? cached : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function writeCache(path, value) {
70
+ try {
71
+ ensureShipdDir();
72
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
73
+ }
74
+ catch {
75
+ // A cache write failure must not break the command.
76
+ }
77
+ }
78
+ /** Load the quest's CLI config (cached 5 minutes) or fetch it from the quest. */
79
+ export async function questConfig(quest) {
80
+ const cached = readFreshCache(configCachePath(quest), CONFIG_TTL_MS, isValidQuestConfig);
81
+ if (cached)
82
+ return cached;
83
+ const baseUrl = questBaseUrl(quest);
84
+ const configUrl = `${baseUrl}/api/cli/config`;
85
+ let res;
86
+ try {
87
+ res = await fetch(configUrl);
88
+ }
89
+ catch (error) {
90
+ const detail = error instanceof Error ? error.message : String(error);
91
+ throw new UsageError(`Could not reach ${configUrl} (${detail}).\n` +
92
+ ` Set ${envKey(quest, "URL")} if the quest runs somewhere else.`);
93
+ }
94
+ if (!res.ok) {
95
+ throw new UsageError(`Could not fetch CLI config from ${configUrl} (HTTP ${res.status}).`);
96
+ }
97
+ const data = (await res.json());
98
+ if (typeof data.convexUrl !== "string" || !data.convexUrl) {
99
+ throw new UsageError(`CLI config from ${configUrl} is missing convexUrl.\n` +
100
+ ` Temporary workaround: set ${envKey(quest, "CONVEX_URL")} manually.`);
101
+ }
102
+ const config = {
103
+ convexUrl: data.convexUrl,
104
+ minCliVersion: typeof data.minCliVersion === "string" ? data.minCliVersion : "0.0.0",
105
+ fetchedAt: new Date().toISOString(),
106
+ };
107
+ writeCache(configCachePath(quest), config);
108
+ return config;
109
+ }
110
+ /** The quest's Convex deployment URL, with `SHIPD_<QUEST>_CONVEX_URL` override. */
111
+ export async function questConvexUrl(quest) {
112
+ const override = process.env[envKey(quest, "CONVEX_URL")];
113
+ if (override)
114
+ return override;
115
+ return (await questConfig(quest)).convexUrl;
116
+ }
117
+ /** Compare semver strings numerically (e.g. "0.10.0" > "0.9.0"). */
118
+ export function semverGt(a, b) {
119
+ const pa = a.split(".").map(Number);
120
+ const pb = b.split(".").map(Number);
121
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
122
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
123
+ return true;
124
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
125
+ return false;
126
+ }
127
+ return false;
128
+ }
129
+ function isValidVersionCache(value) {
130
+ if (!value || typeof value !== "object" || Array.isArray(value))
131
+ return false;
132
+ const candidate = value;
133
+ return (typeof candidate.latestVersion === "string" &&
134
+ candidate.latestVersion.length > 0 &&
135
+ typeof candidate.fetchedAt === "string" &&
136
+ candidate.fetchedAt.length > 0);
137
+ }
138
+ async function latestPublishedVersion() {
139
+ const cachePath = resolve(shipdDir(), "version-check.json");
140
+ const cached = readFreshCache(cachePath, VERSION_CACHE_TTL_MS, isValidVersionCache);
141
+ if (cached)
142
+ return cached.latestVersion;
143
+ try {
144
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`, {
145
+ headers: { Accept: "application/json" },
146
+ });
147
+ if (!res.ok)
148
+ return null;
149
+ const data = (await res.json());
150
+ if (typeof data.version !== "string" || !data.version)
151
+ return null;
152
+ writeCache(cachePath, { latestVersion: data.version, fetchedAt: new Date().toISOString() });
153
+ return data.version;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ /**
160
+ * Warn (never block) when this CLI is below the quest's minimum or behind the
161
+ * latest npm release. Set SHIPD_NO_UPDATE_CHECK=1 to skip.
162
+ */
163
+ export async function checkVersion(quest = DEFAULT_QUEST) {
164
+ if (process.env.SHIPD_NO_UPDATE_CHECK === "1")
165
+ return;
166
+ const [configResult, latestResult] = await Promise.allSettled([
167
+ questConfig(quest),
168
+ latestPublishedVersion(),
169
+ ]);
170
+ if (configResult.status === "fulfilled" && semverGt(configResult.value.minCliVersion, VERSION)) {
171
+ console.error(`\n CLI v${VERSION} is below the minimum required v${configResult.value.minCliVersion}` +
172
+ "\n Run: shipd update\n");
173
+ return;
174
+ }
175
+ if (latestResult.status === "fulfilled" &&
176
+ latestResult.value &&
177
+ semverGt(latestResult.value, VERSION)) {
178
+ console.error(`\n CLI v${VERSION} is behind the latest published v${latestResult.value}` +
179
+ "\n Run: shipd update\n");
180
+ }
181
+ }
@@ -0,0 +1,62 @@
1
+ import type { Handle, PrecheckResult, Verdict } from "./contract.js";
2
+ export type StepState = "pending" | "running" | "completed" | "failed" | "skipped" | "error" | "cancelled";
3
+ export type StepRecord = {
4
+ status: StepState;
5
+ handle?: Handle;
6
+ output?: unknown;
7
+ verdict?: Verdict;
8
+ error?: string;
9
+ reason?: string;
10
+ startedAt?: number;
11
+ finishedAt?: number;
12
+ };
13
+ export type CheckStatus = "completed" | "failed" | "skipped" | "error" | "cancelled";
14
+ export type CheckResult = {
15
+ key: string;
16
+ label: string;
17
+ kind: "precheck" | "dynamic";
18
+ status: CheckStatus;
19
+ verdict?: Verdict;
20
+ output?: unknown;
21
+ error?: string;
22
+ reason?: string;
23
+ durationMs: number;
24
+ steps: Record<string, StepRecord>;
25
+ /** Live modes only: the quest's job ids behind this check. */
26
+ jobIds?: Record<string, string>;
27
+ };
28
+ export type ReportMode = "harbor" | "harbor-create" | "task";
29
+ export type ReportSource = {
30
+ dir?: string;
31
+ inputHash?: string;
32
+ problemId?: string;
33
+ versionId?: string;
34
+ title?: string;
35
+ url?: string;
36
+ };
37
+ export type Report = {
38
+ quest: string;
39
+ mode: ReportMode;
40
+ /** `explicit` = --check; `all` = --all. Skips are failures only for explicit selections. */
41
+ selection: "explicit" | "all";
42
+ source: ReportSource;
43
+ /** Live modes only: the task's page in the product (also in `source.url`). */
44
+ taskUrl?: string;
45
+ generatedAt: string;
46
+ checks: Record<string, CheckResult>;
47
+ prechecks: Record<string, PrecheckResult>;
48
+ };
49
+ export declare function modeLabel(mode: ReportMode): string;
50
+ /** The precheck stage status, as the product spells it, mapped to a verdict. */
51
+ export declare function precheckVerdict(status: string): Verdict;
52
+ /** A one-line human detail for a check row. */
53
+ export declare function checkDetail(check: CheckResult): string;
54
+ export declare function renderTable(report: Report, opts?: {
55
+ color?: boolean;
56
+ }): string;
57
+ /**
58
+ * 0 when every run check passed or warned; 1 when any failed or errored (or
59
+ * an explicitly requested check was skipped); 3 when any check was cancelled.
60
+ */
61
+ export declare function exitCodeFor(report: Report): number;
62
+ export declare function writeReport(report: Report, path: string): Promise<string>;