@theagilemonkeys/facility 0.3.1
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/LICENSE +201 -0
- package/README.md +68 -0
- package/bin/facility.mjs +10 -0
- package/modules/README.md +35 -0
- package/modules/ai-queryability/agents/queryability-reviewer.md +35 -0
- package/modules/ai-queryability/module.json +9 -0
- package/modules/ai-queryability/standard-section.md +22 -0
- package/modules/analytics/agents/analytics-reviewer.md +32 -0
- package/modules/analytics/commands/add-telemetry.md +23 -0
- package/modules/analytics/module.json +10 -0
- package/modules/analytics/standard-section.md +23 -0
- package/modules/database/agents/data-security-reviewer.md +38 -0
- package/modules/database/commands/new-migration.md +24 -0
- package/modules/database/guards/migration-versions.mjs +41 -0
- package/modules/database/guards/migrations-immutable.mjs +57 -0
- package/modules/database/hooks/protect-migrations.fragment.mjs +10 -0
- package/modules/database/module.json +25 -0
- package/modules/database/standard-section.md +20 -0
- package/modules/design-system/agents/design-reviewer.md +37 -0
- package/modules/design-system/module.json +9 -0
- package/modules/design-system/standard-section.md +15 -0
- package/package.json +42 -0
- package/src/add.mjs +77 -0
- package/src/cli.mjs +352 -0
- package/src/detect.mjs +127 -0
- package/src/doctor.mjs +582 -0
- package/src/init.mjs +572 -0
- package/src/instance.mjs +114 -0
- package/src/platform-admin.mjs +1542 -0
- package/src/platform-config.mjs +39 -0
- package/src/platform.mjs +1759 -0
- package/src/prompts.mjs +64 -0
- package/src/render.mjs +66 -0
- package/src/ui.mjs +30 -0
- package/templates/claude/agents/security-reviewer.md +41 -0
- package/templates/claude/agents/standards-reviewer.md +31 -0
- package/templates/claude/commands/open-pr.md +21 -0
- package/templates/claude/commands/verify.md +16 -0
- package/templates/claude/hooks/protect-branch.mjs +58 -0
- package/templates/claude/hooks/protect-files.mjs +35 -0
- package/templates/claude/settings.json +71 -0
- package/templates/claude/skills/maintainable-software/SKILL.md +67 -0
- package/templates/claude/skills/reviewing-to-standard/SKILL.md +49 -0
- package/templates/claude/skills/working-to-standard/SKILL.md +45 -0
- package/templates/delivery/verify.mjs +157 -0
- package/templates/doctor/resolve.mjs +144 -0
- package/templates/guards/README.md +30 -0
- package/templates/guards/_kit.mjs +81 -0
- package/templates/guards/actions-pinned.mjs +38 -0
- package/templates/guards/run.mjs +111 -0
- package/templates/guards/watchtower-locked.mjs +66 -0
- package/templates/prompts/address-review.md +14 -0
- package/templates/prompts/architect.md +62 -0
- package/templates/prompts/builder.md +71 -0
- package/templates/prompts/doctor.md +64 -0
- package/templates/prompts/review.md +14 -0
- package/templates/prompts/sweep.md +75 -0
- package/templates/receipts/collect.mjs +289 -0
- package/templates/review/finalize.mjs +38 -0
- package/templates/scripts/move-board-status.sh +155 -0
- package/templates/security/sync-findings.mjs +226 -0
- package/templates/standard/STANDARD.md +141 -0
- package/templates/standard/agents-block.md +25 -0
- package/templates/watchtower/budgets.json +12 -0
- package/templates/watchtower/canary.mjs +216 -0
- package/templates/watchtower/health.mjs +148 -0
- package/templates/watchtower/outcomes.mjs +188 -0
- package/templates/workflows/facility-address-review.yml +153 -0
- package/templates/workflows/facility-canary.yml +61 -0
- package/templates/workflows/facility-codex.yml +326 -0
- package/templates/workflows/facility-crew.yml +350 -0
- package/templates/workflows/facility-doctor.yml +155 -0
- package/templates/workflows/facility-review.yml +134 -0
- package/templates/workflows/facility-security-sweep.yml +204 -0
- package/templates/workflows/facility-watchtower.yml +87 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
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
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
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",
|
|
41
|
+
];
|
|
42
|
+
|
|
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
|
+
|
|
63
|
+
const dir = flags.dir || process.cwd();
|
|
64
|
+
const report = inspectLocalInstall(dir, {
|
|
65
|
+
runGuards: flags["run-guards"] === true,
|
|
66
|
+
github: flags.github === true,
|
|
67
|
+
});
|
|
68
|
+
if (flags.json) {
|
|
69
|
+
(options.stdout || process.stdout).write(`${JSON.stringify(report)}\n`);
|
|
70
|
+
return report.ok ? 0 : 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
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
|
+
}
|
|
243
|
+
}
|
|
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 };
|
|
311
|
+
}
|
|
312
|
+
|
|
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";
|
|
319
|
+
}
|
|
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
|
+
);
|
|
338
|
+
}
|
|
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
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
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}`));
|
|
388
|
+
}
|
|
389
|
+
|
|
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;
|
|
403
|
+
}
|
|
404
|
+
|
|
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);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function stripSlash(value) {
|
|
581
|
+
return String(value).replace(/\/$/, "");
|
|
582
|
+
}
|