@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/README.md +133 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +75 -0
- package/dist/client.d.ts +51 -0
- package/dist/client.js +70 -0
- package/dist/commands/auth.d.ts +22 -0
- package/dist/commands/auth.js +109 -0
- package/dist/commands/checks.d.ts +12 -0
- package/dist/commands/checks.js +669 -0
- package/dist/contract.d.ts +129 -0
- package/dist/contract.js +24 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +49 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.js +93 -0
- package/dist/harbor.d.ts +25 -0
- package/dist/harbor.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +95 -0
- package/dist/plan.d.ts +65 -0
- package/dist/plan.js +367 -0
- package/dist/quests.d.ts +24 -0
- package/dist/quests.js +181 -0
- package/dist/report.d.ts +62 -0
- package/dist/report.js +107 -0
- package/package.json +43 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type BundlePart = "metadata.repo" | "metadata.commit" | "instruction" | "dockerfile" | "testPatch" | "solutionPatch";
|
|
2
|
+
export type HarborMetadata = {
|
|
3
|
+
problemTitle?: string;
|
|
4
|
+
category?: string;
|
|
5
|
+
language?: string;
|
|
6
|
+
difficulty?: string;
|
|
7
|
+
repositoryUrl?: string;
|
|
8
|
+
baseCommitHash?: string;
|
|
9
|
+
dockerImage?: string;
|
|
10
|
+
verifierTimeoutSec?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare const HARBOR_FILES: readonly [{
|
|
13
|
+
readonly key: "taskToml";
|
|
14
|
+
readonly path: "task.toml";
|
|
15
|
+
}, {
|
|
16
|
+
readonly key: "instructionMd";
|
|
17
|
+
readonly path: "instruction.md";
|
|
18
|
+
}, {
|
|
19
|
+
readonly key: "dockerfile";
|
|
20
|
+
readonly path: "environment/Dockerfile";
|
|
21
|
+
}, {
|
|
22
|
+
readonly key: "testPatch";
|
|
23
|
+
readonly path: "tests/test.patch";
|
|
24
|
+
}, {
|
|
25
|
+
readonly key: "testSh";
|
|
26
|
+
readonly path: "tests/test.sh";
|
|
27
|
+
}, {
|
|
28
|
+
readonly key: "solutionPatch";
|
|
29
|
+
readonly path: "solution/solution.patch";
|
|
30
|
+
}, {
|
|
31
|
+
readonly key: "solveSh";
|
|
32
|
+
readonly path: "solution/solve.sh";
|
|
33
|
+
}];
|
|
34
|
+
export type HarborFileKey = (typeof HARBOR_FILES)[number]["key"];
|
|
35
|
+
export type HarborFilePath = (typeof HARBOR_FILES)[number]["path"];
|
|
36
|
+
export declare const HARBOR_FILE_PATHS: readonly HarborFilePath[];
|
|
37
|
+
export type HarborBundle = {
|
|
38
|
+
dir: string;
|
|
39
|
+
metadata: HarborMetadata;
|
|
40
|
+
files: Partial<Record<HarborFileKey, string>>;
|
|
41
|
+
};
|
|
42
|
+
export type Step = {
|
|
43
|
+
id: string;
|
|
44
|
+
after: string[];
|
|
45
|
+
};
|
|
46
|
+
export type CheckDef = {
|
|
47
|
+
key: string;
|
|
48
|
+
label: string;
|
|
49
|
+
kind: "precheck" | "dynamic";
|
|
50
|
+
requires: BundlePart[];
|
|
51
|
+
steps: Step[];
|
|
52
|
+
notes?: string;
|
|
53
|
+
};
|
|
54
|
+
export type Catalog = {
|
|
55
|
+
quest: string;
|
|
56
|
+
checks: CheckDef[];
|
|
57
|
+
};
|
|
58
|
+
export type AdaptResult = {
|
|
59
|
+
fields: Record<string, unknown>;
|
|
60
|
+
draft: Record<string, unknown>;
|
|
61
|
+
checks: Record<string, {
|
|
62
|
+
eligible: boolean;
|
|
63
|
+
missing: BundlePart[];
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
66
|
+
export type Handle = {
|
|
67
|
+
quest: string;
|
|
68
|
+
checkKey: string;
|
|
69
|
+
stepId: string;
|
|
70
|
+
jobType: string;
|
|
71
|
+
modalFunctionCallId: string;
|
|
72
|
+
artifactPath: string;
|
|
73
|
+
startedAt: number;
|
|
74
|
+
};
|
|
75
|
+
export type StartStepResult = {
|
|
76
|
+
kind: "spawned";
|
|
77
|
+
handle: Handle;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "immediate";
|
|
80
|
+
output: Record<string, unknown>;
|
|
81
|
+
};
|
|
82
|
+
export type Verdict = "pass" | "fail" | "warning" | "error" | "pending" | "skipped" | string;
|
|
83
|
+
export type StepStatus = {
|
|
84
|
+
status: "running";
|
|
85
|
+
} | {
|
|
86
|
+
status: "completed";
|
|
87
|
+
output: Record<string, unknown>;
|
|
88
|
+
verdict: Verdict;
|
|
89
|
+
} | {
|
|
90
|
+
status: "failed";
|
|
91
|
+
error: string;
|
|
92
|
+
output?: Record<string, unknown>;
|
|
93
|
+
verdict: Verdict;
|
|
94
|
+
};
|
|
95
|
+
export type Options = {
|
|
96
|
+
agentModel?: string;
|
|
97
|
+
reasoningEffort?: "low" | "medium" | "high" | "xhigh";
|
|
98
|
+
};
|
|
99
|
+
export type TaskRef = {
|
|
100
|
+
problemId: string;
|
|
101
|
+
versionId: string;
|
|
102
|
+
title: string;
|
|
103
|
+
status: string;
|
|
104
|
+
url: string;
|
|
105
|
+
};
|
|
106
|
+
export type LiveStatus = {
|
|
107
|
+
status: "running" | "completed" | "failed";
|
|
108
|
+
error?: string;
|
|
109
|
+
output: Record<string, unknown> | null;
|
|
110
|
+
verdict: Verdict;
|
|
111
|
+
jobIds: Record<string, string>;
|
|
112
|
+
nudgeSynthesis: boolean;
|
|
113
|
+
};
|
|
114
|
+
export type PrecheckResult = {
|
|
115
|
+
id: string;
|
|
116
|
+
name: string;
|
|
117
|
+
status: string;
|
|
118
|
+
missingFields?: string[];
|
|
119
|
+
checks: Array<{
|
|
120
|
+
id: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
status: string;
|
|
123
|
+
message?: string;
|
|
124
|
+
details?: unknown;
|
|
125
|
+
}>;
|
|
126
|
+
};
|
|
127
|
+
export declare const PRECHECK_PREFIX = "precheck:";
|
|
128
|
+
export declare function isPrecheckKey(key: string): boolean;
|
|
129
|
+
export declare function precheckStageId(key: string): string;
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared shapes of the quest contract. Every quest that supports the CLI
|
|
2
|
+
// exposes `adhocChecks:*` Convex functions with these argument and return
|
|
3
|
+
// types; the quest mirrors them with Convex validators. The CLI never imports
|
|
4
|
+
// quest source, so this file is the only place the two sides meet.
|
|
5
|
+
// The seven files read from a Harbor task directory. `key` is the wire
|
|
6
|
+
// field (Convex object validators only accept identifier keys); `path` is
|
|
7
|
+
// the on-disk location.
|
|
8
|
+
export const HARBOR_FILES = [
|
|
9
|
+
{ key: "taskToml", path: "task.toml" },
|
|
10
|
+
{ key: "instructionMd", path: "instruction.md" },
|
|
11
|
+
{ key: "dockerfile", path: "environment/Dockerfile" },
|
|
12
|
+
{ key: "testPatch", path: "tests/test.patch" },
|
|
13
|
+
{ key: "testSh", path: "tests/test.sh" },
|
|
14
|
+
{ key: "solutionPatch", path: "solution/solution.patch" },
|
|
15
|
+
{ key: "solveSh", path: "solution/solve.sh" },
|
|
16
|
+
];
|
|
17
|
+
export const HARBOR_FILE_PATHS = HARBOR_FILES.map((f) => f.path);
|
|
18
|
+
export const PRECHECK_PREFIX = "precheck:";
|
|
19
|
+
export function isPrecheckKey(key) {
|
|
20
|
+
return key.startsWith(PRECHECK_PREFIX);
|
|
21
|
+
}
|
|
22
|
+
export function precheckStageId(key) {
|
|
23
|
+
return key.slice(PRECHECK_PREFIX.length);
|
|
24
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const EXIT_OK = 0;
|
|
2
|
+
export declare const EXIT_CHECK_FAILED = 1;
|
|
3
|
+
export declare const EXIT_USAGE = 2;
|
|
4
|
+
export declare const EXIT_CANCELLED = 3;
|
|
5
|
+
export declare class CliError extends Error {
|
|
6
|
+
readonly exitCode: number;
|
|
7
|
+
constructor(message: string, exitCode?: number);
|
|
8
|
+
}
|
|
9
|
+
export declare class UsageError extends CliError {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare class AuthError extends CliError {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
export declare class BundleError extends CliError {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
/** Map any thrown value to the exit code the CLI should use for it. */
|
|
19
|
+
export declare function exitCodeForError(error: unknown): number;
|
|
20
|
+
export declare function errorMessage(error: unknown): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Exit codes: 0 every run check passed or warned; 1 a check failed or errored;
|
|
2
|
+
// 2 usage, auth, or bundle error; 3 cancelled.
|
|
3
|
+
export const EXIT_OK = 0;
|
|
4
|
+
export const EXIT_CHECK_FAILED = 1;
|
|
5
|
+
export const EXIT_USAGE = 2;
|
|
6
|
+
export const EXIT_CANCELLED = 3;
|
|
7
|
+
export class CliError extends Error {
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(message, exitCode = EXIT_USAGE) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "CliError";
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class UsageError extends CliError {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message, EXIT_USAGE);
|
|
18
|
+
this.name = "UsageError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class AuthError extends CliError {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message, EXIT_USAGE);
|
|
24
|
+
this.name = "AuthError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class BundleError extends CliError {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message, EXIT_USAGE);
|
|
30
|
+
this.name = "BundleError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Map any thrown value to the exit code the CLI should use for it. */
|
|
34
|
+
export function exitCodeForError(error) {
|
|
35
|
+
if (error instanceof CliError)
|
|
36
|
+
return error.exitCode;
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
// Quest-side auth failures (Convex `Unauthorized` / `Forbidden: admin role
|
|
39
|
+
// required`) are usage errors from the CLI's point of view.
|
|
40
|
+
if (/\b(unauthorized|unauthenticated|forbidden|not authenticated)\b/i.test(message)) {
|
|
41
|
+
return EXIT_USAGE;
|
|
42
|
+
}
|
|
43
|
+
return EXIT_CHECK_FAILED;
|
|
44
|
+
}
|
|
45
|
+
export function errorMessage(error) {
|
|
46
|
+
if (error instanceof Error)
|
|
47
|
+
return error.message;
|
|
48
|
+
return String(error);
|
|
49
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function stripAnsi(str: string): string;
|
|
2
|
+
export type TableColumn = {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
};
|
|
7
|
+
export type TableRow = Record<string, string | number | boolean | null | undefined>;
|
|
8
|
+
/** Render rows as an aligned text table (two-space indent, same look as the Olympus CLI). */
|
|
9
|
+
export declare function formatTable(rows: TableRow[], columns: TableColumn[]): string[];
|
|
10
|
+
export declare function printTable(rows: TableRow[], columns: TableColumn[]): void;
|
|
11
|
+
export declare function printJson(data: unknown): void;
|
|
12
|
+
export declare function printKeyValue(pairs: [string, string | undefined | null][]): void;
|
|
13
|
+
/** Status badge for terminal output; colors only when asked for. */
|
|
14
|
+
export declare function statusBadge(status: string, color?: boolean): string;
|
|
15
|
+
export declare function dim(text: string, color?: boolean): string;
|
|
16
|
+
/** Collapse to one line and truncate with an ellipsis. */
|
|
17
|
+
export declare function truncate(str: string, max: number): string;
|
|
18
|
+
/** Compact human duration: 850ms, 5s, 2m10s, 3h, 1h05m. */
|
|
19
|
+
export declare function formatDuration(ms: number): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Minimal output formatting helpers. Every command also supports --json.
|
|
2
|
+
const ESC = String.fromCharCode(27);
|
|
3
|
+
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
|
|
4
|
+
export function stripAnsi(str) {
|
|
5
|
+
return str.replace(ANSI_RE, "");
|
|
6
|
+
}
|
|
7
|
+
function padVisible(str, width) {
|
|
8
|
+
const needed = width - stripAnsi(str).length;
|
|
9
|
+
return needed > 0 ? str + " ".repeat(needed) : str;
|
|
10
|
+
}
|
|
11
|
+
/** Render rows as an aligned text table (two-space indent, same look as the Olympus CLI). */
|
|
12
|
+
export function formatTable(rows, columns) {
|
|
13
|
+
if (rows.length === 0)
|
|
14
|
+
return [" (no results)"];
|
|
15
|
+
const widths = columns.map((col) => {
|
|
16
|
+
const maxData = Math.max(...rows.map((r) => stripAnsi(String(r[col.key] ?? "")).length));
|
|
17
|
+
return col.width ?? Math.max(col.label.length, maxData);
|
|
18
|
+
});
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push(` ${columns.map((col, i) => col.label.padEnd(widths[i])).join(" ")}`);
|
|
21
|
+
lines.push(` ${widths.map((w) => "─".repeat(w)).join(" ")}`);
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
lines.push(` ${columns.map((col, i) => padVisible(String(row[col.key] ?? ""), widths[i])).join(" ")}`);
|
|
24
|
+
}
|
|
25
|
+
return lines.map((line) => line.trimEnd());
|
|
26
|
+
}
|
|
27
|
+
export function printTable(rows, columns) {
|
|
28
|
+
for (const line of formatTable(rows, columns))
|
|
29
|
+
console.log(line);
|
|
30
|
+
}
|
|
31
|
+
export function printJson(data) {
|
|
32
|
+
console.log(JSON.stringify(data, null, 2));
|
|
33
|
+
}
|
|
34
|
+
export function printKeyValue(pairs) {
|
|
35
|
+
const maxKey = Math.max(...pairs.map(([k]) => k.length));
|
|
36
|
+
for (const [key, value] of pairs) {
|
|
37
|
+
if (value !== undefined && value !== null) {
|
|
38
|
+
console.log(` ${key.padEnd(maxKey)} ${value}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const COLORS = {
|
|
43
|
+
green: "\x1b[32m",
|
|
44
|
+
red: "\x1b[31m",
|
|
45
|
+
yellow: "\x1b[33m",
|
|
46
|
+
gray: "\x1b[90m",
|
|
47
|
+
blue: "\x1b[34m",
|
|
48
|
+
};
|
|
49
|
+
const RESET = "\x1b[0m";
|
|
50
|
+
const BADGES = {
|
|
51
|
+
completed: { icon: "✓", color: "green" },
|
|
52
|
+
pass: { icon: "✓", color: "green" },
|
|
53
|
+
running: { icon: "⟳", color: "yellow" },
|
|
54
|
+
pending: { icon: "○", color: "gray" },
|
|
55
|
+
failed: { icon: "✗", color: "red" },
|
|
56
|
+
fail: { icon: "✗", color: "red" },
|
|
57
|
+
error: { icon: "✗", color: "red" },
|
|
58
|
+
warning: { icon: "!", color: "yellow" },
|
|
59
|
+
skipped: { icon: "-", color: "gray" },
|
|
60
|
+
cancelled: { icon: "-", color: "gray" },
|
|
61
|
+
missing_fields: { icon: "○", color: "yellow" },
|
|
62
|
+
};
|
|
63
|
+
/** Status badge for terminal output; colors only when asked for. */
|
|
64
|
+
export function statusBadge(status, color = false) {
|
|
65
|
+
const badge = BADGES[status] ?? { icon: "?", color: "gray" };
|
|
66
|
+
const icon = color ? `${COLORS[badge.color]}${badge.icon}${RESET}` : badge.icon;
|
|
67
|
+
return `${icon} ${status}`;
|
|
68
|
+
}
|
|
69
|
+
export function dim(text, color = false) {
|
|
70
|
+
return color ? `${COLORS.gray}${text}${RESET}` : text;
|
|
71
|
+
}
|
|
72
|
+
/** Collapse to one line and truncate with an ellipsis. */
|
|
73
|
+
export function truncate(str, max) {
|
|
74
|
+
const oneLine = str.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
|
75
|
+
if (oneLine.length <= max)
|
|
76
|
+
return oneLine;
|
|
77
|
+
return `${oneLine.slice(0, max - 3)}...`;
|
|
78
|
+
}
|
|
79
|
+
/** Compact human duration: 850ms, 5s, 2m10s, 3h, 1h05m. */
|
|
80
|
+
export function formatDuration(ms) {
|
|
81
|
+
if (ms < 1000)
|
|
82
|
+
return `${Math.round(ms)}ms`;
|
|
83
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
84
|
+
if (totalSeconds < 60)
|
|
85
|
+
return `${totalSeconds}s`;
|
|
86
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
87
|
+
const seconds = totalSeconds % 60;
|
|
88
|
+
if (minutes < 60)
|
|
89
|
+
return seconds ? `${minutes}m${String(seconds).padStart(2, "0")}s` : `${minutes}m`;
|
|
90
|
+
const hours = Math.floor(minutes / 60);
|
|
91
|
+
const rest = minutes % 60;
|
|
92
|
+
return rest ? `${hours}h${String(rest).padStart(2, "0")}m` : `${hours}h`;
|
|
93
|
+
}
|
package/dist/harbor.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type HarborBundle, type HarborMetadata } from "./contract.js";
|
|
2
|
+
/** Total text the CLI will send to a quest. Well under Convex's argument limit. */
|
|
3
|
+
export declare const MAX_BUNDLE_BYTES: number;
|
|
4
|
+
/** Command-line overrides that win over `task.toml`. */
|
|
5
|
+
export type MetadataOverrides = {
|
|
6
|
+
repo?: string;
|
|
7
|
+
commit?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
category?: string;
|
|
10
|
+
language?: string;
|
|
11
|
+
difficulty?: string;
|
|
12
|
+
};
|
|
13
|
+
export type HarborBundleWithHash = HarborBundle & {
|
|
14
|
+
inputHash: string;
|
|
15
|
+
};
|
|
16
|
+
/** Map `task.toml` tables onto `HarborMetadata`; overrides win. */
|
|
17
|
+
export declare function metadataFromToml(toml: Record<string, unknown>, overrides?: MetadataOverrides): HarborMetadata;
|
|
18
|
+
/** JSON with object keys sorted at every level, so equal content hashes equal. */
|
|
19
|
+
export declare function canonicalJson(value: unknown): string;
|
|
20
|
+
export declare function bundleInputHash(bundle: HarborBundle): string;
|
|
21
|
+
/**
|
|
22
|
+
* Read a Harbor task directory. Only the seven known paths are read; anything
|
|
23
|
+
* else in the directory is ignored. `task.toml` must exist and parse.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readHarborBundle(dir: string, overrides?: MetadataOverrides): Promise<HarborBundleWithHash>;
|
package/dist/harbor.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { HARBOR_FILE_PATHS, HARBOR_FILES, } from "./contract.js";
|
|
6
|
+
import { BundleError } from "./errors.js";
|
|
7
|
+
/** Total text the CLI will send to a quest. Well under Convex's argument limit. */
|
|
8
|
+
export const MAX_BUNDLE_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function str(table, key) {
|
|
13
|
+
const value = table?.[key];
|
|
14
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
function num(table, key) {
|
|
17
|
+
const value = table?.[key];
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function nonEmpty(value) {
|
|
21
|
+
return value && value.trim().length > 0 ? value : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Map `task.toml` tables onto `HarborMetadata`; overrides win. */
|
|
24
|
+
export function metadataFromToml(toml, overrides = {}) {
|
|
25
|
+
const metadata = isRecord(toml.metadata) ? toml.metadata : undefined;
|
|
26
|
+
const environment = isRecord(toml.environment) ? toml.environment : undefined;
|
|
27
|
+
const verifier = isRecord(toml.verifier) ? toml.verifier : undefined;
|
|
28
|
+
const out = {
|
|
29
|
+
problemTitle: nonEmpty(overrides.title) ?? str(metadata, "problem_title"),
|
|
30
|
+
category: nonEmpty(overrides.category) ?? str(metadata, "category"),
|
|
31
|
+
language: nonEmpty(overrides.language) ?? str(metadata, "language"),
|
|
32
|
+
difficulty: nonEmpty(overrides.difficulty) ?? str(metadata, "difficulty"),
|
|
33
|
+
repositoryUrl: nonEmpty(overrides.repo) ?? str(metadata, "repository_url"),
|
|
34
|
+
baseCommitHash: nonEmpty(overrides.commit) ?? str(metadata, "base_commit_hash"),
|
|
35
|
+
dockerImage: str(environment, "docker_image"),
|
|
36
|
+
verifierTimeoutSec: num(verifier, "timeout_sec"),
|
|
37
|
+
};
|
|
38
|
+
// Drop undefined keys so the wire payload and the hash only carry set fields.
|
|
39
|
+
for (const key of Object.keys(out)) {
|
|
40
|
+
if (out[key] === undefined)
|
|
41
|
+
delete out[key];
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** JSON with object keys sorted at every level, so equal content hashes equal. */
|
|
46
|
+
export function canonicalJson(value) {
|
|
47
|
+
return JSON.stringify(sortKeys(value));
|
|
48
|
+
}
|
|
49
|
+
function sortKeys(value) {
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return value.map(sortKeys);
|
|
52
|
+
if (isRecord(value)) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of Object.keys(value).sort()) {
|
|
55
|
+
const v = value[key];
|
|
56
|
+
if (v !== undefined)
|
|
57
|
+
out[key] = sortKeys(v);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export function bundleInputHash(bundle) {
|
|
64
|
+
const digest = createHash("sha256")
|
|
65
|
+
.update(canonicalJson({ metadata: bundle.metadata, files: bundle.files }))
|
|
66
|
+
.digest("hex");
|
|
67
|
+
return `sha256:${digest}`;
|
|
68
|
+
}
|
|
69
|
+
function formatMiB(bytes) {
|
|
70
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
|
71
|
+
}
|
|
72
|
+
async function fileSize(path) {
|
|
73
|
+
try {
|
|
74
|
+
// lstat, not stat: a bundle from an untrusted source could point a
|
|
75
|
+
// whitelisted name at any readable file via a symlink and have its
|
|
76
|
+
// contents sent to the quest.
|
|
77
|
+
const info = await lstat(path);
|
|
78
|
+
if (info.isSymbolicLink()) {
|
|
79
|
+
throw new BundleError(`${path} is a symlink; symlinked bundle files are not read.`);
|
|
80
|
+
}
|
|
81
|
+
return info.isFile() ? info.size : null;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof BundleError)
|
|
85
|
+
throw error;
|
|
86
|
+
if (error.code === "ENOENT")
|
|
87
|
+
return null;
|
|
88
|
+
throw new BundleError(`Could not read ${path}: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read a Harbor task directory. Only the seven known paths are read; anything
|
|
93
|
+
* else in the directory is ignored. `task.toml` must exist and parse.
|
|
94
|
+
*/
|
|
95
|
+
export async function readHarborBundle(dir, overrides = {}) {
|
|
96
|
+
const root = resolve(dir);
|
|
97
|
+
try {
|
|
98
|
+
const info = await stat(root);
|
|
99
|
+
if (!info.isDirectory())
|
|
100
|
+
throw new BundleError(`${root} is not a directory.`);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof BundleError)
|
|
104
|
+
throw error;
|
|
105
|
+
throw new BundleError(`Harbor directory not found: ${root}`);
|
|
106
|
+
}
|
|
107
|
+
const present = [];
|
|
108
|
+
for (const { key, path } of HARBOR_FILES) {
|
|
109
|
+
const size = await fileSize(resolve(root, path));
|
|
110
|
+
if (size !== null)
|
|
111
|
+
present.push({ key, path, size });
|
|
112
|
+
}
|
|
113
|
+
if (!present.some((f) => f.key === "taskToml")) {
|
|
114
|
+
throw new BundleError(`task.toml not found in ${root}. Is this a Harbor task directory?`);
|
|
115
|
+
}
|
|
116
|
+
const totalBytes = present.reduce((sum, f) => sum + f.size, 0);
|
|
117
|
+
if (totalBytes > MAX_BUNDLE_BYTES) {
|
|
118
|
+
throw new BundleError(`Harbor bundle text is ${formatMiB(totalBytes)}; the cap is ${formatMiB(MAX_BUNDLE_BYTES)}. ` +
|
|
119
|
+
`Only ${HARBOR_FILE_PATHS.join(", ")} are read.`);
|
|
120
|
+
}
|
|
121
|
+
const files = {};
|
|
122
|
+
for (const { key, path } of present) {
|
|
123
|
+
files[key] = await readFile(resolve(root, path), "utf-8");
|
|
124
|
+
}
|
|
125
|
+
let toml;
|
|
126
|
+
try {
|
|
127
|
+
toml = parseToml(files.taskToml ?? "");
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new BundleError(`task.toml could not be parsed: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(toml)) {
|
|
133
|
+
throw new BundleError("task.toml could not be parsed: expected a table at the top level.");
|
|
134
|
+
}
|
|
135
|
+
const bundle = { dir: root, metadata: metadataFromToml(toml, overrides), files };
|
|
136
|
+
return { ...bundle, inputHash: bundleInputHash(bundle) };
|
|
137
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { defineCommand, runCommand, showUsage } from "citty";
|
|
4
|
+
import { login, logout, whoami } from "./commands/auth.js";
|
|
5
|
+
import checks from "./commands/checks.js";
|
|
6
|
+
import { EXIT_USAGE, errorMessage, exitCodeForError } from "./errors.js";
|
|
7
|
+
import { checkVersion, PACKAGE_NAME, VERSION } from "./quests.js";
|
|
8
|
+
const update = defineCommand({
|
|
9
|
+
meta: { name: "update", description: "Update the CLI to the latest published version" },
|
|
10
|
+
args: {
|
|
11
|
+
version: { type: "string", description: "Target version (default: latest)" },
|
|
12
|
+
},
|
|
13
|
+
run: async ({ args }) => {
|
|
14
|
+
const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
|
|
15
|
+
console.log(`\n Updating to ${spec}...`);
|
|
16
|
+
try {
|
|
17
|
+
execFileSync("npm", ["install", "-g", spec], { stdio: "inherit" });
|
|
18
|
+
console.log("\n Updated successfully.");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
console.error(`\n Update failed. Try manually: npm install -g ${spec}`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const version = defineCommand({
|
|
27
|
+
meta: { name: "version", description: "Print the CLI version" },
|
|
28
|
+
run: () => {
|
|
29
|
+
console.log(VERSION);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const main = defineCommand({
|
|
33
|
+
meta: {
|
|
34
|
+
name: "shipd",
|
|
35
|
+
version: VERSION,
|
|
36
|
+
description: "Shipd admin CLI: run quest checks on Harbor tasks or existing tasks",
|
|
37
|
+
},
|
|
38
|
+
subCommands: { login, logout, whoami, checks, update, version },
|
|
39
|
+
});
|
|
40
|
+
async function run() {
|
|
41
|
+
const rawArgs = process.argv.slice(2);
|
|
42
|
+
if (rawArgs.length === 0 || rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
43
|
+
await showUsageFor(rawArgs);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (rawArgs.length === 1 && (rawArgs[0] === "--version" || rawArgs[0] === "-v")) {
|
|
47
|
+
console.log(VERSION);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Warn about an outdated CLI without blocking the command.
|
|
51
|
+
void checkVersion(questFromArgs(rawArgs)).catch(() => { });
|
|
52
|
+
try {
|
|
53
|
+
await runCommand(main, { rawArgs });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const name = error instanceof Error ? error.name : "";
|
|
57
|
+
if (name === "CLIError") {
|
|
58
|
+
// citty's own usage errors (unknown command, missing argument).
|
|
59
|
+
await showUsageFor(rawArgs);
|
|
60
|
+
console.error(`\n Error: ${errorMessage(error)}\n`);
|
|
61
|
+
process.exitCode = EXIT_USAGE;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.error(`\n Error: ${errorMessage(error)}\n`);
|
|
65
|
+
process.exitCode = exitCodeForError(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function questFromArgs(rawArgs) {
|
|
69
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
70
|
+
const arg = rawArgs[i];
|
|
71
|
+
if (arg === "--quest")
|
|
72
|
+
return rawArgs[i + 1];
|
|
73
|
+
if (arg.startsWith("--quest="))
|
|
74
|
+
return arg.slice("--quest=".length);
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
async function showUsageFor(rawArgs) {
|
|
79
|
+
// Resolve the deepest sub-command named on the command line so `shipd checks run --help`
|
|
80
|
+
// prints the right usage.
|
|
81
|
+
let cmd = main;
|
|
82
|
+
let parent;
|
|
83
|
+
for (const arg of rawArgs) {
|
|
84
|
+
if (arg.startsWith("-"))
|
|
85
|
+
break;
|
|
86
|
+
const subs = (cmd.subCommands ?? {});
|
|
87
|
+
const next = subs[arg];
|
|
88
|
+
if (!next)
|
|
89
|
+
break;
|
|
90
|
+
parent = cmd;
|
|
91
|
+
cmd = next;
|
|
92
|
+
}
|
|
93
|
+
await showUsage(cmd, parent);
|
|
94
|
+
}
|
|
95
|
+
void run();
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { QuestApi } from "./client.js";
|
|
2
|
+
import { type CheckDef, type Handle, type HarborBundle, type Options, type TaskRef, type Verdict } from "./contract.js";
|
|
3
|
+
import { type CheckResult, type StepRecord } from "./report.js";
|
|
4
|
+
export declare const DEFAULT_POLL_MS = 15000;
|
|
5
|
+
/** The longest worker timeout. */
|
|
6
|
+
export declare const DEFAULT_MAX_STEP_MS: number;
|
|
7
|
+
export type PlanEvent = {
|
|
8
|
+
type: "check:start";
|
|
9
|
+
checkKey: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "step:start";
|
|
12
|
+
checkKey: string;
|
|
13
|
+
stepId: string;
|
|
14
|
+
handle?: Handle;
|
|
15
|
+
immediate: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
type: "step:end";
|
|
18
|
+
checkKey: string;
|
|
19
|
+
stepId: string;
|
|
20
|
+
status: StepRecord["status"];
|
|
21
|
+
verdict?: Verdict;
|
|
22
|
+
error?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "live:started";
|
|
26
|
+
checkKey: string;
|
|
27
|
+
started: boolean;
|
|
28
|
+
detail?: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "live:nudge";
|
|
31
|
+
checkKey: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "check:end";
|
|
34
|
+
checkKey: string;
|
|
35
|
+
result: CheckResult;
|
|
36
|
+
};
|
|
37
|
+
export type RunControls = {
|
|
38
|
+
pollMs: number;
|
|
39
|
+
maxStepMs: number;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
onEvent?: (event: PlanEvent) => void;
|
|
42
|
+
/**
|
|
43
|
+
* What an abort does to in-flight Modal steps. `"cancel"` (default)
|
|
44
|
+
* cancels them; `"leave"` keeps them running and only stops polling, so
|
|
45
|
+
* their handles survive in the report for `checks status` / `checks
|
|
46
|
+
* cancel` later. The interrupt handler flips this to `"leave"` when the
|
|
47
|
+
* user declines the cancel prompt.
|
|
48
|
+
*/
|
|
49
|
+
onAbort?: () => "cancel" | "leave";
|
|
50
|
+
};
|
|
51
|
+
export type RunTarget = {
|
|
52
|
+
kind: "harbor";
|
|
53
|
+
bundle: HarborBundle;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "live";
|
|
56
|
+
task: TaskRef;
|
|
57
|
+
};
|
|
58
|
+
/** The step nothing depends on. With several, the last declared wins. */
|
|
59
|
+
export declare function sinkStepId(check: CheckDef): string;
|
|
60
|
+
/** Run one check against a Harbor bundle without writing anything to the quest. */
|
|
61
|
+
export declare function executeStatelessCheck(api: QuestApi, bundle: HarborBundle, check: CheckDef, options: Options, controls: RunControls): Promise<CheckResult>;
|
|
62
|
+
/** Run one check live on an existing task; the quest writes its normal rows. */
|
|
63
|
+
export declare function executeLiveCheck(api: QuestApi, task: TaskRef, check: CheckDef, options: Options, controls: RunControls): Promise<CheckResult>;
|
|
64
|
+
/** Run the selected checks concurrently and return one result per check. */
|
|
65
|
+
export declare function runChecks(api: QuestApi, target: RunTarget, checks: CheckDef[], options: Options, controls: RunControls): Promise<CheckResult[]>;
|