agentxchain 0.8.8 → 2.2.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 (74) hide show
  1. package/README.md +136 -136
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +858 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,692 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ } from 'fs';
11
+ import { createHash } from 'crypto';
12
+ import { join, resolve, relative } from 'path';
13
+ import { tmpdir } from 'os';
14
+ import { spawnSync } from 'child_process';
15
+
16
+ import { loadProjectContext, CONFIG_FILE } from './config.js';
17
+ import { validateHooksConfig } from './hook-runner.js';
18
+ import { validateInstalledPluginConfigs, validatePluginConfigInput } from './plugin-config-schema.js';
19
+ import { safeWriteJson } from './safe-write.js';
20
+
21
+ export const PLUGIN_MANIFEST_FILE = 'agentxchain-plugin.json';
22
+ export const PLUGINS_DIR = '.agentxchain/plugins';
23
+ const PLUGIN_SCHEMA_VERSION = '0.1';
24
+ const PLUGIN_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/;
25
+
26
+ function clone(value) {
27
+ return JSON.parse(JSON.stringify(value));
28
+ }
29
+
30
+ function readJson(filePath) {
31
+ return JSON.parse(readFileSync(filePath, 'utf8'));
32
+ }
33
+
34
+ function sanitizePluginName(name) {
35
+ const readable = name
36
+ .replace(/^@/, '')
37
+ .replace(/\//g, '--')
38
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
39
+ .replace(/^-+|-+$/g, '') || 'plugin';
40
+ const suffix = createHash('sha1').update(name).digest('hex').slice(0, 8);
41
+ return `${readable}--${suffix}`;
42
+ }
43
+
44
+ function ensureGovernedProject(startDir) {
45
+ const project = loadProjectContext(startDir);
46
+ if (!project) {
47
+ return { ok: false, error: 'No governed project found. Run this inside an AgentXchain project.' };
48
+ }
49
+ if (project.version !== 4) {
50
+ return { ok: false, error: 'Plugin commands only support governed projects.' };
51
+ }
52
+ return { ok: true, project };
53
+ }
54
+
55
+ function cleanupTempDir(tempDir) {
56
+ if (tempDir) {
57
+ rmSync(tempDir, { recursive: true, force: true });
58
+ }
59
+ }
60
+
61
+ function cleanupEmptyPluginsDir(projectRoot) {
62
+ const pluginsRoot = join(projectRoot, PLUGINS_DIR);
63
+ if (!existsSync(pluginsRoot)) {
64
+ return;
65
+ }
66
+
67
+ if (readdirSync(pluginsRoot).length === 0) {
68
+ rmSync(pluginsRoot, { recursive: true, force: true });
69
+ }
70
+ }
71
+
72
+ function ensureInstalledPluginsAreValid(rawConfig) {
73
+ const validation = validateInstalledPluginConfigs(rawConfig);
74
+ if (!validation.ok) {
75
+ return {
76
+ ok: false,
77
+ error: `Installed plugin config is invalid: ${validation.errors.join('; ')}`,
78
+ errors: validation.errors,
79
+ };
80
+ }
81
+ return { ok: true };
82
+ }
83
+
84
+ function findManifestRoot(baseDir) {
85
+ const direct = join(baseDir, PLUGIN_MANIFEST_FILE);
86
+ if (existsSync(direct)) {
87
+ return baseDir;
88
+ }
89
+
90
+ const packageRoot = join(baseDir, 'package');
91
+ if (existsSync(join(packageRoot, PLUGIN_MANIFEST_FILE))) {
92
+ return packageRoot;
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ function extractArchive(archivePath) {
99
+ const tempDir = mkdtempSync(join(tmpdir(), 'axc-plugin-'));
100
+ const extract = spawnSync('tar', ['-xzf', archivePath, '-C', tempDir], {
101
+ encoding: 'utf8',
102
+ timeout: 30000,
103
+ });
104
+
105
+ if (extract.status !== 0) {
106
+ cleanupTempDir(tempDir);
107
+ return {
108
+ ok: false,
109
+ error: `Failed to extract plugin archive: ${(extract.stderr || extract.stdout || 'tar failed').trim()}`,
110
+ };
111
+ }
112
+
113
+ const root = findManifestRoot(tempDir);
114
+ if (!root) {
115
+ cleanupTempDir(tempDir);
116
+ return { ok: false, error: `Missing ${PLUGIN_MANIFEST_FILE} in extracted archive.` };
117
+ }
118
+
119
+ return {
120
+ ok: true,
121
+ type: 'archive',
122
+ root,
123
+ cleanup: () => cleanupTempDir(tempDir),
124
+ };
125
+ }
126
+
127
+ function packNpmPlugin(spec) {
128
+ const tempDir = mkdtempSync(join(tmpdir(), 'axc-plugin-pack-'));
129
+ const pack = spawnSync('npm', ['pack', spec, '--silent'], {
130
+ cwd: tempDir,
131
+ encoding: 'utf8',
132
+ timeout: 60000,
133
+ });
134
+
135
+ if (pack.status !== 0) {
136
+ cleanupTempDir(tempDir);
137
+ return {
138
+ ok: false,
139
+ error: `npm pack failed for "${spec}": ${(pack.stderr || pack.stdout || 'unknown error').trim()}`,
140
+ };
141
+ }
142
+
143
+ const tarball = (pack.stdout || '')
144
+ .trim()
145
+ .split(/\r?\n/)
146
+ .filter(Boolean)
147
+ .pop();
148
+
149
+ if (!tarball) {
150
+ cleanupTempDir(tempDir);
151
+ return { ok: false, error: `npm pack produced no tarball for "${spec}"` };
152
+ }
153
+
154
+ const extracted = extractArchive(join(tempDir, tarball));
155
+ if (!extracted.ok) {
156
+ cleanupTempDir(tempDir);
157
+ return extracted;
158
+ }
159
+
160
+ return {
161
+ ok: true,
162
+ type: 'npm_package',
163
+ root: extracted.root,
164
+ cleanup: () => {
165
+ extracted.cleanup?.();
166
+ cleanupTempDir(tempDir);
167
+ },
168
+ };
169
+ }
170
+
171
+ function resolvePluginSource(spec, startDir) {
172
+ const resolvedPath = resolve(startDir, spec);
173
+ if (existsSync(resolvedPath)) {
174
+ const stats = statSync(resolvedPath);
175
+ if (stats.isDirectory()) {
176
+ const root = findManifestRoot(resolvedPath);
177
+ if (!root) {
178
+ return { ok: false, error: `Missing ${PLUGIN_MANIFEST_FILE} in ${resolvedPath}` };
179
+ }
180
+
181
+ return {
182
+ ok: true,
183
+ type: 'local_path',
184
+ root,
185
+ sourceSpec: spec,
186
+ cleanup: null,
187
+ };
188
+ }
189
+
190
+ if (stats.isFile() && (resolvedPath.endsWith('.tgz') || resolvedPath.endsWith('.tar.gz'))) {
191
+ const extracted = extractArchive(resolvedPath);
192
+ if (!extracted.ok) {
193
+ return extracted;
194
+ }
195
+ return {
196
+ ...extracted,
197
+ sourceSpec: spec,
198
+ };
199
+ }
200
+ }
201
+
202
+ const packed = packNpmPlugin(spec);
203
+ if (!packed.ok) {
204
+ return packed;
205
+ }
206
+
207
+ return {
208
+ ...packed,
209
+ sourceSpec: spec,
210
+ };
211
+ }
212
+
213
+ export function validatePluginManifest(manifest, sourceRoot) {
214
+ const errors = [];
215
+
216
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
217
+ return { ok: false, errors: ['plugin manifest must be a JSON object'] };
218
+ }
219
+
220
+ if (manifest.schema_version !== PLUGIN_SCHEMA_VERSION) {
221
+ errors.push(`schema_version must be "${PLUGIN_SCHEMA_VERSION}"`);
222
+ }
223
+
224
+ if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
225
+ errors.push('name must be a non-empty string');
226
+ } else if (!PLUGIN_NAME_RE.test(manifest.name)) {
227
+ errors.push('name must be a valid package-style identifier');
228
+ }
229
+
230
+ if (typeof manifest.version !== 'string' || !manifest.version.trim()) {
231
+ errors.push('version must be a non-empty string');
232
+ }
233
+
234
+ if ('description' in manifest && manifest.description !== undefined && typeof manifest.description !== 'string') {
235
+ errors.push('description must be a string when provided');
236
+ }
237
+
238
+ if (!manifest.hooks || typeof manifest.hooks !== 'object' || Array.isArray(manifest.hooks)) {
239
+ errors.push('hooks must be an object');
240
+ } else {
241
+ const hookValidation = validateHooksConfig(manifest.hooks, sourceRoot || null);
242
+ errors.push(...hookValidation.errors);
243
+ }
244
+
245
+ if ('config_schema' in manifest && manifest.config_schema !== undefined) {
246
+ if (!manifest.config_schema || typeof manifest.config_schema !== 'object' || Array.isArray(manifest.config_schema)) {
247
+ errors.push('config_schema must be an object when provided');
248
+ }
249
+ }
250
+
251
+ return { ok: errors.length === 0, errors };
252
+ }
253
+
254
+ function rewriteCommandTokens(command, sourceRoot, installRoot, projectRoot) {
255
+ return command.map((token) => {
256
+ if (typeof token !== 'string') {
257
+ return token;
258
+ }
259
+ if (!token.startsWith('./') && !token.startsWith('../')) {
260
+ return token;
261
+ }
262
+
263
+ const sourcePath = resolve(sourceRoot, token);
264
+ if (!existsSync(sourcePath)) {
265
+ throw new Error(`Plugin command path does not exist: ${token}`);
266
+ }
267
+
268
+ const installPath = resolve(installRoot, relative(sourceRoot, sourcePath));
269
+ return relative(projectRoot, installPath);
270
+ });
271
+ }
272
+
273
+ function buildInstalledHooks(manifest, sourceRoot, installRoot, projectRoot, runtimeEnv = {}) {
274
+ const installedHooks = {};
275
+
276
+ for (const [phase, hookList] of Object.entries(manifest.hooks || {})) {
277
+ installedHooks[phase] = hookList.map((hookDef) => ({
278
+ ...clone(hookDef),
279
+ command: rewriteCommandTokens(hookDef.command, sourceRoot, installRoot, projectRoot),
280
+ env: {
281
+ ...(hookDef.env || {}),
282
+ ...runtimeEnv,
283
+ },
284
+ }));
285
+ }
286
+
287
+ return installedHooks;
288
+ }
289
+
290
+ function mergePluginHooks(existingHooks, pluginHooks) {
291
+ const merged = clone(existingHooks || {});
292
+ const collisions = [];
293
+
294
+ for (const [phase, hookList] of Object.entries(pluginHooks || {})) {
295
+ const current = Array.isArray(merged[phase]) ? clone(merged[phase]) : [];
296
+ const existingNames = new Set(current.map((hook) => hook.name));
297
+
298
+ for (const hook of hookList) {
299
+ if (existingNames.has(hook.name)) {
300
+ collisions.push({ phase, hook_name: hook.name });
301
+ continue;
302
+ }
303
+ current.push(hook);
304
+ existingNames.add(hook.name);
305
+ }
306
+
307
+ merged[phase] = current;
308
+ }
309
+
310
+ return { merged, collisions };
311
+ }
312
+
313
+ function buildPluginMetadata(manifest, installRelPath, sourceType, sourceSpec, pluginConfig) {
314
+ const hooks = {};
315
+ for (const [phase, hookList] of Object.entries(manifest.hooks || {})) {
316
+ hooks[phase] = hookList.map((hook) => hook.name);
317
+ }
318
+
319
+ return {
320
+ schema_version: manifest.schema_version,
321
+ name: manifest.name,
322
+ version: manifest.version,
323
+ description: manifest.description || null,
324
+ install_path: installRelPath,
325
+ source: {
326
+ type: sourceType,
327
+ spec: sourceSpec,
328
+ },
329
+ installed_at: new Date().toISOString(),
330
+ hooks,
331
+ config_schema: manifest.config_schema || null,
332
+ config: pluginConfig,
333
+ };
334
+ }
335
+
336
+ function buildPluginRuntimeEnv(manifest, pluginConfig) {
337
+ const runtimeEnv = {
338
+ AGENTXCHAIN_PLUGIN_NAME: manifest.name,
339
+ AGENTXCHAIN_PLUGIN_VERSION: manifest.version,
340
+ };
341
+
342
+ if (pluginConfig !== undefined) {
343
+ runtimeEnv.AGENTXCHAIN_PLUGIN_CONFIG = JSON.stringify(pluginConfig);
344
+ }
345
+
346
+ return runtimeEnv;
347
+ }
348
+
349
+ function buildValidatedPluginConfig(manifest, requestedConfig) {
350
+ const validation = validatePluginConfigInput(
351
+ manifest.config_schema,
352
+ requestedConfig,
353
+ `plugins.${manifest.name}.config`,
354
+ );
355
+ if (!validation.ok) {
356
+ return {
357
+ ok: false,
358
+ error: validation.errors.join('; '),
359
+ errors: validation.errors,
360
+ };
361
+ }
362
+ return { ok: true, config: validation.value };
363
+ }
364
+
365
+ function stripPluginHooksFromConfig(rawConfig, pluginName) {
366
+ const nextConfig = clone(rawConfig);
367
+ const pluginMeta = rawConfig.plugins?.[pluginName];
368
+ const pluginHooks = pluginMeta?.hooks || {};
369
+
370
+ for (const [phase, hookNames] of Object.entries(pluginHooks)) {
371
+ if (!Array.isArray(nextConfig.hooks?.[phase])) {
372
+ continue;
373
+ }
374
+ nextConfig.hooks[phase] = nextConfig.hooks[phase].filter((hook) => !hookNames.includes(hook.name));
375
+ if (nextConfig.hooks[phase].length === 0) {
376
+ delete nextConfig.hooks[phase];
377
+ }
378
+ }
379
+
380
+ if (nextConfig.plugins) {
381
+ delete nextConfig.plugins[pluginName];
382
+ if (Object.keys(nextConfig.plugins).length === 0) {
383
+ delete nextConfig.plugins;
384
+ }
385
+ }
386
+
387
+ return nextConfig;
388
+ }
389
+
390
+ function assertSafeInstallPath(installRelPath) {
391
+ return typeof installRelPath === 'string'
392
+ && (installRelPath.startsWith(`${PLUGINS_DIR}/`) || installRelPath === PLUGINS_DIR);
393
+ }
394
+
395
+ export function listInstalledPlugins(startDir = process.cwd()) {
396
+ const governed = ensureGovernedProject(startDir);
397
+ if (!governed.ok) {
398
+ return governed;
399
+ }
400
+
401
+ const { project } = governed;
402
+ const pluginValidation = ensureInstalledPluginsAreValid(project.rawConfig);
403
+ if (!pluginValidation.ok) {
404
+ return pluginValidation;
405
+ }
406
+ const plugins = Object.entries(project.rawConfig.plugins || {}).map(([name, meta]) => ({
407
+ name,
408
+ version: meta.version || null,
409
+ description: meta.description || null,
410
+ install_path: meta.install_path || null,
411
+ source: meta.source || null,
412
+ hooks: meta.hooks || {},
413
+ installed: meta.install_path ? existsSync(join(project.root, meta.install_path)) : false,
414
+ }));
415
+
416
+ return { ok: true, plugins };
417
+ }
418
+
419
+ export function installPlugin(spec, startDir = process.cwd(), options = {}) {
420
+ const governed = ensureGovernedProject(startDir);
421
+ if (!governed.ok) {
422
+ return governed;
423
+ }
424
+
425
+ const { project } = governed;
426
+ const pluginValidation = ensureInstalledPluginsAreValid(project.rawConfig);
427
+ if (!pluginValidation.ok) {
428
+ return pluginValidation;
429
+ }
430
+ const source = resolvePluginSource(spec, startDir);
431
+ if (!source.ok) {
432
+ return source;
433
+ }
434
+
435
+ let installAbsPath = null;
436
+ let installRecorded = false;
437
+
438
+ try {
439
+ const manifestPath = join(source.root, PLUGIN_MANIFEST_FILE);
440
+ const manifest = readJson(manifestPath);
441
+ const manifestValidation = validatePluginManifest(manifest, source.root);
442
+ if (!manifestValidation.ok) {
443
+ return { ok: false, error: manifestValidation.errors.join('; '), errors: manifestValidation.errors };
444
+ }
445
+
446
+ const configValidation = buildValidatedPluginConfig(manifest, options.config);
447
+ if (!configValidation.ok) {
448
+ return configValidation;
449
+ }
450
+
451
+ if (project.rawConfig.plugins?.[manifest.name]) {
452
+ return { ok: false, error: `Plugin "${manifest.name}" is already installed.` };
453
+ }
454
+
455
+ const installId = sanitizePluginName(manifest.name);
456
+ const installRelPath = join(PLUGINS_DIR, installId);
457
+ installAbsPath = join(project.root, installRelPath);
458
+
459
+ if (existsSync(installAbsPath)) {
460
+ return { ok: false, error: `Install path already exists: ${installRelPath}` };
461
+ }
462
+
463
+ cpSync(source.root, installAbsPath, { recursive: true, force: false });
464
+
465
+ const runtimeEnv = buildPluginRuntimeEnv(manifest, configValidation.config);
466
+ const installedHooks = buildInstalledHooks(manifest, source.root, installAbsPath, project.root, runtimeEnv);
467
+ const mergedHooks = mergePluginHooks(project.rawConfig.hooks || {}, installedHooks);
468
+ if (mergedHooks.collisions.length > 0) {
469
+ return {
470
+ ok: false,
471
+ error: `Plugin hook conflicts with existing config: ${mergedHooks.collisions.map((c) => `${c.phase}:${c.hook_name}`).join(', ')}`,
472
+ collisions: mergedHooks.collisions,
473
+ };
474
+ }
475
+
476
+ const hookValidation = validateHooksConfig(mergedHooks.merged, project.root);
477
+ if (!hookValidation.ok) {
478
+ return { ok: false, error: hookValidation.errors.join('; '), errors: hookValidation.errors };
479
+ }
480
+
481
+ const nextConfig = clone(project.rawConfig);
482
+ nextConfig.hooks = mergedHooks.merged;
483
+ nextConfig.plugins = {
484
+ ...(nextConfig.plugins || {}),
485
+ [manifest.name]: buildPluginMetadata(
486
+ manifest,
487
+ installRelPath,
488
+ source.type,
489
+ source.sourceSpec,
490
+ configValidation.config,
491
+ ),
492
+ };
493
+
494
+ safeWriteJson(join(project.root, CONFIG_FILE), nextConfig);
495
+ installRecorded = true;
496
+
497
+ return {
498
+ ok: true,
499
+ action: 'installed',
500
+ name: manifest.name,
501
+ version: manifest.version,
502
+ install_path: installRelPath,
503
+ hooks: nextConfig.plugins[manifest.name].hooks,
504
+ source: nextConfig.plugins[manifest.name].source,
505
+ config: nextConfig.plugins[manifest.name].config,
506
+ };
507
+ } catch (error) {
508
+ return { ok: false, error: error.message || String(error) };
509
+ } finally {
510
+ if (!installRecorded && installAbsPath && existsSync(installAbsPath)) {
511
+ rmSync(installAbsPath, { recursive: true, force: true });
512
+ cleanupEmptyPluginsDir(project.root);
513
+ }
514
+
515
+ source.cleanup?.();
516
+ }
517
+ }
518
+
519
+ export function removePlugin(name, startDir = process.cwd()) {
520
+ const governed = ensureGovernedProject(startDir);
521
+ if (!governed.ok) {
522
+ return governed;
523
+ }
524
+
525
+ const { project } = governed;
526
+ const pluginValidation = ensureInstalledPluginsAreValid(project.rawConfig);
527
+ if (!pluginValidation.ok) {
528
+ return pluginValidation;
529
+ }
530
+ const pluginMeta = project.rawConfig.plugins?.[name];
531
+ if (!pluginMeta) {
532
+ return { ok: false, error: `Plugin "${name}" is not installed.` };
533
+ }
534
+
535
+ const installRelPath = pluginMeta.install_path;
536
+ if (!assertSafeInstallPath(installRelPath)) {
537
+ return { ok: false, error: `Refusing to remove unsafe plugin path: ${installRelPath || 'missing path'}` };
538
+ }
539
+
540
+ const nextConfig = stripPluginHooksFromConfig(project.rawConfig, name);
541
+ const pluginHooks = pluginMeta.hooks || {};
542
+
543
+ safeWriteJson(join(project.root, CONFIG_FILE), nextConfig);
544
+ rmSync(join(project.root, installRelPath), { recursive: true, force: true });
545
+ cleanupEmptyPluginsDir(project.root);
546
+
547
+ return {
548
+ ok: true,
549
+ action: 'removed',
550
+ name,
551
+ removed_hooks: pluginHooks,
552
+ install_path: installRelPath,
553
+ };
554
+ }
555
+
556
+ export function upgradePlugin(name, spec, startDir = process.cwd(), options = {}) {
557
+ const governed = ensureGovernedProject(startDir);
558
+ if (!governed.ok) {
559
+ return governed;
560
+ }
561
+
562
+ const { project } = governed;
563
+ const pluginValidation = ensureInstalledPluginsAreValid(project.rawConfig);
564
+ if (!pluginValidation.ok) {
565
+ return pluginValidation;
566
+ }
567
+
568
+ const currentMeta = project.rawConfig.plugins?.[name];
569
+ if (!currentMeta) {
570
+ return { ok: false, error: `Plugin "${name}" is not installed.` };
571
+ }
572
+
573
+ const sourceSpec = spec || currentMeta.source?.spec;
574
+ if (!sourceSpec) {
575
+ return { ok: false, error: `Plugin "${name}" has no recorded source spec. Pass an explicit source to upgrade.` };
576
+ }
577
+
578
+ const installRelPath = currentMeta.install_path;
579
+ if (!assertSafeInstallPath(installRelPath)) {
580
+ return { ok: false, error: `Refusing to upgrade unsafe plugin path: ${installRelPath || 'missing path'}` };
581
+ }
582
+
583
+ const installAbsPath = join(project.root, installRelPath);
584
+ if (!existsSync(installAbsPath)) {
585
+ return { ok: false, error: `Plugin "${name}" install path is missing on disk: ${installRelPath}` };
586
+ }
587
+
588
+ const source = resolvePluginSource(sourceSpec, startDir);
589
+ if (!source.ok) {
590
+ return source;
591
+ }
592
+
593
+ const stagePath = `${installAbsPath}.upgrade-${Date.now()}`;
594
+ const backupPath = `${installAbsPath}.rollback-${Date.now()}`;
595
+ const commitJson = options.writeJson || safeWriteJson;
596
+
597
+ try {
598
+ const manifest = readJson(join(source.root, PLUGIN_MANIFEST_FILE));
599
+ const manifestValidation = validatePluginManifest(manifest, source.root);
600
+ if (!manifestValidation.ok) {
601
+ return { ok: false, error: manifestValidation.errors.join('; '), errors: manifestValidation.errors };
602
+ }
603
+
604
+ if (manifest.name !== name) {
605
+ return {
606
+ ok: false,
607
+ error: `Upgrade source manifest name "${manifest.name}" does not match installed plugin "${name}"`,
608
+ };
609
+ }
610
+
611
+ const configCandidate = options.config !== undefined ? options.config : currentMeta.config;
612
+ const configValidation = buildValidatedPluginConfig(manifest, configCandidate);
613
+ if (!configValidation.ok) {
614
+ return configValidation;
615
+ }
616
+
617
+ cpSync(source.root, stagePath, { recursive: true, force: false });
618
+
619
+ const baseConfig = stripPluginHooksFromConfig(project.rawConfig, name);
620
+ const runtimeEnv = buildPluginRuntimeEnv(manifest, configValidation.config);
621
+ const installedHooks = buildInstalledHooks(manifest, source.root, installAbsPath, project.root, runtimeEnv);
622
+ const mergedHooks = mergePluginHooks(baseConfig.hooks || {}, installedHooks);
623
+ if (mergedHooks.collisions.length > 0) {
624
+ return {
625
+ ok: false,
626
+ error: `Plugin hook conflicts with existing config: ${mergedHooks.collisions.map((c) => `${c.phase}:${c.hook_name}`).join(', ')}`,
627
+ collisions: mergedHooks.collisions,
628
+ };
629
+ }
630
+
631
+ const hookValidation = validateHooksConfig(mergedHooks.merged, project.root);
632
+ if (!hookValidation.ok) {
633
+ return { ok: false, error: hookValidation.errors.join('; '), errors: hookValidation.errors };
634
+ }
635
+
636
+ const nextConfig = clone(baseConfig);
637
+ nextConfig.hooks = mergedHooks.merged;
638
+ nextConfig.plugins = {
639
+ ...(nextConfig.plugins || {}),
640
+ [name]: buildPluginMetadata(
641
+ manifest,
642
+ installRelPath,
643
+ source.type,
644
+ source.sourceSpec,
645
+ configValidation.config,
646
+ ),
647
+ };
648
+
649
+ renameSync(installAbsPath, backupPath);
650
+ try {
651
+ renameSync(stagePath, installAbsPath);
652
+ try {
653
+ commitJson(join(project.root, CONFIG_FILE), nextConfig);
654
+ } catch (error) {
655
+ rmSync(installAbsPath, { recursive: true, force: true });
656
+ renameSync(backupPath, installAbsPath);
657
+ try {
658
+ safeWriteJson(join(project.root, CONFIG_FILE), project.rawConfig);
659
+ } catch {}
660
+ return { ok: false, error: error.message || String(error) };
661
+ }
662
+ } catch (error) {
663
+ if (!existsSync(installAbsPath) && existsSync(backupPath)) {
664
+ renameSync(backupPath, installAbsPath);
665
+ }
666
+ return { ok: false, error: error.message || String(error) };
667
+ }
668
+
669
+ rmSync(backupPath, { recursive: true, force: true });
670
+
671
+ return {
672
+ ok: true,
673
+ action: 'upgraded',
674
+ name,
675
+ version: manifest.version,
676
+ install_path: installRelPath,
677
+ hooks: nextConfig.plugins[name].hooks,
678
+ source: nextConfig.plugins[name].source,
679
+ config: nextConfig.plugins[name].config,
680
+ };
681
+ } catch (error) {
682
+ return { ok: false, error: error.message || String(error) };
683
+ } finally {
684
+ if (existsSync(stagePath)) {
685
+ rmSync(stagePath, { recursive: true, force: true });
686
+ }
687
+ if (existsSync(backupPath) && !existsSync(installAbsPath)) {
688
+ renameSync(backupPath, installAbsPath);
689
+ }
690
+ source.cleanup?.();
691
+ }
692
+ }