forgedock 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine-cli.mjs +137 -11
  4. package/bin/env-detect.mjs +249 -0
  5. package/bin/forge-utils.mjs +364 -4
  6. package/bin/forgedock.mjs +1064 -135
  7. package/bin/hooks/session-start.mjs +68 -3
  8. package/bin/init-detect.mjs +101 -12
  9. package/bin/journey.mjs +935 -19
  10. package/bin/recall.mjs +24 -7
  11. package/bin/registry.mjs +91 -3
  12. package/bin/runner.mjs +38 -2
  13. package/bin/tui.mjs +49 -3
  14. package/commands/adopt.md +1 -1
  15. package/commands/analytics.md +8 -8
  16. package/commands/audit.md +1 -1
  17. package/commands/autopilot.md +1 -1
  18. package/commands/ci-audit.md +1 -1
  19. package/commands/cleanup.md +87 -52
  20. package/commands/compat-audit.md +1 -1
  21. package/commands/deploy-info.md +1 -1
  22. package/commands/deploy-pr.md +1 -1
  23. package/commands/diagnose.md +1 -1
  24. package/commands/explain.md +1 -1
  25. package/commands/fix-ci.md +1 -1
  26. package/commands/forgedock-init.md +1 -1
  27. package/commands/incident-response.md +3 -3
  28. package/commands/issue.md +1 -1
  29. package/commands/optimize.md +1 -1
  30. package/commands/orchestrate/config.md +13 -3
  31. package/commands/orchestrate/phase-1-resolve.md +1 -1
  32. package/commands/orchestrate/phase-2-triage.md +1 -1
  33. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  34. package/commands/orchestrate/phase-3-dependency.md +128 -20
  35. package/commands/orchestrate/phase-4-execution.md +598 -43
  36. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  37. package/commands/orchestrate/phase-6-report.md +51 -3
  38. package/commands/orchestrate/safety.md +2 -2
  39. package/commands/qa-sweep.md +1 -1
  40. package/commands/quality-gate.md +1 -1
  41. package/commands/recover-orphans.md +5 -5
  42. package/commands/replay.md +1 -1
  43. package/commands/review-pr-agents/api.md +2 -2
  44. package/commands/review-pr-agents/auth.md +2 -2
  45. package/commands/review-pr-agents/billing.md +2 -2
  46. package/commands/review-pr-agents/concurrency.md +2 -2
  47. package/commands/review-pr-agents/database.md +2 -2
  48. package/commands/review-pr-agents/frontend.md +2 -2
  49. package/commands/review-pr-agents/infra.md +2 -2
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-agents/scraper.md +2 -2
  52. package/commands/review-pr-agents/security.md +2 -2
  53. package/commands/review-pr-staging.md +41 -3
  54. package/commands/review-pr.md +71 -6
  55. package/commands/rollback.md +1 -1
  56. package/commands/scope.md +1 -1
  57. package/commands/security-audit.md +1 -1
  58. package/commands/test-gate.md +1 -1
  59. package/commands/validate.md +1 -1
  60. package/commands/work-on/build/architect.md +1 -1
  61. package/commands/work-on/build/context.md +9 -6
  62. package/commands/work-on/build/implement.md +1 -1
  63. package/commands/work-on/build/validate.md +1 -1
  64. package/commands/work-on/build.md +31 -6
  65. package/commands/work-on/close.md +3 -2
  66. package/commands/work-on/decompose.md +1 -1
  67. package/commands/work-on/investigate.md +14 -5
  68. package/commands/work-on/remediate.md +331 -0
  69. package/commands/work-on/review.md +1 -1
  70. package/commands/work-on-monolithic.md +1 -1
  71. package/commands/work-on.md +194 -56
  72. package/docs/CI.md +2 -2
  73. package/docs/CODEX.md +1 -1
  74. package/docs/CONFIG.md +186 -0
  75. package/docs/site/command-reference.md +31 -1
  76. package/docs/site/getting-started.md +9 -34
  77. package/docs/site/index.md +1 -1
  78. package/docs/spec/label-state-machine.md +1 -1
  79. package/package.json +2 -1
  80. package/packages/protocol/src/types.js +1 -1
  81. package/scripts/check-command-docs-drift.sh +4 -2
  82. package/scripts/danger-zones.mjs +19 -12
  83. package/scripts/transition-label.sh +101 -13
  84. 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 # project-scoped install: checks your environment, installs commands for this repo, detects your repo, and hands you a reviewed forge.yaml
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
- **Project-scoped is the default.** `npx forgedock` installs commands scoped to the current project directory. Use `--global` only when you want the commands available across all your projects on this machine.
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
- **Install modes:**
255
+ **Explicit install command:**
257
256
 
258
257
  ```bash
259
- npx forgedock install # project-scoped: <cwd>/.claude/commands/ (default)
260
- npx forgedock install --global # global: ~/.claude/commands/ (opt-in)
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 (auto-detects mode)
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 (auto-detects mode)
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>Migrating from a global install</strong></summary>
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
- **Switch to project-scoped** (recommended for multi-repo setups):
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
- Run `npx forgedock doctor` after switching to confirm everything is healthy.
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
- /** Ember-bordered fix card (wraps tui.box; border color applied when able). */
204
- export function fixCard(lines, mode) {
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 ember = fg([255, 107, 53], mode);
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) => `${ember}${m}\x1b[0m`))
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
  }
@@ -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
- /** Real gh/git accessors. */
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
- const ACTIVE_LABELS = [
152
- "workflow:investigating",
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 ACTIVE_LABELS) {
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
+ }