forgedock 1.5.1 → 1.7.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.
@@ -0,0 +1,435 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { parseInstallTier } from "./journey.mjs";
10
+
11
+ const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
12
+ const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
13
+ const LEGACY_SENTINEL = "<!-- ForgeDock managed";
14
+ const MANIFEST_VERSION = 1;
15
+
16
+ function portablePath(path) {
17
+ return path.replaceAll("\\", "/");
18
+ }
19
+
20
+ function stripJsonc(raw) {
21
+ let result = "";
22
+ let i = 0;
23
+ while (i < raw.length) {
24
+ const ch = raw[i];
25
+ if (ch === '"') {
26
+ result += ch;
27
+ i++;
28
+ while (i < raw.length) {
29
+ const stringChar = raw[i];
30
+ result += stringChar;
31
+ if (stringChar === "\\" && i + 1 < raw.length) {
32
+ i++;
33
+ result += raw[i];
34
+ } else if (stringChar === '"') {
35
+ break;
36
+ }
37
+ i++;
38
+ }
39
+ i++;
40
+ continue;
41
+ }
42
+ if (ch === "/" && raw[i + 1] === "/") {
43
+ while (i < raw.length && raw[i] !== "\n") i++;
44
+ continue;
45
+ }
46
+ if (ch === "/" && raw[i + 1] === "*") {
47
+ i += 2;
48
+ while (i + 1 < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
49
+ if (i + 1 < raw.length) i += 2;
50
+ continue;
51
+ }
52
+ if (ch === ",") {
53
+ let next = i + 1;
54
+ while (/[\s]/.test(raw[next] || "")) next++;
55
+ if (raw[next] === "}" || raw[next] === "]") {
56
+ i++;
57
+ continue;
58
+ }
59
+ }
60
+ result += ch;
61
+ i++;
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export function shellPath(path, platform = process.platform) {
67
+ const portable = portablePath(path);
68
+ if (platform !== "win32") return portable;
69
+ const drive = portable.match(/^([A-Za-z]):\/(.*)$/);
70
+ return drive ? `/${drive[1].toLowerCase()}/${drive[2]}` : portable;
71
+ }
72
+
73
+ function yamlString(value) {
74
+ return JSON.stringify(value.replace(/[\r\n]+/g, " ").trim());
75
+ }
76
+
77
+ function parseDescription(content) {
78
+ const frontmatter = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
79
+ if (!frontmatter) return "";
80
+ for (const line of frontmatter[1].split(/\r?\n/)) {
81
+ const match = line.match(/^description:\s*(.*)$/);
82
+ if (!match) continue;
83
+ return match[1].trim().replace(/^(["'])(.*)\1$/, "$2");
84
+ }
85
+ return "";
86
+ }
87
+
88
+ function isLegacyCommandDefinition(name, definition) {
89
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
90
+ const keys = Object.keys(definition).sort();
91
+ return (
92
+ keys.length === 2 &&
93
+ keys[0] === "description" &&
94
+ keys[1] === "template" &&
95
+ typeof definition.template === "string" &&
96
+ definition.template.includes(`/commands/${name}.md`) &&
97
+ String(definition.description || "").includes("ForgeDock")
98
+ );
99
+ }
100
+
101
+ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
102
+ const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
103
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
104
+ if (env.XDG_CONFIG_HOME) return join(resolve(env.XDG_CONFIG_HOME), "opencode");
105
+ return join(resolvedHome, ".config", "opencode");
106
+ }
107
+
108
+ export function renderOpenCodeCommand({ description, forgeHome, command }) {
109
+ const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
110
+ const commandsPath = portablePath(join(forgeHome, "commands"));
111
+ return `---
112
+ description: ${yamlString(`ForgeDock: ${description}`)}
113
+ agent: build
114
+ ---
115
+ ${COMMAND_SENTINEL}
116
+
117
+ Run the authoritative ForgeDock workflow at \`${specPath}\` with these exact arguments:
118
+
119
+ $ARGUMENTS
120
+
121
+ Use \`read\` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.
122
+
123
+ OpenCode runtime mapping:
124
+
125
+ - \`Skill(skill="x", args="y")\` means lazily read \`${commandsPath}/\${x.replaceAll(":", "/")}.md\` and execute that workflow in the current context with the exact arguments. Colon separators become slash separators; existing slash separators remain unchanged. This matches Claude Code Skill's in-conversation loading; it is not a reason to spawn a subagent.
126
+ - \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool. Preserve requested isolation and parallelism, use \`general\` for implementation/review and \`explore\` for read-only discovery, and resume by task ID when requested. If background tasks are unavailable, launch independent foreground tasks concurrently where possible and use the workflow's GitHub-label polling fallback; never inline a required isolated review.
127
+ - Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
128
+ - OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
129
+ - If a Claude-version, Claude-transcript, or Claude-cache rule has no OpenCode equivalent, ignore only that runtime-specific optimization and preserve the workflow invariant it was intended to protect.
130
+ `;
131
+ }
132
+
133
+ export function renderOpenCodePlugin(forgeHome) {
134
+ const gitBashHome = shellPath(forgeHome, "win32");
135
+ return `${PLUGIN_SENTINEL}
136
+ import { existsSync } from "node:fs"
137
+ import { join } from "node:path"
138
+
139
+ const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
140
+ const GIT_BASH_FORGE_HOME = ${JSON.stringify(gitBashHome)}
141
+ let shellForgeHome = NATIVE_FORGE_HOME
142
+
143
+ export const ForgeDockPlugin = async () => ({
144
+ config: async (config) => {
145
+ if (config.subagent_depth === undefined) config.subagent_depth = 2
146
+ if (process.platform === "win32" && !config.shell) {
147
+ const candidates = [
148
+ process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
149
+ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe"),
150
+ ].filter(Boolean)
151
+ const gitBash = candidates.find((candidate) => existsSync(candidate))
152
+ if (gitBash) config.shell = gitBash
153
+ }
154
+ if (/bash(?:\.exe)?$/i.test(String(config.shell || ""))) {
155
+ shellForgeHome = GIT_BASH_FORGE_HOME
156
+ }
157
+ },
158
+ "shell.env": async (_input, output) => {
159
+ output.env.FORGE_HOME = shellForgeHome
160
+ output.env.FORGE_RUNTIME = "opencode"
161
+ },
162
+ })
163
+ `;
164
+ }
165
+
166
+ async function atomicWrite(path, content) {
167
+ await mkdir(dirname(path), { recursive: true });
168
+ const tmp = `${path}.forgedock.tmp-${process.pid}`;
169
+ try {
170
+ await writeFile(tmp, content, "utf8");
171
+ await rename(tmp, path);
172
+ } catch (error) {
173
+ await rm(tmp, { force: true }).catch(() => {});
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ async function readManifest(path) {
179
+ try {
180
+ const value = JSON.parse(await readFile(path, "utf8"));
181
+ if (
182
+ value?.version === MANIFEST_VERSION &&
183
+ Array.isArray(value.files) &&
184
+ value.files.every((file) => typeof file === "string") &&
185
+ typeof value.digest === "string"
186
+ ) return value;
187
+ } catch {
188
+ // A missing or malformed manifest means there are no trusted owned files.
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function digestFiles(files) {
194
+ return createHash("sha256")
195
+ .update(
196
+ [...files]
197
+ .sort((a, b) => a.rel.localeCompare(b.rel))
198
+ .map((item) => `${item.rel}\0${item.content}`)
199
+ .join("\0"),
200
+ )
201
+ .digest("hex");
202
+ }
203
+
204
+ function pathInside(root, candidate) {
205
+ const rel = relative(resolve(root), resolve(candidate));
206
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
207
+ }
208
+
209
+ async function isManagedFile(path) {
210
+ try {
211
+ const content = await readFile(path, "utf8");
212
+ return content.includes(COMMAND_SENTINEL) || content.includes(PLUGIN_SENTINEL);
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ async function removeOwnedFiles(configDir, files) {
219
+ let removed = 0;
220
+ for (const rel of files) {
221
+ const path = join(configDir, rel);
222
+ if (!pathInside(configDir, path) || !(await isManagedFile(path))) continue;
223
+ await unlink(path).catch((error) => {
224
+ if (error.code !== "ENOENT") throw error;
225
+ });
226
+ removed++;
227
+ }
228
+ return removed;
229
+ }
230
+
231
+ async function discoverEntrypoints(forgeHome, includeExtras) {
232
+ const commandsDir = join(forgeHome, "commands");
233
+ const entries = await readdir(commandsDir, { withFileTypes: true });
234
+ const commands = [];
235
+ for (const entry of entries) {
236
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
237
+ const source = join(commandsDir, entry.name);
238
+ const tier = parseInstallTier(source);
239
+ if (tier === "internal" || (tier === "extras" && !includeExtras)) continue;
240
+ const content = await readFile(source, "utf8");
241
+ const description = parseDescription(content);
242
+ if (!description) continue;
243
+ commands.push({
244
+ name: entry.name.slice(0, -3),
245
+ description,
246
+ tier,
247
+ });
248
+ }
249
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
250
+ }
251
+
252
+ async function migrateLegacyAdapter({ configDir, home }) {
253
+ const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
254
+ const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
255
+ const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
256
+ let legacyInstructionsOwned = false;
257
+ try {
258
+ const content = await readFile(legacyInstructions, "utf8");
259
+ if (content.includes(LEGACY_SENTINEL)) {
260
+ legacyInstructionsOwned = true;
261
+ }
262
+ } catch (error) {
263
+ if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
264
+ }
265
+
266
+ const configPaths = new Set([
267
+ join(resolvedHome, ".config", "opencode", "opencode.json"),
268
+ join(configDir, "opencode.json"),
269
+ ]);
270
+ const legacyCommands = new Set(["work-on", "review-pr", "quality-gate", "orchestrate"]);
271
+ const staged = [];
272
+ let parseFailed = false;
273
+ for (const configPath of configPaths) {
274
+ if (!existsSync(configPath)) continue;
275
+ let config;
276
+ try {
277
+ config = JSON.parse(stripJsonc(await readFile(configPath, "utf8")));
278
+ } catch (error) {
279
+ result.warnings.push(`Could not migrate legacy entries in ${configPath}: ${error.message}`);
280
+ parseFailed = true;
281
+ continue;
282
+ }
283
+
284
+ let changed = false;
285
+ if (legacyInstructionsOwned && Array.isArray(config.instructions)) {
286
+ const before = config.instructions.length;
287
+ config.instructions = config.instructions.filter((item) => {
288
+ if (typeof item !== "string") return true;
289
+ return portablePath(resolve(item)) !== portablePath(resolve(legacyInstructions));
290
+ });
291
+ const removed = before - config.instructions.length;
292
+ result.removedConfigEntries += removed;
293
+ changed ||= removed > 0;
294
+ if (config.instructions.length === 0) delete config.instructions;
295
+ }
296
+ if (config.command && typeof config.command === "object" && !Array.isArray(config.command)) {
297
+ for (const name of legacyCommands) {
298
+ const definition = config.command[name];
299
+ if (isLegacyCommandDefinition(name, definition)) {
300
+ delete config.command[name];
301
+ result.removedConfigEntries++;
302
+ changed = true;
303
+ }
304
+ }
305
+ if (Object.keys(config.command).length === 0) delete config.command;
306
+ }
307
+ if (changed) {
308
+ staged.push({
309
+ path: configPath,
310
+ original: await readFile(configPath, "utf8"),
311
+ content: `${JSON.stringify(config, null, 2)}\n`,
312
+ });
313
+ }
314
+ }
315
+ if (parseFailed) return result;
316
+ const written = [];
317
+ try {
318
+ for (const item of staged) {
319
+ await atomicWrite(item.path, item.content);
320
+ written.push(item);
321
+ }
322
+ } catch (error) {
323
+ for (const item of written) await atomicWrite(item.path, item.original).catch(() => {});
324
+ result.warnings.push(`Could not write legacy migration: ${error.message}`);
325
+ return result;
326
+ }
327
+ if (legacyInstructionsOwned) {
328
+ await unlink(legacyInstructions);
329
+ result.removedInstructionsFile = true;
330
+ }
331
+ return result;
332
+ }
333
+
334
+ export async function installOpenCodeAdapter({
335
+ forgeHome,
336
+ home,
337
+ env = process.env,
338
+ includeExtras = false,
339
+ } = {}) {
340
+ if (!forgeHome) throw new Error("forgeHome is required");
341
+ if (!existsSync(join(forgeHome, "commands"))) {
342
+ throw new Error(`ForgeDock commands directory not found: ${join(forgeHome, "commands")}`);
343
+ }
344
+
345
+ const configDir = resolveOpenCodeConfigDir({ home, env });
346
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
347
+ const previous = (await readManifest(manifestPath)) || { files: [] };
348
+ const commands = await discoverEntrypoints(forgeHome, includeExtras);
349
+ const rendered = [];
350
+
351
+ for (const command of commands) {
352
+ const rel = portablePath(join("commands", "forge", `${command.name}.md`));
353
+ const content = renderOpenCodeCommand({
354
+ command: command.name,
355
+ description: command.description,
356
+ forgeHome,
357
+ });
358
+ rendered.push({ rel, content });
359
+ }
360
+
361
+ const pluginRel = portablePath(join("plugins", "forgedock.js"));
362
+ const pluginContent = renderOpenCodePlugin(forgeHome);
363
+ rendered.push({ rel: pluginRel, content: pluginContent });
364
+
365
+ // Preflight every collision before the first write so a rejected install
366
+ // cannot leave unmanifested command files behind.
367
+ for (const item of rendered) {
368
+ const path = join(configDir, item.rel);
369
+ if (existsSync(path) && !(await isManagedFile(path))) {
370
+ throw new Error(`Refusing to overwrite user-owned OpenCode file: ${path}`);
371
+ }
372
+ }
373
+ for (const item of rendered) await atomicWrite(join(configDir, item.rel), item.content);
374
+
375
+ const nextFiles = rendered.map((item) => item.rel).sort();
376
+ const stale = previous.files.filter((file) => !nextFiles.includes(file));
377
+ const removed = await removeOwnedFiles(configDir, stale);
378
+ const digest = digestFiles(rendered);
379
+ const manifest = {
380
+ version: MANIFEST_VERSION,
381
+ forgeHome,
382
+ includeExtras,
383
+ commandCount: commands.length,
384
+ files: nextFiles,
385
+ digest,
386
+ };
387
+ await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
388
+ const migration = await migrateLegacyAdapter({ configDir, home });
389
+
390
+ return { configDir, manifestPath, commandCount: commands.length, removed, digest, migration };
391
+ }
392
+
393
+ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
394
+ const configDir = resolveOpenCodeConfigDir({ home, env });
395
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
396
+ if (!existsSync(manifestPath)) return { installed: false, healthy: false, configDir, missing: [] };
397
+ const manifest = await readManifest(manifestPath);
398
+ if (!manifest) {
399
+ return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
400
+ }
401
+ const missing = [];
402
+ const current = [];
403
+ for (const rel of manifest.files) {
404
+ const path = join(configDir, rel);
405
+ if (!existsSync(path) || !(await isManagedFile(path))) {
406
+ missing.push(rel);
407
+ continue;
408
+ }
409
+ current.push({ rel, content: await readFile(path, "utf8") });
410
+ }
411
+ const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
412
+ return {
413
+ installed: true,
414
+ healthy: missing.length === 0 && integrity,
415
+ configDir,
416
+ manifest,
417
+ missing,
418
+ integrity: integrity ? "valid" : "digest-mismatch",
419
+ };
420
+ }
421
+
422
+ export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
423
+ const configDir = resolveOpenCodeConfigDir({ home, env });
424
+ const manifestPath = join(configDir, "forgedock", "manifest.json");
425
+ const manifest = (await readManifest(manifestPath)) || { files: [] };
426
+ const removed = await removeOwnedFiles(configDir, manifest.files);
427
+ await unlink(manifestPath).catch((error) => {
428
+ if (error.code !== "ENOENT") throw error;
429
+ });
430
+ await rmdir(join(configDir, "forgedock")).catch((error) => {
431
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
432
+ });
433
+ const migration = await migrateLegacyAdapter({ configDir, home });
434
+ return { configDir, removed, migration };
435
+ }
package/bin/registry.mjs CHANGED
@@ -465,6 +465,93 @@ export async function setPersistedHomeState({ path, version, updatedAt } = {}) {
465
465
  });
466
466
  }
467
467
 
468
+ // ---------------------------------------------------------------------------
469
+ // Package-vs-installed-commands drift detection (issue #2719)
470
+ // ---------------------------------------------------------------------------
471
+ //
472
+ // Single shared source of truth for "is the persisted ~/.forge/ toolset
473
+ // (commands/skills the SessionStart hook and ~/.claude/commands actually
474
+ // load) older than the forgedock package currently running?" — previously
475
+ // this comparison existed only inline inside doctor's Check 5d
476
+ // (bin/forgedock.mjs), so a user who bumped the npm package but never ran
477
+ // `forgedock update` had no way to learn about the drift outside of
478
+ // proactively running `doctor`. This helper is now the one place the
479
+ // comparison lives; doctor, the `version` command, and the SessionStart hook
480
+ // all call it instead of re-deriving the logic (forge#1589 split-brain
481
+ // precedent — see the persistHome() comment above for why that matters).
482
+
483
+ /**
484
+ * Compare the running forgedock package's version against the version last
485
+ * persisted to `~/.forge/` (the copy the SessionStart hook and
486
+ * `~/.claude/commands` actually load from — see persistHome() in
487
+ * bin/journey.mjs).
488
+ *
489
+ * Fail-open: never throws. Any failure to read either version (missing/
490
+ * corrupt package.json, missing/corrupt registry, missing/unreadable
491
+ * `~/.forge/version` file) resolves to `unknown: true` with empty version
492
+ * strings, rather than propagating an error to the caller. This mirrors the
493
+ * fail-open contract required of both doctor (best-effort diagnostics) and
494
+ * the SessionStart hook (must never block a Claude Code session — see
495
+ * forge#383/forge#489).
496
+ *
497
+ * Version comparison delegates to the already-fixed compareVersions() above
498
+ * (forge#1932 — do not reimplement comparison logic here).
499
+ *
500
+ * @param {string} forgeHome - Absolute path to the ForgeDock installation
501
+ * root whose package.json holds the "source" version (the package
502
+ * currently running). Callers pass their own FORGE_HOME.
503
+ * @returns {{
504
+ * persistedVersion: string,
505
+ * sourceVersion: string,
506
+ * isStale: boolean,
507
+ * unknown: boolean
508
+ * }}
509
+ */
510
+ export function getInstalledCommandsDriftStatus(forgeHome) {
511
+ const notFound = { persistedVersion: "", sourceVersion: "", isStale: false, unknown: true };
512
+
513
+ let sourceVersion = "";
514
+ try {
515
+ const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
516
+ sourceVersion = typeof pkg.version === "string" ? pkg.version.trim() : "";
517
+ } catch {
518
+ // Missing/unreadable/corrupt package.json — cannot determine the source
519
+ // version at all; degrade to unknown rather than guessing.
520
+ return notFound;
521
+ }
522
+ if (!sourceVersion) return notFound;
523
+
524
+ // Prefer the version registry.mjs last recorded for persistHome()'s write
525
+ // (the single source of truth update()/install also write through),
526
+ // falling back to reading ~/.forge/version directly on disk when the
527
+ // registry has no entry (e.g. an older ForgeDock version wrote ~/.forge/
528
+ // before forge#1943 added registry tracking). Mirrors doctor's Check 5d.
529
+ let persistedVersion = "";
530
+ try {
531
+ const recorded = getPersistedHomeState();
532
+ persistedVersion = recorded?.version || "";
533
+ } catch {
534
+ // getPersistedHomeState() already fails open to null — this catch is
535
+ // defensive only.
536
+ }
537
+ if (!persistedVersion) {
538
+ try {
539
+ const versionPath = join(HOME, ".forge", "version");
540
+ persistedVersion = readFileSync(versionPath, "utf-8").trim();
541
+ } catch {
542
+ // version file missing/unreadable — no persisted-home version is
543
+ // knowable; degrade to unknown.
544
+ }
545
+ }
546
+
547
+ if (!persistedVersion) {
548
+ return notFound;
549
+ }
550
+
551
+ const isStale = compareVersions(persistedVersion, sourceVersion) < 0;
552
+ return { persistedVersion, sourceVersion, isStale, unknown: false };
553
+ }
554
+
468
555
  // ---------------------------------------------------------------------------
469
556
  // Claude Code version detection and breakpoints resolution (issue #1252)
470
557
  // ---------------------------------------------------------------------------