forgedock 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
package/bin/journey.mjs
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* bin/journey.mjs — The five-act onboarding journey for ForgeDock.
|
|
3
3
|
*
|
|
4
4
|
* Acts (added across Tasks 4–8):
|
|
5
|
-
* preflight() → forge() → read() → review() → celebrate()
|
|
5
|
+
* preflight() → forge() → read() → setupRepo() → review() → celebrate()
|
|
6
|
+
* (setupRepo, forge#4173: interactive repo creation / main push / staging)
|
|
6
7
|
*
|
|
7
8
|
* This file owns forge.yaml generation (single source of truth, driven from
|
|
8
9
|
* detection values) and orchestrates the existing modules:
|
|
9
|
-
* init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
|
|
10
|
+
* init-detect.mjs, init-enrich-api.mjs, init-repo.mjs, tui.annotatedReviewScreen, registry.mjs
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
|
|
@@ -16,6 +17,24 @@ import { join, basename } from "path";
|
|
|
16
17
|
// forge.yaml generation (Task 4)
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the Claude Code configuration directory (forge#4173): a non-empty
|
|
22
|
+
* CLAUDE_CONFIG_DIR wins (Claude Code reads commands, settings.json and MCP
|
|
23
|
+
* config from there), otherwise `<home>/.claude`. Shared by the installer
|
|
24
|
+
* here and by doctor/status/uninstall in forgedock.mjs and the engine, so
|
|
25
|
+
* every path agrees on one directory (no #1589 split-brain).
|
|
26
|
+
*
|
|
27
|
+
* @param {Record<string, string|undefined>} [env]
|
|
28
|
+
* @param {string} [home]
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveClaudeConfigDir(env = process.env, home = env.HOME || env.USERPROFILE || os.homedir()) {
|
|
32
|
+
return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Claude config dir for a journey ctx. */
|
|
36
|
+
const claudeDirOf = (ctx) => resolveClaudeConfigDir(ctx.env ?? process.env, ctx.home);
|
|
37
|
+
|
|
19
38
|
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
20
39
|
|
|
21
40
|
/** Append the TODO flag for a low-confidence field. */
|
|
@@ -508,47 +527,88 @@ export function validateForgeYamlShape(cwd) {
|
|
|
508
527
|
}
|
|
509
528
|
|
|
510
529
|
// ---------------------------------------------------------------------------
|
|
511
|
-
// Description detection (
|
|
530
|
+
// Description detection (forge#4173)
|
|
512
531
|
// ---------------------------------------------------------------------------
|
|
532
|
+
//
|
|
533
|
+
// The description comes from explicit project metadata only. README/CLAUDE.md
|
|
534
|
+
// prose is never used: its first paragraph is routinely a badge line, a
|
|
535
|
+
// tagline, or setup text, and was written into forge.yaml as if it were the
|
|
536
|
+
// project description (field-test friction in #4155).
|
|
537
|
+
|
|
538
|
+
const MAX_DESCRIPTION = 200;
|
|
513
539
|
|
|
514
|
-
function
|
|
515
|
-
|
|
516
|
-
let started = false;
|
|
517
|
-
const out = [];
|
|
518
|
-
for (const line of lines) {
|
|
519
|
-
if (!started && line.match(/^#/)) continue;
|
|
520
|
-
if (!started && line.trim() === "") continue;
|
|
521
|
-
if (!started && line.match(/^[!<\[`|]/)) continue;
|
|
522
|
-
if (!started && line.match(/^---/)) continue;
|
|
523
|
-
if (!started) started = true;
|
|
524
|
-
if (line.trim() === "") break;
|
|
525
|
-
out.push(line.trim());
|
|
526
|
-
}
|
|
527
|
-
if (out.length === 0) return "";
|
|
528
|
-
return out
|
|
529
|
-
.join(" ")
|
|
530
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
531
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
532
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
533
|
-
.slice(0, 200)
|
|
534
|
-
.trim();
|
|
540
|
+
function clean(value) {
|
|
541
|
+
return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_DESCRIPTION).trim();
|
|
535
542
|
}
|
|
536
543
|
|
|
537
544
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
545
|
+
* Read `description = "..."` from the first matching TOML table.
|
|
546
|
+
* Minimal line-based reader: handles basic and literal single-line strings.
|
|
547
|
+
* @param {string} content
|
|
548
|
+
* @param {string[]} tables - e.g. ["project", "tool.poetry"]
|
|
549
|
+
* @returns {string}
|
|
540
550
|
*/
|
|
541
|
-
|
|
542
|
-
|
|
551
|
+
function tomlTableDescription(content, tables) {
|
|
552
|
+
let current = "";
|
|
553
|
+
const found = new Map();
|
|
554
|
+
for (const raw of content.split("\n")) {
|
|
555
|
+
const line = raw.trim();
|
|
556
|
+
const header = line.match(/^\[([^\[\]]+)\]\s*(#.*)?$/);
|
|
557
|
+
if (header) {
|
|
558
|
+
current = header[1].trim();
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!tables.includes(current) || found.has(current)) continue;
|
|
562
|
+
const m = line.match(/^description\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/);
|
|
563
|
+
if (m) found.set(current, m[1] !== undefined ? m[1].replace(/\\(["\\])/g, "$1") : m[2]);
|
|
564
|
+
}
|
|
565
|
+
for (const t of tables) if (found.get(t)) return found.get(t);
|
|
566
|
+
return "";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const MANIFEST_SOURCES = [
|
|
570
|
+
{
|
|
571
|
+
file: "package.json",
|
|
572
|
+
read: (content) => {
|
|
573
|
+
const pkg = JSON.parse(content);
|
|
574
|
+
return typeof pkg.description === "string" ? pkg.description : "";
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
{ file: "pyproject.toml", read: (content) => tomlTableDescription(content, ["project", "tool.poetry"]) },
|
|
578
|
+
{ file: "Cargo.toml", read: (content) => tomlTableDescription(content, ["package"]) },
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Detect a project description from project metadata: package.json,
|
|
583
|
+
* pyproject.toml, Cargo.toml, then (best effort) the GitHub repository
|
|
584
|
+
* description. Never reads README.md or CLAUDE.md.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} cwd
|
|
587
|
+
* @param {object} [opts]
|
|
588
|
+
* @param {Function} [opts.exec] - exec(cmd, args) → stdout. When given with
|
|
589
|
+
* opts.repo, used for `gh repo view <repo> --json description`.
|
|
590
|
+
* @param {string} [opts.repo] - "owner/name" of a detected GitHub remote.
|
|
591
|
+
* @returns {{ value: string, source: string }}
|
|
592
|
+
*/
|
|
593
|
+
export function detectDescription(cwd, { exec, repo } = {}) {
|
|
594
|
+
for (const { file, read } of MANIFEST_SOURCES) {
|
|
543
595
|
try {
|
|
544
596
|
const p = join(cwd, file);
|
|
545
597
|
if (!existsSync(p)) continue;
|
|
546
|
-
const value =
|
|
598
|
+
const value = clean(read(readFileSync(p, "utf-8")));
|
|
547
599
|
if (value) return { value, source: file };
|
|
548
600
|
} catch {
|
|
549
601
|
// best-effort only
|
|
550
602
|
}
|
|
551
603
|
}
|
|
604
|
+
if (exec && repo) {
|
|
605
|
+
try {
|
|
606
|
+
const value = clean(exec("gh", ["repo", "view", repo, "--json", "description", "--jq", ".description"]));
|
|
607
|
+
if (value) return { value, source: "GitHub repository" };
|
|
608
|
+
} catch {
|
|
609
|
+
// gh missing, unauthenticated, or repo not on GitHub — no description
|
|
610
|
+
}
|
|
611
|
+
}
|
|
552
612
|
return { value: "", source: "" };
|
|
553
613
|
}
|
|
554
614
|
|
|
@@ -685,10 +745,10 @@ export async function preflight(ctx) {
|
|
|
685
745
|
{
|
|
686
746
|
label: "Claude Code",
|
|
687
747
|
run: async () => {
|
|
688
|
-
const claudeDir =
|
|
748
|
+
const claudeDir = claudeDirOf(ctx);
|
|
689
749
|
return existsSync(claudeDir)
|
|
690
|
-
? { ok: true, detail:
|
|
691
|
-
: { ok: false, detail:
|
|
750
|
+
? { ok: true, detail: `${claudeDir} found` }
|
|
751
|
+
: { ok: false, detail: `${claudeDir} not found`, fix: ["Install Claude Code: https://claude.com/claude-code"] };
|
|
692
752
|
},
|
|
693
753
|
},
|
|
694
754
|
{
|
|
@@ -1701,7 +1761,7 @@ export async function persistHome(ctx) {
|
|
|
1701
1761
|
*/
|
|
1702
1762
|
async function linkPipelineScripts(ctx) {
|
|
1703
1763
|
const scriptsSourceDir = join(ctx.forgeHome, "scripts");
|
|
1704
|
-
const scriptsTargetDir = join(ctx
|
|
1764
|
+
const scriptsTargetDir = join(claudeDirOf(ctx), "scripts");
|
|
1705
1765
|
const wantSymlink = ctx.linkStrategy !== "copy";
|
|
1706
1766
|
|
|
1707
1767
|
await mkdir(scriptsTargetDir, { recursive: true });
|
|
@@ -2157,7 +2217,7 @@ function renderForgeReceipt(ctx, results) {
|
|
|
2157
2217
|
const scriptsDetail = scriptsChanged > 0
|
|
2158
2218
|
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
2159
2219
|
: `(unchanged ${scriptsResult.skipped})`;
|
|
2160
|
-
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into
|
|
2220
|
+
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ${join(claudeDirOf(ctx), "scripts")} ${dimLine(ctx, scriptsDetail)}\n`);
|
|
2161
2221
|
}
|
|
2162
2222
|
if (manifestSaveFailed) {
|
|
2163
2223
|
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
@@ -2253,8 +2313,8 @@ function renderForgeReceipt(ctx, results) {
|
|
|
2253
2313
|
export async function forge(ctx) {
|
|
2254
2314
|
const { stdout: w } = ctx;
|
|
2255
2315
|
const commandsDir = join(ctx.forgeHome, "commands");
|
|
2256
|
-
const targetDir = join(ctx
|
|
2257
|
-
const manifestPath = join(ctx
|
|
2316
|
+
const targetDir = join(claudeDirOf(ctx), "commands");
|
|
2317
|
+
const manifestPath = join(claudeDirOf(ctx), "forgedock", "copied-commands.json");
|
|
2258
2318
|
const wantSymlink = ctx.linkStrategy !== "copy";
|
|
2259
2319
|
|
|
2260
2320
|
w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
|
|
@@ -2382,7 +2442,7 @@ export async function forge(ctx) {
|
|
|
2382
2442
|
const scriptsResult = await linkPipelineScripts(ctx);
|
|
2383
2443
|
|
|
2384
2444
|
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
2385
|
-
const settingsPath = join(ctx
|
|
2445
|
+
const settingsPath = join(claudeDirOf(ctx), "settings.json");
|
|
2386
2446
|
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
2387
2447
|
|
|
2388
2448
|
// Install enforcement hooks (#1250): PreToolUse (branch/label validation)
|
|
@@ -2426,7 +2486,11 @@ export async function forge(ctx) {
|
|
|
2426
2486
|
import { detectConfig } from "./init-detect.mjs";
|
|
2427
2487
|
import { enrich, resolveEnrichBackend } from "./init-enrich.mjs";
|
|
2428
2488
|
import { isClaudeCliAvailable } from "./runner.mjs";
|
|
2429
|
-
import { annotatedReviewScreen, box, confirm, getLogoTagline } from "./tui.mjs";
|
|
2489
|
+
import { annotatedReviewScreen, box, confirm, getLogoTagline, input, select } from "./tui.mjs";
|
|
2490
|
+
import {
|
|
2491
|
+
defaultExec as repoExec, listOwners, listAppInstallations, ownerCoverageWarning,
|
|
2492
|
+
createRepo, ensureInitialCommit, pushMain, createStaging,
|
|
2493
|
+
} from "./init-repo.mjs";
|
|
2430
2494
|
|
|
2431
2495
|
const badgeOf = (field) => field.confidence;
|
|
2432
2496
|
|
|
@@ -2440,7 +2504,7 @@ export async function read(ctx) {
|
|
|
2440
2504
|
w.write("\n " + ember("Reading your repository", ctx.mode) + "\n\n");
|
|
2441
2505
|
|
|
2442
2506
|
let draft = await detectConfig(ctx.cwd);
|
|
2443
|
-
const description = detectDescription(ctx.cwd);
|
|
2507
|
+
const description = detectDescription(ctx.cwd, descriptionOpts(ctx, draft));
|
|
2444
2508
|
|
|
2445
2509
|
const rows = [
|
|
2446
2510
|
{ label: "owner/repo", run: async () => ({ ok: true, detail: `${draft.project.owner.value}/${draft.project.repo.value}`, badge: badgeOf(draft.project.owner) }) },
|
|
@@ -2466,7 +2530,9 @@ export async function read(ctx) {
|
|
|
2466
2530
|
try {
|
|
2467
2531
|
w.write(" " + dimLine(ctx, "✦ enriching with AI…") + "\n");
|
|
2468
2532
|
const enriched = await ctx.enrichFn(draft, { backend: enrichBackend, cwd: ctx.cwd, env: ctx.env });
|
|
2469
|
-
|
|
2533
|
+
// meta holds detection facts (remote/commit/staging state) that drive
|
|
2534
|
+
// setupRepo() — never let model output rewrite or drop them (#4173).
|
|
2535
|
+
if (enriched && typeof enriched === "object") draft = { ...enriched, meta: draft.meta };
|
|
2470
2536
|
} catch {
|
|
2471
2537
|
w.write(" " + dimLine(ctx, "✦ AI enrichment unavailable — continuing with detection only") + "\n");
|
|
2472
2538
|
}
|
|
@@ -2477,6 +2543,163 @@ export async function read(ctx) {
|
|
|
2477
2543
|
return { draft, description };
|
|
2478
2544
|
}
|
|
2479
2545
|
|
|
2546
|
+
/** gh lookup options for detectDescription — only with a detected GitHub remote. */
|
|
2547
|
+
function descriptionOpts(ctx, draft) {
|
|
2548
|
+
return draft?.meta?.remoteDetected
|
|
2549
|
+
? { exec: ctx.exec, repo: `${draft.project.owner.value}/${draft.project.repo.value}` }
|
|
2550
|
+
: {};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// ---------------------------------------------------------------------------
|
|
2554
|
+
// Act III.5 — Repository setup (forge#4173)
|
|
2555
|
+
// ---------------------------------------------------------------------------
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* Bind the bin/init-repo.mjs helpers to one exec. Default uses
|
|
2559
|
+
* init-repo's own exec (longer timeout than ctx.exec — pushes and
|
|
2560
|
+
* `gh repo create` can take a while).
|
|
2561
|
+
* @param {Function} [exec]
|
|
2562
|
+
*/
|
|
2563
|
+
export function bindRepoOps(exec = repoExec) {
|
|
2564
|
+
return {
|
|
2565
|
+
listOwners: () => listOwners(exec),
|
|
2566
|
+
listAppInstallations: (owners) => listAppInstallations(exec, owners),
|
|
2567
|
+
createRepo: (opts) => createRepo(exec, opts),
|
|
2568
|
+
ensureInitialCommit: (root) => ensureInitialCommit(exec, root),
|
|
2569
|
+
pushMain: (root, branch) => pushMain(exec, root, branch),
|
|
2570
|
+
createStaging: (root, from) => createStaging(exec, root, from),
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const COVERAGE_BADGE = {
|
|
2575
|
+
installed: "ForgeDock App installed",
|
|
2576
|
+
"not-installed": "App NOT installed",
|
|
2577
|
+
unknown: "App status unknown",
|
|
2578
|
+
};
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Act III.5: when the repo has no GitHub remote, no commits, or no
|
|
2582
|
+
* `staging` branch, offer to fix that instead of writing placeholders and
|
|
2583
|
+
* TODOs into forge.yaml:
|
|
2584
|
+
* - create the GitHub repo (private by default) under an owner picked from
|
|
2585
|
+
* a list that shows where the ForgeDock GitHub App is installed, warning
|
|
2586
|
+
* when the chosen owner is not covered;
|
|
2587
|
+
* - make an empty initial commit when HEAD is unborn, push `main`;
|
|
2588
|
+
* - create `staging` from the default branch (fast-lane convention).
|
|
2589
|
+
* Then re-runs detection so the draft carries the real values.
|
|
2590
|
+
*
|
|
2591
|
+
* Interactive only: non-TTY and --fast runs return the draft untouched and
|
|
2592
|
+
* make no git/GitHub calls. Declining any step stops there and keeps the
|
|
2593
|
+
* existing placeholder/TODO behaviour. Failures are reported, never thrown.
|
|
2594
|
+
*
|
|
2595
|
+
* Injectable: ctx.confirmFn, ctx.selectFn, ctx.inputFn, ctx.repoOps,
|
|
2596
|
+
* ctx.isTTY (defaults to process.stdin.isTTY).
|
|
2597
|
+
*
|
|
2598
|
+
* @returns {Promise<{ draft: object, changed: boolean, created?: string, error?: string }>}
|
|
2599
|
+
*/
|
|
2600
|
+
export async function setupRepo(ctx, draft) {
|
|
2601
|
+
const { stdout: w } = ctx;
|
|
2602
|
+
const meta = draft?.meta ?? {};
|
|
2603
|
+
const isTTY = ctx.isTTY ?? process.stdin.isTTY === true;
|
|
2604
|
+
const needsRepo = meta.remoteDetected === false;
|
|
2605
|
+
const needsCommit = meta.hasCommits === false;
|
|
2606
|
+
const needsStaging = meta.stagingMissing === true;
|
|
2607
|
+
if (!isTTY || ctx.argv.includes("--fast") || !(needsRepo || needsCommit || needsStaging)) {
|
|
2608
|
+
return { draft, changed: false };
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
const ops = ctx.repoOps ?? bindRepoOps();
|
|
2612
|
+
const confirmFn = ctx.confirmFn ?? confirm;
|
|
2613
|
+
const selectFn = ctx.selectFn ?? select;
|
|
2614
|
+
const inputFn = ctx.inputFn ?? input;
|
|
2615
|
+
const root = draft.paths.root.value;
|
|
2616
|
+
const branch = meta.unbornBranch || draft.branches.default.value || "main";
|
|
2617
|
+
const note = (s) => w.write(" " + dimLine(ctx, s) + "\n");
|
|
2618
|
+
const warnLine = (s) => w.write(` ${ctx.mode === "none" ? "⚠" : "\x1b[38;2;255;179;71m⚠\x1b[0m"} ${s}\n`);
|
|
2619
|
+
|
|
2620
|
+
w.write("\n " + ember("Setting up your repository", ctx.mode) + "\n\n");
|
|
2621
|
+
|
|
2622
|
+
let created;
|
|
2623
|
+
let changed = false;
|
|
2624
|
+
try {
|
|
2625
|
+
if (needsRepo) {
|
|
2626
|
+
const yes = await confirmFn("No GitHub repository is connected. Create one now?", true);
|
|
2627
|
+
if (!yes) {
|
|
2628
|
+
note("Skipped — forge.yaml will keep placeholder owner/repo values.");
|
|
2629
|
+
return { draft, changed: false };
|
|
2630
|
+
}
|
|
2631
|
+
const owners = ops.listOwners();
|
|
2632
|
+
if (owners.length === 0) {
|
|
2633
|
+
note("Could not list GitHub accounts — run `gh auth login`, then `npx forgedock init` again.");
|
|
2634
|
+
return { draft, changed: false };
|
|
2635
|
+
}
|
|
2636
|
+
const coverage = ops.listAppInstallations(owners);
|
|
2637
|
+
const choices = owners.map((o) => ({
|
|
2638
|
+
label: `${o.login} (${o.type === "Organization" ? "org" : "personal"} · ${COVERAGE_BADGE[coverage.get(o.login) ?? "unknown"]})`,
|
|
2639
|
+
value: o.login,
|
|
2640
|
+
}));
|
|
2641
|
+
const firstCovered = owners.findIndex((o) => coverage.get(o.login) === "installed");
|
|
2642
|
+
let owner;
|
|
2643
|
+
for (;;) {
|
|
2644
|
+
owner = await selectFn("Repository owner", choices, { initialIndex: Math.max(firstCovered, 0) });
|
|
2645
|
+
if (!owner) {
|
|
2646
|
+
note("Skipped — no owner chosen.");
|
|
2647
|
+
return { draft, changed: false };
|
|
2648
|
+
}
|
|
2649
|
+
const warning = ownerCoverageWarning(owner, coverage.get(owner));
|
|
2650
|
+
if (!warning) break;
|
|
2651
|
+
warnLine(warning);
|
|
2652
|
+
if (await confirmFn(`Create the repository under '${owner}' anyway?`, false)) break;
|
|
2653
|
+
}
|
|
2654
|
+
const name = await inputFn("Repository name", basename(root));
|
|
2655
|
+
const isPrivate = await confirmFn("Make the repository private?", true);
|
|
2656
|
+
const repo = ops.createRepo({ owner, name, private: isPrivate, root, branch });
|
|
2657
|
+
created = repo.fullName;
|
|
2658
|
+
changed = true;
|
|
2659
|
+
w.write(` ✔ Created ${repo.visibility} repository ${repo.fullName}\n`);
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
if (needsCommit) {
|
|
2663
|
+
const yes = await confirmFn(`The repository has no commits. Create an empty initial commit on '${branch}' so it can be pushed?`, true);
|
|
2664
|
+
if (!yes) {
|
|
2665
|
+
note(`Skipped — push '${branch}' and create 'staging' yourself once you have a commit.`);
|
|
2666
|
+
return await finish();
|
|
2667
|
+
}
|
|
2668
|
+
ops.ensureInitialCommit(root);
|
|
2669
|
+
changed = true;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (needsRepo || needsCommit) {
|
|
2673
|
+
ops.pushMain(root, branch);
|
|
2674
|
+
changed = true;
|
|
2675
|
+
w.write(` ✔ Pushed ${branch}\n`);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
if (needsStaging || needsRepo) {
|
|
2679
|
+
const yes = await confirmFn(`Create a 'staging' branch from '${branch}'? (ForgeDock fast-lane PRs merge into staging)`, true);
|
|
2680
|
+
if (yes) {
|
|
2681
|
+
const res = ops.createStaging(root, branch);
|
|
2682
|
+
changed = true;
|
|
2683
|
+
w.write(` ✔ ${res.created ? "Created" : "Found"} staging\n`);
|
|
2684
|
+
} else {
|
|
2685
|
+
note(`Skipped — branches.staging will default to '${branch}'.`);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
const msg = String(err?.stderr || err?.message || err).trim().split("\n")[0];
|
|
2690
|
+
warnLine(`Repository setup failed: ${msg}`);
|
|
2691
|
+
note("Continuing with the detected values — fix this and re-run `npx forgedock init`.");
|
|
2692
|
+
return await finish(msg);
|
|
2693
|
+
}
|
|
2694
|
+
return await finish();
|
|
2695
|
+
|
|
2696
|
+
async function finish(error) {
|
|
2697
|
+
if (!changed) return { draft, changed: false, ...(error ? { error } : {}) };
|
|
2698
|
+
const redetected = await detectConfig(ctx.cwd);
|
|
2699
|
+
return { draft: redetected, changed: true, ...(created ? { created } : {}), ...(error ? { error } : {}) };
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2480
2703
|
// ---------------------------------------------------------------------------
|
|
2481
2704
|
// Act IV — The Review (Task 8)
|
|
2482
2705
|
// ---------------------------------------------------------------------------
|
|
@@ -2516,7 +2739,7 @@ export async function review(ctx, draft, description) {
|
|
|
2516
2739
|
}
|
|
2517
2740
|
|
|
2518
2741
|
const extraFields = description.value
|
|
2519
|
-
? { description: { value: description.value, confidence: "medium", source: description.source, why: `
|
|
2742
|
+
? { description: { value: description.value, confidence: "medium", source: description.source, why: `Description from ${description.source}` } }
|
|
2520
2743
|
: {};
|
|
2521
2744
|
|
|
2522
2745
|
const accepted = await annotatedReviewScreen(draft, {
|
|
@@ -2574,7 +2797,7 @@ export function celebrate(ctx, summary) {
|
|
|
2574
2797
|
w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
|
|
2575
2798
|
}
|
|
2576
2799
|
if (summary.scriptsResult && summary.scriptsResult.total > 0) {
|
|
2577
|
-
w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, "
|
|
2800
|
+
w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, `${join(claudeDirOf(ctx), "scripts")} — classify-lane, transition-label, validate-pr-target`)}\n`);
|
|
2578
2801
|
}
|
|
2579
2802
|
w.write("\n");
|
|
2580
2803
|
if (summary.isMinimal) {
|
|
@@ -2840,6 +3063,18 @@ export async function maybeOfferDemo(ctx) {
|
|
|
2840
3063
|
* Acts I→V. Returns the process exit code.
|
|
2841
3064
|
* SIGINT: restore cursor, summarize partial state, exit 130.
|
|
2842
3065
|
*/
|
|
3066
|
+
/**
|
|
3067
|
+
* Run setupRepo() on read()'s result; when it changed the repository,
|
|
3068
|
+
* re-detect the description against the new state.
|
|
3069
|
+
* @returns {Promise<{ draft: object, description: { value: string, source: string } }>}
|
|
3070
|
+
*/
|
|
3071
|
+
export async function setupAfterRead(ctx, { draft, description }) {
|
|
3072
|
+
const setup = await setupRepo(ctx, draft);
|
|
3073
|
+
if (!setup.changed) return { draft, description };
|
|
3074
|
+
const fresh = description.value ? description : detectDescription(ctx.cwd, descriptionOpts(ctx, setup.draft));
|
|
3075
|
+
return { draft: setup.draft, description: fresh };
|
|
3076
|
+
}
|
|
3077
|
+
|
|
2843
3078
|
export async function runJourney(ctx) {
|
|
2844
3079
|
const onSigint = () => {
|
|
2845
3080
|
ctx.stdout.write("\x1b[0m\x1b[?25h\n Interrupted. Partial state: commands may be installed; config not written.\n Finish anytime with: npx forgedock init\n");
|
|
@@ -2860,7 +3095,7 @@ export async function runJourney(ctx) {
|
|
|
2860
3095
|
ctx.forgeHome = persistResult.forgeHome;
|
|
2861
3096
|
}
|
|
2862
3097
|
const forged = await forge(ctx);
|
|
2863
|
-
const { draft, description } = await read(ctx);
|
|
3098
|
+
const { draft, description } = await setupAfterRead(ctx, await read(ctx));
|
|
2864
3099
|
const reviewed = await review(ctx, draft, description);
|
|
2865
3100
|
const connected = await connect(ctx);
|
|
2866
3101
|
celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus, scriptsResult: forged.scriptsResult });
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -109,7 +109,8 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
|
|
|
109
109
|
rmSync(rf, { force: true });
|
|
110
110
|
const out = openSync(batch.logFile(issue), "a");
|
|
111
111
|
try {
|
|
112
|
-
|
|
112
|
+
// FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
|
|
113
|
+
const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
|
|
113
114
|
child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
|
|
114
115
|
child.unref?.();
|
|
115
116
|
return { pid: child.pid };
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
|
|
10
|
-
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic } from "./batch.mjs";
|
|
10
|
+
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
11
11
|
import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
|
|
12
12
|
|
|
13
13
|
const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
|
|
@@ -87,6 +87,22 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
87
87
|
assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
|
|
91
|
+
const { now, sleep } = fakeClock();
|
|
92
|
+
const updates = [];
|
|
93
|
+
let calls = 0;
|
|
94
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
|
|
95
|
+
onUpdate: (r) => updates.push(r),
|
|
96
|
+
runOne: async () => (++calls <= 3
|
|
97
|
+
? { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 60, detail: "gh repo view failed" }
|
|
98
|
+
: { terminalReason: "merged", pr: 5 }) });
|
|
99
|
+
assert.equal(calls, 4);
|
|
100
|
+
assert.equal(rows[0].outcome, "merged");
|
|
101
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
102
|
+
assert.equal(updates.filter((u) => /requeued in 60s: gh.unavailable/.test(u.detail ?? "")).length, 3);
|
|
103
|
+
assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
|
|
104
|
+
});
|
|
105
|
+
|
|
90
106
|
it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
|
|
91
107
|
const { now, sleep, c } = fakeClock();
|
|
92
108
|
const t0 = c.t, starts = {};
|
|
@@ -139,6 +155,25 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
139
155
|
assert.deepEqual([again, row.outcome], [[9, 9], "engine-error"]);
|
|
140
156
|
});
|
|
141
157
|
|
|
158
|
+
it("#4153 a resume re-derives a pending issue's lane from its current milestone", async () => {
|
|
159
|
+
const saved = { state: null };
|
|
160
|
+
const store = { load: () => saved.state, save: (s) => { saved.state = JSON.parse(JSON.stringify(s)); } };
|
|
161
|
+
const milestoneLane = { 1: "staging", 2: "staging" };
|
|
162
|
+
const resolveLane = async (n) => ({ ok: true, lane: milestoneLane[n] });
|
|
163
|
+
const runs = [];
|
|
164
|
+
let rows = await orchestrate({ issues: [1, 2], parallel: 1, breaker: { errors: 1, window_min: 30 }, store, resolveLane, readBody: async () => "",
|
|
165
|
+
runOne: async (n, lane) => { runs.push([n, lane]); throw new Error("x"); } });
|
|
166
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["held", "held"]);
|
|
167
|
+
assert.equal(saved.state.lanes["2"].lane, "staging");
|
|
168
|
+
milestoneLane[1] = milestoneLane[2] = "milestone/m1"; // the operator re-milestones both issues
|
|
169
|
+
saved.state = { ...saved.state, breaker: null, errorTimes: [] }; // what --resume does
|
|
170
|
+
rows = await orchestrate({ issues: [1, 2], parallel: 1, store, resolveLane, readBody: async () => "",
|
|
171
|
+
runOne: async (n, lane) => { runs.push([n, lane]); return { terminalReason: "merged" }; } });
|
|
172
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
173
|
+
assert.deepEqual(runs.slice(1).sort((a, b) => a[0] - b[0]), [[1, "milestone/m1"], [2, "milestone/m1"]]);
|
|
174
|
+
assert.equal(saved.state.lanes["2"].lane, "milestone/m1");
|
|
175
|
+
});
|
|
176
|
+
|
|
142
177
|
it("concurrency follows the live K", async () => {
|
|
143
178
|
let k = 1, active = 0, maxActive = 0;
|
|
144
179
|
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6, 7], parallel: 1, pollMs: 5, getParallel: () => k, readBody: async () => "",
|
|
@@ -182,3 +217,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
182
217
|
assert.ok(!existsSync(`${rf}.tmp-${process.pid}`));
|
|
183
218
|
});
|
|
184
219
|
});
|
|
220
|
+
|
|
221
|
+
describe("lane launch env (#4179)", () => {
|
|
222
|
+
it("launchLane passes FORGEDOCK_BATCH_ID (the ledger dir) in the lane env and keeps the rest of the env and argv", () => {
|
|
223
|
+
const batch = openBatch(tmpDir("batch-"), "b20261019-120000-1");
|
|
224
|
+
const spawned = [];
|
|
225
|
+
const { pid } = launchLane({ batch, issue: 7, args: ["--repo", "o/r"], script: "/x/forgedock.mjs", bin: "node", env: { PATH: "/bin", KEEP: "1" },
|
|
226
|
+
spawnFn: (bin, argv, opts) => { spawned.push({ bin, argv, opts }); return { pid: 99, unref() {}, on() {} }; } });
|
|
227
|
+
assert.equal(pid, 99);
|
|
228
|
+
assert.equal(spawned[0].opts.env.FORGEDOCK_BATCH_ID, batch.id);
|
|
229
|
+
assert.equal(spawned[0].opts.env.KEEP, "1");
|
|
230
|
+
assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
|
|
231
|
+
});
|
|
232
|
+
});
|