agentic-workflow-manager 3.1.0 → 3.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 (92) hide show
  1. package/dist/src/commands/add.js +84 -0
  2. package/dist/src/commands/agent.js +95 -0
  3. package/dist/src/commands/backup.js +46 -0
  4. package/dist/src/commands/doctor.js +80 -4
  5. package/dist/src/commands/hooks/claude.js +192 -0
  6. package/dist/src/commands/hooks/codex.js +191 -0
  7. package/dist/src/commands/hooks/index.js +23 -8
  8. package/dist/src/commands/hooks/install.js +11 -107
  9. package/dist/src/commands/hooks/resync.js +28 -18
  10. package/dist/src/commands/hooks/shared.js +94 -0
  11. package/dist/src/commands/hooks/status.js +13 -64
  12. package/dist/src/commands/hooks/uninstall.js +11 -47
  13. package/dist/src/commands/init.js +90 -28
  14. package/dist/src/commands/registry/index.js +1 -1
  15. package/dist/src/commands/sync.js +113 -0
  16. package/dist/src/commands/update.js +107 -0
  17. package/dist/src/core/agent-targets.js +50 -0
  18. package/dist/src/core/artifact-state.js +65 -0
  19. package/dist/src/core/atomic-file.js +72 -0
  20. package/dist/src/core/bundle-install.js +60 -38
  21. package/dist/src/core/bundles.js +21 -0
  22. package/dist/src/core/context/managed-block.js +190 -0
  23. package/dist/src/core/context/orchestrator.js +3 -1
  24. package/dist/src/core/context/project-constitution-inject.js +1 -1
  25. package/dist/src/core/context/regenerate.js +3 -3
  26. package/dist/src/core/context/strategies/codex-agents.js +100 -0
  27. package/dist/src/core/diagnostics/checks.js +16 -0
  28. package/dist/src/core/diagnostics/context.js +37 -8
  29. package/dist/src/core/diagnostics/provider-checks.js +219 -0
  30. package/dist/src/core/executor.js +30 -9
  31. package/dist/src/core/init/mutation-targets.js +139 -0
  32. package/dist/src/core/init/provider-facts.js +164 -0
  33. package/dist/src/core/init/steps.js +65 -9
  34. package/dist/src/core/install-planner.js +206 -0
  35. package/dist/src/core/install-transaction.js +405 -0
  36. package/dist/src/core/profile.js +1 -1
  37. package/dist/src/core/provider-artifacts.js +54 -0
  38. package/dist/src/core/provider-version.js +36 -0
  39. package/dist/src/core/reconciliation.js +61 -0
  40. package/dist/src/core/registries.js +10 -0
  41. package/dist/src/core/renderers/canonical-agent.js +27 -0
  42. package/dist/src/core/renderers/codex-agent.js +60 -0
  43. package/dist/src/core/skill-integrity.js +4 -3
  44. package/dist/src/index.js +97 -289
  45. package/dist/src/providers/index.js +143 -39
  46. package/dist/src/ui/provider-preflight.js +26 -0
  47. package/dist/src/utils/config.js +101 -9
  48. package/dist/tests/commands/agent.test.js +103 -0
  49. package/dist/tests/commands/backup.test.js +109 -0
  50. package/dist/tests/commands/doctor.test.js +65 -4
  51. package/dist/tests/commands/hooks/codex.test.js +230 -0
  52. package/dist/tests/commands/hooks/install.test.js +20 -1
  53. package/dist/tests/commands/hooks/resync.test.js +53 -6
  54. package/dist/tests/commands/hooks/status.test.js +10 -0
  55. package/dist/tests/commands/hooks/uninstall.test.js +12 -0
  56. package/dist/tests/commands/init.test.js +195 -8
  57. package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
  58. package/dist/tests/core/agent-targets.test.js +45 -0
  59. package/dist/tests/core/artifact-state.test.js +144 -0
  60. package/dist/tests/core/atomic-file.test.js +96 -0
  61. package/dist/tests/core/bundle-install.test.js +208 -17
  62. package/dist/tests/core/bundles.test.js +12 -0
  63. package/dist/tests/core/context/managed-block.test.js +90 -0
  64. package/dist/tests/core/context/orchestrator.test.js +48 -3
  65. package/dist/tests/core/context/regenerate.test.js +1 -1
  66. package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
  67. package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
  68. package/dist/tests/core/diagnostics/checks.test.js +19 -0
  69. package/dist/tests/core/diagnostics/context.test.js +74 -0
  70. package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
  71. package/dist/tests/core/executor.test.js +20 -0
  72. package/dist/tests/core/export/engine.test.js +3 -2
  73. package/dist/tests/core/init/mutation-targets.test.js +235 -0
  74. package/dist/tests/core/init/orchestrator.test.js +1 -1
  75. package/dist/tests/core/init/provider-facts.test.js +129 -0
  76. package/dist/tests/core/init/steps.test.js +106 -2
  77. package/dist/tests/core/install-planner.test.js +224 -0
  78. package/dist/tests/core/install-transaction.test.js +257 -0
  79. package/dist/tests/core/provider-artifacts.test.js +64 -0
  80. package/dist/tests/core/provider-version.test.js +53 -0
  81. package/dist/tests/core/reconciliation.test.js +165 -0
  82. package/dist/tests/core/registries-sync.test.js +4 -4
  83. package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
  84. package/dist/tests/core/renderers/codex-agent.test.js +128 -0
  85. package/dist/tests/core/versioning.test.js +1 -1
  86. package/dist/tests/integration/codex-provider-isolated.test.js +206 -0
  87. package/dist/tests/providers/hooks-config.test.js +43 -25
  88. package/dist/tests/providers/index.test.js +144 -39
  89. package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
  90. package/dist/tests/ui/provider-preflight.test.js +25 -0
  91. package/dist/tests/utils/config.test.js +178 -18
  92. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const install_planner_1 = require("../../src/core/install-planner");
10
+ describe('install-planner', () => {
11
+ let tmpHome;
12
+ let tmpWork;
13
+ let originalHome;
14
+ let originalAwmHome;
15
+ beforeEach(() => {
16
+ // Isolate ~ (and thus provider global paths, per CLAUDE.md: no test may
17
+ // touch the real ~/.awm or ~/.agents). HOME also drives providerFor()'s
18
+ // global dirs, which is exactly what makes opencode/codex "share" a
19
+ // physical skills directory in these tests.
20
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-home-'));
21
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-work-'));
22
+ originalHome = process.env.HOME;
23
+ originalAwmHome = process.env.AWM_HOME;
24
+ process.env.HOME = tmpHome;
25
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
26
+ });
27
+ afterEach(() => {
28
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
29
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
30
+ if (originalHome === undefined)
31
+ delete process.env.HOME;
32
+ else
33
+ process.env.HOME = originalHome;
34
+ if (originalAwmHome === undefined)
35
+ delete process.env.AWM_HOME;
36
+ else
37
+ process.env.AWM_HOME = originalAwmHome;
38
+ });
39
+ function skillArtifact(name) {
40
+ return {
41
+ name,
42
+ installName: name,
43
+ type: 'skill',
44
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
45
+ };
46
+ }
47
+ function agentArtifact(name) {
48
+ return {
49
+ name,
50
+ installName: `${name}.md`,
51
+ type: 'agent',
52
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'agents', `${name}.md`),
53
+ };
54
+ }
55
+ function workflowArtifact(name) {
56
+ return {
57
+ name,
58
+ installName: `${name}.md`,
59
+ type: 'workflow',
60
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'workflows', `${name}.md`),
61
+ };
62
+ }
63
+ function recordOwnedBy(name, owners) {
64
+ return {
65
+ name,
66
+ type: 'skill',
67
+ scope: 'global',
68
+ targetPath: path_1.default.join(tmpHome, '.agents', 'skills', name),
69
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
70
+ renderer: 'link',
71
+ owners,
72
+ };
73
+ }
74
+ describe('planInstall', () => {
75
+ it('deduplicates the OpenCode/Codex physical skill write and reports both owners', () => {
76
+ const plan = (0, install_planner_1.planInstall)({
77
+ artifacts: [skillArtifact('development-process')],
78
+ selectedAgents: ['opencode', 'codex'],
79
+ enabledAgents: ['claude-code', 'opencode', 'codex'],
80
+ scope: 'global',
81
+ projectRoot: tmpWork,
82
+ method: 'symlink',
83
+ });
84
+ expect(plan.operations).toHaveLength(1); // verifies R15
85
+ expect(plan.operations[0].owners).toEqual(['opencode', 'codex']); // verifies R15.1
86
+ });
87
+ it('aborts a skill change for an incomplete shared target group before writes', () => {
88
+ expect(() => (0, install_planner_1.planInstall)({
89
+ artifacts: [skillArtifact('development-process')],
90
+ selectedAgents: ['codex'],
91
+ enabledAgents: ['opencode', 'codex'],
92
+ scope: 'global',
93
+ projectRoot: tmpWork,
94
+ method: 'symlink',
95
+ })).toThrow('select the complete shared target group: opencode,codex'); // verifies R14
96
+ });
97
+ it('does not apply the shared-domain restriction to independently addressed agents', () => {
98
+ const plan = (0, install_planner_1.planInstall)({
99
+ artifacts: [agentArtifact('development-process')],
100
+ selectedAgents: ['codex'],
101
+ enabledAgents: ['opencode', 'codex'],
102
+ scope: 'global',
103
+ projectRoot: tmpWork,
104
+ method: 'symlink',
105
+ });
106
+ expect(plan.operations[0].owners).toEqual(['codex']); // verifies R13
107
+ });
108
+ it('returns no operations for an empty artifacts array', () => {
109
+ const plan = (0, install_planner_1.planInstall)({
110
+ artifacts: [],
111
+ selectedAgents: ['claude-code'],
112
+ enabledAgents: ['claude-code'],
113
+ scope: 'global',
114
+ projectRoot: tmpWork,
115
+ method: 'symlink',
116
+ });
117
+ expect(plan).toEqual({ operations: [], records: [], reports: [] });
118
+ });
119
+ it('returns no operations for an empty selectedAgents array', () => {
120
+ const plan = (0, install_planner_1.planInstall)({
121
+ artifacts: [skillArtifact('development-process')],
122
+ selectedAgents: [],
123
+ enabledAgents: ['claude-code'],
124
+ scope: 'global',
125
+ projectRoot: tmpWork,
126
+ method: 'symlink',
127
+ });
128
+ expect(plan).toEqual({ operations: [], records: [], reports: [] });
129
+ });
130
+ it('skips an artifact type unsupported by an agent instead of throwing (workflow on claude-code)', () => {
131
+ const plan = (0, install_planner_1.planInstall)({
132
+ artifacts: [workflowArtifact('wf-ext')],
133
+ selectedAgents: ['claude-code'],
134
+ enabledAgents: ['claude-code'],
135
+ scope: 'global',
136
+ projectRoot: tmpWork,
137
+ method: 'symlink',
138
+ });
139
+ expect(plan.operations).toEqual([]);
140
+ });
141
+ it('resolves a local-scope target beneath projectRoot', () => {
142
+ const plan = (0, install_planner_1.planInstall)({
143
+ artifacts: [skillArtifact('development-process')],
144
+ selectedAgents: ['claude-code'],
145
+ enabledAgents: ['claude-code'],
146
+ scope: 'local',
147
+ projectRoot: tmpWork,
148
+ method: 'symlink',
149
+ });
150
+ expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.claude', 'skills', 'development-process'));
151
+ });
152
+ it('dedupes two intents that share both targetPath and sourcePath into one operation', () => {
153
+ // e.g. the same skill pulled in twice via different bundles in a
154
+ // closure — same physical target, same backing source: fine.
155
+ const plan = (0, install_planner_1.planInstall)({
156
+ artifacts: [skillArtifact('development-process'), skillArtifact('development-process')],
157
+ selectedAgents: ['claude-code'],
158
+ enabledAgents: ['claude-code'],
159
+ scope: 'global',
160
+ projectRoot: tmpWork,
161
+ method: 'symlink',
162
+ });
163
+ expect(plan.operations).toHaveLength(1);
164
+ });
165
+ it('throws when two intents share a targetPath but have different sourcePaths', () => {
166
+ // e.g. the same skill name shipped by two different registries —
167
+ // one physical target can't be backed by two different sources.
168
+ const registryA = {
169
+ name: 'development-process',
170
+ installName: 'development-process',
171
+ type: 'skill',
172
+ sourcePath: path_1.default.join(tmpWork, 'registry-a', 'skills', 'development-process'),
173
+ };
174
+ const registryB = {
175
+ name: 'development-process',
176
+ installName: 'development-process',
177
+ type: 'skill',
178
+ sourcePath: path_1.default.join(tmpWork, 'registry-b', 'skills', 'development-process'),
179
+ };
180
+ expect(() => (0, install_planner_1.planInstall)({
181
+ artifacts: [registryA, registryB],
182
+ selectedAgents: ['claude-code'],
183
+ enabledAgents: ['claude-code'],
184
+ scope: 'global',
185
+ projectRoot: tmpWork,
186
+ method: 'symlink',
187
+ })).toThrow(/physical target already claimed by a different source/);
188
+ });
189
+ });
190
+ describe('planRemoval', () => {
191
+ it('retains a target while a non-selected enabled owner remains', () => {
192
+ const result = (0, install_planner_1.planRemoval)({
193
+ records: [recordOwnedBy('development-process', ['opencode', 'codex'])],
194
+ selectedAgents: ['codex'],
195
+ enabledAgents: ['opencode', 'codex'],
196
+ artifactNames: ['development-process'],
197
+ });
198
+ expect(result.operations).toEqual([]);
199
+ expect(result.records[0].owners).toEqual(['opencode']); // verifies R16
200
+ });
201
+ it('produces an unlink once the last enabled owner is removed', () => {
202
+ const result = (0, install_planner_1.planRemoval)({
203
+ records: [recordOwnedBy('development-process', ['codex'])],
204
+ selectedAgents: ['codex'],
205
+ enabledAgents: ['opencode', 'codex'],
206
+ artifactNames: ['development-process'],
207
+ });
208
+ expect(result.records).toEqual([]);
209
+ expect(result.operations).toHaveLength(1);
210
+ expect(result.operations[0].action).toBe('unlink');
211
+ });
212
+ it('leaves records for artifacts not named in artifactNames untouched', () => {
213
+ const other = recordOwnedBy('other-skill', ['codex']);
214
+ const result = (0, install_planner_1.planRemoval)({
215
+ records: [other],
216
+ selectedAgents: ['codex'],
217
+ enabledAgents: ['opencode', 'codex'],
218
+ artifactNames: ['development-process'],
219
+ });
220
+ expect(result.records).toEqual([other]);
221
+ expect(result.operations).toEqual([]);
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // tests/core/install-transaction.test.ts
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const install_transaction_1 = require("../../src/core/install-transaction");
11
+ const artifact_state_1 = require("../../src/core/artifact-state");
12
+ // Per CLAUDE.md: no test may touch the real ~/.awm. Every test here backs up
13
+ // or transacts through awmHome(), so HOME/AWM_HOME must point at an isolated
14
+ // tmpdir (pattern from tests/commands/hooks/install.test.ts).
15
+ let tmpHome;
16
+ let tmpWork;
17
+ let originalHome;
18
+ let originalAwmHome;
19
+ beforeEach(() => {
20
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-txn-home-'));
21
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-txn-work-'));
22
+ originalHome = process.env.HOME;
23
+ originalAwmHome = process.env.AWM_HOME;
24
+ process.env.HOME = tmpHome;
25
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
26
+ });
27
+ afterEach(() => {
28
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
29
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
30
+ if (originalHome === undefined)
31
+ delete process.env.HOME;
32
+ else
33
+ process.env.HOME = originalHome;
34
+ if (originalAwmHome === undefined)
35
+ delete process.env.AWM_HOME;
36
+ else
37
+ process.env.AWM_HOME = originalAwmHome;
38
+ });
39
+ function makeOp(name, overrides = {}) {
40
+ return {
41
+ name,
42
+ type: 'skill',
43
+ scope: 'local',
44
+ targetPath: path_1.default.join(tmpWork, name),
45
+ sourcePath: path_1.default.join(tmpWork, `${name}-source`),
46
+ renderer: 'link',
47
+ owners: ['claude-code'],
48
+ method: 'copy',
49
+ output: 'link',
50
+ ...overrides,
51
+ };
52
+ }
53
+ function planWithTwoTargets() {
54
+ const a = makeOp('a');
55
+ const b = makeOp('b');
56
+ return { operations: [a, b], records: [], reports: [] };
57
+ }
58
+ function planWithThreeTargets() {
59
+ const a = makeOp('a');
60
+ const b = makeOp('b');
61
+ const c = makeOp('c');
62
+ return { operations: [a, b, c], records: [], reports: [] };
63
+ }
64
+ /**
65
+ * Builds a plan whose second operation ("b") is guaranteed to be replaced
66
+ * (real filesystem write) before verification runs, letting the test force a
67
+ * verification failure via a `verify` override while still exercising the
68
+ * REAL default backup/stage/replace/rollback pipeline underneath.
69
+ */
70
+ function planThatFailsSecondVerification(original) {
71
+ const sourceA = path_1.default.join(tmpWork, 'source-a');
72
+ fs_1.default.mkdirSync(sourceA, { recursive: true });
73
+ fs_1.default.writeFileSync(path_1.default.join(sourceA, 'file.txt'), 'new-a');
74
+ const sourceB = path_1.default.join(tmpWork, 'source-b');
75
+ fs_1.default.mkdirSync(sourceB, { recursive: true });
76
+ fs_1.default.writeFileSync(path_1.default.join(sourceB, 'file.txt'), 'new-b');
77
+ const opA = makeOp('a', { targetPath: original, sourcePath: sourceA });
78
+ const opB = makeOp('b', { targetPath: path_1.default.join(tmpWork, 'target-b'), sourcePath: sourceB });
79
+ return { operations: [opA, opB], records: [], reports: [] };
80
+ }
81
+ describe('applyInstallPlan', () => {
82
+ it('validates and backs up every target before the first replacement', () => {
83
+ const calls = [];
84
+ (0, install_transaction_1.applyInstallPlan)(planWithTwoTargets(), {
85
+ validate: (op) => calls.push(`validate:${op.name}`),
86
+ backup: (op) => { calls.push(`backup:${op.name}`); return null; },
87
+ stage: (op) => { calls.push(`stage:${op.name}`); return `/staged/${op.name}`; },
88
+ replace: (op) => calls.push(`replace:${op.name}`),
89
+ verify: (op) => calls.push(`verify:${op.name}`),
90
+ rollback: (op) => calls.push(`rollback:${op.name}`),
91
+ });
92
+ expect(calls.indexOf('validate:a')).toBeLessThan(calls.indexOf('backup:a'));
93
+ expect(calls.indexOf('backup:b')).toBeLessThan(calls.indexOf('replace:a')); // verifies R24.1
94
+ });
95
+ it('restores already replaced targets when verification fails', () => {
96
+ const original = path_1.default.join(tmpWork, 'target-a');
97
+ fs_1.default.mkdirSync(original, { recursive: true });
98
+ fs_1.default.writeFileSync(path_1.default.join(original, 'sentinel'), 'before');
99
+ const plan = planThatFailsSecondVerification(original);
100
+ const deps = {
101
+ ...(0, install_transaction_1.defaultTransactionDeps)(),
102
+ verify: (op) => {
103
+ if (op.name === 'b')
104
+ throw new Error('verification failed: forced for test');
105
+ },
106
+ };
107
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, deps)).toThrow('verification failed');
108
+ expect(fs_1.default.readFileSync(path_1.default.join(original, 'sentinel'), 'utf8')).toBe('before'); // verifies R25
109
+ });
110
+ it('returns a transactionId and the list of modified files on success', () => {
111
+ const sourceA = path_1.default.join(tmpWork, 'source-a');
112
+ fs_1.default.mkdirSync(sourceA, { recursive: true });
113
+ fs_1.default.writeFileSync(path_1.default.join(sourceA, 'file.txt'), 'content-a');
114
+ const targetA = path_1.default.join(tmpWork, 'target-a');
115
+ const plan = {
116
+ operations: [makeOp('a', { targetPath: targetA, sourcePath: sourceA })],
117
+ records: [],
118
+ reports: [{ owner: 'claude-code', targetPath: targetA, action: 'install' }],
119
+ };
120
+ const summary = (0, install_transaction_1.applyInstallPlan)(plan);
121
+ expect(summary.transactionId).toBeTruthy();
122
+ expect(summary.modifiedFiles).toEqual([targetA]);
123
+ expect(fs_1.default.existsSync(path_1.default.join(targetA, 'file.txt'))).toBe(true);
124
+ const backupDir = path_1.default.join(process.env.AWM_HOME, 'backups', summary.transactionId);
125
+ expect(fs_1.default.existsSync(path_1.default.join(backupDir, 'manifest.json'))).toBe(true);
126
+ });
127
+ it('is a safe no-op for an empty plan', () => {
128
+ const summary = (0, install_transaction_1.applyInstallPlan)({ operations: [], records: [], reports: [] });
129
+ expect(summary.installed).toEqual([]);
130
+ expect(summary.modifiedFiles).toEqual([]);
131
+ expect(summary.transactionId).toBeTruthy();
132
+ });
133
+ it('keeps rolling back remaining targets, and still surfaces the original error, when a MIDDLE rollback fails', () => {
134
+ // 3 ops (a, b, c) all replaced; verification fails on 'c', so rollback
135
+ // runs in reverse order: c, b, a. Making 'b' — the middle op in that
136
+ // reversed order, with an op both before and after it in the loop —
137
+ // the one that throws is what actually proves the per-op try/catch
138
+ // isolates failures: 'a' still runs AFTER 'b' throws, not just
139
+ // trivially "nothing left to run" as a last-in-order failure would.
140
+ const calls = [];
141
+ const plan = planWithThreeTargets();
142
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, {
143
+ validate: () => { },
144
+ backup: () => null,
145
+ stage: (op) => `/staged/${op.name}`,
146
+ replace: (op) => calls.push(`replace:${op.name}`),
147
+ verify: (op) => { if (op.name === 'c')
148
+ throw new Error('verification failed: forced'); },
149
+ rollback: (op) => {
150
+ calls.push(`rollback:${op.name}`);
151
+ if (op.name === 'b')
152
+ throw new Error('rollback failed for b (simulated)');
153
+ },
154
+ })).toThrow('verification failed: forced');
155
+ // All three replaced ops got a rollback attempt, in reverse order —
156
+ // including 'a', which runs strictly after 'b' throws, proving the
157
+ // loop doesn't stop or skip ahead when a non-terminal rollback fails.
158
+ expect(calls).toEqual(['replace:a', 'replace:b', 'replace:c', 'rollback:c', 'rollback:b', 'rollback:a']);
159
+ });
160
+ describe('artifact-state persistence across multiple calls (BLOCKER regression)', () => {
161
+ // A real `awm init` run makes several separate applyInstallPlan calls
162
+ // in sequence — one per bundle (dev-core, each ambient bundle, each
163
+ // synced project extension). Each call's plan.records only covers the
164
+ // artifacts THAT call touched. Persisting must be additive: an
165
+ // earlier call's ownership records must survive a later call, not be
166
+ // silently discarded by a wholesale overwrite of state/artifacts.json.
167
+ function sourceFor(name) {
168
+ const src = path_1.default.join(tmpWork, `${name}-source`);
169
+ fs_1.default.mkdirSync(src, { recursive: true });
170
+ fs_1.default.writeFileSync(path_1.default.join(src, 'file.txt'), name);
171
+ return src;
172
+ }
173
+ function planFor(name, targetPath) {
174
+ const op = makeOp(name, { targetPath, sourcePath: sourceFor(name) });
175
+ return { operations: [op], records: [op], reports: [{ owner: 'claude-code', targetPath, action: 'install' }] };
176
+ }
177
+ it('preserves an earlier applyInstallPlan call’s records after a later call for a DIFFERENT target', () => {
178
+ const targetA = path_1.default.join(tmpWork, 'target-a');
179
+ const targetB = path_1.default.join(tmpWork, 'target-b');
180
+ (0, install_transaction_1.applyInstallPlan)(planFor('a', targetA));
181
+ (0, install_transaction_1.applyInstallPlan)(planFor('b', targetB));
182
+ const state = (0, artifact_state_1.readArtifactState)();
183
+ expect(state.map((r) => r.targetPath).sort()).toEqual([targetA, targetB].sort());
184
+ });
185
+ it('replaces (does not duplicate) the record for a targetPath touched by two separate applyInstallPlan calls', () => {
186
+ const targetA = path_1.default.join(tmpWork, 'target-a');
187
+ (0, install_transaction_1.applyInstallPlan)(planFor('a', targetA));
188
+ const secondOp = makeOp('a', { targetPath: targetA, sourcePath: sourceFor('a2'), owners: ['claude-code', 'codex'] });
189
+ (0, install_transaction_1.applyInstallPlan)({
190
+ operations: [secondOp],
191
+ records: [secondOp],
192
+ reports: [{ owner: 'codex', targetPath: targetA, action: 'retain' }],
193
+ });
194
+ const state = (0, artifact_state_1.readArtifactState)();
195
+ expect(state).toHaveLength(1);
196
+ expect(state[0].targetPath).toBe(targetA);
197
+ expect(state[0].owners).toEqual(['claude-code', 'codex']);
198
+ });
199
+ });
200
+ });
201
+ describe('beginBackupSession / restoreBackup', () => {
202
+ it('backs up existing targets before mutation and restores them on rollback', () => {
203
+ const fileA = path_1.default.join(tmpWork, 'a.json');
204
+ const fileB = path_1.default.join(tmpWork, 'b.json');
205
+ fs_1.default.writeFileSync(fileA, 'A-before');
206
+ fs_1.default.writeFileSync(fileB, 'B-before');
207
+ const session = (0, install_transaction_1.beginBackupSession)([fileA, fileB]);
208
+ fs_1.default.writeFileSync(fileA, 'A-after');
209
+ fs_1.default.writeFileSync(fileB, 'B-after');
210
+ session.rollback();
211
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('A-before');
212
+ expect(fs_1.default.readFileSync(fileB, 'utf8')).toBe('B-before');
213
+ });
214
+ it('removes a target that did not exist before the session, on rollback', () => {
215
+ const newFile = path_1.default.join(tmpWork, 'created.json');
216
+ const session = (0, install_transaction_1.beginBackupSession)([newFile]);
217
+ fs_1.default.writeFileSync(newFile, 'created content');
218
+ session.rollback();
219
+ expect(fs_1.default.existsSync(newFile)).toBe(false);
220
+ });
221
+ it('commit marks the manifest committed without restoring anything', () => {
222
+ const fileA = path_1.default.join(tmpWork, 'c.json');
223
+ fs_1.default.writeFileSync(fileA, 'C-before');
224
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
225
+ fs_1.default.writeFileSync(fileA, 'C-after');
226
+ session.commit();
227
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('C-after');
228
+ const manifestPath = path_1.default.join(process.env.AWM_HOME, 'backups', session.transactionId, 'manifest.json');
229
+ const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf8'));
230
+ expect(manifest.committed).toBe(true);
231
+ });
232
+ it('refuses to back up a filesystem root', () => {
233
+ expect(() => (0, install_transaction_1.beginBackupSession)(['/'])).toThrow('refusing to back up a filesystem root');
234
+ });
235
+ it('creates the backup directory and manifest with restrictive permissions, no content leaked', () => {
236
+ const fileA = path_1.default.join(tmpWork, 'perm.json');
237
+ fs_1.default.writeFileSync(fileA, 'secret-content');
238
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
239
+ const backupDir = path_1.default.join(process.env.AWM_HOME, 'backups', session.transactionId);
240
+ const dirMode = fs_1.default.statSync(backupDir).mode & 0o777;
241
+ const manifestPath = path_1.default.join(backupDir, 'manifest.json');
242
+ const manifestMode = fs_1.default.statSync(manifestPath).mode & 0o777;
243
+ expect(dirMode).toBe(0o700);
244
+ expect(manifestMode).toBe(0o600);
245
+ const manifestRaw = fs_1.default.readFileSync(manifestPath, 'utf8');
246
+ expect(manifestRaw).not.toContain('secret-content');
247
+ });
248
+ it('restoreBackup restores exactly the manifest targets', () => {
249
+ const fileA = path_1.default.join(tmpWork, 'd.json');
250
+ fs_1.default.writeFileSync(fileA, 'D-before');
251
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
252
+ fs_1.default.writeFileSync(fileA, 'D-after');
253
+ const result = (0, install_transaction_1.restoreBackup)(session.transactionId);
254
+ expect(result.restored).toEqual([fileA]);
255
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('D-before');
256
+ });
257
+ });
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const provider_artifacts_1 = require("../../src/core/provider-artifacts");
10
+ describe('preflightLinkArtifactPairs', () => {
11
+ it('rejects a mixed Codex selection before any installer call', () => {
12
+ const install = jest.fn();
13
+ expect(() => {
14
+ const pairs = [
15
+ { agent: 'codex', artifact: { type: 'skill', name: 'linked-skill' } },
16
+ { agent: 'codex', artifact: { type: 'agent', name: 'toml-agent' } },
17
+ ];
18
+ (0, provider_artifacts_1.preflightLinkArtifactPairs)(pairs);
19
+ for (const item of pairs)
20
+ install(item);
21
+ }).toThrow("Renderer 'codex-agent-toml' for codex agent artifacts is not implemented yet");
22
+ expect(install).not.toHaveBeenCalled();
23
+ });
24
+ });
25
+ describe('scanLegacyArtifacts', () => {
26
+ const originalHome = process.env.HOME;
27
+ const originalAwmHome = process.env.AWM_HOME;
28
+ let tmpHome;
29
+ beforeEach(() => {
30
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-artifacts-'));
31
+ process.env.HOME = tmpHome;
32
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
33
+ });
34
+ afterEach(() => {
35
+ if (originalHome === undefined)
36
+ delete process.env.HOME;
37
+ else
38
+ process.env.HOME = originalHome;
39
+ if (originalAwmHome === undefined)
40
+ delete process.env.AWM_HOME;
41
+ else
42
+ process.env.AWM_HOME = originalAwmHome;
43
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
44
+ });
45
+ it('never lists Codex TOML agents for legacy removal', () => {
46
+ const skillPath = path_1.default.join(tmpHome, '.agents/skills/linked-skill');
47
+ const agentPath = path_1.default.join(tmpHome, '.codex/agents/protected.toml');
48
+ fs_1.default.mkdirSync(skillPath, { recursive: true });
49
+ fs_1.default.mkdirSync(path_1.default.dirname(agentPath), { recursive: true });
50
+ fs_1.default.writeFileSync(agentPath, 'name = "protected"\n');
51
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
52
+ const legacyRemove = jest.fn((target) => fs_1.default.rmSync(target, {
53
+ recursive: true,
54
+ force: true,
55
+ }));
56
+ for (const artifact of listed) {
57
+ for (const fullPath of artifact.fullPaths)
58
+ legacyRemove(fullPath);
59
+ }
60
+ expect(listed.map((artifact) => artifact.name)).toEqual(['linked-skill']);
61
+ expect(legacyRemove).not.toHaveBeenCalledWith(agentPath);
62
+ expect(fs_1.default.existsSync(agentPath)).toBe(true);
63
+ });
64
+ });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const provider_version_1 = require("../../src/core/provider-version");
4
+ describe('assertProviderSupported', () => {
5
+ it('accepts the current stable Codex line', () => {
6
+ const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
7
+ expect((0, provider_version_1.assertProviderSupported)('codex', exec)).toEqual({
8
+ provider: 'codex',
9
+ version: '0.145.0',
10
+ });
11
+ expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.any(Object));
12
+ });
13
+ it.each(['0.145.1', '0.146.0', '1.0.0'])('accepts stable Codex version %s above the minimum', (version) => {
14
+ expect((0, provider_version_1.assertProviderSupported)('codex', () => Buffer.from(`codex-cli ${version}\n`))).toEqual({ provider: 'codex', version });
15
+ });
16
+ it.each([
17
+ [Buffer.from('codex-cli 0.144.9\n'), 'requires Codex >= 0.145.0'],
18
+ [Buffer.from('unknown\n'), 'could not parse Codex version'],
19
+ [Buffer.from('codex-cli 0.145.0-beta.1\n'), 'could not parse Codex version'],
20
+ [Buffer.from('codex-cli v0.145.0\n'), 'could not parse Codex version'],
21
+ [Buffer.from('codex-cli 0.145\n'), 'could not parse Codex version'],
22
+ [Buffer.from('unexpected-tool 9.9.9\n'), 'could not parse Codex version'],
23
+ [Buffer.from('notice: codex-cli 0.145.0\n'), 'could not parse Codex version'],
24
+ [Buffer.from('codex-cli stable 0.145.0\n'), 'could not parse Codex version'],
25
+ [Buffer.from('codex-cli 0.145.0 trailing\n'), 'could not parse Codex version'],
26
+ ])('rejects unsupported output without mutation', (output, message) => {
27
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => output)).toThrow(message);
28
+ });
29
+ it('returns a null version for providers without a version gate', () => {
30
+ const exec = jest.fn();
31
+ expect((0, provider_version_1.assertProviderSupported)('claude-code', exec)).toEqual({
32
+ provider: 'claude-code',
33
+ version: null,
34
+ });
35
+ expect(exec).not.toHaveBeenCalled();
36
+ });
37
+ it('reports a missing Codex binary distinctly', () => {
38
+ const missing = Object.assign(new Error('spawnSync codex ENOENT'), { code: 'ENOENT' });
39
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => { throw missing; }))
40
+ .toThrow('Codex is not installed or not available on PATH');
41
+ });
42
+ it('reports other Codex probe failures distinctly', () => {
43
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => {
44
+ throw new Error('permission denied');
45
+ })).toThrow('Codex version probe failed: permission denied');
46
+ });
47
+ it('fails loudly for invalid runtime provider input', () => {
48
+ const exec = jest.fn();
49
+ expect(() => (0, provider_version_1.assertProviderSupported)('invalid', exec))
50
+ .toThrow('Unknown agent target');
51
+ expect(exec).not.toHaveBeenCalled();
52
+ });
53
+ });