@valescoagency/runway 0.4.0 → 0.6.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 +87 -6
- package/dist/commands/doctor.js +67 -631
- package/dist/commands/init.js +2 -311
- package/dist/commands/run.js +39 -12
- package/dist/commands/upgrade-repo.js +2 -251
- package/dist/config.js +53 -67
- package/dist/diagnostics/base-branch.js +45 -0
- package/dist/diagnostics/claude-auth-mode.js +51 -0
- package/dist/diagnostics/docker-image.js +119 -0
- package/dist/diagnostics/docker.js +40 -0
- package/dist/diagnostics/gh.js +51 -0
- package/dist/diagnostics/git-state.js +73 -0
- package/dist/diagnostics/git.js +9 -0
- package/dist/diagnostics/index.js +180 -0
- package/dist/diagnostics/linear-api-key.js +14 -0
- package/dist/diagnostics/linear-config.js +133 -0
- package/dist/diagnostics/linear-scope.js +25 -0
- package/dist/diagnostics/node.js +18 -0
- package/dist/diagnostics/op-token.js +14 -0
- package/dist/diagnostics/op.js +9 -0
- package/dist/diagnostics/policy.js +35 -0
- package/dist/diagnostics/render.js +69 -0
- package/dist/diagnostics/runway-scaffold.js +31 -0
- package/dist/diagnostics/varlock.js +9 -0
- package/dist/finalize.js +35 -0
- package/dist/git.js +121 -29
- package/dist/github.js +136 -21
- package/dist/hitl.js +104 -0
- package/dist/implement.js +149 -0
- package/dist/linear.js +257 -65
- package/dist/orchestrator.js +111 -364
- package/dist/policy.js +0 -11
- package/dist/prompts.js +54 -55
- package/dist/repo-upgrade.js +268 -0
- package/dist/review.js +82 -0
- package/dist/sandcastle.js +60 -0
- package/dist/scaffolder-dockerfile.js +27 -0
- package/dist/scaffolder-image.js +26 -0
- package/dist/scaffolder-preflight.js +66 -0
- package/dist/scaffolder-varlock.js +67 -0
- package/dist/scaffolder-verify.js +102 -0
- package/dist/scaffolder.js +54 -0
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +9 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { DIAGNOSTICS, runDiagnostics, } from "../diagnostics/index.js";
|
|
4
|
+
import { renderJson, renderText } from "../diagnostics/render.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Required sections — failures in any of these set the exit code to 1.
|
|
7
|
+
// "Repo state" is informational only (dirty tree, no scaffold) so its
|
|
8
|
+
// failures don't affect the exit code.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const REQUIRED_SECTIONS = new Set([
|
|
11
|
+
"Host tooling",
|
|
12
|
+
"Environment",
|
|
13
|
+
"Docker image",
|
|
14
|
+
"Linear configuration",
|
|
15
|
+
]);
|
|
8
16
|
// ---------------------------------------------------------------------------
|
|
9
17
|
// Usage
|
|
10
18
|
// ---------------------------------------------------------------------------
|
|
@@ -17,7 +25,7 @@ and you want a sanity report.
|
|
|
17
25
|
|
|
18
26
|
USAGE
|
|
19
27
|
cd /path/to/your/repo # (or runway's own clone)
|
|
20
|
-
runway doctor [--tier=1|2] [--detailed] [--json]
|
|
28
|
+
runway doctor [--tier=1|2] [--detailed] [--json] [--check=NAME ...]
|
|
21
29
|
|
|
22
30
|
OPTIONS
|
|
23
31
|
--tier=N Force tier 1 or 2 checks. Default: auto-detect from
|
|
@@ -25,6 +33,8 @@ OPTIONS
|
|
|
25
33
|
--detailed Print version numbers, paths, and full error output.
|
|
26
34
|
Default mode is terse (just ✓/✗/⚠).
|
|
27
35
|
--json Machine-readable output for CI / scripted health checks.
|
|
36
|
+
--check=NAME Run only diagnostics with the given name (repeatable).
|
|
37
|
+
Use --check=help to list available names.
|
|
28
38
|
--help, -h Show this help.
|
|
29
39
|
|
|
30
40
|
SECTIONS REPORTED
|
|
@@ -34,10 +44,11 @@ SECTIONS REPORTED
|
|
|
34
44
|
tier 2 also checks OP_SERVICE_ACCOUNT_TOKEN.
|
|
35
45
|
3. Repo state git repo? clean tree? branch? scaffold detected?
|
|
36
46
|
4. Docker image sandcastle:<sanitized-cwd> exists; user/group match.
|
|
47
|
+
5. Linear config team, workflow states, HITL label on the team.
|
|
37
48
|
|
|
38
49
|
EXIT CODES
|
|
39
50
|
0 All required checks pass (warnings allowed).
|
|
40
|
-
1 At least one ✗ in tooling, environment, or
|
|
51
|
+
1 At least one ✗ in tooling, environment, image, or Linear checks.
|
|
41
52
|
Repo-state issues (no init, dirty tree) are warnings only.
|
|
42
53
|
`);
|
|
43
54
|
}
|
|
@@ -48,6 +59,7 @@ function parseDoctorArgs(argv) {
|
|
|
48
59
|
let tierOverride;
|
|
49
60
|
let detailed = false;
|
|
50
61
|
let json = false;
|
|
62
|
+
const checks = [];
|
|
51
63
|
for (const arg of argv) {
|
|
52
64
|
if (arg === "--help" || arg === "-h") {
|
|
53
65
|
printDoctorUsage();
|
|
@@ -65,61 +77,76 @@ function parseDoctorArgs(argv) {
|
|
|
65
77
|
else if (arg === "--json") {
|
|
66
78
|
json = true;
|
|
67
79
|
}
|
|
80
|
+
else if (arg.startsWith("--check=")) {
|
|
81
|
+
const name = arg.slice("--check=".length).trim();
|
|
82
|
+
if (name.length === 0) {
|
|
83
|
+
throw new Error("--check requires a name (e.g. --check=node)");
|
|
84
|
+
}
|
|
85
|
+
checks.push(name);
|
|
86
|
+
}
|
|
68
87
|
else {
|
|
69
88
|
throw new Error(`unknown argument: ${arg}`);
|
|
70
89
|
}
|
|
71
90
|
}
|
|
72
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
tierOverride,
|
|
93
|
+
detailed,
|
|
94
|
+
json,
|
|
95
|
+
checks: checks.length > 0 ? checks : undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate that every `--check=NAME` matches a registered diagnostic.
|
|
100
|
+
* Unknown names fail fast with the available list so the user sees
|
|
101
|
+
* what to type instead.
|
|
102
|
+
*/
|
|
103
|
+
function validateCheckNames(checks) {
|
|
104
|
+
if (!checks?.length)
|
|
105
|
+
return;
|
|
106
|
+
if (checks.includes("help")) {
|
|
107
|
+
const available = DIAGNOSTICS.map((d) => ` ${d.name} (${d.section})`).join("\n");
|
|
108
|
+
console.log(`Available --check names:\n${available}`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
const known = new Set(DIAGNOSTICS.map((d) => d.name));
|
|
112
|
+
const unknown = checks.filter((n) => !known.has(n));
|
|
113
|
+
if (unknown.length > 0) {
|
|
114
|
+
throw new Error(`unknown --check name(s): ${unknown.join(", ")}. ` +
|
|
115
|
+
`Available: ${[...known].sort().join(", ")} (or --check=help for a grouped list)`);
|
|
116
|
+
}
|
|
73
117
|
}
|
|
74
118
|
// ---------------------------------------------------------------------------
|
|
75
119
|
// Entry point
|
|
76
120
|
// ---------------------------------------------------------------------------
|
|
77
121
|
export async function doctorCommand(argv) {
|
|
78
122
|
const opts = parseDoctorArgs(argv);
|
|
123
|
+
validateCheckNames(opts.checks);
|
|
79
124
|
const cwd = process.cwd();
|
|
80
125
|
const repo = detectRepoState(cwd);
|
|
81
126
|
const tier = opts.tierOverride ?? repo.tier;
|
|
82
|
-
|
|
83
|
-
// We pick tier 2 as the "default check posture" for tooling so varlock/op
|
|
84
|
-
// are reported (Valesco's standard), unless the user explicitly says tier 1.
|
|
85
|
-
const tierForToolingChecks = tier ?? 2;
|
|
127
|
+
const tierForChecks = tier ?? 2;
|
|
86
128
|
const initialised = tier !== null;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
sections.push(skippedSection("Environment"));
|
|
98
|
-
sections.push(skippedSection("Repo state"));
|
|
99
|
-
sections.push(skippedSection("Docker image"));
|
|
100
|
-
sections.push(skippedSection("Linear configuration"));
|
|
101
|
-
}
|
|
102
|
-
// Render
|
|
129
|
+
const ctx = {
|
|
130
|
+
cwd,
|
|
131
|
+
tier: tierForChecks,
|
|
132
|
+
repo,
|
|
133
|
+
options: opts,
|
|
134
|
+
};
|
|
135
|
+
const sections = await runDiagnostics(ctx);
|
|
136
|
+
const failed = sections.some((s) => s.ran &&
|
|
137
|
+
REQUIRED_SECTIONS.has(s.title) &&
|
|
138
|
+
[...s.checks.values()].some((c) => c.status === "fail"));
|
|
103
139
|
if (opts.json) {
|
|
104
|
-
renderJson(sections, tier,
|
|
140
|
+
renderJson(sections, tier, !failed);
|
|
105
141
|
}
|
|
106
142
|
else {
|
|
107
143
|
renderText(sections, tier, initialised, opts.detailed);
|
|
108
144
|
}
|
|
109
|
-
// Exit code: required-check failures = 1.
|
|
110
|
-
// Required: 0 host tooling, 1 environment, 3 docker image, 4 Linear
|
|
111
|
-
// config. Section 2 (repo state) is informational.
|
|
112
|
-
const requiredSections = [
|
|
113
|
-
sections[0],
|
|
114
|
-
sections[1],
|
|
115
|
-
sections[3],
|
|
116
|
-
sections[4],
|
|
117
|
-
];
|
|
118
|
-
const failed = requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
|
|
119
145
|
process.exit(failed ? 1 : 0);
|
|
120
146
|
}
|
|
121
147
|
// ---------------------------------------------------------------------------
|
|
122
|
-
// Repo / tier detection
|
|
148
|
+
// Repo / tier detection — produces the DoctorContext, not itself a
|
|
149
|
+
// diagnostic.
|
|
123
150
|
// ---------------------------------------------------------------------------
|
|
124
151
|
function detectRepoState(cwd) {
|
|
125
152
|
const hasDockerfile = existsSync(join(cwd, ".sandcastle", "Dockerfile"));
|
|
@@ -157,594 +184,3 @@ function detectRepoState(cwd) {
|
|
|
157
184
|
}
|
|
158
185
|
return { tier, hasDockerfile, hasSchema, authMode, hasConflictingAuthVars };
|
|
159
186
|
}
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
// Section: Host tooling
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
async function checkHostTooling(tier) {
|
|
164
|
-
const checks = new Map();
|
|
165
|
-
checks.set("git", await checkBinaryVersion("git", ["--version"]));
|
|
166
|
-
const nodeVersion = process.versions.node;
|
|
167
|
-
const nodeMajor = Number.parseInt(nodeVersion.split(".")[0] ?? "0", 10);
|
|
168
|
-
checks.set("node", nodeMajor >= 22
|
|
169
|
-
? { status: "ok", label: "node", detail: `v${nodeVersion}` }
|
|
170
|
-
: {
|
|
171
|
-
status: "fail",
|
|
172
|
-
label: "node",
|
|
173
|
-
detail: `v${nodeVersion} — node ≥ 22 required`,
|
|
174
|
-
});
|
|
175
|
-
checks.set("docker", await checkDockerDaemon());
|
|
176
|
-
checks.set("gh", await checkGhAuth());
|
|
177
|
-
if (tier === 2) {
|
|
178
|
-
checks.set("varlock", await checkBinaryVersion("varlock", ["--version"]));
|
|
179
|
-
checks.set("op", await checkBinaryVersion("op", ["--version"]));
|
|
180
|
-
}
|
|
181
|
-
return { title: "Host tooling", checks, ran: true };
|
|
182
|
-
}
|
|
183
|
-
async function checkBinaryVersion(bin, args) {
|
|
184
|
-
try {
|
|
185
|
-
const { stdout, stderr } = await execa(bin, args, { reject: false });
|
|
186
|
-
const out = (stdout || stderr || "").split("\n")[0]?.trim() ?? "";
|
|
187
|
-
return { status: "ok", label: bin, detail: out || "(installed)" };
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
return {
|
|
191
|
-
status: "fail",
|
|
192
|
-
label: bin,
|
|
193
|
-
detail: `${bin} not on PATH`,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
async function checkDockerDaemon() {
|
|
198
|
-
try {
|
|
199
|
-
await execa("docker", ["--version"], { reject: true });
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
return {
|
|
203
|
-
status: "fail",
|
|
204
|
-
label: "docker",
|
|
205
|
-
detail: "docker not on PATH (Docker Desktop or Podman)",
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
try {
|
|
209
|
-
await execa("docker", ["info"], { reject: true });
|
|
210
|
-
return { status: "ok", label: "docker", detail: "daemon up" };
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
return {
|
|
214
|
-
status: "fail",
|
|
215
|
-
label: "docker",
|
|
216
|
-
detail: `daemon not running — ${errMsg(err)}`,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
async function checkGhAuth() {
|
|
221
|
-
try {
|
|
222
|
-
await execa("gh", ["--version"], { reject: true });
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
return {
|
|
226
|
-
status: "fail",
|
|
227
|
-
label: "gh",
|
|
228
|
-
detail: "gh not on PATH",
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
try {
|
|
232
|
-
const { stdout, stderr } = await execa("gh", ["auth", "status"], {
|
|
233
|
-
reject: true,
|
|
234
|
-
});
|
|
235
|
-
// First non-empty line of `gh auth status` is the host header.
|
|
236
|
-
const summary = (stdout || stderr || "")
|
|
237
|
-
.split("\n")
|
|
238
|
-
.map((s) => s.trim())
|
|
239
|
-
.find((s) => s.length > 0);
|
|
240
|
-
return {
|
|
241
|
-
status: "ok",
|
|
242
|
-
label: "gh",
|
|
243
|
-
detail: summary ?? "authenticated",
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
catch (err) {
|
|
247
|
-
return {
|
|
248
|
-
status: "fail",
|
|
249
|
-
label: "gh",
|
|
250
|
-
detail: `not authenticated — ${errMsg(err)}`,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
// Section: Environment
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
async function checkEnvironment(tier, cwd, repo) {
|
|
258
|
-
const checks = new Map();
|
|
259
|
-
checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
|
|
260
|
-
// Informational: which Linear scope a `runway run` would use.
|
|
261
|
-
const team = process.env.RUNWAY_LINEAR_TEAM?.trim() || "VA";
|
|
262
|
-
const project = process.env.RUNWAY_LINEAR_PROJECT?.trim();
|
|
263
|
-
checks.set("linear_scope", {
|
|
264
|
-
status: "ok",
|
|
265
|
-
label: "linear scope",
|
|
266
|
-
detail: project
|
|
267
|
-
? `team ${team} / project ${project}`
|
|
268
|
-
: `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
|
|
269
|
-
});
|
|
270
|
-
// Informational: which base branch a `runway run` would diff against
|
|
271
|
-
// and target with PRs. Detection failure here is a real problem —
|
|
272
|
-
// surface it as a fail so the user knows up front.
|
|
273
|
-
const override = process.env.RUNWAY_BASE_BRANCH?.trim();
|
|
274
|
-
if (override) {
|
|
275
|
-
checks.set("base_branch", {
|
|
276
|
-
status: "ok",
|
|
277
|
-
label: "base branch",
|
|
278
|
-
detail: `${override} (RUNWAY_BASE_BRANCH override)`,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
try {
|
|
283
|
-
const detected = await detectBaseBranch(cwd);
|
|
284
|
-
checks.set("base_branch", {
|
|
285
|
-
status: "ok",
|
|
286
|
-
label: "base branch",
|
|
287
|
-
detail: `${detected} (detected from origin/HEAD)`,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
catch (err) {
|
|
291
|
-
checks.set("base_branch", {
|
|
292
|
-
status: "fail",
|
|
293
|
-
label: "base branch",
|
|
294
|
-
detail: errMsg(err),
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (tier === 2) {
|
|
299
|
-
// Tier 2: needed by varlock to resolve op:// refs in the container.
|
|
300
|
-
checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
|
|
301
|
-
// Surface which Claude Code auth env var the .env.schema declares.
|
|
302
|
-
// ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN aren't
|
|
303
|
-
// interchangeable; a mismatch between this and what's stored in
|
|
304
|
-
// 1Password yields a generic "Invalid API key" inside the
|
|
305
|
-
// container with no useful diagnostic.
|
|
306
|
-
if (repo.hasConflictingAuthVars) {
|
|
307
|
-
checks.set("auth_mode", {
|
|
308
|
-
status: "fail",
|
|
309
|
-
label: "claude auth mode",
|
|
310
|
-
detail: ".env.schema declares both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN — pick one (they are not interchangeable)",
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
else if (repo.authMode === "oauth") {
|
|
314
|
-
checks.set("auth_mode", {
|
|
315
|
-
status: "ok",
|
|
316
|
-
label: "claude auth mode",
|
|
317
|
-
detail: "oauth (CLAUDE_CODE_OAUTH_TOKEN — Pro/Max subscription)",
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
else if (repo.authMode === "api-key") {
|
|
321
|
-
checks.set("auth_mode", {
|
|
322
|
-
status: "ok",
|
|
323
|
-
label: "claude auth mode",
|
|
324
|
-
detail: "api-key (ANTHROPIC_API_KEY — pay-per-token)",
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
checks.set("auth_mode", {
|
|
329
|
-
status: "fail",
|
|
330
|
-
label: "claude auth mode",
|
|
331
|
-
detail: ".env.schema declares neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN",
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// VA-352: surface the active impl-pass write-path policy so the
|
|
336
|
-
// operator can see whether an agent run can touch CI workflows, etc.
|
|
337
|
-
try {
|
|
338
|
-
const policy = loadPolicy(cwd);
|
|
339
|
-
checks.set("policy", {
|
|
340
|
-
status: "ok",
|
|
341
|
-
label: "impl policy",
|
|
342
|
-
detail: `${policy.source} (${policy.forbiddenPaths.length} forbidden path${policy.forbiddenPaths.length === 1 ? "" : "s"})`,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
catch (err) {
|
|
346
|
-
checks.set("policy", {
|
|
347
|
-
status: "fail",
|
|
348
|
-
label: "impl policy",
|
|
349
|
-
detail: errMsg(err),
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
return { title: "Environment", checks, ran: true };
|
|
353
|
-
}
|
|
354
|
-
function envSet(name, missingStatus) {
|
|
355
|
-
const v = process.env[name];
|
|
356
|
-
if (v && v.trim().length > 0) {
|
|
357
|
-
return { status: "ok", label: name, detail: "set" };
|
|
358
|
-
}
|
|
359
|
-
return {
|
|
360
|
-
status: missingStatus,
|
|
361
|
-
label: name,
|
|
362
|
-
detail: "unset or empty",
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
// ---------------------------------------------------------------------------
|
|
366
|
-
// Section: Repo state
|
|
367
|
-
// ---------------------------------------------------------------------------
|
|
368
|
-
async function checkRepoState(cwd, repo) {
|
|
369
|
-
const checks = new Map();
|
|
370
|
-
// Is this a git repo?
|
|
371
|
-
let isGitRepo = false;
|
|
372
|
-
try {
|
|
373
|
-
await execa("git", ["rev-parse", "--git-dir"], { cwd });
|
|
374
|
-
isGitRepo = true;
|
|
375
|
-
checks.set("git_repo", { status: "ok", label: "git repo" });
|
|
376
|
-
}
|
|
377
|
-
catch {
|
|
378
|
-
checks.set("git_repo", {
|
|
379
|
-
status: "warn",
|
|
380
|
-
label: "git repo",
|
|
381
|
-
detail: "not inside a git repo",
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
if (isGitRepo) {
|
|
385
|
-
// Working tree clean? (info-only)
|
|
386
|
-
try {
|
|
387
|
-
const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
|
|
388
|
-
if (stdout.trim().length === 0) {
|
|
389
|
-
checks.set("clean_tree", { status: "ok", label: "working tree clean" });
|
|
390
|
-
}
|
|
391
|
-
else {
|
|
392
|
-
const count = stdout.trim().split("\n").length;
|
|
393
|
-
checks.set("clean_tree", {
|
|
394
|
-
status: "warn",
|
|
395
|
-
label: "working tree clean",
|
|
396
|
-
detail: `${count} change(s) — info only`,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
catch {
|
|
401
|
-
checks.set("clean_tree", {
|
|
402
|
-
status: "warn",
|
|
403
|
-
label: "working tree clean",
|
|
404
|
-
detail: "git status failed",
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
// Current branch
|
|
408
|
-
try {
|
|
409
|
-
const { stdout } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
|
|
410
|
-
checks.set("branch", {
|
|
411
|
-
status: "ok",
|
|
412
|
-
label: "branch",
|
|
413
|
-
detail: stdout.trim() || "(detached)",
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
catch {
|
|
417
|
-
checks.set("branch", {
|
|
418
|
-
status: "warn",
|
|
419
|
-
label: "branch",
|
|
420
|
-
detail: "no commits yet",
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
// Scaffold detection
|
|
425
|
-
if (repo.tier === null) {
|
|
426
|
-
checks.set("scaffold", {
|
|
427
|
-
status: "warn",
|
|
428
|
-
label: "runway scaffold",
|
|
429
|
-
detail: "no runway scaffold detected — run `runway init` first " +
|
|
430
|
-
"(only host-tooling diagnostics apply until then)",
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
checks.set("scaffold", {
|
|
435
|
-
status: "ok",
|
|
436
|
-
label: "runway scaffold",
|
|
437
|
-
detail: `tier ${repo.tier} — Dockerfile=${repo.hasDockerfile} schema=${repo.hasSchema}`,
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
return { title: "Repo state", checks, ran: true };
|
|
441
|
-
}
|
|
442
|
-
// ---------------------------------------------------------------------------
|
|
443
|
-
// Section: Docker image
|
|
444
|
-
// ---------------------------------------------------------------------------
|
|
445
|
-
async function checkDockerImage(cwd) {
|
|
446
|
-
const checks = new Map();
|
|
447
|
-
const imageName = expectedImageName(cwd);
|
|
448
|
-
try {
|
|
449
|
-
const { stdout } = await execa("docker", ["image", "inspect", imageName, "--format", "{{.Config.User}}"], { reject: true });
|
|
450
|
-
const imageUser = stdout.trim();
|
|
451
|
-
checks.set("image_present", {
|
|
452
|
-
status: "ok",
|
|
453
|
-
label: `image ${imageName}`,
|
|
454
|
-
detail: "present",
|
|
455
|
-
});
|
|
456
|
-
// User mismatch warning per sandcastle's pre-flight diagnostic:
|
|
457
|
-
// image was built for one UID:GID; if the host UID:GID differs the
|
|
458
|
-
// bind-mount permissions will be off.
|
|
459
|
-
const hostUid = process.getuid?.() ?? 1000;
|
|
460
|
-
const hostGid = process.getgid?.() ?? 1000;
|
|
461
|
-
const expected = `${hostUid}:${hostGid}`;
|
|
462
|
-
if (imageUser && imageUser !== expected) {
|
|
463
|
-
checks.set("image_user", {
|
|
464
|
-
status: "warn",
|
|
465
|
-
label: "image user matches host UID:GID",
|
|
466
|
-
detail: `image User=${imageUser}, host=${expected} — rebuild with --build-arg AGENT_UID/AGENT_GID`,
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
else {
|
|
470
|
-
checks.set("image_user", {
|
|
471
|
-
status: "ok",
|
|
472
|
-
label: "image user matches host UID:GID",
|
|
473
|
-
detail: imageUser ? `User=${imageUser}` : `User unset (root); host=${expected}`,
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
// VA-351: container readiness — pnpm on PATH + HOME/cache env
|
|
477
|
-
// baked in. Cheap one-shot run; fails fast if the image is stale.
|
|
478
|
-
try {
|
|
479
|
-
const probe = await execa("docker", [
|
|
480
|
-
"run",
|
|
481
|
-
"--rm",
|
|
482
|
-
imageName,
|
|
483
|
-
"bash",
|
|
484
|
-
"-lc",
|
|
485
|
-
'set -e; which pnpm >/dev/null && printf "HOME=%s\\nXDG_CACHE_HOME=%s\\nTURBO_CACHE_DIR=%s\\n" "$HOME" "$XDG_CACHE_HOME" "$TURBO_CACHE_DIR"',
|
|
486
|
-
], { reject: false });
|
|
487
|
-
const out = probe.stdout ?? "";
|
|
488
|
-
const missing = [];
|
|
489
|
-
if (probe.exitCode !== 0)
|
|
490
|
-
missing.push("pnpm");
|
|
491
|
-
if (!/^HOME=\/home\/agent\s*$/m.test(out))
|
|
492
|
-
missing.push("HOME");
|
|
493
|
-
if (!/^XDG_CACHE_HOME=\/home\/agent\/.cache\s*$/m.test(out)) {
|
|
494
|
-
missing.push("XDG_CACHE_HOME");
|
|
495
|
-
}
|
|
496
|
-
if (!/^TURBO_CACHE_DIR=\/tmp\/turbo-cache\s*$/m.test(out)) {
|
|
497
|
-
missing.push("TURBO_CACHE_DIR");
|
|
498
|
-
}
|
|
499
|
-
if (missing.length === 0) {
|
|
500
|
-
checks.set("container_ready", {
|
|
501
|
-
status: "ok",
|
|
502
|
-
label: "container readiness",
|
|
503
|
-
detail: "pnpm on PATH; HOME, XDG_CACHE_HOME, TURBO_CACHE_DIR set",
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
checks.set("container_ready", {
|
|
508
|
-
status: "warn",
|
|
509
|
-
label: "container readiness",
|
|
510
|
-
detail: `missing or wrong inside container: ${missing.join(", ")} — rebuild via \`runway upgrade-repo && docker build .sandcastle -t ${imageName}\``,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
catch (err) {
|
|
515
|
-
checks.set("container_ready", {
|
|
516
|
-
status: "warn",
|
|
517
|
-
label: "container readiness",
|
|
518
|
-
detail: `probe failed: ${errMsg(err)}`,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
checks.set("image_present", {
|
|
524
|
-
status: "fail",
|
|
525
|
-
label: `image ${imageName}`,
|
|
526
|
-
detail: `not built — ${errMsg(err)}`,
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
return { title: "Docker image", checks, ran: true };
|
|
530
|
-
}
|
|
531
|
-
// ---------------------------------------------------------------------------
|
|
532
|
-
// Section: Linear configuration (VA-354)
|
|
533
|
-
// ---------------------------------------------------------------------------
|
|
534
|
-
/**
|
|
535
|
-
* Validate that the team, workflow states, and HITL label `runway run`
|
|
536
|
-
* would use actually exist on the Linear workspace. Without this,
|
|
537
|
-
* misconfiguration only surfaces deep inside a long agent run — too
|
|
538
|
-
* late to fix without losing the work.
|
|
539
|
-
*/
|
|
540
|
-
async function checkLinearConfig() {
|
|
541
|
-
const checks = new Map();
|
|
542
|
-
// The config loader's only hard requirement is LINEAR_API_KEY; the
|
|
543
|
-
// rest defaults. If the key is missing, the Environment section
|
|
544
|
-
// already fails — surface a skip here rather than re-failing.
|
|
545
|
-
if (!process.env.LINEAR_API_KEY) {
|
|
546
|
-
checks.set("linear_config", {
|
|
547
|
-
status: "skip",
|
|
548
|
-
label: "Linear config",
|
|
549
|
-
detail: "LINEAR_API_KEY unset — skipped",
|
|
550
|
-
});
|
|
551
|
-
return { title: "Linear configuration", checks, ran: true };
|
|
552
|
-
}
|
|
553
|
-
let config;
|
|
554
|
-
try {
|
|
555
|
-
config = loadConfig();
|
|
556
|
-
}
|
|
557
|
-
catch (err) {
|
|
558
|
-
checks.set("linear_config", {
|
|
559
|
-
status: "fail",
|
|
560
|
-
label: "Linear config",
|
|
561
|
-
detail: `failed to load runway config: ${errMsg(err)}`,
|
|
562
|
-
});
|
|
563
|
-
return { title: "Linear configuration", checks, ran: true };
|
|
564
|
-
}
|
|
565
|
-
let result;
|
|
566
|
-
try {
|
|
567
|
-
result = await validateLinearConfig(config);
|
|
568
|
-
}
|
|
569
|
-
catch (err) {
|
|
570
|
-
checks.set("linear_api", {
|
|
571
|
-
status: "fail",
|
|
572
|
-
label: "Linear API",
|
|
573
|
-
detail: `validation request failed: ${errMsg(err)}`,
|
|
574
|
-
});
|
|
575
|
-
return { title: "Linear configuration", checks, ran: true };
|
|
576
|
-
}
|
|
577
|
-
if (result.team.kind === "missing") {
|
|
578
|
-
checks.set("team", {
|
|
579
|
-
status: "fail",
|
|
580
|
-
label: `team ${config.linearTeam}`,
|
|
581
|
-
detail: `Linear team key "${result.team.key}" not found — set RUNWAY_LINEAR_TEAM`,
|
|
582
|
-
});
|
|
583
|
-
// States/labels are skipped when the team missing; surface
|
|
584
|
-
// explicitly so the user knows they weren't checked.
|
|
585
|
-
checks.set("states", {
|
|
586
|
-
status: "skip",
|
|
587
|
-
label: "workflow states",
|
|
588
|
-
detail: "skipped (team missing)",
|
|
589
|
-
});
|
|
590
|
-
checks.set("hitl_label", {
|
|
591
|
-
status: "skip",
|
|
592
|
-
label: "HITL label",
|
|
593
|
-
detail: "skipped (team missing)",
|
|
594
|
-
});
|
|
595
|
-
return { title: "Linear configuration", checks, ran: true };
|
|
596
|
-
}
|
|
597
|
-
checks.set("team", {
|
|
598
|
-
status: "ok",
|
|
599
|
-
label: `team ${config.linearTeam}`,
|
|
600
|
-
detail: `id=${result.team.id}`,
|
|
601
|
-
});
|
|
602
|
-
for (const [key, configured, state] of [
|
|
603
|
-
["ready_state", config.readyStatus, result.readyStatus],
|
|
604
|
-
["in_progress_state", config.inProgressStatus, result.inProgressStatus],
|
|
605
|
-
["in_review_state", config.inReviewStatus, result.inReviewStatus],
|
|
606
|
-
]) {
|
|
607
|
-
if (state.kind === "ok") {
|
|
608
|
-
checks.set(key, {
|
|
609
|
-
status: "ok",
|
|
610
|
-
label: `workflow state "${configured}"`,
|
|
611
|
-
detail: "present",
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
else if (state.kind === "skipped") {
|
|
615
|
-
checks.set(key, {
|
|
616
|
-
status: "skip",
|
|
617
|
-
label: `workflow state "${configured}"`,
|
|
618
|
-
detail: state.reason,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
else {
|
|
622
|
-
checks.set(key, {
|
|
623
|
-
status: "fail",
|
|
624
|
-
label: `workflow state "${configured}"`,
|
|
625
|
-
detail: `not found on team; available: ${formatList(state.available)}`,
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
if (result.hitlLabel.kind === "ok") {
|
|
630
|
-
checks.set("hitl_label", {
|
|
631
|
-
status: "ok",
|
|
632
|
-
label: `HITL label "${config.hitlLabel}"`,
|
|
633
|
-
detail: "present",
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
else if (result.hitlLabel.kind === "skipped") {
|
|
637
|
-
checks.set("hitl_label", {
|
|
638
|
-
status: "skip",
|
|
639
|
-
label: `HITL label "${config.hitlLabel}"`,
|
|
640
|
-
detail: result.hitlLabel.reason,
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
else {
|
|
644
|
-
checks.set("hitl_label", {
|
|
645
|
-
status: "fail",
|
|
646
|
-
label: `HITL label "${config.hitlLabel}"`,
|
|
647
|
-
detail: `not found on team — set RUNWAY_HITL_LABEL or create the label. Available: ${formatList(result.hitlLabel.available)}`,
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
return { title: "Linear configuration", checks, ran: true };
|
|
651
|
-
}
|
|
652
|
-
function formatList(items) {
|
|
653
|
-
if (items.length === 0)
|
|
654
|
-
return "(none)";
|
|
655
|
-
if (items.length <= 8)
|
|
656
|
-
return items.join(", ");
|
|
657
|
-
return `${items.slice(0, 8).join(", ")}, …(+${items.length - 8} more)`;
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* Sanitize the cwd's basename the same way sandcastle's `defaultImageName`
|
|
661
|
-
* does: lowercase, replace any char outside `[a-z0-9_.-]` with `-`, fall
|
|
662
|
-
* back to "local" if empty. Prefix `sandcastle:`.
|
|
663
|
-
*/
|
|
664
|
-
function expectedImageName(cwd) {
|
|
665
|
-
const dirName = cwd
|
|
666
|
-
.replace(/[\\/]+$/, "")
|
|
667
|
-
.split(/[\\/]/)
|
|
668
|
-
.pop() ?? "local";
|
|
669
|
-
const sanitized = dirName.toLowerCase().replace(/[^a-z0-9_.-]/g, "-") || "local";
|
|
670
|
-
return `sandcastle:${sanitized}`;
|
|
671
|
-
}
|
|
672
|
-
// ---------------------------------------------------------------------------
|
|
673
|
-
// Helpers
|
|
674
|
-
// ---------------------------------------------------------------------------
|
|
675
|
-
function skippedSection(title) {
|
|
676
|
-
return { title, checks: new Map(), ran: false };
|
|
677
|
-
}
|
|
678
|
-
function errMsg(err) {
|
|
679
|
-
if (err instanceof Error)
|
|
680
|
-
return err.message.split("\n")[0] ?? err.message;
|
|
681
|
-
return String(err);
|
|
682
|
-
}
|
|
683
|
-
// ---------------------------------------------------------------------------
|
|
684
|
-
// Renderers
|
|
685
|
-
// ---------------------------------------------------------------------------
|
|
686
|
-
function statusGlyph(s) {
|
|
687
|
-
switch (s) {
|
|
688
|
-
case "ok":
|
|
689
|
-
return "✓";
|
|
690
|
-
case "fail":
|
|
691
|
-
return "✗";
|
|
692
|
-
case "warn":
|
|
693
|
-
return "⚠";
|
|
694
|
-
case "skip":
|
|
695
|
-
return "·";
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
function renderText(sections, tier, initialised, detailed) {
|
|
699
|
-
const tierLabel = tier === null ? "not initialized" : `tier ${tier}`;
|
|
700
|
-
console.log(`runway doctor — ${tierLabel}`);
|
|
701
|
-
console.log("");
|
|
702
|
-
for (const section of sections) {
|
|
703
|
-
console.log(`${section.title}`);
|
|
704
|
-
if (!section.ran) {
|
|
705
|
-
console.log(" · skipped (no runway scaffold detected)");
|
|
706
|
-
console.log("");
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
for (const check of section.checks.values()) {
|
|
710
|
-
const glyph = statusGlyph(check.status);
|
|
711
|
-
if (detailed && check.detail) {
|
|
712
|
-
console.log(` ${glyph} ${check.label} — ${check.detail}`);
|
|
713
|
-
}
|
|
714
|
-
else if (check.status !== "ok" && check.detail) {
|
|
715
|
-
// Always surface non-OK detail even in terse mode.
|
|
716
|
-
console.log(` ${glyph} ${check.label} — ${check.detail}`);
|
|
717
|
-
}
|
|
718
|
-
else {
|
|
719
|
-
console.log(` ${glyph} ${check.label}`);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
console.log("");
|
|
723
|
-
}
|
|
724
|
-
if (!initialised) {
|
|
725
|
-
console.log("Hint: cwd has no runway scaffold. Run `runway init` from a target repo " +
|
|
726
|
-
"to enable the full diagnostic.");
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
function renderJson(sections, tier, _initialised) {
|
|
730
|
-
const sectionKey = (title) => title.toLowerCase().replace(/\s+/g, "_");
|
|
731
|
-
const checksByName = (s) => {
|
|
732
|
-
const out = {};
|
|
733
|
-
for (const [k, v] of s.checks.entries())
|
|
734
|
-
out[k] = v.status;
|
|
735
|
-
return out;
|
|
736
|
-
};
|
|
737
|
-
// Determine `ok` from required sections (tooling, env, docker image).
|
|
738
|
-
const requiredSections = [sections[0], sections[1], sections[3]];
|
|
739
|
-
const ok = !requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
|
|
740
|
-
const checks = {};
|
|
741
|
-
for (const s of sections) {
|
|
742
|
-
checks[sectionKey(s.title)] = s.ran ? checksByName(s) : null;
|
|
743
|
-
}
|
|
744
|
-
const payload = {
|
|
745
|
-
ok,
|
|
746
|
-
tier,
|
|
747
|
-
checks,
|
|
748
|
-
};
|
|
749
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
750
|
-
}
|