agentic-workflow-manager 3.1.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/add.js +84 -0
- package/dist/src/commands/agent.js +95 -0
- package/dist/src/commands/backup.js +46 -0
- package/dist/src/commands/doctor.js +80 -4
- package/dist/src/commands/hooks/claude.js +192 -0
- package/dist/src/commands/hooks/codex.js +191 -0
- package/dist/src/commands/hooks/index.js +23 -8
- package/dist/src/commands/hooks/install.js +11 -107
- package/dist/src/commands/hooks/resync.js +28 -18
- package/dist/src/commands/hooks/shared.js +94 -0
- package/dist/src/commands/hooks/status.js +13 -64
- package/dist/src/commands/hooks/uninstall.js +11 -47
- package/dist/src/commands/init.js +90 -28
- package/dist/src/commands/registry/index.js +1 -1
- package/dist/src/commands/sync.js +113 -0
- package/dist/src/commands/update.js +107 -0
- package/dist/src/core/agent-targets.js +50 -0
- package/dist/src/core/artifact-state.js +65 -0
- package/dist/src/core/atomic-file.js +72 -0
- package/dist/src/core/bundle-install.js +60 -38
- package/dist/src/core/bundles.js +21 -0
- package/dist/src/core/context/managed-block.js +190 -0
- package/dist/src/core/context/orchestrator.js +3 -1
- package/dist/src/core/context/project-constitution-inject.js +1 -1
- package/dist/src/core/context/regenerate.js +3 -3
- package/dist/src/core/context/strategies/codex-agents.js +100 -0
- package/dist/src/core/diagnostics/checks.js +16 -0
- package/dist/src/core/diagnostics/context.js +37 -8
- package/dist/src/core/diagnostics/provider-checks.js +219 -0
- package/dist/src/core/executor.js +30 -9
- package/dist/src/core/init/mutation-targets.js +139 -0
- package/dist/src/core/init/provider-facts.js +179 -0
- package/dist/src/core/init/steps.js +65 -9
- package/dist/src/core/install-planner.js +206 -0
- package/dist/src/core/install-transaction.js +405 -0
- package/dist/src/core/profile.js +1 -1
- package/dist/src/core/provider-artifacts.js +54 -0
- package/dist/src/core/provider-version.js +36 -0
- package/dist/src/core/reconciliation.js +61 -0
- package/dist/src/core/registries.js +10 -0
- package/dist/src/core/renderers/canonical-agent.js +27 -0
- package/dist/src/core/renderers/codex-agent.js +60 -0
- package/dist/src/core/skill-integrity.js +4 -3
- package/dist/src/index.js +97 -289
- package/dist/src/providers/index.js +143 -39
- package/dist/src/ui/provider-preflight.js +26 -0
- package/dist/src/utils/config.js +101 -9
- package/dist/tests/commands/agent.test.js +103 -0
- package/dist/tests/commands/backup.test.js +109 -0
- package/dist/tests/commands/doctor.test.js +65 -4
- package/dist/tests/commands/hooks/codex.test.js +230 -0
- package/dist/tests/commands/hooks/install.test.js +20 -1
- package/dist/tests/commands/hooks/resync.test.js +53 -6
- package/dist/tests/commands/hooks/status.test.js +10 -0
- package/dist/tests/commands/hooks/uninstall.test.js +12 -0
- package/dist/tests/commands/init.test.js +195 -8
- package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
- package/dist/tests/core/agent-targets.test.js +45 -0
- package/dist/tests/core/artifact-state.test.js +144 -0
- package/dist/tests/core/atomic-file.test.js +96 -0
- package/dist/tests/core/bundle-install.test.js +208 -17
- package/dist/tests/core/bundles.test.js +12 -0
- package/dist/tests/core/context/managed-block.test.js +90 -0
- package/dist/tests/core/context/orchestrator.test.js +48 -3
- package/dist/tests/core/context/regenerate.test.js +1 -1
- package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
- package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
- package/dist/tests/core/diagnostics/checks.test.js +19 -0
- package/dist/tests/core/diagnostics/context.test.js +74 -0
- package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
- package/dist/tests/core/executor.test.js +20 -0
- package/dist/tests/core/export/engine.test.js +3 -2
- package/dist/tests/core/init/mutation-targets.test.js +235 -0
- package/dist/tests/core/init/orchestrator.test.js +1 -1
- package/dist/tests/core/init/provider-facts.test.js +177 -0
- package/dist/tests/core/init/steps.test.js +106 -2
- package/dist/tests/core/install-planner.test.js +224 -0
- package/dist/tests/core/install-transaction.test.js +257 -0
- package/dist/tests/core/provider-artifacts.test.js +64 -0
- package/dist/tests/core/provider-version.test.js +53 -0
- package/dist/tests/core/reconciliation.test.js +165 -0
- package/dist/tests/core/registries-sync.test.js +4 -4
- package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
- package/dist/tests/core/renderers/codex-agent.test.js +128 -0
- package/dist/tests/core/versioning.test.js +1 -1
- package/dist/tests/integration/codex-provider-isolated.test.js +235 -0
- package/dist/tests/providers/hooks-config.test.js +43 -25
- package/dist/tests/providers/index.test.js +144 -39
- package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
- package/dist/tests/ui/provider-preflight.test.js +25 -0
- package/dist/tests/utils/config.test.js +178 -18
- package/package.json +1 -1
|
@@ -72,12 +72,21 @@ describe('runInit', () => {
|
|
|
72
72
|
});
|
|
73
73
|
it('returns exit 1 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
|
|
74
74
|
const { runInit } = require('../../src/commands/init');
|
|
75
|
-
const code = await runInit({
|
|
75
|
+
const code = await runInit({
|
|
76
|
+
cwd: tmpHome,
|
|
77
|
+
yes: true,
|
|
78
|
+
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
79
|
+
});
|
|
76
80
|
expect(code).toBe(1); // cache/hook/devCore siguen ausentes (syncCache no-op) → degradado
|
|
77
81
|
});
|
|
78
82
|
it('--json emits a parseable InitOutcome', async () => {
|
|
79
83
|
const { runInit } = require('../../src/commands/init');
|
|
80
|
-
const code = await runInit({
|
|
84
|
+
const code = await runInit({
|
|
85
|
+
cwd: tmpHome,
|
|
86
|
+
yes: true,
|
|
87
|
+
json: true,
|
|
88
|
+
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
89
|
+
});
|
|
81
90
|
const written = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
82
91
|
const parsed = JSON.parse(written);
|
|
83
92
|
expect(Array.isArray(parsed.steps)).toBe(true);
|
|
@@ -85,23 +94,201 @@ describe('runInit', () => {
|
|
|
85
94
|
expect(code).toBe(1);
|
|
86
95
|
});
|
|
87
96
|
const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
|
|
88
|
-
const
|
|
97
|
+
const readPreferences = () => JSON.parse(fs_1.default.readFileSync(prefsFile(), 'utf-8'));
|
|
98
|
+
const readAgent = () => readPreferences().defaultAgent;
|
|
99
|
+
const readPrefs = () => readPreferences();
|
|
100
|
+
function writePrefs(prefs) {
|
|
101
|
+
const { savePreferences } = require('../../src/utils/config');
|
|
102
|
+
savePreferences(prefs);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Full no-op InitActions stub set — every member records its own token
|
|
106
|
+
* onto `calls` (in invocation order) and returns a benign, type-shaped
|
|
107
|
+
* value so a whole `runInitSteps` pass can complete without touching real
|
|
108
|
+
* registry content. Used both to keep the "does the pipeline actually
|
|
109
|
+
* succeed" tests independent of real registry content, and to observe
|
|
110
|
+
* call order (install-hook / install-bundle) relative to the gate/backup/
|
|
111
|
+
* save-preferences sequence.
|
|
112
|
+
*/
|
|
113
|
+
function fakeActions(calls) {
|
|
114
|
+
return {
|
|
115
|
+
syncCache: async () => { calls.push('syncCache'); },
|
|
116
|
+
installHook: () => { calls.push('install-hook'); return { status: 'installed' }; },
|
|
117
|
+
installBundle: () => { calls.push('install-bundle'); return { installed: [], skipped: [] }; },
|
|
118
|
+
syncProfile: () => { calls.push('syncProfile'); return { installed: [], skipped: [], extensions: [] }; },
|
|
119
|
+
initSensors: () => { calls.push('initSensors'); return { detection: { pack: 'generic' } }; },
|
|
120
|
+
addExtension: () => { calls.push('addExtension'); },
|
|
121
|
+
ensureProfile: () => { calls.push('ensureProfile'); },
|
|
122
|
+
gatherProject: () => { calls.push('gatherProject'); return null; },
|
|
123
|
+
contextStatus: () => { calls.push('contextStatus'); return 'absent'; },
|
|
124
|
+
installContext: () => { calls.push('installContext'); },
|
|
125
|
+
repairGlobalSkills: () => { calls.push('repairGlobalSkills'); return { relinked: [], pruned: [], failed: [] }; },
|
|
126
|
+
injectProjectConstitution: () => { calls.push('injectProjectConstitution'); return 'injected'; },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function codexInitOptions(calls = []) {
|
|
130
|
+
return {
|
|
131
|
+
cwd: tmpHome,
|
|
132
|
+
yes: true,
|
|
133
|
+
agent: 'codex',
|
|
134
|
+
assertProviderSupported: () => ({ provider: 'codex', version: '0.150.0' }),
|
|
135
|
+
actions: fakeActions(calls),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Cheap recursive content snapshot for "did this subtree change at all" assertions. */
|
|
139
|
+
function snapshotTree(dir) {
|
|
140
|
+
if (!fs_1.default.existsSync(dir))
|
|
141
|
+
return null;
|
|
142
|
+
const walk = (p) => {
|
|
143
|
+
const st = fs_1.default.lstatSync(p);
|
|
144
|
+
if (st.isSymbolicLink())
|
|
145
|
+
return { type: 'symlink', target: fs_1.default.readlinkSync(p) };
|
|
146
|
+
if (st.isDirectory()) {
|
|
147
|
+
const entries = fs_1.default.readdirSync(p).sort();
|
|
148
|
+
return { type: 'dir', entries: Object.fromEntries(entries.map((e) => [e, walk(path_1.default.join(p, e))])) };
|
|
149
|
+
}
|
|
150
|
+
return { type: 'file', content: fs_1.default.readFileSync(p, 'utf8') };
|
|
151
|
+
};
|
|
152
|
+
return walk(dir);
|
|
153
|
+
}
|
|
89
154
|
it('#7: first init (no -a) persists claude-code as the default agent', async () => {
|
|
90
155
|
const { runInit } = require('../../src/commands/init');
|
|
91
156
|
expect(fs_1.default.existsSync(prefsFile())).toBe(false);
|
|
92
|
-
await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { } } });
|
|
157
|
+
await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) } });
|
|
93
158
|
expect(readAgent()).toBe('claude-code');
|
|
94
159
|
});
|
|
95
160
|
it('#7: init -a opencode persists the explicit agent', async () => {
|
|
96
161
|
const { runInit } = require('../../src/commands/init');
|
|
97
|
-
|
|
162
|
+
const code = await runInit({
|
|
163
|
+
cwd: tmpHome,
|
|
164
|
+
yes: true,
|
|
165
|
+
agent: 'opencode',
|
|
166
|
+
actions: fakeActions([]),
|
|
167
|
+
});
|
|
168
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
98
169
|
expect(readAgent()).toBe('opencode');
|
|
170
|
+
expect(readPreferences().enabledAgents).toContain('opencode');
|
|
99
171
|
});
|
|
100
172
|
it('#7: re-init without -a does NOT clobber an existing explicit preference', async () => {
|
|
101
|
-
|
|
102
|
-
|
|
173
|
+
writePrefs({
|
|
174
|
+
defaultAgent: 'opencode',
|
|
175
|
+
enabledAgents: ['opencode'],
|
|
176
|
+
installMethod: 'symlink',
|
|
177
|
+
defaultScope: 'local',
|
|
178
|
+
});
|
|
103
179
|
const { runInit } = require('../../src/commands/init');
|
|
104
|
-
await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { } } });
|
|
180
|
+
await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) } });
|
|
105
181
|
expect(readAgent()).toBe('opencode');
|
|
106
182
|
});
|
|
183
|
+
it('enabling a new default preserves every previously enabled agent', async () => {
|
|
184
|
+
writePrefs({
|
|
185
|
+
defaultAgent: 'claude-code',
|
|
186
|
+
enabledAgents: ['claude-code', 'codex'],
|
|
187
|
+
installMethod: 'symlink',
|
|
188
|
+
defaultScope: 'local',
|
|
189
|
+
});
|
|
190
|
+
const { runInit } = require('../../src/commands/init');
|
|
191
|
+
const code = await runInit({
|
|
192
|
+
cwd: tmpHome,
|
|
193
|
+
yes: true,
|
|
194
|
+
agent: 'opencode',
|
|
195
|
+
actions: fakeActions([]),
|
|
196
|
+
});
|
|
197
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
198
|
+
expect(readPreferences().enabledAgents)
|
|
199
|
+
.toEqual(['claude-code', 'codex', 'opencode']);
|
|
200
|
+
});
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
// Step 1 — gate ordering, backup/rollback atomicity, Codex/Claude coexistence
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
204
|
+
it('gates Codex before preferences or provider writes', async () => {
|
|
205
|
+
const calls = [];
|
|
206
|
+
const { runInit } = require('../../src/commands/init');
|
|
207
|
+
const code = await runInit({
|
|
208
|
+
cwd: tmpHome,
|
|
209
|
+
agent: 'codex',
|
|
210
|
+
yes: true,
|
|
211
|
+
actions: fakeActions(calls),
|
|
212
|
+
assertProviderSupported: () => {
|
|
213
|
+
calls.push('version-gate');
|
|
214
|
+
throw new Error('requires Codex >= 0.145.0');
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
expect(code).toBe(2);
|
|
218
|
+
expect(calls).toEqual(['version-gate']); // verifies R2
|
|
219
|
+
expect(fs_1.default.existsSync(prefsFile())).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
it('enables Codex without changing the existing default or Claude files', async () => {
|
|
222
|
+
writePrefs({
|
|
223
|
+
defaultAgent: 'claude-code',
|
|
224
|
+
enabledAgents: ['claude-code', 'opencode'],
|
|
225
|
+
installMethod: 'symlink',
|
|
226
|
+
defaultScope: 'local',
|
|
227
|
+
});
|
|
228
|
+
const claudeBefore = snapshotTree(path_1.default.join(tmpHome, '.claude'));
|
|
229
|
+
const { runInit } = require('../../src/commands/init');
|
|
230
|
+
const code = await runInit(codexInitOptions());
|
|
231
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
232
|
+
expect(readPrefs().defaultAgent).toBe('claude-code');
|
|
233
|
+
expect(readPrefs().enabledAgents).toEqual(['claude-code', 'opencode', 'codex']); // verifies R11
|
|
234
|
+
expect(snapshotTree(path_1.default.join(tmpHome, '.claude'))).toEqual(claudeBefore); // verifies R19
|
|
235
|
+
});
|
|
236
|
+
it('runs one session in order: version-gate < begin-backup < save-preferences < install-hook < install-bundle', async () => {
|
|
237
|
+
const calls = [];
|
|
238
|
+
const installTransaction = require('../../src/core/install-transaction');
|
|
239
|
+
const realBeginBackupSession = installTransaction.beginBackupSession;
|
|
240
|
+
jest.spyOn(installTransaction, 'beginBackupSession').mockImplementation((...args) => {
|
|
241
|
+
calls.push('begin-backup');
|
|
242
|
+
return realBeginBackupSession(...args);
|
|
243
|
+
});
|
|
244
|
+
const config = require('../../src/utils/config');
|
|
245
|
+
const realSavePreferences = config.savePreferences;
|
|
246
|
+
jest.spyOn(config, 'savePreferences').mockImplementation((...args) => {
|
|
247
|
+
calls.push('save-preferences');
|
|
248
|
+
return realSavePreferences(...args);
|
|
249
|
+
});
|
|
250
|
+
const { runInit } = require('../../src/commands/init');
|
|
251
|
+
const code = await runInit({
|
|
252
|
+
cwd: tmpHome,
|
|
253
|
+
yes: true,
|
|
254
|
+
actions: fakeActions(calls),
|
|
255
|
+
assertProviderSupported: () => {
|
|
256
|
+
calls.push('version-gate');
|
|
257
|
+
return { provider: 'claude-code', version: null };
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
261
|
+
const relevant = calls.filter((c) => ['version-gate', 'begin-backup', 'save-preferences', 'install-hook', 'install-bundle'].includes(c));
|
|
262
|
+
expect(relevant).toEqual(['version-gate', 'begin-backup', 'save-preferences', 'install-hook', 'install-bundle']);
|
|
263
|
+
});
|
|
264
|
+
it('rolls back a genuinely-modified target (real hook install) when a later step fails', async () => {
|
|
265
|
+
// Seed just enough real registry content for the REAL installHook to
|
|
266
|
+
// succeed and actually merge an AWM entry into settings.json.
|
|
267
|
+
const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
|
|
268
|
+
fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
|
|
269
|
+
fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', 'session-start'), '#!/bin/sh\n', { mode: 0o755 });
|
|
270
|
+
fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', 'run-hook.cmd'), '#!/bin/sh\n', { mode: 0o755 });
|
|
271
|
+
fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', 'using-awm'), { recursive: true });
|
|
272
|
+
fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'skills', 'using-awm', 'SKILL.md'), '# using-awm');
|
|
273
|
+
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2));
|
|
274
|
+
const settingsPath = path_1.default.join(tmpHome, '.claude', 'settings.json');
|
|
275
|
+
fs_1.default.mkdirSync(path_1.default.dirname(settingsPath), { recursive: true });
|
|
276
|
+
fs_1.default.writeFileSync(settingsPath, JSON.stringify({ pristine: true, unrelated: 'keep' }, null, 2));
|
|
277
|
+
const calls = [];
|
|
278
|
+
const actions = fakeActions(calls);
|
|
279
|
+
delete actions.installHook; // let the REAL installHook run and genuinely modify settings.json
|
|
280
|
+
actions.installBundle = () => { throw new Error('boom'); }; // devCore step fails AFTER hook succeeded
|
|
281
|
+
const { runInit } = require('../../src/commands/init');
|
|
282
|
+
const code = await runInit({
|
|
283
|
+
cwd: tmpHome,
|
|
284
|
+
yes: true,
|
|
285
|
+
actions,
|
|
286
|
+
});
|
|
287
|
+
expect(code).toBe(2);
|
|
288
|
+
// preferences.json didn't exist before this run — rollback removes it again
|
|
289
|
+
expect(fs_1.default.existsSync(prefsFile())).toBe(false);
|
|
290
|
+
// settings.json WAS genuinely modified mid-run (real installHook ran) —
|
|
291
|
+
// rollback must restore its exact pre-run content, not just leave it be.
|
|
292
|
+
expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
|
|
293
|
+
});
|
|
107
294
|
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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/commands/multi-agent-targeting.test.ts
|
|
7
|
+
//
|
|
8
|
+
// R12/R13/R14: `add`, `remove`, `sync`, `update` and `doctor` must all resolve
|
|
9
|
+
// agent targets the same way — every enabled agent when `--agent` is absent,
|
|
10
|
+
// an explicit comma-separated subset when present — and `add` must refuse a
|
|
11
|
+
// selection that would strand a shared artifact target (R14).
|
|
12
|
+
//
|
|
13
|
+
// Each command exposes (or is wired to) a UI-free core that this file drives
|
|
14
|
+
// directly, injecting spies where the command has a real side effect
|
|
15
|
+
// (installBundle / syncProfile / planReconciliation) so the test observes
|
|
16
|
+
// exactly which agent targets that side effect received, without touching
|
|
17
|
+
// real registry content or the network.
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const os_1 = __importDefault(require("os"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
|
|
22
|
+
let tmpHome;
|
|
23
|
+
let originalHome;
|
|
24
|
+
let originalAwmHome;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-targeting-'));
|
|
27
|
+
originalHome = process.env.HOME;
|
|
28
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
29
|
+
process.env.HOME = tmpHome;
|
|
30
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
31
|
+
jest.resetModules();
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
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
|
+
});
|
|
44
|
+
function writePrefs(enabledAgents, defaultAgent = enabledAgents[0]) {
|
|
45
|
+
const { savePreferences } = require('../../src/utils/config');
|
|
46
|
+
const prefs = {
|
|
47
|
+
defaultAgent,
|
|
48
|
+
enabledAgents,
|
|
49
|
+
installMethod: 'symlink',
|
|
50
|
+
defaultScope: 'local',
|
|
51
|
+
};
|
|
52
|
+
savePreferences(prefs);
|
|
53
|
+
return prefs;
|
|
54
|
+
}
|
|
55
|
+
function bundle(over = {}) {
|
|
56
|
+
return {
|
|
57
|
+
name: 'demo', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
|
|
58
|
+
dependsOn: [], skills: [{ name: 'demo-skill', onSignal: false }], workflows: [], agents: [],
|
|
59
|
+
...over,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function invokeCommandWithPlannerSpy(command, explicit) {
|
|
63
|
+
const prefs = writePrefs(['claude-code', 'opencode', 'codex'], 'claude-code');
|
|
64
|
+
switch (command) {
|
|
65
|
+
case 'add': {
|
|
66
|
+
const { runAddBundleCore } = require('../../src/commands/add');
|
|
67
|
+
let captured = [];
|
|
68
|
+
const outcome = runAddBundleCore({ name: 'demo', agent: explicit, cwd: tmpHome }, prefs, [bundle()], { addBundle: (o) => { captured = o.agents; return { installed: [], skipped: [], recordedExtension: null, transactionId: 'tx', modifiedFiles: [] }; } });
|
|
69
|
+
return { selectedAgents: captured, code: outcome.code };
|
|
70
|
+
}
|
|
71
|
+
case 'remove': {
|
|
72
|
+
// remove has no bundle-name concept — it resolves targets the same
|
|
73
|
+
// way as the other 4 commands (index.ts calls resolveAgentTargets
|
|
74
|
+
// directly), so this exercises that exact function.
|
|
75
|
+
const { resolveAgentTargets } = require('../../src/core/agent-targets');
|
|
76
|
+
const selectedAgents = resolveAgentTargets({ prefs, explicit });
|
|
77
|
+
return { selectedAgents };
|
|
78
|
+
}
|
|
79
|
+
case 'sync': {
|
|
80
|
+
const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-project-'));
|
|
81
|
+
fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
|
|
82
|
+
fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['demo'] }));
|
|
83
|
+
const { runSyncCore } = require('../../src/commands/sync');
|
|
84
|
+
let captured = [];
|
|
85
|
+
const outcome = await runSyncCore({ cwd: projectRoot, agent: explicit }, {
|
|
86
|
+
syncRegistries: async () => [],
|
|
87
|
+
verifyMinCliVersions: () => [],
|
|
88
|
+
verifyProjectPins: async () => [],
|
|
89
|
+
syncProfile: (o) => { captured = o.agents; return { installed: [], skipped: [], extensions: ['demo'], transactionIds: [], modifiedFiles: [] }; },
|
|
90
|
+
});
|
|
91
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
92
|
+
return { selectedAgents: captured, code: outcome.code };
|
|
93
|
+
}
|
|
94
|
+
case 'update': {
|
|
95
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
96
|
+
let captured = [];
|
|
97
|
+
const outcome = await runUpdateCore({ agent: explicit }, {
|
|
98
|
+
syncRegistries: async () => [],
|
|
99
|
+
verifyMinCliVersions: () => [],
|
|
100
|
+
regenerateGlobalContext: () => [],
|
|
101
|
+
planReconciliation: (o) => { captured = o.targets; return { operations: [], records: [], reports: [] }; },
|
|
102
|
+
applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
|
|
103
|
+
resyncInstalledHooks: () => [],
|
|
104
|
+
offerSelfUpdate: async () => { },
|
|
105
|
+
});
|
|
106
|
+
return { selectedAgents: captured, code: outcome.code };
|
|
107
|
+
}
|
|
108
|
+
case 'doctor': {
|
|
109
|
+
const { runDoctor } = require('../../src/commands/doctor');
|
|
110
|
+
let captured = [];
|
|
111
|
+
const { resolveAgentTargets } = require('../../src/core/agent-targets');
|
|
112
|
+
const code = runDoctor({
|
|
113
|
+
cwd: tmpHome,
|
|
114
|
+
agent: explicit,
|
|
115
|
+
resolveTargets: (input) => {
|
|
116
|
+
const resolved = resolveAgentTargets(input);
|
|
117
|
+
captured = resolved;
|
|
118
|
+
return resolved;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return { selectedAgents: captured, code };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
it.each(['add', 'remove', 'sync', 'update', 'doctor'])('%s targets all enabled providers when --agent is absent', async (command) => {
|
|
126
|
+
const observed = await invokeCommandWithPlannerSpy(command, undefined);
|
|
127
|
+
expect(observed.selectedAgents).toEqual(['claude-code', 'opencode', 'codex']); // verifies R12
|
|
128
|
+
});
|
|
129
|
+
it.each(['add', 'remove', 'sync', 'update', 'doctor'])('%s honors an independently addressable explicit subset', async (command) => {
|
|
130
|
+
const observed = await invokeCommandWithPlannerSpy(command, 'codex,claude-code');
|
|
131
|
+
expect(observed.selectedAgents).toEqual(['codex', 'claude-code']); // verifies R13
|
|
132
|
+
});
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// R14 — shared-target completeness
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
it('add refuses a codex-only skill install when OpenCode (sharing the same skill dir) is also enabled', () => {
|
|
137
|
+
writePrefs(['opencode', 'codex'], 'opencode');
|
|
138
|
+
const { runAddBundleCore } = require('../../src/commands/add');
|
|
139
|
+
const { getPreferences } = require('../../src/utils/config');
|
|
140
|
+
const outcome = runAddBundleCore({ name: 'demo', agent: 'codex', cwd: tmpHome }, getPreferences(), [bundle()]);
|
|
141
|
+
expect(outcome.code).toBe(1); // verifies R14 — planInstall's assertCompleteSharedGroup rejects the partial selection
|
|
142
|
+
});
|
|
143
|
+
// remove does NOT reproduce add's shared-target refusal by design: R16
|
|
144
|
+
// says a shared target is *retained* (not deleted, not errored) as long
|
|
145
|
+
// as another enabled owner remains. Selecting only 'codex' to remove a
|
|
146
|
+
// skill OpenCode still owns must succeed and leave the shared symlink in
|
|
147
|
+
// place — documented here since the plan text's "add/remove both fail"
|
|
148
|
+
// phrasing doesn't hold for remove once R16 is taken into account (see
|
|
149
|
+
// install-planner.ts's planRemoval, which has no assertCompleteSharedGroup
|
|
150
|
+
// equivalent, unlike planInstall).
|
|
151
|
+
it('remove retains (does not error on) a shared skill target still owned by another enabled agent', () => {
|
|
152
|
+
const { planRemoval } = require('../../src/core/install-planner');
|
|
153
|
+
const shared = {
|
|
154
|
+
name: 'demo-skill', type: 'skill', scope: 'global',
|
|
155
|
+
targetPath: '/home/x/.agents/skills/demo-skill', sourcePath: '/registry/skills/demo-skill',
|
|
156
|
+
renderer: 'link', owners: ['opencode', 'codex'],
|
|
157
|
+
};
|
|
158
|
+
const plan = planRemoval({
|
|
159
|
+
records: [shared],
|
|
160
|
+
selectedAgents: ['codex'],
|
|
161
|
+
enabledAgents: ['opencode', 'codex'],
|
|
162
|
+
artifactNames: ['demo-skill'],
|
|
163
|
+
});
|
|
164
|
+
expect(plan.operations).toEqual([]); // no unlink — still owned by opencode
|
|
165
|
+
expect(plan.records).toEqual([{ ...shared, owners: ['opencode'] }]);
|
|
166
|
+
});
|
|
167
|
+
// -----------------------------------------------------------------------
|
|
168
|
+
// update: syncs each registry once, then reconciles the full target set once
|
|
169
|
+
// -----------------------------------------------------------------------
|
|
170
|
+
it('update syncs registries once and reconciles the resolved targets in a single plan/apply pass', async () => {
|
|
171
|
+
writePrefs(['claude-code', 'opencode', 'codex'], 'claude-code');
|
|
172
|
+
// Seed a hooks/ dir so capabilityRoot('hooks') resolves and the hook-resync stage actually runs.
|
|
173
|
+
const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
|
|
174
|
+
fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
|
|
175
|
+
fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2));
|
|
176
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
177
|
+
const syncRegistries = jest.fn(async () => []);
|
|
178
|
+
const planReconciliation = jest.fn((o) => ({ operations: [], records: [], reports: [] }));
|
|
179
|
+
const applyInstallPlan = jest.fn(() => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }));
|
|
180
|
+
const resyncInstalledHooks = jest.fn(() => []);
|
|
181
|
+
const outcome = await runUpdateCore({}, {
|
|
182
|
+
syncRegistries,
|
|
183
|
+
verifyMinCliVersions: () => [],
|
|
184
|
+
regenerateGlobalContext: () => [],
|
|
185
|
+
planReconciliation,
|
|
186
|
+
applyInstallPlan,
|
|
187
|
+
resyncInstalledHooks,
|
|
188
|
+
offerSelfUpdate: async () => { },
|
|
189
|
+
});
|
|
190
|
+
expect(outcome.code).toBe(0);
|
|
191
|
+
expect(syncRegistries).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(planReconciliation).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(applyInstallPlan).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(planReconciliation.mock.calls[0][0].targets).toEqual(['claude-code', 'opencode', 'codex']);
|
|
195
|
+
expect(resyncInstalledHooks).toHaveBeenCalledTimes(1); // once per update run, not once per provider
|
|
196
|
+
});
|
|
197
|
+
it('update reports the failing stage and returns a non-zero exit code instead of swallowing the error', async () => {
|
|
198
|
+
writePrefs(['claude-code'], 'claude-code');
|
|
199
|
+
const { runUpdateCore } = require('../../src/commands/update');
|
|
200
|
+
const outcome = await runUpdateCore({}, {
|
|
201
|
+
syncRegistries: async () => [],
|
|
202
|
+
verifyMinCliVersions: () => [],
|
|
203
|
+
regenerateGlobalContext: () => [],
|
|
204
|
+
planReconciliation: () => { throw new Error('reconciliation exploded'); },
|
|
205
|
+
applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
|
|
206
|
+
resyncInstalledHooks: () => [],
|
|
207
|
+
offerSelfUpdate: async () => { },
|
|
208
|
+
});
|
|
209
|
+
expect(outcome.code).toBe(1); // no silent catch{} — verifies Step 6's "no aborta" removal
|
|
210
|
+
});
|
|
211
|
+
it('sync reports a syncProfile failure and returns a non-zero exit code instead of crashing uncaught', async () => {
|
|
212
|
+
writePrefs(['claude-code'], 'claude-code');
|
|
213
|
+
const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-project-'));
|
|
214
|
+
fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
|
|
215
|
+
fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['demo'] }));
|
|
216
|
+
const { runSyncCore } = require('../../src/commands/sync');
|
|
217
|
+
let outcome;
|
|
218
|
+
try {
|
|
219
|
+
outcome = await runSyncCore({ cwd: projectRoot }, {
|
|
220
|
+
syncRegistries: async () => [],
|
|
221
|
+
verifyMinCliVersions: () => [],
|
|
222
|
+
verifyProjectPins: async () => [],
|
|
223
|
+
syncProfile: () => { throw new Error('physical target already claimed by a different source'); },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
expect(outcome.code).toBe(1); // no silent catch{} — syncProfile's throw must not propagate uncaught
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const agent_targets_1 = require("../../src/core/agent-targets");
|
|
4
|
+
const prefs = {
|
|
5
|
+
defaultAgent: 'claude-code',
|
|
6
|
+
enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
7
|
+
installMethod: 'symlink',
|
|
8
|
+
defaultScope: 'local',
|
|
9
|
+
};
|
|
10
|
+
describe('resolveAgentTargets', () => {
|
|
11
|
+
it('targets every enabled agent when --agent is absent', () => {
|
|
12
|
+
expect((0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: undefined }))
|
|
13
|
+
.toEqual(['claude-code', 'opencode', 'codex']);
|
|
14
|
+
});
|
|
15
|
+
it('returns a copy without mutating the enabled list', () => {
|
|
16
|
+
const enabledBefore = [...prefs.enabledAgents];
|
|
17
|
+
const resolved = (0, agent_targets_1.resolveAgentTargets)({ prefs });
|
|
18
|
+
expect(resolved).not.toBe(prefs.enabledAgents);
|
|
19
|
+
expect(prefs.enabledAgents).toEqual(enabledBefore);
|
|
20
|
+
});
|
|
21
|
+
it('preserves an exact explicit enabled subset', () => {
|
|
22
|
+
expect((0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: 'codex,claude-code' }))
|
|
23
|
+
.toEqual(['codex', 'claude-code']);
|
|
24
|
+
});
|
|
25
|
+
it('trims and deduplicates explicit agents in first-seen order', () => {
|
|
26
|
+
expect((0, agent_targets_1.resolveAgentTargets)({
|
|
27
|
+
prefs,
|
|
28
|
+
explicit: ' codex, claude-code,codex,opencode ',
|
|
29
|
+
})).toEqual(['codex', 'claude-code', 'opencode']);
|
|
30
|
+
});
|
|
31
|
+
it('rejects an explicit disabled provider', () => {
|
|
32
|
+
expect(() => (0, agent_targets_1.resolveAgentTargets)({
|
|
33
|
+
prefs: { ...prefs, enabledAgents: ['claude-code'] },
|
|
34
|
+
explicit: 'codex',
|
|
35
|
+
})).toThrow('codex is not enabled; run awm init --agent codex');
|
|
36
|
+
});
|
|
37
|
+
it.each(['', ' ', ' , , '])('rejects empty explicit input %j', (explicit) => {
|
|
38
|
+
expect(() => (0, agent_targets_1.resolveAgentTargets)({ prefs, explicit }))
|
|
39
|
+
.toThrow('--agent requires at least one provider');
|
|
40
|
+
});
|
|
41
|
+
it('rejects an invalid provider', () => {
|
|
42
|
+
expect(() => (0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: 'unknown' }))
|
|
43
|
+
.toThrow('Invalid agent "unknown".');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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 artifact_state_1 = require("../../src/core/artifact-state");
|
|
10
|
+
describe('artifact-state', () => {
|
|
11
|
+
let tmpHome;
|
|
12
|
+
let stateFile;
|
|
13
|
+
let originalHome;
|
|
14
|
+
let originalAwmHome;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Per CLAUDE.md: no test may touch the real ~/.awm — isolate HOME/AWM_HOME.
|
|
17
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-artifact-state-'));
|
|
18
|
+
originalHome = process.env.HOME;
|
|
19
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
20
|
+
process.env.HOME = tmpHome;
|
|
21
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
22
|
+
stateFile = path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json');
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
26
|
+
if (originalHome === undefined)
|
|
27
|
+
delete process.env.HOME;
|
|
28
|
+
else
|
|
29
|
+
process.env.HOME = originalHome;
|
|
30
|
+
if (originalAwmHome === undefined)
|
|
31
|
+
delete process.env.AWM_HOME;
|
|
32
|
+
else
|
|
33
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
34
|
+
});
|
|
35
|
+
function record(name, targetPath, owners) {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
type: 'skill',
|
|
39
|
+
scope: 'global',
|
|
40
|
+
targetPath,
|
|
41
|
+
sourcePath: path_1.default.join(tmpHome, 'registry', 'skills', name),
|
|
42
|
+
renderer: 'link',
|
|
43
|
+
owners,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
describe('artifactStateFile', () => {
|
|
47
|
+
it('resolves under AWM_HOME/state/artifacts.json', () => {
|
|
48
|
+
expect((0, artifact_state_1.artifactStateFile)()).toBe(stateFile);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('readArtifactState', () => {
|
|
52
|
+
it('returns [] when the file does not exist', () => {
|
|
53
|
+
expect(fs_1.default.existsSync(stateFile)).toBe(false);
|
|
54
|
+
expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
it('throws a clear error on invalid JSON', () => {
|
|
57
|
+
fs_1.default.mkdirSync(path_1.default.dirname(stateFile), { recursive: true });
|
|
58
|
+
fs_1.default.writeFileSync(stateFile, '{ not json');
|
|
59
|
+
expect(() => (0, artifact_state_1.readArtifactState)(stateFile)).toThrow(/not valid JSON/);
|
|
60
|
+
});
|
|
61
|
+
it('throws when the parsed content is not an array', () => {
|
|
62
|
+
fs_1.default.mkdirSync(path_1.default.dirname(stateFile), { recursive: true });
|
|
63
|
+
fs_1.default.writeFileSync(stateFile, JSON.stringify({ foo: 'bar' }));
|
|
64
|
+
expect(() => (0, artifact_state_1.readArtifactState)(stateFile)).toThrow(/must contain an array/);
|
|
65
|
+
});
|
|
66
|
+
it('defaults to artifactStateFile() when no file is given', () => {
|
|
67
|
+
expect((0, artifact_state_1.readArtifactState)()).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('writeArtifactState / round-trip', () => {
|
|
71
|
+
it('round-trips records written then read back', () => {
|
|
72
|
+
const records = [
|
|
73
|
+
record('development-process', path_1.default.join(tmpHome, '.agents/skills/development-process'), ['opencode', 'codex']),
|
|
74
|
+
];
|
|
75
|
+
(0, artifact_state_1.writeArtifactState)(records, stateFile);
|
|
76
|
+
expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual(records);
|
|
77
|
+
});
|
|
78
|
+
it('sorts output by targetPath deterministically regardless of input order', () => {
|
|
79
|
+
const b = record('b-skill', path_1.default.join(tmpHome, '.agents/skills/b-skill'), ['codex']);
|
|
80
|
+
const a = record('a-skill', path_1.default.join(tmpHome, '.agents/skills/a-skill'), ['codex']);
|
|
81
|
+
const c = record('c-skill', path_1.default.join(tmpHome, '.agents/skills/c-skill'), ['codex']);
|
|
82
|
+
(0, artifact_state_1.writeArtifactState)([b, c, a], stateFile);
|
|
83
|
+
const persisted = JSON.parse(fs_1.default.readFileSync(stateFile, 'utf8'));
|
|
84
|
+
expect(persisted.map((r) => r.targetPath)).toEqual([
|
|
85
|
+
a.targetPath,
|
|
86
|
+
b.targetPath,
|
|
87
|
+
c.targetPath,
|
|
88
|
+
]);
|
|
89
|
+
expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual([a, b, c]);
|
|
90
|
+
});
|
|
91
|
+
it('creates the state directory if missing and writes with restrictive mode', () => {
|
|
92
|
+
expect(fs_1.default.existsSync(path_1.default.dirname(stateFile))).toBe(false);
|
|
93
|
+
(0, artifact_state_1.writeArtifactState)([record('s', path_1.default.join(tmpHome, '.agents/skills/s'), ['codex'])], stateFile);
|
|
94
|
+
expect(fs_1.default.existsSync(stateFile)).toBe(true);
|
|
95
|
+
expect(fs_1.default.statSync(stateFile).mode & 0o777).toBe(0o600);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('mergeArtifactRecords', () => {
|
|
99
|
+
it('preserves existing records whose targetPath is not touched by incoming', () => {
|
|
100
|
+
const a = record('a-skill', path_1.default.join(tmpHome, '.agents/skills/a-skill'), ['codex']);
|
|
101
|
+
const b = record('b-skill', path_1.default.join(tmpHome, '.agents/skills/b-skill'), ['opencode']);
|
|
102
|
+
const merged = (0, artifact_state_1.mergeArtifactRecords)([a], [b]);
|
|
103
|
+
expect(merged).toEqual(expect.arrayContaining([a, b]));
|
|
104
|
+
expect(merged).toHaveLength(2);
|
|
105
|
+
});
|
|
106
|
+
it('replaces the existing record for a targetPath shared with an incoming record, without duplicating it', () => {
|
|
107
|
+
const targetPath = path_1.default.join(tmpHome, '.agents/skills/shared');
|
|
108
|
+
const before = record('shared', targetPath, ['codex']);
|
|
109
|
+
const after = record('shared', targetPath, ['codex', 'opencode']);
|
|
110
|
+
const merged = (0, artifact_state_1.mergeArtifactRecords)([before], [after]);
|
|
111
|
+
expect(merged).toHaveLength(1);
|
|
112
|
+
expect(merged[0]).toEqual(after);
|
|
113
|
+
});
|
|
114
|
+
it('simulates a multi-bundle awm init: sequential applyInstallPlan-style merges accumulate, not overwrite', () => {
|
|
115
|
+
// Mirrors the real bug: several separate applyInstallPlan calls,
|
|
116
|
+
// each only knowing about the artifacts IT touched, must not
|
|
117
|
+
// discard each other's records when persisted in sequence.
|
|
118
|
+
const bundle1 = record('dev-core', path_1.default.join(tmpHome, '.agents/skills/dev-core'), ['claude-code']);
|
|
119
|
+
const bundle2 = record('ambient', path_1.default.join(tmpHome, '.agents/skills/ambient'), ['codex']);
|
|
120
|
+
let state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [bundle1]);
|
|
121
|
+
(0, artifact_state_1.writeArtifactState)(state, stateFile);
|
|
122
|
+
state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [bundle2]);
|
|
123
|
+
(0, artifact_state_1.writeArtifactState)(state, stateFile);
|
|
124
|
+
expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual(expect.arrayContaining([bundle1, bundle2]));
|
|
125
|
+
expect((0, artifact_state_1.readArtifactState)(stateFile)).toHaveLength(2);
|
|
126
|
+
});
|
|
127
|
+
it('updating the same targetPath twice (e.g. re-running init) replaces without duplicating or losing unrelated records', () => {
|
|
128
|
+
const unrelated = record('unrelated', path_1.default.join(tmpHome, '.agents/skills/unrelated'), ['codex']);
|
|
129
|
+
const targetPath = path_1.default.join(tmpHome, '.agents/skills/dev-core');
|
|
130
|
+
const v1 = record('dev-core', targetPath, ['claude-code']);
|
|
131
|
+
const v2 = record('dev-core', targetPath, ['claude-code', 'opencode']);
|
|
132
|
+
let state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [unrelated]);
|
|
133
|
+
(0, artifact_state_1.writeArtifactState)(state, stateFile);
|
|
134
|
+
state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [v1]);
|
|
135
|
+
(0, artifact_state_1.writeArtifactState)(state, stateFile);
|
|
136
|
+
state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [v2]);
|
|
137
|
+
(0, artifact_state_1.writeArtifactState)(state, stateFile);
|
|
138
|
+
const final = (0, artifact_state_1.readArtifactState)(stateFile);
|
|
139
|
+
expect(final).toHaveLength(2);
|
|
140
|
+
expect(final).toEqual(expect.arrayContaining([unrelated, v2]));
|
|
141
|
+
expect(final).not.toEqual(expect.arrayContaining([v1]));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|