@theagilemonkeys/facility 0.11.3 → 0.12.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 (67) hide show
  1. package/README.md +61 -47
  2. package/package.json +3 -4
  3. package/src/cli.mjs +27 -176
  4. package/src/detect.mjs +24 -94
  5. package/src/doctor.mjs +54 -559
  6. package/src/init.mjs +92 -535
  7. package/templates/agents/address-review.md +53 -0
  8. package/templates/agents/architect.md +50 -0
  9. package/templates/agents/builder.md +58 -0
  10. package/templates/agents/ci-doctor.md +55 -0
  11. package/templates/agents/pr-reviewer.md +52 -0
  12. package/templates/agents/security-audit.md +54 -0
  13. package/modules/README.md +0 -35
  14. package/modules/ai-queryability/agents/queryability-reviewer.md +0 -35
  15. package/modules/ai-queryability/module.json +0 -9
  16. package/modules/ai-queryability/standard-section.md +0 -22
  17. package/modules/analytics/agents/analytics-reviewer.md +0 -32
  18. package/modules/analytics/commands/add-telemetry.md +0 -23
  19. package/modules/analytics/module.json +0 -10
  20. package/modules/analytics/standard-section.md +0 -23
  21. package/modules/database/agents/data-security-reviewer.md +0 -38
  22. package/modules/database/commands/new-migration.md +0 -24
  23. package/modules/database/guards/migration-versions.mjs +0 -41
  24. package/modules/database/guards/migrations-immutable.mjs +0 -57
  25. package/modules/database/hooks/protect-migrations.fragment.mjs +0 -10
  26. package/modules/database/module.json +0 -25
  27. package/modules/database/standard-section.md +0 -20
  28. package/modules/design-system/agents/design-reviewer.md +0 -37
  29. package/modules/design-system/module.json +0 -9
  30. package/modules/design-system/standard-section.md +0 -15
  31. package/src/add.mjs +0 -77
  32. package/src/platform-admin.mjs +0 -1552
  33. package/src/platform-config.mjs +0 -39
  34. package/src/platform.mjs +0 -1759
  35. package/src/render.mjs +0 -66
  36. package/templates/claude/settings.json +0 -71
  37. package/templates/delivery/verify.mjs +0 -157
  38. package/templates/doctor/resolve.mjs +0 -572
  39. package/templates/guards/README.md +0 -30
  40. package/templates/guards/_kit.mjs +0 -81
  41. package/templates/guards/actions-pinned.mjs +0 -38
  42. package/templates/guards/run.mjs +0 -111
  43. package/templates/guards/watchtower-locked.mjs +0 -66
  44. package/templates/prompts/address-review.md +0 -14
  45. package/templates/prompts/architect.md +0 -63
  46. package/templates/prompts/builder.md +0 -79
  47. package/templates/prompts/doctor.md +0 -69
  48. package/templates/prompts/review.md +0 -14
  49. package/templates/prompts/sweep.md +0 -75
  50. package/templates/receipts/collect.mjs +0 -297
  51. package/templates/review/finalize.mjs +0 -38
  52. package/templates/scripts/move-board-status.sh +0 -155
  53. package/templates/security/sync-findings.mjs +0 -226
  54. package/templates/standard/STANDARD.md +0 -141
  55. package/templates/standard/agents-block.md +0 -25
  56. package/templates/watchtower/budgets.json +0 -12
  57. package/templates/watchtower/canary.mjs +0 -216
  58. package/templates/watchtower/health.mjs +0 -148
  59. package/templates/watchtower/outcomes.mjs +0 -188
  60. package/templates/workflows/facility-address-review.yml +0 -154
  61. package/templates/workflows/facility-canary.yml +0 -61
  62. package/templates/workflows/facility-codex.yml +0 -327
  63. package/templates/workflows/facility-crew.yml +0 -351
  64. package/templates/workflows/facility-doctor.yml +0 -174
  65. package/templates/workflows/facility-review.yml +0 -135
  66. package/templates/workflows/facility-security-sweep.yml +0 -204
  67. package/templates/workflows/facility-watchtower.yml +0 -87
package/src/doctor.mjs CHANGED
@@ -1,582 +1,77 @@
1
- // `facility doctor` — check the install and tell the truth about what's left.
2
- // Static checks run locally; the GitHub-side items it can't verify are
3
- // printed as the explicit manual checklist instead of being assumed.
4
- import { spawnSync } from "node:child_process";
5
1
  import { existsSync, readFileSync } from "node:fs";
6
2
  import { join } from "node:path";
7
- import { getProfile, loadConfig } from "./platform-config.mjs";
8
- import { banner, bold, dim, fail, green, heading, item, ok, red, warn, yellow } from "./ui.mjs";
9
-
10
- const REQUIRED = [
11
- ".github/workflows/facility-crew.yml",
12
- ".github/workflows/facility-codex.yml",
13
- ".github/workflows/facility-review.yml",
14
- ".github/workflows/facility-address-review.yml",
15
- ".github/workflows/facility-doctor.yml",
16
- ".github/workflows/facility-security-sweep.yml",
17
- ".github/workflows/facility-watchtower.yml",
18
- ".github/workflows/facility-canary.yml",
19
- ".github/facility/architect.md",
20
- ".github/facility/builder.md",
21
- ".github/facility/doctor.md",
22
- ".github/facility/sweep.md",
23
- ".github/facility/doctor/resolve.mjs",
24
- ".github/facility/delivery/verify.mjs",
25
- ".github/facility/receipts/collect.mjs",
26
- ".github/facility/review/finalize.mjs",
27
- ".github/facility/security/sync-findings.mjs",
28
- ".github/facility/watchtower/outcomes.mjs",
29
- ".github/facility/watchtower/health.mjs",
30
- ".github/facility/watchtower/canary.mjs",
31
- ".github/facility/watchtower/budgets.json",
32
- "STANDARD.md",
33
- "AGENTS.md",
34
- ".claude/hooks/protect-branch.mjs",
35
- ".claude/hooks/protect-files.mjs",
36
- ".claude/skills/working-to-standard/SKILL.md",
37
- ".claude/skills/reviewing-to-standard/SKILL.md",
38
- ".claude/skills/maintainable-software/SKILL.md",
39
- ".claude/commands/verify.md",
40
- "guards/run.mjs",
3
+ import { banner, heading, item, ok, warn } from "./ui.mjs";
4
+
5
+ const AGENTS = [
6
+ "architect",
7
+ "builder",
8
+ "pr-reviewer",
9
+ "address-review",
10
+ "ci-doctor",
11
+ "security-audit",
41
12
  ];
42
13
 
43
- export async function doctor(flags, version, options = {}) {
44
- const platform = platformTarget(flags, options);
45
- if (!flags.local && platform) return platformDoctor(flags, version, platform, options);
46
- if ((flags.platform || flags.url || flags.key || flags.profile) && !platform) {
47
- const message = "facility doctor needs both --url and --key, or a saved login profile.";
48
- if (flags.json) {
49
- (options.stdout || process.stdout).write(
50
- `${JSON.stringify({
51
- mode: "platform",
52
- target: null,
53
- ok: false,
54
- checks: [],
55
- error: { code: "doctor_target_required", message },
56
- })}\n`,
57
- );
58
- }
59
- else console.log(message);
60
- return 2;
61
- }
62
-
14
+ export async function doctor(flags, version) {
63
15
  const dir = flags.dir || process.cwd();
64
- const report = inspectLocalInstall(dir, {
65
- runGuards: flags["run-guards"] === true,
66
- github: flags.github === true,
67
- });
16
+ const checks = [checkProjectManifest(dir), ...AGENTS.map((name) => checkAgent(dir, name))];
17
+ const problems = checks.filter((check) => !check.ok).length;
18
+ const result = { mode: "local", ok: problems === 0, problems, checks };
19
+
68
20
  if (flags.json) {
69
- (options.stdout || process.stdout).write(`${JSON.stringify(report)}\n`);
70
- return report.ok ? 0 : 1;
21
+ console.log(JSON.stringify(result));
22
+ return result.ok ? 0 : 1;
71
23
  }
72
24
 
73
25
  banner(version);
74
- renderLocalReport(report);
75
- console.log("");
76
- if (report.ok) item(`${dim("Everything checkable checks out.")}`);
77
- else item(`${dim(`${report.problems} problem${report.problems === 1 ? "" : "s"} found.`)}`);
78
- console.log("");
79
- return report.ok ? 0 : 1;
80
- }
81
-
82
- function inspectLocalInstall(dir, options = {}) {
83
- const checks = [];
84
- let manifest = {};
85
- for (const file of REQUIRED) {
86
- checks.push(
87
- existsSync(join(dir, file))
88
- ? localCheck("Files", file, "pass", "Present")
89
- : localCheck("Files", file, "fail", "missing", "Run `npx @theagilemonkeys/facility init`."),
90
- );
91
- }
92
-
93
- const manifestPath = join(dir, ".facility.json");
94
- if (!existsSync(manifestPath)) {
95
- checks.push(localCheck("Manifest", ".facility.json", "fail", "missing"));
96
- } else {
97
- try {
98
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
99
- const models = manifest.models;
100
- checks.push(
101
- models?.build &&
102
- models?.review &&
103
- models?.plan &&
104
- models?.codexBuild &&
105
- models?.codexPlan
106
- ? localCheck(
107
- "Manifest",
108
- ".facility.json",
109
- "pass",
110
- `engines ${(manifest.engines || [manifest.engine || "claude-code"]).join(",")}, models build=${models.build}, review=${models.review}, plan=${models.plan}, codexBuild=${models.codexBuild}, codexPlan=${models.codexPlan}`,
111
- )
112
- : localCheck(
113
- "Manifest",
114
- "models",
115
- "fail",
116
- "Claude and Codex build/plan models must all be configured.",
117
- "Rerun init with explicit Claude and Codex model flags.",
118
- ),
119
- );
120
- const mode = detectAnthropicAuthMode(manifest, dir);
121
- checks.push(
122
- mode
123
- ? localCheck("Manifest", "auth", "pass", `Anthropic auth: ${mode}`)
124
- : localCheck(
125
- "Manifest",
126
- "auth",
127
- "fail",
128
- "Anthropic auth mode is missing or unsupported.",
129
- "Rerun init with --auth=<api-key|oauth|wif|bedrock|vertex>.",
130
- ),
131
- );
132
- checks.push(
133
- manifest.provision
134
- ? localCheck("Manifest", "provision", "pass", String(manifest.provision))
135
- : localCheck(
136
- "Manifest",
137
- "provision",
138
- "fail",
139
- "No provision command configured; the crew will under-verify.",
140
- "Set manifest.provision.",
141
- ),
142
- );
143
- checks.push(
144
- manifest.checks?.length
145
- ? localCheck("Manifest", "checks", "pass", manifest.checks.join(", "))
146
- : localCheck(
147
- "Manifest",
148
- "checks",
149
- "fail",
150
- "No check commands configured; verify-before-done has nothing to run.",
151
- "Set manifest.checks.",
152
- ),
153
- );
154
- if (manifest.preview?.enabled) {
155
- const previewValid =
156
- typeof manifest.preview.image === "string" &&
157
- Number.isInteger(manifest.preview.port) &&
158
- (!manifest.preview.readinessPath ||
159
- String(manifest.preview.readinessPath).startsWith("/"));
160
- checks.push(
161
- previewValid
162
- ? localCheck(
163
- "Manifest",
164
- "protected preview",
165
- "pass",
166
- `${manifest.preview.image}:${manifest.preview.port}${manifest.preview.readinessPath ?? ""}`,
167
- )
168
- : localCheck(
169
- "Manifest",
170
- "protected preview",
171
- "fail",
172
- "Preview image, port, or readiness path is invalid.",
173
- "Rerun init with --preview-image, --preview-port, and an optional /readiness path.",
174
- ),
175
- );
176
- }
177
- } catch (error) {
178
- checks.push(
179
- localCheck(
180
- "Manifest",
181
- ".facility.json",
182
- "fail",
183
- `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
184
- "Repair the manifest or rerun `npx @theagilemonkeys/facility init --force`.",
185
- ),
186
- );
187
- }
188
- }
189
-
190
- if (existsSync(join(dir, "guards/run.mjs")) && options.runGuards) {
191
- const result = spawnSync(process.execPath, ["guards/run.mjs"], { cwd: dir, encoding: "utf8" });
192
- checks.push(
193
- result.status === 0
194
- ? localCheck("Guards", "guards/run.mjs", "pass", "Guards pass")
195
- : localCheck(
196
- "Guards",
197
- "guards/run.mjs",
198
- "fail",
199
- "Guards failed",
200
- "Run `node guards/run.mjs` and fix every reported violation.",
201
- `${result.stdout}${result.stderr}`.trim(),
202
- ),
203
- );
204
- } else if (existsSync(join(dir, "guards/run.mjs"))) {
205
- checks.push(
206
- localCheck(
207
- "Guards",
208
- "guards/run.mjs",
209
- "warn",
210
- "Not executed by the static doctor.",
211
- "Pass --run-guards only for a trusted checkout, or run `node guards/run.mjs` directly.",
212
- ),
213
- );
214
- }
215
-
216
- const requirements = authRequirements(detectAnthropicAuthMode(manifest, dir));
217
- const ghSecrets = options.github
218
- ? spawnSync("gh", ["secret", "list"], {
219
- cwd: dir,
220
- encoding: "utf8",
221
- timeout: 10_000,
222
- })
223
- : undefined;
224
- const ghVariables = options.github
225
- ? spawnSync("gh", ["variable", "list"], {
226
- cwd: dir,
227
- encoding: "utf8",
228
- timeout: 10_000,
229
- })
230
- : undefined;
231
- const environmentSecrets = new Set();
232
- if (options.github) {
233
- for (const environment of ["facility-crew", "facility-codex"]) {
234
- const result = spawnSync("gh", ["secret", "list", "--env", environment], {
235
- cwd: dir,
236
- encoding: "utf8",
237
- timeout: 10_000,
238
- });
239
- if (result.status === 0) {
240
- for (const name of namesFromGhList(result.stdout)) environmentSecrets.add(name);
241
- }
242
- }
26
+ heading("Workspace contract");
27
+ for (const check of checks) {
28
+ if (check.ok) ok(`${check.label}: ${check.detail}`);
29
+ else warn(`${check.label}: ${check.detail}`);
243
30
  }
244
- const requiredSecrets = new Set(requirements.secrets);
245
- if (manifest.engines?.includes("codex")) requiredSecrets.add("OPENAI_API_KEY");
246
- if (manifest.preview?.enabled) {
247
- for (const name of ["FACILITY_API_URL", "FACILITY_PROJECT_ID", "FACILITY_PREVIEW_KEY"]) {
248
- requiredSecrets.add(name);
249
- }
250
- }
251
- if (ghSecrets?.status === 0 && ghVariables?.status === 0) {
252
- const secrets = namesFromGhList(ghSecrets.stdout);
253
- for (const name of environmentSecrets) secrets.add(name);
254
- const variables = namesFromGhList(ghVariables.stdout);
255
- for (const name of requiredSecrets) {
256
- checks.push(
257
- secrets.has(name)
258
- ? localCheck("GitHub", name, "pass", "Secret exists")
259
- : localCheck("GitHub", name, "fail", "Secret not found", credentialRemediation(name, requirements.remediation)),
260
- );
261
- }
262
- for (const name of requirements.variables) {
263
- checks.push(
264
- variables.has(name)
265
- ? localCheck("GitHub", name, "pass", "Variable exists")
266
- : localCheck("GitHub", name, "fail", "Variable not found", requirements.remediation),
267
- );
268
- }
269
- checks.push(githubBranchProtectionCheck(dir, manifest));
270
- } else if (options.github) {
271
- checks.push(
272
- localCheck(
273
- "GitHub",
274
- "automation credentials",
275
- "warn",
276
- "Could not query secrets and variables because gh is unavailable or unauthenticated.",
277
- "Verify the required repo/org credentials manually.",
278
- ),
279
- );
280
- } else {
281
- for (const name of requiredSecrets) {
282
- checks.push(
283
- localCheck(
284
- "GitHub",
285
- name,
286
- "warn",
287
- "Secret not queried by the offline doctor.",
288
- "Pass --github to query with gh, or verify it manually.",
289
- ),
290
- );
291
- }
292
- for (const name of requirements.variables) {
293
- checks.push(
294
- localCheck(
295
- "GitHub",
296
- name,
297
- "warn",
298
- "Variable not queried by the offline doctor.",
299
- "Pass --github to query with gh, or verify it manually.",
300
- ),
301
- );
302
- }
303
- }
304
- const manual = [
305
- "Claude GitHub App installed on the repo",
306
- ...(options.github ? [] : ["default branch protected: PR + 1 human review required"]),
307
- "provider TEST keys (if any) live in the facility-crew Environment",
308
- ];
309
- const problems = checks.filter((check) => check.status === "fail").length;
310
- return { ok: problems === 0, mode: "local", directory: dir, problems, checks, manual };
31
+ item(result.ok ? "Facility can create a story workspace from this repository." : `${problems} problem${problems === 1 ? "" : "s"} found.`);
32
+ return result.ok ? 0 : 1;
311
33
  }
312
34
 
313
- function credentialRemediation(name, authRemediation) {
314
- if (name === "OPENAI_API_KEY") {
315
- return "store a dedicated, spend-capped key in the facility-codex environment";
316
- }
317
- if (name.startsWith("FACILITY_")) {
318
- return "configure the Facility API URL, project id, and project-scoped preview key together";
35
+ function checkProjectManifest(dir) {
36
+ const path = join(dir, ".facility.yml");
37
+ if (!existsSync(path)) return failed("start command", ".facility.yml is missing");
38
+ const source = readFileSync(path, "utf8");
39
+ if (!/^version:\s*1\s*$/m.test(source)) return failed("start command", "version must be 1");
40
+ if (!/^\s{2}primary:\s*["']?github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+["']?\s*$/m.test(source)) {
41
+ return failed("start command", "repositories.primary must be github.com/owner/repository");
319
42
  }
320
- return authRemediation;
321
- }
322
-
323
- function githubBranchProtectionCheck(dir, manifest) {
324
- const repo = spawnSync("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
325
- cwd: dir,
326
- encoding: "utf8",
327
- timeout: 10_000,
328
- });
329
- const nameWithOwner = repo.stdout.trim();
330
- if (repo.status !== 0 || !nameWithOwner) {
331
- return localCheck(
332
- "GitHub",
333
- "branch protection",
334
- "warn",
335
- "Could not resolve the GitHub repository.",
336
- "Run gh auth login and verify branch protection manually.",
337
- );
43
+ if (!/^\s{2}start:\s*["']?.+$/m.test(source)) return failed("start command", "environment.start is missing");
44
+ const servicePort = /^\s{6}port:\s*([1-9]\d{0,4})\s*$/m.exec(source);
45
+ if (!servicePort || Number(servicePort[1]) > 65_535) {
46
+ return failed("start command", "a service port between 1 and 65535 is required");
338
47
  }
339
- const branch = String(manifest.defaultBranch || "main");
340
- const protection = spawnSync(
341
- "gh",
342
- ["api", `repos/${nameWithOwner}/branches/${encodeURIComponent(branch)}/protection`],
343
- { cwd: dir, encoding: "utf8", timeout: 10_000 },
344
- );
345
- return protection.status === 0
346
- ? localCheck("GitHub", `${branch} branch protection`, "pass", "Protection is enabled")
347
- : localCheck(
348
- "GitHub",
349
- `${branch} branch protection`,
350
- "fail",
351
- "Protection could not be verified.",
352
- "Require pull requests and at least one human approval on the default branch.",
353
- );
354
- }
355
-
356
- function localCheck(section, label, status, message, remediation, detail) {
357
- return {
358
- section,
359
- label,
360
- status,
361
- message,
362
- ...(remediation ? { remediation } : {}),
363
- ...(detail ? { detail } : {}),
364
- };
48
+ return passed("start command", ".facility.yml declares the repository and development environment");
365
49
  }
366
50
 
367
- function renderLocalReport(report) {
368
- for (const section of ["Files", "Manifest", "Guards", "GitHub"]) {
369
- heading(section === "GitHub" ? "GitHub side (verify by hand or with gh)" : section);
370
- for (const check of report.checks.filter((candidate) => candidate.section === section)) {
371
- const message = `${check.label}${check.message && check.message !== "Present" ? ` — ${check.message}` : ""}${
372
- check.remediation ? ` ${check.remediation}` : ""
373
- }`;
374
- if (check.status === "pass") ok(message);
375
- else if (check.status === "warn") warn(message);
376
- else fail(message);
377
- if (check.detail) {
378
- console.log(
379
- check.detail
380
- .split("\n")
381
- .map((line) => ` ${line}`)
382
- .join("\n"),
383
- );
384
- }
385
- }
386
- }
387
- for (const line of report.manual) item(dim(` · ${line}`));
51
+ function checkAgent(dir, name) {
52
+ const relative = `.agents/${name}.md`;
53
+ const path = join(dir, relative);
54
+ if (!existsSync(path)) return failed(relative, "missing");
55
+ const source = readFileSync(path, "utf8").replace(/\r\n?/g, "\n");
56
+ if (!source.startsWith("---\n") || !/\n---\n[\s\S]*\S/.test(source)) return failed(relative, "invalid frontmatter or empty prompt");
57
+ if (!new RegExp(`^name:\\s*${escapeRegExp(name)}\\s*$`, "m").test(source)) return failed(relative, `name must be ${name}`);
58
+ if (!/^engine:\s*(?:claude_code|codex)\s*$/m.test(source)) return failed(relative, "engine must be claude_code or codex");
59
+ if (!/^model:\s*\S+\s*$/m.test(source)) return failed(relative, "model is missing");
60
+ if (!/^triggers:\s*$/m.test(source) || !/^\s{2}- type:\s*(?:manual|schedule|github)\s*$/m.test(source)) {
61
+ return failed(relative, "at least one supported trigger is required");
62
+ }
63
+ if (/^(?:permissions|sandbox|tools):/m.test(source)) return failed(relative, "per-agent access controls are not supported");
64
+ return passed(relative, "valid agent manifest");
388
65
  }
389
66
 
390
- function detectAnthropicAuthMode(manifest, dir) {
391
- const configured = manifest.auth?.provider === "anthropic" ? manifest.auth.mode : undefined;
392
- if (["api-key", "oauth", "wif", "bedrock", "vertex"].includes(configured)) return configured;
393
-
394
- const workflowPath = join(dir, ".github/workflows/facility-crew.yml");
395
- if (!existsSync(workflowPath)) return undefined;
396
- const workflow = readFileSync(workflowPath, "utf8");
397
- if (workflow.includes("anthropic_federation_rule_id:")) return "wif";
398
- if (workflow.includes("use_bedrock:")) return "bedrock";
399
- if (workflow.includes("use_vertex:")) return "vertex";
400
- if (workflow.includes("anthropic_api_key:")) return "api-key";
401
- if (workflow.includes("claude_code_oauth_token:")) return "oauth";
402
- return undefined;
67
+ function passed(label, detail) {
68
+ return { label, ok: true, detail };
403
69
  }
404
70
 
405
- function authRequirements(mode) {
406
- if (mode === "api-key") {
407
- return {
408
- secrets: ["ANTHROPIC_API_KEY"],
409
- variables: [],
410
- remediation: "store a dedicated, spend-capped test key",
411
- };
412
- }
413
- if (mode === "oauth") {
414
- return {
415
- secrets: ["CLAUDE_CODE_OAUTH_TOKEN"],
416
- variables: [],
417
- remediation: "run `claude setup-token`, then store the token",
418
- };
419
- }
420
- if (mode === "wif") {
421
- return {
422
- secrets: [],
423
- variables: ["ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID"],
424
- remediation: "configure Anthropic Workload Identity Federation",
425
- };
426
- }
427
- if (mode === "bedrock") {
428
- return {
429
- secrets: ["AWS_ROLE_TO_ASSUME"],
430
- variables: ["AWS_REGION"],
431
- remediation: "configure the AWS GitHub OIDC role and Bedrock region",
432
- };
433
- }
434
- if (mode === "vertex") {
435
- return {
436
- secrets: [],
437
- variables: ["GCP_WORKLOAD_IDENTITY_PROVIDER", "GCP_SERVICE_ACCOUNT"],
438
- remediation: "configure Google Workload Identity Federation for Vertex AI",
439
- };
440
- }
441
- return { secrets: [], variables: [], remediation: "select a supported auth mode" };
442
- }
443
-
444
- function namesFromGhList(output) {
445
- return new Set(
446
- output
447
- .split("\n")
448
- .map((line) => line.trim().split(/\s+/)[0])
449
- .filter(Boolean),
450
- );
451
- }
452
-
453
- function platformTarget(flags, options) {
454
- if (!flags.platform && !flags.profile && !flags.url && !flags.key) return null;
455
- if (flags.url || flags.key) {
456
- if (!flags.url || !flags.key) return null;
457
- return { url: stripSlash(flags.url), key: flags.key, profileName: flags.profile || "adhoc" };
458
- }
459
- const configPath = options.configPath || options.env?.FACILITY_CONFIG || process.env.FACILITY_CONFIG;
460
- const config = options.config || loadConfig(configPath);
461
- const { name, value } = getProfile(config, flags.profile);
462
- if (!value?.url || !value?.key) return null;
463
- return {
464
- url: stripSlash(value.url),
465
- key: value.key,
466
- profileName: name,
467
- allowInsecure: value.allowInsecure === true,
468
- };
469
- }
470
-
471
- async function platformDoctor(flags, version, target, options) {
472
- const stdout = options.stdout || process.stdout;
473
- const fetchImpl = options.fetch || fetch;
474
- const write = (line = "") => stdout.write(`${line}\n`);
475
- if (!flags.json) {
476
- write("");
477
- write(` ${bold("facility")} ${dim(`v${version}`)} ${dim("— deployment readiness doctor")}`);
478
- write("");
479
- write(` ${bold("Profile")} ${target.profileName}`);
480
- write(` ${bold("API")} ${target.url}`);
481
- write("");
482
- }
483
- try {
484
- assertSafePlatformUrl(target.url, flags, target.allowInsecure);
485
- const payload = await requestDoctor(fetchImpl, target, doctorTimeoutMs(flags.timeout));
486
- if (flags.json) {
487
- write(
488
- JSON.stringify({
489
- mode: "platform",
490
- target: { profile: target.profileName, url: target.url },
491
- ...payload,
492
- }),
493
- );
494
- return payload.ok ? 0 : 1;
495
- }
496
- write(bold("Readiness"));
497
- for (const check of payload.checks || []) {
498
- const marker =
499
- check.status === "pass" ? green("✓") : check.status === "warn" ? yellow("!") : red("✗");
500
- write(` ${marker} ${check.label}`);
501
- write(` ${dim(check.message)}`);
502
- if (check.remediation) write(` ${bold("Fix:")} ${check.remediation}`);
503
- }
504
- write("");
505
- write(payload.ok ? "Ready for production traffic." : "Not ready for production traffic.");
506
- write("");
507
- return payload.ok ? 0 : 1;
508
- } catch (error) {
509
- if (flags.json) {
510
- write(JSON.stringify({
511
- mode: "platform",
512
- target: { profile: target.profileName, url: target.url },
513
- ok: false,
514
- checks: [],
515
- error: {
516
- code: error.status === 401 ? "unauthorized" : error.code || "doctor_failed",
517
- message: error.message || "facility doctor failed",
518
- ...(error.status ? { status: error.status } : {}),
519
- },
520
- }));
521
- } else write(error.message || "facility doctor failed");
522
- return error.status === 401 ? 2 : 1;
523
- }
524
- }
525
-
526
- function assertSafePlatformUrl(value, flags, profileAllowsInsecure = false) {
527
- let parsed;
528
- try {
529
- parsed = new URL(value);
530
- } catch {
531
- throw new Error("Facility API URL must be a valid absolute URL");
532
- }
533
- const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
534
- if (
535
- parsed.protocol !== "https:" &&
536
- !local &&
537
- !flags["allow-insecure"] &&
538
- !profileAllowsInsecure
539
- ) {
540
- const error = new Error(
541
- "Refusing to send an API key over plain HTTP. Use HTTPS or pass --allow-insecure for a trusted development endpoint.",
542
- );
543
- error.code = "insecure_api_url";
544
- throw error;
545
- }
546
- }
547
-
548
- async function requestDoctor(fetchImpl, target, timeoutMs) {
549
- const response = await fetchImpl(new URL(`${target.url}/v1/admin/doctor`), {
550
- method: "GET",
551
- headers: { authorization: `Bearer ${target.key}` },
552
- signal: AbortSignal.timeout(timeoutMs),
553
- });
554
- const payload = await response.json().catch(() => undefined);
555
- if (!payload || typeof payload !== "object") {
556
- throw new Error("Facility API returned an invalid JSON response");
557
- }
558
- if (!response.ok) {
559
- const error = new Error(payload?.error?.message || `Facility API returned ${response.status}`);
560
- error.status = response.status;
561
- throw error;
562
- }
563
- if (typeof payload.ok !== "boolean" || !Array.isArray(payload.checks)) {
564
- throw new Error("Facility API returned an invalid doctor response");
565
- }
566
- return payload;
567
- }
568
-
569
- function doctorTimeoutMs(value) {
570
- if (value === undefined) return 30_000;
571
- const seconds = Number(value);
572
- if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 300) {
573
- const error = new Error("--timeout must be greater than 0 and at most 300 seconds");
574
- error.code = "invalid_flag";
575
- throw error;
576
- }
577
- return Math.round(seconds * 1_000);
71
+ function failed(label, detail) {
72
+ return { label, ok: false, detail };
578
73
  }
579
74
 
580
- function stripSlash(value) {
581
- return String(value).replace(/\/$/, "");
75
+ function escapeRegExp(value) {
76
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
582
77
  }