forgedock 1.0.5 → 1.0.7

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 (42) hide show
  1. package/README.md +7 -1
  2. package/bin/forgedock.mjs +496 -364
  3. package/bin/labels.json +152 -0
  4. package/commands/audit-agents.md +4 -2
  5. package/commands/autopilot.md +19 -11
  6. package/commands/cleanup.md +27 -12
  7. package/commands/deploy-info.md +36 -17
  8. package/commands/incident-response.md +15 -10
  9. package/commands/issue.md +8 -6
  10. package/commands/milestone.md +97 -48
  11. package/commands/orchestrate.md +109 -65
  12. package/commands/pipeline-health.md +8 -1
  13. package/commands/qa-sweep.md +14 -3
  14. package/commands/review-pr-agents.md +1 -1
  15. package/commands/review-pr-staging.md +129 -23
  16. package/commands/review-pr.md +237 -90
  17. package/commands/rollback.md +26 -7
  18. package/commands/security-audit.md +27 -25
  19. package/commands/sync-ecosystem.md +2 -2
  20. package/commands/work-on/build/architect.md +154 -2
  21. package/commands/work-on/build/context.md +131 -3
  22. package/commands/work-on/build/validate.md +32 -13
  23. package/commands/work-on/decompose.md +2 -0
  24. package/commands/work-on-monolithic.md +9 -5
  25. package/commands/work-on.md +67 -23
  26. package/docs/CONFIG.md +117 -0
  27. package/package.json +2 -1
  28. package/scripts/verify-route-registration.sh +36 -15
  29. package/scripts/verify-sops-chain.sh +18 -0
  30. package/templates/devdocs/README.md +96 -0
  31. package/templates/devdocs/agent/using-forgedock.md +245 -0
  32. package/templates/devdocs/agent/using-github.md +325 -0
  33. package/templates/devdocs/project/architecture.md +122 -0
  34. package/templates/devdocs/project/conventions.md +158 -0
  35. package/templates/devdocs/project/custom-instructions.md +73 -0
  36. package/templates/devdocs/project/glossary.md +84 -0
  37. package/templates/devdocs/project/stack.md +80 -0
  38. package/templates/devdocs/templates/commit-convention.md +147 -0
  39. package/templates/devdocs/templates/issue.md +141 -0
  40. package/templates/devdocs/templates/pull-request.md +115 -0
  41. package/templates/devdocs/templates/review-finding.md +147 -0
  42. package/bin/github-app-manifest.json +0 -24
package/bin/forgedock.mjs CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  import os from "os";
4
4
  import { fileURLToPath } from "url";
5
- import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
5
+ import {
6
+ basename,
7
+ dirname,
8
+ isAbsolute,
9
+ join,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from "path";
6
14
  import {
7
15
  mkdir,
8
16
  symlink,
@@ -10,6 +18,7 @@ import {
10
18
  readlink,
11
19
  lstat,
12
20
  readdir,
21
+ realpath,
13
22
  stat,
14
23
  writeFile,
15
24
  unlink as fsUnlink,
@@ -17,14 +26,12 @@ import {
17
26
  import {
18
27
  existsSync,
19
28
  appendFileSync,
20
- chmodSync,
21
29
  readFileSync,
22
30
  writeFileSync,
23
31
  renameSync,
24
32
  unlinkSync,
25
33
  } from "fs";
26
34
  import { execSync, execFileSync } from "child_process";
27
- import { createSign } from "crypto";
28
35
  import {
29
36
  BOLD,
30
37
  GREEN,
@@ -1221,15 +1228,8 @@ function queryNpmRegistry(pkg) {
1221
1228
 
1222
1229
  /**
1223
1230
  * Path to the update-check cache file.
1224
- * Stored in ~/.forgedock/ alongside credentials.json.
1225
- * Populated lazily — FORGEDOCK_HOME is defined later in the file, but this
1226
- * constant is only resolved at module evaluation time so the ordering is fine.
1231
+ * Stored in ~/.forgedock/update-check.json.
1227
1232
  */
1228
- // NOTE: FORGEDOCK_HOME is declared later in the file (line ~2974). This const
1229
- // is a forward reference that is only evaluated when the module finishes loading,
1230
- // which is after FORGEDOCK_HOME is defined. Node ESM top-level await ensures
1231
- // the module is fully evaluated before any exported binding is read.
1232
- // We defer the join() call to avoid the forward-reference issue:
1233
1233
  function _getUpdateCheckCachePath() {
1234
1234
  return join(HOME, ".forgedock", "update-check.json");
1235
1235
  }
@@ -1862,6 +1862,8 @@ async function init() {
1862
1862
  });
1863
1863
  console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1864
1864
  console.log("");
1865
+ await injectClaudeMd(cwd);
1866
+ console.log("");
1865
1867
  _printNextSteps({ remoteDetected });
1866
1868
  await validate(outputPath);
1867
1869
  return;
@@ -2160,12 +2162,216 @@ async function init() {
2160
2162
  );
2161
2163
  }
2162
2164
  console.log("");
2165
+ await injectClaudeMd(cwd);
2166
+ console.log("");
2163
2167
  _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2164
2168
  await validate(outputPath);
2165
2169
  }
2166
2170
  }
2167
2171
  }
2168
2172
 
2173
+ // ---------------------------------------------------------------------------
2174
+ // CLAUDE.md Integration — injectClaudeMd()
2175
+ // ---------------------------------------------------------------------------
2176
+
2177
+ const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
2178
+ const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
2179
+
2180
+ /**
2181
+ * Extract the `description:` value from a command file's YAML frontmatter.
2182
+ * Returns null if no frontmatter or no description is found.
2183
+ *
2184
+ * @param {string} content - Full file content
2185
+ * @returns {string|null}
2186
+ */
2187
+ function _extractFrontmatterDescription(content) {
2188
+ // Match YAML frontmatter block: starts with ---, ends with ---
2189
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2190
+ if (!fmMatch) return null;
2191
+ const fm = fmMatch[1];
2192
+ // Extract description: value (may span the rest of the line)
2193
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
2194
+ if (!descMatch) return null;
2195
+ return descMatch[1].trim();
2196
+ }
2197
+
2198
+ /**
2199
+ * Build the managed ForgeDock block content (between markers, not including them).
2200
+ * Reads all commands/*.md files, extracts frontmatter descriptions, and generates
2201
+ * a concise command index.
2202
+ *
2203
+ * @returns {Promise<string>} Block content
2204
+ */
2205
+ async function _buildForgeDockBlock() {
2206
+ const files = await findMarkdownFiles(COMMANDS_DIR);
2207
+
2208
+ /** @type {Array<{name: string, description: string}>} */
2209
+ const commands = [];
2210
+
2211
+ for (const file of files) {
2212
+ let content = "";
2213
+ try {
2214
+ content = readFileSync(file, "utf-8");
2215
+ } catch {
2216
+ continue; // skip unreadable files
2217
+ }
2218
+ const description = _extractFrontmatterDescription(content);
2219
+ if (!description) continue;
2220
+
2221
+ // Derive display name: relative path from COMMANDS_DIR, remove .md extension
2222
+ const rel = relative(COMMANDS_DIR, file)
2223
+ .replace(/\\/g, "/")
2224
+ .replace(/\.md$/, "");
2225
+ commands.push({ name: rel, description });
2226
+ }
2227
+
2228
+ // Sort: top-level commands first, then sub-commands alphabetically
2229
+ commands.sort((a, b) => {
2230
+ const aDepth = a.name.split("/").length;
2231
+ const bDepth = b.name.split("/").length;
2232
+ if (aDepth !== bDepth) return aDepth - bDepth;
2233
+ return a.name.localeCompare(b.name);
2234
+ });
2235
+
2236
+ const indexLines = commands
2237
+ .map(({ name, description }) => `- \`/${name}\` — ${description}`)
2238
+ .join("\n");
2239
+
2240
+ return `## ForgeDock — Autonomous Development Pipeline
2241
+
2242
+ This project is driven by **ForgeDock**. GitHub issues are the knowledge graph.
2243
+ Core loop: **issue in → PR out** (PRs target \`staging\` fast-lane or \`milestone/{slug}\`).
2244
+
2245
+ ### When to Use What
2246
+
2247
+ - \`/issue\` — file a pipeline-ready issue
2248
+ - \`/work-on N\` — full investigate→build→review→merge for issue N
2249
+ - \`/milestone\` + \`/orchestrate\` — plan and parallelize milestone work
2250
+ - \`/review-pr\` — review a PR with domain agents
2251
+ - \`/quality-gate\` — pre-commit quality check
2252
+ - \`/validate\` — validate forge.yaml configuration
2253
+
2254
+ ### Command Index
2255
+
2256
+ ${indexLines}
2257
+
2258
+ ### Conventions
2259
+
2260
+ - Fast-lane PRs target \`staging\`; milestone PRs target \`milestone/{slug}\`
2261
+ - Review findings become separate issues (not merge blockers)
2262
+ - \`forge.yaml\` is the project config — see \`docs/CONFIG.md\`
2263
+ - Re-generate this block: \`npx forgedock integrate\``;
2264
+ }
2265
+
2266
+ /**
2267
+ * Idempotently inject (or update) a managed ForgeDock usage block into a file.
2268
+ * The block is bounded by BEGIN FORGEDOCK / END FORGEDOCK HTML comment markers.
2269
+ * Content outside the markers is never modified.
2270
+ *
2271
+ * @param {string} filePath - Absolute path to the target file (CLAUDE.md or AGENTS.md)
2272
+ * @param {string} blockContent - Content to place between the markers
2273
+ * @param {boolean} createIfMissing - Create the file if it does not exist
2274
+ * @returns {'created'|'updated'|'skipped'} Result status
2275
+ */
2276
+ function _injectManagedBlock(filePath, blockContent, createIfMissing) {
2277
+ const managed = `${CLAUDE_BLOCK_BEGIN}\n${blockContent}\n${CLAUDE_BLOCK_END}`;
2278
+
2279
+ if (!existsSync(filePath)) {
2280
+ if (!createIfMissing) return "skipped";
2281
+ writeFileSync(filePath, `${managed}\n`, "utf-8");
2282
+ return "created";
2283
+ }
2284
+
2285
+ let existing = readFileSync(filePath, "utf-8");
2286
+
2287
+ if (
2288
+ existing.includes(CLAUDE_BLOCK_BEGIN) &&
2289
+ existing.includes(CLAUDE_BLOCK_END)
2290
+ ) {
2291
+ // Both markers present — replace existing block in place.
2292
+ // Guard: if markers are reversed (END before BEGIN) the regex finds no
2293
+ // forward-spanning match; fall through to the repair path instead of
2294
+ // writing the file unchanged and returning a false 'updated'. <!-- Added: forge#291 -->
2295
+ const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(
2296
+ /[.*+?^${}()|[\]\\]/g,
2297
+ "\\$&",
2298
+ );
2299
+ const escaped_end = CLAUDE_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2300
+ const blockRegex = new RegExp(
2301
+ `${escaped_begin}[\\s\\S]*?${escaped_end}`,
2302
+ "g",
2303
+ );
2304
+ const replaced = existing.replace(blockRegex, managed);
2305
+ if (replaced !== existing) {
2306
+ writeFileSync(filePath, replaced, "utf-8");
2307
+ return "updated";
2308
+ }
2309
+ // Regex found no match (e.g. reversed markers) — strip both orphaned
2310
+ // markers below and fall through to the append path to repair the file.
2311
+ existing = existing
2312
+ .replace(CLAUDE_BLOCK_BEGIN, "")
2313
+ .replace(CLAUDE_BLOCK_END, "")
2314
+ .trimEnd();
2315
+ } else if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2316
+ // BEGIN present but END missing — file is truncated or malformed.
2317
+ // Strip the orphaned marker and everything after it, then fall through
2318
+ // to the append path below so the complete block is written correctly.
2319
+ existing = existing
2320
+ .slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN))
2321
+ .trimEnd();
2322
+ } else if (existing.includes(CLAUDE_BLOCK_END)) {
2323
+ // END present but BEGIN missing — orphaned END marker left by a previous
2324
+ // failed write. Strip it and fall through to append the complete block.
2325
+ // <!-- Added: forge#291 -->
2326
+ existing = existing.replace(CLAUDE_BLOCK_END, "").trimEnd();
2327
+ }
2328
+
2329
+ // Append block to existing content, separated by a blank line
2330
+ const separator =
2331
+ existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2332
+ writeFileSync(filePath, `${existing}${separator}${managed}\n`, "utf-8");
2333
+ return "updated";
2334
+ }
2335
+
2336
+ /**
2337
+ * Inject or update the managed ForgeDock block into the project CLAUDE.md.
2338
+ * Also mirrors the block into AGENTS.md if that file already exists.
2339
+ * Creates CLAUDE.md if absent; never creates AGENTS.md.
2340
+ *
2341
+ * @param {string} cwd - Project root directory
2342
+ * @returns {Promise<void>}
2343
+ */
2344
+ async function injectClaudeMd(cwd) {
2345
+ const claudePath = join(cwd, "CLAUDE.md");
2346
+ const agentsPath = join(cwd, "AGENTS.md");
2347
+
2348
+ let blockContent;
2349
+ try {
2350
+ blockContent = await _buildForgeDockBlock();
2351
+ } catch (err) {
2352
+ console.log(
2353
+ ` ${YELLOW}!${RESET} Could not generate command index — skipping CLAUDE.md update (${err.message})`,
2354
+ );
2355
+ return;
2356
+ }
2357
+
2358
+ const claudeResult = _injectManagedBlock(claudePath, blockContent, true);
2359
+ if (claudeResult === "created") {
2360
+ console.log(
2361
+ ` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`,
2362
+ );
2363
+ } else {
2364
+ console.log(
2365
+ ` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`,
2366
+ );
2367
+ }
2368
+
2369
+ if (existsSync(agentsPath)) {
2370
+ _injectManagedBlock(agentsPath, blockContent, false);
2371
+ console.log(` ${GREEN}✔${RESET} AGENTS.md mirrored`);
2372
+ }
2373
+ }
2374
+
2169
2375
  // ---------------------------------------------------------------------------
2170
2376
  // Helpers for init()
2171
2377
  // ---------------------------------------------------------------------------
@@ -2731,21 +2937,6 @@ ${reviewSection}
2731
2937
  # =============================================================================
2732
2938
 
2733
2939
  ${verificationSection}
2734
-
2735
- # =============================================================================
2736
- # BOT (OPTIONAL)
2737
- # GitHub App bot identity for pipeline operations.
2738
- # Credentials are stored separately in ~/.forgedock/credentials.json — not here.
2739
- # Run: npx forgedock bot setup to configure interactively.
2740
- # Run: npx forgedock bot status to verify.
2741
- # =============================================================================
2742
-
2743
- # bot:
2744
- # app_id: 12345
2745
- # installation_id: 67890
2746
- # private_key_path: "~/.forgedock/forgedock-bot.private-key.pem"
2747
- # # When configured, pipeline commands use the app token instead of personal token.
2748
- # # Bot-authored comments include <!-- forgedock-bot --> for audit filtering.
2749
2940
  `;
2750
2941
  }
2751
2942
 
@@ -3210,388 +3401,303 @@ function _renderValidationSummary(checks) {
3210
3401
  }
3211
3402
 
3212
3403
  // ---------------------------------------------------------------------------
3213
- // Bot credentials — store in ~/.forgedock/credentials.json (NOT forge.yaml)
3404
+ // Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
3214
3405
  // ---------------------------------------------------------------------------
3215
3406
 
3216
- const FORGEDOCK_HOME = join(HOME, ".forgedock");
3217
- const CREDENTIALS_FILE = join(FORGEDOCK_HOME, "credentials.json");
3218
-
3219
3407
  /**
3220
- * Save bot credentials to ~/.forgedock/credentials.json.
3221
- * The private key itself is NOT stored — only the path.
3408
+ * Resolve the target repo for the labels command.
3222
3409
  *
3223
- * @param {{ appId: string, installationId: string, privateKeyPath: string }} creds
3224
- */
3225
- async function saveBotCredentials(creds) {
3226
- await mkdir(FORGEDOCK_HOME, { recursive: true, mode: 0o700 });
3227
- // Tighten permissions on pre-existing installs: fs.mkdir({ recursive: true }) ignores
3228
- // mode when the directory already exists (Linux kernel behaviour). chmodSync enforces
3229
- // 0o700 unconditionally, matching the intent of the mode option above.
3230
- chmodSync(FORGEDOCK_HOME, 0o700);
3231
- const existing = loadBotCredentials() ?? {};
3232
- const updated = { ...existing, bot: creds };
3233
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2) + "\n", {
3234
- encoding: "utf-8",
3235
- mode: 0o600,
3236
- });
3237
- // Tighten permissions on files created before this fix (mode is only applied at creation time)
3238
- chmodSync(CREDENTIALS_FILE, 0o600);
3239
- }
3240
-
3241
- /**
3242
- * Load bot credentials from ~/.forgedock/credentials.json.
3243
- * Returns null if the file is absent or malformed.
3410
+ * Priority order:
3411
+ * 1. --repo <owner/repo> passed on the CLI
3412
+ * 2. forge.yaml → project.owner + project.repo in the current working directory
3413
+ * 3. Returns null if neither is available (caller prints an error)
3244
3414
  *
3245
- * @returns {{ appId: string, installationId: string, privateKeyPath: string } | null}
3415
+ * @param {string[]} subArgs - CLI args after "labels [setup]"
3416
+ * @returns {string|null} "owner/repo" string or null
3246
3417
  */
3247
- function loadBotCredentials() {
3248
- try {
3249
- const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
3250
- const parsed = JSON.parse(raw);
3251
- return parsed.bot ?? null;
3252
- } catch {
3253
- return null;
3418
+ function resolveLabelsRepo(subArgs) {
3419
+ // 1. Explicit --repo flag
3420
+ const repoFlagIdx = subArgs.indexOf("--repo");
3421
+ if (repoFlagIdx !== -1 && subArgs[repoFlagIdx + 1]) {
3422
+ return subArgs[repoFlagIdx + 1];
3254
3423
  }
3255
- }
3256
3424
 
3257
- // ---------------------------------------------------------------------------
3258
- // GitHub App JWT — generates a short-lived RS256 JWT for App-level API calls
3259
- // ---------------------------------------------------------------------------
3425
+ // 2. forge.yaml in cwd
3426
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
3427
+ if (existsSync(forgeYamlPath)) {
3428
+ try {
3429
+ const raw = readFileSync(forgeYamlPath, "utf-8");
3430
+ const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
3431
+ const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
3432
+ if (ownerMatch && repoMatch) {
3433
+ return `${ownerMatch[1]}/${repoMatch[1]}`;
3434
+ }
3435
+ } catch {
3436
+ // fall through
3437
+ }
3438
+ }
3439
+
3440
+ return null;
3441
+ }
3260
3442
 
3261
3443
  /**
3262
- * Generate a GitHub App JWT (valid for 10 minutes).
3444
+ * Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
3445
+ *
3446
+ * Reads the canonical manifest from bin/labels.json (co-located with this
3447
+ * script). For each label entry, calls:
3448
+ *
3449
+ * gh label create <name> --color <hex> --description <desc> --force --repo <repo>
3263
3450
  *
3264
- * @param {string} appId - GitHub App ID (numeric string)
3265
- * @param {string} privateKey - PEM-encoded PKCS#8 or PKCS#1 RSA private key content
3266
- * @returns {string} Signed JWT
3451
+ * --force makes the call idempotent: it creates the label if absent, or updates
3452
+ * its color and description if it already exists. Safe to re-run at any time.
3453
+ *
3454
+ * @param {string} repo - "owner/repo" string
3455
+ * @returns {{ created: number, failed: string[] }}
3267
3456
  */
3268
- function generateAppJwt(appId, privateKey) {
3269
- const now = Math.floor(Date.now() / 1000);
3270
- const payload = {
3271
- iat: now - 60, // issued 60s ago to account for clock skew
3272
- exp: now + 600, // expires in 10 minutes (GitHub max)
3273
- iss: appId,
3274
- };
3275
-
3276
- const header = Buffer.from(
3277
- JSON.stringify({ alg: "RS256", typ: "JWT" }),
3278
- ).toString("base64url");
3279
- const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
3280
- const unsigned = `${header}.${body}`;
3281
-
3282
- const sign = createSign("RSA-SHA256");
3283
- sign.update(unsigned);
3284
- const signature = sign.sign(privateKey, "base64url");
3457
+ async function labelsSetup(repo) {
3458
+ const manifestPath = join(__dirname, "labels.json");
3285
3459
 
3286
- return `${unsigned}.${signature}`;
3287
- }
3460
+ if (!existsSync(manifestPath)) {
3461
+ console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
3462
+ process.exit(1);
3463
+ }
3288
3464
 
3289
- // ---------------------------------------------------------------------------
3290
- // Bot — connect existing GitHub App
3291
- // ---------------------------------------------------------------------------
3465
+ /** @type {Array<{name: string, color: string, description: string}>} */
3466
+ let labels;
3467
+ try {
3468
+ labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
3469
+ } catch (err) {
3470
+ console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
3471
+ process.exit(1);
3472
+ }
3292
3473
 
3293
- /**
3294
- * Interactive flow: prompt for App ID, private key path, installation ID.
3295
- * Validates credentials against the GitHub API before saving.
3296
- */
3297
- async function connectExistingBot() {
3298
3474
  console.log("");
3299
- console.log(
3300
- box(
3301
- [
3302
- `${bold("Connect an existing GitHub App")}`,
3303
- "",
3304
- "You'll need:",
3305
- ` • App ID (GitHub → Settings → Developer settings → GitHub Apps → your app)`,
3306
- ` • Private key file (.pem downloaded from app settings)`,
3307
- ` • Installation ID (GitHub → your org → Settings → Installed Apps → your app → configure → URL)`,
3308
- ].join("\n"),
3309
- { title: "Connect GitHub App" },
3310
- ),
3311
- );
3475
+ console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
3476
+ console.log(` Repository: ${cyan(repo)}`);
3477
+ console.log(` Labels: ${labels.length} managed labels`);
3312
3478
  console.log("");
3313
3479
 
3314
- const appId = await input("App ID (numeric):", "");
3315
- if (!appId || !/^\d+$/.test(appId.trim())) {
3316
- console.log(` ${RED}Invalid App ID — must be a numeric string.${RESET}`);
3317
- return false;
3318
- }
3319
-
3320
- const privateKeyPath = await input("Private key path (.pem):", "");
3321
- const trimmedKeyPath = privateKeyPath.trim();
3322
- const home = os.homedir();
3323
- const expanded = trimmedKeyPath.startsWith("~")
3324
- ? join(home, trimmedKeyPath.slice(1))
3325
- : trimmedKeyPath;
3326
- const resolvedKeyPath = resolve(expanded);
3327
- if (trimmedKeyPath.startsWith("~")) {
3328
- const rel = relative(home, resolvedKeyPath);
3329
- if (rel.startsWith("..") || isAbsolute(rel)) {
3330
- console.log(
3331
- ` ${RED}Invalid path: cannot traverse outside home directory.${RESET}`,
3480
+ const bar = createProgressBar(labels.length, {
3481
+ label: " Bootstrapping labels...",
3482
+ });
3483
+
3484
+ let created = 0;
3485
+ const failed = [];
3486
+
3487
+ for (const { name, color, description } of labels) {
3488
+ bar.tick(1, dim(name));
3489
+ try {
3490
+ execFileSync(
3491
+ "gh",
3492
+ [
3493
+ "label",
3494
+ "create",
3495
+ name,
3496
+ "--color",
3497
+ color,
3498
+ "--description",
3499
+ description,
3500
+ "--force",
3501
+ "--repo",
3502
+ repo,
3503
+ ],
3504
+ { stdio: ["pipe", "pipe", "pipe"] },
3332
3505
  );
3333
- return false;
3506
+ created++;
3507
+ } catch (err) {
3508
+ failed.push(name);
3509
+ // Continue — don't abort the whole bootstrap for one failure
3334
3510
  }
3335
3511
  }
3336
- if (!existsSync(resolvedKeyPath)) {
3337
- console.log(` ${RED}File not found: ${resolvedKeyPath}${RESET}`);
3338
- return false;
3339
- }
3340
3512
 
3341
- const installationId = await input("Installation ID (numeric):", "");
3342
- if (!installationId || !/^\d+$/.test(installationId.trim())) {
3343
- console.log(
3344
- ` ${RED}Invalid Installation ID — must be a numeric string.${RESET}`,
3345
- );
3346
- return false;
3347
- }
3348
-
3349
- // Validate credentials with GitHub API
3350
- console.log("");
3351
- console.log(` Validating credentials…`);
3352
- let privateKey;
3353
- try {
3354
- privateKey = readFileSync(resolvedKeyPath, "utf-8");
3355
- } catch (err) {
3356
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3357
- return false;
3358
- }
3513
+ bar.done(
3514
+ failed.length === 0
3515
+ ? `${green("✔")} Bootstrapped ${created}/${labels.length} labels on ${cyan(repo)}`
3516
+ : `${yellow("⚠")} Bootstrapped ${created}/${labels.length} labels — ${failed.length} failed`,
3517
+ );
3359
3518
 
3360
- try {
3361
- const jwt = generateAppJwt(appId.trim(), privateKey);
3362
- const resp = await fetch("https://api.github.com/app", {
3363
- headers: {
3364
- Authorization: `Bearer ${jwt}`,
3365
- Accept: "application/vnd.github+json",
3366
- "X-GitHub-Api-Version": "2022-11-28",
3367
- },
3368
- });
3369
- if (!resp.ok) {
3370
- const body = await resp.json().catch(() => ({}));
3371
- console.log(
3372
- ` ${RED}GitHub API error ${resp.status}: ${body.message ?? "unknown"}${RESET}`,
3373
- );
3374
- return false;
3519
+ if (failed.length > 0) {
3520
+ console.log("");
3521
+ console.log(` ${RED}Failed labels:${RESET}`);
3522
+ for (const name of failed) {
3523
+ console.log(` ${dim("•")} ${name}`);
3375
3524
  }
3376
- const appData = await resp.json();
3377
3525
  console.log(
3378
- ` ${GREEN}Connected!${RESET} App: ${bold(appData.name)} (id: ${appId.trim()})`,
3526
+ `\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
3379
3527
  );
3380
- } catch (err) {
3381
- console.log(` ${RED}Validation failed: ${err.message}${RESET}`);
3382
- return false;
3383
3528
  }
3384
3529
 
3385
- await saveBotCredentials({
3386
- appId: appId.trim(),
3387
- installationId: installationId.trim(),
3388
- privateKeyPath: resolvedKeyPath,
3389
- });
3390
-
3391
- console.log(` Credentials saved to ${cyan(CREDENTIALS_FILE)}`);
3392
- return true;
3530
+ console.log("");
3531
+ return { created, failed };
3393
3532
  }
3394
3533
 
3395
3534
  // ---------------------------------------------------------------------------
3396
- // Bot — status command
3535
+ // docs init — scaffold devdocs knowledge tree into a project
3397
3536
  // ---------------------------------------------------------------------------
3398
3537
 
3399
3538
  /**
3400
- * Show bot health, identity, and rate limit usage.
3401
- * Gracefully handles missing credentials.
3539
+ * Walk a directory tree and return all file paths (any extension).
3540
+ * @param {string} dir
3541
+ * @returns {Promise<string[]>}
3402
3542
  */
3403
- async function botStatus() {
3404
- console.log("");
3405
- console.log(`${BOLD}ForgeDock Bot Status${RESET}`);
3406
- console.log("");
3407
-
3408
- const creds = loadBotCredentials();
3409
- if (!creds) {
3410
- console.log(
3411
- ` ${YELLOW}No bot credentials found.${RESET}\n` +
3412
- ` Run ${cyan("npx forgedock bot setup")} to configure a GitHub App, or\n` +
3413
- ` add credentials to ${cyan(CREDENTIALS_FILE)}.`,
3414
- );
3415
- console.log("");
3416
- return;
3543
+ async function _findAllFiles(dir) {
3544
+ const results = [];
3545
+ const entries = await readdir(dir, { withFileTypes: true });
3546
+ for (const entry of entries) {
3547
+ const full = join(dir, entry.name);
3548
+ if (entry.isDirectory()) {
3549
+ results.push(...(await _findAllFiles(full)));
3550
+ } else {
3551
+ results.push(full);
3552
+ }
3417
3553
  }
3554
+ return results.sort();
3555
+ }
3418
3556
 
3419
- console.log(` App ID: ${cyan(creds.appId)}`);
3420
- console.log(` Installation ID: ${cyan(creds.installationId)}`);
3421
- console.log(` Private key: ${cyan(creds.privateKeyPath)}`);
3422
- console.log("");
3423
-
3424
- // Check private key file
3425
- if (!existsSync(creds.privateKeyPath)) {
3426
- console.log(
3427
- ` ${RED}Private key file not found: ${creds.privateKeyPath}${RESET}`,
3428
- );
3429
- console.log(` Run ${cyan("npx forgedock bot setup")} to reconfigure.`);
3430
- console.log("");
3431
- return;
3432
- }
3557
+ async function docsInit() {
3558
+ const cwd = process.cwd();
3433
3559
 
3434
- console.log(" Verifying with GitHub API…");
3435
- let privateKey;
3436
- try {
3437
- privateKey = readFileSync(creds.privateKeyPath, "utf-8");
3438
- } catch (err) {
3439
- console.log(` ${RED}Cannot read private key: ${err.message}${RESET}`);
3440
- console.log("");
3441
- return;
3560
+ // Resolve target devdocs path: forge.yaml → devdocs.path, default "devdocs/"
3561
+ let devdocsRelPath = "devdocs";
3562
+ const forgeYamlPath = join(cwd, "forge.yaml");
3563
+ if (existsSync(forgeYamlPath)) {
3564
+ try {
3565
+ const raw = readFileSync(forgeYamlPath, "utf-8");
3566
+ const parsed = _parseYamlKey(raw, "devdocs.path");
3567
+ if (parsed && parsed.trim()) {
3568
+ devdocsRelPath = _sanitizePathValue(parsed.trim().replace(/\/$/, ""));
3569
+ }
3570
+ } catch {
3571
+ // forge.yaml unreadable — use default
3572
+ }
3442
3573
  }
3443
3574
 
3575
+ const targetDir = isAbsolute(devdocsRelPath)
3576
+ ? devdocsRelPath
3577
+ : join(cwd, devdocsRelPath);
3578
+
3579
+ // Security: assert targetDir is confined to the project directory.
3580
+ // Prevents path traversal via ../.. sequences, absolute paths, and symlinks in devdocs.path.
3581
+ //
3582
+ // Two hardening steps beyond the initial check:
3583
+ //
3584
+ // 1. realpath(cwd) — on Windows, process.cwd() may return a directory-junction path while
3585
+ // realpath() returns the canonical target. Comparing a canonical resolvedTarget against
3586
+ // a non-canonical cwd would false-reject valid paths (BUG-3).
3587
+ //
3588
+ // 2. Walk-up-to-existing-ancestor resolution on ENOENT — when the target path does not yet
3589
+ // exist, falling back to lexical resolve() fails to follow intermediate symlinks. A
3590
+ // symlink planted at "sub/" pointing outside the project would not be dereferenced, so
3591
+ // the lexical result would appear inside the project and the containment check would
3592
+ // pass, allowing mkdir() to write through the symlink (SEC-1). Calling realpath() only
3593
+ // one level up ("parent-first") still fails when the parent also does not exist (e.g.,
3594
+ // devdocs.path = "sub/mid/leaf" where "sub" is a symlink but "sub/mid" is absent) —
3595
+ // realpath("sub/mid") follows sub then ENOENT-s on mid, and the lexical fallback does
3596
+ // not dereference sub (SEC-2). Instead, walk up the directory hierarchy until finding
3597
+ // an ancestor that exists, realpath() it to dereference any symlinks at that level, then
3598
+ // reattach all collected suffix segments.
3599
+ const realCwd = await realpath(cwd).catch(() => resolve(cwd));
3600
+ let resolvedTarget;
3444
3601
  try {
3445
- const jwt = generateAppJwt(creds.appId, privateKey);
3446
-
3447
- // App identity
3448
- const appResp = await fetch("https://api.github.com/app", {
3449
- headers: {
3450
- Authorization: `Bearer ${jwt}`,
3451
- Accept: "application/vnd.github+json",
3452
- "X-GitHub-Api-Version": "2022-11-28",
3453
- },
3454
- });
3455
- if (!appResp.ok) {
3456
- const body = await appResp.json().catch(() => ({}));
3457
- console.log(
3458
- ` ${RED}GitHub API error ${appResp.status}: ${body.message ?? "unknown"}${RESET}`,
3459
- );
3460
- console.log("");
3461
- return;
3602
+ resolvedTarget = await realpath(targetDir);
3603
+ } catch {
3604
+ // targetDir does not exist — walk up the hierarchy to the nearest existing ancestor,
3605
+ // dereference it via realpath() to follow any intermediate symlinks, then reattach the
3606
+ // collected suffix segments. Initialise resolvedTarget to the lexical path as a
3607
+ // last-resort sentinel (overwritten by the loop on the first successful realpath call).
3608
+ const trailSegments = [basename(targetDir)];
3609
+ let ancestor = dirname(targetDir);
3610
+ resolvedTarget = resolve(targetDir);
3611
+ while (true) {
3612
+ const parent = dirname(ancestor);
3613
+ if (parent === ancestor) break; // reached filesystem root — keep lexical sentinel
3614
+ try {
3615
+ resolvedTarget = join(await realpath(ancestor), ...trailSegments);
3616
+ break;
3617
+ } catch {
3618
+ trailSegments.unshift(basename(ancestor));
3619
+ ancestor = parent;
3620
+ }
3462
3621
  }
3463
- const appData = await appResp.json();
3622
+ }
3623
+ if (resolvedTarget !== realCwd && !resolvedTarget.startsWith(realCwd + sep)) {
3464
3624
  console.log(
3465
- ` ${GREEN}Connected${RESET} — App: ${bold(appData.name)} (slug: @${appData.slug})`,
3625
+ `${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
3466
3626
  );
3467
-
3468
- // Rate limit for app
3469
- const rlResp = await fetch("https://api.github.com/rate_limit", {
3470
- headers: {
3471
- Authorization: `Bearer ${jwt}`,
3472
- Accept: "application/vnd.github+json",
3473
- "X-GitHub-Api-Version": "2022-11-28",
3474
- },
3475
- });
3476
- if (rlResp.ok) {
3477
- const rl = await rlResp.json();
3478
- const core = rl.resources?.core;
3479
- if (core) {
3480
- const used = core.limit - core.remaining;
3481
- const resetTime = new Date(core.reset * 1000).toLocaleTimeString();
3482
- console.log(
3483
- ` Rate limit: ${cyan(core.remaining)}/${core.limit} remaining (${used} used, resets ${resetTime})`,
3484
- );
3485
- }
3486
- }
3487
- } catch (err) {
3488
- console.log(` ${RED}Error: ${err.message}${RESET}`);
3627
+ console.log(` Resolved: ${resolvedTarget}`);
3628
+ console.log(` Project: ${realCwd}`);
3629
+ console.log(
3630
+ ` Fix: set ${CYAN}devdocs.path${RESET} to a relative path inside your project (e.g. "devdocs" or "docs/knowledge").`,
3631
+ );
3632
+ process.exit(1);
3489
3633
  }
3490
3634
 
3491
- console.log("");
3492
- }
3635
+ // Template source: templates/devdocs/ inside the ForgeDock package
3636
+ const templatesDir = join(FORGE_HOME, "templates", "devdocs");
3493
3637
 
3494
- // ---------------------------------------------------------------------------
3495
- // Bot — setup TUI step (called from StepOrchestrator or directly)
3496
- // ---------------------------------------------------------------------------
3638
+ if (!existsSync(templatesDir)) {
3639
+ console.log(`${RED}DevDocs templates not found: ${templatesDir}${RESET}`);
3640
+ console.log(` This usually means ForgeDock is not installed correctly.`);
3641
+ console.log(
3642
+ ` Try: ${CYAN}npm install -g forgedock${RESET} or ${CYAN}npx forgedock@latest docs init${RESET}`,
3643
+ );
3644
+ process.exit(1);
3645
+ }
3646
+
3647
+ const templateFiles = await _findAllFiles(templatesDir);
3497
3648
 
3498
- /**
3499
- * Interactive bot setup step.
3500
- * Shows options: Create new app / Connect existing / Skip.
3501
- * Called as an optional step in tuiOnboarding() or via `npx forgedock bot setup`.
3502
- */
3503
- async function botSetup() {
3504
3649
  console.log("");
3505
- console.log(
3506
- box(
3507
- [
3508
- `${bold("GitHub Bot Setup")} ${dim("(Optional)")}`,
3509
- "",
3510
- "A GitHub App gives ForgeDock its own identity",
3511
- "for pipeline operations. Benefits:",
3512
- ` ${cyan("•")} Separate audit trail for bot actions`,
3513
- ` ${cyan("•")} Higher API rate limits`,
3514
- ` ${cyan("•")} Webhook-driven automation (future)`,
3515
- ` ${cyan("•")} Multi-user team support`,
3516
- ].join("\n"),
3517
- { title: "GitHub Bot" },
3518
- ),
3519
- );
3650
+ console.log(`${BOLD}ForgeDock${RESET} — Scaffold DevDocs`);
3651
+ console.log(` Source: ${dim(templatesDir + "/")}`);
3652
+ console.log(` Target: ${dim(targetDir + "/")}`);
3653
+ console.log(` Files: ${templateFiles.length} seed files`);
3520
3654
  console.log("");
3521
3655
 
3522
- const action = await select("How would you like to set up the bot?", [
3523
- { label: "Create new GitHub App (opens browser)", value: "create" },
3524
- { label: "Connect existing GitHub App", value: "connect" },
3525
- { label: "Skip — use personal token (current default)", value: "skip" },
3526
- ]);
3656
+ await mkdir(targetDir, { recursive: true });
3527
3657
 
3528
- if (action === "skip") {
3529
- console.log(
3530
- ` ${dim("Skipped.")} Run ${cyan("npx forgedock bot setup")} later to configure a bot identity.`,
3531
- );
3532
- return;
3533
- }
3534
-
3535
- if (action === "create") {
3536
- // Build the GitHub App manifest URL — opens the browser-based creation flow
3537
- const manifestPath = join(dirname(__filename), "github-app-manifest.json");
3538
- let manifest;
3539
- try {
3540
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
3541
- } catch {
3542
- console.log(
3543
- ` ${RED}Could not read github-app-manifest.json — skipping.${RESET}`,
3544
- );
3545
- return;
3546
- }
3658
+ let copied = 0;
3659
+ let skipped = 0;
3547
3660
 
3548
- const encodedManifest = encodeURIComponent(JSON.stringify(manifest));
3549
- const createUrl = `https://github.com/settings/apps/new?manifest=${encodedManifest}`;
3661
+ for (const srcFile of templateFiles) {
3662
+ const rel = relative(templatesDir, srcFile);
3663
+ const destFile = join(targetDir, rel);
3664
+ const destDir = dirname(destFile);
3550
3665
 
3551
- console.log("");
3552
- console.log(
3553
- ` ${bold("Opening GitHub App registration in your browser…")}`,
3554
- );
3555
- console.log(` URL: ${cyan(createUrl.substring(0, 80))}…`);
3556
- console.log("");
3666
+ await mkdir(destDir, { recursive: true });
3557
3667
 
3558
- // Try to open the browser
3559
- try {
3560
- const opener =
3561
- process.platform === "win32"
3562
- ? "start"
3563
- : process.platform === "darwin"
3564
- ? "open"
3565
- : "xdg-open";
3566
- // On Windows, `start "url"` treats the first quoted arg as the window title.
3567
- // Pass an empty title placeholder so the URL is treated as the target: `start "" "url"`.
3568
- const cmd =
3569
- process.platform === "win32"
3570
- ? `start "" "${createUrl}"`
3571
- : `${opener} "${createUrl}"`;
3572
- execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
3573
- } catch {
3574
- console.log(` ${YELLOW}Could not open browser automatically.${RESET}`);
3575
- console.log(` Please open this URL manually:`);
3576
- console.log(` ${cyan(createUrl)}`);
3668
+ if (existsSync(destFile)) {
3669
+ skipped++;
3670
+ console.log(` ${dim("skip")} ${dim(rel)}`);
3671
+ } else {
3672
+ await copyFile(srcFile, destFile);
3673
+ copied++;
3674
+ console.log(` ${green("+")} ${rel}`);
3577
3675
  }
3676
+ }
3578
3677
 
3579
- console.log("");
3580
- console.log(" After creating the app in your browser:");
3581
- console.log(` 1. Download the private key (.pem) from the app settings`);
3582
- console.log(` 2. Note your App ID and Installation ID`);
3678
+ console.log("");
3679
+ if (copied > 0 && skipped === 0) {
3583
3680
  console.log(
3584
- ` 3. Run ${cyan("npx forgedock bot setup")} again and choose \"Connect existing\"`,
3681
+ `${green("DevDocs scaffolded!")} ${copied} file${copied !== 1 ? "s" : ""} created in ${cyan(devdocsRelPath + "/")}.`,
3682
+ );
3683
+ } else if (copied > 0) {
3684
+ console.log(
3685
+ `${green("DevDocs scaffolded!")} ${copied} file${copied !== 1 ? "s" : ""} created, ${skipped} already existed (skipped).`,
3686
+ );
3687
+ } else {
3688
+ console.log(
3689
+ `${yellow("DevDocs already up to date.")} All ${skipped} file${skipped !== 1 ? "s" : ""} already exist — nothing was overwritten.`,
3585
3690
  );
3586
- console.log("");
3587
- return;
3588
3691
  }
3589
3692
 
3590
- // action === "connect"
3591
- const connected = await connectExistingBot();
3592
- if (!connected) {
3593
- throw new Error("Bot connection failed — credentials were not saved.");
3594
- }
3693
+ console.log("");
3694
+ console.log(
3695
+ ` ${dim("Tip:")} Customise the files in ${cyan(devdocsRelPath + "/")} for your project.`,
3696
+ );
3697
+ console.log(
3698
+ ` ${dim("Agents read these files as authoritative project knowledge.")}`,
3699
+ );
3700
+ console.log("");
3595
3701
  }
3596
3702
 
3597
3703
  function help() {
@@ -3621,13 +3727,19 @@ function help() {
3621
3727
  ` ${CYAN}npx forgedock update${RESET} Pull latest & reinstall`,
3622
3728
  );
3623
3729
  console.log(
3624
- ` ${CYAN}npx forgedock bot${RESET} Manage GitHub App bot identity`,
3730
+ ` ${CYAN}npx forgedock labels${RESET} Bootstrap ForgeDock-managed labels on a GitHub repo`,
3731
+ );
3732
+ console.log(
3733
+ ` ${CYAN}npx forgedock labels setup${RESET} Create/update all managed labels (idempotent)`,
3734
+ );
3735
+ console.log(
3736
+ ` ${CYAN}npx forgedock labels setup --repo owner/repo${RESET} Target a specific repo`,
3625
3737
  );
3626
3738
  console.log(
3627
- ` ${CYAN}npx forgedock bot status${RESET} Show bot health and rate limit usage`,
3739
+ ` ${CYAN}npx forgedock integrate${RESET} Inject/update ForgeDock usage block in project CLAUDE.md`,
3628
3740
  );
3629
3741
  console.log(
3630
- ` ${CYAN}npx forgedock bot setup${RESET} Configure GitHub App bot (interactive)`,
3742
+ ` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
3631
3743
  );
3632
3744
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
3633
3745
  console.log("");
@@ -3984,13 +4096,6 @@ async function tuiOnboarding() {
3984
4096
  await validate(forgeYamlPath);
3985
4097
  },
3986
4098
  },
3987
- {
3988
- name: "GitHub Bot Setup",
3989
- optional: true,
3990
- run: async () => {
3991
- await botSetup();
3992
- },
3993
- },
3994
4099
  ];
3995
4100
 
3996
4101
  const orchestrator = new StepOrchestrator(steps);
@@ -4040,15 +4145,42 @@ if (!command) {
4040
4145
  case "update":
4041
4146
  await update();
4042
4147
  break;
4043
- case "bot": {
4148
+ case "labels": {
4044
4149
  const subcommand = args[1];
4045
- if (!subcommand || subcommand === "status") {
4046
- await botStatus();
4047
- } else if (subcommand === "setup") {
4048
- await botSetup();
4150
+ if (
4151
+ !subcommand ||
4152
+ subcommand === "setup" ||
4153
+ subcommand.startsWith("--")
4154
+ ) {
4155
+ const subArgs = args.slice(1);
4156
+ const repo = resolveLabelsRepo(subArgs);
4157
+ if (!repo) {
4158
+ console.log(
4159
+ `${RED}No repository specified.${RESET}\n` +
4160
+ ` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
4161
+ );
4162
+ process.exit(1);
4163
+ }
4164
+ await labelsSetup(repo);
4165
+ } else {
4166
+ console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
4167
+ console.log(
4168
+ `Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
4169
+ );
4170
+ process.exit(1);
4171
+ }
4172
+ break;
4173
+ }
4174
+ case "integrate":
4175
+ await injectClaudeMd(process.cwd());
4176
+ break;
4177
+ case "docs": {
4178
+ const docsSubcommand = args[1];
4179
+ if (!docsSubcommand || docsSubcommand === "init") {
4180
+ await docsInit();
4049
4181
  } else {
4050
- console.log(`${RED}Unknown bot subcommand: ${subcommand}${RESET}`);
4051
- console.log(`Usage: ${CYAN}npx forgedock bot [status|setup]${RESET}`);
4182
+ console.log(`${RED}Unknown docs subcommand: ${docsSubcommand}${RESET}`);
4183
+ console.log(`Usage: ${CYAN}npx forgedock docs init${RESET}`);
4052
4184
  process.exit(1);
4053
4185
  }
4054
4186
  break;