forgedock 1.7.0 → 1.7.1
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 +2 -1
- package/bin/engine-cli.mjs +2 -0
- package/bin/forgedock.mjs +109 -46
- package/bin/opencode-adapter.mjs +618 -92
- package/bin/runner.mjs +47 -5
- package/commands/orchestrate/phase-3-dependency.md +26 -9
- package/commands/orchestrate/phase-4-execution.md +42 -3
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +18 -0
- package/commands/review-pr.md +20 -2
- package/docs/CONFIG.md +6 -5
- package/docs/OPENCODE.md +113 -25
- package/package.json +1 -1
package/bin/opencode-adapter.mjs
CHANGED
|
@@ -1,22 +1,77 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
|
-
import { mkdir, readFile,
|
|
6
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
basename,
|
|
10
|
+
dirname,
|
|
11
|
+
isAbsolute,
|
|
12
|
+
join,
|
|
13
|
+
posix,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
sep,
|
|
17
|
+
win32,
|
|
18
|
+
} from "node:path";
|
|
19
|
+
import { findMarkdownFiles } from "./journey.mjs";
|
|
10
20
|
|
|
11
21
|
const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
|
|
22
|
+
const SKILL_SENTINEL = "<!-- forgedock:managed-opencode-skill -->";
|
|
12
23
|
const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
|
|
13
|
-
const LEGACY_SENTINEL = "<!-- ForgeDock managed";
|
|
24
|
+
const LEGACY_SENTINEL = "<!-- ForgeDock managed — do not remove this line -->";
|
|
14
25
|
const MANIFEST_VERSION = 1;
|
|
26
|
+
const ADAPTER_LOCK_STALE_AGE_MS = 30_000;
|
|
27
|
+
const ADAPTER_LOCK_HEARTBEAT_MS = 10_000;
|
|
28
|
+
const ADAPTER_LOCK_RETRY_DELAYS_MS = [10, 20, 40, 80, 150];
|
|
29
|
+
const LEGACY_COMMAND_CONTRACTS = {
|
|
30
|
+
"work-on": {
|
|
31
|
+
description: "Run the ForgeDock full issue pipeline (investigate \u2192 build \u2192 review \u2192 merge)",
|
|
32
|
+
templateSuffix: " and execute the pipeline for issue {{args}}.",
|
|
33
|
+
},
|
|
34
|
+
"review-pr": {
|
|
35
|
+
description: "Run the ForgeDock PR review pipeline",
|
|
36
|
+
templateSuffix: " and execute the PR review for PR {{args}}.",
|
|
37
|
+
},
|
|
38
|
+
"quality-gate": {
|
|
39
|
+
description: "Run ForgeDock pre-commit quality checks",
|
|
40
|
+
templateSuffix: " and run all quality gate checks.",
|
|
41
|
+
},
|
|
42
|
+
orchestrate: {
|
|
43
|
+
description: "Run ForgeDock parallel multi-issue orchestration",
|
|
44
|
+
templateSuffix: " and orchestrate the issues: {{args}}.",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
15
47
|
|
|
16
48
|
function portablePath(path) {
|
|
17
49
|
return path.replaceAll("\\", "/");
|
|
18
50
|
}
|
|
19
51
|
|
|
52
|
+
function legacyPathFlavor(path) {
|
|
53
|
+
if (typeof path !== "string") return null;
|
|
54
|
+
if (posix.isAbsolute(path)) return "posix";
|
|
55
|
+
if (win32.isAbsolute(path)) return "win32";
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeLegacyPath(path, flavor) {
|
|
60
|
+
const pathFlavor = flavor || legacyPathFlavor(path);
|
|
61
|
+
if (!pathFlavor) return null;
|
|
62
|
+
const pathApi = pathFlavor === "win32" ? win32 : posix;
|
|
63
|
+
if (!pathApi.isAbsolute(path)) return null;
|
|
64
|
+
|
|
65
|
+
let normalized = pathApi.normalize(path);
|
|
66
|
+
if (pathFlavor === "win32") {
|
|
67
|
+
normalized = portablePath(normalized).toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
if (normalized !== "/" && !/^[a-z]:\/$/i.test(normalized)) {
|
|
70
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
20
75
|
function stripJsonc(raw) {
|
|
21
76
|
let result = "";
|
|
22
77
|
let i = 0;
|
|
@@ -85,17 +140,51 @@ function parseDescription(content) {
|
|
|
85
140
|
return "";
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Map a source workflow path to OpenCode's native skill-name contract.
|
|
145
|
+
* OpenCode skill names cannot contain path separators, so nested source paths
|
|
146
|
+
* use a stable hyphen separator (for example, work-on/investigate).
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeOpenCodeSkillName(command) {
|
|
149
|
+
const name = portablePath(command)
|
|
150
|
+
.replace(/\.md$/i, "")
|
|
151
|
+
.split("/")
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.map((segment) => segment.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join("-")
|
|
156
|
+
.replace(/-{2,}/g, "-");
|
|
157
|
+
if (!name) throw new Error(`Cannot register empty OpenCode skill name for workflow: ${command}`);
|
|
158
|
+
if (name.length > 64) {
|
|
159
|
+
throw new Error(`OpenCode skill name exceeds 64 characters for workflow: ${command}`);
|
|
160
|
+
}
|
|
161
|
+
return name;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isLegacyCommandDefinition(name, definition, forgeHome) {
|
|
89
165
|
if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
|
|
90
166
|
const keys = Object.keys(definition).sort();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
keys
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
167
|
+
const contract = LEGACY_COMMAND_CONTRACTS[name];
|
|
168
|
+
if (
|
|
169
|
+
!contract ||
|
|
170
|
+
keys.length !== 2 ||
|
|
171
|
+
keys[0] !== "description" ||
|
|
172
|
+
keys[1] !== "template" ||
|
|
173
|
+
definition.description !== contract.description ||
|
|
174
|
+
typeof definition.template !== "string"
|
|
175
|
+
) return false;
|
|
176
|
+
|
|
177
|
+
const template = portablePath(definition.template);
|
|
178
|
+
const suffix = `/commands/${name}.md${contract.templateSuffix}`;
|
|
179
|
+
if (!template.startsWith("Read ") || !template.endsWith(suffix)) return false;
|
|
180
|
+
const pathFlavor = legacyPathFlavor(forgeHome);
|
|
181
|
+
if (!pathFlavor) return false;
|
|
182
|
+
const rawSuffix = definition.template.slice(-suffix.length);
|
|
183
|
+
if (portablePath(rawSuffix) !== suffix || (pathFlavor === "posix" && rawSuffix !== suffix)) return false;
|
|
184
|
+
const expectedHome = normalizeLegacyPath(forgeHome);
|
|
185
|
+
if (!expectedHome) return false;
|
|
186
|
+
const templateHome = definition.template.slice("Read ".length, -suffix.length);
|
|
187
|
+
return normalizeLegacyPath(templateHome, pathFlavor) === expectedHome;
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
|
|
@@ -105,25 +194,71 @@ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
|
|
|
105
194
|
return join(resolvedHome, ".config", "opencode");
|
|
106
195
|
}
|
|
107
196
|
|
|
197
|
+
function openCodeReviewDispatchContract() {
|
|
198
|
+
return [
|
|
199
|
+
"OpenCode review-dispatch override:",
|
|
200
|
+
"Before applying the workflow's Claude-specific Task/Agent availability check, treat an OpenCode runtime marker (`FORGE_RUNTIME=opencode`, `OPENCODE_SESSION_ID`, `OPENCODE_PID`, or `OPENCODE`) as native capability context and set `DISPATCH_TOOL=task`.",
|
|
201
|
+
"Do not enter the `Neither tool is available` branch solely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background: true }`; use `general` for implementation/review and `explore` for read-only discovery.",
|
|
202
|
+
"Use `background: true` for independent orchestration and review work. OpenCode returns a running task marker immediately and injects a later task-result event into the parent; process each event immediately instead of waiting for the slowest sibling.",
|
|
203
|
+
"If lowercase native `task` is genuinely absent from the current tool registry, post `FORGE:REVIEW_BLOCKED` and stop; never replace the required isolated review with inline work or another controller.",
|
|
204
|
+
].join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
108
207
|
export function renderOpenCodeCommand({ description, forgeHome, command }) {
|
|
109
208
|
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
110
209
|
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
210
|
+
const nativeSkillExpression = '${x.replaceAll(":", "-").replaceAll("/", "-")}';
|
|
211
|
+
return [
|
|
212
|
+
"---",
|
|
213
|
+
`description: ${yamlString(`ForgeDock: ${description}`)}`,
|
|
214
|
+
"agent: build",
|
|
215
|
+
"---",
|
|
216
|
+
COMMAND_SENTINEL,
|
|
217
|
+
"",
|
|
218
|
+
"Run the authoritative ForgeDock workflow at `" + specPath + "` with these exact arguments:",
|
|
219
|
+
"",
|
|
220
|
+
"$ARGUMENTS",
|
|
221
|
+
"",
|
|
222
|
+
"Use `read` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.",
|
|
223
|
+
"",
|
|
224
|
+
"OpenCode runtime mapping:",
|
|
225
|
+
"",
|
|
226
|
+
"- `Skill(skill=\"x\", args=\"y\")` means use the registered native OpenCode skill named `" + nativeSkillExpression + "` in the current context with the exact arguments. Its authoritative source is `" + commandsPath + "/${x.replaceAll(\":\", \"/\")}.md`. If the native skill or source is unavailable, stop with `FORGE_OPENCODE_CAPABILITY_ERROR` and an actionable path; never invoke `forgedock run-issue`, `npx forgedock run-issue`, or recursive `opencode run` as a fallback.",
|
|
227
|
+
"- `Task(...)` or a permitted `Agent(...)` means use OpenCode's `task` tool with a top-level argument object shaped like `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background: true }`. `subagent_type` is mandatory: map Claude `general-purpose` to `general` for implementation/review and `codebase-explorer` to `explore` for read-only discovery. If the source omits a type, set `subagent_type: \"general\"` before calling the tool; never emit a call containing only `description` and `prompt`. Unsupported types must stop with `FORGE_OPENCODE_CAPABILITY_ERROR`. Preserve requested isolation and parallelism, resume by task ID when requested, and never inline a required isolated review.",
|
|
228
|
+
openCodeReviewDispatchContract(),
|
|
229
|
+
"- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.",
|
|
230
|
+
"- OpenCode injects `FORGE_HOME` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.",
|
|
231
|
+
"- 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.",
|
|
232
|
+
].join("\n") + "\n";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function renderOpenCodeSkill({ description, forgeHome, command }) {
|
|
236
|
+
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
237
|
+
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
238
|
+
const name = normalizeOpenCodeSkillName(command);
|
|
111
239
|
return `---
|
|
240
|
+
name: ${name}
|
|
112
241
|
description: ${yamlString(`ForgeDock: ${description}`)}
|
|
113
|
-
|
|
242
|
+
compatibility: opencode
|
|
243
|
+
metadata:
|
|
244
|
+
forgedock: "managed"
|
|
245
|
+
source: ${yamlString(command)}
|
|
114
246
|
---
|
|
115
|
-
${
|
|
247
|
+
${SKILL_SENTINEL}
|
|
116
248
|
|
|
117
|
-
|
|
249
|
+
Load and execute the authoritative ForgeDock workflow at \`${specPath}\` in the current context.
|
|
118
250
|
|
|
119
|
-
|
|
251
|
+
The parent workflow's exact arguments are already present in the current context. Preserve them; do not invent new arguments or launch a second controller. Keep loading token-efficient: read only this workflow and the next spec explicitly reached by its dispatcher.
|
|
120
252
|
|
|
121
|
-
|
|
253
|
+
${openCodeReviewDispatchContract()}
|
|
122
254
|
|
|
255
|
+
If the workflow source or a required native capability is unavailable, stop and report exactly:
|
|
256
|
+
\`FORGE_OPENCODE_CAPABILITY_ERROR\`: ForgeDock workflow \`${command}\` is unavailable at \`${specPath}\`.
|
|
257
|
+
Do not invoke \`forgedock run-issue\`, \`npx forgedock run-issue\`, or recursive \`opencode run\` to recover.
|
|
123
258
|
OpenCode runtime mapping:
|
|
124
259
|
|
|
125
260
|
- \`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
|
|
261
|
+
- \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool with \`{ description, prompt, subagent_type, background }\`. Translate \`general-purpose\` to \`general\` and \`codebase-explorer\` to \`explore\`; never omit \`subagent_type\`. Resume by task ID when requested. A successful background call returns \`<task id=... state="running">\`; a later \`state="completed"\` or \`state="error"\` task result is the completion event for that one issue. Do not wait for all tasks before processing an event.
|
|
127
262
|
- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
|
|
128
263
|
- OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
|
|
129
264
|
- 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.
|
|
@@ -132,17 +267,85 @@ OpenCode runtime mapping:
|
|
|
132
267
|
|
|
133
268
|
export function renderOpenCodePlugin(forgeHome) {
|
|
134
269
|
const gitBashHome = shellPath(forgeHome, "win32");
|
|
270
|
+
const runtimeGuard = String.raw`
|
|
271
|
+
const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR"
|
|
272
|
+
|
|
273
|
+
function commandPattern(executable, subcommand) {
|
|
274
|
+
const assignments = "(?:(?:[A-Za-z_][A-Za-z0-9_]*)=(?:\"[^\"]*\"|'[^']*'|[^\\s;&|]+)\\s+)*"
|
|
275
|
+
const wrappers = "(?:(?:env|command|exec)\\s+)*"
|
|
276
|
+
const npx = "(?:npx(?:\\s+--[^\\s;&|]+)*\\s+)?"
|
|
277
|
+
const path = "(?:(?:[^\\s;&|/\\\\]+[/\\\\])+)?"
|
|
278
|
+
const suffix = subcommand ? "\\s+" + subcommand + "(?:\\s|$)" : "(?:\\s|$)"
|
|
279
|
+
return new RegExp("(?:^|[;&|]\\s*)" + assignments + wrappers + npx + path + executable + "(?:\\.cmd|\\.exe)?" + suffix, "i")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function blockedOperation(command) {
|
|
283
|
+
const normalized = String(command || "").replace(/\\r?\\n/g, " ")
|
|
284
|
+
if (commandPattern("claude", "").test(normalized)) return "claude"
|
|
285
|
+
if (commandPattern("forgedock", "run-issue").test(normalized)) return "forgedock run-issue"
|
|
286
|
+
if (commandPattern("opencode", "run").test(normalized)) return "opencode run"
|
|
287
|
+
return ""
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function capabilityError(operation) {
|
|
291
|
+
const error = new Error(FORGE_OPENCODE_CAPABILITY_ERROR + ": " + operation + " is unavailable in an OpenCode ForgeDock workflow. Use native Skill/Task dispatch instead.")
|
|
292
|
+
error.code = FORGE_OPENCODE_CAPABILITY_ERROR
|
|
293
|
+
return error
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function normalizeTaskArgs(args) {
|
|
297
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
298
|
+
throw capabilityError("task arguments are invalid")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const sourceType = args.subagent_type
|
|
302
|
+
const subagentType = sourceType === undefined || sourceType === null || sourceType === ""
|
|
303
|
+
? "general"
|
|
304
|
+
: sourceType === "general-purpose"
|
|
305
|
+
? "general"
|
|
306
|
+
: sourceType === "codebase-explorer"
|
|
307
|
+
? "explore"
|
|
308
|
+
: sourceType
|
|
309
|
+
|
|
310
|
+
if (subagentType !== "general" && subagentType !== "explore") {
|
|
311
|
+
throw capabilityError("task subagent_type " + String(sourceType) + " is unsupported")
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
args.subagent_type = subagentType
|
|
315
|
+
// The shared OpenCode workflow requires streaming task completions. The only
|
|
316
|
+
// supported opt-out is the explicit environment flag, so an omitted or
|
|
317
|
+
// false per-call value cannot silently reintroduce a wave barrier.
|
|
318
|
+
if (process.env[BACKGROUND_FLAG] !== "false") args.background = true
|
|
319
|
+
}
|
|
320
|
+
`;
|
|
135
321
|
return `${PLUGIN_SENTINEL}
|
|
136
322
|
import { existsSync } from "node:fs"
|
|
137
323
|
import { join } from "node:path"
|
|
138
324
|
|
|
139
325
|
const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
|
|
140
326
|
const GIT_BASH_FORGE_HOME = ${JSON.stringify(gitBashHome)}
|
|
327
|
+
const BACKGROUND_FLAG = "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"
|
|
328
|
+
|
|
329
|
+
// ForgeDock orchestration relies on per-task completion events rather than wave barriers.
|
|
330
|
+
// Keep an explicit false opt-out for users who need the experimental feature disabled.
|
|
331
|
+
if (process.env[BACKGROUND_FLAG] === undefined) process.env[BACKGROUND_FLAG] = "true"
|
|
332
|
+
|
|
141
333
|
let shellForgeHome = NATIVE_FORGE_HOME
|
|
334
|
+
${runtimeGuard}
|
|
142
335
|
|
|
143
336
|
export const ForgeDockPlugin = async () => ({
|
|
144
337
|
config: async (config) => {
|
|
338
|
+
// Two nested subagent levels cover orchestrate -> work-on -> review/gate
|
|
339
|
+
// without enabling unnecessary recursive fan-out.
|
|
145
340
|
if (config.subagent_depth === undefined) config.subagent_depth = 2
|
|
341
|
+
// OpenCode otherwise adds task: deny to every subagent session, which prevents
|
|
342
|
+
// an orchestrated work-on child from spawning its isolated review agents.
|
|
343
|
+
config.agent ??= {}
|
|
344
|
+
config.agent.general ??= {}
|
|
345
|
+
config.agent.general.permission ??= {}
|
|
346
|
+
if (config.agent.general.permission.task === undefined && config.permission?.task === undefined) {
|
|
347
|
+
config.agent.general.permission.task = "allow"
|
|
348
|
+
}
|
|
146
349
|
if (process.platform === "win32" && !config.shell) {
|
|
147
350
|
const candidates = [
|
|
148
351
|
process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
|
|
@@ -155,29 +358,182 @@ export const ForgeDockPlugin = async () => ({
|
|
|
155
358
|
shellForgeHome = GIT_BASH_FORGE_HOME
|
|
156
359
|
}
|
|
157
360
|
},
|
|
361
|
+
"tool.execute.before": async (input, output) => {
|
|
362
|
+
if (input.tool === "task") {
|
|
363
|
+
normalizeTaskArgs(output?.args)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (input.tool !== "bash") return
|
|
367
|
+
const operation = blockedOperation(output?.args?.command)
|
|
368
|
+
if (operation) throw capabilityError(operation)
|
|
369
|
+
},
|
|
158
370
|
"shell.env": async (_input, output) => {
|
|
159
371
|
output.env.FORGE_HOME = shellForgeHome
|
|
160
372
|
output.env.FORGE_RUNTIME = "opencode"
|
|
373
|
+
output.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS =
|
|
374
|
+
process.env[BACKGROUND_FLAG] ?? "true"
|
|
161
375
|
},
|
|
162
376
|
})
|
|
163
377
|
`;
|
|
164
378
|
}
|
|
165
379
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
380
|
+
function pathInside(root, candidate) {
|
|
381
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
382
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function assertSafePath(root, candidate) {
|
|
386
|
+
const resolvedRoot = resolve(root);
|
|
387
|
+
const resolvedCandidate = resolve(candidate);
|
|
388
|
+
if (!pathInside(resolvedRoot, resolvedCandidate)) {
|
|
389
|
+
throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let current = resolvedCandidate;
|
|
393
|
+
while (true) {
|
|
394
|
+
let stat;
|
|
395
|
+
try {
|
|
396
|
+
stat = await lstat(current);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.code !== "ENOENT") throw error;
|
|
399
|
+
}
|
|
400
|
+
if (stat?.isSymbolicLink()) {
|
|
401
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${current}`);
|
|
402
|
+
}
|
|
403
|
+
if (current === resolvedRoot) return;
|
|
404
|
+
const parent = dirname(current);
|
|
405
|
+
if (parent === current || !pathInside(resolvedRoot, parent)) {
|
|
406
|
+
throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
|
|
407
|
+
}
|
|
408
|
+
current = parent;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function isSafePath(root, candidate) {
|
|
413
|
+
try {
|
|
414
|
+
await assertSafePath(root, candidate);
|
|
415
|
+
return true;
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function resolveSafePath(root, candidate, { createParent = false } = {}) {
|
|
422
|
+
await assertSafePath(root, candidate);
|
|
423
|
+
if (createParent) await mkdir(dirname(candidate), { recursive: true });
|
|
424
|
+
await assertSafePath(root, candidate);
|
|
425
|
+
|
|
426
|
+
let resolvedRoot;
|
|
427
|
+
let resolvedParent;
|
|
428
|
+
try {
|
|
429
|
+
resolvedRoot = await realpath(resolve(root));
|
|
430
|
+
resolvedParent = await realpath(dirname(candidate));
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error.code === "ENOENT") return null;
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
if (!pathInside(resolvedRoot, resolvedParent)) {
|
|
436
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${dirname(candidate)}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const safeCandidate = join(resolvedParent, basename(candidate));
|
|
440
|
+
let stat;
|
|
441
|
+
try {
|
|
442
|
+
stat = await lstat(safeCandidate);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error.code !== "ENOENT") throw error;
|
|
445
|
+
}
|
|
446
|
+
if (stat?.isSymbolicLink()) {
|
|
447
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${safeCandidate}`);
|
|
448
|
+
}
|
|
449
|
+
return { path: safeCandidate, root: resolvedRoot };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function tryResolveSafePath(root, candidate) {
|
|
453
|
+
try {
|
|
454
|
+
return await resolveSafePath(root, candidate);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function acquireAdapterLock(configDir) {
|
|
461
|
+
const lockPath = join(configDir, "forgedock", "install.lock");
|
|
462
|
+
const safeLock = await resolveSafePath(configDir, lockPath, { createParent: true });
|
|
463
|
+
if (!safeLock) throw new Error(`Unable to resolve safe OpenCode lock path: ${lockPath}`);
|
|
464
|
+
|
|
465
|
+
for (let attempt = 0; attempt <= ADAPTER_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
466
|
+
try {
|
|
467
|
+
return { handle: await open(safeLock.path, "wx"), path: safeLock.path };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error.code !== "EEXIST") throw error;
|
|
470
|
+
let reclaimed = false;
|
|
471
|
+
try {
|
|
472
|
+
const stat = await lstat(safeLock.path);
|
|
473
|
+
if (Date.now() - stat.mtimeMs >= ADAPTER_LOCK_STALE_AGE_MS) {
|
|
474
|
+
const current = await lstat(safeLock.path);
|
|
475
|
+
if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
|
|
476
|
+
await unlink(safeLock.path);
|
|
477
|
+
reclaimed = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
// The lock may have been released or replaced between checks.
|
|
482
|
+
}
|
|
483
|
+
if (reclaimed) continue;
|
|
484
|
+
if (attempt === ADAPTER_LOCK_RETRY_DELAYS_MS.length) break;
|
|
485
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, ADAPTER_LOCK_RETRY_DELAYS_MS[attempt]));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
throw new Error("Another OpenCode adapter operation is in progress; try again shortly");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function withAdapterLock(configDir, operation) {
|
|
492
|
+
const lock = await acquireAdapterLock(configDir);
|
|
493
|
+
const heartbeat = setInterval(() => {
|
|
494
|
+
lock.handle.utimes(new Date(), new Date()).catch(() => {});
|
|
495
|
+
}, ADAPTER_LOCK_HEARTBEAT_MS);
|
|
496
|
+
heartbeat.unref?.();
|
|
497
|
+
try {
|
|
498
|
+
return await operation();
|
|
499
|
+
} finally {
|
|
500
|
+
clearInterval(heartbeat);
|
|
501
|
+
await lock.handle.close().catch(() => {});
|
|
502
|
+
await unlink(lock.path).catch(() => {});
|
|
503
|
+
await rmdir(dirname(lock.path)).catch(() => {});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function atomicWrite(path, content, root) {
|
|
508
|
+
const safe = root ? await resolveSafePath(root, path, { createParent: true }) : null;
|
|
509
|
+
if (root && !safe) throw new Error(`Unable to resolve safe OpenCode path: ${path}`);
|
|
510
|
+
const target = safe?.path || path;
|
|
511
|
+
if (!root) await mkdir(dirname(target), { recursive: true });
|
|
512
|
+
const tmp = `${target}.forgedock.tmp-${process.pid}-${randomUUID()}`;
|
|
169
513
|
try {
|
|
170
514
|
await writeFile(tmp, content, "utf8");
|
|
171
|
-
await rename(tmp,
|
|
515
|
+
await rename(tmp, target);
|
|
172
516
|
} catch (error) {
|
|
173
517
|
await rm(tmp, { force: true }).catch(() => {});
|
|
174
518
|
throw error;
|
|
175
519
|
}
|
|
176
520
|
}
|
|
177
521
|
|
|
178
|
-
async function readManifest(path) {
|
|
522
|
+
async function readManifest(path, root) {
|
|
523
|
+
let target = path;
|
|
524
|
+
if (root) {
|
|
525
|
+
try {
|
|
526
|
+
const safe = await resolveSafePath(root, path);
|
|
527
|
+
if (!safe) return null;
|
|
528
|
+
target = safe.path;
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
179
533
|
try {
|
|
180
|
-
const
|
|
534
|
+
const raw = await readRegularFile(target);
|
|
535
|
+
if (raw === null) return null;
|
|
536
|
+
const value = JSON.parse(raw);
|
|
181
537
|
if (
|
|
182
538
|
value?.version === MANIFEST_VERSION &&
|
|
183
539
|
Array.isArray(value.files) &&
|
|
@@ -201,17 +557,33 @@ function digestFiles(files) {
|
|
|
201
557
|
.digest("hex");
|
|
202
558
|
}
|
|
203
559
|
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
560
|
+
async function readRegularFile(path) {
|
|
561
|
+
try {
|
|
562
|
+
const stat = await lstat(path);
|
|
563
|
+
if (!stat.isFile()) return null;
|
|
564
|
+
return await readFile(path, "utf8");
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function hasManagedSentinel(content) {
|
|
571
|
+
return content.includes(COMMAND_SENTINEL) || content.includes(SKILL_SENTINEL) || content.includes(PLUGIN_SENTINEL);
|
|
207
572
|
}
|
|
208
573
|
|
|
209
574
|
async function isManagedFile(path) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
575
|
+
const content = await readRegularFile(path);
|
|
576
|
+
return content !== null && hasManagedSentinel(content);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function removeEmptyParentDirs(configDir, filePath) {
|
|
580
|
+
const root = resolve(configDir);
|
|
581
|
+
let directory = dirname(filePath);
|
|
582
|
+
while (resolve(directory) !== root && pathInside(root, directory)) {
|
|
583
|
+
await rmdir(directory).catch((error) => {
|
|
584
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
585
|
+
});
|
|
586
|
+
directory = dirname(directory);
|
|
215
587
|
}
|
|
216
588
|
}
|
|
217
589
|
|
|
@@ -219,10 +591,12 @@ async function removeOwnedFiles(configDir, files) {
|
|
|
219
591
|
let removed = 0;
|
|
220
592
|
for (const rel of files) {
|
|
221
593
|
const path = join(configDir, rel);
|
|
222
|
-
|
|
223
|
-
await
|
|
594
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
595
|
+
if (!safe || !(await isManagedFile(safe.path))) continue;
|
|
596
|
+
await unlink(safe.path).catch((error) => {
|
|
224
597
|
if (error.code !== "ENOENT") throw error;
|
|
225
598
|
});
|
|
599
|
+
await removeEmptyParentDirs(safe.root, safe.path);
|
|
226
600
|
removed++;
|
|
227
601
|
}
|
|
228
602
|
return removed;
|
|
@@ -230,37 +604,47 @@ async function removeOwnedFiles(configDir, files) {
|
|
|
230
604
|
|
|
231
605
|
async function discoverEntrypoints(forgeHome, includeExtras) {
|
|
232
606
|
const commandsDir = join(forgeHome, "commands");
|
|
233
|
-
const
|
|
607
|
+
const sources = await findMarkdownFiles(commandsDir, { includeExtras });
|
|
234
608
|
const commands = [];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const tier = parseInstallTier(source);
|
|
239
|
-
if (tier === "internal" || (tier === "extras" && !includeExtras)) continue;
|
|
609
|
+
const skillNames = new Map();
|
|
610
|
+
for (const source of sources) {
|
|
611
|
+
const sourcePath = portablePath(relative(commandsDir, source)).replace(/\.md$/i, "");
|
|
240
612
|
const content = await readFile(source, "utf8");
|
|
241
613
|
const description = parseDescription(content);
|
|
242
614
|
if (!description) continue;
|
|
615
|
+
const nativeName = normalizeOpenCodeSkillName(sourcePath);
|
|
616
|
+
const existing = skillNames.get(nativeName);
|
|
617
|
+
if (existing && existing !== sourcePath) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`OpenCode skill name collision: ${nativeName} maps both ${existing} and ${sourcePath}`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
skillNames.set(nativeName, sourcePath);
|
|
243
623
|
commands.push({
|
|
244
|
-
name:
|
|
624
|
+
name: sourcePath,
|
|
625
|
+
nativeName,
|
|
245
626
|
description,
|
|
246
|
-
|
|
627
|
+
topLevel: !sourcePath.includes("/"),
|
|
247
628
|
});
|
|
248
629
|
}
|
|
249
630
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
250
631
|
}
|
|
251
632
|
|
|
252
|
-
async function migrateLegacyAdapter({ configDir, home }) {
|
|
633
|
+
async function migrateLegacyAdapter({ configDir, home, forgeHome }) {
|
|
253
634
|
const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
|
|
254
635
|
const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
|
|
255
636
|
const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
|
|
637
|
+
const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
|
|
256
638
|
let legacyInstructionsOwned = false;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
639
|
+
if (safeLegacyInstructions) {
|
|
640
|
+
try {
|
|
641
|
+
const content = await readRegularFile(safeLegacyInstructions.path);
|
|
642
|
+
if (content?.split(/\r?\n/).some((line) => line.trim() === LEGACY_SENTINEL)) {
|
|
643
|
+
legacyInstructionsOwned = true;
|
|
644
|
+
}
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
|
|
261
647
|
}
|
|
262
|
-
} catch (error) {
|
|
263
|
-
if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
|
|
264
648
|
}
|
|
265
649
|
|
|
266
650
|
const configPaths = new Set([
|
|
@@ -271,12 +655,14 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
271
655
|
const staged = [];
|
|
272
656
|
let parseFailed = false;
|
|
273
657
|
for (const configPath of configPaths) {
|
|
274
|
-
|
|
658
|
+
const safeConfig = await tryResolveSafePath(dirname(configPath), configPath);
|
|
659
|
+
if (!safeConfig || !existsSync(safeConfig.path)) continue;
|
|
660
|
+
const path = safeConfig.path;
|
|
275
661
|
let config;
|
|
276
662
|
try {
|
|
277
|
-
config = JSON.parse(stripJsonc(await readFile(
|
|
663
|
+
config = JSON.parse(stripJsonc(await readFile(path, "utf8")));
|
|
278
664
|
} catch (error) {
|
|
279
|
-
result.warnings.push(`Could not migrate legacy entries in ${
|
|
665
|
+
result.warnings.push(`Could not migrate legacy entries in ${path}: ${error.message}`);
|
|
280
666
|
parseFailed = true;
|
|
281
667
|
continue;
|
|
282
668
|
}
|
|
@@ -293,10 +679,15 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
293
679
|
changed ||= removed > 0;
|
|
294
680
|
if (config.instructions.length === 0) delete config.instructions;
|
|
295
681
|
}
|
|
296
|
-
if (
|
|
682
|
+
if (
|
|
683
|
+
legacyInstructionsOwned &&
|
|
684
|
+
config.command &&
|
|
685
|
+
typeof config.command === "object" &&
|
|
686
|
+
!Array.isArray(config.command)
|
|
687
|
+
) {
|
|
297
688
|
for (const name of legacyCommands) {
|
|
298
689
|
const definition = config.command[name];
|
|
299
|
-
if (isLegacyCommandDefinition(name, definition)) {
|
|
690
|
+
if (isLegacyCommandDefinition(name, definition, forgeHome)) {
|
|
300
691
|
delete config.command[name];
|
|
301
692
|
result.removedConfigEntries++;
|
|
302
693
|
changed = true;
|
|
@@ -306,8 +697,8 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
306
697
|
}
|
|
307
698
|
if (changed) {
|
|
308
699
|
staged.push({
|
|
309
|
-
path
|
|
310
|
-
original: await readFile(
|
|
700
|
+
path,
|
|
701
|
+
original: await readFile(path, "utf8"),
|
|
311
702
|
content: `${JSON.stringify(config, null, 2)}\n`,
|
|
312
703
|
});
|
|
313
704
|
}
|
|
@@ -316,21 +707,61 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
316
707
|
const written = [];
|
|
317
708
|
try {
|
|
318
709
|
for (const item of staged) {
|
|
319
|
-
await atomicWrite(item.path, item.content);
|
|
710
|
+
await atomicWrite(item.path, item.content, dirname(item.path));
|
|
320
711
|
written.push(item);
|
|
321
712
|
}
|
|
322
713
|
} catch (error) {
|
|
323
|
-
for (const item of written)
|
|
714
|
+
for (const item of written) {
|
|
715
|
+
await atomicWrite(item.path, item.original, dirname(item.path)).catch(() => {});
|
|
716
|
+
}
|
|
324
717
|
result.warnings.push(`Could not write legacy migration: ${error.message}`);
|
|
325
718
|
return result;
|
|
326
719
|
}
|
|
327
|
-
if (legacyInstructionsOwned) {
|
|
328
|
-
|
|
329
|
-
|
|
720
|
+
if (legacyInstructionsOwned && safeLegacyInstructions) {
|
|
721
|
+
try {
|
|
722
|
+
await unlink(safeLegacyInstructions.path);
|
|
723
|
+
result.removedInstructionsFile = true;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error.code !== "ENOENT") {
|
|
726
|
+
result.warnings.push(`Could not remove legacy instructions file ${legacyInstructions}: ${error.message}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
330
729
|
}
|
|
331
730
|
return result;
|
|
332
731
|
}
|
|
333
732
|
|
|
733
|
+
async function snapshotAdapterFiles(configDir, files) {
|
|
734
|
+
const snapshots = new Map();
|
|
735
|
+
for (const rel of files) {
|
|
736
|
+
const path = join(configDir, rel);
|
|
737
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
738
|
+
const content = safe ? await readRegularFile(safe.path) : null;
|
|
739
|
+
snapshots.set(rel, content !== null && hasManagedSentinel(content) ? content : null);
|
|
740
|
+
}
|
|
741
|
+
return snapshots;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function restoreAdapterState({ configDir, manifestPath, files, manifestContent }) {
|
|
745
|
+
for (const [rel, content] of files) {
|
|
746
|
+
const path = join(configDir, rel);
|
|
747
|
+
if (content !== null) {
|
|
748
|
+
await atomicWrite(path, content, configDir).catch(() => {});
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
752
|
+
if (!safe || !(await isManagedFile(safe.path))) continue;
|
|
753
|
+
await unlink(safe.path).catch(() => {});
|
|
754
|
+
await removeEmptyParentDirs(safe.root, safe.path).catch(() => {});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (manifestContent !== null) {
|
|
758
|
+
await atomicWrite(manifestPath, manifestContent, configDir).catch(() => {});
|
|
759
|
+
} else {
|
|
760
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
761
|
+
if (safeManifest) await unlink(safeManifest.path).catch(() => {});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
334
765
|
export async function installOpenCodeAdapter({
|
|
335
766
|
forgeHome,
|
|
336
767
|
home,
|
|
@@ -344,8 +775,19 @@ export async function installOpenCodeAdapter({
|
|
|
344
775
|
|
|
345
776
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
346
777
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
347
|
-
|
|
348
|
-
|
|
778
|
+
return withAdapterLock(configDir, () => installOpenCodeAdapterLocked({
|
|
779
|
+
forgeHome,
|
|
780
|
+
home,
|
|
781
|
+
includeExtras,
|
|
782
|
+
configDir,
|
|
783
|
+
manifestPath,
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function installOpenCodeAdapterLocked({ forgeHome, home, includeExtras, configDir, manifestPath }) {
|
|
788
|
+
const previous = (await readManifest(manifestPath, configDir)) || { files: [] };
|
|
789
|
+
const workflows = await discoverEntrypoints(forgeHome, includeExtras);
|
|
790
|
+
const commands = workflows.filter((workflow) => workflow.topLevel);
|
|
349
791
|
const rendered = [];
|
|
350
792
|
|
|
351
793
|
for (const command of commands) {
|
|
@@ -358,43 +800,110 @@ export async function installOpenCodeAdapter({
|
|
|
358
800
|
rendered.push({ rel, content });
|
|
359
801
|
}
|
|
360
802
|
|
|
803
|
+
for (const workflow of workflows) {
|
|
804
|
+
const rel = portablePath(join("skills", workflow.nativeName, "SKILL.md"));
|
|
805
|
+
const content = renderOpenCodeSkill({
|
|
806
|
+
command: workflow.name,
|
|
807
|
+
description: workflow.description,
|
|
808
|
+
forgeHome,
|
|
809
|
+
});
|
|
810
|
+
rendered.push({ rel, content });
|
|
811
|
+
}
|
|
812
|
+
|
|
361
813
|
const pluginRel = portablePath(join("plugins", "forgedock.js"));
|
|
362
814
|
const pluginContent = renderOpenCodePlugin(forgeHome);
|
|
363
815
|
rendered.push({ rel: pluginRel, content: pluginContent });
|
|
364
816
|
|
|
817
|
+
await assertSafePath(configDir, manifestPath);
|
|
365
818
|
// Preflight every collision before the first write so a rejected install
|
|
366
819
|
// cannot leave unmanifested command files behind.
|
|
367
820
|
for (const item of rendered) {
|
|
368
821
|
const path = join(configDir, item.rel);
|
|
369
|
-
|
|
370
|
-
|
|
822
|
+
await assertSafePath(configDir, path);
|
|
823
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
824
|
+
if (safe && existsSync(safe.path) && !(await isManagedFile(safe.path))) {
|
|
825
|
+
throw new Error(`Refusing to overwrite user-owned OpenCode file: ${safe.path}`);
|
|
371
826
|
}
|
|
372
827
|
}
|
|
373
|
-
for (const item of rendered) await atomicWrite(join(configDir, item.rel), item.content);
|
|
374
|
-
|
|
375
828
|
const nextFiles = rendered.map((item) => item.rel).sort();
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
829
|
+
const trackedFiles = [...new Set([...previous.files, ...nextFiles])];
|
|
830
|
+
const snapshots = await snapshotAdapterFiles(configDir, trackedFiles);
|
|
831
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
832
|
+
const previousManifestContent = safeManifest ? await readRegularFile(safeManifest.path) : null;
|
|
833
|
+
let removed;
|
|
834
|
+
let digest;
|
|
835
|
+
let migration;
|
|
836
|
+
try {
|
|
837
|
+
for (const item of rendered) {
|
|
838
|
+
await atomicWrite(join(configDir, item.rel), item.content, configDir);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const stale = previous.files.filter((file) => !nextFiles.includes(file));
|
|
842
|
+
removed = await removeOwnedFiles(configDir, stale);
|
|
843
|
+
digest = digestFiles(rendered);
|
|
844
|
+
const manifest = {
|
|
845
|
+
version: MANIFEST_VERSION,
|
|
846
|
+
forgeHome,
|
|
847
|
+
includeExtras,
|
|
848
|
+
commandCount: commands.length,
|
|
849
|
+
skillCount: workflows.length,
|
|
850
|
+
files: nextFiles,
|
|
851
|
+
digest,
|
|
852
|
+
};
|
|
853
|
+
await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, configDir);
|
|
854
|
+
migration = await migrateLegacyAdapter({ configDir, home, forgeHome });
|
|
855
|
+
} catch (error) {
|
|
856
|
+
await restoreAdapterState({
|
|
857
|
+
configDir,
|
|
858
|
+
manifestPath,
|
|
859
|
+
files: snapshots,
|
|
860
|
+
manifestContent: previousManifestContent,
|
|
861
|
+
});
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return {
|
|
866
|
+
configDir,
|
|
867
|
+
manifestPath,
|
|
383
868
|
commandCount: commands.length,
|
|
384
|
-
|
|
869
|
+
skillCount: workflows.length,
|
|
870
|
+
removed,
|
|
385
871
|
digest,
|
|
872
|
+
migration,
|
|
386
873
|
};
|
|
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
874
|
}
|
|
392
875
|
|
|
393
876
|
export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
|
|
394
877
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
395
878
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
396
|
-
|
|
397
|
-
const
|
|
879
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
880
|
+
const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
|
|
881
|
+
const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
|
|
882
|
+
const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
|
|
883
|
+
const legacyContent = safeLegacyInstructions
|
|
884
|
+
? await readRegularFile(safeLegacyInstructions.path)
|
|
885
|
+
: null;
|
|
886
|
+
const legacyInstalled = legacyContent?.split(/\r?\n/).some(
|
|
887
|
+
(line) => line.trim() === LEGACY_SENTINEL,
|
|
888
|
+
) === true;
|
|
889
|
+
|
|
890
|
+
if (!safeManifest || !existsSync(safeManifest.path)) {
|
|
891
|
+
if (legacyInstalled) {
|
|
892
|
+
return {
|
|
893
|
+
installed: true,
|
|
894
|
+
healthy: false,
|
|
895
|
+
legacy: true,
|
|
896
|
+
configDir,
|
|
897
|
+
missing: [],
|
|
898
|
+
integrity: "legacy-adapter",
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return { installed: false, healthy: false, configDir, missing: [] };
|
|
902
|
+
}
|
|
903
|
+
if (!(await isSafePath(configDir, manifestPath))) {
|
|
904
|
+
return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
|
|
905
|
+
}
|
|
906
|
+
const manifest = await readManifest(manifestPath, configDir);
|
|
398
907
|
if (!manifest) {
|
|
399
908
|
return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
|
|
400
909
|
}
|
|
@@ -402,11 +911,13 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
|
|
|
402
911
|
const current = [];
|
|
403
912
|
for (const rel of manifest.files) {
|
|
404
913
|
const path = join(configDir, rel);
|
|
405
|
-
|
|
914
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
915
|
+
const content = safe ? await readRegularFile(safe.path) : null;
|
|
916
|
+
if (!safe || content === null || !hasManagedSentinel(content)) {
|
|
406
917
|
missing.push(rel);
|
|
407
918
|
continue;
|
|
408
919
|
}
|
|
409
|
-
current.push({ rel, content
|
|
920
|
+
current.push({ rel, content });
|
|
410
921
|
}
|
|
411
922
|
const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
|
|
412
923
|
return {
|
|
@@ -421,15 +932,30 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
|
|
|
421
932
|
|
|
422
933
|
export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
|
|
423
934
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
935
|
+
return withAdapterLock(configDir, () => uninstallOpenCodeAdapterLocked({ home, env, configDir }));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function uninstallOpenCodeAdapterLocked({ home, env, configDir }) {
|
|
424
939
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
425
|
-
const manifest = (await readManifest(manifestPath)) || { files: [] };
|
|
940
|
+
const manifest = (await readManifest(manifestPath, configDir)) || { files: [] };
|
|
426
941
|
const removed = await removeOwnedFiles(configDir, manifest.files);
|
|
427
|
-
await
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
942
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
943
|
+
if (safeManifest) {
|
|
944
|
+
await unlink(safeManifest.path).catch((error) => {
|
|
945
|
+
if (error.code !== "ENOENT") throw error;
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
const manifestDir = join(configDir, "forgedock");
|
|
949
|
+
const safeManifestDir = await tryResolveSafePath(configDir, manifestDir);
|
|
950
|
+
if (safeManifestDir) {
|
|
951
|
+
await rmdir(safeManifestDir.path).catch((error) => {
|
|
952
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const migration = await migrateLegacyAdapter({
|
|
956
|
+
configDir,
|
|
957
|
+
home,
|
|
958
|
+
forgeHome: typeof manifest.forgeHome === "string" ? manifest.forgeHome : undefined,
|
|
432
959
|
});
|
|
433
|
-
const migration = await migrateLegacyAdapter({ configDir, home });
|
|
434
960
|
return { configDir, removed, migration };
|
|
435
961
|
}
|