@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.
Files changed (45) hide show
  1. package/README.md +87 -6
  2. package/dist/commands/doctor.js +67 -631
  3. package/dist/commands/init.js +2 -311
  4. package/dist/commands/run.js +39 -12
  5. package/dist/commands/upgrade-repo.js +2 -251
  6. package/dist/config.js +53 -67
  7. package/dist/diagnostics/base-branch.js +45 -0
  8. package/dist/diagnostics/claude-auth-mode.js +51 -0
  9. package/dist/diagnostics/docker-image.js +119 -0
  10. package/dist/diagnostics/docker.js +40 -0
  11. package/dist/diagnostics/gh.js +51 -0
  12. package/dist/diagnostics/git-state.js +73 -0
  13. package/dist/diagnostics/git.js +9 -0
  14. package/dist/diagnostics/index.js +180 -0
  15. package/dist/diagnostics/linear-api-key.js +14 -0
  16. package/dist/diagnostics/linear-config.js +133 -0
  17. package/dist/diagnostics/linear-scope.js +25 -0
  18. package/dist/diagnostics/node.js +18 -0
  19. package/dist/diagnostics/op-token.js +14 -0
  20. package/dist/diagnostics/op.js +9 -0
  21. package/dist/diagnostics/policy.js +35 -0
  22. package/dist/diagnostics/render.js +69 -0
  23. package/dist/diagnostics/runway-scaffold.js +31 -0
  24. package/dist/diagnostics/varlock.js +9 -0
  25. package/dist/finalize.js +35 -0
  26. package/dist/git.js +121 -29
  27. package/dist/github.js +136 -21
  28. package/dist/hitl.js +104 -0
  29. package/dist/implement.js +149 -0
  30. package/dist/linear.js +257 -65
  31. package/dist/orchestrator.js +111 -364
  32. package/dist/policy.js +0 -11
  33. package/dist/prompts.js +54 -55
  34. package/dist/repo-upgrade.js +268 -0
  35. package/dist/review.js +82 -0
  36. package/dist/sandcastle.js +60 -0
  37. package/dist/scaffolder-dockerfile.js +27 -0
  38. package/dist/scaffolder-image.js +26 -0
  39. package/dist/scaffolder-preflight.js +66 -0
  40. package/dist/scaffolder-varlock.js +67 -0
  41. package/dist/scaffolder-verify.js +102 -0
  42. package/dist/scaffolder.js +54 -0
  43. package/dist/subprocess.js +40 -0
  44. package/dist/telemetry.js +31 -0
  45. package/package.json +9 -1
@@ -1,10 +1,18 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { execa } from "execa";
4
- import { detectBaseBranch } from "../git.js";
5
- import { loadPolicy } from "../policy.js";
6
- import { loadConfig } from "../config.js";
7
- import { validateLinearConfig } from "../linear.js";
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 image checks.
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 { tierOverride, detailed, json };
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
- // If no scaffold AND no override → still run host-tooling, but skip the rest.
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 sections = [];
88
- sections.push(await checkHostTooling(tierForToolingChecks));
89
- if (initialised || opts.tierOverride !== undefined) {
90
- sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
91
- sections.push(await checkRepoState(cwd, repo));
92
- sections.push(await checkDockerImage(cwd));
93
- sections.push(await checkLinearConfig());
94
- }
95
- else {
96
- // Push placeholder skipped sections so JSON output stays well-shaped.
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, initialised);
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
- }