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.
- package/README.md +7 -1
- package/bin/forgedock.mjs +496 -364
- package/bin/labels.json +152 -0
- package/commands/audit-agents.md +4 -2
- package/commands/autopilot.md +19 -11
- package/commands/cleanup.md +27 -12
- package/commands/deploy-info.md +36 -17
- package/commands/incident-response.md +15 -10
- package/commands/issue.md +8 -6
- package/commands/milestone.md +97 -48
- package/commands/orchestrate.md +109 -65
- package/commands/pipeline-health.md +8 -1
- package/commands/qa-sweep.md +14 -3
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +129 -23
- package/commands/review-pr.md +237 -90
- package/commands/rollback.md +26 -7
- package/commands/security-audit.md +27 -25
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +154 -2
- package/commands/work-on/build/context.md +131 -3
- package/commands/work-on/build/validate.md +32 -13
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on-monolithic.md +9 -5
- package/commands/work-on.md +67 -23
- package/docs/CONFIG.md +117 -0
- package/package.json +2 -1
- package/scripts/verify-route-registration.sh +36 -15
- package/scripts/verify-sops-chain.sh +18 -0
- package/templates/devdocs/README.md +96 -0
- package/templates/devdocs/agent/using-forgedock.md +245 -0
- package/templates/devdocs/agent/using-github.md +325 -0
- package/templates/devdocs/project/architecture.md +122 -0
- package/templates/devdocs/project/conventions.md +158 -0
- package/templates/devdocs/project/custom-instructions.md +73 -0
- package/templates/devdocs/project/glossary.md +84 -0
- package/templates/devdocs/project/stack.md +80 -0
- package/templates/devdocs/templates/commit-convention.md +147 -0
- package/templates/devdocs/templates/issue.md +141 -0
- package/templates/devdocs/templates/pull-request.md +115 -0
- package/templates/devdocs/templates/review-finding.md +147 -0
- 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 {
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
*
|
|
3221
|
-
* The private key itself is NOT stored — only the path.
|
|
3408
|
+
* Resolve the target repo for the labels command.
|
|
3222
3409
|
*
|
|
3223
|
-
*
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
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
|
-
* @
|
|
3415
|
+
* @param {string[]} subArgs - CLI args after "labels [setup]"
|
|
3416
|
+
* @returns {string|null} "owner/repo" string or null
|
|
3246
3417
|
*/
|
|
3247
|
-
function
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3265
|
-
*
|
|
3266
|
-
*
|
|
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
|
|
3269
|
-
const
|
|
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
|
-
|
|
3287
|
-
}
|
|
3460
|
+
if (!existsSync(manifestPath)) {
|
|
3461
|
+
console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
|
|
3462
|
+
process.exit(1);
|
|
3463
|
+
}
|
|
3288
3464
|
|
|
3289
|
-
|
|
3290
|
-
|
|
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
|
-
|
|
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
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
const
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
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
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3386
|
-
|
|
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
|
-
//
|
|
3535
|
+
// docs init — scaffold devdocs knowledge tree into a project
|
|
3397
3536
|
// ---------------------------------------------------------------------------
|
|
3398
3537
|
|
|
3399
3538
|
/**
|
|
3400
|
-
*
|
|
3401
|
-
*
|
|
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
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
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
|
-
|
|
3420
|
-
|
|
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
|
-
|
|
3435
|
-
let
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
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
|
-
|
|
3446
|
-
|
|
3447
|
-
//
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
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
|
-
|
|
3622
|
+
}
|
|
3623
|
+
if (resolvedTarget !== realCwd && !resolvedTarget.startsWith(realCwd + sep)) {
|
|
3464
3624
|
console.log(
|
|
3465
|
-
|
|
3625
|
+
`${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
|
|
3466
3626
|
);
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
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
|
-
|
|
3492
|
-
|
|
3635
|
+
// Template source: templates/devdocs/ inside the ForgeDock package
|
|
3636
|
+
const templatesDir = join(FORGE_HOME, "templates", "devdocs");
|
|
3493
3637
|
|
|
3494
|
-
|
|
3495
|
-
|
|
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
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3529
|
-
|
|
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
|
-
|
|
3549
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
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
|
-
|
|
3580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
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
|
|
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
|
|
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
|
|
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 "
|
|
4148
|
+
case "labels": {
|
|
4044
4149
|
const subcommand = args[1];
|
|
4045
|
-
if (
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
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
|
|
4051
|
-
console.log(`Usage: ${CYAN}npx forgedock
|
|
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;
|