opencode-plugin-flow 5.3.3 → 6.0.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/CHANGELOG.md +41 -0
- package/README.md +143 -309
- package/dist/index.js +2790 -13955
- package/dist/index.js.map +27 -41
- package/package.json +4 -20
- package/dist/cli.js +0 -2925
- package/dist/cli.js.map +0 -15
package/dist/cli.js
DELETED
|
@@ -1,2925 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/distribution/activation.ts
|
|
4
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import { constants } from "node:fs";
|
|
6
|
-
import {
|
|
7
|
-
access,
|
|
8
|
-
lstat,
|
|
9
|
-
mkdir,
|
|
10
|
-
open,
|
|
11
|
-
readdir,
|
|
12
|
-
rename,
|
|
13
|
-
rm,
|
|
14
|
-
rmdir,
|
|
15
|
-
writeFile
|
|
16
|
-
} from "node:fs/promises";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
|
-
import {
|
|
19
|
-
basename,
|
|
20
|
-
dirname,
|
|
21
|
-
isAbsolute,
|
|
22
|
-
join,
|
|
23
|
-
normalize,
|
|
24
|
-
relative,
|
|
25
|
-
resolve,
|
|
26
|
-
sep
|
|
27
|
-
} from "node:path";
|
|
28
|
-
import { fileURLToPath } from "node:url";
|
|
29
|
-
|
|
30
|
-
// src/platform/opencode/leadership.ts
|
|
31
|
-
var REGISTRY_KIND = "opencode-plugin-flow.runtime-leadership";
|
|
32
|
-
var MAX_VERSION_LENGTH = 256;
|
|
33
|
-
var FLOW_LEADERSHIP_REGISTRY_SYMBOL = Symbol.for(REGISTRY_KIND);
|
|
34
|
-
var SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
35
|
-
function parseSemanticVersion(version) {
|
|
36
|
-
if (version.length === 0 || version.length > MAX_VERSION_LENGTH)
|
|
37
|
-
return null;
|
|
38
|
-
const match = SEMVER_PATTERN.exec(version);
|
|
39
|
-
const major = match?.[1];
|
|
40
|
-
const minor = match?.[2];
|
|
41
|
-
const patch = match?.[3];
|
|
42
|
-
if (major === undefined || minor === undefined || patch === undefined) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
const prereleaseText = match?.[4];
|
|
46
|
-
const prerelease = prereleaseText ? prereleaseText.split(".").map((identifier) => /^\d+$/.test(identifier) ? { kind: "numeric", value: BigInt(identifier) } : { kind: "text", value: identifier }) : null;
|
|
47
|
-
return {
|
|
48
|
-
major: BigInt(major),
|
|
49
|
-
minor: BigInt(minor),
|
|
50
|
-
patch: BigInt(patch),
|
|
51
|
-
prerelease
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
function compareBigInts(left, right) {
|
|
55
|
-
if (left < right)
|
|
56
|
-
return -1;
|
|
57
|
-
if (left > right)
|
|
58
|
-
return 1;
|
|
59
|
-
return 0;
|
|
60
|
-
}
|
|
61
|
-
function compareText(left, right) {
|
|
62
|
-
if (left < right)
|
|
63
|
-
return -1;
|
|
64
|
-
if (left > right)
|
|
65
|
-
return 1;
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
function comparePrerelease(left, right) {
|
|
69
|
-
if (left === null && right === null)
|
|
70
|
-
return 0;
|
|
71
|
-
if (left === null)
|
|
72
|
-
return 1;
|
|
73
|
-
if (right === null)
|
|
74
|
-
return -1;
|
|
75
|
-
const length = Math.max(left.length, right.length);
|
|
76
|
-
for (let index = 0;index < length; index += 1) {
|
|
77
|
-
const leftIdentifier = left[index];
|
|
78
|
-
const rightIdentifier = right[index];
|
|
79
|
-
if (leftIdentifier === undefined)
|
|
80
|
-
return -1;
|
|
81
|
-
if (rightIdentifier === undefined)
|
|
82
|
-
return 1;
|
|
83
|
-
if (leftIdentifier.kind === "numeric" && rightIdentifier.kind === "numeric") {
|
|
84
|
-
const comparison2 = compareBigInts(leftIdentifier.value, rightIdentifier.value);
|
|
85
|
-
if (comparison2 !== 0)
|
|
86
|
-
return comparison2;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (leftIdentifier.kind === "numeric")
|
|
90
|
-
return -1;
|
|
91
|
-
if (rightIdentifier.kind === "numeric")
|
|
92
|
-
return 1;
|
|
93
|
-
const comparison = compareText(leftIdentifier.value, rightIdentifier.value);
|
|
94
|
-
if (comparison !== 0)
|
|
95
|
-
return comparison;
|
|
96
|
-
}
|
|
97
|
-
return 0;
|
|
98
|
-
}
|
|
99
|
-
function compareSemanticVersions(left, right) {
|
|
100
|
-
const leftVersion = parseSemanticVersion(left);
|
|
101
|
-
const rightVersion = parseSemanticVersion(right);
|
|
102
|
-
if (!leftVersion || !rightVersion) {
|
|
103
|
-
throw new TypeError("Flow leadership versions must be valid exact semantic versions.");
|
|
104
|
-
}
|
|
105
|
-
for (const [leftPart, rightPart] of [
|
|
106
|
-
[leftVersion.major, rightVersion.major],
|
|
107
|
-
[leftVersion.minor, rightVersion.minor],
|
|
108
|
-
[leftVersion.patch, rightVersion.patch]
|
|
109
|
-
]) {
|
|
110
|
-
const comparison = compareBigInts(leftPart, rightPart);
|
|
111
|
-
if (comparison !== 0)
|
|
112
|
-
return comparison;
|
|
113
|
-
}
|
|
114
|
-
return comparePrerelease(leftVersion.prerelease, rightVersion.prerelease);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// src/version.ts
|
|
118
|
-
import { createRequire } from "node:module";
|
|
119
|
-
function resolveFlowPluginVersion() {
|
|
120
|
-
try {
|
|
121
|
-
const require2 = createRequire(import.meta.url);
|
|
122
|
-
const manifest = require2("../package.json");
|
|
123
|
-
if (manifest.version)
|
|
124
|
-
return manifest.version;
|
|
125
|
-
} catch {}
|
|
126
|
-
return "0.0.0";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// src/distribution/activation.ts
|
|
130
|
-
var FLOW_PACKAGE_NAME = "opencode-plugin-flow";
|
|
131
|
-
var OWNED_WRAPPER_MARKER = "// @opencode-plugin-flow-owned-wrapper v1";
|
|
132
|
-
var NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
133
|
-
var MAX_LOCAL_PLUGIN_BYTES = 1024 * 1024;
|
|
134
|
-
var EXACT_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
135
|
-
var FLOW_NPM_SPECIFIER_PATTERN = /^opencode-plugin-flow(?:@(.+))?$/;
|
|
136
|
-
var LOCAL_PLUGIN_EXTENSION_PATTERN = /\.(?:js|ts)$/i;
|
|
137
|
-
function sha256(value) {
|
|
138
|
-
return createHash("sha256").update(value).digest("hex");
|
|
139
|
-
}
|
|
140
|
-
function configuredHome(env) {
|
|
141
|
-
return env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
|
|
142
|
-
}
|
|
143
|
-
function normalizedInjectedRoot(path) {
|
|
144
|
-
return normalize(resolve(path));
|
|
145
|
-
}
|
|
146
|
-
function resolveEnvironmentPath(value, project) {
|
|
147
|
-
return normalize(isAbsolute(value) ? value : resolve(project, value));
|
|
148
|
-
}
|
|
149
|
-
function systemManagedConfigRoot(platform, env) {
|
|
150
|
-
if (platform === "darwin")
|
|
151
|
-
return "/Library/Application Support/opencode";
|
|
152
|
-
if (platform === "win32") {
|
|
153
|
-
return join(env.ProgramData?.trim() || "C:\\ProgramData", "opencode");
|
|
154
|
-
}
|
|
155
|
-
return "/etc/opencode";
|
|
156
|
-
}
|
|
157
|
-
function systemManagedPreferencePaths(platform, home) {
|
|
158
|
-
if (platform !== "darwin")
|
|
159
|
-
return [];
|
|
160
|
-
const username = basename(home) || "user";
|
|
161
|
-
return [
|
|
162
|
-
join("/Library/Managed Preferences", username, "ai.opencode.managed.plist"),
|
|
163
|
-
join("/Library/Managed Preferences", "ai.opencode.managed.plist")
|
|
164
|
-
];
|
|
165
|
-
}
|
|
166
|
-
function isExactFlowVersion(value) {
|
|
167
|
-
return EXACT_VERSION_PATTERN.test(value);
|
|
168
|
-
}
|
|
169
|
-
function resolveActivationTarget(target) {
|
|
170
|
-
const resolved = target ?? resolveFlowPluginVersion();
|
|
171
|
-
if (!isExactFlowVersion(resolved)) {
|
|
172
|
-
throw new Error(`Flow activation target '${resolved}' must be an exact semantic version; tags and ranges are not resolved.`);
|
|
173
|
-
}
|
|
174
|
-
return resolved;
|
|
175
|
-
}
|
|
176
|
-
function resolveActivationPaths(project, options = {}) {
|
|
177
|
-
if (!isAbsolute(project)) {
|
|
178
|
-
throw new Error(`Flow activation project path must be absolute: ${project}`);
|
|
179
|
-
}
|
|
180
|
-
const env = options.env ?? process.env;
|
|
181
|
-
const home = normalizedInjectedRoot(options.home ?? configuredHome(env));
|
|
182
|
-
const configRoot = normalizedInjectedRoot(options.configRoot ?? (options.home ? join(home, ".config", "opencode") : env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "opencode") : join(home, ".config", "opencode")));
|
|
183
|
-
const cacheRoot = normalizedInjectedRoot(options.cacheRoot ?? (options.home ? join(home, ".cache", "opencode") : env.XDG_CACHE_HOME ? join(env.XDG_CACHE_HOME, "opencode") : join(home, ".cache", "opencode")));
|
|
184
|
-
const absoluteProject = normalizedInjectedRoot(project);
|
|
185
|
-
const platform = options.platform ?? process.platform;
|
|
186
|
-
const customConfigFile = env.OPENCODE_CONFIG?.trim() ? resolveEnvironmentPath(env.OPENCODE_CONFIG.trim(), absoluteProject) : null;
|
|
187
|
-
const customConfigDirectory = env.OPENCODE_CONFIG_DIR?.trim() ? resolveEnvironmentPath(env.OPENCODE_CONFIG_DIR.trim(), absoluteProject) : null;
|
|
188
|
-
const managedConfigRoot = normalizedInjectedRoot(options.managedConfigRoot ?? env.OPENCODE_TEST_MANAGED_CONFIG_DIR ?? systemManagedConfigRoot(platform, env));
|
|
189
|
-
const globalConfigFiles = [
|
|
190
|
-
join(configRoot, "config.json"),
|
|
191
|
-
join(configRoot, "opencode.json"),
|
|
192
|
-
join(configRoot, "opencode.jsonc")
|
|
193
|
-
];
|
|
194
|
-
const projectConfigFiles = [
|
|
195
|
-
join(absoluteProject, "opencode.jsonc"),
|
|
196
|
-
join(absoluteProject, "opencode.json")
|
|
197
|
-
];
|
|
198
|
-
const projectDirectoryRoot = join(absoluteProject, ".opencode");
|
|
199
|
-
const projectDirectoryConfigFiles = [
|
|
200
|
-
join(projectDirectoryRoot, "opencode.json"),
|
|
201
|
-
join(projectDirectoryRoot, "opencode.jsonc")
|
|
202
|
-
];
|
|
203
|
-
const customDirectoryConfigFiles = customConfigDirectory ? [
|
|
204
|
-
join(customConfigDirectory, "opencode.json"),
|
|
205
|
-
join(customConfigDirectory, "opencode.jsonc")
|
|
206
|
-
] : [];
|
|
207
|
-
const managedConfigFiles = [
|
|
208
|
-
join(managedConfigRoot, "opencode.json"),
|
|
209
|
-
join(managedConfigRoot, "opencode.jsonc")
|
|
210
|
-
];
|
|
211
|
-
const pluginDirectories = [
|
|
212
|
-
...["plugin", "plugins"].map((name) => ({
|
|
213
|
-
source: "global-plugin-directory",
|
|
214
|
-
scope: "global",
|
|
215
|
-
path: join(configRoot, name),
|
|
216
|
-
safetyRoot: dirname(configRoot)
|
|
217
|
-
})),
|
|
218
|
-
...["plugin", "plugins"].map((name) => ({
|
|
219
|
-
source: "home-plugin-directory",
|
|
220
|
-
scope: "global",
|
|
221
|
-
path: join(home, ".opencode", name),
|
|
222
|
-
safetyRoot: home
|
|
223
|
-
})),
|
|
224
|
-
...["plugin", "plugins"].map((name) => ({
|
|
225
|
-
source: "project-plugin-directory",
|
|
226
|
-
scope: "project",
|
|
227
|
-
path: join(projectDirectoryRoot, name),
|
|
228
|
-
safetyRoot: absoluteProject
|
|
229
|
-
})),
|
|
230
|
-
...customConfigDirectory ? ["plugin", "plugins"].map((name) => ({
|
|
231
|
-
source: "custom-plugin-directory",
|
|
232
|
-
scope: "custom",
|
|
233
|
-
path: join(customConfigDirectory, name),
|
|
234
|
-
safetyRoot: dirname(customConfigDirectory)
|
|
235
|
-
})) : []
|
|
236
|
-
];
|
|
237
|
-
return {
|
|
238
|
-
project: absoluteProject,
|
|
239
|
-
home,
|
|
240
|
-
configRoot,
|
|
241
|
-
cacheRoot,
|
|
242
|
-
globalConfig: join(configRoot, "opencode.json"),
|
|
243
|
-
projectConfig: join(absoluteProject, "opencode.json"),
|
|
244
|
-
globalPluginDirectory: join(configRoot, "plugins"),
|
|
245
|
-
projectPluginDirectory: join(absoluteProject, ".opencode", "plugins"),
|
|
246
|
-
globalConfigFiles,
|
|
247
|
-
projectConfigFiles,
|
|
248
|
-
projectDirectoryConfigFiles,
|
|
249
|
-
customConfigFile,
|
|
250
|
-
customConfigDirectory,
|
|
251
|
-
customDirectoryConfigFiles,
|
|
252
|
-
managedConfigRoot,
|
|
253
|
-
managedConfigFiles,
|
|
254
|
-
pluginDirectories,
|
|
255
|
-
managedPreferencePaths: options.managedPreferencePaths?.map(normalizedInjectedRoot) ?? systemManagedPreferencePaths(platform, home),
|
|
256
|
-
hasInlineConfig: Boolean(env.OPENCODE_CONFIG_CONTENT?.trim()),
|
|
257
|
-
packageCacheRoot: join(cacheRoot, "packages"),
|
|
258
|
-
journalRoot: join(configRoot, "flow-activation-recovery"),
|
|
259
|
-
globalWrapperRecoveryRoot: join(configRoot, "flow-activation-recovery"),
|
|
260
|
-
projectWrapperRecoveryRoot: join(absoluteProject, ".opencode", "flow-activation-recovery"),
|
|
261
|
-
cacheRecoveryRoot: join(cacheRoot, "flow-activation-recovery")
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
async function optionalLstat(path) {
|
|
265
|
-
try {
|
|
266
|
-
return await lstat(path);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
if (error.code === "ENOENT")
|
|
269
|
-
return null;
|
|
270
|
-
throw error;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
async function removeEmptyDirectory(path) {
|
|
274
|
-
try {
|
|
275
|
-
await rmdir(path);
|
|
276
|
-
} catch (error) {
|
|
277
|
-
const code = error.code;
|
|
278
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY")
|
|
279
|
-
throw error;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
async function symlinkInPathRange(safetyRoot, target) {
|
|
283
|
-
const root = normalize(safetyRoot);
|
|
284
|
-
const destination = normalize(target);
|
|
285
|
-
const pathFromRoot = relative(root, destination);
|
|
286
|
-
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
|
|
287
|
-
throw new Error(`${destination} is outside its mutation safety root ${root}`);
|
|
288
|
-
}
|
|
289
|
-
let current = root;
|
|
290
|
-
const parts = pathFromRoot ? pathFromRoot.split(sep) : [];
|
|
291
|
-
for (let index = -1;index < parts.length; index += 1) {
|
|
292
|
-
if (index >= 0) {
|
|
293
|
-
const part = parts[index];
|
|
294
|
-
if (!part)
|
|
295
|
-
continue;
|
|
296
|
-
current = join(current, part);
|
|
297
|
-
}
|
|
298
|
-
const metadata = await optionalLstat(current);
|
|
299
|
-
if (!metadata)
|
|
300
|
-
return null;
|
|
301
|
-
if (metadata.isSymbolicLink())
|
|
302
|
-
return current;
|
|
303
|
-
}
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
async function assertSafeMutationPath(safetyRoot, target) {
|
|
307
|
-
const symlink = await symlinkInPathRange(safetyRoot, target);
|
|
308
|
-
if (symlink) {
|
|
309
|
-
throw new Error(`${target}: mutation refused because ancestor ${symlink} is a symbolic link`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
async function readRegularFileWithoutFollowing(path, maximumBytes) {
|
|
313
|
-
const pathMetadata = await lstat(path);
|
|
314
|
-
if (pathMetadata.isSymbolicLink()) {
|
|
315
|
-
throw new Error("symbolic link refused");
|
|
316
|
-
}
|
|
317
|
-
if (!pathMetadata.isFile())
|
|
318
|
-
throw new Error("not a regular file");
|
|
319
|
-
if (maximumBytes !== undefined && pathMetadata.size > maximumBytes) {
|
|
320
|
-
throw new Error(`file exceeds ${maximumBytes} bytes`);
|
|
321
|
-
}
|
|
322
|
-
const handle = await open(path, constants.O_RDONLY | NO_FOLLOW);
|
|
323
|
-
try {
|
|
324
|
-
const metadata = await handle.stat();
|
|
325
|
-
if (!metadata.isFile())
|
|
326
|
-
throw new Error("not a regular file");
|
|
327
|
-
if (metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino) {
|
|
328
|
-
throw new Error("file changed while activation was running");
|
|
329
|
-
}
|
|
330
|
-
return await handle.readFile({ encoding: "utf8" });
|
|
331
|
-
} catch (error) {
|
|
332
|
-
if (error.code === "ELOOP") {
|
|
333
|
-
throw new Error("symbolic link refused");
|
|
334
|
-
}
|
|
335
|
-
throw error;
|
|
336
|
-
} finally {
|
|
337
|
-
await handle.close();
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
function configDescriptors(paths) {
|
|
341
|
-
const descriptors = [
|
|
342
|
-
...paths.globalConfigFiles.map((path) => ({
|
|
343
|
-
source: "global-config",
|
|
344
|
-
scope: "global",
|
|
345
|
-
path,
|
|
346
|
-
mutable: true,
|
|
347
|
-
safetyRoot: dirname(paths.configRoot),
|
|
348
|
-
manualRemediation: `edit ${path} manually`
|
|
349
|
-
})),
|
|
350
|
-
...paths.customConfigFile ? [
|
|
351
|
-
{
|
|
352
|
-
source: "custom-config",
|
|
353
|
-
scope: "custom",
|
|
354
|
-
path: paths.customConfigFile,
|
|
355
|
-
mutable: true,
|
|
356
|
-
safetyRoot: dirname(paths.customConfigFile),
|
|
357
|
-
manualRemediation: `edit OPENCODE_CONFIG file ${paths.customConfigFile} manually`
|
|
358
|
-
}
|
|
359
|
-
] : [],
|
|
360
|
-
...paths.projectConfigFiles.map((path) => ({
|
|
361
|
-
source: "project-config",
|
|
362
|
-
scope: "project",
|
|
363
|
-
path,
|
|
364
|
-
mutable: true,
|
|
365
|
-
safetyRoot: paths.project,
|
|
366
|
-
manualRemediation: `edit ${path} manually`
|
|
367
|
-
})),
|
|
368
|
-
...paths.projectDirectoryConfigFiles.map((path) => ({
|
|
369
|
-
source: "project-directory-config",
|
|
370
|
-
scope: "project",
|
|
371
|
-
path,
|
|
372
|
-
mutable: true,
|
|
373
|
-
safetyRoot: paths.project,
|
|
374
|
-
manualRemediation: `edit ${path} manually`
|
|
375
|
-
})),
|
|
376
|
-
...paths.customDirectoryConfigFiles.map((path) => ({
|
|
377
|
-
source: "custom-directory-config",
|
|
378
|
-
scope: "custom",
|
|
379
|
-
path,
|
|
380
|
-
mutable: true,
|
|
381
|
-
safetyRoot: dirname(paths.customConfigDirectory),
|
|
382
|
-
manualRemediation: `edit OPENCODE_CONFIG_DIR file ${path} manually`
|
|
383
|
-
})),
|
|
384
|
-
...paths.managedConfigFiles.map((path) => ({
|
|
385
|
-
source: "managed-config",
|
|
386
|
-
scope: "managed",
|
|
387
|
-
path,
|
|
388
|
-
mutable: false,
|
|
389
|
-
safetyRoot: dirname(paths.managedConfigRoot),
|
|
390
|
-
manualRemediation: `ask the OpenCode administrator to remove the Flow entry from ${path}`
|
|
391
|
-
}))
|
|
392
|
-
];
|
|
393
|
-
const unique = new Map;
|
|
394
|
-
for (const descriptor of descriptors) {
|
|
395
|
-
const previous = unique.get(descriptor.path);
|
|
396
|
-
if (!previous || !descriptor.mutable && previous.mutable) {
|
|
397
|
-
unique.set(descriptor.path, descriptor);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
return [...unique.values()];
|
|
401
|
-
}
|
|
402
|
-
function pluginDirectoryDescriptors(paths) {
|
|
403
|
-
const unique = new Map;
|
|
404
|
-
for (const descriptor of paths.pluginDirectories) {
|
|
405
|
-
if (!unique.has(descriptor.path))
|
|
406
|
-
unique.set(descriptor.path, descriptor);
|
|
407
|
-
}
|
|
408
|
-
return [...unique.values()];
|
|
409
|
-
}
|
|
410
|
-
function parseFlowNpmSpecifier(specifier) {
|
|
411
|
-
const match = FLOW_NPM_SPECIFIER_PATTERN.exec(specifier);
|
|
412
|
-
if (!match)
|
|
413
|
-
return { isFlow: false, version: null };
|
|
414
|
-
const candidate = match[1];
|
|
415
|
-
return {
|
|
416
|
-
isFlow: true,
|
|
417
|
-
version: candidate && isExactFlowVersion(candidate) ? candidate : null
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
function pluginSpecifier(entry) {
|
|
421
|
-
return Array.isArray(entry) ? entry[0] : entry;
|
|
422
|
-
}
|
|
423
|
-
function isPluginOptions(value) {
|
|
424
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
425
|
-
}
|
|
426
|
-
function parsePluginSpecs(value) {
|
|
427
|
-
if (value === undefined)
|
|
428
|
-
return [];
|
|
429
|
-
if (!Array.isArray(value)) {
|
|
430
|
-
throw new Error("config plugin must be an array");
|
|
431
|
-
}
|
|
432
|
-
return value.map((entry, index) => {
|
|
433
|
-
if (typeof entry === "string")
|
|
434
|
-
return entry;
|
|
435
|
-
if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && isPluginOptions(entry[1])) {
|
|
436
|
-
return [entry[0], entry[1]];
|
|
437
|
-
}
|
|
438
|
-
throw new Error(`config plugin[${index}] must be a string or [specifier, options] tuple`);
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
function stripJsoncComments(content) {
|
|
442
|
-
let output = "";
|
|
443
|
-
let inString = false;
|
|
444
|
-
let escaped = false;
|
|
445
|
-
for (let index = 0;index < content.length; index += 1) {
|
|
446
|
-
const character = content[index];
|
|
447
|
-
if (!character)
|
|
448
|
-
continue;
|
|
449
|
-
if (inString) {
|
|
450
|
-
output += character;
|
|
451
|
-
if (escaped)
|
|
452
|
-
escaped = false;
|
|
453
|
-
else if (character === "\\")
|
|
454
|
-
escaped = true;
|
|
455
|
-
else if (character === '"')
|
|
456
|
-
inString = false;
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
if (character === '"') {
|
|
460
|
-
inString = true;
|
|
461
|
-
output += character;
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
const next = content[index + 1];
|
|
465
|
-
if (character === "/" && next === "/") {
|
|
466
|
-
output += " ";
|
|
467
|
-
index += 2;
|
|
468
|
-
while (index < content.length) {
|
|
469
|
-
const commentCharacter = content[index];
|
|
470
|
-
if (commentCharacter === `
|
|
471
|
-
` || commentCharacter === "\r") {
|
|
472
|
-
index -= 1;
|
|
473
|
-
break;
|
|
474
|
-
}
|
|
475
|
-
output += " ";
|
|
476
|
-
index += 1;
|
|
477
|
-
}
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (character === "/" && next === "*") {
|
|
481
|
-
output += " ";
|
|
482
|
-
index += 2;
|
|
483
|
-
let closed = false;
|
|
484
|
-
while (index < content.length) {
|
|
485
|
-
const commentCharacter = content[index];
|
|
486
|
-
const following = content[index + 1];
|
|
487
|
-
if (commentCharacter === "*" && following === "/") {
|
|
488
|
-
output += " ";
|
|
489
|
-
index += 1;
|
|
490
|
-
closed = true;
|
|
491
|
-
break;
|
|
492
|
-
}
|
|
493
|
-
output += commentCharacter === `
|
|
494
|
-
` || commentCharacter === "\r" ? commentCharacter : " ";
|
|
495
|
-
index += 1;
|
|
496
|
-
}
|
|
497
|
-
if (!closed)
|
|
498
|
-
throw new Error("unterminated block comment");
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
output += character;
|
|
502
|
-
}
|
|
503
|
-
if (inString)
|
|
504
|
-
throw new Error("unterminated JSON string");
|
|
505
|
-
return output;
|
|
506
|
-
}
|
|
507
|
-
function removeJsoncTrailingCommas(content) {
|
|
508
|
-
let output = "";
|
|
509
|
-
let inString = false;
|
|
510
|
-
let escaped = false;
|
|
511
|
-
for (let index = 0;index < content.length; index += 1) {
|
|
512
|
-
const character = content[index];
|
|
513
|
-
if (!character)
|
|
514
|
-
continue;
|
|
515
|
-
if (inString) {
|
|
516
|
-
output += character;
|
|
517
|
-
if (escaped)
|
|
518
|
-
escaped = false;
|
|
519
|
-
else if (character === "\\")
|
|
520
|
-
escaped = true;
|
|
521
|
-
else if (character === '"')
|
|
522
|
-
inString = false;
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
if (character === '"') {
|
|
526
|
-
inString = true;
|
|
527
|
-
output += character;
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
if (character === ",") {
|
|
531
|
-
let lookahead = index + 1;
|
|
532
|
-
while (/\s/.test(content[lookahead] ?? ""))
|
|
533
|
-
lookahead += 1;
|
|
534
|
-
if (content[lookahead] === "}" || content[lookahead] === "]") {
|
|
535
|
-
output += " ";
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
output += character;
|
|
540
|
-
}
|
|
541
|
-
return output;
|
|
542
|
-
}
|
|
543
|
-
function parseConfigContent(content) {
|
|
544
|
-
let parsed;
|
|
545
|
-
let format = "strict-json";
|
|
546
|
-
try {
|
|
547
|
-
parsed = JSON.parse(content);
|
|
548
|
-
} catch {
|
|
549
|
-
format = "jsonc";
|
|
550
|
-
parsed = JSON.parse(removeJsoncTrailingCommas(stripJsoncComments(content)));
|
|
551
|
-
}
|
|
552
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
553
|
-
throw new Error("config root must be a JSON object");
|
|
554
|
-
}
|
|
555
|
-
const value = parsed;
|
|
556
|
-
return { value, plugin: parsePluginSpecs(value.plugin), format };
|
|
557
|
-
}
|
|
558
|
-
function looksLikeFlowPath(path) {
|
|
559
|
-
const name = basename(path);
|
|
560
|
-
return /opencode-plugin-flow/i.test(path) || /(?:^|[-_.])flow(?:[-_.].*)?(?:plugin|wrapper)|(?:plugin|wrapper).*flow/i.test(name);
|
|
561
|
-
}
|
|
562
|
-
function parseOwnedWrapper(content) {
|
|
563
|
-
if (!content.startsWith(OWNED_WRAPPER_MARKER)) {
|
|
564
|
-
return { kind: "not-owned" };
|
|
565
|
-
}
|
|
566
|
-
const firstNewline = content.indexOf(`
|
|
567
|
-
`);
|
|
568
|
-
const secondNewline = content.indexOf(`
|
|
569
|
-
`, firstNewline + 1);
|
|
570
|
-
const thirdNewline = content.indexOf(`
|
|
571
|
-
`, secondNewline + 1);
|
|
572
|
-
if (firstNewline < 0 || secondNewline < 0 || thirdNewline < 0) {
|
|
573
|
-
return { kind: "invalid", reason: "owned wrapper marker is incomplete" };
|
|
574
|
-
}
|
|
575
|
-
const versionLine = content.slice(firstNewline + 1, secondNewline);
|
|
576
|
-
const hashLine = content.slice(secondNewline + 1, thirdNewline);
|
|
577
|
-
const version = /^\/\/ version=(.+)$/.exec(versionLine)?.[1];
|
|
578
|
-
const expectedHash = /^\/\/ body-sha256=([a-f0-9]{64})$/.exec(hashLine)?.[1];
|
|
579
|
-
if (!version || !isExactFlowVersion(version)) {
|
|
580
|
-
return { kind: "invalid", reason: "owned wrapper version is invalid" };
|
|
581
|
-
}
|
|
582
|
-
if (!expectedHash) {
|
|
583
|
-
return { kind: "invalid", reason: "owned wrapper hash is invalid" };
|
|
584
|
-
}
|
|
585
|
-
const body = content.slice(thirdNewline + 1);
|
|
586
|
-
if (sha256(body) !== expectedHash) {
|
|
587
|
-
return { kind: "invalid", reason: "owned wrapper was edited" };
|
|
588
|
-
}
|
|
589
|
-
if (!body.includes(`${FLOW_PACKAGE_NAME}@${version}`)) {
|
|
590
|
-
return {
|
|
591
|
-
kind: "invalid",
|
|
592
|
-
reason: "owned wrapper body does not reference its declared Flow version"
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
return { kind: "owned", version };
|
|
596
|
-
}
|
|
597
|
-
function legacyFlowWrapperContent(version) {
|
|
598
|
-
return [
|
|
599
|
-
"const flowPluginUrl = new URL(",
|
|
600
|
-
` "../.cache/opencode/packages/${FLOW_PACKAGE_NAME}@${version}/node_modules/${FLOW_PACKAGE_NAME}/dist/index.js",`,
|
|
601
|
-
` \`file://\${process.env.HOME}/\`,`,
|
|
602
|
-
")",
|
|
603
|
-
"",
|
|
604
|
-
"export default async function flowPlugin(input, options) {",
|
|
605
|
-
' process.env.BUN_BE_BUN = "1"',
|
|
606
|
-
" const { default: plugin } = await import(flowPluginUrl.href)",
|
|
607
|
-
" return plugin(input, options)",
|
|
608
|
-
"}",
|
|
609
|
-
""
|
|
610
|
-
].join(`
|
|
611
|
-
`);
|
|
612
|
-
}
|
|
613
|
-
function parseLegacyFlowWrapper(path, content) {
|
|
614
|
-
const normalized = content.replaceAll(`\r
|
|
615
|
-
`, `
|
|
616
|
-
`);
|
|
617
|
-
const version = hintedFlowVersion(normalized);
|
|
618
|
-
if (!version || basename(path) !== `flow-${version}-wrapper.js`)
|
|
619
|
-
return null;
|
|
620
|
-
return normalized === legacyFlowWrapperContent(version) ? { version } : null;
|
|
621
|
-
}
|
|
622
|
-
function hintedFlowVersion(content) {
|
|
623
|
-
const candidate = new RegExp(`${FLOW_PACKAGE_NAME.replaceAll("-", "\\-")}@([^/\\s"']+)`).exec(content)?.[1];
|
|
624
|
-
return candidate && isExactFlowVersion(candidate) ? candidate : null;
|
|
625
|
-
}
|
|
626
|
-
async function classifyLocalPlugin(path, source, scope, target, specifier) {
|
|
627
|
-
const metadata = await optionalLstat(path);
|
|
628
|
-
if (!metadata) {
|
|
629
|
-
if (!looksLikeFlowPath(specifier))
|
|
630
|
-
return null;
|
|
631
|
-
return {
|
|
632
|
-
source,
|
|
633
|
-
scope,
|
|
634
|
-
path,
|
|
635
|
-
specifier,
|
|
636
|
-
resolvedVersion: null,
|
|
637
|
-
ownership: "unknown-flow-like",
|
|
638
|
-
status: "refused",
|
|
639
|
-
reason: "referenced local plugin is absent"
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
if (metadata.isSymbolicLink()) {
|
|
643
|
-
if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
|
|
644
|
-
return null;
|
|
645
|
-
return {
|
|
646
|
-
source,
|
|
647
|
-
scope,
|
|
648
|
-
path,
|
|
649
|
-
specifier,
|
|
650
|
-
resolvedVersion: null,
|
|
651
|
-
ownership: "unknown-flow-like",
|
|
652
|
-
status: "refused",
|
|
653
|
-
reason: "symbolic link refused"
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
if (!metadata.isFile()) {
|
|
657
|
-
if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
|
|
658
|
-
return null;
|
|
659
|
-
return {
|
|
660
|
-
source,
|
|
661
|
-
scope,
|
|
662
|
-
path,
|
|
663
|
-
specifier,
|
|
664
|
-
resolvedVersion: null,
|
|
665
|
-
ownership: "unknown-flow-like",
|
|
666
|
-
status: "refused",
|
|
667
|
-
reason: "local plugin is not a regular file"
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
let content;
|
|
671
|
-
try {
|
|
672
|
-
content = await readRegularFileWithoutFollowing(path, MAX_LOCAL_PLUGIN_BYTES);
|
|
673
|
-
} catch (error) {
|
|
674
|
-
if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
|
|
675
|
-
return null;
|
|
676
|
-
return {
|
|
677
|
-
source,
|
|
678
|
-
scope,
|
|
679
|
-
path,
|
|
680
|
-
specifier,
|
|
681
|
-
resolvedVersion: null,
|
|
682
|
-
ownership: "unknown-flow-like",
|
|
683
|
-
status: "refused",
|
|
684
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
const owned = parseOwnedWrapper(content);
|
|
688
|
-
if (owned.kind === "owned") {
|
|
689
|
-
return {
|
|
690
|
-
source,
|
|
691
|
-
scope,
|
|
692
|
-
path,
|
|
693
|
-
specifier,
|
|
694
|
-
resolvedVersion: owned.version,
|
|
695
|
-
ownership: "marker-owned-wrapper",
|
|
696
|
-
status: "conflict",
|
|
697
|
-
reason: owned.version === target ? "local wrapper duplicates the canonical npm activation source" : "local wrapper activates another Flow version"
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
const legacy = parseLegacyFlowWrapper(path, content);
|
|
701
|
-
if (legacy) {
|
|
702
|
-
return {
|
|
703
|
-
source,
|
|
704
|
-
scope,
|
|
705
|
-
path,
|
|
706
|
-
specifier,
|
|
707
|
-
resolvedVersion: legacy.version,
|
|
708
|
-
ownership: "legacy-flow-wrapper",
|
|
709
|
-
status: "conflict",
|
|
710
|
-
reason: "exact known legacy Flow wrapper must be removed"
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
const flowLike = looksLikeFlowPath(specifier) || looksLikeFlowPath(path) || content.includes(FLOW_PACKAGE_NAME) || content.includes(OWNED_WRAPPER_MARKER);
|
|
714
|
-
if (!flowLike)
|
|
715
|
-
return null;
|
|
716
|
-
return {
|
|
717
|
-
source,
|
|
718
|
-
scope,
|
|
719
|
-
path,
|
|
720
|
-
specifier,
|
|
721
|
-
resolvedVersion: hintedFlowVersion(content),
|
|
722
|
-
ownership: "unknown-flow-like",
|
|
723
|
-
status: "refused",
|
|
724
|
-
reason: owned.kind === "invalid" ? owned.reason : "Flow-like local plugin has no verifiable ownership marker"
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
function relativeLocalSpecifier(configPath, specifier) {
|
|
728
|
-
if (specifier.startsWith("file:"))
|
|
729
|
-
return fileURLToPath(specifier);
|
|
730
|
-
if (isAbsolute(specifier))
|
|
731
|
-
return normalize(specifier);
|
|
732
|
-
return resolve(dirname(configPath), specifier);
|
|
733
|
-
}
|
|
734
|
-
function isLocalPluginSpecifier(specifier) {
|
|
735
|
-
return specifier.startsWith(".") || specifier.startsWith("file:") || isAbsolute(specifier);
|
|
736
|
-
}
|
|
737
|
-
async function inspectConfig(descriptor, target) {
|
|
738
|
-
const records = [];
|
|
739
|
-
const issues = [];
|
|
740
|
-
const symlinkAncestor = await symlinkInPathRange(descriptor.safetyRoot, descriptor.path);
|
|
741
|
-
if (symlinkAncestor) {
|
|
742
|
-
issues.push({
|
|
743
|
-
source: descriptor.source,
|
|
744
|
-
path: descriptor.path,
|
|
745
|
-
code: "unsafe-symlink",
|
|
746
|
-
message: `config ancestor ${symlinkAncestor} is a symbolic link`
|
|
747
|
-
});
|
|
748
|
-
return { records, issues };
|
|
749
|
-
}
|
|
750
|
-
const metadata = await optionalLstat(descriptor.path);
|
|
751
|
-
if (!metadata)
|
|
752
|
-
return { records, issues };
|
|
753
|
-
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
754
|
-
issues.push({
|
|
755
|
-
source: descriptor.source,
|
|
756
|
-
path: descriptor.path,
|
|
757
|
-
code: "invalid-config",
|
|
758
|
-
message: metadata.isSymbolicLink() ? "config symbolic link refused" : "config is not a regular file"
|
|
759
|
-
});
|
|
760
|
-
return { records, issues };
|
|
761
|
-
}
|
|
762
|
-
let content;
|
|
763
|
-
try {
|
|
764
|
-
content = await readRegularFileWithoutFollowing(descriptor.path);
|
|
765
|
-
} catch (error) {
|
|
766
|
-
issues.push({
|
|
767
|
-
source: descriptor.source,
|
|
768
|
-
path: descriptor.path,
|
|
769
|
-
code: "invalid-config",
|
|
770
|
-
message: error instanceof Error ? error.message : String(error)
|
|
771
|
-
});
|
|
772
|
-
return { records, issues };
|
|
773
|
-
}
|
|
774
|
-
let parsed;
|
|
775
|
-
try {
|
|
776
|
-
parsed = parseConfigContent(content);
|
|
777
|
-
} catch (error) {
|
|
778
|
-
issues.push({
|
|
779
|
-
source: descriptor.source,
|
|
780
|
-
path: descriptor.path,
|
|
781
|
-
code: "invalid-config",
|
|
782
|
-
message: `config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`
|
|
783
|
-
});
|
|
784
|
-
return { records, issues };
|
|
785
|
-
}
|
|
786
|
-
for (const entry of parsed.plugin) {
|
|
787
|
-
const specifier = pluginSpecifier(entry);
|
|
788
|
-
const npm = parseFlowNpmSpecifier(specifier);
|
|
789
|
-
if (npm.isFlow) {
|
|
790
|
-
const record = {
|
|
791
|
-
source: descriptor.source,
|
|
792
|
-
scope: descriptor.scope,
|
|
793
|
-
path: descriptor.path,
|
|
794
|
-
specifier,
|
|
795
|
-
resolvedVersion: npm.version,
|
|
796
|
-
ownership: "flow-npm",
|
|
797
|
-
status: npm.version === target ? "target" : "conflict"
|
|
798
|
-
};
|
|
799
|
-
if (npm.version === null) {
|
|
800
|
-
record.reason = "Flow npm activation is not pinned to an exact version";
|
|
801
|
-
} else if (npm.version !== target) {
|
|
802
|
-
record.reason = "Flow npm activation targets another version";
|
|
803
|
-
}
|
|
804
|
-
records.push(record);
|
|
805
|
-
continue;
|
|
806
|
-
}
|
|
807
|
-
if (!looksLikeFlowPath(specifier) && !isLocalPluginSpecifier(specifier)) {
|
|
808
|
-
continue;
|
|
809
|
-
}
|
|
810
|
-
let localPath;
|
|
811
|
-
try {
|
|
812
|
-
localPath = relativeLocalSpecifier(descriptor.path, specifier);
|
|
813
|
-
} catch {
|
|
814
|
-
if (!looksLikeFlowPath(specifier))
|
|
815
|
-
continue;
|
|
816
|
-
records.push({
|
|
817
|
-
source: descriptor.source,
|
|
818
|
-
scope: descriptor.scope,
|
|
819
|
-
path: specifier,
|
|
820
|
-
specifier,
|
|
821
|
-
resolvedVersion: null,
|
|
822
|
-
ownership: "unknown-flow-like",
|
|
823
|
-
status: "refused",
|
|
824
|
-
reason: "local Flow-like plugin specifier is invalid"
|
|
825
|
-
});
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
const local = await classifyLocalPlugin(localPath, descriptor.source, descriptor.scope, target, specifier);
|
|
829
|
-
if (local)
|
|
830
|
-
records.push(local);
|
|
831
|
-
}
|
|
832
|
-
return { records, issues };
|
|
833
|
-
}
|
|
834
|
-
async function inspectInlineConfig(content, project, target) {
|
|
835
|
-
const records = [];
|
|
836
|
-
const issues = [];
|
|
837
|
-
let parsed;
|
|
838
|
-
try {
|
|
839
|
-
parsed = parseConfigContent(content);
|
|
840
|
-
} catch (error) {
|
|
841
|
-
issues.push({
|
|
842
|
-
source: "inline-config",
|
|
843
|
-
path: "env:OPENCODE_CONFIG_CONTENT",
|
|
844
|
-
code: "invalid-config",
|
|
845
|
-
message: `inline config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`
|
|
846
|
-
});
|
|
847
|
-
return { records, issues };
|
|
848
|
-
}
|
|
849
|
-
const virtualConfigPath = join(project, "opencode.inline.json");
|
|
850
|
-
for (const entry of parsed.plugin) {
|
|
851
|
-
const specifier = pluginSpecifier(entry);
|
|
852
|
-
const npm = parseFlowNpmSpecifier(specifier);
|
|
853
|
-
if (npm.isFlow) {
|
|
854
|
-
const record = {
|
|
855
|
-
source: "inline-config",
|
|
856
|
-
scope: "inline",
|
|
857
|
-
path: "env:OPENCODE_CONFIG_CONTENT",
|
|
858
|
-
specifier,
|
|
859
|
-
resolvedVersion: npm.version,
|
|
860
|
-
ownership: "flow-npm",
|
|
861
|
-
status: npm.version === target ? "target" : "conflict"
|
|
862
|
-
};
|
|
863
|
-
if (npm.version === null) {
|
|
864
|
-
record.reason = "Flow npm activation is not pinned to an exact version";
|
|
865
|
-
} else if (npm.version !== target) {
|
|
866
|
-
record.reason = "Flow npm activation targets another version";
|
|
867
|
-
}
|
|
868
|
-
records.push(record);
|
|
869
|
-
continue;
|
|
870
|
-
}
|
|
871
|
-
if (!looksLikeFlowPath(specifier) && !isLocalPluginSpecifier(specifier)) {
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
let localPath;
|
|
875
|
-
try {
|
|
876
|
-
localPath = relativeLocalSpecifier(virtualConfigPath, specifier);
|
|
877
|
-
} catch {
|
|
878
|
-
if (!looksLikeFlowPath(specifier))
|
|
879
|
-
continue;
|
|
880
|
-
records.push({
|
|
881
|
-
source: "inline-config",
|
|
882
|
-
scope: "inline",
|
|
883
|
-
path: "env:OPENCODE_CONFIG_CONTENT",
|
|
884
|
-
specifier,
|
|
885
|
-
resolvedVersion: null,
|
|
886
|
-
ownership: "unknown-flow-like",
|
|
887
|
-
status: "refused",
|
|
888
|
-
reason: "inline local Flow-like plugin specifier is invalid"
|
|
889
|
-
});
|
|
890
|
-
continue;
|
|
891
|
-
}
|
|
892
|
-
const local = await classifyLocalPlugin(localPath, "inline-config", "inline", target, specifier);
|
|
893
|
-
if (local)
|
|
894
|
-
records.push(local);
|
|
895
|
-
}
|
|
896
|
-
return { records, issues };
|
|
897
|
-
}
|
|
898
|
-
async function inspectPluginDirectory(descriptor, target) {
|
|
899
|
-
const records = [];
|
|
900
|
-
const issues = [];
|
|
901
|
-
const symlinkAncestor = await symlinkInPathRange(descriptor.safetyRoot, descriptor.path);
|
|
902
|
-
if (symlinkAncestor) {
|
|
903
|
-
issues.push({
|
|
904
|
-
source: descriptor.source,
|
|
905
|
-
path: descriptor.path,
|
|
906
|
-
code: "unsafe-symlink",
|
|
907
|
-
message: `plugin directory ancestor ${symlinkAncestor} is a symbolic link`
|
|
908
|
-
});
|
|
909
|
-
return { records, issues };
|
|
910
|
-
}
|
|
911
|
-
const metadata = await optionalLstat(descriptor.path);
|
|
912
|
-
if (!metadata)
|
|
913
|
-
return { records, issues };
|
|
914
|
-
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
915
|
-
issues.push({
|
|
916
|
-
source: descriptor.source,
|
|
917
|
-
path: descriptor.path,
|
|
918
|
-
code: "invalid-plugin-directory",
|
|
919
|
-
message: metadata.isSymbolicLink() ? "plugin directory symbolic link refused" : "plugin directory is not a real directory"
|
|
920
|
-
});
|
|
921
|
-
return { records, issues };
|
|
922
|
-
}
|
|
923
|
-
const entries = await readdir(descriptor.path, { withFileTypes: true });
|
|
924
|
-
for (const entry of entries) {
|
|
925
|
-
if (!LOCAL_PLUGIN_EXTENSION_PATTERN.test(entry.name))
|
|
926
|
-
continue;
|
|
927
|
-
const path = join(descriptor.path, entry.name);
|
|
928
|
-
const record = await classifyLocalPlugin(path, descriptor.source, descriptor.scope, target, path);
|
|
929
|
-
if (record)
|
|
930
|
-
records.push(record);
|
|
931
|
-
}
|
|
932
|
-
return { records, issues };
|
|
933
|
-
}
|
|
934
|
-
async function inspectCacheArtifact(path, specifier, target) {
|
|
935
|
-
const metadata = await optionalLstat(path);
|
|
936
|
-
if (!metadata?.isDirectory() || metadata.isSymbolicLink()) {
|
|
937
|
-
return {
|
|
938
|
-
path,
|
|
939
|
-
specifier,
|
|
940
|
-
resolvedVersion: null,
|
|
941
|
-
status: "ambiguous",
|
|
942
|
-
reason: "cache artifact is not a real directory"
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
const nodeModulesPath = join(path, "node_modules");
|
|
946
|
-
const packagePath = join(nodeModulesPath, FLOW_PACKAGE_NAME);
|
|
947
|
-
const manifestPath = join(packagePath, "package.json");
|
|
948
|
-
try {
|
|
949
|
-
for (const directory of [nodeModulesPath, packagePath]) {
|
|
950
|
-
const directoryMetadata = await lstat(directory);
|
|
951
|
-
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
952
|
-
throw new Error("nested package path is not a real directory");
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
const manifest = JSON.parse(await readRegularFileWithoutFollowing(manifestPath));
|
|
956
|
-
if (manifest.name !== FLOW_PACKAGE_NAME || typeof manifest.version !== "string" || !isExactFlowVersion(manifest.version)) {
|
|
957
|
-
throw new Error("nested package manifest does not prove a Flow version");
|
|
958
|
-
}
|
|
959
|
-
return {
|
|
960
|
-
path,
|
|
961
|
-
specifier,
|
|
962
|
-
resolvedVersion: manifest.version,
|
|
963
|
-
status: manifest.version === target ? "target" : "inactive"
|
|
964
|
-
};
|
|
965
|
-
} catch (error) {
|
|
966
|
-
return {
|
|
967
|
-
path,
|
|
968
|
-
specifier,
|
|
969
|
-
resolvedVersion: null,
|
|
970
|
-
status: "ambiguous",
|
|
971
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
async function inspectCache(paths, target) {
|
|
976
|
-
const artifacts = [];
|
|
977
|
-
const issues = [];
|
|
978
|
-
const symlinkAncestor = await symlinkInPathRange(dirname(paths.cacheRoot), paths.packageCacheRoot);
|
|
979
|
-
if (symlinkAncestor) {
|
|
980
|
-
issues.push({
|
|
981
|
-
source: "cache",
|
|
982
|
-
path: paths.packageCacheRoot,
|
|
983
|
-
code: "unsafe-symlink",
|
|
984
|
-
message: `cache ancestor ${symlinkAncestor} is a symbolic link`
|
|
985
|
-
});
|
|
986
|
-
return { artifacts, issues };
|
|
987
|
-
}
|
|
988
|
-
const metadata = await optionalLstat(paths.packageCacheRoot);
|
|
989
|
-
if (!metadata)
|
|
990
|
-
return { artifacts, issues };
|
|
991
|
-
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
992
|
-
issues.push({
|
|
993
|
-
source: "cache",
|
|
994
|
-
path: paths.packageCacheRoot,
|
|
995
|
-
code: "ambiguous-cache-artifact",
|
|
996
|
-
message: "OpenCode package cache root is not a real directory"
|
|
997
|
-
});
|
|
998
|
-
return { artifacts, issues };
|
|
999
|
-
}
|
|
1000
|
-
for (const entry of await readdir(paths.packageCacheRoot, {
|
|
1001
|
-
withFileTypes: true
|
|
1002
|
-
})) {
|
|
1003
|
-
if (entry.name !== FLOW_PACKAGE_NAME && !entry.name.startsWith(`${FLOW_PACKAGE_NAME}@`)) {
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
const artifact = await inspectCacheArtifact(join(paths.packageCacheRoot, entry.name), entry.name, target);
|
|
1007
|
-
artifacts.push(artifact);
|
|
1008
|
-
if (artifact.status === "ambiguous") {
|
|
1009
|
-
issues.push({
|
|
1010
|
-
source: "cache",
|
|
1011
|
-
path: artifact.path,
|
|
1012
|
-
code: "ambiguous-cache-artifact",
|
|
1013
|
-
message: artifact.reason ?? "cache artifact does not prove a Flow version"
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
return { artifacts, issues };
|
|
1018
|
-
}
|
|
1019
|
-
async function activationLimitations(paths) {
|
|
1020
|
-
const limitations = [
|
|
1021
|
-
{
|
|
1022
|
-
source: "remote-config",
|
|
1023
|
-
coverage: "runtime-leadership",
|
|
1024
|
-
blocking: false,
|
|
1025
|
-
detail: "Authenticated .well-known and organization API configs cannot be inspected offline; Flow runtime leadership detects duplicate loaded versions within each OpenCode project context and fails closed there."
|
|
1026
|
-
}
|
|
1027
|
-
];
|
|
1028
|
-
if (paths.managedPreferencePaths.length > 0) {
|
|
1029
|
-
const detected = [];
|
|
1030
|
-
for (const path of paths.managedPreferencePaths) {
|
|
1031
|
-
try {
|
|
1032
|
-
if (await optionalLstat(path))
|
|
1033
|
-
detected.push(path);
|
|
1034
|
-
} catch {
|
|
1035
|
-
detected.push(`${path} (unreadable)`);
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
limitations.push({
|
|
1039
|
-
source: "managed-preferences",
|
|
1040
|
-
coverage: "runtime-leadership",
|
|
1041
|
-
blocking: false,
|
|
1042
|
-
detail: detected.length > 0 ? `Detected managed preference source(s) ${detected.join(", ")}; plist/MDM plugin values are not decoded by this dependency-free preflight, and runtime leadership fails closed on same-project duplicates.` : "macOS MDM preferences may add runtime config outside readable JSON/JSONC files; Flow runtime leadership fails closed on duplicate loaded versions within each OpenCode project context."
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
return limitations;
|
|
1046
|
-
}
|
|
1047
|
-
function activationReasons(records, cacheArtifacts, issues, target) {
|
|
1048
|
-
const reasons = issues.map((issue) => `${issue.path}: ${issue.message}`);
|
|
1049
|
-
const targetPins = records.filter((record) => record.ownership === "flow-npm" && record.resolvedVersion === target && record.status === "target");
|
|
1050
|
-
if (targetPins.length !== 1) {
|
|
1051
|
-
reasons.push(`expected one exact ${FLOW_PACKAGE_NAME}@${target} activation, found ${targetPins.length}`);
|
|
1052
|
-
}
|
|
1053
|
-
if (records.length !== 1) {
|
|
1054
|
-
reasons.push(`expected one Flow activation source, found ${records.length}`);
|
|
1055
|
-
}
|
|
1056
|
-
const inactive = cacheArtifacts.filter((artifact) => artifact.status === "inactive");
|
|
1057
|
-
if (inactive.length > 0) {
|
|
1058
|
-
reasons.push(`found ${inactive.length} inactive Flow cache artifact(s)`);
|
|
1059
|
-
}
|
|
1060
|
-
return [...new Set(reasons)];
|
|
1061
|
-
}
|
|
1062
|
-
async function checkFlowActivation(options) {
|
|
1063
|
-
const target = resolveActivationTarget(options.target);
|
|
1064
|
-
const paths = resolveActivationPaths(options.project, options.paths);
|
|
1065
|
-
const env = options.paths?.env ?? process.env;
|
|
1066
|
-
const records = [];
|
|
1067
|
-
const issues = [];
|
|
1068
|
-
try {
|
|
1069
|
-
const projectMetadata = await lstat(paths.project);
|
|
1070
|
-
if (projectMetadata.isSymbolicLink()) {
|
|
1071
|
-
issues.push({
|
|
1072
|
-
source: "project",
|
|
1073
|
-
path: paths.project,
|
|
1074
|
-
code: "unsafe-symlink",
|
|
1075
|
-
message: "project root symbolic link refused for activation mutation safety"
|
|
1076
|
-
});
|
|
1077
|
-
} else if (!projectMetadata.isDirectory()) {
|
|
1078
|
-
issues.push({
|
|
1079
|
-
source: "project",
|
|
1080
|
-
path: paths.project,
|
|
1081
|
-
code: "invalid-project",
|
|
1082
|
-
message: "project path is not a directory"
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
} catch (error) {
|
|
1086
|
-
issues.push({
|
|
1087
|
-
source: "project",
|
|
1088
|
-
path: paths.project,
|
|
1089
|
-
code: "invalid-project",
|
|
1090
|
-
message: error.code === "ENOENT" ? "project path does not exist" : error instanceof Error ? error.message : String(error)
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
for (const descriptor of configDescriptors(paths)) {
|
|
1094
|
-
try {
|
|
1095
|
-
const inspected = await inspectConfig(descriptor, target);
|
|
1096
|
-
records.push(...inspected.records);
|
|
1097
|
-
issues.push(...inspected.issues);
|
|
1098
|
-
} catch (error) {
|
|
1099
|
-
issues.push({
|
|
1100
|
-
source: descriptor.source,
|
|
1101
|
-
path: descriptor.path,
|
|
1102
|
-
code: "invalid-config",
|
|
1103
|
-
message: `config could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
if (env.OPENCODE_CONFIG_CONTENT?.trim()) {
|
|
1108
|
-
const inspected = await inspectInlineConfig(env.OPENCODE_CONFIG_CONTENT, paths.project, target);
|
|
1109
|
-
records.push(...inspected.records);
|
|
1110
|
-
issues.push(...inspected.issues);
|
|
1111
|
-
}
|
|
1112
|
-
for (const descriptor of pluginDirectoryDescriptors(paths)) {
|
|
1113
|
-
try {
|
|
1114
|
-
const inspected = await inspectPluginDirectory(descriptor, target);
|
|
1115
|
-
records.push(...inspected.records);
|
|
1116
|
-
issues.push(...inspected.issues);
|
|
1117
|
-
} catch (error) {
|
|
1118
|
-
issues.push({
|
|
1119
|
-
source: descriptor.source,
|
|
1120
|
-
path: descriptor.path,
|
|
1121
|
-
code: "invalid-plugin-directory",
|
|
1122
|
-
message: `plugin directory could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
let cache;
|
|
1127
|
-
try {
|
|
1128
|
-
cache = await inspectCache(paths, target);
|
|
1129
|
-
} catch (error) {
|
|
1130
|
-
cache = {
|
|
1131
|
-
artifacts: [],
|
|
1132
|
-
issues: [
|
|
1133
|
-
{
|
|
1134
|
-
source: "cache",
|
|
1135
|
-
path: paths.packageCacheRoot,
|
|
1136
|
-
code: "ambiguous-cache-artifact",
|
|
1137
|
-
message: `package cache could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
|
|
1138
|
-
}
|
|
1139
|
-
]
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
issues.push(...cache.issues);
|
|
1143
|
-
issues.push(...await activationJournalIssues(paths, options.ignoreRecoveryRunId));
|
|
1144
|
-
const limitations = await activationLimitations(paths);
|
|
1145
|
-
const reasons = activationReasons(records, cache.artifacts, issues, target);
|
|
1146
|
-
return {
|
|
1147
|
-
mode: "check",
|
|
1148
|
-
project: paths.project,
|
|
1149
|
-
target,
|
|
1150
|
-
coverage: {
|
|
1151
|
-
globalSources: true,
|
|
1152
|
-
selectedProject: paths.project,
|
|
1153
|
-
otherProjectTrees: false
|
|
1154
|
-
},
|
|
1155
|
-
paths,
|
|
1156
|
-
records,
|
|
1157
|
-
cacheArtifacts: cache.artifacts,
|
|
1158
|
-
issues,
|
|
1159
|
-
limitations,
|
|
1160
|
-
singleVersionSatisfied: reasons.length === 0,
|
|
1161
|
-
reasons
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
|
-
function detectIndent(content) {
|
|
1165
|
-
return /\n([ \t]+)"/.exec(content)?.[1] ?? "\t";
|
|
1166
|
-
}
|
|
1167
|
-
async function readConfigSnapshot(descriptor) {
|
|
1168
|
-
const metadata = await optionalLstat(descriptor.path);
|
|
1169
|
-
if (!metadata) {
|
|
1170
|
-
return {
|
|
1171
|
-
descriptor,
|
|
1172
|
-
exists: false,
|
|
1173
|
-
content: null,
|
|
1174
|
-
digest: null,
|
|
1175
|
-
value: {},
|
|
1176
|
-
plugin: [],
|
|
1177
|
-
format: "strict-json",
|
|
1178
|
-
indent: "\t",
|
|
1179
|
-
newline: `
|
|
1180
|
-
`,
|
|
1181
|
-
finalNewline: true,
|
|
1182
|
-
mode: 384
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
1186
|
-
throw new Error(`${descriptor.path}: config must be a regular file`);
|
|
1187
|
-
}
|
|
1188
|
-
await assertSafeMutationPath(descriptor.safetyRoot, descriptor.path);
|
|
1189
|
-
const content = await readRegularFileWithoutFollowing(descriptor.path);
|
|
1190
|
-
let parsed;
|
|
1191
|
-
try {
|
|
1192
|
-
parsed = parseConfigContent(content);
|
|
1193
|
-
} catch (error) {
|
|
1194
|
-
throw new Error(`${descriptor.path}: config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`);
|
|
1195
|
-
}
|
|
1196
|
-
return {
|
|
1197
|
-
descriptor,
|
|
1198
|
-
exists: true,
|
|
1199
|
-
content,
|
|
1200
|
-
digest: sha256(content),
|
|
1201
|
-
value: parsed.value,
|
|
1202
|
-
plugin: parsed.plugin,
|
|
1203
|
-
format: parsed.format,
|
|
1204
|
-
indent: detectIndent(content),
|
|
1205
|
-
newline: content.includes(`\r
|
|
1206
|
-
`) ? `\r
|
|
1207
|
-
` : `
|
|
1208
|
-
`,
|
|
1209
|
-
finalNewline: content.endsWith(`
|
|
1210
|
-
`),
|
|
1211
|
-
mode: metadata.mode & 511
|
|
1212
|
-
};
|
|
1213
|
-
}
|
|
1214
|
-
function updatedConfigContent(snapshot, entries) {
|
|
1215
|
-
const value = { ...snapshot.value, plugin: entries };
|
|
1216
|
-
let content = JSON.stringify(value, null, snapshot.indent).replaceAll(`
|
|
1217
|
-
`, snapshot.newline);
|
|
1218
|
-
if (snapshot.finalNewline)
|
|
1219
|
-
content += snapshot.newline;
|
|
1220
|
-
return content;
|
|
1221
|
-
}
|
|
1222
|
-
function wrapperRecoveryRoot(paths, wrapper) {
|
|
1223
|
-
if ([
|
|
1224
|
-
"global-config",
|
|
1225
|
-
"project-config",
|
|
1226
|
-
"project-directory-config",
|
|
1227
|
-
"custom-config",
|
|
1228
|
-
"custom-directory-config"
|
|
1229
|
-
].includes(wrapper.source)) {
|
|
1230
|
-
return join(dirname(wrapper.path), ".flow-activation-recovery");
|
|
1231
|
-
}
|
|
1232
|
-
if (wrapper.source === "home-plugin-directory") {
|
|
1233
|
-
return join(paths.home, ".opencode", "flow-activation-recovery");
|
|
1234
|
-
}
|
|
1235
|
-
if (wrapper.scope === "global")
|
|
1236
|
-
return paths.globalWrapperRecoveryRoot;
|
|
1237
|
-
if (wrapper.scope === "custom" && paths.customConfigDirectory) {
|
|
1238
|
-
return join(paths.customConfigDirectory, "flow-activation-recovery");
|
|
1239
|
-
}
|
|
1240
|
-
return paths.projectWrapperRecoveryRoot;
|
|
1241
|
-
}
|
|
1242
|
-
function wrapperMutationSafetyRoot(paths, wrapper) {
|
|
1243
|
-
const directory = pluginDirectoryDescriptors(paths).find((descriptor) => {
|
|
1244
|
-
if (descriptor.source !== wrapper.source)
|
|
1245
|
-
return false;
|
|
1246
|
-
const fromDirectory = relative(descriptor.path, wrapper.path);
|
|
1247
|
-
return fromDirectory !== ".." && !fromDirectory.startsWith(`..${sep}`) && !isAbsolute(fromDirectory);
|
|
1248
|
-
});
|
|
1249
|
-
if (directory)
|
|
1250
|
-
return directory.safetyRoot;
|
|
1251
|
-
const config = configDescriptors(paths).find((descriptor) => descriptor.source === wrapper.source);
|
|
1252
|
-
return config?.safetyRoot ?? dirname(wrapper.path);
|
|
1253
|
-
}
|
|
1254
|
-
function uniqueOwnedWrappers(records) {
|
|
1255
|
-
const wrappers = new Map;
|
|
1256
|
-
for (const record of records) {
|
|
1257
|
-
if (record.ownership !== "marker-owned-wrapper" && record.ownership !== "legacy-flow-wrapper" || record.resolvedVersion === null) {
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
wrappers.set(record.path, {
|
|
1261
|
-
path: record.path,
|
|
1262
|
-
scope: record.scope,
|
|
1263
|
-
source: record.source,
|
|
1264
|
-
version: record.resolvedVersion,
|
|
1265
|
-
ownership: record.ownership
|
|
1266
|
-
});
|
|
1267
|
-
}
|
|
1268
|
-
return [...wrappers.values()];
|
|
1269
|
-
}
|
|
1270
|
-
function removableConfigEntry(entry, descriptor, records) {
|
|
1271
|
-
const specifier = pluginSpecifier(entry);
|
|
1272
|
-
if (parseFlowNpmSpecifier(specifier).isFlow)
|
|
1273
|
-
return true;
|
|
1274
|
-
if (!isLocalPluginSpecifier(specifier) && !looksLikeFlowPath(specifier)) {
|
|
1275
|
-
return false;
|
|
1276
|
-
}
|
|
1277
|
-
let localPath;
|
|
1278
|
-
try {
|
|
1279
|
-
localPath = relativeLocalSpecifier(descriptor.path, specifier);
|
|
1280
|
-
} catch {
|
|
1281
|
-
return false;
|
|
1282
|
-
}
|
|
1283
|
-
return records.some((record) => record.source === descriptor.source && record.specifier === specifier && record.path === localPath && (record.ownership === "marker-owned-wrapper" || record.ownership === "legacy-flow-wrapper"));
|
|
1284
|
-
}
|
|
1285
|
-
function activationRefusals(before) {
|
|
1286
|
-
return [
|
|
1287
|
-
...before.issues.map((issue) => `${issue.path}: ${issue.message}`),
|
|
1288
|
-
...before.records.filter((record) => record.ownership === "unknown-flow-like").map((record) => `${record.path}: ${record.reason ?? "unknown Flow-like activation refused"}`),
|
|
1289
|
-
...before.cacheArtifacts.filter((artifact) => artifact.status === "ambiguous").map((artifact) => `${artifact.path}: ${artifact.reason ?? "ambiguous cache artifact refused"}`)
|
|
1290
|
-
];
|
|
1291
|
-
}
|
|
1292
|
-
function downgradeRefusals(before) {
|
|
1293
|
-
const newerVersions = new Set;
|
|
1294
|
-
for (const version of [
|
|
1295
|
-
...before.records.map((record) => record.resolvedVersion),
|
|
1296
|
-
...before.cacheArtifacts.map((artifact) => artifact.resolvedVersion)
|
|
1297
|
-
]) {
|
|
1298
|
-
if (version && compareSemanticVersions(version, before.target) > 0) {
|
|
1299
|
-
newerVersions.add(version);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
return [...newerVersions].sort((left, right) => compareSemanticVersions(right, left)).map((version) => `refusing to replace newer installed Flow ${version} with older target ${before.target}; run ${FLOW_PACKAGE_NAME}@latest instead`);
|
|
1303
|
-
}
|
|
1304
|
-
async function assertUnchangedConfig(snapshot) {
|
|
1305
|
-
await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
|
|
1306
|
-
const metadata = await optionalLstat(snapshot.descriptor.path);
|
|
1307
|
-
if (!snapshot.exists) {
|
|
1308
|
-
if (metadata) {
|
|
1309
|
-
throw new Error(`${snapshot.descriptor.path}: config appeared while activation was running`);
|
|
1310
|
-
}
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
if (!metadata?.isFile() || metadata.isSymbolicLink()) {
|
|
1314
|
-
throw new Error(`${snapshot.descriptor.path}: config changed while activation was running`);
|
|
1315
|
-
}
|
|
1316
|
-
const current = await readRegularFileWithoutFollowing(snapshot.descriptor.path);
|
|
1317
|
-
if (sha256(current) !== snapshot.digest) {
|
|
1318
|
-
throw new Error(`${snapshot.descriptor.path}: config changed while activation was running`);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
async function atomicWriteConfig(snapshot, content, runId) {
|
|
1322
|
-
await assertUnchangedConfig(snapshot);
|
|
1323
|
-
await mkdir(dirname(snapshot.descriptor.path), { recursive: true });
|
|
1324
|
-
await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
|
|
1325
|
-
const temporaryPath = join(dirname(snapshot.descriptor.path), `.${basename(snapshot.descriptor.path)}.flow-${runId}.tmp`);
|
|
1326
|
-
try {
|
|
1327
|
-
await writeFile(temporaryPath, content, {
|
|
1328
|
-
encoding: "utf8",
|
|
1329
|
-
flag: "wx",
|
|
1330
|
-
mode: snapshot.mode
|
|
1331
|
-
});
|
|
1332
|
-
await rename(temporaryPath, snapshot.descriptor.path);
|
|
1333
|
-
} finally {
|
|
1334
|
-
await rm(temporaryPath, { force: true });
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
async function configIsWritable(snapshot) {
|
|
1338
|
-
if (!snapshot.descriptor.mutable)
|
|
1339
|
-
return false;
|
|
1340
|
-
try {
|
|
1341
|
-
await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
|
|
1342
|
-
if (snapshot.exists) {
|
|
1343
|
-
await access(snapshot.descriptor.path, constants.W_OK);
|
|
1344
|
-
return true;
|
|
1345
|
-
}
|
|
1346
|
-
let parent = dirname(snapshot.descriptor.path);
|
|
1347
|
-
while (!await optionalLstat(parent)) {
|
|
1348
|
-
const next = dirname(parent);
|
|
1349
|
-
if (next === parent)
|
|
1350
|
-
return false;
|
|
1351
|
-
parent = next;
|
|
1352
|
-
}
|
|
1353
|
-
await access(parent, constants.W_OK);
|
|
1354
|
-
return true;
|
|
1355
|
-
} catch {
|
|
1356
|
-
return false;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
async function pathCanBeMoved(source, destinationRoot) {
|
|
1360
|
-
try {
|
|
1361
|
-
await access(dirname(source), constants.W_OK);
|
|
1362
|
-
let parent = destinationRoot;
|
|
1363
|
-
while (!await optionalLstat(parent)) {
|
|
1364
|
-
const next = dirname(parent);
|
|
1365
|
-
if (next === parent)
|
|
1366
|
-
return false;
|
|
1367
|
-
parent = next;
|
|
1368
|
-
}
|
|
1369
|
-
await access(parent, constants.W_OK);
|
|
1370
|
-
return true;
|
|
1371
|
-
} catch {
|
|
1372
|
-
return false;
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
async function writeJournal(journalPath, journal) {
|
|
1376
|
-
await mkdir(dirname(journalPath), { recursive: true, mode: 448 });
|
|
1377
|
-
const temporaryPath = `${journalPath}.${randomUUID()}.tmp`;
|
|
1378
|
-
try {
|
|
1379
|
-
await writeFile(temporaryPath, `${JSON.stringify(journal, null, 2)}
|
|
1380
|
-
`, {
|
|
1381
|
-
encoding: "utf8",
|
|
1382
|
-
flag: "wx",
|
|
1383
|
-
mode: 384
|
|
1384
|
-
});
|
|
1385
|
-
await rename(temporaryPath, journalPath);
|
|
1386
|
-
} finally {
|
|
1387
|
-
await rm(temporaryPath, { force: true });
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
var TERMINAL_JOURNAL_STATES = new Set([
|
|
1391
|
-
"complete",
|
|
1392
|
-
"rolled-back"
|
|
1393
|
-
]);
|
|
1394
|
-
function isRecord(value) {
|
|
1395
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1396
|
-
}
|
|
1397
|
-
function parseActivationJournal(content, journalPath) {
|
|
1398
|
-
let value;
|
|
1399
|
-
try {
|
|
1400
|
-
value = JSON.parse(content);
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
throw new Error(`${journalPath}: recovery journal is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1403
|
-
}
|
|
1404
|
-
if (!isRecord(value) || typeof value.runId !== "string" || value.runId !== basename(dirname(journalPath)) || typeof value.state !== "string") {
|
|
1405
|
-
throw new Error(`${journalPath}: recovery journal schema is invalid`);
|
|
1406
|
-
}
|
|
1407
|
-
if (value.format === "flow-activation-journal-v1") {
|
|
1408
|
-
if (![
|
|
1409
|
-
"prepared",
|
|
1410
|
-
"applying",
|
|
1411
|
-
"complete",
|
|
1412
|
-
"failed",
|
|
1413
|
-
"rolled-back",
|
|
1414
|
-
"rollback-failed"
|
|
1415
|
-
].includes(value.state)) {
|
|
1416
|
-
throw new Error(`${journalPath}: legacy recovery journal state is invalid`);
|
|
1417
|
-
}
|
|
1418
|
-
return value;
|
|
1419
|
-
}
|
|
1420
|
-
if (value.format !== "flow-activation-journal-v2" || typeof value.createdAt !== "string" || typeof value.project !== "string" || !isAbsolute(value.project) || typeof value.target !== "string" || !isExactFlowVersion(value.target) || value.scope !== "global" && value.scope !== "project" || ![
|
|
1421
|
-
"prepared",
|
|
1422
|
-
"applying",
|
|
1423
|
-
"committed",
|
|
1424
|
-
"complete",
|
|
1425
|
-
"failed",
|
|
1426
|
-
"cleanup-failed",
|
|
1427
|
-
"rolled-back",
|
|
1428
|
-
"rollback-failed"
|
|
1429
|
-
].includes(value.state) || !Array.isArray(value.actions)) {
|
|
1430
|
-
throw new Error(`${journalPath}: recovery journal schema is invalid`);
|
|
1431
|
-
}
|
|
1432
|
-
for (const action of value.actions) {
|
|
1433
|
-
if (!isRecord(action) || !["rewrite-config", "remove-wrapper", "remove-cache"].includes(String(action.action)) || typeof action.path !== "string" || !isAbsolute(action.path) || !["pending", "complete", "rolled-back", "rollback-failed"].includes(String(action.state))) {
|
|
1434
|
-
throw new Error(`${journalPath}: recovery journal action is invalid`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
if (value.ownerPid !== undefined && (!Number.isSafeInteger(value.ownerPid) || Number(value.ownerPid) <= 0)) {
|
|
1438
|
-
throw new Error(`${journalPath}: recovery journal owner pid is invalid`);
|
|
1439
|
-
}
|
|
1440
|
-
return value;
|
|
1441
|
-
}
|
|
1442
|
-
async function readActivationJournalEntries(paths) {
|
|
1443
|
-
try {
|
|
1444
|
-
await assertSafeMutationPath(dirname(paths.configRoot), paths.journalRoot);
|
|
1445
|
-
} catch (error) {
|
|
1446
|
-
return [
|
|
1447
|
-
{
|
|
1448
|
-
journalPath: paths.journalRoot,
|
|
1449
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1450
|
-
}
|
|
1451
|
-
];
|
|
1452
|
-
}
|
|
1453
|
-
const rootMetadata = await optionalLstat(paths.journalRoot);
|
|
1454
|
-
if (!rootMetadata)
|
|
1455
|
-
return [];
|
|
1456
|
-
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
|
1457
|
-
return [
|
|
1458
|
-
{
|
|
1459
|
-
journalPath: paths.journalRoot,
|
|
1460
|
-
error: `${paths.journalRoot}: recovery root is not a real directory`
|
|
1461
|
-
}
|
|
1462
|
-
];
|
|
1463
|
-
}
|
|
1464
|
-
const entries = [];
|
|
1465
|
-
for (const directory of await readdir(paths.journalRoot, {
|
|
1466
|
-
withFileTypes: true
|
|
1467
|
-
})) {
|
|
1468
|
-
const runRoot = join(paths.journalRoot, directory.name);
|
|
1469
|
-
const journalPath = join(runRoot, "journal.json");
|
|
1470
|
-
if (directory.isSymbolicLink()) {
|
|
1471
|
-
entries.push({
|
|
1472
|
-
journalPath,
|
|
1473
|
-
error: `${runRoot}: symbolic recovery directory refused`
|
|
1474
|
-
});
|
|
1475
|
-
continue;
|
|
1476
|
-
}
|
|
1477
|
-
if (!directory.isDirectory() || !await optionalLstat(journalPath)) {
|
|
1478
|
-
continue;
|
|
1479
|
-
}
|
|
1480
|
-
try {
|
|
1481
|
-
const content = await readRegularFileWithoutFollowing(journalPath, MAX_LOCAL_PLUGIN_BYTES);
|
|
1482
|
-
entries.push({
|
|
1483
|
-
journalPath,
|
|
1484
|
-
journal: parseActivationJournal(content, journalPath)
|
|
1485
|
-
});
|
|
1486
|
-
} catch (error) {
|
|
1487
|
-
entries.push({
|
|
1488
|
-
journalPath,
|
|
1489
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1490
|
-
});
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
return entries.sort((left, right) => left.journalPath.localeCompare(right.journalPath));
|
|
1494
|
-
}
|
|
1495
|
-
function processIsAlive(pid) {
|
|
1496
|
-
try {
|
|
1497
|
-
process.kill(pid, 0);
|
|
1498
|
-
return true;
|
|
1499
|
-
} catch (error) {
|
|
1500
|
-
return error.code === "EPERM";
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
async function activationJournalIssues(paths, ignoreRunId) {
|
|
1504
|
-
const issues = [];
|
|
1505
|
-
for (const entry of await readActivationJournalEntries(paths)) {
|
|
1506
|
-
if (entry.error) {
|
|
1507
|
-
issues.push({
|
|
1508
|
-
source: "recovery",
|
|
1509
|
-
path: entry.journalPath,
|
|
1510
|
-
code: "incomplete-recovery",
|
|
1511
|
-
message: entry.error
|
|
1512
|
-
});
|
|
1513
|
-
continue;
|
|
1514
|
-
}
|
|
1515
|
-
const journal = entry.journal;
|
|
1516
|
-
if (journal.runId === ignoreRunId || TERMINAL_JOURNAL_STATES.has(journal.state)) {
|
|
1517
|
-
continue;
|
|
1518
|
-
}
|
|
1519
|
-
const activeOwner = journal.format === "flow-activation-journal-v2" && journal.ownerPid !== undefined && processIsAlive(journal.ownerPid);
|
|
1520
|
-
issues.push({
|
|
1521
|
-
source: "recovery",
|
|
1522
|
-
path: entry.journalPath,
|
|
1523
|
-
code: "incomplete-recovery",
|
|
1524
|
-
message: activeOwner ? `activation recovery is still owned by running process ${journal.ownerPid}` : journal.format === "flow-activation-journal-v1" ? `legacy activation recovery is incomplete in state ${journal.state}; follow that journal's manual recovery guidance before installing` : journal.state === "rollback-failed" ? "activation rollback previously failed and requires the journal's manual recovery guidance" : `activation recovery is incomplete in state ${journal.state}; rerun install to reconcile it before evaluating success`
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
return issues;
|
|
1528
|
-
}
|
|
1529
|
-
async function verifyOwnedWrapper(wrapper) {
|
|
1530
|
-
const content = await readRegularFileWithoutFollowing(wrapper.path, MAX_LOCAL_PLUGIN_BYTES);
|
|
1531
|
-
const version = wrapper.ownership === "marker-owned-wrapper" ? (() => {
|
|
1532
|
-
const parsed = parseOwnedWrapper(content);
|
|
1533
|
-
return parsed.kind === "owned" ? parsed.version : null;
|
|
1534
|
-
})() : parseLegacyFlowWrapper(wrapper.path, content)?.version;
|
|
1535
|
-
if (version !== wrapper.version) {
|
|
1536
|
-
throw new Error(`${wrapper.path}: removable wrapper changed while activation was running`);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
async function verifyCacheArtifact(artifact, target) {
|
|
1540
|
-
const inspected = await inspectCacheArtifact(artifact.path, artifact.specifier, target);
|
|
1541
|
-
if (inspected.status !== "inactive" || inspected.resolvedVersion !== artifact.resolvedVersion) {
|
|
1542
|
-
throw new Error(`${artifact.path}: cache artifact changed while activation was running`);
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
async function replaceKnownConfigContent(options) {
|
|
1546
|
-
await assertSafeMutationPath(options.descriptor.safetyRoot, options.descriptor.path);
|
|
1547
|
-
const current = await readRegularFileWithoutFollowing(options.descriptor.path);
|
|
1548
|
-
if (sha256(current) !== options.expectedDigest) {
|
|
1549
|
-
throw new Error(`${options.descriptor.path}: automatic restore refused because the applied config changed`);
|
|
1550
|
-
}
|
|
1551
|
-
const temporaryPath = join(dirname(options.descriptor.path), `.${basename(options.descriptor.path)}.restore-${options.runId}.tmp`);
|
|
1552
|
-
try {
|
|
1553
|
-
await writeFile(temporaryPath, options.content, {
|
|
1554
|
-
encoding: "utf8",
|
|
1555
|
-
flag: "wx",
|
|
1556
|
-
mode: options.mode
|
|
1557
|
-
});
|
|
1558
|
-
await rename(temporaryPath, options.descriptor.path);
|
|
1559
|
-
} finally {
|
|
1560
|
-
await rm(temporaryPath, { force: true });
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
async function rollbackCompletedActions(options) {
|
|
1564
|
-
const failures = [];
|
|
1565
|
-
for (const action of options.journal.actions.toReversed()) {
|
|
1566
|
-
if (action.state !== "complete")
|
|
1567
|
-
continue;
|
|
1568
|
-
try {
|
|
1569
|
-
if (action.action === "rewrite-config") {
|
|
1570
|
-
const snapshot = options.snapshots.find((candidate) => candidate.descriptor.path === action.path);
|
|
1571
|
-
if (!snapshot || !action.appliedDigest) {
|
|
1572
|
-
throw new Error("restore metadata is incomplete");
|
|
1573
|
-
}
|
|
1574
|
-
if (action.originalAbsent) {
|
|
1575
|
-
await assertSafeMutationPath(snapshot.descriptor.safetyRoot, action.path);
|
|
1576
|
-
const current = await readRegularFileWithoutFollowing(action.path);
|
|
1577
|
-
if (sha256(current) !== action.appliedDigest) {
|
|
1578
|
-
throw new Error("created config changed after apply; automatic removal refused");
|
|
1579
|
-
}
|
|
1580
|
-
if (!action.recoveryPath) {
|
|
1581
|
-
throw new Error("created config recovery path is missing");
|
|
1582
|
-
}
|
|
1583
|
-
await assertSafeMutationPath(snapshot.descriptor.safetyRoot, action.recoveryPath);
|
|
1584
|
-
await mkdir(dirname(action.recoveryPath), {
|
|
1585
|
-
recursive: true,
|
|
1586
|
-
mode: 448
|
|
1587
|
-
});
|
|
1588
|
-
await rename(action.path, action.recoveryPath);
|
|
1589
|
-
} else {
|
|
1590
|
-
if (!action.backupPath)
|
|
1591
|
-
throw new Error("config backup is missing");
|
|
1592
|
-
const backup = await readRegularFileWithoutFollowing(action.backupPath);
|
|
1593
|
-
await replaceKnownConfigContent({
|
|
1594
|
-
descriptor: snapshot.descriptor,
|
|
1595
|
-
expectedDigest: action.appliedDigest,
|
|
1596
|
-
content: backup,
|
|
1597
|
-
mode: action.originalMode ?? snapshot.mode,
|
|
1598
|
-
runId: options.runId
|
|
1599
|
-
});
|
|
1600
|
-
}
|
|
1601
|
-
} else if (action.action === "remove-wrapper" || action.action === "remove-cache") {
|
|
1602
|
-
if (!action.stagingPath)
|
|
1603
|
-
throw new Error("staging path is missing");
|
|
1604
|
-
let safetyRoot;
|
|
1605
|
-
if (action.action === "remove-cache") {
|
|
1606
|
-
safetyRoot = dirname(options.paths.cacheRoot);
|
|
1607
|
-
} else {
|
|
1608
|
-
const wrapper = options.wrappers.find((candidate) => candidate.path === action.path);
|
|
1609
|
-
if (!wrapper)
|
|
1610
|
-
throw new Error("wrapper safety metadata is missing");
|
|
1611
|
-
safetyRoot = wrapperMutationSafetyRoot(options.paths, wrapper);
|
|
1612
|
-
}
|
|
1613
|
-
await assertSafeMutationPath(safetyRoot, action.path);
|
|
1614
|
-
await assertSafeMutationPath(safetyRoot, action.stagingPath);
|
|
1615
|
-
if (await optionalLstat(action.path)) {
|
|
1616
|
-
throw new Error("original path is occupied; automatic restore refused");
|
|
1617
|
-
}
|
|
1618
|
-
const staged = await optionalLstat(action.stagingPath);
|
|
1619
|
-
if (!staged || staged.isSymbolicLink()) {
|
|
1620
|
-
throw new Error("staged artifact is missing or symbolic");
|
|
1621
|
-
}
|
|
1622
|
-
await mkdir(dirname(action.path), { recursive: true });
|
|
1623
|
-
await rename(action.stagingPath, action.path);
|
|
1624
|
-
}
|
|
1625
|
-
action.state = "rolled-back";
|
|
1626
|
-
} catch (error) {
|
|
1627
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1628
|
-
action.state = "rollback-failed";
|
|
1629
|
-
action.error = message;
|
|
1630
|
-
failures.push(`${action.path}: ${message}`);
|
|
1631
|
-
}
|
|
1632
|
-
try {
|
|
1633
|
-
await writeJournal(options.journalPath, options.journal);
|
|
1634
|
-
} catch (error) {
|
|
1635
|
-
failures.push(`journal update: ${error instanceof Error ? error.message : String(error)}`);
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
return failures;
|
|
1639
|
-
}
|
|
1640
|
-
function journalConfigDescriptor(paths, action) {
|
|
1641
|
-
const descriptor = configDescriptors(paths).find((candidate) => candidate.path === action.path && (action.source === undefined || candidate.source === action.source));
|
|
1642
|
-
if (!descriptor) {
|
|
1643
|
-
throw new Error(`${action.path}: journal config path is outside inventory`);
|
|
1644
|
-
}
|
|
1645
|
-
return descriptor;
|
|
1646
|
-
}
|
|
1647
|
-
function journalOwnedWrapper(paths, journal, action) {
|
|
1648
|
-
if (action.ownership !== "marker-owned-wrapper" && action.ownership !== "legacy-flow-wrapper" || typeof action.resolvedVersion !== "string" || !isExactFlowVersion(action.resolvedVersion) || typeof action.source !== "string" || typeof action.scope !== "string") {
|
|
1649
|
-
throw new Error(`${action.path}: wrapper recovery metadata is incomplete`);
|
|
1650
|
-
}
|
|
1651
|
-
const wrapper = {
|
|
1652
|
-
path: action.path,
|
|
1653
|
-
source: action.source,
|
|
1654
|
-
scope: action.scope,
|
|
1655
|
-
version: action.resolvedVersion,
|
|
1656
|
-
ownership: action.ownership
|
|
1657
|
-
};
|
|
1658
|
-
const pluginDirectory = pluginDirectoryDescriptors(paths).find((descriptor) => descriptor.source === wrapper.source && descriptor.scope === wrapper.scope && dirname(wrapper.path) === descriptor.path);
|
|
1659
|
-
const config = configDescriptors(paths).find((descriptor) => {
|
|
1660
|
-
if (descriptor.source !== wrapper.source || descriptor.scope !== wrapper.scope) {
|
|
1661
|
-
return false;
|
|
1662
|
-
}
|
|
1663
|
-
const fromRoot = relative(descriptor.safetyRoot, wrapper.path);
|
|
1664
|
-
return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
1665
|
-
});
|
|
1666
|
-
const safetyRoot = pluginDirectory?.safetyRoot ?? config?.safetyRoot;
|
|
1667
|
-
if (!safetyRoot) {
|
|
1668
|
-
throw new Error(`${action.path}: wrapper path is outside inventory`);
|
|
1669
|
-
}
|
|
1670
|
-
const expectedStagingPath = join(wrapperRecoveryRoot(paths, wrapper), journal.runId, sha256(action.path).slice(0, 12), basename(action.path));
|
|
1671
|
-
if (action.stagingPath !== expectedStagingPath) {
|
|
1672
|
-
throw new Error(`${action.path}: wrapper staging path does not match journal`);
|
|
1673
|
-
}
|
|
1674
|
-
return { wrapper, safetyRoot };
|
|
1675
|
-
}
|
|
1676
|
-
function journalCacheArtifact(paths, journal, action) {
|
|
1677
|
-
if (typeof action.specifier !== "string" || action.specifier !== FLOW_PACKAGE_NAME && !action.specifier.startsWith(`${FLOW_PACKAGE_NAME}@`) || action.specifier.includes("/") || action.specifier.includes("\\") || typeof action.resolvedVersion !== "string" || !isExactFlowVersion(action.resolvedVersion) || action.path !== join(paths.packageCacheRoot, action.specifier)) {
|
|
1678
|
-
throw new Error(`${action.path}: cache recovery metadata is incomplete`);
|
|
1679
|
-
}
|
|
1680
|
-
const expectedStagingPath = join(paths.cacheRecoveryRoot, journal.runId, sha256(action.path).slice(0, 12), basename(action.path));
|
|
1681
|
-
if (action.stagingPath !== expectedStagingPath) {
|
|
1682
|
-
throw new Error(`${action.path}: cache staging path does not match journal`);
|
|
1683
|
-
}
|
|
1684
|
-
return {
|
|
1685
|
-
path: action.path,
|
|
1686
|
-
specifier: action.specifier,
|
|
1687
|
-
resolvedVersion: action.resolvedVersion,
|
|
1688
|
-
status: "inactive"
|
|
1689
|
-
};
|
|
1690
|
-
}
|
|
1691
|
-
async function rollbackInterruptedConfigAction(options) {
|
|
1692
|
-
const { action, journal, journalPath, paths } = options;
|
|
1693
|
-
const descriptor = journalConfigDescriptor(paths, action);
|
|
1694
|
-
await assertSafeMutationPath(descriptor.safetyRoot, action.path);
|
|
1695
|
-
const currentMetadata = await optionalLstat(action.path);
|
|
1696
|
-
if (currentMetadata?.isSymbolicLink() || currentMetadata && !currentMetadata.isFile()) {
|
|
1697
|
-
throw new Error(`${action.path}: interrupted config is not a regular file`);
|
|
1698
|
-
}
|
|
1699
|
-
const current = currentMetadata ? await readRegularFileWithoutFollowing(action.path) : null;
|
|
1700
|
-
if (action.originalAbsent === true) {
|
|
1701
|
-
const expectedRecoveryPath = join(dirname(action.path), ".flow-activation-recovery", journal.runId, `${basename(action.path)}-${sha256(action.path).slice(0, 12)}`);
|
|
1702
|
-
if (action.recoveryPath !== expectedRecoveryPath) {
|
|
1703
|
-
throw new Error(`${action.path}: created-config recovery path is invalid`);
|
|
1704
|
-
}
|
|
1705
|
-
if (current === null)
|
|
1706
|
-
return;
|
|
1707
|
-
if (!action.appliedDigest || sha256(current) !== action.appliedDigest) {
|
|
1708
|
-
throw new Error(`${action.path}: interrupted created config changed; automatic recovery refused`);
|
|
1709
|
-
}
|
|
1710
|
-
await assertSafeMutationPath(descriptor.safetyRoot, action.recoveryPath);
|
|
1711
|
-
if (await optionalLstat(action.recoveryPath)) {
|
|
1712
|
-
throw new Error(`${action.path}: created-config recovery path is occupied`);
|
|
1713
|
-
}
|
|
1714
|
-
await mkdir(dirname(action.recoveryPath), {
|
|
1715
|
-
recursive: true,
|
|
1716
|
-
mode: 448
|
|
1717
|
-
});
|
|
1718
|
-
await rename(action.path, action.recoveryPath);
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
const expectedBackupPath = join(dirname(journalPath), "configs", `${descriptor.source}-${sha256(action.path).slice(0, 12)}.backup`);
|
|
1722
|
-
if (action.backupPath !== expectedBackupPath) {
|
|
1723
|
-
throw new Error(`${action.path}: config backup path is invalid`);
|
|
1724
|
-
}
|
|
1725
|
-
const backup = await readRegularFileWithoutFollowing(action.backupPath, MAX_LOCAL_PLUGIN_BYTES);
|
|
1726
|
-
if (current === null) {
|
|
1727
|
-
throw new Error(`${action.path}: interrupted config is missing`);
|
|
1728
|
-
}
|
|
1729
|
-
if (sha256(current) === sha256(backup))
|
|
1730
|
-
return;
|
|
1731
|
-
if (!action.appliedDigest || sha256(current) !== action.appliedDigest) {
|
|
1732
|
-
throw new Error(`${action.path}: interrupted config changed; automatic recovery refused`);
|
|
1733
|
-
}
|
|
1734
|
-
await replaceKnownConfigContent({
|
|
1735
|
-
descriptor,
|
|
1736
|
-
expectedDigest: action.appliedDigest,
|
|
1737
|
-
content: backup,
|
|
1738
|
-
mode: action.originalMode ?? 384,
|
|
1739
|
-
runId: journal.runId
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
async function rollbackInterruptedRemovalAction(options) {
|
|
1743
|
-
const { action, journal, paths } = options;
|
|
1744
|
-
let safetyRoot;
|
|
1745
|
-
let verifyStaged;
|
|
1746
|
-
if (action.action === "remove-wrapper") {
|
|
1747
|
-
const { wrapper, safetyRoot: wrapperSafetyRoot } = journalOwnedWrapper(paths, journal, action);
|
|
1748
|
-
safetyRoot = wrapperSafetyRoot;
|
|
1749
|
-
verifyStaged = () => verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
|
|
1750
|
-
} else {
|
|
1751
|
-
const artifact = journalCacheArtifact(paths, journal, action);
|
|
1752
|
-
safetyRoot = dirname(paths.cacheRoot);
|
|
1753
|
-
verifyStaged = () => verifyCacheArtifact({ ...artifact, path: action.stagingPath }, journal.target);
|
|
1754
|
-
}
|
|
1755
|
-
await assertSafeMutationPath(safetyRoot, action.path);
|
|
1756
|
-
await assertSafeMutationPath(safetyRoot, action.stagingPath);
|
|
1757
|
-
const original = await optionalLstat(action.path);
|
|
1758
|
-
const staged = await optionalLstat(action.stagingPath);
|
|
1759
|
-
if (original && staged) {
|
|
1760
|
-
throw new Error(`${action.path}: both original and staged artifacts exist`);
|
|
1761
|
-
}
|
|
1762
|
-
if (original) {
|
|
1763
|
-
if (original.isSymbolicLink()) {
|
|
1764
|
-
throw new Error(`${action.path}: restored artifact is symbolic`);
|
|
1765
|
-
}
|
|
1766
|
-
return;
|
|
1767
|
-
}
|
|
1768
|
-
if (!staged || staged.isSymbolicLink()) {
|
|
1769
|
-
throw new Error(`${action.path}: interrupted staged artifact is missing`);
|
|
1770
|
-
}
|
|
1771
|
-
await verifyStaged();
|
|
1772
|
-
await mkdir(dirname(action.path), { recursive: true });
|
|
1773
|
-
await rename(action.stagingPath, action.path);
|
|
1774
|
-
}
|
|
1775
|
-
async function rollbackInterruptedJournal(journal, journalPath, paths) {
|
|
1776
|
-
for (const action of journal.actions.toReversed()) {
|
|
1777
|
-
if (action.state === "rolled-back")
|
|
1778
|
-
continue;
|
|
1779
|
-
if (action.action === "rewrite-config") {
|
|
1780
|
-
await rollbackInterruptedConfigAction({
|
|
1781
|
-
journal,
|
|
1782
|
-
journalPath,
|
|
1783
|
-
action,
|
|
1784
|
-
paths
|
|
1785
|
-
});
|
|
1786
|
-
} else {
|
|
1787
|
-
await rollbackInterruptedRemovalAction({ journal, action, paths });
|
|
1788
|
-
}
|
|
1789
|
-
action.state = "rolled-back";
|
|
1790
|
-
delete action.error;
|
|
1791
|
-
await writeJournal(journalPath, journal);
|
|
1792
|
-
}
|
|
1793
|
-
journal.state = "rolled-back";
|
|
1794
|
-
delete journal.ownerPid;
|
|
1795
|
-
delete journal.error;
|
|
1796
|
-
await writeJournal(journalPath, journal);
|
|
1797
|
-
}
|
|
1798
|
-
async function finishCommittedJournalCleanup(journal, journalPath, paths) {
|
|
1799
|
-
const removalActions = journal.actions.filter((action) => action.action === "remove-wrapper" || action.action === "remove-cache");
|
|
1800
|
-
for (const action of removalActions) {
|
|
1801
|
-
let safetyRoot;
|
|
1802
|
-
let verifyStaged;
|
|
1803
|
-
if (action.action === "remove-wrapper") {
|
|
1804
|
-
const { wrapper, safetyRoot: wrapperSafetyRoot } = journalOwnedWrapper(paths, journal, action);
|
|
1805
|
-
safetyRoot = wrapperSafetyRoot;
|
|
1806
|
-
verifyStaged = () => verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
|
|
1807
|
-
} else {
|
|
1808
|
-
const artifact = journalCacheArtifact(paths, journal, action);
|
|
1809
|
-
safetyRoot = dirname(paths.cacheRoot);
|
|
1810
|
-
verifyStaged = () => verifyCacheArtifact({ ...artifact, path: action.stagingPath }, journal.target);
|
|
1811
|
-
}
|
|
1812
|
-
await assertSafeMutationPath(safetyRoot, action.path);
|
|
1813
|
-
await assertSafeMutationPath(safetyRoot, action.stagingPath);
|
|
1814
|
-
if (await optionalLstat(action.path)) {
|
|
1815
|
-
throw new Error(`${action.path}: obsolete original path reappeared after activation commit`);
|
|
1816
|
-
}
|
|
1817
|
-
if (await optionalLstat(action.stagingPath)) {
|
|
1818
|
-
await verifyStaged();
|
|
1819
|
-
await rm(action.stagingPath, {
|
|
1820
|
-
recursive: action.action === "remove-cache"
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
if (await optionalLstat(action.stagingPath)) {
|
|
1824
|
-
throw new Error(`${action.path}: obsolete staged artifact still exists`);
|
|
1825
|
-
}
|
|
1826
|
-
action.deleted = true;
|
|
1827
|
-
await removeEmptyDirectory(dirname(action.stagingPath));
|
|
1828
|
-
await writeJournal(journalPath, journal);
|
|
1829
|
-
}
|
|
1830
|
-
for (const runDirectory of new Set(removalActions.map((action) => dirname(dirname(action.stagingPath))))) {
|
|
1831
|
-
await removeEmptyDirectory(runDirectory);
|
|
1832
|
-
}
|
|
1833
|
-
await removeEmptyDirectory(paths.cacheRecoveryRoot);
|
|
1834
|
-
journal.state = "complete";
|
|
1835
|
-
delete journal.ownerPid;
|
|
1836
|
-
delete journal.error;
|
|
1837
|
-
await writeJournal(journalPath, journal);
|
|
1838
|
-
}
|
|
1839
|
-
async function reconcileIncompleteActivationJournals(paths, pathOptions) {
|
|
1840
|
-
const failures = [];
|
|
1841
|
-
for (const entry of await readActivationJournalEntries(paths)) {
|
|
1842
|
-
if (entry.error) {
|
|
1843
|
-
failures.push(`recovery journal could not be inspected safely at ${entry.journalPath}: ${entry.error}`);
|
|
1844
|
-
continue;
|
|
1845
|
-
}
|
|
1846
|
-
const journal = entry.journal;
|
|
1847
|
-
if (TERMINAL_JOURNAL_STATES.has(journal.state))
|
|
1848
|
-
continue;
|
|
1849
|
-
if (journal.format === "flow-activation-journal-v1") {
|
|
1850
|
-
failures.push(`${entry.journalPath}: legacy activation recovery is incomplete in state ${journal.state}; follow its manual recovery guidance before retrying`);
|
|
1851
|
-
continue;
|
|
1852
|
-
}
|
|
1853
|
-
if (journal.ownerPid !== undefined && processIsAlive(journal.ownerPid)) {
|
|
1854
|
-
failures.push(`${entry.journalPath}: activation is still owned by running process ${journal.ownerPid}`);
|
|
1855
|
-
continue;
|
|
1856
|
-
}
|
|
1857
|
-
if (journal.state === "rollback-failed") {
|
|
1858
|
-
failures.push(`${entry.journalPath}: previous rollback failed; follow its manual recovery guidance before retrying`);
|
|
1859
|
-
continue;
|
|
1860
|
-
}
|
|
1861
|
-
const journalPaths = resolveActivationPaths(journal.project, pathOptions);
|
|
1862
|
-
if (journalPaths.journalRoot !== paths.journalRoot) {
|
|
1863
|
-
failures.push(`${entry.journalPath}: journal resolves to a different recovery root`);
|
|
1864
|
-
continue;
|
|
1865
|
-
}
|
|
1866
|
-
try {
|
|
1867
|
-
if (journal.state === "committed" || journal.state === "cleanup-failed") {
|
|
1868
|
-
await finishCommittedJournalCleanup(journal, entry.journalPath, journalPaths);
|
|
1869
|
-
} else {
|
|
1870
|
-
await rollbackInterruptedJournal(journal, entry.journalPath, journalPaths);
|
|
1871
|
-
}
|
|
1872
|
-
} catch (error) {
|
|
1873
|
-
journal.state = journal.state === "committed" || journal.state === "cleanup-failed" ? "cleanup-failed" : "rollback-failed";
|
|
1874
|
-
journal.error = error instanceof Error ? error.message : String(error);
|
|
1875
|
-
delete journal.ownerPid;
|
|
1876
|
-
try {
|
|
1877
|
-
await writeJournal(entry.journalPath, journal);
|
|
1878
|
-
} catch {}
|
|
1879
|
-
failures.push(`${entry.journalPath}: ${journal.error}`);
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
return failures;
|
|
1883
|
-
}
|
|
1884
|
-
async function applyFlowActivation(options) {
|
|
1885
|
-
const target = resolveActivationTarget(options.target);
|
|
1886
|
-
const paths = resolveActivationPaths(options.project, options.paths);
|
|
1887
|
-
const recoveryRefusals = options.apply === true ? await reconcileIncompleteActivationJournals(paths, options.paths) : [];
|
|
1888
|
-
const before = await checkFlowActivation({
|
|
1889
|
-
project: options.project,
|
|
1890
|
-
target,
|
|
1891
|
-
...options.paths ? { paths: options.paths } : {}
|
|
1892
|
-
});
|
|
1893
|
-
const refusals = [
|
|
1894
|
-
...recoveryRefusals,
|
|
1895
|
-
...activationRefusals(before),
|
|
1896
|
-
...downgradeRefusals(before)
|
|
1897
|
-
];
|
|
1898
|
-
const snapshots = [];
|
|
1899
|
-
for (const descriptor of configDescriptors(before.paths)) {
|
|
1900
|
-
try {
|
|
1901
|
-
snapshots.push(await readConfigSnapshot(descriptor));
|
|
1902
|
-
} catch (error) {
|
|
1903
|
-
refusals.push(error instanceof Error ? error.message : String(error));
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
const pin = `${FLOW_PACKAGE_NAME}@${target}`;
|
|
1907
|
-
const canonicalPath = options.scope === "global" ? before.paths.globalConfig : before.paths.projectConfig;
|
|
1908
|
-
const nextEntries = new Map;
|
|
1909
|
-
for (const snapshot of snapshots) {
|
|
1910
|
-
const retained = snapshot.plugin.filter((entry) => !removableConfigEntry(entry, snapshot.descriptor, before.records));
|
|
1911
|
-
if (snapshot.descriptor.path === canonicalPath)
|
|
1912
|
-
retained.push(pin);
|
|
1913
|
-
nextEntries.set(snapshot.descriptor.path, retained);
|
|
1914
|
-
}
|
|
1915
|
-
const ownedWrappers = uniqueOwnedWrappers(before.records).filter((wrapper) => wrapper.source !== "inline-config" && wrapper.source !== "managed-config");
|
|
1916
|
-
const wrappers = [];
|
|
1917
|
-
for (const wrapper of ownedWrappers) {
|
|
1918
|
-
try {
|
|
1919
|
-
const safetyRoot = wrapperMutationSafetyRoot(before.paths, wrapper);
|
|
1920
|
-
const recoveryRoot = wrapperRecoveryRoot(before.paths, wrapper);
|
|
1921
|
-
await assertSafeMutationPath(safetyRoot, wrapper.path);
|
|
1922
|
-
await assertSafeMutationPath(safetyRoot, recoveryRoot);
|
|
1923
|
-
if (!await pathCanBeMoved(wrapper.path, recoveryRoot)) {
|
|
1924
|
-
throw new Error(`${wrapper.path}: removable wrapper or staging parent is not writable; remove it manually and rerun activation-apply`);
|
|
1925
|
-
}
|
|
1926
|
-
wrappers.push(wrapper);
|
|
1927
|
-
} catch (error) {
|
|
1928
|
-
refusals.push(error instanceof Error ? error.message : String(error));
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
const provenInactiveCache = before.cacheArtifacts.filter((artifact) => artifact.status === "inactive");
|
|
1932
|
-
const inactiveCache = [];
|
|
1933
|
-
for (const artifact of provenInactiveCache) {
|
|
1934
|
-
try {
|
|
1935
|
-
const safetyRoot = dirname(before.paths.cacheRoot);
|
|
1936
|
-
await assertSafeMutationPath(safetyRoot, artifact.path);
|
|
1937
|
-
await assertSafeMutationPath(safetyRoot, before.paths.cacheRecoveryRoot);
|
|
1938
|
-
if (!await pathCanBeMoved(artifact.path, before.paths.cacheRecoveryRoot)) {
|
|
1939
|
-
throw new Error(`${artifact.path}: proven inactive cache artifact or recovery parent is not writable; move it outside ${before.paths.packageCacheRoot} manually and rerun activation-apply`);
|
|
1940
|
-
}
|
|
1941
|
-
inactiveCache.push(artifact);
|
|
1942
|
-
} catch (error) {
|
|
1943
|
-
refusals.push(error instanceof Error ? error.message : String(error));
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
const plan = [];
|
|
1947
|
-
const changedSnapshots = [];
|
|
1948
|
-
const addManualRemediation = (snapshot, path, detail) => {
|
|
1949
|
-
plan.push({
|
|
1950
|
-
action: "manual-remediation",
|
|
1951
|
-
...snapshot ? { scope: snapshot.descriptor.scope } : {},
|
|
1952
|
-
path,
|
|
1953
|
-
detail
|
|
1954
|
-
});
|
|
1955
|
-
refusals.push(`${path}: ${detail}`);
|
|
1956
|
-
};
|
|
1957
|
-
const scheduleConfigRewrite = async (snapshot, detail) => {
|
|
1958
|
-
if (changedSnapshots.includes(snapshot))
|
|
1959
|
-
return;
|
|
1960
|
-
if (!snapshot.descriptor.mutable) {
|
|
1961
|
-
addManualRemediation(snapshot, snapshot.descriptor.path, `managed config is immutable; ${snapshot.descriptor.manualRemediation}`);
|
|
1962
|
-
return;
|
|
1963
|
-
}
|
|
1964
|
-
if (snapshot.format === "jsonc") {
|
|
1965
|
-
addManualRemediation(snapshot, snapshot.descriptor.path, `JSONC Flow activation was inventoried but cannot be edited losslessly without a JSONC editor; ${snapshot.descriptor.manualRemediation}, remove every ${FLOW_PACKAGE_NAME} entry, then rerun activation-apply`);
|
|
1966
|
-
return;
|
|
1967
|
-
}
|
|
1968
|
-
if (!await configIsWritable(snapshot)) {
|
|
1969
|
-
addManualRemediation(snapshot, snapshot.descriptor.path, `config or its mutation path is not safely writable; ${snapshot.descriptor.manualRemediation}`);
|
|
1970
|
-
return;
|
|
1971
|
-
}
|
|
1972
|
-
changedSnapshots.push(snapshot);
|
|
1973
|
-
plan.push({
|
|
1974
|
-
action: "rewrite-config",
|
|
1975
|
-
scope: snapshot.descriptor.scope,
|
|
1976
|
-
path: snapshot.descriptor.path,
|
|
1977
|
-
detail
|
|
1978
|
-
});
|
|
1979
|
-
};
|
|
1980
|
-
for (const snapshot of snapshots) {
|
|
1981
|
-
const entries = nextEntries.get(snapshot.descriptor.path) ?? [];
|
|
1982
|
-
const changed = snapshot.descriptor.path === canonicalPath && !snapshot.exists || JSON.stringify(entries) !== JSON.stringify(snapshot.plugin);
|
|
1983
|
-
if (!changed)
|
|
1984
|
-
continue;
|
|
1985
|
-
await scheduleConfigRewrite(snapshot, snapshot.descriptor.path === canonicalPath ? `write canonical exact pin ${pin} last` : "remove recognized Flow activation entries while preserving string/tuple entries and options");
|
|
1986
|
-
}
|
|
1987
|
-
for (const record of before.records.filter((record2) => record2.source === "inline-config")) {
|
|
1988
|
-
addManualRemediation(null, "env:OPENCODE_CONFIG_CONTENT", `inline Flow activation ${record.specifier} is immutable; remove it from OPENCODE_CONFIG_CONTENT or unset that variable, then rerun activation-apply`);
|
|
1989
|
-
}
|
|
1990
|
-
const canonicalSnapshot = snapshots.find((snapshot) => snapshot.descriptor.path === canonicalPath);
|
|
1991
|
-
for (const wrapper of wrappers) {
|
|
1992
|
-
plan.push({
|
|
1993
|
-
action: "remove-wrapper",
|
|
1994
|
-
scope: wrapper.scope,
|
|
1995
|
-
path: wrapper.path,
|
|
1996
|
-
detail: "permanently remove the proven Flow wrapper after reversible staging and activation verification"
|
|
1997
|
-
});
|
|
1998
|
-
}
|
|
1999
|
-
for (const artifact of inactiveCache) {
|
|
2000
|
-
plan.push({
|
|
2001
|
-
action: "remove-cache",
|
|
2002
|
-
path: artifact.path,
|
|
2003
|
-
detail: `permanently remove proven inactive Flow ${artifact.resolvedVersion} after reversible staging; preserve the cache root and unrelated packages`
|
|
2004
|
-
});
|
|
2005
|
-
}
|
|
2006
|
-
const base = {
|
|
2007
|
-
mode: options.apply === true ? "apply" : "dry-run",
|
|
2008
|
-
project: before.project,
|
|
2009
|
-
target,
|
|
2010
|
-
scope: options.scope,
|
|
2011
|
-
status: refusals.length > 0 ? "refused" : "ready",
|
|
2012
|
-
before,
|
|
2013
|
-
plan,
|
|
2014
|
-
refusals: [...new Set(refusals)]
|
|
2015
|
-
};
|
|
2016
|
-
if (options.apply !== true || refusals.length > 0)
|
|
2017
|
-
return base;
|
|
2018
|
-
if (plan.length === 0) {
|
|
2019
|
-
return { ...base, status: "applied", after: before };
|
|
2020
|
-
}
|
|
2021
|
-
const runId = `${new Date().toISOString().replaceAll(":", "-")}-${randomUUID()}`;
|
|
2022
|
-
const runRoot = join(before.paths.journalRoot, runId);
|
|
2023
|
-
const journalPath = join(runRoot, "journal.json");
|
|
2024
|
-
const actions = [];
|
|
2025
|
-
for (const snapshot of changedSnapshots) {
|
|
2026
|
-
const action = {
|
|
2027
|
-
action: "rewrite-config",
|
|
2028
|
-
path: snapshot.descriptor.path,
|
|
2029
|
-
source: snapshot.descriptor.source,
|
|
2030
|
-
scope: snapshot.descriptor.scope,
|
|
2031
|
-
originalAbsent: !snapshot.exists,
|
|
2032
|
-
originalMode: snapshot.mode,
|
|
2033
|
-
state: "pending"
|
|
2034
|
-
};
|
|
2035
|
-
if (snapshot.exists) {
|
|
2036
|
-
action.backupPath = join(runRoot, "configs", `${snapshot.descriptor.source}-${sha256(snapshot.descriptor.path).slice(0, 12)}.backup`);
|
|
2037
|
-
} else {
|
|
2038
|
-
action.recoveryPath = join(dirname(snapshot.descriptor.path), ".flow-activation-recovery", runId, `${basename(snapshot.descriptor.path)}-${sha256(snapshot.descriptor.path).slice(0, 12)}`);
|
|
2039
|
-
}
|
|
2040
|
-
actions.push(action);
|
|
2041
|
-
}
|
|
2042
|
-
for (const wrapper of wrappers) {
|
|
2043
|
-
actions.push({
|
|
2044
|
-
action: "remove-wrapper",
|
|
2045
|
-
path: wrapper.path,
|
|
2046
|
-
source: wrapper.source,
|
|
2047
|
-
scope: wrapper.scope,
|
|
2048
|
-
resolvedVersion: wrapper.version,
|
|
2049
|
-
ownership: wrapper.ownership,
|
|
2050
|
-
stagingPath: join(wrapperRecoveryRoot(before.paths, wrapper), runId, sha256(wrapper.path).slice(0, 12), basename(wrapper.path)),
|
|
2051
|
-
state: "pending"
|
|
2052
|
-
});
|
|
2053
|
-
}
|
|
2054
|
-
for (const artifact of inactiveCache) {
|
|
2055
|
-
actions.push({
|
|
2056
|
-
action: "remove-cache",
|
|
2057
|
-
path: artifact.path,
|
|
2058
|
-
...artifact.resolvedVersion ? { resolvedVersion: artifact.resolvedVersion } : {},
|
|
2059
|
-
specifier: artifact.specifier,
|
|
2060
|
-
stagingPath: join(before.paths.cacheRecoveryRoot, runId, sha256(artifact.path).slice(0, 12), basename(artifact.path)),
|
|
2061
|
-
state: "pending"
|
|
2062
|
-
});
|
|
2063
|
-
}
|
|
2064
|
-
const journal = {
|
|
2065
|
-
format: "flow-activation-journal-v2",
|
|
2066
|
-
runId,
|
|
2067
|
-
createdAt: new Date().toISOString(),
|
|
2068
|
-
ownerPid: process.pid,
|
|
2069
|
-
project: before.project,
|
|
2070
|
-
target,
|
|
2071
|
-
scope: options.scope,
|
|
2072
|
-
state: "prepared",
|
|
2073
|
-
actions
|
|
2074
|
-
};
|
|
2075
|
-
try {
|
|
2076
|
-
await assertSafeMutationPath(dirname(before.paths.configRoot), runRoot);
|
|
2077
|
-
await mkdir(runRoot, { recursive: true, mode: 448 });
|
|
2078
|
-
await writeJournal(journalPath, journal);
|
|
2079
|
-
} catch (error) {
|
|
2080
|
-
const message = `recovery journal could not be prepared safely at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2081
|
-
return {
|
|
2082
|
-
...base,
|
|
2083
|
-
status: "refused",
|
|
2084
|
-
refusals: [...new Set([...base.refusals, message])]
|
|
2085
|
-
};
|
|
2086
|
-
}
|
|
2087
|
-
let removalCommitStarted = false;
|
|
2088
|
-
let committedAfter;
|
|
2089
|
-
try {
|
|
2090
|
-
for (const snapshot of changedSnapshots) {
|
|
2091
|
-
await assertUnchangedConfig(snapshot);
|
|
2092
|
-
const action = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === snapshot.descriptor.path);
|
|
2093
|
-
if (snapshot.exists && action?.backupPath) {
|
|
2094
|
-
await mkdir(dirname(action.backupPath), {
|
|
2095
|
-
recursive: true,
|
|
2096
|
-
mode: 448
|
|
2097
|
-
});
|
|
2098
|
-
await writeFile(action.backupPath, snapshot.content, {
|
|
2099
|
-
encoding: "utf8",
|
|
2100
|
-
flag: "wx",
|
|
2101
|
-
mode: 384
|
|
2102
|
-
});
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
if (canonicalSnapshot && !changedSnapshots.includes(canonicalSnapshot)) {
|
|
2106
|
-
await assertUnchangedConfig(canonicalSnapshot);
|
|
2107
|
-
}
|
|
2108
|
-
for (const wrapper of wrappers)
|
|
2109
|
-
await verifyOwnedWrapper(wrapper);
|
|
2110
|
-
for (const artifact of inactiveCache) {
|
|
2111
|
-
await verifyCacheArtifact(artifact, target);
|
|
2112
|
-
}
|
|
2113
|
-
journal.state = "applying";
|
|
2114
|
-
await writeJournal(journalPath, journal);
|
|
2115
|
-
const nonTargetConfigs = changedSnapshots.filter((snapshot) => snapshot.descriptor.path !== canonicalPath);
|
|
2116
|
-
const targetConfig = changedSnapshots.find((snapshot) => snapshot.descriptor.path === canonicalPath);
|
|
2117
|
-
for (const snapshot of nonTargetConfigs) {
|
|
2118
|
-
const entries = nextEntries.get(snapshot.descriptor.path) ?? [];
|
|
2119
|
-
const content = updatedConfigContent(snapshot, entries);
|
|
2120
|
-
const action = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === snapshot.descriptor.path);
|
|
2121
|
-
if (action) {
|
|
2122
|
-
action.appliedDigest = sha256(content);
|
|
2123
|
-
}
|
|
2124
|
-
await writeJournal(journalPath, journal);
|
|
2125
|
-
await atomicWriteConfig(snapshot, content, runId);
|
|
2126
|
-
if (action)
|
|
2127
|
-
action.state = "complete";
|
|
2128
|
-
await writeJournal(journalPath, journal);
|
|
2129
|
-
await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === snapshot.descriptor.path));
|
|
2130
|
-
}
|
|
2131
|
-
for (const wrapper of wrappers) {
|
|
2132
|
-
const action = actions.find((candidate) => candidate.action === "remove-wrapper" && candidate.path === wrapper.path);
|
|
2133
|
-
if (!action?.stagingPath) {
|
|
2134
|
-
throw new Error(`${wrapper.path}: missing wrapper staging path`);
|
|
2135
|
-
}
|
|
2136
|
-
await verifyOwnedWrapper(wrapper);
|
|
2137
|
-
await assertSafeMutationPath(wrapperMutationSafetyRoot(before.paths, wrapper), wrapper.path);
|
|
2138
|
-
await assertSafeMutationPath(wrapperMutationSafetyRoot(before.paths, wrapper), action.stagingPath);
|
|
2139
|
-
await mkdir(dirname(action.stagingPath), {
|
|
2140
|
-
recursive: true,
|
|
2141
|
-
mode: 448
|
|
2142
|
-
});
|
|
2143
|
-
await rename(wrapper.path, action.stagingPath);
|
|
2144
|
-
action.state = "complete";
|
|
2145
|
-
await writeJournal(journalPath, journal);
|
|
2146
|
-
await options.afterMutation?.(plan.find((operation) => operation.action === "remove-wrapper" && operation.path === wrapper.path));
|
|
2147
|
-
}
|
|
2148
|
-
for (const artifact of inactiveCache) {
|
|
2149
|
-
const action = actions.find((candidate) => candidate.action === "remove-cache" && candidate.path === artifact.path);
|
|
2150
|
-
if (!action?.stagingPath) {
|
|
2151
|
-
throw new Error(`${artifact.path}: missing cache staging path`);
|
|
2152
|
-
}
|
|
2153
|
-
await verifyCacheArtifact(artifact, target);
|
|
2154
|
-
await assertSafeMutationPath(dirname(before.paths.cacheRoot), artifact.path);
|
|
2155
|
-
await assertSafeMutationPath(dirname(before.paths.cacheRoot), action.stagingPath);
|
|
2156
|
-
await mkdir(dirname(action.stagingPath), {
|
|
2157
|
-
recursive: true,
|
|
2158
|
-
mode: 448
|
|
2159
|
-
});
|
|
2160
|
-
await rename(artifact.path, action.stagingPath);
|
|
2161
|
-
action.state = "complete";
|
|
2162
|
-
await writeJournal(journalPath, journal);
|
|
2163
|
-
await options.afterMutation?.(plan.find((operation) => operation.action === "remove-cache" && operation.path === artifact.path));
|
|
2164
|
-
}
|
|
2165
|
-
if (targetConfig) {
|
|
2166
|
-
const targetEntries = nextEntries.get(targetConfig.descriptor.path) ?? [];
|
|
2167
|
-
const targetContent = updatedConfigContent(targetConfig, targetEntries);
|
|
2168
|
-
const targetAction = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === targetConfig.descriptor.path);
|
|
2169
|
-
if (targetAction) {
|
|
2170
|
-
targetAction.appliedDigest = sha256(targetContent);
|
|
2171
|
-
}
|
|
2172
|
-
await writeJournal(journalPath, journal);
|
|
2173
|
-
await atomicWriteConfig(targetConfig, targetContent, runId);
|
|
2174
|
-
if (targetAction)
|
|
2175
|
-
targetAction.state = "complete";
|
|
2176
|
-
await writeJournal(journalPath, journal);
|
|
2177
|
-
await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === targetConfig.descriptor.path));
|
|
2178
|
-
}
|
|
2179
|
-
const after = await checkFlowActivation({
|
|
2180
|
-
project: before.project,
|
|
2181
|
-
target,
|
|
2182
|
-
ignoreRecoveryRunId: runId,
|
|
2183
|
-
...options.paths ? { paths: options.paths } : {}
|
|
2184
|
-
});
|
|
2185
|
-
if (!after.singleVersionSatisfied) {
|
|
2186
|
-
throw new Error(`post-apply inventory did not prove a single version: ${after.reasons.join("; ")}`);
|
|
2187
|
-
}
|
|
2188
|
-
committedAfter = after;
|
|
2189
|
-
for (const wrapper of wrappers) {
|
|
2190
|
-
const action = actions.find((candidate) => candidate.action === "remove-wrapper" && candidate.path === wrapper.path);
|
|
2191
|
-
if (!action?.stagingPath) {
|
|
2192
|
-
throw new Error(`${wrapper.path}: missing staged wrapper at commit`);
|
|
2193
|
-
}
|
|
2194
|
-
await verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
|
|
2195
|
-
}
|
|
2196
|
-
for (const artifact of inactiveCache) {
|
|
2197
|
-
const action = actions.find((candidate) => candidate.action === "remove-cache" && candidate.path === artifact.path);
|
|
2198
|
-
if (!action?.stagingPath) {
|
|
2199
|
-
throw new Error(`${artifact.path}: missing staged cache at commit`);
|
|
2200
|
-
}
|
|
2201
|
-
await verifyCacheArtifact({ ...artifact, path: action.stagingPath }, target);
|
|
2202
|
-
}
|
|
2203
|
-
const removalActions = actions.filter((candidate) => candidate.action === "remove-wrapper" || candidate.action === "remove-cache");
|
|
2204
|
-
if (removalActions.length > 0) {
|
|
2205
|
-
journal.state = "committed";
|
|
2206
|
-
await writeJournal(journalPath, journal);
|
|
2207
|
-
removalCommitStarted = true;
|
|
2208
|
-
await options.afterRemovalCommit?.();
|
|
2209
|
-
}
|
|
2210
|
-
for (const action of removalActions) {
|
|
2211
|
-
if (!action.stagingPath) {
|
|
2212
|
-
throw new Error(`${action.path}: missing staging path at deletion`);
|
|
2213
|
-
}
|
|
2214
|
-
await rm(action.stagingPath, {
|
|
2215
|
-
recursive: action.action === "remove-cache"
|
|
2216
|
-
});
|
|
2217
|
-
if (await optionalLstat(action.stagingPath)) {
|
|
2218
|
-
throw new Error(`${action.path}: staged obsolete artifact still exists`);
|
|
2219
|
-
}
|
|
2220
|
-
action.deleted = true;
|
|
2221
|
-
await removeEmptyDirectory(dirname(action.stagingPath));
|
|
2222
|
-
await writeJournal(journalPath, journal);
|
|
2223
|
-
}
|
|
2224
|
-
for (const runDirectory of new Set(removalActions.flatMap((action) => action.stagingPath ? [dirname(dirname(action.stagingPath))] : []))) {
|
|
2225
|
-
await removeEmptyDirectory(runDirectory);
|
|
2226
|
-
}
|
|
2227
|
-
await removeEmptyDirectory(before.paths.cacheRecoveryRoot);
|
|
2228
|
-
journal.state = "complete";
|
|
2229
|
-
delete journal.ownerPid;
|
|
2230
|
-
await writeJournal(journalPath, journal);
|
|
2231
|
-
return {
|
|
2232
|
-
...base,
|
|
2233
|
-
status: "applied",
|
|
2234
|
-
recovery: { runId, journalPath },
|
|
2235
|
-
after,
|
|
2236
|
-
refusals: []
|
|
2237
|
-
};
|
|
2238
|
-
} catch (error) {
|
|
2239
|
-
if (removalCommitStarted) {
|
|
2240
|
-
journal.state = "cleanup-failed";
|
|
2241
|
-
delete journal.ownerPid;
|
|
2242
|
-
journal.error = error instanceof Error ? error.message : String(error);
|
|
2243
|
-
try {
|
|
2244
|
-
await writeJournal(journalPath, journal);
|
|
2245
|
-
} catch {}
|
|
2246
|
-
return {
|
|
2247
|
-
...base,
|
|
2248
|
-
status: "refused",
|
|
2249
|
-
recovery: { runId, journalPath },
|
|
2250
|
-
...committedAfter ? { after: committedAfter } : {},
|
|
2251
|
-
failure: {
|
|
2252
|
-
message: journal.error,
|
|
2253
|
-
recoveryState: "cleanup-failed",
|
|
2254
|
-
guidance: [
|
|
2255
|
-
"The newest Flow activation is committed and remains authoritative; do not restore an older config or plugin source.",
|
|
2256
|
-
`Inspect ${journalPath} and permanently delete each remaining remove-wrapper or remove-cache stagingPath after verifying it still contains only the recorded obsolete Flow version.`
|
|
2257
|
-
]
|
|
2258
|
-
},
|
|
2259
|
-
refusals: [journal.error]
|
|
2260
|
-
};
|
|
2261
|
-
}
|
|
2262
|
-
journal.state = "failed";
|
|
2263
|
-
journal.error = error instanceof Error ? error.message : String(error);
|
|
2264
|
-
try {
|
|
2265
|
-
await writeJournal(journalPath, journal);
|
|
2266
|
-
} catch {}
|
|
2267
|
-
const rollbackFailures = await rollbackCompletedActions({
|
|
2268
|
-
journal,
|
|
2269
|
-
journalPath,
|
|
2270
|
-
runId,
|
|
2271
|
-
snapshots: changedSnapshots,
|
|
2272
|
-
paths: before.paths,
|
|
2273
|
-
wrappers
|
|
2274
|
-
});
|
|
2275
|
-
const recoveryState = rollbackFailures.length === 0 ? "rolled-back" : "rollback-failed";
|
|
2276
|
-
journal.state = recoveryState;
|
|
2277
|
-
delete journal.ownerPid;
|
|
2278
|
-
if (rollbackFailures.length > 0) {
|
|
2279
|
-
journal.error = `${journal.error ?? "apply failed"}; rollback: ${rollbackFailures.join("; ")}`;
|
|
2280
|
-
}
|
|
2281
|
-
try {
|
|
2282
|
-
await writeJournal(journalPath, journal);
|
|
2283
|
-
} catch {}
|
|
2284
|
-
const guidance = recoveryState === "rolled-back" ? [
|
|
2285
|
-
"All completed mutations were restored from exact backups or reversible staging moves.",
|
|
2286
|
-
`Inspect ${journalPath} and resolve the recorded failure before retrying.`
|
|
2287
|
-
] : [
|
|
2288
|
-
"Stop OpenCode before manual recovery.",
|
|
2289
|
-
`Inspect ${journalPath}; for rollback-failed actions, restore backupPath to path or rename stagingPath back to path only after verifying the destination is absent or unchanged.`,
|
|
2290
|
-
"Do not delete the recovery directory until activation-check succeeds."
|
|
2291
|
-
];
|
|
2292
|
-
return {
|
|
2293
|
-
...base,
|
|
2294
|
-
status: "refused",
|
|
2295
|
-
recovery: { runId, journalPath },
|
|
2296
|
-
failure: {
|
|
2297
|
-
message: journal.error ?? "activation apply failed",
|
|
2298
|
-
recoveryState,
|
|
2299
|
-
guidance
|
|
2300
|
-
},
|
|
2301
|
-
refusals: [journal.error ?? "activation apply failed"]
|
|
2302
|
-
};
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
|
-
// src/distribution/legacy-cleanup.ts
|
|
2307
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
2308
|
-
import { constants as constants2 } from "node:fs";
|
|
2309
|
-
import {
|
|
2310
|
-
lstat as lstat2,
|
|
2311
|
-
mkdir as mkdir2,
|
|
2312
|
-
open as open2,
|
|
2313
|
-
readdir as readdir2,
|
|
2314
|
-
rename as rename2
|
|
2315
|
-
} from "node:fs/promises";
|
|
2316
|
-
import { homedir as homedir2 } from "node:os";
|
|
2317
|
-
import { isAbsolute as isAbsolute2, join as join2, normalize as normalize2, sep as sep2 } from "node:path";
|
|
2318
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
2319
|
-
|
|
2320
|
-
// src/guidance/ids.ts
|
|
2321
|
-
var FLOW_GUIDANCE_TOPICS = [
|
|
2322
|
-
"flow",
|
|
2323
|
-
"flow-plan",
|
|
2324
|
-
"flow-run",
|
|
2325
|
-
"flow-test",
|
|
2326
|
-
"flow-review",
|
|
2327
|
-
"flow-deslop",
|
|
2328
|
-
"flow-ui-quality",
|
|
2329
|
-
"flow-commit"
|
|
2330
|
-
];
|
|
2331
|
-
|
|
2332
|
-
// src/distribution/legacy-cleanup.ts
|
|
2333
|
-
var LEGACY_MARKER = ".flow-skill-version";
|
|
2334
|
-
var NO_FOLLOW2 = constants2.O_NOFOLLOW ?? 0;
|
|
2335
|
-
var SUPPORTED_LEGACY_MAJOR = "4";
|
|
2336
|
-
var POST_MOVE_VERIFICATION_ATTEMPTS = 4;
|
|
2337
|
-
var POST_MOVE_VERIFICATION_RETRY_MS = 25;
|
|
2338
|
-
var SEMVER_PATTERN2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
2339
|
-
function configuredHome2() {
|
|
2340
|
-
return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || homedir2();
|
|
2341
|
-
}
|
|
2342
|
-
function resolveLegacySkillsRoot(home = configuredHome2()) {
|
|
2343
|
-
return join2(home, ".config", "opencode", "skills");
|
|
2344
|
-
}
|
|
2345
|
-
function resolveLegacyArchiveRoot(home = configuredHome2()) {
|
|
2346
|
-
return join2(home, ".config", "opencode", "flow-legacy-skills");
|
|
2347
|
-
}
|
|
2348
|
-
function sha2562(content) {
|
|
2349
|
-
return createHash2("sha256").update(content).digest("hex");
|
|
2350
|
-
}
|
|
2351
|
-
function safeLegacyPath(folder, relativePath) {
|
|
2352
|
-
if (!relativePath || isAbsolute2(relativePath) || relativePath.includes("\\") || relativePath.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
2353
|
-
throw new Error(`unsafe marker path '${relativePath}'`);
|
|
2354
|
-
}
|
|
2355
|
-
const resolved = normalize2(join2(folder, ...relativePath.split("/")));
|
|
2356
|
-
if (!resolved.startsWith(`${folder}${sep2}`)) {
|
|
2357
|
-
throw new Error(`unsafe marker path '${relativePath}'`);
|
|
2358
|
-
}
|
|
2359
|
-
return resolved;
|
|
2360
|
-
}
|
|
2361
|
-
async function optionalStat(path) {
|
|
2362
|
-
try {
|
|
2363
|
-
return await lstat2(path, { bigint: false });
|
|
2364
|
-
} catch (error) {
|
|
2365
|
-
if (error.code === "ENOENT")
|
|
2366
|
-
return null;
|
|
2367
|
-
throw error;
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
async function readRegularFileWithoutFollowing2(path) {
|
|
2371
|
-
let handle;
|
|
2372
|
-
try {
|
|
2373
|
-
const pathMetadata = await lstat2(path);
|
|
2374
|
-
if (pathMetadata.isSymbolicLink()) {
|
|
2375
|
-
throw new Error(`symbolic link refused: ${path}`);
|
|
2376
|
-
}
|
|
2377
|
-
if (!pathMetadata.isFile())
|
|
2378
|
-
throw new Error(`not a regular file: ${path}`);
|
|
2379
|
-
handle = await open2(path, constants2.O_RDONLY | NO_FOLLOW2);
|
|
2380
|
-
const metadata = await handle.stat();
|
|
2381
|
-
if (!metadata.isFile())
|
|
2382
|
-
throw new Error(`not a regular file: ${path}`);
|
|
2383
|
-
if (metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino) {
|
|
2384
|
-
throw new Error(`file changed while cleanup was running: ${path}`);
|
|
2385
|
-
}
|
|
2386
|
-
return await handle.readFile({ encoding: "utf8" });
|
|
2387
|
-
} catch (error) {
|
|
2388
|
-
if (error.code === "ELOOP") {
|
|
2389
|
-
throw new Error(`symbolic link refused: ${path}`);
|
|
2390
|
-
}
|
|
2391
|
-
throw error;
|
|
2392
|
-
} finally {
|
|
2393
|
-
await handle?.close();
|
|
2394
|
-
}
|
|
2395
|
-
}
|
|
2396
|
-
function parseMarker(content) {
|
|
2397
|
-
let version;
|
|
2398
|
-
const files = new Map;
|
|
2399
|
-
for (const line of content.split(/\r?\n/)) {
|
|
2400
|
-
if (!line)
|
|
2401
|
-
continue;
|
|
2402
|
-
const versionMatch = /^version=(.+)$/.exec(line);
|
|
2403
|
-
if (versionMatch?.[1]) {
|
|
2404
|
-
if (version)
|
|
2405
|
-
throw new Error("marker contains duplicate versions");
|
|
2406
|
-
version = versionMatch[1];
|
|
2407
|
-
continue;
|
|
2408
|
-
}
|
|
2409
|
-
const fileMatch = /^file=(.+) sha256=([a-f0-9]{64})$/.exec(line) ?? /^file=(.+)=sha256:([a-f0-9]{64})$/.exec(line);
|
|
2410
|
-
if (fileMatch?.[1] && fileMatch[2]) {
|
|
2411
|
-
if (files.has(fileMatch[1])) {
|
|
2412
|
-
throw new Error(`marker contains duplicate file '${fileMatch[1]}'`);
|
|
2413
|
-
}
|
|
2414
|
-
files.set(fileMatch[1], fileMatch[2]);
|
|
2415
|
-
continue;
|
|
2416
|
-
}
|
|
2417
|
-
const topLevelHash = /^hash=sha256:([a-f0-9]{64})$/.exec(line);
|
|
2418
|
-
if (topLevelHash?.[1] && !files.has("SKILL.md")) {
|
|
2419
|
-
files.set("SKILL.md", topLevelHash[1]);
|
|
2420
|
-
continue;
|
|
2421
|
-
}
|
|
2422
|
-
throw new Error(`marker contains an invalid line: '${line}'`);
|
|
2423
|
-
}
|
|
2424
|
-
if (!version)
|
|
2425
|
-
throw new Error("marker has no version");
|
|
2426
|
-
if (!files.has("SKILL.md"))
|
|
2427
|
-
throw new Error("marker does not own SKILL.md");
|
|
2428
|
-
return { version, files };
|
|
2429
|
-
}
|
|
2430
|
-
function assertSupportedLegacyVersion(version) {
|
|
2431
|
-
const match = SEMVER_PATTERN2.exec(version);
|
|
2432
|
-
if (!match) {
|
|
2433
|
-
throw new Error(`marker version '${version}' is not a valid semantic version`);
|
|
2434
|
-
}
|
|
2435
|
-
if (match[1] !== SUPPORTED_LEGACY_MAJOR) {
|
|
2436
|
-
throw new Error(`marker version '${version}' is outside the supported legacy range >=4.0.0 <5.0.0`);
|
|
2437
|
-
}
|
|
2438
|
-
}
|
|
2439
|
-
function expectedDirectoryEntries(marker) {
|
|
2440
|
-
const entries = new Map([
|
|
2441
|
-
["", new Set([LEGACY_MARKER])]
|
|
2442
|
-
]);
|
|
2443
|
-
for (const relativePath of marker.files.keys()) {
|
|
2444
|
-
const parts = relativePath.split("/");
|
|
2445
|
-
let parent = "";
|
|
2446
|
-
for (let index = 0;index < parts.length; index += 1) {
|
|
2447
|
-
const part = parts[index];
|
|
2448
|
-
if (!part)
|
|
2449
|
-
throw new Error(`unsafe marker path '${relativePath}'`);
|
|
2450
|
-
const children = entries.get(parent) ?? new Set;
|
|
2451
|
-
children.add(part);
|
|
2452
|
-
entries.set(parent, children);
|
|
2453
|
-
if (index < parts.length - 1) {
|
|
2454
|
-
parent = parent ? `${parent}/${part}` : part;
|
|
2455
|
-
if (!entries.has(parent))
|
|
2456
|
-
entries.set(parent, new Set);
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
return entries;
|
|
2461
|
-
}
|
|
2462
|
-
async function inspectLegacyFolder(name, folder) {
|
|
2463
|
-
const metadata = await optionalStat(folder);
|
|
2464
|
-
if (!metadata)
|
|
2465
|
-
return { name, path: folder, status: "absent" };
|
|
2466
|
-
if (!metadata.isDirectory()) {
|
|
2467
|
-
return {
|
|
2468
|
-
name,
|
|
2469
|
-
path: folder,
|
|
2470
|
-
status: "refused",
|
|
2471
|
-
reason: "path is not a real directory"
|
|
2472
|
-
};
|
|
2473
|
-
}
|
|
2474
|
-
try {
|
|
2475
|
-
const markerPath = join2(folder, LEGACY_MARKER);
|
|
2476
|
-
const marker = parseMarker(await readRegularFileWithoutFollowing2(markerPath));
|
|
2477
|
-
assertSupportedLegacyVersion(marker.version);
|
|
2478
|
-
const directories = expectedDirectoryEntries(marker);
|
|
2479
|
-
for (const [relativeDirectory, expectedEntries] of directories) {
|
|
2480
|
-
const directory = relativeDirectory ? safeLegacyPath(folder, relativeDirectory) : folder;
|
|
2481
|
-
const directoryMetadata = await optionalStat(directory);
|
|
2482
|
-
if (!directoryMetadata?.isDirectory()) {
|
|
2483
|
-
throw new Error(`expected real directory: ${relativeDirectory || "."}`);
|
|
2484
|
-
}
|
|
2485
|
-
const actualEntries = await readdir2(directory);
|
|
2486
|
-
const unexpected = actualEntries.filter((entry) => !expectedEntries.has(entry));
|
|
2487
|
-
const missing = [...expectedEntries].filter((entry) => !actualEntries.includes(entry));
|
|
2488
|
-
if (unexpected.length > 0 || missing.length > 0) {
|
|
2489
|
-
throw new Error([
|
|
2490
|
-
unexpected.length > 0 ? `unexpected entries: ${unexpected.join(", ")}` : "",
|
|
2491
|
-
missing.length > 0 ? `missing entries: ${missing.join(", ")}` : ""
|
|
2492
|
-
].filter(Boolean).join("; "));
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
|
-
for (const [relativePath, expectedHash] of marker.files) {
|
|
2496
|
-
const path = safeLegacyPath(folder, relativePath);
|
|
2497
|
-
const content = await readRegularFileWithoutFollowing2(path);
|
|
2498
|
-
if (sha2562(content) !== expectedHash) {
|
|
2499
|
-
throw new Error(`edited file refused: ${relativePath}`);
|
|
2500
|
-
}
|
|
2501
|
-
}
|
|
2502
|
-
return {
|
|
2503
|
-
name,
|
|
2504
|
-
path: folder,
|
|
2505
|
-
status: "eligible"
|
|
2506
|
-
};
|
|
2507
|
-
} catch (error) {
|
|
2508
|
-
return {
|
|
2509
|
-
name,
|
|
2510
|
-
path: folder,
|
|
2511
|
-
status: "refused",
|
|
2512
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
2513
|
-
};
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
async function ensureRealArchiveRoot(path) {
|
|
2517
|
-
const existing = await optionalStat(path);
|
|
2518
|
-
if (existing) {
|
|
2519
|
-
if (!existing.isDirectory()) {
|
|
2520
|
-
throw new Error(`Legacy archive path is not a real directory: ${path}`);
|
|
2521
|
-
}
|
|
2522
|
-
return;
|
|
2523
|
-
}
|
|
2524
|
-
try {
|
|
2525
|
-
await mkdir2(path, { mode: 448 });
|
|
2526
|
-
} catch (error) {
|
|
2527
|
-
if (error.code !== "EEXIST")
|
|
2528
|
-
throw error;
|
|
2529
|
-
const raced = await optionalStat(path);
|
|
2530
|
-
if (!raced?.isDirectory()) {
|
|
2531
|
-
throw new Error(`Legacy archive path is not a real directory: ${path}`);
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
async function verifyMovedLegacyFolder(name, archivePath) {
|
|
2536
|
-
let verified = await inspectLegacyFolder(name, archivePath);
|
|
2537
|
-
for (let attempt = 1;attempt < POST_MOVE_VERIFICATION_ATTEMPTS && verified.status !== "eligible"; attempt += 1) {
|
|
2538
|
-
await sleep(POST_MOVE_VERIFICATION_RETRY_MS * attempt);
|
|
2539
|
-
verified = await inspectLegacyFolder(name, archivePath);
|
|
2540
|
-
}
|
|
2541
|
-
return verified;
|
|
2542
|
-
}
|
|
2543
|
-
async function cleanupLegacySkills(options) {
|
|
2544
|
-
const home = options?.home ?? configuredHome2();
|
|
2545
|
-
const root = resolveLegacySkillsRoot(home);
|
|
2546
|
-
const archiveRoot = resolveLegacyArchiveRoot(home);
|
|
2547
|
-
const apply = options?.apply === true;
|
|
2548
|
-
const results = [];
|
|
2549
|
-
let archiveReady = false;
|
|
2550
|
-
for (const name of FLOW_GUIDANCE_TOPICS) {
|
|
2551
|
-
const path = join2(root, name);
|
|
2552
|
-
const inspected = await inspectLegacyFolder(name, path);
|
|
2553
|
-
if (!apply || inspected.status !== "eligible") {
|
|
2554
|
-
results.push(inspected);
|
|
2555
|
-
continue;
|
|
2556
|
-
}
|
|
2557
|
-
if (!archiveReady) {
|
|
2558
|
-
await ensureRealArchiveRoot(archiveRoot);
|
|
2559
|
-
archiveReady = true;
|
|
2560
|
-
}
|
|
2561
|
-
const archivePath = join2(archiveRoot, `${name}-${new Date().toISOString().replaceAll(":", "-")}-${crypto.randomUUID()}`);
|
|
2562
|
-
try {
|
|
2563
|
-
await rename2(path, archivePath);
|
|
2564
|
-
} catch (error) {
|
|
2565
|
-
if (error.code !== "ENOENT")
|
|
2566
|
-
throw error;
|
|
2567
|
-
results.push({
|
|
2568
|
-
name,
|
|
2569
|
-
path,
|
|
2570
|
-
status: "refused",
|
|
2571
|
-
reason: "folder changed while cleanup was running"
|
|
2572
|
-
});
|
|
2573
|
-
continue;
|
|
2574
|
-
}
|
|
2575
|
-
await options?.afterQuarantine?.({ name, path, archivePath });
|
|
2576
|
-
const verified = await verifyMovedLegacyFolder(name, archivePath);
|
|
2577
|
-
if (verified.status === "absent") {
|
|
2578
|
-
results.push({
|
|
2579
|
-
name,
|
|
2580
|
-
path,
|
|
2581
|
-
status: "refused",
|
|
2582
|
-
reason: "archive moved again while cleanup was running; inspect the legacy archive root"
|
|
2583
|
-
});
|
|
2584
|
-
continue;
|
|
2585
|
-
}
|
|
2586
|
-
if (verified.status !== "eligible") {
|
|
2587
|
-
results.push({
|
|
2588
|
-
name,
|
|
2589
|
-
path,
|
|
2590
|
-
status: "quarantined",
|
|
2591
|
-
reason: "folder changed while cleanup was running; preserved for manual recovery",
|
|
2592
|
-
archivePath
|
|
2593
|
-
});
|
|
2594
|
-
continue;
|
|
2595
|
-
}
|
|
2596
|
-
results.push({
|
|
2597
|
-
name,
|
|
2598
|
-
path,
|
|
2599
|
-
status: "archived",
|
|
2600
|
-
archivePath
|
|
2601
|
-
});
|
|
2602
|
-
}
|
|
2603
|
-
return {
|
|
2604
|
-
mode: apply ? "apply" : "dry-run",
|
|
2605
|
-
root,
|
|
2606
|
-
archiveRoot,
|
|
2607
|
-
results
|
|
2608
|
-
};
|
|
2609
|
-
}
|
|
2610
|
-
|
|
2611
|
-
// src/cli.ts
|
|
2612
|
-
function usage() {
|
|
2613
|
-
return [
|
|
2614
|
-
"usage:",
|
|
2615
|
-
" opencode-plugin-flow install --project <absolute-path> --scope <global|project> [--json]",
|
|
2616
|
-
" opencode-plugin-flow activation-check --project <absolute-path> [--target <exact-version>] [--json]",
|
|
2617
|
-
" opencode-plugin-flow activation-apply --project <absolute-path> --scope <global|project> [--target <exact-version>] [--apply] [--json]",
|
|
2618
|
-
" opencode-plugin-flow legacy-cleanup <--dry-run|--apply> [--json]",
|
|
2619
|
-
"",
|
|
2620
|
-
"commands:",
|
|
2621
|
-
" install Converge immediately to this package's exact version and remove proven older copies",
|
|
2622
|
-
" activation-check Inventory global sources, one selected project, and cache artifacts",
|
|
2623
|
-
" activation-apply Plan a single-version activation; mutate only with --apply",
|
|
2624
|
-
" legacy-cleanup Inspect or archive marker-proven legacy global Flow skills",
|
|
2625
|
-
"",
|
|
2626
|
-
"activation options:",
|
|
2627
|
-
" --project <path> Absolute project/worktree path; other project trees are not scanned",
|
|
2628
|
-
" --scope <scope> Config that receives the one canonical exact npm pin",
|
|
2629
|
-
" --target <version> Exact version only; defaults to this package's embedded version",
|
|
2630
|
-
" --apply Create backups/journal and apply the activation plan",
|
|
2631
|
-
" Without --apply, activation-apply is read-only",
|
|
2632
|
-
"",
|
|
2633
|
-
"legacy cleanup options:",
|
|
2634
|
-
" --dry-run Report eligible folders without changing the filesystem",
|
|
2635
|
-
" --apply Move eligible folders to a recoverable archive outside skill discovery",
|
|
2636
|
-
" Cleanup never deletes legacy folders",
|
|
2637
|
-
"",
|
|
2638
|
-
"common options:",
|
|
2639
|
-
" --json Write the report as JSON",
|
|
2640
|
-
" --help Show this help",
|
|
2641
|
-
" --version Print the plugin version"
|
|
2642
|
-
].join(`
|
|
2643
|
-
`);
|
|
2644
|
-
}
|
|
2645
|
-
function writeLegacyReport(report, json) {
|
|
2646
|
-
if (json) {
|
|
2647
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2648
|
-
`);
|
|
2649
|
-
return;
|
|
2650
|
-
}
|
|
2651
|
-
process.stdout.write(`Flow legacy skill cleanup (${report.mode})
|
|
2652
|
-
`);
|
|
2653
|
-
process.stdout.write(`- legacy root: ${report.root}
|
|
2654
|
-
`);
|
|
2655
|
-
process.stdout.write(`- archive root: ${report.archiveRoot}
|
|
2656
|
-
`);
|
|
2657
|
-
for (const result of report.results) {
|
|
2658
|
-
process.stdout.write(`- ${result.name}: ${result.status}
|
|
2659
|
-
`);
|
|
2660
|
-
if (result.reason)
|
|
2661
|
-
process.stdout.write(` reason: ${result.reason}
|
|
2662
|
-
`);
|
|
2663
|
-
if (result.archivePath) {
|
|
2664
|
-
const label = result.status === "archived" ? "archived" : "preserved";
|
|
2665
|
-
process.stdout.write(` ${label} at: ${result.archivePath}
|
|
2666
|
-
`);
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2670
|
-
function writeActivationCheck(report, json) {
|
|
2671
|
-
if (json) {
|
|
2672
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2673
|
-
`);
|
|
2674
|
-
return;
|
|
2675
|
-
}
|
|
2676
|
-
process.stdout.write(`Flow activation check: ${report.singleVersionSatisfied ? "satisfied" : "not satisfied"}
|
|
2677
|
-
`);
|
|
2678
|
-
process.stdout.write(`- project: ${report.project}
|
|
2679
|
-
`);
|
|
2680
|
-
process.stdout.write(`- coverage: global sources plus the selected project; other project trees are not scanned
|
|
2681
|
-
`);
|
|
2682
|
-
process.stdout.write(`- target: opencode-plugin-flow@${report.target}
|
|
2683
|
-
`);
|
|
2684
|
-
process.stdout.write(`- activation sources: ${report.records.length}
|
|
2685
|
-
`);
|
|
2686
|
-
for (const record of report.records) {
|
|
2687
|
-
process.stdout.write(` - ${record.source}: ${record.specifier} (${record.ownership}, ${record.status}, version=${record.resolvedVersion ?? "unresolved"})
|
|
2688
|
-
`);
|
|
2689
|
-
if (record.reason)
|
|
2690
|
-
process.stdout.write(` reason: ${record.reason}
|
|
2691
|
-
`);
|
|
2692
|
-
}
|
|
2693
|
-
process.stdout.write(`- Flow cache artifacts: ${report.cacheArtifacts.length}
|
|
2694
|
-
`);
|
|
2695
|
-
for (const artifact of report.cacheArtifacts) {
|
|
2696
|
-
process.stdout.write(` - ${artifact.specifier}: ${artifact.status} (version=${artifact.resolvedVersion ?? "unresolved"})
|
|
2697
|
-
`);
|
|
2698
|
-
if (artifact.reason)
|
|
2699
|
-
process.stdout.write(` reason: ${artifact.reason}
|
|
2700
|
-
`);
|
|
2701
|
-
}
|
|
2702
|
-
for (const limitation of report.limitations) {
|
|
2703
|
-
process.stdout.write(`- limitation (${limitation.coverage}): ${limitation.source}
|
|
2704
|
-
${limitation.detail}
|
|
2705
|
-
`);
|
|
2706
|
-
}
|
|
2707
|
-
for (const reason of report.reasons) {
|
|
2708
|
-
process.stdout.write(`- blocked: ${reason}
|
|
2709
|
-
`);
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
function writeActivationApply(report, json) {
|
|
2713
|
-
if (json) {
|
|
2714
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2715
|
-
`);
|
|
2716
|
-
return;
|
|
2717
|
-
}
|
|
2718
|
-
process.stdout.write(`Flow activation ${report.mode}: ${report.status}
|
|
2719
|
-
`);
|
|
2720
|
-
process.stdout.write(`- project: ${report.project}
|
|
2721
|
-
`);
|
|
2722
|
-
process.stdout.write(`- coverage: global sources plus the selected project; other project trees are not scanned
|
|
2723
|
-
`);
|
|
2724
|
-
process.stdout.write(`- canonical scope: ${report.scope}
|
|
2725
|
-
`);
|
|
2726
|
-
process.stdout.write(`- target: opencode-plugin-flow@${report.target}
|
|
2727
|
-
`);
|
|
2728
|
-
for (const refusal of report.refusals) {
|
|
2729
|
-
process.stdout.write(`- refused: ${refusal}
|
|
2730
|
-
`);
|
|
2731
|
-
}
|
|
2732
|
-
if (report.status === "refused" && report.plan.length > 0) {
|
|
2733
|
-
process.stdout.write(`- blocked plan (not executed):
|
|
2734
|
-
`);
|
|
2735
|
-
}
|
|
2736
|
-
for (const operation of report.plan) {
|
|
2737
|
-
const action = report.status === "refused" ? `would-${operation.action}` : operation.action;
|
|
2738
|
-
process.stdout.write(`- ${action}: ${operation.path}
|
|
2739
|
-
${operation.detail}
|
|
2740
|
-
`);
|
|
2741
|
-
}
|
|
2742
|
-
if (report.recovery) {
|
|
2743
|
-
process.stdout.write(`- recovery journal: ${report.recovery.journalPath}
|
|
2744
|
-
`);
|
|
2745
|
-
}
|
|
2746
|
-
if (report.failure) {
|
|
2747
|
-
process.stdout.write(`- recovery state: ${report.failure.recoveryState}
|
|
2748
|
-
- failure: ${report.failure.message}
|
|
2749
|
-
`);
|
|
2750
|
-
for (const guidance of report.failure.guidance) {
|
|
2751
|
-
process.stdout.write(` recovery: ${guidance}
|
|
2752
|
-
`);
|
|
2753
|
-
}
|
|
2754
|
-
}
|
|
2755
|
-
if (report.mode === "dry-run" && report.status === "ready") {
|
|
2756
|
-
process.stdout.write(`- no files changed; repeat with --apply to execute
|
|
2757
|
-
`);
|
|
2758
|
-
}
|
|
2759
|
-
}
|
|
2760
|
-
function parseActivationFlags(flags) {
|
|
2761
|
-
const parsed = {
|
|
2762
|
-
apply: false,
|
|
2763
|
-
json: false,
|
|
2764
|
-
help: false
|
|
2765
|
-
};
|
|
2766
|
-
const seen = new Set;
|
|
2767
|
-
for (let index = 0;index < flags.length; index += 1) {
|
|
2768
|
-
const flag = flags[index];
|
|
2769
|
-
if (!flag || seen.has(flag))
|
|
2770
|
-
return null;
|
|
2771
|
-
seen.add(flag);
|
|
2772
|
-
if (flag === "--apply") {
|
|
2773
|
-
parsed.apply = true;
|
|
2774
|
-
continue;
|
|
2775
|
-
}
|
|
2776
|
-
if (flag === "--json") {
|
|
2777
|
-
parsed.json = true;
|
|
2778
|
-
continue;
|
|
2779
|
-
}
|
|
2780
|
-
if (flag === "--help" || flag === "-h") {
|
|
2781
|
-
parsed.help = true;
|
|
2782
|
-
continue;
|
|
2783
|
-
}
|
|
2784
|
-
if (!["--project", "--target", "--scope"].includes(flag))
|
|
2785
|
-
return null;
|
|
2786
|
-
const value = flags[index + 1];
|
|
2787
|
-
if (!value || value.startsWith("--"))
|
|
2788
|
-
return null;
|
|
2789
|
-
index += 1;
|
|
2790
|
-
if (flag === "--project")
|
|
2791
|
-
parsed.project = value;
|
|
2792
|
-
if (flag === "--target")
|
|
2793
|
-
parsed.target = value;
|
|
2794
|
-
if (flag === "--scope")
|
|
2795
|
-
parsed.scope = value;
|
|
2796
|
-
}
|
|
2797
|
-
return parsed;
|
|
2798
|
-
}
|
|
2799
|
-
function activationScope(value) {
|
|
2800
|
-
return value === "global" || value === "project" ? value : null;
|
|
2801
|
-
}
|
|
2802
|
-
async function runActivationCheck(flags) {
|
|
2803
|
-
const parsed = parseActivationFlags(flags);
|
|
2804
|
-
if (!parsed || parsed.apply || parsed.scope || !parsed.project && !parsed.help) {
|
|
2805
|
-
process.stderr.write(`${usage()}
|
|
2806
|
-
`);
|
|
2807
|
-
process.exitCode = 2;
|
|
2808
|
-
return;
|
|
2809
|
-
}
|
|
2810
|
-
if (parsed.help) {
|
|
2811
|
-
process.stdout.write(`${usage()}
|
|
2812
|
-
`);
|
|
2813
|
-
return;
|
|
2814
|
-
}
|
|
2815
|
-
const report = await checkFlowActivation({
|
|
2816
|
-
project: parsed.project,
|
|
2817
|
-
...parsed.target ? { target: parsed.target } : {}
|
|
2818
|
-
});
|
|
2819
|
-
writeActivationCheck(report, parsed.json);
|
|
2820
|
-
if (!report.singleVersionSatisfied)
|
|
2821
|
-
process.exitCode = 1;
|
|
2822
|
-
}
|
|
2823
|
-
async function runActivationApply(flags) {
|
|
2824
|
-
const parsed = parseActivationFlags(flags);
|
|
2825
|
-
const scope = activationScope(parsed?.scope);
|
|
2826
|
-
if (!parsed || !parsed.project && !parsed.help || !scope && !parsed.help) {
|
|
2827
|
-
process.stderr.write(`${usage()}
|
|
2828
|
-
`);
|
|
2829
|
-
process.exitCode = 2;
|
|
2830
|
-
return;
|
|
2831
|
-
}
|
|
2832
|
-
if (parsed.help) {
|
|
2833
|
-
process.stdout.write(`${usage()}
|
|
2834
|
-
`);
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
const report = await applyFlowActivation({
|
|
2838
|
-
project: parsed.project,
|
|
2839
|
-
scope,
|
|
2840
|
-
apply: parsed.apply,
|
|
2841
|
-
...parsed.target ? { target: parsed.target } : {}
|
|
2842
|
-
});
|
|
2843
|
-
writeActivationApply(report, parsed.json);
|
|
2844
|
-
if (report.status === "refused")
|
|
2845
|
-
process.exitCode = 1;
|
|
2846
|
-
}
|
|
2847
|
-
async function runInstall(flags) {
|
|
2848
|
-
const parsed = parseActivationFlags(flags);
|
|
2849
|
-
const scope = activationScope(parsed?.scope);
|
|
2850
|
-
if (!parsed || parsed.apply || parsed.target || !parsed.project && !parsed.help || !scope && !parsed.help) {
|
|
2851
|
-
process.stderr.write(`${usage()}
|
|
2852
|
-
`);
|
|
2853
|
-
process.exitCode = 2;
|
|
2854
|
-
return;
|
|
2855
|
-
}
|
|
2856
|
-
if (parsed.help) {
|
|
2857
|
-
process.stdout.write(`${usage()}
|
|
2858
|
-
`);
|
|
2859
|
-
return;
|
|
2860
|
-
}
|
|
2861
|
-
const report = await applyFlowActivation({
|
|
2862
|
-
project: parsed.project,
|
|
2863
|
-
scope,
|
|
2864
|
-
apply: true
|
|
2865
|
-
});
|
|
2866
|
-
writeActivationApply(report, parsed.json);
|
|
2867
|
-
if (report.status === "refused")
|
|
2868
|
-
process.exitCode = 1;
|
|
2869
|
-
}
|
|
2870
|
-
async function runLegacyCleanup(flags) {
|
|
2871
|
-
const knownFlags = new Set(["--dry-run", "--apply", "--json"]);
|
|
2872
|
-
const validFlags = flags.every((flag) => knownFlags.has(flag));
|
|
2873
|
-
const dryRun = flags.includes("--dry-run");
|
|
2874
|
-
const apply = flags.includes("--apply");
|
|
2875
|
-
if (!validFlags || dryRun === apply) {
|
|
2876
|
-
process.stderr.write(`${usage()}
|
|
2877
|
-
`);
|
|
2878
|
-
process.exitCode = 2;
|
|
2879
|
-
return;
|
|
2880
|
-
}
|
|
2881
|
-
const report = await cleanupLegacySkills({ apply });
|
|
2882
|
-
writeLegacyReport(report, flags.includes("--json"));
|
|
2883
|
-
if (apply && report.results.some((result) => ["refused", "quarantined"].includes(result.status))) {
|
|
2884
|
-
process.exitCode = 1;
|
|
2885
|
-
}
|
|
2886
|
-
}
|
|
2887
|
-
async function main(argv) {
|
|
2888
|
-
const [command, ...flags] = argv.slice(2);
|
|
2889
|
-
if (command === "--help" || command === "-h") {
|
|
2890
|
-
process.stdout.write(`${usage()}
|
|
2891
|
-
`);
|
|
2892
|
-
return;
|
|
2893
|
-
}
|
|
2894
|
-
if (command === "--version" || command === "-v") {
|
|
2895
|
-
process.stdout.write(`${resolveFlowPluginVersion()}
|
|
2896
|
-
`);
|
|
2897
|
-
return;
|
|
2898
|
-
}
|
|
2899
|
-
if (command === "activation-check") {
|
|
2900
|
-
await runActivationCheck(flags);
|
|
2901
|
-
return;
|
|
2902
|
-
}
|
|
2903
|
-
if (command === "install") {
|
|
2904
|
-
await runInstall(flags);
|
|
2905
|
-
return;
|
|
2906
|
-
}
|
|
2907
|
-
if (command === "activation-apply") {
|
|
2908
|
-
await runActivationApply(flags);
|
|
2909
|
-
return;
|
|
2910
|
-
}
|
|
2911
|
-
if (command === "legacy-cleanup") {
|
|
2912
|
-
await runLegacyCleanup(flags);
|
|
2913
|
-
return;
|
|
2914
|
-
}
|
|
2915
|
-
process.stderr.write(`${usage()}
|
|
2916
|
-
`);
|
|
2917
|
-
process.exitCode = 2;
|
|
2918
|
-
}
|
|
2919
|
-
main(process.argv).catch((error) => {
|
|
2920
|
-
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
2921
|
-
`);
|
|
2922
|
-
process.exitCode = 1;
|
|
2923
|
-
});
|
|
2924
|
-
|
|
2925
|
-
//# debugId=8E9D5A34AAD016F364756E2164756E21
|