forgedock 1.1.8 → 1.1.9
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 +10 -33
- package/bin/cinema.mjs +23 -5
- package/bin/engine-cli.mjs +137 -11
- package/bin/env-detect.mjs +249 -0
- package/bin/forge-utils.mjs +364 -4
- package/bin/forgedock.mjs +1064 -135
- package/bin/hooks/session-start.mjs +68 -3
- package/bin/init-detect.mjs +101 -12
- package/bin/journey.mjs +917 -16
- package/bin/recall.mjs +24 -7
- package/bin/registry.mjs +91 -3
- package/bin/runner.mjs +38 -2
- package/bin/tui.mjs +49 -3
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +8 -8
- package/commands/audit.md +1 -1
- package/commands/autopilot.md +1 -1
- package/commands/ci-audit.md +1 -1
- package/commands/cleanup.md +87 -52
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/explain.md +1 -1
- package/commands/fix-ci.md +1 -1
- package/commands/forgedock-init.md +1 -1
- package/commands/incident-response.md +3 -3
- package/commands/issue.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate/config.md +13 -3
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-2-triage.md +1 -1
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +128 -20
- package/commands/orchestrate/phase-4-execution.md +598 -43
- package/commands/orchestrate/phase-5-cleanup.md +1 -1
- package/commands/orchestrate/phase-6-report.md +51 -3
- package/commands/orchestrate/safety.md +2 -2
- package/commands/qa-sweep.md +1 -1
- package/commands/quality-gate.md +1 -1
- package/commands/recover-orphans.md +5 -5
- package/commands/replay.md +1 -1
- package/commands/review-pr-agents/api.md +2 -2
- package/commands/review-pr-agents/auth.md +2 -2
- package/commands/review-pr-agents/billing.md +2 -2
- package/commands/review-pr-agents/concurrency.md +2 -2
- package/commands/review-pr-agents/database.md +2 -2
- package/commands/review-pr-agents/frontend.md +2 -2
- package/commands/review-pr-agents/infra.md +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-agents/scraper.md +2 -2
- package/commands/review-pr-agents/security.md +2 -2
- package/commands/review-pr-staging.md +41 -3
- package/commands/review-pr.md +71 -6
- package/commands/rollback.md +1 -1
- package/commands/scope.md +1 -1
- package/commands/security-audit.md +1 -1
- package/commands/test-gate.md +1 -1
- package/commands/validate.md +1 -1
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/context.md +9 -6
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +1 -1
- package/commands/work-on/build.md +31 -6
- package/commands/work-on/close.md +3 -2
- package/commands/work-on/decompose.md +1 -1
- package/commands/work-on/investigate.md +14 -5
- package/commands/work-on/remediate.md +331 -0
- package/commands/work-on/review.md +1 -1
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +194 -56
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +186 -0
- package/docs/site/command-reference.md +31 -1
- package/docs/site/getting-started.md +9 -34
- package/docs/site/index.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +2 -1
- package/packages/protocol/src/types.js +1 -1
- package/scripts/check-command-docs-drift.sh +4 -2
- package/scripts/danger-zones.mjs +19 -12
- package/scripts/transition-label.sh +101 -13
- package/scripts/refresh-bot-token.sh +0 -97
package/README.md
CHANGED
|
@@ -221,11 +221,10 @@ More ship today (web-property analytics, browser QA sweeps, self-benchmarking)
|
|
|
221
221
|
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
|
|
222
222
|
|
|
223
223
|
```bash
|
|
224
|
-
npx forgedock
|
|
225
|
-
npx forgedock --global # global install: installs into ~/.claude/commands/ — available in every Claude Code session on this machine
|
|
224
|
+
npx forgedock # checks your environment, installs commands into ~/.claude/commands/ (available in every Claude Code session on this machine), detects your repo, and hands you a reviewed forge.yaml
|
|
226
225
|
```
|
|
227
226
|
|
|
228
|
-
**
|
|
227
|
+
**Install is always global**, to `~/.claude/commands/`. `--global` is still accepted on the command line for backward compatibility but has no effect — there's no other install location to opt out of.
|
|
229
228
|
|
|
230
229
|
> **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
|
|
231
230
|
|
|
@@ -253,15 +252,13 @@ Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock
|
|
|
253
252
|
|
|
254
253
|
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan; with an `ANTHROPIC_API_KEY`, `npx forgedock run` drives the same command specs through a hardened tool-use loop, and `npx forgedock run-issue <issue>` executes them on the durable engine (event-sourced run log, leases, crash-safe resume).
|
|
255
254
|
|
|
256
|
-
**
|
|
255
|
+
**Explicit install command:**
|
|
257
256
|
|
|
258
257
|
```bash
|
|
259
|
-
npx forgedock install #
|
|
260
|
-
npx forgedock install --global # global
|
|
258
|
+
npx forgedock install # installs into ~/.claude/commands/
|
|
259
|
+
npx forgedock install --global # same thing — --global is accepted but is a no-op
|
|
261
260
|
```
|
|
262
261
|
|
|
263
|
-
`uninstall`, `update`, and `doctor` auto-detect which mode is installed — no need to re-specify `--global` after a global install. To explicitly target global: `npx forgedock doctor --global`.
|
|
264
|
-
|
|
265
262
|
**Maintenance:**
|
|
266
263
|
|
|
267
264
|
```bash
|
|
@@ -269,9 +266,9 @@ npx forgedock update # relink commands + refresh the SessionStart hook
|
|
|
269
266
|
npx forgedock enable # turn ForgeDock on for this directory
|
|
270
267
|
npx forgedock disable # turn ForgeDock off for this directory
|
|
271
268
|
npx forgedock status # show ForgeDock's state for this directory
|
|
272
|
-
npx forgedock doctor # installation health check with fix hints
|
|
269
|
+
npx forgedock doctor # installation health check with fix hints
|
|
273
270
|
npx forgedock report # 30-day pipeline impact receipts (--md for Markdown, --json for scripting)
|
|
274
|
-
npx forgedock uninstall # remove commands, the hook, and tracked copies
|
|
271
|
+
npx forgedock uninstall # remove commands, the hook, and tracked copies
|
|
275
272
|
npx forgedock help # show everything
|
|
276
273
|
```
|
|
277
274
|
|
|
@@ -280,31 +277,11 @@ npx forgedock help # show everything
|
|
|
280
277
|
</details>
|
|
281
278
|
|
|
282
279
|
<details>
|
|
283
|
-
<summary><strong>
|
|
284
|
-
|
|
285
|
-
If you installed ForgeDock before project-scoped became the default, your commands live in `~/.claude/commands/`. You have two paths forward:
|
|
286
|
-
|
|
287
|
-
**Keep the global install** — add `--global` going forward and nothing changes:
|
|
288
|
-
|
|
289
|
-
```bash
|
|
290
|
-
npx forgedock --global # install / update globally as before
|
|
291
|
-
```
|
|
280
|
+
<summary><strong>A note on install location</strong></summary>
|
|
292
281
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
```bash
|
|
296
|
-
# 1. Check your current install mode
|
|
297
|
-
npx forgedock status
|
|
298
|
-
|
|
299
|
-
# 2. Remove the global install
|
|
300
|
-
npx forgedock uninstall --global
|
|
301
|
-
|
|
302
|
-
# 3. Install project-scoped in each repo you use ForgeDock in
|
|
303
|
-
cd /path/to/your/project
|
|
304
|
-
npx forgedock
|
|
305
|
-
```
|
|
282
|
+
ForgeDock briefly experimented with a project-scoped-by-default install mode. It was backed out after causing a "split-brain" bug (`detect` assumed project-scoped while the installer still wrote globally — [#1589](https://github.com/RapierCraftStudios/ForgeDock/issues/1589)), so every version you'd realistically install today only ever writes to `~/.claude/commands/`. `--global` is still accepted as a flag for old scripts/muscle memory, but it changes nothing.
|
|
306
283
|
|
|
307
|
-
|
|
284
|
+
If you're carrying an older `--global` habit in scripts or CI, it's harmless to leave it — `npx forgedock --global` and `npx forgedock` do exactly the same thing.
|
|
308
285
|
|
|
309
286
|
</details>
|
|
310
287
|
|
package/bin/cinema.mjs
CHANGED
|
@@ -200,14 +200,32 @@ function badgeText(badge, mode) {
|
|
|
200
200
|
return `${fg(rgb, mode)}[${label}]\x1b[0m`;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Severity-banded fix card (wraps tui.box; border color/weight applied when
|
|
205
|
+
* able).
|
|
206
|
+
*
|
|
207
|
+
* - "error" (default): ember/red bordered box — current look, unchanged.
|
|
208
|
+
* - "warning": amber bordered box — lighter than error, still boxed.
|
|
209
|
+
* - "info": no border — plain, dimmed lines only.
|
|
210
|
+
*
|
|
211
|
+
* @param {string[]} lines
|
|
212
|
+
* @param {string} mode
|
|
213
|
+
* @param {'error'|'warning'|'info'} [severity]
|
|
214
|
+
*/
|
|
215
|
+
export function fixCard(lines, mode, severity = "error") {
|
|
216
|
+
if (severity === "info") {
|
|
217
|
+
const padded = lines.map((l) => ` ${l} `);
|
|
218
|
+
if (mode === "none") return padded.join("\n");
|
|
219
|
+
return padded.map((l) => `\x1b[2m${l}\x1b[22m`).join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
205
222
|
const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
|
|
206
223
|
if (mode === "none") return boxed;
|
|
207
|
-
const
|
|
224
|
+
const rgb = severity === "warning" ? [232, 192, 96] : [255, 107, 53];
|
|
225
|
+
const color = fg(rgb, mode);
|
|
208
226
|
return boxed
|
|
209
227
|
.split("\n")
|
|
210
|
-
.map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${
|
|
228
|
+
.map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${color}${m}\x1b[0m`))
|
|
211
229
|
.join("\n");
|
|
212
230
|
}
|
|
213
231
|
|
|
@@ -253,7 +271,7 @@ export async function revealRows(
|
|
|
253
271
|
}
|
|
254
272
|
writer.write(formatRow(row.label, res, mode) + "\n");
|
|
255
273
|
if (!res.ok && res.fix && res.fix.length > 0) {
|
|
256
|
-
writer.write(fixCard(res.fix, mode) + "\n");
|
|
274
|
+
writer.write(fixCard(res.fix, mode, res.severity || "error") + "\n");
|
|
257
275
|
}
|
|
258
276
|
results.push(res);
|
|
259
277
|
}
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -6,17 +6,48 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { readdirSync, statSync } from "node:fs";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
import { runIssue } from "./engine.mjs";
|
|
12
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
|
+
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
18
|
+
* Shared by resume-stalled's candidate enumeration and countEngineActivity()
|
|
19
|
+
* (the re-entry dashboard's in-flight/stalled counter, #1945) so both surfaces
|
|
20
|
+
* agree on what "in-flight" means — a single source of truth rather than two
|
|
21
|
+
* lists that can silently drift apart.
|
|
22
|
+
*/
|
|
23
|
+
export const ACTIVE_WORKFLOW_LABELS = [
|
|
24
|
+
"workflow:investigating",
|
|
25
|
+
"workflow:ready-to-build",
|
|
26
|
+
"workflow:building",
|
|
27
|
+
"workflow:in-review",
|
|
28
|
+
];
|
|
13
29
|
|
|
14
30
|
const pexec = promisify(execFile);
|
|
15
31
|
|
|
16
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Real gh/git accessors.
|
|
34
|
+
*
|
|
35
|
+
* `timeout: 10000` matches the convention already used for the sibling
|
|
36
|
+
* `gh pr list` call in the interactive dashboard (bin/forgedock.mjs's
|
|
37
|
+
* gatherDashboardData()). Every io.gh()/io.git() call site issues a single,
|
|
38
|
+
* normally-fast metadata command (gh issue view, gh api .../comments,
|
|
39
|
+
* gh pr list/view, git rev-list --count) — none are legitimately
|
|
40
|
+
* long-running — so one shared timeout is appropriate across all callers
|
|
41
|
+
* (the interactive status dashboard, run-issue, and resume-stalled). A
|
|
42
|
+
* hung/black-holed gh process (flaky network, corporate proxy, stuck auth
|
|
43
|
+
* prompt) would otherwise hang commands like `npx forgedock status`
|
|
44
|
+
* indefinitely (#1962). Callers already wrap every io.gh()/io.git() call in
|
|
45
|
+
* try/catch and degrade to null/"unknown" on any rejection, so a timeout
|
|
46
|
+
* fails closed gracefully with no additional error handling required.
|
|
47
|
+
*/
|
|
17
48
|
export function makeIo() {
|
|
18
49
|
const run = (bin) => async (args) => {
|
|
19
|
-
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
|
|
50
|
+
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024, timeout: 10000 });
|
|
20
51
|
return stdout;
|
|
21
52
|
};
|
|
22
53
|
return { gh: run("gh"), git: run("git") };
|
|
@@ -88,6 +119,106 @@ export async function scanStalls(issues, io, now) {
|
|
|
88
119
|
return stalled;
|
|
89
120
|
}
|
|
90
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Read-only in-flight/stalled counter for the `status`/`install` re-entry
|
|
124
|
+
* dashboard (#1945). Reuses the exact same enumeration + lease-expiry logic
|
|
125
|
+
* as `resumeStalledFromCli` (workflow-label search via `gh issue list`, then
|
|
126
|
+
* `scanStalls` against the GitHub-authoritative FORGE:STATE via the
|
|
127
|
+
* projector) — GitHub is this codebase's source of truth for run state, not
|
|
128
|
+
* the local `~/.forge/runs` hot-path log, so the dashboard counts the same
|
|
129
|
+
* way the orchestrator does. Never dispatches or mutates anything.
|
|
130
|
+
*
|
|
131
|
+
* Best-effort: a `gh issue list` failure for one label is swallowed (treated
|
|
132
|
+
* as zero matches for that label) — consistent with resumeStalledFromCli's
|
|
133
|
+
* existing tolerance — so one bad label search never blocks the others.
|
|
134
|
+
*
|
|
135
|
+
* @param {{gh: Function}} io
|
|
136
|
+
* @param {string|null} repo - "owner/repo", or null to use gh's cwd-resolved default
|
|
137
|
+
* @param {number} now
|
|
138
|
+
* @returns {Promise<{total: number, inFlight: number, stalled: number}>}
|
|
139
|
+
*/
|
|
140
|
+
export async function countEngineActivity(io, repo, now) {
|
|
141
|
+
const repoFlag = repo ? ["--repo", repo] : [];
|
|
142
|
+
const issueSet = new Set();
|
|
143
|
+
|
|
144
|
+
for (const label of ACTIVE_WORKFLOW_LABELS) {
|
|
145
|
+
try {
|
|
146
|
+
const out = await io.gh([
|
|
147
|
+
"issue", "list",
|
|
148
|
+
...repoFlag,
|
|
149
|
+
"--state", "open",
|
|
150
|
+
"--label", label,
|
|
151
|
+
"--limit", "100",
|
|
152
|
+
"--json", "number",
|
|
153
|
+
]);
|
|
154
|
+
const items = JSON.parse(out);
|
|
155
|
+
for (const { number } of items) issueSet.add(number);
|
|
156
|
+
} catch {
|
|
157
|
+
// gh may return non-zero when no issues match, or fail transiently —
|
|
158
|
+
// treat as empty for this label rather than aborting the whole count.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (issueSet.size === 0) return { total: 0, inFlight: 0, stalled: 0 };
|
|
163
|
+
|
|
164
|
+
const projector = makeProjector(io);
|
|
165
|
+
const candidates = [...issueSet];
|
|
166
|
+
const stalled = await scanStalls(candidates, projector, now);
|
|
167
|
+
return { total: candidates.length, inFlight: candidates.length - stalled.length, stalled: stalled.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Best-effort summary of the most recently modified local run-log under
|
|
172
|
+
* `~/.forge/runs` (runDir()). This is a MACHINE-LOCAL view — the crash-safe
|
|
173
|
+
* hot-path log described in engine/runlog.mjs, not the GitHub-authoritative
|
|
174
|
+
* state used by countEngineActivity() above. Appropriate only for a "what did
|
|
175
|
+
* I last run here" dashboard row, not for cross-machine in-flight/stalled
|
|
176
|
+
* counts.
|
|
177
|
+
*
|
|
178
|
+
* Never throws: returns null when the runs dir doesn't exist, holds no
|
|
179
|
+
* `.jsonl` files, or the newest file can't be read/parsed. A single corrupt
|
|
180
|
+
* or unreadable file is skipped in favor of the next-newest one rather than
|
|
181
|
+
* failing the whole lookup (forge#1528 — batch operations here must not abort
|
|
182
|
+
* on one bad item).
|
|
183
|
+
*
|
|
184
|
+
* @param {string} dir - runDir()
|
|
185
|
+
* @returns {{issue: number, terminal: boolean, terminalReason: string|null} | null}
|
|
186
|
+
*/
|
|
187
|
+
export function lastLocalRun(dir) {
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sort newest-first by mtime so a corrupt newest file falls through to the
|
|
196
|
+
// next-newest candidate instead of giving up entirely.
|
|
197
|
+
const withMtime = [];
|
|
198
|
+
for (const f of entries) {
|
|
199
|
+
try {
|
|
200
|
+
withMtime.push({ f, mtime: statSync(join(dir, f)).mtimeMs });
|
|
201
|
+
} catch {
|
|
202
|
+
// unreadable — skip
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
206
|
+
|
|
207
|
+
for (const { f } of withMtime) {
|
|
208
|
+
const issue = parseInt(f.slice(0, -".jsonl".length), 10);
|
|
209
|
+
if (!Number.isInteger(issue)) continue;
|
|
210
|
+
try {
|
|
211
|
+
const events = readLog(dir, issue);
|
|
212
|
+
if (events.length === 0) continue;
|
|
213
|
+
const state = deriveState(events);
|
|
214
|
+
return { issue, terminal: state.terminal, terminalReason: state.terminalReason };
|
|
215
|
+
} catch {
|
|
216
|
+
continue; // corrupt file — try the next-newest
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
91
222
|
/**
|
|
92
223
|
* @param {string[]} argv
|
|
93
224
|
* @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
|
|
@@ -147,18 +278,13 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
147
278
|
const projector = makeProjector(io);
|
|
148
279
|
const now = Date.now();
|
|
149
280
|
|
|
150
|
-
// Collect candidate issue numbers from all non-terminal workflow labels
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"workflow:ready-to-build",
|
|
154
|
-
"workflow:building",
|
|
155
|
-
"workflow:in-review",
|
|
156
|
-
];
|
|
157
|
-
|
|
281
|
+
// Collect candidate issue numbers from all non-terminal workflow labels
|
|
282
|
+
// (ACTIVE_WORKFLOW_LABELS — shared with countEngineActivity() so both
|
|
283
|
+
// surfaces agree on what "in-flight" means).
|
|
158
284
|
const repoFlag = repo ? ["--repo", repo] : [];
|
|
159
285
|
const issueSet = new Set();
|
|
160
286
|
|
|
161
|
-
for (const label of
|
|
287
|
+
for (const label of ACTIVE_WORKFLOW_LABELS) {
|
|
162
288
|
try {
|
|
163
289
|
const out = await io.gh([
|
|
164
290
|
"issue", "list",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* env-detect.mjs — Pure deterministic environment detection for ForgeDock.
|
|
4
|
+
*
|
|
5
|
+
* Exports a single function: detectEnvironment(opts)
|
|
6
|
+
*
|
|
7
|
+
* Returns a flat, structured description of the host environment the
|
|
8
|
+
* installer is running in — platform, WSL status, shell, and a best-guess at
|
|
9
|
+
* symlink support. Mirrors the conventions established by init-detect.mjs:
|
|
10
|
+
*
|
|
11
|
+
* Contract guarantees:
|
|
12
|
+
* - Pure: no prompts, no writes; reads only injectable inputs (or their
|
|
13
|
+
* process defaults) — process.platform, os.release(), process.env, and
|
|
14
|
+
* (best-effort) /proc/version.
|
|
15
|
+
* - Safe: every risky read is wrapped in try/catch and degrades to a
|
|
16
|
+
* best-guess default. detectEnvironment() never throws.
|
|
17
|
+
* - Testable: inject `platform`, `env`, `release`, and `readFileSync` to
|
|
18
|
+
* simulate any OS/shell/WSL combination without mocking global process
|
|
19
|
+
* state.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync as fsReadFileSync } from "fs";
|
|
23
|
+
import os from "os";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Detection helpers — each is pure and never throws
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect whether the process is running inside WSL, and which distro.
|
|
31
|
+
*
|
|
32
|
+
* Strategy (first success wins):
|
|
33
|
+
* 1. `WSL_DISTRO_NAME` env var — set by WSL itself, authoritative when present.
|
|
34
|
+
* 2. On `linux`, best-effort read `/proc/version` and test for "microsoft"
|
|
35
|
+
* (case-insensitive) — covers WSL setups that don't export the env var
|
|
36
|
+
* (e.g. processes launched without inheriting the interactive shell's
|
|
37
|
+
* environment).
|
|
38
|
+
*
|
|
39
|
+
* Never throws: a missing/unreadable /proc/version degrades to "not WSL".
|
|
40
|
+
*
|
|
41
|
+
* @param {string} platform
|
|
42
|
+
* @param {NodeJS.ProcessEnv} env
|
|
43
|
+
* @param {(path: string, encoding: string) => string} readFile
|
|
44
|
+
* @returns {{ isWSL: boolean, wslDistro: string | null }}
|
|
45
|
+
*/
|
|
46
|
+
function detectWSL(platform, env, readFile) {
|
|
47
|
+
if (env.WSL_DISTRO_NAME) {
|
|
48
|
+
return { isWSL: true, wslDistro: env.WSL_DISTRO_NAME };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (platform === "linux") {
|
|
52
|
+
try {
|
|
53
|
+
const version = readFile("/proc/version", "utf-8");
|
|
54
|
+
if (/microsoft/i.test(version)) {
|
|
55
|
+
return { isWSL: true, wslDistro: null };
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// /proc/version missing or unreadable — not WSL, or undetectable; degrade quietly.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { isWSL: false, wslDistro: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Derive a human-readable platform label.
|
|
67
|
+
*
|
|
68
|
+
* Windows build numbers >= 22000 report as "Windows 11" (Node's os.release()
|
|
69
|
+
* on win32 returns the kernel version, e.g. "10.0.22631" — Windows 11 never
|
|
70
|
+
* bumped the major.minor past 10.0, only the build number). Anything that
|
|
71
|
+
* doesn't match a recognized pattern falls back to a generic label rather
|
|
72
|
+
* than throwing or guessing wildly.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} platform
|
|
75
|
+
* @param {string} release
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function detectPlatformLabel(platform, release) {
|
|
79
|
+
switch (platform) {
|
|
80
|
+
case "win32": {
|
|
81
|
+
const buildMatch = String(release).match(/^10\.0\.(\d+)/);
|
|
82
|
+
if (buildMatch && Number(buildMatch[1]) >= 22000) return "Windows 11";
|
|
83
|
+
if (String(release).startsWith("10.")) return "Windows 10";
|
|
84
|
+
return "Windows";
|
|
85
|
+
}
|
|
86
|
+
case "darwin":
|
|
87
|
+
return "macOS";
|
|
88
|
+
case "linux":
|
|
89
|
+
return "Linux";
|
|
90
|
+
default:
|
|
91
|
+
return platform;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Best-effort shell name detection.
|
|
97
|
+
*
|
|
98
|
+
* Strategy (first success wins):
|
|
99
|
+
* 1. `$SHELL` — set on POSIX (bash/zsh/fish/...) and also by Git-Bash/MSYS
|
|
100
|
+
* environments on Windows. Basename it (strip any path).
|
|
101
|
+
* 2. On `win32` without `$SHELL`: `$PSModulePath` presence indicates
|
|
102
|
+
* PowerShell; otherwise `$ComSpec` presence indicates cmd.exe.
|
|
103
|
+
* 3. "unknown" — no shell signal found.
|
|
104
|
+
*
|
|
105
|
+
* Never throws.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} platform
|
|
108
|
+
* @param {NodeJS.ProcessEnv} env
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function detectShell(platform, env) {
|
|
112
|
+
if (env.SHELL) {
|
|
113
|
+
const parts = String(env.SHELL).split(/[\\/]/);
|
|
114
|
+
const name = parts[parts.length - 1];
|
|
115
|
+
return name || env.SHELL;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (platform === "win32") {
|
|
119
|
+
if (env.PSModulePath) return "PowerShell";
|
|
120
|
+
if (env.ComSpec) return "cmd";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Public API
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {{
|
|
132
|
+
* platform: string,
|
|
133
|
+
* platformLabel: string,
|
|
134
|
+
* isWSL: boolean,
|
|
135
|
+
* wslDistro: string | null,
|
|
136
|
+
* shell: string,
|
|
137
|
+
* symlinkSupport: boolean,
|
|
138
|
+
* }} EnvironmentInfo
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Detect the current host environment: platform, WSL status, shell, and a
|
|
143
|
+
* best-guess at symlink support. Never throws — every risky read degrades to
|
|
144
|
+
* a best-guess default.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} [opts]
|
|
147
|
+
* @param {string} [opts.platform] - Defaults to `process.platform`. Inject to test other OSes.
|
|
148
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Defaults to `process.env`. Inject to test WSL/shell scenarios.
|
|
149
|
+
* @param {string} [opts.release] - Defaults to `os.release()`. Inject to force a specific Windows
|
|
150
|
+
* build number (e.g. "10.0.22631") so Windows 10 vs 11 labeling is deterministically testable.
|
|
151
|
+
* @param {(path: string, encoding: string) => string} [opts.readFileSync] - Defaults to `fs.readFileSync`.
|
|
152
|
+
* Inject to simulate /proc/version contents (or failures) without touching the real filesystem.
|
|
153
|
+
* @returns {EnvironmentInfo}
|
|
154
|
+
*/
|
|
155
|
+
export function detectEnvironment(opts = {}) {
|
|
156
|
+
const platform = opts.platform ?? process.platform;
|
|
157
|
+
const env = opts.env ?? process.env;
|
|
158
|
+
let release = opts.release;
|
|
159
|
+
if (release === undefined) {
|
|
160
|
+
try {
|
|
161
|
+
release = os.release();
|
|
162
|
+
} catch {
|
|
163
|
+
release = "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const readFile = opts.readFileSync ?? fsReadFileSync;
|
|
167
|
+
|
|
168
|
+
let isWSL = false;
|
|
169
|
+
let wslDistro = null;
|
|
170
|
+
try {
|
|
171
|
+
({ isWSL, wslDistro } = detectWSL(platform, env, readFile));
|
|
172
|
+
} catch {
|
|
173
|
+
// Best-effort — degrade to "not WSL" on any unexpected failure.
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let platformLabel = platform;
|
|
177
|
+
try {
|
|
178
|
+
platformLabel = detectPlatformLabel(platform, release);
|
|
179
|
+
} catch {
|
|
180
|
+
platformLabel = platform;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let shell = "unknown";
|
|
184
|
+
try {
|
|
185
|
+
shell = detectShell(platform, env);
|
|
186
|
+
} catch {
|
|
187
|
+
shell = "unknown";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Best-guess only — POSIX filesystems support symlinks unconditionally;
|
|
191
|
+
// Windows requires Developer Mode or elevation, which isn't reliably
|
|
192
|
+
// detectable without a live filesystem probe (out of scope: this module is
|
|
193
|
+
// pure/read-only). journey.mjs's forge() already handles the real-world
|
|
194
|
+
// EPERM/EACCES case reactively via a copy fallback.
|
|
195
|
+
const symlinkSupport = platform !== "win32";
|
|
196
|
+
|
|
197
|
+
return { platform, platformLabel, isWSL, wslDistro, shell, symlinkSupport };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Cross-environment path translation (forge#1893)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Translate a WSL-mounted-drive path to its native Windows equivalent.
|
|
206
|
+
*
|
|
207
|
+
* WSL exposes Windows drives under `/mnt/<drive>/...` (lowercase drive
|
|
208
|
+
* letter, single character). This is a live bind mount — `/mnt/c/Users/foo`
|
|
209
|
+
* and `C:\Users\foo` refer to the exact same files on disk. Only that shape
|
|
210
|
+
* is recognized; a plain WSL-native path (`/home/foo/...`) has no Windows
|
|
211
|
+
* equivalent and returns `null` rather than guessing.
|
|
212
|
+
*
|
|
213
|
+
* Pure, never throws.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} p - Absolute POSIX path, e.g. `/mnt/c/Users/foo/repo`.
|
|
216
|
+
* @returns {string | null} e.g. `C:\Users\foo\repo`, or `null` if `p` isn't
|
|
217
|
+
* a `/mnt/<drive>/...` path.
|
|
218
|
+
*/
|
|
219
|
+
export function wslPathToWindows(p) {
|
|
220
|
+
if (!p || typeof p !== "string") return null;
|
|
221
|
+
const m = p.match(/^\/mnt\/([a-z])(\/.*)?$/i);
|
|
222
|
+
if (!m) return null;
|
|
223
|
+
const drive = m[1].toUpperCase();
|
|
224
|
+
const rest = (m[2] || "").replace(/\//g, "\\");
|
|
225
|
+
return `${drive}:${rest}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Translate a native Windows path to its WSL-mounted-drive equivalent.
|
|
230
|
+
*
|
|
231
|
+
* Mirrors `wslPathToWindows()`: `C:\Users\foo\repo` (or the forward-slash
|
|
232
|
+
* variant `C:/Users/foo/repo`, which Node also accepts on win32) becomes
|
|
233
|
+
* `/mnt/c/Users/foo/repo`. Only a `<drive>:` prefix is recognized; anything
|
|
234
|
+
* else (UNC paths, relative paths) returns `null` rather than guessing.
|
|
235
|
+
*
|
|
236
|
+
* Pure, never throws.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} p - Absolute Windows path, e.g. `C:\Users\foo\repo`.
|
|
239
|
+
* @returns {string | null} e.g. `/mnt/c/Users/foo/repo`, or `null` if `p`
|
|
240
|
+
* isn't a `<drive>:...` path.
|
|
241
|
+
*/
|
|
242
|
+
export function windowsPathToWsl(p) {
|
|
243
|
+
if (!p || typeof p !== "string") return null;
|
|
244
|
+
const m = p.match(/^([a-z]):[\\/]?(.*)$/i);
|
|
245
|
+
if (!m) return null;
|
|
246
|
+
const drive = m[1].toLowerCase();
|
|
247
|
+
const rest = m[2].replace(/\\/g, "/");
|
|
248
|
+
return `/mnt/${drive}${rest ? `/${rest}` : ""}`;
|
|
249
|
+
}
|