opencode-plugin-flow 5.2.1 → 5.3.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +96 -19
  3. package/dist/cli.js +1928 -58
  4. package/dist/cli.js.map +7 -6
  5. package/dist/index.js +13440 -8946
  6. package/dist/index.js.map +37 -24
  7. package/package.json +14 -5
  8. package/dist/application/errors.d.ts +0 -10
  9. package/dist/application/flow-service.d.ts +0 -311
  10. package/dist/application/ports/evidence-artifact-store.d.ts +0 -28
  11. package/dist/application/ports/session-repository.d.ts +0 -22
  12. package/dist/application/ports/source-identity.d.ts +0 -61
  13. package/dist/application/replay/canonical-json.d.ts +0 -7
  14. package/dist/application/replay/contract.d.ts +0 -2130
  15. package/dist/application/replay/engine.d.ts +0 -101
  16. package/dist/application/replay/index.d.ts +0 -7
  17. package/dist/application/replay/privacy.d.ts +0 -14
  18. package/dist/application/schema.d.ts +0 -1813
  19. package/dist/cli.d.ts +0 -1
  20. package/dist/config-shared.d.ts +0 -287
  21. package/dist/config.d.ts +0 -1
  22. package/dist/distribution/legacy-cleanup.d.ts +0 -25
  23. package/dist/domain/feature-id.d.ts +0 -3
  24. package/dist/domain/limits.d.ts +0 -4
  25. package/dist/domain/orchestration-policy.d.ts +0 -27
  26. package/dist/domain/session-invariants.d.ts +0 -8
  27. package/dist/domain/session.d.ts +0 -356
  28. package/dist/domain/transitions.d.ts +0 -275
  29. package/dist/domain/validation-command.d.ts +0 -8
  30. package/dist/guidance/catalog.d.ts +0 -18
  31. package/dist/guidance/ids.d.ts +0 -4
  32. package/dist/infrastructure/fs/evidence-artifact-store.d.ts +0 -5
  33. package/dist/infrastructure/fs/session-repository.d.ts +0 -2
  34. package/dist/infrastructure/fs/source-identity.d.ts +0 -26
  35. package/dist/infrastructure/fs/strict-json-object.d.ts +0 -9
  36. package/dist/infrastructure/fs/workspace-flow-service.d.ts +0 -10
  37. package/dist/infrastructure/fs/workspace.d.ts +0 -63
  38. package/dist/infrastructure/system/transition-environment.d.ts +0 -2
  39. package/dist/platform/opencode/config.d.ts +0 -2
  40. package/dist/platform/opencode/logging.d.ts +0 -3
  41. package/dist/platform/opencode/tools.d.ts +0 -4
  42. package/dist/prompt-baseline-fixtures.d.ts +0 -20
  43. package/dist/prompt-model-evaluation.d.ts +0 -88
  44. package/dist/prompt-quality.d.ts +0 -80
  45. package/dist/prompt-surfaces.d.ts +0 -28
  46. package/dist/version.d.ts +0 -1
package/dist/cli.js CHANGED
@@ -1,17 +1,1691 @@
1
1
  #!/usr/bin/env node
2
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
+ writeFile
15
+ } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import {
18
+ basename,
19
+ dirname,
20
+ isAbsolute,
21
+ join,
22
+ normalize,
23
+ relative,
24
+ resolve,
25
+ sep
26
+ } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ // src/version.ts
30
+ import { createRequire } from "node:module";
31
+ function resolveFlowPluginVersion() {
32
+ try {
33
+ const require2 = createRequire(import.meta.url);
34
+ const manifest = require2("../package.json");
35
+ if (manifest.version)
36
+ return manifest.version;
37
+ } catch {}
38
+ return "0.0.0";
39
+ }
40
+
41
+ // src/distribution/activation.ts
42
+ var FLOW_PACKAGE_NAME = "opencode-plugin-flow";
43
+ var OWNED_WRAPPER_MARKER = "// @opencode-plugin-flow-owned-wrapper v1";
44
+ var NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
45
+ var MAX_LOCAL_PLUGIN_BYTES = 1024 * 1024;
46
+ 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-]+)*)?$/;
47
+ var FLOW_NPM_SPECIFIER_PATTERN = /^opencode-plugin-flow(?:@(.+))?$/;
48
+ var LOCAL_PLUGIN_EXTENSION_PATTERN = /\.(?:js|ts)$/i;
49
+ function sha256(value) {
50
+ return createHash("sha256").update(value).digest("hex");
51
+ }
52
+ function configuredHome(env) {
53
+ return env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
54
+ }
55
+ function normalizedInjectedRoot(path) {
56
+ return normalize(resolve(path));
57
+ }
58
+ function resolveEnvironmentPath(value, project) {
59
+ return normalize(isAbsolute(value) ? value : resolve(project, value));
60
+ }
61
+ function systemManagedConfigRoot(platform, env) {
62
+ if (platform === "darwin")
63
+ return "/Library/Application Support/opencode";
64
+ if (platform === "win32") {
65
+ return join(env.ProgramData?.trim() || "C:\\ProgramData", "opencode");
66
+ }
67
+ return "/etc/opencode";
68
+ }
69
+ function systemManagedPreferencePaths(platform, home) {
70
+ if (platform !== "darwin")
71
+ return [];
72
+ const username = basename(home) || "user";
73
+ return [
74
+ join("/Library/Managed Preferences", username, "ai.opencode.managed.plist"),
75
+ join("/Library/Managed Preferences", "ai.opencode.managed.plist")
76
+ ];
77
+ }
78
+ function isExactFlowVersion(value) {
79
+ return EXACT_VERSION_PATTERN.test(value);
80
+ }
81
+ function resolveActivationTarget(target) {
82
+ const resolved = target ?? resolveFlowPluginVersion();
83
+ if (!isExactFlowVersion(resolved)) {
84
+ throw new Error(`Flow activation target '${resolved}' must be an exact semantic version; tags and ranges are not resolved.`);
85
+ }
86
+ return resolved;
87
+ }
88
+ function resolveActivationPaths(project, options = {}) {
89
+ if (!isAbsolute(project)) {
90
+ throw new Error(`Flow activation project path must be absolute: ${project}`);
91
+ }
92
+ const env = options.env ?? process.env;
93
+ const home = normalizedInjectedRoot(options.home ?? configuredHome(env));
94
+ 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")));
95
+ 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")));
96
+ const absoluteProject = normalizedInjectedRoot(project);
97
+ const platform = options.platform ?? process.platform;
98
+ const customConfigFile = env.OPENCODE_CONFIG?.trim() ? resolveEnvironmentPath(env.OPENCODE_CONFIG.trim(), absoluteProject) : null;
99
+ const customConfigDirectory = env.OPENCODE_CONFIG_DIR?.trim() ? resolveEnvironmentPath(env.OPENCODE_CONFIG_DIR.trim(), absoluteProject) : null;
100
+ const managedConfigRoot = normalizedInjectedRoot(options.managedConfigRoot ?? env.OPENCODE_TEST_MANAGED_CONFIG_DIR ?? systemManagedConfigRoot(platform, env));
101
+ const globalConfigFiles = [
102
+ join(configRoot, "config.json"),
103
+ join(configRoot, "opencode.json"),
104
+ join(configRoot, "opencode.jsonc")
105
+ ];
106
+ const projectConfigFiles = [
107
+ join(absoluteProject, "opencode.jsonc"),
108
+ join(absoluteProject, "opencode.json")
109
+ ];
110
+ const projectDirectoryRoot = join(absoluteProject, ".opencode");
111
+ const projectDirectoryConfigFiles = [
112
+ join(projectDirectoryRoot, "opencode.json"),
113
+ join(projectDirectoryRoot, "opencode.jsonc")
114
+ ];
115
+ const customDirectoryConfigFiles = customConfigDirectory ? [
116
+ join(customConfigDirectory, "opencode.json"),
117
+ join(customConfigDirectory, "opencode.jsonc")
118
+ ] : [];
119
+ const managedConfigFiles = [
120
+ join(managedConfigRoot, "opencode.json"),
121
+ join(managedConfigRoot, "opencode.jsonc")
122
+ ];
123
+ const pluginDirectories = [
124
+ ...["plugin", "plugins"].map((name) => ({
125
+ source: "global-plugin-directory",
126
+ scope: "global",
127
+ path: join(configRoot, name),
128
+ safetyRoot: dirname(configRoot)
129
+ })),
130
+ ...["plugin", "plugins"].map((name) => ({
131
+ source: "home-plugin-directory",
132
+ scope: "global",
133
+ path: join(home, ".opencode", name),
134
+ safetyRoot: home
135
+ })),
136
+ ...["plugin", "plugins"].map((name) => ({
137
+ source: "project-plugin-directory",
138
+ scope: "project",
139
+ path: join(projectDirectoryRoot, name),
140
+ safetyRoot: absoluteProject
141
+ })),
142
+ ...customConfigDirectory ? ["plugin", "plugins"].map((name) => ({
143
+ source: "custom-plugin-directory",
144
+ scope: "custom",
145
+ path: join(customConfigDirectory, name),
146
+ safetyRoot: dirname(customConfigDirectory)
147
+ })) : []
148
+ ];
149
+ return {
150
+ project: absoluteProject,
151
+ home,
152
+ configRoot,
153
+ cacheRoot,
154
+ globalConfig: join(configRoot, "opencode.json"),
155
+ projectConfig: join(absoluteProject, "opencode.json"),
156
+ globalPluginDirectory: join(configRoot, "plugins"),
157
+ projectPluginDirectory: join(absoluteProject, ".opencode", "plugins"),
158
+ globalConfigFiles,
159
+ projectConfigFiles,
160
+ projectDirectoryConfigFiles,
161
+ customConfigFile,
162
+ customConfigDirectory,
163
+ customDirectoryConfigFiles,
164
+ managedConfigRoot,
165
+ managedConfigFiles,
166
+ pluginDirectories,
167
+ managedPreferencePaths: options.managedPreferencePaths?.map(normalizedInjectedRoot) ?? systemManagedPreferencePaths(platform, home),
168
+ hasInlineConfig: Boolean(env.OPENCODE_CONFIG_CONTENT?.trim()),
169
+ packageCacheRoot: join(cacheRoot, "packages"),
170
+ journalRoot: join(configRoot, "flow-activation-recovery"),
171
+ globalWrapperRecoveryRoot: join(configRoot, "flow-activation-recovery"),
172
+ projectWrapperRecoveryRoot: join(absoluteProject, ".opencode", "flow-activation-recovery"),
173
+ cacheRecoveryRoot: join(cacheRoot, "flow-activation-recovery")
174
+ };
175
+ }
176
+ async function optionalLstat(path) {
177
+ try {
178
+ return await lstat(path);
179
+ } catch (error) {
180
+ if (error.code === "ENOENT")
181
+ return null;
182
+ throw error;
183
+ }
184
+ }
185
+ async function symlinkInPathRange(safetyRoot, target) {
186
+ const root = normalize(safetyRoot);
187
+ const destination = normalize(target);
188
+ const pathFromRoot = relative(root, destination);
189
+ if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
190
+ throw new Error(`${destination} is outside its mutation safety root ${root}`);
191
+ }
192
+ let current = root;
193
+ const parts = pathFromRoot ? pathFromRoot.split(sep) : [];
194
+ for (let index = -1;index < parts.length; index += 1) {
195
+ if (index >= 0) {
196
+ const part = parts[index];
197
+ if (!part)
198
+ continue;
199
+ current = join(current, part);
200
+ }
201
+ const metadata = await optionalLstat(current);
202
+ if (!metadata)
203
+ return null;
204
+ if (metadata.isSymbolicLink())
205
+ return current;
206
+ }
207
+ return null;
208
+ }
209
+ async function assertSafeMutationPath(safetyRoot, target) {
210
+ const symlink = await symlinkInPathRange(safetyRoot, target);
211
+ if (symlink) {
212
+ throw new Error(`${target}: mutation refused because ancestor ${symlink} is a symbolic link`);
213
+ }
214
+ }
215
+ async function readRegularFileWithoutFollowing(path, maximumBytes) {
216
+ const pathMetadata = await lstat(path);
217
+ if (pathMetadata.isSymbolicLink()) {
218
+ throw new Error("symbolic link refused");
219
+ }
220
+ if (!pathMetadata.isFile())
221
+ throw new Error("not a regular file");
222
+ if (maximumBytes !== undefined && pathMetadata.size > maximumBytes) {
223
+ throw new Error(`file exceeds ${maximumBytes} bytes`);
224
+ }
225
+ const handle = await open(path, constants.O_RDONLY | NO_FOLLOW);
226
+ try {
227
+ const metadata = await handle.stat();
228
+ if (!metadata.isFile())
229
+ throw new Error("not a regular file");
230
+ if (metadata.dev !== pathMetadata.dev || metadata.ino !== pathMetadata.ino) {
231
+ throw new Error("file changed while activation was running");
232
+ }
233
+ return await handle.readFile({ encoding: "utf8" });
234
+ } catch (error) {
235
+ if (error.code === "ELOOP") {
236
+ throw new Error("symbolic link refused");
237
+ }
238
+ throw error;
239
+ } finally {
240
+ await handle.close();
241
+ }
242
+ }
243
+ function configDescriptors(paths) {
244
+ const descriptors = [
245
+ ...paths.globalConfigFiles.map((path) => ({
246
+ source: "global-config",
247
+ scope: "global",
248
+ path,
249
+ mutable: true,
250
+ safetyRoot: dirname(paths.configRoot),
251
+ manualRemediation: `edit ${path} manually`
252
+ })),
253
+ ...paths.customConfigFile ? [
254
+ {
255
+ source: "custom-config",
256
+ scope: "custom",
257
+ path: paths.customConfigFile,
258
+ mutable: true,
259
+ safetyRoot: dirname(paths.customConfigFile),
260
+ manualRemediation: `edit OPENCODE_CONFIG file ${paths.customConfigFile} manually`
261
+ }
262
+ ] : [],
263
+ ...paths.projectConfigFiles.map((path) => ({
264
+ source: "project-config",
265
+ scope: "project",
266
+ path,
267
+ mutable: true,
268
+ safetyRoot: paths.project,
269
+ manualRemediation: `edit ${path} manually`
270
+ })),
271
+ ...paths.projectDirectoryConfigFiles.map((path) => ({
272
+ source: "project-directory-config",
273
+ scope: "project",
274
+ path,
275
+ mutable: true,
276
+ safetyRoot: paths.project,
277
+ manualRemediation: `edit ${path} manually`
278
+ })),
279
+ ...paths.customDirectoryConfigFiles.map((path) => ({
280
+ source: "custom-directory-config",
281
+ scope: "custom",
282
+ path,
283
+ mutable: true,
284
+ safetyRoot: dirname(paths.customConfigDirectory),
285
+ manualRemediation: `edit OPENCODE_CONFIG_DIR file ${path} manually`
286
+ })),
287
+ ...paths.managedConfigFiles.map((path) => ({
288
+ source: "managed-config",
289
+ scope: "managed",
290
+ path,
291
+ mutable: false,
292
+ safetyRoot: dirname(paths.managedConfigRoot),
293
+ manualRemediation: `ask the OpenCode administrator to remove the Flow entry from ${path}`
294
+ }))
295
+ ];
296
+ const unique = new Map;
297
+ for (const descriptor of descriptors) {
298
+ const previous = unique.get(descriptor.path);
299
+ if (!previous || !descriptor.mutable && previous.mutable) {
300
+ unique.set(descriptor.path, descriptor);
301
+ }
302
+ }
303
+ return [...unique.values()];
304
+ }
305
+ function pluginDirectoryDescriptors(paths) {
306
+ const unique = new Map;
307
+ for (const descriptor of paths.pluginDirectories) {
308
+ if (!unique.has(descriptor.path))
309
+ unique.set(descriptor.path, descriptor);
310
+ }
311
+ return [...unique.values()];
312
+ }
313
+ function parseFlowNpmSpecifier(specifier) {
314
+ const match = FLOW_NPM_SPECIFIER_PATTERN.exec(specifier);
315
+ if (!match)
316
+ return { isFlow: false, version: null };
317
+ const candidate = match[1];
318
+ return {
319
+ isFlow: true,
320
+ version: candidate && isExactFlowVersion(candidate) ? candidate : null
321
+ };
322
+ }
323
+ function pluginSpecifier(entry) {
324
+ return Array.isArray(entry) ? entry[0] : entry;
325
+ }
326
+ function isPluginOptions(value) {
327
+ return typeof value === "object" && value !== null && !Array.isArray(value);
328
+ }
329
+ function parsePluginSpecs(value) {
330
+ if (value === undefined)
331
+ return [];
332
+ if (!Array.isArray(value)) {
333
+ throw new Error("config plugin must be an array");
334
+ }
335
+ return value.map((entry, index) => {
336
+ if (typeof entry === "string")
337
+ return entry;
338
+ if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && isPluginOptions(entry[1])) {
339
+ return [entry[0], entry[1]];
340
+ }
341
+ throw new Error(`config plugin[${index}] must be a string or [specifier, options] tuple`);
342
+ });
343
+ }
344
+ function stripJsoncComments(content) {
345
+ let output = "";
346
+ let inString = false;
347
+ let escaped = false;
348
+ for (let index = 0;index < content.length; index += 1) {
349
+ const character = content[index];
350
+ if (!character)
351
+ continue;
352
+ if (inString) {
353
+ output += character;
354
+ if (escaped)
355
+ escaped = false;
356
+ else if (character === "\\")
357
+ escaped = true;
358
+ else if (character === '"')
359
+ inString = false;
360
+ continue;
361
+ }
362
+ if (character === '"') {
363
+ inString = true;
364
+ output += character;
365
+ continue;
366
+ }
367
+ const next = content[index + 1];
368
+ if (character === "/" && next === "/") {
369
+ output += " ";
370
+ index += 2;
371
+ while (index < content.length) {
372
+ const commentCharacter = content[index];
373
+ if (commentCharacter === `
374
+ ` || commentCharacter === "\r") {
375
+ index -= 1;
376
+ break;
377
+ }
378
+ output += " ";
379
+ index += 1;
380
+ }
381
+ continue;
382
+ }
383
+ if (character === "/" && next === "*") {
384
+ output += " ";
385
+ index += 2;
386
+ let closed = false;
387
+ while (index < content.length) {
388
+ const commentCharacter = content[index];
389
+ const following = content[index + 1];
390
+ if (commentCharacter === "*" && following === "/") {
391
+ output += " ";
392
+ index += 1;
393
+ closed = true;
394
+ break;
395
+ }
396
+ output += commentCharacter === `
397
+ ` || commentCharacter === "\r" ? commentCharacter : " ";
398
+ index += 1;
399
+ }
400
+ if (!closed)
401
+ throw new Error("unterminated block comment");
402
+ continue;
403
+ }
404
+ output += character;
405
+ }
406
+ if (inString)
407
+ throw new Error("unterminated JSON string");
408
+ return output;
409
+ }
410
+ function removeJsoncTrailingCommas(content) {
411
+ let output = "";
412
+ let inString = false;
413
+ let escaped = false;
414
+ for (let index = 0;index < content.length; index += 1) {
415
+ const character = content[index];
416
+ if (!character)
417
+ continue;
418
+ if (inString) {
419
+ output += character;
420
+ if (escaped)
421
+ escaped = false;
422
+ else if (character === "\\")
423
+ escaped = true;
424
+ else if (character === '"')
425
+ inString = false;
426
+ continue;
427
+ }
428
+ if (character === '"') {
429
+ inString = true;
430
+ output += character;
431
+ continue;
432
+ }
433
+ if (character === ",") {
434
+ let lookahead = index + 1;
435
+ while (/\s/.test(content[lookahead] ?? ""))
436
+ lookahead += 1;
437
+ if (content[lookahead] === "}" || content[lookahead] === "]") {
438
+ output += " ";
439
+ continue;
440
+ }
441
+ }
442
+ output += character;
443
+ }
444
+ return output;
445
+ }
446
+ function parseConfigContent(content) {
447
+ let parsed;
448
+ let format = "strict-json";
449
+ try {
450
+ parsed = JSON.parse(content);
451
+ } catch {
452
+ format = "jsonc";
453
+ parsed = JSON.parse(removeJsoncTrailingCommas(stripJsoncComments(content)));
454
+ }
455
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
456
+ throw new Error("config root must be a JSON object");
457
+ }
458
+ const value = parsed;
459
+ return { value, plugin: parsePluginSpecs(value.plugin), format };
460
+ }
461
+ function looksLikeFlowPath(path) {
462
+ const name = basename(path);
463
+ return /opencode-plugin-flow/i.test(path) || /(?:^|[-_.])flow(?:[-_.].*)?(?:plugin|wrapper)|(?:plugin|wrapper).*flow/i.test(name);
464
+ }
465
+ function parseOwnedWrapper(content) {
466
+ if (!content.startsWith(OWNED_WRAPPER_MARKER)) {
467
+ return { kind: "not-owned" };
468
+ }
469
+ const firstNewline = content.indexOf(`
470
+ `);
471
+ const secondNewline = content.indexOf(`
472
+ `, firstNewline + 1);
473
+ const thirdNewline = content.indexOf(`
474
+ `, secondNewline + 1);
475
+ if (firstNewline < 0 || secondNewline < 0 || thirdNewline < 0) {
476
+ return { kind: "invalid", reason: "owned wrapper marker is incomplete" };
477
+ }
478
+ const versionLine = content.slice(firstNewline + 1, secondNewline);
479
+ const hashLine = content.slice(secondNewline + 1, thirdNewline);
480
+ const version = /^\/\/ version=(.+)$/.exec(versionLine)?.[1];
481
+ const expectedHash = /^\/\/ body-sha256=([a-f0-9]{64})$/.exec(hashLine)?.[1];
482
+ if (!version || !isExactFlowVersion(version)) {
483
+ return { kind: "invalid", reason: "owned wrapper version is invalid" };
484
+ }
485
+ if (!expectedHash) {
486
+ return { kind: "invalid", reason: "owned wrapper hash is invalid" };
487
+ }
488
+ const body = content.slice(thirdNewline + 1);
489
+ if (sha256(body) !== expectedHash) {
490
+ return { kind: "invalid", reason: "owned wrapper was edited" };
491
+ }
492
+ if (!body.includes(`${FLOW_PACKAGE_NAME}@${version}`)) {
493
+ return {
494
+ kind: "invalid",
495
+ reason: "owned wrapper body does not reference its declared Flow version"
496
+ };
497
+ }
498
+ return { kind: "owned", version };
499
+ }
500
+ function hintedFlowVersion(content) {
501
+ const candidate = new RegExp(`${FLOW_PACKAGE_NAME.replaceAll("-", "\\-")}@([^/\\s"']+)`).exec(content)?.[1];
502
+ return candidate && isExactFlowVersion(candidate) ? candidate : null;
503
+ }
504
+ async function classifyLocalPlugin(path, source, scope, target, specifier) {
505
+ const metadata = await optionalLstat(path);
506
+ if (!metadata) {
507
+ if (!looksLikeFlowPath(specifier))
508
+ return null;
509
+ return {
510
+ source,
511
+ scope,
512
+ path,
513
+ specifier,
514
+ resolvedVersion: null,
515
+ ownership: "unknown-flow-like",
516
+ status: "refused",
517
+ reason: "referenced local plugin is absent"
518
+ };
519
+ }
520
+ if (metadata.isSymbolicLink()) {
521
+ if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
522
+ return null;
523
+ return {
524
+ source,
525
+ scope,
526
+ path,
527
+ specifier,
528
+ resolvedVersion: null,
529
+ ownership: "unknown-flow-like",
530
+ status: "refused",
531
+ reason: "symbolic link refused"
532
+ };
533
+ }
534
+ if (!metadata.isFile()) {
535
+ if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
536
+ return null;
537
+ return {
538
+ source,
539
+ scope,
540
+ path,
541
+ specifier,
542
+ resolvedVersion: null,
543
+ ownership: "unknown-flow-like",
544
+ status: "refused",
545
+ reason: "local plugin is not a regular file"
546
+ };
547
+ }
548
+ let content;
549
+ try {
550
+ content = await readRegularFileWithoutFollowing(path, MAX_LOCAL_PLUGIN_BYTES);
551
+ } catch (error) {
552
+ if (!looksLikeFlowPath(specifier) && !looksLikeFlowPath(path))
553
+ return null;
554
+ return {
555
+ source,
556
+ scope,
557
+ path,
558
+ specifier,
559
+ resolvedVersion: null,
560
+ ownership: "unknown-flow-like",
561
+ status: "refused",
562
+ reason: error instanceof Error ? error.message : String(error)
563
+ };
564
+ }
565
+ const owned = parseOwnedWrapper(content);
566
+ if (owned.kind === "owned") {
567
+ return {
568
+ source,
569
+ scope,
570
+ path,
571
+ specifier,
572
+ resolvedVersion: owned.version,
573
+ ownership: "marker-owned-wrapper",
574
+ status: "conflict",
575
+ reason: owned.version === target ? "local wrapper duplicates the canonical npm activation source" : "local wrapper activates another Flow version"
576
+ };
577
+ }
578
+ const flowLike = looksLikeFlowPath(specifier) || looksLikeFlowPath(path) || content.includes(FLOW_PACKAGE_NAME) || content.includes(OWNED_WRAPPER_MARKER);
579
+ if (!flowLike)
580
+ return null;
581
+ return {
582
+ source,
583
+ scope,
584
+ path,
585
+ specifier,
586
+ resolvedVersion: hintedFlowVersion(content),
587
+ ownership: "unknown-flow-like",
588
+ status: "refused",
589
+ reason: owned.kind === "invalid" ? owned.reason : "Flow-like local plugin has no verifiable ownership marker"
590
+ };
591
+ }
592
+ function relativeLocalSpecifier(configPath, specifier) {
593
+ if (specifier.startsWith("file:"))
594
+ return fileURLToPath(specifier);
595
+ if (isAbsolute(specifier))
596
+ return normalize(specifier);
597
+ return resolve(dirname(configPath), specifier);
598
+ }
599
+ function isLocalPluginSpecifier(specifier) {
600
+ return specifier.startsWith(".") || specifier.startsWith("file:") || isAbsolute(specifier);
601
+ }
602
+ async function inspectConfig(descriptor, target) {
603
+ const records = [];
604
+ const issues = [];
605
+ const symlinkAncestor = await symlinkInPathRange(descriptor.safetyRoot, descriptor.path);
606
+ if (symlinkAncestor) {
607
+ issues.push({
608
+ source: descriptor.source,
609
+ path: descriptor.path,
610
+ code: "unsafe-symlink",
611
+ message: `config ancestor ${symlinkAncestor} is a symbolic link`
612
+ });
613
+ return { records, issues };
614
+ }
615
+ const metadata = await optionalLstat(descriptor.path);
616
+ if (!metadata)
617
+ return { records, issues };
618
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
619
+ issues.push({
620
+ source: descriptor.source,
621
+ path: descriptor.path,
622
+ code: "invalid-config",
623
+ message: metadata.isSymbolicLink() ? "config symbolic link refused" : "config is not a regular file"
624
+ });
625
+ return { records, issues };
626
+ }
627
+ let content;
628
+ try {
629
+ content = await readRegularFileWithoutFollowing(descriptor.path);
630
+ } catch (error) {
631
+ issues.push({
632
+ source: descriptor.source,
633
+ path: descriptor.path,
634
+ code: "invalid-config",
635
+ message: error instanceof Error ? error.message : String(error)
636
+ });
637
+ return { records, issues };
638
+ }
639
+ let parsed;
640
+ try {
641
+ parsed = parseConfigContent(content);
642
+ } catch (error) {
643
+ issues.push({
644
+ source: descriptor.source,
645
+ path: descriptor.path,
646
+ code: "invalid-config",
647
+ message: `config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`
648
+ });
649
+ return { records, issues };
650
+ }
651
+ for (const entry of parsed.plugin) {
652
+ const specifier = pluginSpecifier(entry);
653
+ const npm = parseFlowNpmSpecifier(specifier);
654
+ if (npm.isFlow) {
655
+ const record = {
656
+ source: descriptor.source,
657
+ scope: descriptor.scope,
658
+ path: descriptor.path,
659
+ specifier,
660
+ resolvedVersion: npm.version,
661
+ ownership: "flow-npm",
662
+ status: npm.version === target ? "target" : "conflict"
663
+ };
664
+ if (npm.version === null) {
665
+ record.reason = "Flow npm activation is not pinned to an exact version";
666
+ } else if (npm.version !== target) {
667
+ record.reason = "Flow npm activation targets another version";
668
+ }
669
+ records.push(record);
670
+ continue;
671
+ }
672
+ if (!looksLikeFlowPath(specifier) && !isLocalPluginSpecifier(specifier)) {
673
+ continue;
674
+ }
675
+ let localPath;
676
+ try {
677
+ localPath = relativeLocalSpecifier(descriptor.path, specifier);
678
+ } catch {
679
+ if (!looksLikeFlowPath(specifier))
680
+ continue;
681
+ records.push({
682
+ source: descriptor.source,
683
+ scope: descriptor.scope,
684
+ path: specifier,
685
+ specifier,
686
+ resolvedVersion: null,
687
+ ownership: "unknown-flow-like",
688
+ status: "refused",
689
+ reason: "local Flow-like plugin specifier is invalid"
690
+ });
691
+ continue;
692
+ }
693
+ const local = await classifyLocalPlugin(localPath, descriptor.source, descriptor.scope, target, specifier);
694
+ if (local)
695
+ records.push(local);
696
+ }
697
+ return { records, issues };
698
+ }
699
+ async function inspectInlineConfig(content, project, target) {
700
+ const records = [];
701
+ const issues = [];
702
+ let parsed;
703
+ try {
704
+ parsed = parseConfigContent(content);
705
+ } catch (error) {
706
+ issues.push({
707
+ source: "inline-config",
708
+ path: "env:OPENCODE_CONFIG_CONTENT",
709
+ code: "invalid-config",
710
+ message: `inline config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`
711
+ });
712
+ return { records, issues };
713
+ }
714
+ const virtualConfigPath = join(project, "opencode.inline.json");
715
+ for (const entry of parsed.plugin) {
716
+ const specifier = pluginSpecifier(entry);
717
+ const npm = parseFlowNpmSpecifier(specifier);
718
+ if (npm.isFlow) {
719
+ const record = {
720
+ source: "inline-config",
721
+ scope: "inline",
722
+ path: "env:OPENCODE_CONFIG_CONTENT",
723
+ specifier,
724
+ resolvedVersion: npm.version,
725
+ ownership: "flow-npm",
726
+ status: npm.version === target ? "target" : "conflict"
727
+ };
728
+ if (npm.version === null) {
729
+ record.reason = "Flow npm activation is not pinned to an exact version";
730
+ } else if (npm.version !== target) {
731
+ record.reason = "Flow npm activation targets another version";
732
+ }
733
+ records.push(record);
734
+ continue;
735
+ }
736
+ if (!looksLikeFlowPath(specifier) && !isLocalPluginSpecifier(specifier)) {
737
+ continue;
738
+ }
739
+ let localPath;
740
+ try {
741
+ localPath = relativeLocalSpecifier(virtualConfigPath, specifier);
742
+ } catch {
743
+ if (!looksLikeFlowPath(specifier))
744
+ continue;
745
+ records.push({
746
+ source: "inline-config",
747
+ scope: "inline",
748
+ path: "env:OPENCODE_CONFIG_CONTENT",
749
+ specifier,
750
+ resolvedVersion: null,
751
+ ownership: "unknown-flow-like",
752
+ status: "refused",
753
+ reason: "inline local Flow-like plugin specifier is invalid"
754
+ });
755
+ continue;
756
+ }
757
+ const local = await classifyLocalPlugin(localPath, "inline-config", "inline", target, specifier);
758
+ if (local)
759
+ records.push(local);
760
+ }
761
+ return { records, issues };
762
+ }
763
+ async function inspectPluginDirectory(descriptor, target) {
764
+ const records = [];
765
+ const issues = [];
766
+ const symlinkAncestor = await symlinkInPathRange(descriptor.safetyRoot, descriptor.path);
767
+ if (symlinkAncestor) {
768
+ issues.push({
769
+ source: descriptor.source,
770
+ path: descriptor.path,
771
+ code: "unsafe-symlink",
772
+ message: `plugin directory ancestor ${symlinkAncestor} is a symbolic link`
773
+ });
774
+ return { records, issues };
775
+ }
776
+ const metadata = await optionalLstat(descriptor.path);
777
+ if (!metadata)
778
+ return { records, issues };
779
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
780
+ issues.push({
781
+ source: descriptor.source,
782
+ path: descriptor.path,
783
+ code: "invalid-plugin-directory",
784
+ message: metadata.isSymbolicLink() ? "plugin directory symbolic link refused" : "plugin directory is not a real directory"
785
+ });
786
+ return { records, issues };
787
+ }
788
+ const entries = await readdir(descriptor.path, { withFileTypes: true });
789
+ for (const entry of entries) {
790
+ if (!LOCAL_PLUGIN_EXTENSION_PATTERN.test(entry.name))
791
+ continue;
792
+ const path = join(descriptor.path, entry.name);
793
+ const record = await classifyLocalPlugin(path, descriptor.source, descriptor.scope, target, path);
794
+ if (record)
795
+ records.push(record);
796
+ }
797
+ return { records, issues };
798
+ }
799
+ async function inspectCacheArtifact(path, specifier, target) {
800
+ const metadata = await optionalLstat(path);
801
+ if (!metadata?.isDirectory() || metadata.isSymbolicLink()) {
802
+ return {
803
+ path,
804
+ specifier,
805
+ resolvedVersion: null,
806
+ status: "ambiguous",
807
+ reason: "cache artifact is not a real directory"
808
+ };
809
+ }
810
+ const nodeModulesPath = join(path, "node_modules");
811
+ const packagePath = join(nodeModulesPath, FLOW_PACKAGE_NAME);
812
+ const manifestPath = join(packagePath, "package.json");
813
+ try {
814
+ for (const directory of [nodeModulesPath, packagePath]) {
815
+ const directoryMetadata = await lstat(directory);
816
+ if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
817
+ throw new Error("nested package path is not a real directory");
818
+ }
819
+ }
820
+ const manifest = JSON.parse(await readRegularFileWithoutFollowing(manifestPath));
821
+ if (manifest.name !== FLOW_PACKAGE_NAME || typeof manifest.version !== "string" || !isExactFlowVersion(manifest.version)) {
822
+ throw new Error("nested package manifest does not prove a Flow version");
823
+ }
824
+ return {
825
+ path,
826
+ specifier,
827
+ resolvedVersion: manifest.version,
828
+ status: manifest.version === target ? "target" : "inactive"
829
+ };
830
+ } catch (error) {
831
+ return {
832
+ path,
833
+ specifier,
834
+ resolvedVersion: null,
835
+ status: "ambiguous",
836
+ reason: error instanceof Error ? error.message : String(error)
837
+ };
838
+ }
839
+ }
840
+ async function inspectCache(paths, target) {
841
+ const artifacts = [];
842
+ const issues = [];
843
+ const symlinkAncestor = await symlinkInPathRange(dirname(paths.cacheRoot), paths.packageCacheRoot);
844
+ if (symlinkAncestor) {
845
+ issues.push({
846
+ source: "cache",
847
+ path: paths.packageCacheRoot,
848
+ code: "unsafe-symlink",
849
+ message: `cache ancestor ${symlinkAncestor} is a symbolic link`
850
+ });
851
+ return { artifacts, issues };
852
+ }
853
+ const metadata = await optionalLstat(paths.packageCacheRoot);
854
+ if (!metadata)
855
+ return { artifacts, issues };
856
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
857
+ issues.push({
858
+ source: "cache",
859
+ path: paths.packageCacheRoot,
860
+ code: "ambiguous-cache-artifact",
861
+ message: "OpenCode package cache root is not a real directory"
862
+ });
863
+ return { artifacts, issues };
864
+ }
865
+ for (const entry of await readdir(paths.packageCacheRoot, {
866
+ withFileTypes: true
867
+ })) {
868
+ if (entry.name !== FLOW_PACKAGE_NAME && !entry.name.startsWith(`${FLOW_PACKAGE_NAME}@`)) {
869
+ continue;
870
+ }
871
+ const artifact = await inspectCacheArtifact(join(paths.packageCacheRoot, entry.name), entry.name, target);
872
+ artifacts.push(artifact);
873
+ if (artifact.status === "ambiguous") {
874
+ issues.push({
875
+ source: "cache",
876
+ path: artifact.path,
877
+ code: "ambiguous-cache-artifact",
878
+ message: artifact.reason ?? "cache artifact does not prove a Flow version"
879
+ });
880
+ }
881
+ }
882
+ return { artifacts, issues };
883
+ }
884
+ async function activationLimitations(paths) {
885
+ const limitations = [
886
+ {
887
+ source: "remote-config",
888
+ coverage: "runtime-leadership",
889
+ blocking: false,
890
+ detail: "Authenticated .well-known and organization API configs cannot be inspected offline; Flow runtime leadership detects duplicate loaded versions and fails closed."
891
+ }
892
+ ];
893
+ if (paths.managedPreferencePaths.length > 0) {
894
+ const detected = [];
895
+ for (const path of paths.managedPreferencePaths) {
896
+ try {
897
+ if (await optionalLstat(path))
898
+ detected.push(path);
899
+ } catch {
900
+ detected.push(`${path} (unreadable)`);
901
+ }
902
+ }
903
+ limitations.push({
904
+ source: "managed-preferences",
905
+ coverage: "runtime-leadership",
906
+ blocking: false,
907
+ 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 duplicates.` : "macOS MDM preferences may add runtime config outside readable JSON/JSONC files; Flow runtime leadership fails closed on duplicate loaded versions."
908
+ });
909
+ }
910
+ return limitations;
911
+ }
912
+ function activationReasons(records, cacheArtifacts, issues, target) {
913
+ const reasons = issues.map((issue) => `${issue.path}: ${issue.message}`);
914
+ const targetPins = records.filter((record) => record.ownership === "flow-npm" && record.resolvedVersion === target && record.status === "target");
915
+ if (targetPins.length !== 1) {
916
+ reasons.push(`expected one exact ${FLOW_PACKAGE_NAME}@${target} activation, found ${targetPins.length}`);
917
+ }
918
+ if (records.length !== 1) {
919
+ reasons.push(`expected one Flow activation source, found ${records.length}`);
920
+ }
921
+ const inactive = cacheArtifacts.filter((artifact) => artifact.status === "inactive");
922
+ if (inactive.length > 0) {
923
+ reasons.push(`found ${inactive.length} inactive Flow cache artifact(s)`);
924
+ }
925
+ return [...new Set(reasons)];
926
+ }
927
+ async function checkFlowActivation(options) {
928
+ const target = resolveActivationTarget(options.target);
929
+ const paths = resolveActivationPaths(options.project, options.paths);
930
+ const env = options.paths?.env ?? process.env;
931
+ const records = [];
932
+ const issues = [];
933
+ try {
934
+ const projectMetadata = await lstat(paths.project);
935
+ if (projectMetadata.isSymbolicLink()) {
936
+ issues.push({
937
+ source: "project",
938
+ path: paths.project,
939
+ code: "unsafe-symlink",
940
+ message: "project root symbolic link refused for activation mutation safety"
941
+ });
942
+ } else if (!projectMetadata.isDirectory()) {
943
+ issues.push({
944
+ source: "project",
945
+ path: paths.project,
946
+ code: "invalid-project",
947
+ message: "project path is not a directory"
948
+ });
949
+ }
950
+ } catch (error) {
951
+ issues.push({
952
+ source: "project",
953
+ path: paths.project,
954
+ code: "invalid-project",
955
+ message: error.code === "ENOENT" ? "project path does not exist" : error instanceof Error ? error.message : String(error)
956
+ });
957
+ }
958
+ for (const descriptor of configDescriptors(paths)) {
959
+ try {
960
+ const inspected = await inspectConfig(descriptor, target);
961
+ records.push(...inspected.records);
962
+ issues.push(...inspected.issues);
963
+ } catch (error) {
964
+ issues.push({
965
+ source: descriptor.source,
966
+ path: descriptor.path,
967
+ code: "invalid-config",
968
+ message: `config could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
969
+ });
970
+ }
971
+ }
972
+ if (env.OPENCODE_CONFIG_CONTENT?.trim()) {
973
+ const inspected = await inspectInlineConfig(env.OPENCODE_CONFIG_CONTENT, paths.project, target);
974
+ records.push(...inspected.records);
975
+ issues.push(...inspected.issues);
976
+ }
977
+ for (const descriptor of pluginDirectoryDescriptors(paths)) {
978
+ try {
979
+ const inspected = await inspectPluginDirectory(descriptor, target);
980
+ records.push(...inspected.records);
981
+ issues.push(...inspected.issues);
982
+ } catch (error) {
983
+ issues.push({
984
+ source: descriptor.source,
985
+ path: descriptor.path,
986
+ code: "invalid-plugin-directory",
987
+ message: `plugin directory could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
988
+ });
989
+ }
990
+ }
991
+ let cache;
992
+ try {
993
+ cache = await inspectCache(paths, target);
994
+ } catch (error) {
995
+ cache = {
996
+ artifacts: [],
997
+ issues: [
998
+ {
999
+ source: "cache",
1000
+ path: paths.packageCacheRoot,
1001
+ code: "ambiguous-cache-artifact",
1002
+ message: `package cache could not be inspected safely: ${error instanceof Error ? error.message : String(error)}`
1003
+ }
1004
+ ]
1005
+ };
1006
+ }
1007
+ issues.push(...cache.issues);
1008
+ const limitations = await activationLimitations(paths);
1009
+ const reasons = activationReasons(records, cache.artifacts, issues, target);
1010
+ return {
1011
+ mode: "check",
1012
+ project: paths.project,
1013
+ target,
1014
+ paths,
1015
+ records,
1016
+ cacheArtifacts: cache.artifacts,
1017
+ issues,
1018
+ limitations,
1019
+ singleVersionSatisfied: reasons.length === 0,
1020
+ reasons
1021
+ };
1022
+ }
1023
+ function detectIndent(content) {
1024
+ return /\n([ \t]+)"/.exec(content)?.[1] ?? "\t";
1025
+ }
1026
+ async function readConfigSnapshot(descriptor) {
1027
+ const metadata = await optionalLstat(descriptor.path);
1028
+ if (!metadata) {
1029
+ return {
1030
+ descriptor,
1031
+ exists: false,
1032
+ content: null,
1033
+ digest: null,
1034
+ value: {},
1035
+ plugin: [],
1036
+ format: "strict-json",
1037
+ indent: "\t",
1038
+ newline: `
1039
+ `,
1040
+ finalNewline: true,
1041
+ mode: 384
1042
+ };
1043
+ }
1044
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1045
+ throw new Error(`${descriptor.path}: config must be a regular file`);
1046
+ }
1047
+ await assertSafeMutationPath(descriptor.safetyRoot, descriptor.path);
1048
+ const content = await readRegularFileWithoutFollowing(descriptor.path);
1049
+ let parsed;
1050
+ try {
1051
+ parsed = parseConfigContent(content);
1052
+ } catch (error) {
1053
+ throw new Error(`${descriptor.path}: config cannot be conservatively parsed as JSON/JSONC: ${error instanceof Error ? error.message : String(error)}`);
1054
+ }
1055
+ return {
1056
+ descriptor,
1057
+ exists: true,
1058
+ content,
1059
+ digest: sha256(content),
1060
+ value: parsed.value,
1061
+ plugin: parsed.plugin,
1062
+ format: parsed.format,
1063
+ indent: detectIndent(content),
1064
+ newline: content.includes(`\r
1065
+ `) ? `\r
1066
+ ` : `
1067
+ `,
1068
+ finalNewline: content.endsWith(`
1069
+ `),
1070
+ mode: metadata.mode & 511
1071
+ };
1072
+ }
1073
+ function updatedConfigContent(snapshot, entries) {
1074
+ const value = { ...snapshot.value, plugin: entries };
1075
+ let content = JSON.stringify(value, null, snapshot.indent).replaceAll(`
1076
+ `, snapshot.newline);
1077
+ if (snapshot.finalNewline)
1078
+ content += snapshot.newline;
1079
+ return content;
1080
+ }
1081
+ function wrapperRecoveryRoot(paths, wrapper) {
1082
+ if ([
1083
+ "global-config",
1084
+ "project-config",
1085
+ "project-directory-config",
1086
+ "custom-config",
1087
+ "custom-directory-config"
1088
+ ].includes(wrapper.source)) {
1089
+ return join(dirname(wrapper.path), ".flow-activation-recovery");
1090
+ }
1091
+ if (wrapper.source === "home-plugin-directory") {
1092
+ return join(paths.home, ".opencode", "flow-activation-recovery");
1093
+ }
1094
+ if (wrapper.scope === "global")
1095
+ return paths.globalWrapperRecoveryRoot;
1096
+ if (wrapper.scope === "custom" && paths.customConfigDirectory) {
1097
+ return join(paths.customConfigDirectory, "flow-activation-recovery");
1098
+ }
1099
+ return paths.projectWrapperRecoveryRoot;
1100
+ }
1101
+ function wrapperMutationSafetyRoot(paths, wrapper) {
1102
+ const directory = pluginDirectoryDescriptors(paths).find((descriptor) => {
1103
+ if (descriptor.source !== wrapper.source)
1104
+ return false;
1105
+ const fromDirectory = relative(descriptor.path, wrapper.path);
1106
+ return fromDirectory !== ".." && !fromDirectory.startsWith(`..${sep}`) && !isAbsolute(fromDirectory);
1107
+ });
1108
+ if (directory)
1109
+ return directory.safetyRoot;
1110
+ const config = configDescriptors(paths).find((descriptor) => descriptor.source === wrapper.source);
1111
+ return config?.safetyRoot ?? dirname(wrapper.path);
1112
+ }
1113
+ function uniqueOwnedWrappers(records) {
1114
+ const wrappers = new Map;
1115
+ for (const record of records) {
1116
+ if (record.ownership !== "marker-owned-wrapper" || record.resolvedVersion === null) {
1117
+ continue;
1118
+ }
1119
+ wrappers.set(record.path, {
1120
+ path: record.path,
1121
+ scope: record.scope,
1122
+ source: record.source,
1123
+ version: record.resolvedVersion
1124
+ });
1125
+ }
1126
+ return [...wrappers.values()];
1127
+ }
1128
+ function removableConfigEntry(entry, descriptor, records) {
1129
+ const specifier = pluginSpecifier(entry);
1130
+ if (parseFlowNpmSpecifier(specifier).isFlow)
1131
+ return true;
1132
+ if (!isLocalPluginSpecifier(specifier) && !looksLikeFlowPath(specifier)) {
1133
+ return false;
1134
+ }
1135
+ let localPath;
1136
+ try {
1137
+ localPath = relativeLocalSpecifier(descriptor.path, specifier);
1138
+ } catch {
1139
+ return false;
1140
+ }
1141
+ return records.some((record) => record.source === descriptor.source && record.specifier === specifier && record.path === localPath && record.ownership === "marker-owned-wrapper");
1142
+ }
1143
+ function activationRefusals(before) {
1144
+ return [
1145
+ ...before.issues.map((issue) => `${issue.path}: ${issue.message}`),
1146
+ ...before.records.filter((record) => record.ownership === "unknown-flow-like").map((record) => `${record.path}: ${record.reason ?? "unknown Flow-like activation refused"}`),
1147
+ ...before.cacheArtifacts.filter((artifact) => artifact.status === "ambiguous").map((artifact) => `${artifact.path}: ${artifact.reason ?? "ambiguous cache artifact refused"}`)
1148
+ ];
1149
+ }
1150
+ async function assertUnchangedConfig(snapshot) {
1151
+ await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
1152
+ const metadata = await optionalLstat(snapshot.descriptor.path);
1153
+ if (!snapshot.exists) {
1154
+ if (metadata) {
1155
+ throw new Error(`${snapshot.descriptor.path}: config appeared while activation was running`);
1156
+ }
1157
+ return;
1158
+ }
1159
+ if (!metadata?.isFile() || metadata.isSymbolicLink()) {
1160
+ throw new Error(`${snapshot.descriptor.path}: config changed while activation was running`);
1161
+ }
1162
+ const current = await readRegularFileWithoutFollowing(snapshot.descriptor.path);
1163
+ if (sha256(current) !== snapshot.digest) {
1164
+ throw new Error(`${snapshot.descriptor.path}: config changed while activation was running`);
1165
+ }
1166
+ }
1167
+ async function atomicWriteConfig(snapshot, content, runId) {
1168
+ await assertUnchangedConfig(snapshot);
1169
+ await mkdir(dirname(snapshot.descriptor.path), { recursive: true });
1170
+ await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
1171
+ const temporaryPath = join(dirname(snapshot.descriptor.path), `.${basename(snapshot.descriptor.path)}.flow-${runId}.tmp`);
1172
+ try {
1173
+ await writeFile(temporaryPath, content, {
1174
+ encoding: "utf8",
1175
+ flag: "wx",
1176
+ mode: snapshot.mode
1177
+ });
1178
+ await rename(temporaryPath, snapshot.descriptor.path);
1179
+ } finally {
1180
+ await rm(temporaryPath, { force: true });
1181
+ }
1182
+ }
1183
+ async function configIsWritable(snapshot) {
1184
+ if (!snapshot.descriptor.mutable)
1185
+ return false;
1186
+ try {
1187
+ await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
1188
+ if (snapshot.exists) {
1189
+ await access(snapshot.descriptor.path, constants.W_OK);
1190
+ return true;
1191
+ }
1192
+ let parent = dirname(snapshot.descriptor.path);
1193
+ while (!await optionalLstat(parent)) {
1194
+ const next = dirname(parent);
1195
+ if (next === parent)
1196
+ return false;
1197
+ parent = next;
1198
+ }
1199
+ await access(parent, constants.W_OK);
1200
+ return true;
1201
+ } catch {
1202
+ return false;
1203
+ }
1204
+ }
1205
+ async function pathCanBeMoved(source, destinationRoot) {
1206
+ try {
1207
+ await access(dirname(source), constants.W_OK);
1208
+ let parent = destinationRoot;
1209
+ while (!await optionalLstat(parent)) {
1210
+ const next = dirname(parent);
1211
+ if (next === parent)
1212
+ return false;
1213
+ parent = next;
1214
+ }
1215
+ await access(parent, constants.W_OK);
1216
+ return true;
1217
+ } catch {
1218
+ return false;
1219
+ }
1220
+ }
1221
+ async function writeJournal(journalPath, journal) {
1222
+ await mkdir(dirname(journalPath), { recursive: true, mode: 448 });
1223
+ const temporaryPath = `${journalPath}.${randomUUID()}.tmp`;
1224
+ try {
1225
+ await writeFile(temporaryPath, `${JSON.stringify(journal, null, 2)}
1226
+ `, {
1227
+ encoding: "utf8",
1228
+ flag: "wx",
1229
+ mode: 384
1230
+ });
1231
+ await rename(temporaryPath, journalPath);
1232
+ } finally {
1233
+ await rm(temporaryPath, { force: true });
1234
+ }
1235
+ }
1236
+ async function verifyOwnedWrapper(wrapper) {
1237
+ const content = await readRegularFileWithoutFollowing(wrapper.path, MAX_LOCAL_PLUGIN_BYTES);
1238
+ const parsed = parseOwnedWrapper(content);
1239
+ if (parsed.kind !== "owned" || parsed.version !== wrapper.version) {
1240
+ throw new Error(`${wrapper.path}: owned wrapper changed while activation was running`);
1241
+ }
1242
+ }
1243
+ async function verifyCacheArtifact(artifact, target) {
1244
+ const inspected = await inspectCacheArtifact(artifact.path, artifact.specifier, target);
1245
+ if (inspected.status !== "inactive" || inspected.resolvedVersion !== artifact.resolvedVersion) {
1246
+ throw new Error(`${artifact.path}: cache artifact changed while activation was running`);
1247
+ }
1248
+ }
1249
+ async function replaceKnownConfigContent(options) {
1250
+ await assertSafeMutationPath(options.snapshot.descriptor.safetyRoot, options.snapshot.descriptor.path);
1251
+ const current = await readRegularFileWithoutFollowing(options.snapshot.descriptor.path);
1252
+ if (sha256(current) !== options.expectedDigest) {
1253
+ throw new Error(`${options.snapshot.descriptor.path}: automatic restore refused because the applied config changed`);
1254
+ }
1255
+ const temporaryPath = join(dirname(options.snapshot.descriptor.path), `.${basename(options.snapshot.descriptor.path)}.restore-${options.runId}.tmp`);
1256
+ try {
1257
+ await writeFile(temporaryPath, options.content, {
1258
+ encoding: "utf8",
1259
+ flag: "wx",
1260
+ mode: options.mode
1261
+ });
1262
+ await rename(temporaryPath, options.snapshot.descriptor.path);
1263
+ } finally {
1264
+ await rm(temporaryPath, { force: true });
1265
+ }
1266
+ }
1267
+ async function rollbackCompletedActions(options) {
1268
+ const failures = [];
1269
+ for (const action of options.journal.actions.toReversed()) {
1270
+ if (action.state !== "complete")
1271
+ continue;
1272
+ try {
1273
+ if (action.action === "rewrite-config") {
1274
+ const snapshot = options.snapshots.find((candidate) => candidate.descriptor.path === action.path);
1275
+ if (!snapshot || !action.appliedDigest) {
1276
+ throw new Error("restore metadata is incomplete");
1277
+ }
1278
+ if (action.originalAbsent) {
1279
+ await assertSafeMutationPath(snapshot.descriptor.safetyRoot, action.path);
1280
+ const current = await readRegularFileWithoutFollowing(action.path);
1281
+ if (sha256(current) !== action.appliedDigest) {
1282
+ throw new Error("created config changed after apply; automatic removal refused");
1283
+ }
1284
+ if (!action.recoveryPath) {
1285
+ throw new Error("created config recovery path is missing");
1286
+ }
1287
+ await assertSafeMutationPath(snapshot.descriptor.safetyRoot, action.recoveryPath);
1288
+ await mkdir(dirname(action.recoveryPath), {
1289
+ recursive: true,
1290
+ mode: 448
1291
+ });
1292
+ await rename(action.path, action.recoveryPath);
1293
+ } else {
1294
+ if (!action.backupPath)
1295
+ throw new Error("config backup is missing");
1296
+ const backup = await readRegularFileWithoutFollowing(action.backupPath);
1297
+ await replaceKnownConfigContent({
1298
+ snapshot,
1299
+ expectedDigest: action.appliedDigest,
1300
+ content: backup,
1301
+ mode: action.originalMode ?? snapshot.mode,
1302
+ runId: options.runId
1303
+ });
1304
+ }
1305
+ } else if (action.action === "quarantine-wrapper" || action.action === "quarantine-cache") {
1306
+ if (!action.recoveryPath)
1307
+ throw new Error("recovery path is missing");
1308
+ let safetyRoot;
1309
+ if (action.action === "quarantine-cache") {
1310
+ safetyRoot = dirname(options.paths.cacheRoot);
1311
+ } else {
1312
+ const wrapper = options.wrappers.find((candidate) => candidate.path === action.path);
1313
+ if (!wrapper)
1314
+ throw new Error("wrapper safety metadata is missing");
1315
+ safetyRoot = wrapperMutationSafetyRoot(options.paths, wrapper);
1316
+ }
1317
+ await assertSafeMutationPath(safetyRoot, action.path);
1318
+ await assertSafeMutationPath(safetyRoot, action.recoveryPath);
1319
+ if (await optionalLstat(action.path)) {
1320
+ throw new Error("original path is occupied; automatic restore refused");
1321
+ }
1322
+ const recovery = await optionalLstat(action.recoveryPath);
1323
+ if (!recovery || recovery.isSymbolicLink()) {
1324
+ throw new Error("quarantined artifact is missing or symbolic");
1325
+ }
1326
+ await mkdir(dirname(action.path), { recursive: true });
1327
+ await rename(action.recoveryPath, action.path);
1328
+ }
1329
+ action.state = "rolled-back";
1330
+ } catch (error) {
1331
+ const message = error instanceof Error ? error.message : String(error);
1332
+ action.state = "rollback-failed";
1333
+ action.error = message;
1334
+ failures.push(`${action.path}: ${message}`);
1335
+ }
1336
+ try {
1337
+ await writeJournal(options.journalPath, options.journal);
1338
+ } catch (error) {
1339
+ failures.push(`journal update: ${error instanceof Error ? error.message : String(error)}`);
1340
+ }
1341
+ }
1342
+ return failures;
1343
+ }
1344
+ async function applyFlowActivation(options) {
1345
+ const target = resolveActivationTarget(options.target);
1346
+ const before = await checkFlowActivation({
1347
+ project: options.project,
1348
+ target,
1349
+ ...options.paths ? { paths: options.paths } : {}
1350
+ });
1351
+ const refusals = activationRefusals(before);
1352
+ const snapshots = [];
1353
+ for (const descriptor of configDescriptors(before.paths)) {
1354
+ try {
1355
+ snapshots.push(await readConfigSnapshot(descriptor));
1356
+ } catch (error) {
1357
+ refusals.push(error instanceof Error ? error.message : String(error));
1358
+ }
1359
+ }
1360
+ const pin = `${FLOW_PACKAGE_NAME}@${target}`;
1361
+ const canonicalPath = options.scope === "global" ? before.paths.globalConfig : before.paths.projectConfig;
1362
+ const nextEntries = new Map;
1363
+ for (const snapshot of snapshots) {
1364
+ const retained = snapshot.plugin.filter((entry) => !removableConfigEntry(entry, snapshot.descriptor, before.records));
1365
+ if (snapshot.descriptor.path === canonicalPath)
1366
+ retained.push(pin);
1367
+ nextEntries.set(snapshot.descriptor.path, retained);
1368
+ }
1369
+ const ownedWrappers = uniqueOwnedWrappers(before.records).filter((wrapper) => wrapper.source !== "inline-config" && wrapper.source !== "managed-config");
1370
+ const wrappers = [];
1371
+ for (const wrapper of ownedWrappers) {
1372
+ try {
1373
+ const safetyRoot = wrapperMutationSafetyRoot(before.paths, wrapper);
1374
+ const recoveryRoot = wrapperRecoveryRoot(before.paths, wrapper);
1375
+ await assertSafeMutationPath(safetyRoot, wrapper.path);
1376
+ await assertSafeMutationPath(safetyRoot, recoveryRoot);
1377
+ if (!await pathCanBeMoved(wrapper.path, recoveryRoot)) {
1378
+ throw new Error(`${wrapper.path}: marker-owned wrapper or recovery parent is not writable; archive it manually and rerun activation-apply`);
1379
+ }
1380
+ wrappers.push(wrapper);
1381
+ } catch (error) {
1382
+ refusals.push(error instanceof Error ? error.message : String(error));
1383
+ }
1384
+ }
1385
+ const provenInactiveCache = before.cacheArtifacts.filter((artifact) => artifact.status === "inactive");
1386
+ const inactiveCache = [];
1387
+ for (const artifact of provenInactiveCache) {
1388
+ try {
1389
+ const safetyRoot = dirname(before.paths.cacheRoot);
1390
+ await assertSafeMutationPath(safetyRoot, artifact.path);
1391
+ await assertSafeMutationPath(safetyRoot, before.paths.cacheRecoveryRoot);
1392
+ if (!await pathCanBeMoved(artifact.path, before.paths.cacheRecoveryRoot)) {
1393
+ 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`);
1394
+ }
1395
+ inactiveCache.push(artifact);
1396
+ } catch (error) {
1397
+ refusals.push(error instanceof Error ? error.message : String(error));
1398
+ }
1399
+ }
1400
+ const plan = [];
1401
+ const changedSnapshots = [];
1402
+ const addManualRemediation = (snapshot, path, detail) => {
1403
+ plan.push({
1404
+ action: "manual-remediation",
1405
+ ...snapshot ? { scope: snapshot.descriptor.scope } : {},
1406
+ path,
1407
+ detail
1408
+ });
1409
+ refusals.push(`${path}: ${detail}`);
1410
+ };
1411
+ const scheduleConfigRewrite = async (snapshot, detail) => {
1412
+ if (changedSnapshots.includes(snapshot))
1413
+ return;
1414
+ if (!snapshot.descriptor.mutable) {
1415
+ addManualRemediation(snapshot, snapshot.descriptor.path, `managed config is immutable; ${snapshot.descriptor.manualRemediation}`);
1416
+ return;
1417
+ }
1418
+ if (snapshot.format === "jsonc") {
1419
+ 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`);
1420
+ return;
1421
+ }
1422
+ if (!await configIsWritable(snapshot)) {
1423
+ addManualRemediation(snapshot, snapshot.descriptor.path, `config or its mutation path is not safely writable; ${snapshot.descriptor.manualRemediation}`);
1424
+ return;
1425
+ }
1426
+ changedSnapshots.push(snapshot);
1427
+ plan.push({
1428
+ action: "rewrite-config",
1429
+ scope: snapshot.descriptor.scope,
1430
+ path: snapshot.descriptor.path,
1431
+ detail
1432
+ });
1433
+ };
1434
+ for (const snapshot of snapshots) {
1435
+ const entries = nextEntries.get(snapshot.descriptor.path) ?? [];
1436
+ const changed = snapshot.descriptor.path === canonicalPath && !snapshot.exists || JSON.stringify(entries) !== JSON.stringify(snapshot.plugin);
1437
+ if (!changed)
1438
+ continue;
1439
+ 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");
1440
+ }
1441
+ for (const record of before.records.filter((record2) => record2.source === "inline-config")) {
1442
+ 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`);
1443
+ }
1444
+ const canonicalSnapshot = snapshots.find((snapshot) => snapshot.descriptor.path === canonicalPath);
1445
+ for (const wrapper of wrappers) {
1446
+ plan.push({
1447
+ action: "quarantine-wrapper",
1448
+ scope: wrapper.scope,
1449
+ path: wrapper.path,
1450
+ detail: "move marker-proven wrapper outside OpenCode plugin discovery"
1451
+ });
1452
+ }
1453
+ for (const artifact of inactiveCache) {
1454
+ plan.push({
1455
+ action: "quarantine-cache",
1456
+ path: artifact.path,
1457
+ detail: `move proven inactive Flow ${artifact.resolvedVersion} cache artifact; never clear the cache root`
1458
+ });
1459
+ }
1460
+ const base = {
1461
+ mode: options.apply === true ? "apply" : "dry-run",
1462
+ project: before.project,
1463
+ target,
1464
+ scope: options.scope,
1465
+ status: refusals.length > 0 ? "refused" : "ready",
1466
+ before,
1467
+ plan,
1468
+ refusals: [...new Set(refusals)]
1469
+ };
1470
+ if (options.apply !== true || refusals.length > 0)
1471
+ return base;
1472
+ if (plan.length === 0) {
1473
+ return { ...base, status: "applied", after: before };
1474
+ }
1475
+ const runId = `${new Date().toISOString().replaceAll(":", "-")}-${randomUUID()}`;
1476
+ const runRoot = join(before.paths.journalRoot, runId);
1477
+ const journalPath = join(runRoot, "journal.json");
1478
+ const actions = [];
1479
+ for (const snapshot of changedSnapshots) {
1480
+ const action = {
1481
+ action: "rewrite-config",
1482
+ path: snapshot.descriptor.path,
1483
+ originalAbsent: !snapshot.exists,
1484
+ originalMode: snapshot.mode,
1485
+ state: "pending"
1486
+ };
1487
+ if (snapshot.exists) {
1488
+ action.backupPath = join(runRoot, "configs", `${snapshot.descriptor.source}-${sha256(snapshot.descriptor.path).slice(0, 12)}.backup`);
1489
+ } else {
1490
+ action.recoveryPath = join(dirname(snapshot.descriptor.path), ".flow-activation-recovery", runId, `${basename(snapshot.descriptor.path)}-${sha256(snapshot.descriptor.path).slice(0, 12)}`);
1491
+ }
1492
+ actions.push(action);
1493
+ }
1494
+ for (const wrapper of wrappers) {
1495
+ actions.push({
1496
+ action: "quarantine-wrapper",
1497
+ path: wrapper.path,
1498
+ recoveryPath: join(wrapperRecoveryRoot(before.paths, wrapper), runId, `${basename(wrapper.path)}-${sha256(wrapper.path).slice(0, 12)}`),
1499
+ state: "pending"
1500
+ });
1501
+ }
1502
+ for (const artifact of inactiveCache) {
1503
+ actions.push({
1504
+ action: "quarantine-cache",
1505
+ path: artifact.path,
1506
+ recoveryPath: join(before.paths.cacheRecoveryRoot, runId, `${basename(artifact.path)}-${sha256(artifact.path).slice(0, 12)}`),
1507
+ state: "pending"
1508
+ });
1509
+ }
1510
+ const journal = {
1511
+ format: "flow-activation-journal-v1",
1512
+ runId,
1513
+ createdAt: new Date().toISOString(),
1514
+ project: before.project,
1515
+ target,
1516
+ scope: options.scope,
1517
+ state: "prepared",
1518
+ actions
1519
+ };
1520
+ try {
1521
+ await assertSafeMutationPath(dirname(before.paths.configRoot), runRoot);
1522
+ await mkdir(runRoot, { recursive: true, mode: 448 });
1523
+ await writeJournal(journalPath, journal);
1524
+ } catch (error) {
1525
+ const message = `recovery journal could not be prepared safely at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`;
1526
+ return {
1527
+ ...base,
1528
+ status: "refused",
1529
+ refusals: [...new Set([...base.refusals, message])]
1530
+ };
1531
+ }
1532
+ try {
1533
+ for (const snapshot of changedSnapshots) {
1534
+ await assertUnchangedConfig(snapshot);
1535
+ const action = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === snapshot.descriptor.path);
1536
+ if (snapshot.exists && action?.backupPath) {
1537
+ await mkdir(dirname(action.backupPath), {
1538
+ recursive: true,
1539
+ mode: 448
1540
+ });
1541
+ await writeFile(action.backupPath, snapshot.content, {
1542
+ encoding: "utf8",
1543
+ flag: "wx",
1544
+ mode: 384
1545
+ });
1546
+ }
1547
+ }
1548
+ if (canonicalSnapshot && !changedSnapshots.includes(canonicalSnapshot)) {
1549
+ await assertUnchangedConfig(canonicalSnapshot);
1550
+ }
1551
+ for (const wrapper of wrappers)
1552
+ await verifyOwnedWrapper(wrapper);
1553
+ for (const artifact of inactiveCache) {
1554
+ await verifyCacheArtifact(artifact, target);
1555
+ }
1556
+ journal.state = "applying";
1557
+ await writeJournal(journalPath, journal);
1558
+ const nonTargetConfigs = changedSnapshots.filter((snapshot) => snapshot.descriptor.path !== canonicalPath);
1559
+ const targetConfig = changedSnapshots.find((snapshot) => snapshot.descriptor.path === canonicalPath);
1560
+ for (const snapshot of nonTargetConfigs) {
1561
+ const entries = nextEntries.get(snapshot.descriptor.path) ?? [];
1562
+ const content = updatedConfigContent(snapshot, entries);
1563
+ await atomicWriteConfig(snapshot, content, runId);
1564
+ const action = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === snapshot.descriptor.path);
1565
+ if (action) {
1566
+ action.appliedDigest = sha256(content);
1567
+ action.state = "complete";
1568
+ }
1569
+ await writeJournal(journalPath, journal);
1570
+ await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === snapshot.descriptor.path));
1571
+ }
1572
+ for (const wrapper of wrappers) {
1573
+ const action = actions.find((candidate) => candidate.action === "quarantine-wrapper" && candidate.path === wrapper.path);
1574
+ if (!action?.recoveryPath) {
1575
+ throw new Error(`${wrapper.path}: missing wrapper recovery path`);
1576
+ }
1577
+ await verifyOwnedWrapper(wrapper);
1578
+ await assertSafeMutationPath(wrapperMutationSafetyRoot(before.paths, wrapper), wrapper.path);
1579
+ await mkdir(dirname(action.recoveryPath), {
1580
+ recursive: true,
1581
+ mode: 448
1582
+ });
1583
+ await rename(wrapper.path, action.recoveryPath);
1584
+ action.state = "complete";
1585
+ await writeJournal(journalPath, journal);
1586
+ await options.afterMutation?.(plan.find((operation) => operation.action === "quarantine-wrapper" && operation.path === wrapper.path));
1587
+ }
1588
+ for (const artifact of inactiveCache) {
1589
+ const action = actions.find((candidate) => candidate.action === "quarantine-cache" && candidate.path === artifact.path);
1590
+ if (!action?.recoveryPath) {
1591
+ throw new Error(`${artifact.path}: missing cache recovery path`);
1592
+ }
1593
+ await verifyCacheArtifact(artifact, target);
1594
+ await assertSafeMutationPath(dirname(before.paths.cacheRoot), artifact.path);
1595
+ await mkdir(dirname(action.recoveryPath), {
1596
+ recursive: true,
1597
+ mode: 448
1598
+ });
1599
+ await rename(artifact.path, action.recoveryPath);
1600
+ action.state = "complete";
1601
+ await writeJournal(journalPath, journal);
1602
+ await options.afterMutation?.(plan.find((operation) => operation.action === "quarantine-cache" && operation.path === artifact.path));
1603
+ }
1604
+ if (targetConfig) {
1605
+ const targetEntries = nextEntries.get(targetConfig.descriptor.path) ?? [];
1606
+ const targetContent = updatedConfigContent(targetConfig, targetEntries);
1607
+ await atomicWriteConfig(targetConfig, targetContent, runId);
1608
+ const targetAction = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === targetConfig.descriptor.path);
1609
+ if (targetAction) {
1610
+ targetAction.appliedDigest = sha256(targetContent);
1611
+ targetAction.state = "complete";
1612
+ }
1613
+ await writeJournal(journalPath, journal);
1614
+ await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === targetConfig.descriptor.path));
1615
+ }
1616
+ const after = await checkFlowActivation({
1617
+ project: before.project,
1618
+ target,
1619
+ ...options.paths ? { paths: options.paths } : {}
1620
+ });
1621
+ if (!after.singleVersionSatisfied) {
1622
+ throw new Error(`post-apply inventory did not prove a single version: ${after.reasons.join("; ")}`);
1623
+ }
1624
+ journal.state = "complete";
1625
+ await writeJournal(journalPath, journal);
1626
+ return {
1627
+ ...base,
1628
+ status: "applied",
1629
+ recovery: { runId, journalPath },
1630
+ after,
1631
+ refusals: []
1632
+ };
1633
+ } catch (error) {
1634
+ journal.state = "failed";
1635
+ journal.error = error instanceof Error ? error.message : String(error);
1636
+ try {
1637
+ await writeJournal(journalPath, journal);
1638
+ } catch {}
1639
+ const rollbackFailures = await rollbackCompletedActions({
1640
+ journal,
1641
+ journalPath,
1642
+ runId,
1643
+ snapshots: changedSnapshots,
1644
+ paths: before.paths,
1645
+ wrappers
1646
+ });
1647
+ const recoveryState = rollbackFailures.length === 0 ? "rolled-back" : "rollback-failed";
1648
+ journal.state = recoveryState;
1649
+ if (rollbackFailures.length > 0) {
1650
+ journal.error = `${journal.error ?? "apply failed"}; rollback: ${rollbackFailures.join("; ")}`;
1651
+ }
1652
+ try {
1653
+ await writeJournal(journalPath, journal);
1654
+ } catch {}
1655
+ const guidance = recoveryState === "rolled-back" ? [
1656
+ "All completed mutations were restored from exact backups or quarantine renames.",
1657
+ `Inspect ${journalPath} and resolve the recorded failure before retrying.`
1658
+ ] : [
1659
+ "Stop OpenCode before manual recovery.",
1660
+ `Inspect ${journalPath}; for rollback-failed actions, restore backupPath to path or rename recoveryPath back to path only after verifying the destination is absent or unchanged.`,
1661
+ "Do not delete the recovery directory until activation-check succeeds."
1662
+ ];
1663
+ return {
1664
+ ...base,
1665
+ status: "refused",
1666
+ recovery: { runId, journalPath },
1667
+ failure: {
1668
+ message: journal.error ?? "activation apply failed",
1669
+ recoveryState,
1670
+ guidance
1671
+ },
1672
+ refusals: [journal.error ?? "activation apply failed"]
1673
+ };
1674
+ }
1675
+ }
1676
+
3
1677
  // src/distribution/legacy-cleanup.ts
4
- import { createHash } from "node:crypto";
5
- import { constants } from "node:fs";
1678
+ import { createHash as createHash2 } from "node:crypto";
1679
+ import { constants as constants2 } from "node:fs";
6
1680
  import {
7
- lstat,
8
- mkdir,
9
- open,
10
- readdir,
11
- rename
1681
+ lstat as lstat2,
1682
+ mkdir as mkdir2,
1683
+ open as open2,
1684
+ readdir as readdir2,
1685
+ rename as rename2
12
1686
  } from "node:fs/promises";
13
- import { homedir } from "node:os";
14
- import { isAbsolute, join, normalize, sep } from "node:path";
1687
+ import { homedir as homedir2 } from "node:os";
1688
+ import { isAbsolute as isAbsolute2, join as join2, normalize as normalize2, sep as sep2 } from "node:path";
15
1689
  import { setTimeout as sleep } from "node:timers/promises";
16
1690
 
17
1691
  // src/guidance/ids.ts
@@ -28,52 +1702,52 @@ var FLOW_GUIDANCE_TOPICS = [
28
1702
 
29
1703
  // src/distribution/legacy-cleanup.ts
30
1704
  var LEGACY_MARKER = ".flow-skill-version";
31
- var NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
1705
+ var NO_FOLLOW2 = constants2.O_NOFOLLOW ?? 0;
32
1706
  var SUPPORTED_LEGACY_MAJOR = "4";
33
1707
  var POST_MOVE_VERIFICATION_ATTEMPTS = 4;
34
1708
  var POST_MOVE_VERIFICATION_RETRY_MS = 25;
35
1709
  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-]+)*)?$/;
36
- function configuredHome() {
37
- return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || homedir();
1710
+ function configuredHome2() {
1711
+ return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || homedir2();
38
1712
  }
39
- function resolveLegacySkillsRoot(home = configuredHome()) {
40
- return join(home, ".config", "opencode", "skills");
1713
+ function resolveLegacySkillsRoot(home = configuredHome2()) {
1714
+ return join2(home, ".config", "opencode", "skills");
41
1715
  }
42
- function resolveLegacyArchiveRoot(home = configuredHome()) {
43
- return join(home, ".config", "opencode", "flow-legacy-skills");
1716
+ function resolveLegacyArchiveRoot(home = configuredHome2()) {
1717
+ return join2(home, ".config", "opencode", "flow-legacy-skills");
44
1718
  }
45
- function sha256(content) {
46
- return createHash("sha256").update(content).digest("hex");
1719
+ function sha2562(content) {
1720
+ return createHash2("sha256").update(content).digest("hex");
47
1721
  }
48
1722
  function safeLegacyPath(folder, relativePath) {
49
- if (!relativePath || isAbsolute(relativePath) || relativePath.includes("\\") || relativePath.split("/").some((part) => !part || part === "." || part === "..")) {
1723
+ if (!relativePath || isAbsolute2(relativePath) || relativePath.includes("\\") || relativePath.split("/").some((part) => !part || part === "." || part === "..")) {
50
1724
  throw new Error(`unsafe marker path '${relativePath}'`);
51
1725
  }
52
- const resolved = normalize(join(folder, ...relativePath.split("/")));
53
- if (!resolved.startsWith(`${folder}${sep}`)) {
1726
+ const resolved = normalize2(join2(folder, ...relativePath.split("/")));
1727
+ if (!resolved.startsWith(`${folder}${sep2}`)) {
54
1728
  throw new Error(`unsafe marker path '${relativePath}'`);
55
1729
  }
56
1730
  return resolved;
57
1731
  }
58
1732
  async function optionalStat(path) {
59
1733
  try {
60
- return await lstat(path, { bigint: false });
1734
+ return await lstat2(path, { bigint: false });
61
1735
  } catch (error) {
62
1736
  if (error.code === "ENOENT")
63
1737
  return null;
64
1738
  throw error;
65
1739
  }
66
1740
  }
67
- async function readRegularFileWithoutFollowing(path) {
1741
+ async function readRegularFileWithoutFollowing2(path) {
68
1742
  let handle;
69
1743
  try {
70
- const pathMetadata = await lstat(path);
1744
+ const pathMetadata = await lstat2(path);
71
1745
  if (pathMetadata.isSymbolicLink()) {
72
1746
  throw new Error(`symbolic link refused: ${path}`);
73
1747
  }
74
1748
  if (!pathMetadata.isFile())
75
1749
  throw new Error(`not a regular file: ${path}`);
76
- handle = await open(path, constants.O_RDONLY | NO_FOLLOW);
1750
+ handle = await open2(path, constants2.O_RDONLY | NO_FOLLOW2);
77
1751
  const metadata = await handle.stat();
78
1752
  if (!metadata.isFile())
79
1753
  throw new Error(`not a regular file: ${path}`);
@@ -169,8 +1843,8 @@ async function inspectLegacyFolder(name, folder) {
169
1843
  };
170
1844
  }
171
1845
  try {
172
- const markerPath = join(folder, LEGACY_MARKER);
173
- const marker = parseMarker(await readRegularFileWithoutFollowing(markerPath));
1846
+ const markerPath = join2(folder, LEGACY_MARKER);
1847
+ const marker = parseMarker(await readRegularFileWithoutFollowing2(markerPath));
174
1848
  assertSupportedLegacyVersion(marker.version);
175
1849
  const directories = expectedDirectoryEntries(marker);
176
1850
  for (const [relativeDirectory, expectedEntries] of directories) {
@@ -179,7 +1853,7 @@ async function inspectLegacyFolder(name, folder) {
179
1853
  if (!directoryMetadata?.isDirectory()) {
180
1854
  throw new Error(`expected real directory: ${relativeDirectory || "."}`);
181
1855
  }
182
- const actualEntries = await readdir(directory);
1856
+ const actualEntries = await readdir2(directory);
183
1857
  const unexpected = actualEntries.filter((entry) => !expectedEntries.has(entry));
184
1858
  const missing = [...expectedEntries].filter((entry) => !actualEntries.includes(entry));
185
1859
  if (unexpected.length > 0 || missing.length > 0) {
@@ -191,8 +1865,8 @@ async function inspectLegacyFolder(name, folder) {
191
1865
  }
192
1866
  for (const [relativePath, expectedHash] of marker.files) {
193
1867
  const path = safeLegacyPath(folder, relativePath);
194
- const content = await readRegularFileWithoutFollowing(path);
195
- if (sha256(content) !== expectedHash) {
1868
+ const content = await readRegularFileWithoutFollowing2(path);
1869
+ if (sha2562(content) !== expectedHash) {
196
1870
  throw new Error(`edited file refused: ${relativePath}`);
197
1871
  }
198
1872
  }
@@ -219,7 +1893,7 @@ async function ensureRealArchiveRoot(path) {
219
1893
  return;
220
1894
  }
221
1895
  try {
222
- await mkdir(path, { mode: 448 });
1896
+ await mkdir2(path, { mode: 448 });
223
1897
  } catch (error) {
224
1898
  if (error.code !== "EEXIST")
225
1899
  throw error;
@@ -238,14 +1912,14 @@ async function verifyMovedLegacyFolder(name, archivePath) {
238
1912
  return verified;
239
1913
  }
240
1914
  async function cleanupLegacySkills(options) {
241
- const home = options?.home ?? configuredHome();
1915
+ const home = options?.home ?? configuredHome2();
242
1916
  const root = resolveLegacySkillsRoot(home);
243
1917
  const archiveRoot = resolveLegacyArchiveRoot(home);
244
1918
  const apply = options?.apply === true;
245
1919
  const results = [];
246
1920
  let archiveReady = false;
247
1921
  for (const name of FLOW_GUIDANCE_TOPICS) {
248
- const path = join(root, name);
1922
+ const path = join2(root, name);
249
1923
  const inspected = await inspectLegacyFolder(name, path);
250
1924
  if (!apply || inspected.status !== "eligible") {
251
1925
  results.push(inspected);
@@ -255,9 +1929,9 @@ async function cleanupLegacySkills(options) {
255
1929
  await ensureRealArchiveRoot(archiveRoot);
256
1930
  archiveReady = true;
257
1931
  }
258
- const archivePath = join(archiveRoot, `${name}-${new Date().toISOString().replaceAll(":", "-")}-${crypto.randomUUID()}`);
1932
+ const archivePath = join2(archiveRoot, `${name}-${new Date().toISOString().replaceAll(":", "-")}-${crypto.randomUUID()}`);
259
1933
  try {
260
- await rename(path, archivePath);
1934
+ await rename2(path, archivePath);
261
1935
  } catch (error) {
262
1936
  if (error.code !== "ENOENT")
263
1937
  throw error;
@@ -271,6 +1945,15 @@ async function cleanupLegacySkills(options) {
271
1945
  }
272
1946
  await options?.afterQuarantine?.({ name, path, archivePath });
273
1947
  const verified = await verifyMovedLegacyFolder(name, archivePath);
1948
+ if (verified.status === "absent") {
1949
+ results.push({
1950
+ name,
1951
+ path,
1952
+ status: "refused",
1953
+ reason: "archive moved again while cleanup was running; inspect the legacy archive root"
1954
+ });
1955
+ continue;
1956
+ }
274
1957
  if (verified.status !== "eligible") {
275
1958
  results.push({
276
1959
  name,
@@ -296,37 +1979,39 @@ async function cleanupLegacySkills(options) {
296
1979
  };
297
1980
  }
298
1981
 
299
- // src/version.ts
300
- import { createRequire } from "node:module";
301
- function resolveFlowPluginVersion() {
302
- try {
303
- const require2 = createRequire(import.meta.url);
304
- const manifest = require2("../package.json");
305
- if (manifest.version)
306
- return manifest.version;
307
- } catch {}
308
- return "0.0.0";
309
- }
310
-
311
1982
  // src/cli.ts
312
1983
  function usage() {
313
1984
  return [
314
- "usage: opencode-plugin-flow legacy-cleanup <--dry-run|--apply> [--json]",
1985
+ "usage:",
1986
+ " opencode-plugin-flow activation-check --project <absolute-path> [--target <exact-version>] [--json]",
1987
+ " opencode-plugin-flow activation-apply --project <absolute-path> --scope <global|project> [--target <exact-version>] [--apply] [--json]",
1988
+ " opencode-plugin-flow legacy-cleanup <--dry-run|--apply> [--json]",
315
1989
  "",
316
1990
  "commands:",
1991
+ " activation-check Inventory all OpenCode Flow activation sources and cache artifacts",
1992
+ " activation-apply Plan a single-version activation; mutate only with --apply",
317
1993
  " legacy-cleanup Inspect or archive marker-proven legacy global Flow skills",
318
1994
  "",
319
- "options:",
1995
+ "activation options:",
1996
+ " --project <path> Absolute project/worktree path whose sources are inventoried",
1997
+ " --scope <scope> Config that receives the one canonical exact npm pin",
1998
+ " --target <version> Exact version only; defaults to this package's embedded version",
1999
+ " --apply Create backups/journal and apply the activation plan",
2000
+ " Without --apply, activation-apply is read-only",
2001
+ "",
2002
+ "legacy cleanup options:",
320
2003
  " --dry-run Report eligible folders without changing the filesystem",
321
2004
  " --apply Move eligible folders to a recoverable archive outside skill discovery",
322
2005
  " Cleanup never deletes legacy folders",
2006
+ "",
2007
+ "common options:",
323
2008
  " --json Write the report as JSON",
324
2009
  " --help Show this help",
325
2010
  " --version Print the plugin version"
326
2011
  ].join(`
327
2012
  `);
328
2013
  }
329
- function writeReport(report, json) {
2014
+ function writeLegacyReport(report, json) {
330
2015
  if (json) {
331
2016
  process.stdout.write(`${JSON.stringify(report, null, 2)}
332
2017
  `);
@@ -351,38 +2036,223 @@ function writeReport(report, json) {
351
2036
  }
352
2037
  }
353
2038
  }
354
- async function main(argv) {
355
- const [command, ...flags] = argv.slice(2);
356
- if (command === "--help" || command === "-h") {
2039
+ function writeActivationCheck(report, json) {
2040
+ if (json) {
2041
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
2042
+ `);
2043
+ return;
2044
+ }
2045
+ process.stdout.write(`Flow activation check: ${report.singleVersionSatisfied ? "satisfied" : "not satisfied"}
2046
+ `);
2047
+ process.stdout.write(`- project: ${report.project}
2048
+ `);
2049
+ process.stdout.write(`- target: opencode-plugin-flow@${report.target}
2050
+ `);
2051
+ process.stdout.write(`- activation sources: ${report.records.length}
2052
+ `);
2053
+ for (const record of report.records) {
2054
+ process.stdout.write(` - ${record.source}: ${record.specifier} (${record.ownership}, ${record.status}, version=${record.resolvedVersion ?? "unresolved"})
2055
+ `);
2056
+ if (record.reason)
2057
+ process.stdout.write(` reason: ${record.reason}
2058
+ `);
2059
+ }
2060
+ process.stdout.write(`- Flow cache artifacts: ${report.cacheArtifacts.length}
2061
+ `);
2062
+ for (const artifact of report.cacheArtifacts) {
2063
+ process.stdout.write(` - ${artifact.specifier}: ${artifact.status} (version=${artifact.resolvedVersion ?? "unresolved"})
2064
+ `);
2065
+ if (artifact.reason)
2066
+ process.stdout.write(` reason: ${artifact.reason}
2067
+ `);
2068
+ }
2069
+ for (const limitation of report.limitations) {
2070
+ process.stdout.write(`- limitation (${limitation.coverage}): ${limitation.source}
2071
+ ${limitation.detail}
2072
+ `);
2073
+ }
2074
+ for (const reason of report.reasons) {
2075
+ process.stdout.write(`- blocked: ${reason}
2076
+ `);
2077
+ }
2078
+ }
2079
+ function writeActivationApply(report, json) {
2080
+ if (json) {
2081
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
2082
+ `);
2083
+ return;
2084
+ }
2085
+ process.stdout.write(`Flow activation ${report.mode}: ${report.status}
2086
+ `);
2087
+ process.stdout.write(`- project: ${report.project}
2088
+ `);
2089
+ process.stdout.write(`- canonical scope: ${report.scope}
2090
+ `);
2091
+ process.stdout.write(`- target: opencode-plugin-flow@${report.target}
2092
+ `);
2093
+ for (const operation of report.plan) {
2094
+ process.stdout.write(`- ${operation.action}: ${operation.path}
2095
+ ${operation.detail}
2096
+ `);
2097
+ }
2098
+ for (const refusal of report.refusals) {
2099
+ process.stdout.write(`- refused: ${refusal}
2100
+ `);
2101
+ }
2102
+ if (report.recovery) {
2103
+ process.stdout.write(`- recovery journal: ${report.recovery.journalPath}
2104
+ `);
2105
+ }
2106
+ if (report.failure) {
2107
+ process.stdout.write(`- recovery state: ${report.failure.recoveryState}
2108
+ - failure: ${report.failure.message}
2109
+ `);
2110
+ for (const guidance of report.failure.guidance) {
2111
+ process.stdout.write(` recovery: ${guidance}
2112
+ `);
2113
+ }
2114
+ }
2115
+ if (report.mode === "dry-run" && report.status === "ready") {
2116
+ process.stdout.write(`- no files changed; repeat with --apply to execute
2117
+ `);
2118
+ }
2119
+ }
2120
+ function parseActivationFlags(flags) {
2121
+ const parsed = {
2122
+ apply: false,
2123
+ json: false,
2124
+ help: false
2125
+ };
2126
+ const seen = new Set;
2127
+ for (let index = 0;index < flags.length; index += 1) {
2128
+ const flag = flags[index];
2129
+ if (!flag || seen.has(flag))
2130
+ return null;
2131
+ seen.add(flag);
2132
+ if (flag === "--apply") {
2133
+ parsed.apply = true;
2134
+ continue;
2135
+ }
2136
+ if (flag === "--json") {
2137
+ parsed.json = true;
2138
+ continue;
2139
+ }
2140
+ if (flag === "--help" || flag === "-h") {
2141
+ parsed.help = true;
2142
+ continue;
2143
+ }
2144
+ if (!["--project", "--target", "--scope"].includes(flag))
2145
+ return null;
2146
+ const value = flags[index + 1];
2147
+ if (!value || value.startsWith("--"))
2148
+ return null;
2149
+ index += 1;
2150
+ if (flag === "--project")
2151
+ parsed.project = value;
2152
+ if (flag === "--target")
2153
+ parsed.target = value;
2154
+ if (flag === "--scope")
2155
+ parsed.scope = value;
2156
+ }
2157
+ return parsed;
2158
+ }
2159
+ function activationScope(value) {
2160
+ return value === "global" || value === "project" ? value : null;
2161
+ }
2162
+ async function runActivationCheck(flags) {
2163
+ const parsed = parseActivationFlags(flags);
2164
+ if (!parsed || parsed.apply || parsed.scope || !parsed.project && !parsed.help) {
2165
+ process.stderr.write(`${usage()}
2166
+ `);
2167
+ process.exitCode = 2;
2168
+ return;
2169
+ }
2170
+ if (parsed.help) {
357
2171
  process.stdout.write(`${usage()}
358
2172
  `);
359
2173
  return;
360
2174
  }
361
- if (command === "--version" || command === "-v") {
362
- process.stdout.write(`${resolveFlowPluginVersion()}
2175
+ const report = await checkFlowActivation({
2176
+ project: parsed.project,
2177
+ ...parsed.target ? { target: parsed.target } : {}
2178
+ });
2179
+ writeActivationCheck(report, parsed.json);
2180
+ if (!report.singleVersionSatisfied)
2181
+ process.exitCode = 1;
2182
+ }
2183
+ async function runActivationApply(flags) {
2184
+ const parsed = parseActivationFlags(flags);
2185
+ const scope = activationScope(parsed?.scope);
2186
+ if (!parsed || !parsed.project && !parsed.help || !scope && !parsed.help) {
2187
+ process.stderr.write(`${usage()}
2188
+ `);
2189
+ process.exitCode = 2;
2190
+ return;
2191
+ }
2192
+ if (parsed.help) {
2193
+ process.stdout.write(`${usage()}
363
2194
  `);
364
2195
  return;
365
2196
  }
2197
+ const report = await applyFlowActivation({
2198
+ project: parsed.project,
2199
+ scope,
2200
+ apply: parsed.apply,
2201
+ ...parsed.target ? { target: parsed.target } : {}
2202
+ });
2203
+ writeActivationApply(report, parsed.json);
2204
+ if (report.status === "refused")
2205
+ process.exitCode = 1;
2206
+ }
2207
+ async function runLegacyCleanup(flags) {
366
2208
  const knownFlags = new Set(["--dry-run", "--apply", "--json"]);
367
2209
  const validFlags = flags.every((flag) => knownFlags.has(flag));
368
2210
  const dryRun = flags.includes("--dry-run");
369
2211
  const apply = flags.includes("--apply");
370
- if (command !== "legacy-cleanup" || !validFlags || dryRun === apply) {
2212
+ if (!validFlags || dryRun === apply) {
371
2213
  process.stderr.write(`${usage()}
372
2214
  `);
373
2215
  process.exitCode = 2;
374
2216
  return;
375
2217
  }
376
2218
  const report = await cleanupLegacySkills({ apply });
377
- writeReport(report, flags.includes("--json"));
2219
+ writeLegacyReport(report, flags.includes("--json"));
378
2220
  if (apply && report.results.some((result) => ["refused", "quarantined"].includes(result.status))) {
379
2221
  process.exitCode = 1;
380
2222
  }
381
2223
  }
2224
+ async function main(argv) {
2225
+ const [command, ...flags] = argv.slice(2);
2226
+ if (command === "--help" || command === "-h") {
2227
+ process.stdout.write(`${usage()}
2228
+ `);
2229
+ return;
2230
+ }
2231
+ if (command === "--version" || command === "-v") {
2232
+ process.stdout.write(`${resolveFlowPluginVersion()}
2233
+ `);
2234
+ return;
2235
+ }
2236
+ if (command === "activation-check") {
2237
+ await runActivationCheck(flags);
2238
+ return;
2239
+ }
2240
+ if (command === "activation-apply") {
2241
+ await runActivationApply(flags);
2242
+ return;
2243
+ }
2244
+ if (command === "legacy-cleanup") {
2245
+ await runLegacyCleanup(flags);
2246
+ return;
2247
+ }
2248
+ process.stderr.write(`${usage()}
2249
+ `);
2250
+ process.exitCode = 2;
2251
+ }
382
2252
  main(process.argv).catch((error) => {
383
2253
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}
384
2254
  `);
385
2255
  process.exitCode = 1;
386
2256
  });
387
2257
 
388
- //# debugId=4B60DFA5FC84470464756E2164756E21
2258
+ //# debugId=963853A3888CECEA64756E2164756E21