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.
- package/README.md +13 -2
- package/bin/engine/orchestrate-canary.mjs +15 -0
- package/bin/engine/orchestrate-canary.test.mjs +28 -0
- package/bin/forgedock.mjs +91 -21
- package/bin/hooks/session-start.mjs +22 -1
- package/bin/opencode-adapter.mjs +435 -0
- package/bin/registry.mjs +87 -0
- package/bin/runner.mjs +354 -43
- package/commands/orchestrate/phase-3-dependency.md +29 -4
- package/commands/orchestrate/phase-4-execution.md +197 -26
- package/commands/work-on/build.md +10 -1
- package/commands/work-on.md +10 -1
- package/docs/OPENCODE.md +132 -252
- package/package.json +3 -2
- package/scripts/check-spec-markers.sh +1 -0
|
@@ -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
|
// ---------------------------------------------------------------------------
|