@abarcenas/ai-workflow-template 1.45.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +110 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/AGENTS.md +42 -3
- package/README.md +5 -2
- package/package.json +1 -1
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// sync-unit.test.js — In-process unit tests for scripts/sync.js
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The spawn-based suite (sync.test.js) exercises the CLI end-to-end but v8
|
|
6
|
+
// cannot instrument child processes. These tests import the module directly and
|
|
7
|
+
// call runSync() with explicit package/consumer roots backed by temp dirs, so
|
|
8
|
+
// every branch below is measured in the test process.
|
|
9
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { createHash } from 'crypto'
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
symlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'fs'
|
|
24
|
+
import { dirname, join } from 'path'
|
|
25
|
+
import { tmpdir } from 'os'
|
|
26
|
+
import { fileURLToPath } from 'url'
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
agentInstructionsStubs,
|
|
30
|
+
collectFiles,
|
|
31
|
+
createSyncContext,
|
|
32
|
+
hashFile,
|
|
33
|
+
isEntryPoint,
|
|
34
|
+
isPathInside,
|
|
35
|
+
isRegularFile,
|
|
36
|
+
isUntouched,
|
|
37
|
+
memoryBankStubs,
|
|
38
|
+
parseArgs,
|
|
39
|
+
readManifest,
|
|
40
|
+
resolvePruneEntry,
|
|
41
|
+
runSync,
|
|
42
|
+
writeManifest,
|
|
43
|
+
} from './sync.js'
|
|
44
|
+
|
|
45
|
+
const tempDirs = []
|
|
46
|
+
|
|
47
|
+
function createTempDir() {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), 'sync-unit-'))
|
|
49
|
+
tempDirs.push(dir)
|
|
50
|
+
return dir
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeFixtureFile(baseDir, relativePath, contents) {
|
|
54
|
+
const filePath = join(baseDir, relativePath)
|
|
55
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
56
|
+
writeFileSync(filePath, contents)
|
|
57
|
+
return filePath
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sha256(contents) {
|
|
61
|
+
return createHash('sha256').update(contents).digest('hex')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readConsumerManifest(consumerDir) {
|
|
65
|
+
return JSON.parse(
|
|
66
|
+
readFileSync(join(consumerDir, '.agents-sync-manifest.json'), 'utf8'),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outputOf(spy) {
|
|
71
|
+
return spy.mock.calls.map((call) => call.join(' ')).join('\n')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Builds a throwaway "published package" with its own .agents/.opencode/scripts
|
|
75
|
+
// and root files. Omitted sections are not created at all, which lets tests hit
|
|
76
|
+
// the "source directory/file missing" branches.
|
|
77
|
+
function createPackage({
|
|
78
|
+
agents,
|
|
79
|
+
opencode,
|
|
80
|
+
scripts,
|
|
81
|
+
root,
|
|
82
|
+
mcpExample,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const packageRoot = createTempDir()
|
|
85
|
+
writeFileSync(join(packageRoot, 'package.json'), '{ "type": "module" }\n')
|
|
86
|
+
|
|
87
|
+
if (agents) {
|
|
88
|
+
mkdirSync(join(packageRoot, '.agents'), { recursive: true })
|
|
89
|
+
for (const [relativePath, contents] of Object.entries(agents)) {
|
|
90
|
+
writeFixtureFile(packageRoot, join('.agents', relativePath), contents)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (opencode) {
|
|
95
|
+
mkdirSync(join(packageRoot, '.opencode'), { recursive: true })
|
|
96
|
+
for (const [relativePath, contents] of Object.entries(opencode)) {
|
|
97
|
+
writeFixtureFile(packageRoot, join('.opencode', relativePath), contents)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (scripts) {
|
|
102
|
+
mkdirSync(join(packageRoot, 'scripts'), { recursive: true })
|
|
103
|
+
for (const [relativePath, contents] of Object.entries(scripts)) {
|
|
104
|
+
writeFixtureFile(packageRoot, join('scripts', relativePath), contents)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (root) {
|
|
109
|
+
for (const [relativePath, contents] of Object.entries(root)) {
|
|
110
|
+
writeFixtureFile(packageRoot, relativePath, contents)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (mcpExample !== undefined) {
|
|
115
|
+
writeFixtureFile(packageRoot, 'opencode.mcp.example.json', mcpExample)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return packageRoot
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let warnSpy
|
|
122
|
+
let errorSpy
|
|
123
|
+
let logSpy
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
127
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
128
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
vi.restoreAllMocks()
|
|
133
|
+
while (tempDirs.length > 0) {
|
|
134
|
+
rmSync(tempDirs.pop(), { recursive: true, force: true })
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ── Argument parsing ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
describe('parseArgs', () => {
|
|
141
|
+
it('defaults every flag to false for an empty argv', () => {
|
|
142
|
+
expect(parseArgs([], {})).toEqual({
|
|
143
|
+
forceOverwrite: false,
|
|
144
|
+
dryRun: false,
|
|
145
|
+
prune: false,
|
|
146
|
+
isVerbose: false,
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('uses an empty argv when none is supplied', () => {
|
|
151
|
+
expect(parseArgs(undefined, {})).toMatchObject({
|
|
152
|
+
forceOverwrite: false,
|
|
153
|
+
dryRun: false,
|
|
154
|
+
prune: false,
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('recognises --force, --dry-run and --prune individually', () => {
|
|
159
|
+
expect(parseArgs(['--force'], {}).forceOverwrite).toBe(true)
|
|
160
|
+
expect(parseArgs(['--dry-run'], {}).dryRun).toBe(true)
|
|
161
|
+
expect(parseArgs(['--prune'], {}).prune).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('recognises any combination of flags', () => {
|
|
165
|
+
expect(parseArgs(['--force', '--prune', '--dry-run'], {})).toMatchObject({
|
|
166
|
+
forceOverwrite: true,
|
|
167
|
+
dryRun: true,
|
|
168
|
+
prune: true,
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('ignores unknown flags and positional arguments', () => {
|
|
173
|
+
expect(parseArgs(['--unknown', 'positional', '--forc'], {})).toMatchObject({
|
|
174
|
+
forceOverwrite: false,
|
|
175
|
+
dryRun: false,
|
|
176
|
+
prune: false,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('enables verbose output only when AI_WORKFLOW_VERBOSE is "1"', () => {
|
|
181
|
+
expect(parseArgs([], { AI_WORKFLOW_VERBOSE: '1' }).isVerbose).toBe(true)
|
|
182
|
+
expect(parseArgs([], { AI_WORKFLOW_VERBOSE: '0' }).isVerbose).toBe(false)
|
|
183
|
+
expect(parseArgs([], {}).isVerbose).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('reads process.env by default', () => {
|
|
187
|
+
const previous = process.env.AI_WORKFLOW_VERBOSE
|
|
188
|
+
process.env.AI_WORKFLOW_VERBOSE = '1'
|
|
189
|
+
try {
|
|
190
|
+
expect(parseArgs([]).isVerbose).toBe(true)
|
|
191
|
+
} finally {
|
|
192
|
+
if (previous === undefined) {
|
|
193
|
+
delete process.env.AI_WORKFLOW_VERBOSE
|
|
194
|
+
} else {
|
|
195
|
+
process.env.AI_WORKFLOW_VERBOSE = previous
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ── Context resolution ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe('createSyncContext', () => {
|
|
204
|
+
it('resolves explicit package and consumer roots', () => {
|
|
205
|
+
const packageRoot = createTempDir()
|
|
206
|
+
const consumerRoot = createTempDir()
|
|
207
|
+
|
|
208
|
+
const context = createSyncContext({
|
|
209
|
+
packageRoot,
|
|
210
|
+
consumerRoot,
|
|
211
|
+
argv: ['--force', '--prune'],
|
|
212
|
+
env: {},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(context.packageRoot).toBe(packageRoot)
|
|
216
|
+
expect(context.consumerRoot).toBe(consumerRoot)
|
|
217
|
+
expect(context.sourceAgentsDir).toBe(join(packageRoot, '.agents'))
|
|
218
|
+
expect(context.targetAgentsDir).toBe(join(consumerRoot, '.agents'))
|
|
219
|
+
expect(context.sourceOpenCodeDir).toBe(join(packageRoot, '.opencode'))
|
|
220
|
+
expect(context.sourceScriptsDir).toBe(join(packageRoot, 'scripts'))
|
|
221
|
+
expect(context.targetScriptsDir).toBe(join(consumerRoot, 'scripts'))
|
|
222
|
+
expect(context.manifestPath).toBe(
|
|
223
|
+
join(consumerRoot, '.agents-sync-manifest.json'),
|
|
224
|
+
)
|
|
225
|
+
expect(context.forceOverwrite).toBe(true)
|
|
226
|
+
expect(context.prune).toBe(true)
|
|
227
|
+
expect(context.dryRun).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('derives the consumer root from INIT_CWD', () => {
|
|
231
|
+
const packageRoot = createTempDir()
|
|
232
|
+
const initCwd = createTempDir()
|
|
233
|
+
|
|
234
|
+
const context = createSyncContext({
|
|
235
|
+
packageRoot,
|
|
236
|
+
env: { INIT_CWD: initCwd },
|
|
237
|
+
argv: [],
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
expect(context.consumerRoot).toBe(initCwd)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('falls back to process.cwd() when INIT_CWD is absent', () => {
|
|
244
|
+
const packageRoot = createTempDir()
|
|
245
|
+
|
|
246
|
+
const context = createSyncContext({ packageRoot, env: {}, argv: [] })
|
|
247
|
+
|
|
248
|
+
expect(context.consumerRoot).toBe(process.cwd())
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('derives the package root from the module location by default', () => {
|
|
252
|
+
const consumerRoot = createTempDir()
|
|
253
|
+
|
|
254
|
+
const context = createSyncContext({ consumerRoot, env: {}, argv: [] })
|
|
255
|
+
|
|
256
|
+
expect(context.packageRoot).toBe(resolveRepoRoot())
|
|
257
|
+
expect(context.sourceAgentsDir).toBe(join(resolveRepoRoot(), '.agents'))
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('accepts an explicit manifest path', () => {
|
|
261
|
+
const consumerRoot = createTempDir()
|
|
262
|
+
const manifestPath = join(consumerRoot, 'nested', 'custom-manifest.json')
|
|
263
|
+
|
|
264
|
+
const context = createSyncContext({
|
|
265
|
+
packageRoot: createTempDir(),
|
|
266
|
+
consumerRoot,
|
|
267
|
+
manifestPath,
|
|
268
|
+
env: {},
|
|
269
|
+
argv: [],
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(context.manifestPath).toBe(manifestPath)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('builds prune channels that match the copy-pass roots', () => {
|
|
276
|
+
const packageRoot = createTempDir()
|
|
277
|
+
const consumerRoot = createTempDir()
|
|
278
|
+
|
|
279
|
+
const context = createSyncContext({ packageRoot, consumerRoot })
|
|
280
|
+
|
|
281
|
+
expect(context.pruneChannels.agents).toEqual({
|
|
282
|
+
prefix: '',
|
|
283
|
+
packageDir: join(packageRoot, '.agents'),
|
|
284
|
+
consumerDir: join(consumerRoot, '.agents'),
|
|
285
|
+
})
|
|
286
|
+
expect(context.pruneChannels.opencode).toEqual({
|
|
287
|
+
prefix: '__opencode__/',
|
|
288
|
+
packageDir: join(packageRoot, '.opencode'),
|
|
289
|
+
consumerDir: join(consumerRoot, '.opencode'),
|
|
290
|
+
})
|
|
291
|
+
expect(context.pruneChannels.scripts).toEqual({
|
|
292
|
+
prefix: '__scripts__/',
|
|
293
|
+
packageDir: join(packageRoot, 'scripts'),
|
|
294
|
+
consumerDir: join(consumerRoot, 'scripts'),
|
|
295
|
+
})
|
|
296
|
+
expect(context.pruneChannels.root).toEqual({
|
|
297
|
+
prefix: '__root__/',
|
|
298
|
+
packageDir: packageRoot,
|
|
299
|
+
consumerDir: consumerRoot,
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('falls back to process.argv when argv is omitted', () => {
|
|
304
|
+
const context = createSyncContext({ env: {} })
|
|
305
|
+
|
|
306
|
+
expect(typeof context.forceOverwrite).toBe('boolean')
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
function resolveRepoRoot() {
|
|
311
|
+
return process.cwd()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Manifest I/O ─────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
describe('readManifest / writeManifest', () => {
|
|
317
|
+
it('returns an empty manifest when the file is missing', () => {
|
|
318
|
+
const manifestPath = join(createTempDir(), 'missing.json')
|
|
319
|
+
|
|
320
|
+
expect(readManifest(manifestPath)).toEqual({ version: 1, files: {} })
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('returns an empty manifest for malformed JSON', () => {
|
|
324
|
+
const dir = createTempDir()
|
|
325
|
+
const manifestPath = writeFixtureFile(dir, 'manifest.json', '{ not json')
|
|
326
|
+
|
|
327
|
+
expect(readManifest(manifestPath)).toEqual({ version: 1, files: {} })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('returns an empty manifest for non-object JSON values', () => {
|
|
331
|
+
const dir = createTempDir()
|
|
332
|
+
|
|
333
|
+
expect(readManifest(writeFixtureFile(dir, 'null.json', 'null'))).toEqual({
|
|
334
|
+
version: 1,
|
|
335
|
+
files: {},
|
|
336
|
+
})
|
|
337
|
+
expect(readManifest(writeFixtureFile(dir, 'string.json', '"text"'))).toEqual({
|
|
338
|
+
version: 1,
|
|
339
|
+
files: {},
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('repairs a missing or non-object files map', () => {
|
|
344
|
+
const dir = createTempDir()
|
|
345
|
+
|
|
346
|
+
const noFiles = readManifest(
|
|
347
|
+
writeFixtureFile(dir, 'no-files.json', '{"version":1}'),
|
|
348
|
+
)
|
|
349
|
+
expect(noFiles.files).toEqual({})
|
|
350
|
+
|
|
351
|
+
const badFiles = readManifest(
|
|
352
|
+
writeFixtureFile(dir, 'bad-files.json', '{"version":1,"files":"nope"}'),
|
|
353
|
+
)
|
|
354
|
+
expect(badFiles.files).toEqual({})
|
|
355
|
+
|
|
356
|
+
const arrayManifest = readManifest(
|
|
357
|
+
writeFixtureFile(dir, 'array.json', '[]'),
|
|
358
|
+
)
|
|
359
|
+
expect(arrayManifest.files).toEqual({})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('preserves valid manifest contents, including unknown fields', () => {
|
|
363
|
+
const dir = createTempDir()
|
|
364
|
+
const manifest = {
|
|
365
|
+
version: 2,
|
|
366
|
+
extra: true,
|
|
367
|
+
files: { 'skills/a/SKILL.md': 'abc' },
|
|
368
|
+
}
|
|
369
|
+
const manifestPath = writeFixtureFile(
|
|
370
|
+
dir,
|
|
371
|
+
'manifest.json',
|
|
372
|
+
JSON.stringify(manifest),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
expect(readManifest(manifestPath)).toEqual(manifest)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('writes pretty-printed JSON with a trailing newline and round-trips', () => {
|
|
379
|
+
const dir = createTempDir()
|
|
380
|
+
const manifestPath = join(dir, '.agents-sync-manifest.json')
|
|
381
|
+
const manifest = { version: 1, files: { 'a.md': sha256('a') } }
|
|
382
|
+
|
|
383
|
+
writeManifest(manifestPath, manifest)
|
|
384
|
+
|
|
385
|
+
const raw = readFileSync(manifestPath, 'utf8')
|
|
386
|
+
expect(raw.endsWith('\n')).toBe(true)
|
|
387
|
+
expect(raw).toBe(`${JSON.stringify(manifest, null, 2)}\n`)
|
|
388
|
+
expect(readManifest(manifestPath)).toEqual(manifest)
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// ── Hashing and overwrite decisions ──────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
describe('hashFile / isUntouched', () => {
|
|
395
|
+
it('hashes file contents with sha256', () => {
|
|
396
|
+
const dir = createTempDir()
|
|
397
|
+
const filePath = writeFixtureFile(dir, 'file.txt', 'hello\n')
|
|
398
|
+
|
|
399
|
+
expect(hashFile(filePath)).toBe(sha256('hello\n'))
|
|
400
|
+
expect(hashFile(filePath)).toMatch(/^[a-f0-9]{64}$/)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('decides a copy is safe only when a recorded hash matches the local file', () => {
|
|
404
|
+
expect(isUntouched('abc', 'abc')).toBe(true)
|
|
405
|
+
expect(isUntouched('abc', 'def')).toBe(false)
|
|
406
|
+
expect(isUntouched(null, 'abc')).toBe(false)
|
|
407
|
+
expect(isUntouched(null, null)).toBe(false)
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// ── File collection ──────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
describe('collectFiles', () => {
|
|
414
|
+
it('collects files recursively and skips directories as entries', () => {
|
|
415
|
+
const dir = createTempDir()
|
|
416
|
+
writeFixtureFile(dir, 'a.md', 'a')
|
|
417
|
+
writeFixtureFile(dir, join('nested', 'b.md'), 'b')
|
|
418
|
+
writeFixtureFile(dir, join('nested', 'deep', 'c.md'), 'c')
|
|
419
|
+
|
|
420
|
+
const files = collectFiles(dir).map((filePath) => filePath.slice(dir.length + 1))
|
|
421
|
+
|
|
422
|
+
expect(files.sort()).toEqual(['a.md', 'nested/b.md', 'nested/deep/c.md'])
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('returns an empty list for an empty directory', () => {
|
|
426
|
+
expect(collectFiles(createTempDir())).toEqual([])
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('skips non-regular entries such as symlinks', () => {
|
|
430
|
+
const dir = createTempDir()
|
|
431
|
+
const target = writeFixtureFile(dir, 'target.md', 'target')
|
|
432
|
+
symlinkSync(target, join(dir, 'link.md'))
|
|
433
|
+
|
|
434
|
+
const files = collectFiles(dir)
|
|
435
|
+
|
|
436
|
+
expect(files).toContain(target)
|
|
437
|
+
expect(files).not.toContain(join(dir, 'link.md'))
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// ── Path containment and regular-file checks ─────────────────────────────────
|
|
442
|
+
|
|
443
|
+
describe('isPathInside', () => {
|
|
444
|
+
it('accepts descendants', () => {
|
|
445
|
+
expect(isPathInside('/base', '/base/child')).toBe(true)
|
|
446
|
+
expect(isPathInside('/base', join('/base', 'a', 'b'))).toBe(true)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('rejects the base directory itself', () => {
|
|
450
|
+
expect(isPathInside('/base', '/base')).toBe(false)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('rejects the parent directory and any escape', () => {
|
|
454
|
+
expect(isPathInside('/base', '/base/..')).toBe(false)
|
|
455
|
+
expect(isPathInside('/base', '/base/../sibling')).toBe(false)
|
|
456
|
+
expect(isPathInside('/base', '/other/child')).toBe(false)
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('isRegularFile', () => {
|
|
461
|
+
it('returns true for regular files and false for directories', () => {
|
|
462
|
+
const dir = createTempDir()
|
|
463
|
+
const filePath = writeFixtureFile(dir, 'file.md', 'x')
|
|
464
|
+
|
|
465
|
+
expect(isRegularFile(filePath)).toBe(true)
|
|
466
|
+
expect(isRegularFile(dir)).toBe(false)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('returns false when the path cannot be statted', () => {
|
|
470
|
+
expect(isRegularFile(join(createTempDir(), 'missing.md'))).toBe(false)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it.skipIf(process.getuid?.() === 0)(
|
|
474
|
+
'returns false for an unreadable parent directory',
|
|
475
|
+
() => {
|
|
476
|
+
const dir = createTempDir()
|
|
477
|
+
writeFixtureFile(dir, 'locked/file.md', 'x')
|
|
478
|
+
chmodSync(join(dir, 'locked'), 0o000)
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
expect(isRegularFile(join(dir, 'locked', 'file.md'))).toBe(false)
|
|
482
|
+
} finally {
|
|
483
|
+
chmodSync(join(dir, 'locked'), 0o755)
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// ── Entry-point detection ────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
describe('isEntryPoint', () => {
|
|
492
|
+
const modulePath = new URL('./sync.js', import.meta.url)
|
|
493
|
+
|
|
494
|
+
it('returns false when process.argv[1] is missing', () => {
|
|
495
|
+
// Passing '' (rather than undefined, which triggers the default parameter)
|
|
496
|
+
// exercises the falsy-argv guard while also exercising the default moduleUrl.
|
|
497
|
+
expect(isEntryPoint(undefined, '')).toBe(false)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('returns true when argv points at this module', () => {
|
|
501
|
+
expect(isEntryPoint(modulePath.href, fileURLToPath(modulePath))).toBe(true)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('returns true across symlinked paths', () => {
|
|
505
|
+
const dir = createTempDir()
|
|
506
|
+
const linkPath = join(dir, 'sync-link.js')
|
|
507
|
+
symlinkSync(fileURLToPath(modulePath), linkPath)
|
|
508
|
+
|
|
509
|
+
expect(isEntryPoint(modulePath.href, linkPath)).toBe(true)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('returns false for a different existing file', () => {
|
|
513
|
+
const other = writeFixtureFile(createTempDir(), 'other.js', '')
|
|
514
|
+
|
|
515
|
+
expect(isEntryPoint(modulePath.href, other)).toBe(false)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('returns false when argv points at a path that does not exist', () => {
|
|
519
|
+
expect(
|
|
520
|
+
isEntryPoint(modulePath.href, join(createTempDir(), 'nope.js')),
|
|
521
|
+
).toBe(false)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('runs one sync pass when Node executes the module as the entry point', async () => {
|
|
525
|
+
const consumerRoot = createTempDir()
|
|
526
|
+
const syncPath = fileURLToPath(new URL('./sync.js', import.meta.url))
|
|
527
|
+
const originalArgv1 = process.argv[1]
|
|
528
|
+
const originalInitCwd = process.env.INIT_CWD
|
|
529
|
+
|
|
530
|
+
process.argv[1] = syncPath
|
|
531
|
+
process.env.INIT_CWD = consumerRoot
|
|
532
|
+
try {
|
|
533
|
+
// A query string busts the module cache so the entry-point guard evaluates
|
|
534
|
+
// with argv[1] pointing at sync.js — exactly what `node scripts/sync.js` does.
|
|
535
|
+
await import('./sync.js?entry-point-test')
|
|
536
|
+
} finally {
|
|
537
|
+
process.argv[1] = originalArgv1
|
|
538
|
+
if (originalInitCwd === undefined) {
|
|
539
|
+
delete process.env.INIT_CWD
|
|
540
|
+
} else {
|
|
541
|
+
process.env.INIT_CWD = originalInitCwd
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
546
|
+
expect(Object.keys(manifest.files).length).toBeGreaterThan(0)
|
|
547
|
+
expect(existsSync(join(consumerRoot, 'AGENTS.md'))).toBe(true)
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
// ── Prune entry resolution (channel + path table) ────────────────────────────
|
|
552
|
+
|
|
553
|
+
describe('resolvePruneEntry', () => {
|
|
554
|
+
function context() {
|
|
555
|
+
return createSyncContext({
|
|
556
|
+
packageRoot: createTempDir(),
|
|
557
|
+
consumerRoot: createTempDir(),
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
it('resolves an untagged manifest key through the .agents channel', () => {
|
|
562
|
+
const ctx = context()
|
|
563
|
+
|
|
564
|
+
const entry = resolvePruneEntry('skills/alpha/SKILL.md', ctx)
|
|
565
|
+
|
|
566
|
+
expect(entry.packagePaths).toEqual([
|
|
567
|
+
join(ctx.sourceAgentsDir, 'skills/alpha/SKILL.md'),
|
|
568
|
+
])
|
|
569
|
+
expect(entry.consumerPath).toBe(
|
|
570
|
+
join(ctx.targetAgentsDir, 'skills/alpha/SKILL.md'),
|
|
571
|
+
)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it.each([
|
|
575
|
+
['__opencode__/agents/coder.md', 'opencode', 'agents/coder.md'],
|
|
576
|
+
['__scripts__/memory-cli.js', 'scripts', 'memory-cli.js'],
|
|
577
|
+
['__root__/AGENTS.md', 'root', 'AGENTS.md'],
|
|
578
|
+
])('resolves %s through the %s channel', (key, channel, relativePath) => {
|
|
579
|
+
const ctx = context()
|
|
580
|
+
|
|
581
|
+
const entry = resolvePruneEntry(key, ctx)
|
|
582
|
+
|
|
583
|
+
expect(entry.packagePaths[0]).toBe(
|
|
584
|
+
join(ctx.pruneChannels[channel].packageDir, relativePath),
|
|
585
|
+
)
|
|
586
|
+
expect(entry.consumerPath).toBe(
|
|
587
|
+
join(ctx.pruneChannels[channel].consumerDir, relativePath),
|
|
588
|
+
)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('keeps opencode.json alive via the example fallback source', () => {
|
|
592
|
+
const ctx = context()
|
|
593
|
+
|
|
594
|
+
const entry = resolvePruneEntry('__root__/opencode.json', ctx)
|
|
595
|
+
|
|
596
|
+
expect(entry.packagePaths).toEqual([
|
|
597
|
+
join(ctx.packageRoot, 'opencode.json'),
|
|
598
|
+
join(ctx.packageRoot, 'opencode.mcp.example.json'),
|
|
599
|
+
])
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it.each([
|
|
603
|
+
['unknown internal namespace', '__future__/thing.md'],
|
|
604
|
+
['scaffold-only memory-bank namespace', '__memory-bank__/projectbrief.md'],
|
|
605
|
+
['scaffold-only instructions namespace', '__agents-instructions__/x.md'],
|
|
606
|
+
['excluded learned-knowledge file', 'instructions/learned-knowledge.instructions.md'],
|
|
607
|
+
['excluded opencode node_modules path', '__opencode__/node_modules/dep.js'],
|
|
608
|
+
['excluded opencode package.json', '__opencode__/package.json'],
|
|
609
|
+
['empty opencode key', '__opencode__/'],
|
|
610
|
+
['empty scripts key', '__scripts__/'],
|
|
611
|
+
['empty root key', '__root__/'],
|
|
612
|
+
['empty agents key', ''],
|
|
613
|
+
['parent-directory escape', '..'],
|
|
614
|
+
['sibling-directory escape', '../escape.txt'],
|
|
615
|
+
['self-referential key', './.'],
|
|
616
|
+
])('returns null for %s', (_label, key) => {
|
|
617
|
+
expect(resolvePruneEntry(key, context())).toBeNull()
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// ── Copy passes ──────────────────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
const AGENTS_FILES = {
|
|
624
|
+
'instructions/typescript.md': 'typescript instructions\n',
|
|
625
|
+
'instructions/learned-knowledge.instructions.md': 'package copy must not win\n',
|
|
626
|
+
'skills/alpha/SKILL.md': 'alpha skill\n',
|
|
627
|
+
}
|
|
628
|
+
const OPENCODE_FILES = {
|
|
629
|
+
'agents/coder.md': 'coder agent\n',
|
|
630
|
+
'node_modules/dep/index.js': 'dependency\n',
|
|
631
|
+
'nested/node_modules/keep.js': 'nested kept\n',
|
|
632
|
+
'package.json': '{"name":"pkg"}\n',
|
|
633
|
+
'.gitignore': 'ignored\n',
|
|
634
|
+
'skills/beta/SKILL.md': 'beta skill\n',
|
|
635
|
+
}
|
|
636
|
+
const SCRIPT_FILES = {
|
|
637
|
+
'memory-cli.js': '// memory cli\n',
|
|
638
|
+
'mcp/playwright-mcp-launcher.js': '// launcher\n',
|
|
639
|
+
}
|
|
640
|
+
const ROOT_FILE_CONTENTS = {
|
|
641
|
+
'AGENTS.md': '# package agents\n',
|
|
642
|
+
'skills-lock.json': '{ "skills": [] }\n',
|
|
643
|
+
}
|
|
644
|
+
const MCP_EXAMPLE = '{\n "mcp": {}\n}\n'
|
|
645
|
+
|
|
646
|
+
function createCopyFixture() {
|
|
647
|
+
const packageRoot = createPackage({
|
|
648
|
+
agents: AGENTS_FILES,
|
|
649
|
+
opencode: OPENCODE_FILES,
|
|
650
|
+
scripts: SCRIPT_FILES,
|
|
651
|
+
root: ROOT_FILE_CONTENTS,
|
|
652
|
+
mcpExample: MCP_EXAMPLE,
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
return { packageRoot, consumerRoot: createTempDir() }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
describe('runSync copy passes', () => {
|
|
659
|
+
it('copies .agents, .opencode, scripts and root files and scaffolds stubs', () => {
|
|
660
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
661
|
+
|
|
662
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
663
|
+
|
|
664
|
+
expect(result.exitCode).toBe(0)
|
|
665
|
+
expect(result.copied).toBe(9)
|
|
666
|
+
expect(result.added).toBe(9)
|
|
667
|
+
expect(result.scaffolded).toBe(8)
|
|
668
|
+
expect(result.skipped).toBe(0)
|
|
669
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
670
|
+
|
|
671
|
+
// .agents copies the regular file and skips the excluded learned-knowledge path.
|
|
672
|
+
expect(
|
|
673
|
+
readFileSync(
|
|
674
|
+
join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'),
|
|
675
|
+
'utf8',
|
|
676
|
+
),
|
|
677
|
+
).toBe(AGENTS_FILES['skills/alpha/SKILL.md'])
|
|
678
|
+
expect(
|
|
679
|
+
readFileSync(
|
|
680
|
+
join(consumerRoot, '.agents', 'instructions', 'typescript.md'),
|
|
681
|
+
'utf8',
|
|
682
|
+
),
|
|
683
|
+
).toBe(AGENTS_FILES['instructions/typescript.md'])
|
|
684
|
+
expect(
|
|
685
|
+
readFileSync(
|
|
686
|
+
join(
|
|
687
|
+
consumerRoot,
|
|
688
|
+
'.agents',
|
|
689
|
+
'instructions',
|
|
690
|
+
'learned-knowledge.instructions.md',
|
|
691
|
+
),
|
|
692
|
+
'utf8',
|
|
693
|
+
),
|
|
694
|
+
).toBe(agentInstructionsStubs['learned-knowledge.instructions.md'])
|
|
695
|
+
|
|
696
|
+
// .opencode copies regular files but not excluded paths or top-level node_modules.
|
|
697
|
+
expect(
|
|
698
|
+
readFileSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'), 'utf8'),
|
|
699
|
+
).toBe(OPENCODE_FILES['agents/coder.md'])
|
|
700
|
+
expect(
|
|
701
|
+
readFileSync(
|
|
702
|
+
join(consumerRoot, '.opencode', 'skills', 'beta', 'SKILL.md'),
|
|
703
|
+
'utf8',
|
|
704
|
+
),
|
|
705
|
+
).toBe(OPENCODE_FILES['skills/beta/SKILL.md'])
|
|
706
|
+
expect(
|
|
707
|
+
readFileSync(
|
|
708
|
+
join(consumerRoot, '.opencode', 'nested', 'node_modules', 'keep.js'),
|
|
709
|
+
'utf8',
|
|
710
|
+
),
|
|
711
|
+
).toBe(OPENCODE_FILES['nested/node_modules/keep.js'])
|
|
712
|
+
expect(existsSync(join(consumerRoot, '.opencode', 'node_modules'))).toBe(false)
|
|
713
|
+
expect(existsSync(join(consumerRoot, '.opencode', 'package.json'))).toBe(false)
|
|
714
|
+
expect(existsSync(join(consumerRoot, '.opencode', '.gitignore'))).toBe(false)
|
|
715
|
+
|
|
716
|
+
// scripts are copied from the allow-list only.
|
|
717
|
+
expect(
|
|
718
|
+
readFileSync(join(consumerRoot, 'scripts', 'memory-cli.js'), 'utf8'),
|
|
719
|
+
).toBe(SCRIPT_FILES['memory-cli.js'])
|
|
720
|
+
expect(
|
|
721
|
+
readFileSync(
|
|
722
|
+
join(consumerRoot, 'scripts', 'mcp', 'playwright-mcp-launcher.js'),
|
|
723
|
+
'utf8',
|
|
724
|
+
),
|
|
725
|
+
).toBe(SCRIPT_FILES['mcp/playwright-mcp-launcher.js'])
|
|
726
|
+
expect(existsSync(join(consumerRoot, 'scripts', 'memory-index.js'))).toBe(false)
|
|
727
|
+
|
|
728
|
+
// root files, with opencode.json scaffolded from the example.
|
|
729
|
+
for (const [fileName, contents] of Object.entries(ROOT_FILE_CONTENTS)) {
|
|
730
|
+
expect(readFileSync(join(consumerRoot, fileName), 'utf8')).toBe(contents)
|
|
731
|
+
}
|
|
732
|
+
expect(readFileSync(join(consumerRoot, 'opencode.json'), 'utf8')).toBe(
|
|
733
|
+
MCP_EXAMPLE,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
// memory-bank stubs.
|
|
737
|
+
for (const fileName of Object.keys(memoryBankStubs)) {
|
|
738
|
+
expect(existsSync(join(consumerRoot, 'memory-bank', fileName))).toBe(true)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Manifest tracks every channel, including scaffolds.
|
|
742
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
743
|
+
expect(manifest.version).toBe(1)
|
|
744
|
+
expect(manifest.files['skills/alpha/SKILL.md']).toBe(
|
|
745
|
+
sha256(AGENTS_FILES['skills/alpha/SKILL.md']),
|
|
746
|
+
)
|
|
747
|
+
expect(manifest.files['instructions/typescript.md']).toBe(
|
|
748
|
+
sha256(AGENTS_FILES['instructions/typescript.md']),
|
|
749
|
+
)
|
|
750
|
+
expect(manifest.files['instructions/learned-knowledge.instructions.md']).toBeUndefined()
|
|
751
|
+
expect(manifest.files['__opencode__/agents/coder.md']).toBe(
|
|
752
|
+
sha256(OPENCODE_FILES['agents/coder.md']),
|
|
753
|
+
)
|
|
754
|
+
expect(manifest.files['__scripts__/memory-cli.js']).toBe(
|
|
755
|
+
sha256(SCRIPT_FILES['memory-cli.js']),
|
|
756
|
+
)
|
|
757
|
+
expect(manifest.files['__root__/AGENTS.md']).toBe(
|
|
758
|
+
sha256(ROOT_FILE_CONTENTS['AGENTS.md']),
|
|
759
|
+
)
|
|
760
|
+
expect(manifest.files['__root__/opencode.json']).toBe(sha256(MCP_EXAMPLE))
|
|
761
|
+
expect(
|
|
762
|
+
manifest.files['__agents-instructions__/learned-knowledge.instructions.md'],
|
|
763
|
+
).toBe(
|
|
764
|
+
sha256(agentInstructionsStubs['learned-knowledge.instructions.md']),
|
|
765
|
+
)
|
|
766
|
+
for (const fileName of Object.keys(memoryBankStubs)) {
|
|
767
|
+
expect(manifest.files[`__memory-bank__/${fileName}`]).toBeDefined()
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
expect(readFileSync(join(consumerRoot, '.agents-sync-manifest.json'), 'utf8'))
|
|
771
|
+
.toMatch(/\n$/)
|
|
772
|
+
expect(logSpy.mock.calls.join('\n')).toContain(
|
|
773
|
+
`Synced ${result.copied} files (${result.added} new)`,
|
|
774
|
+
)
|
|
775
|
+
expect(outputOf(logSpy)).toContain(
|
|
776
|
+
'skipped 0 modified files. Mode: safe.',
|
|
777
|
+
)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('re-copies untouched files on a second run without warnings', () => {
|
|
781
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
782
|
+
|
|
783
|
+
runSync({ packageRoot, consumerRoot })
|
|
784
|
+
const second = runSync({ packageRoot, consumerRoot })
|
|
785
|
+
|
|
786
|
+
expect(second.copied).toBe(9)
|
|
787
|
+
expect(second.added).toBe(0)
|
|
788
|
+
expect(second.scaffolded).toBe(0)
|
|
789
|
+
expect(second.skipped).toBe(0)
|
|
790
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
791
|
+
expect(outputOf(logSpy)).toContain(
|
|
792
|
+
'skipped 0 modified files. Mode: safe.',
|
|
793
|
+
)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('skips locally modified files in every channel', () => {
|
|
797
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
798
|
+
|
|
799
|
+
runSync({ packageRoot, consumerRoot })
|
|
800
|
+
|
|
801
|
+
const modified = [
|
|
802
|
+
['.agents/skills/alpha/SKILL.md', 'agents local'],
|
|
803
|
+
['.opencode/agents/coder.md', 'opencode local'],
|
|
804
|
+
['scripts/memory-cli.js', 'scripts local'],
|
|
805
|
+
['AGENTS.md', 'root local'],
|
|
806
|
+
]
|
|
807
|
+
for (const [relativePath, contents] of modified) {
|
|
808
|
+
writeFixtureFile(consumerRoot, relativePath, contents)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
812
|
+
|
|
813
|
+
expect(result.skipped).toBe(4)
|
|
814
|
+
for (const [relativePath, contents] of modified) {
|
|
815
|
+
expect(readFileSync(join(consumerRoot, relativePath), 'utf8')).toBe(
|
|
816
|
+
contents,
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const warnings = outputOf(warnSpy)
|
|
821
|
+
expect(warnings).toContain(
|
|
822
|
+
'Skipped locally modified file: skills/alpha/SKILL.md',
|
|
823
|
+
)
|
|
824
|
+
expect(warnings).toContain(
|
|
825
|
+
'Skipped locally modified opencode file: agents/coder.md',
|
|
826
|
+
)
|
|
827
|
+
expect(warnings).toContain(
|
|
828
|
+
'Skipped locally modified script file: scripts/memory-cli.js',
|
|
829
|
+
)
|
|
830
|
+
expect(warnings).toContain('Skipped locally modified root file: AGENTS.md')
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('overwrites locally modified files with --force', () => {
|
|
834
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
835
|
+
|
|
836
|
+
runSync({ packageRoot, consumerRoot })
|
|
837
|
+
writeFixtureFile(consumerRoot, '.agents/skills/alpha/SKILL.md', 'agents local')
|
|
838
|
+
writeFixtureFile(consumerRoot, '.opencode/agents/coder.md', 'opencode local')
|
|
839
|
+
writeFixtureFile(consumerRoot, 'scripts/memory-cli.js', 'scripts local')
|
|
840
|
+
writeFixtureFile(consumerRoot, 'AGENTS.md', 'root local')
|
|
841
|
+
|
|
842
|
+
const result = runSync({
|
|
843
|
+
packageRoot,
|
|
844
|
+
consumerRoot,
|
|
845
|
+
argv: ['--force'],
|
|
846
|
+
env: {},
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
expect(result.skipped).toBe(0)
|
|
850
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
851
|
+
expect(
|
|
852
|
+
readFileSync(
|
|
853
|
+
join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'),
|
|
854
|
+
'utf8',
|
|
855
|
+
),
|
|
856
|
+
).toBe(AGENTS_FILES['skills/alpha/SKILL.md'])
|
|
857
|
+
expect(
|
|
858
|
+
readFileSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'), 'utf8'),
|
|
859
|
+
).toBe(OPENCODE_FILES['agents/coder.md'])
|
|
860
|
+
expect(
|
|
861
|
+
readFileSync(join(consumerRoot, 'scripts', 'memory-cli.js'), 'utf8'),
|
|
862
|
+
).toBe(SCRIPT_FILES['memory-cli.js'])
|
|
863
|
+
expect(readFileSync(join(consumerRoot, 'AGENTS.md'), 'utf8')).toBe(
|
|
864
|
+
ROOT_FILE_CONTENTS['AGENTS.md'],
|
|
865
|
+
)
|
|
866
|
+
expect(outputOf(logSpy)).toContain('Mode: force.')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('prefers a package-shipped opencode.json over the example file', () => {
|
|
870
|
+
const packageRoot = createPackage({
|
|
871
|
+
agents: {},
|
|
872
|
+
root: { 'opencode.json': '{ "mcp": { "from": "package" } }\n' },
|
|
873
|
+
mcpExample: MCP_EXAMPLE,
|
|
874
|
+
})
|
|
875
|
+
const consumerRoot = createTempDir()
|
|
876
|
+
|
|
877
|
+
runSync({ packageRoot, consumerRoot })
|
|
878
|
+
|
|
879
|
+
expect(readFileSync(join(consumerRoot, 'opencode.json'), 'utf8')).toBe(
|
|
880
|
+
'{ "mcp": { "from": "package" } }\n',
|
|
881
|
+
)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('logs progress details when AI_WORKFLOW_VERBOSE=1', () => {
|
|
885
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
886
|
+
|
|
887
|
+
runSync({
|
|
888
|
+
packageRoot,
|
|
889
|
+
consumerRoot,
|
|
890
|
+
argv: [],
|
|
891
|
+
env: { AI_WORKFLOW_VERBOSE: '1' },
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
const errors = outputOf(errorSpy)
|
|
895
|
+
expect(errors).toContain('… syncing: .agents/skills/alpha/SKILL.md')
|
|
896
|
+
expect(errors).toContain('… syncing: .opencode/agents/coder.md')
|
|
897
|
+
expect(errors).toContain('… syncing: scripts/memory-cli.js')
|
|
898
|
+
expect(errors).toContain('… syncing: AGENTS.md')
|
|
899
|
+
expect(errors).toContain('… scaffolding: memory-bank/projectbrief.md')
|
|
900
|
+
expect(errors).toContain(
|
|
901
|
+
'… scaffolding: .agents/instructions/learned-knowledge.instructions.md',
|
|
902
|
+
)
|
|
903
|
+
expect(errors).toContain('… scaffolding: opencode.json from example')
|
|
904
|
+
|
|
905
|
+
// A second verbose run covers the overwrite branch logging.
|
|
906
|
+
runSync({
|
|
907
|
+
packageRoot,
|
|
908
|
+
consumerRoot,
|
|
909
|
+
argv: [],
|
|
910
|
+
env: { AI_WORKFLOW_VERBOSE: '1' },
|
|
911
|
+
})
|
|
912
|
+
expect(outputOf(errorSpy)).toContain('… syncing: AGENTS.md')
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('writes nothing in --dry-run except pre-existing scaffolding behaviour', () => {
|
|
916
|
+
const { packageRoot, consumerRoot } = createCopyFixture()
|
|
917
|
+
|
|
918
|
+
const result = runSync({
|
|
919
|
+
packageRoot,
|
|
920
|
+
consumerRoot,
|
|
921
|
+
argv: ['--dry-run'],
|
|
922
|
+
env: {},
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
expect(result.copied).toBe(9)
|
|
926
|
+
expect(result.added).toBe(9)
|
|
927
|
+
// Copy passes are suppressed.
|
|
928
|
+
expect(existsSync(join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'))).toBe(false)
|
|
929
|
+
expect(existsSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'))).toBe(false)
|
|
930
|
+
expect(existsSync(join(consumerRoot, 'scripts', 'memory-cli.js'))).toBe(false)
|
|
931
|
+
expect(existsSync(join(consumerRoot, 'AGENTS.md'))).toBe(false)
|
|
932
|
+
// No manifest is persisted.
|
|
933
|
+
expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(false)
|
|
934
|
+
// Scaffolding is not gated by --dry-run (pre-existing behaviour).
|
|
935
|
+
expect(existsSync(join(consumerRoot, 'memory-bank', 'projectbrief.md'))).toBe(true)
|
|
936
|
+
expect(existsSync(join(consumerRoot, 'opencode.json'))).toBe(true)
|
|
937
|
+
expect(outputOf(logSpy)).toContain('Mode: dry-run.')
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
it('does not copy .opencode when the package has no such directory', () => {
|
|
941
|
+
const packageRoot = createPackage({
|
|
942
|
+
agents: { 'skills/alpha/SKILL.md': 'alpha\n' },
|
|
943
|
+
mcpExample: MCP_EXAMPLE,
|
|
944
|
+
})
|
|
945
|
+
const consumerRoot = createTempDir()
|
|
946
|
+
|
|
947
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
948
|
+
|
|
949
|
+
expect(result.copied).toBe(1)
|
|
950
|
+
expect(existsSync(join(consumerRoot, '.opencode'))).toBe(false)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('warns and returns early when the package has no .agents directory', () => {
|
|
954
|
+
const packageRoot = createPackage({})
|
|
955
|
+
const consumerRoot = createTempDir()
|
|
956
|
+
|
|
957
|
+
const result = runSync({ packageRoot, consumerRoot })
|
|
958
|
+
|
|
959
|
+
expect(result.exitCode).toBe(0)
|
|
960
|
+
expect(result.copied).toBe(0)
|
|
961
|
+
expect(outputOf(warnSpy)).toContain(
|
|
962
|
+
'No .agents directory found to sync.',
|
|
963
|
+
)
|
|
964
|
+
expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(false)
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it('creates missing parent directories for nested targets', () => {
|
|
968
|
+
const packageRoot = createPackage({
|
|
969
|
+
agents: { 'deep/nested/tree/SKILL.md': 'deep\n' },
|
|
970
|
+
mcpExample: MCP_EXAMPLE,
|
|
971
|
+
})
|
|
972
|
+
const consumerRoot = createTempDir()
|
|
973
|
+
|
|
974
|
+
runSync({ packageRoot, consumerRoot })
|
|
975
|
+
|
|
976
|
+
expect(
|
|
977
|
+
readFileSync(
|
|
978
|
+
join(consumerRoot, '.agents', 'deep', 'nested', 'tree', 'SKILL.md'),
|
|
979
|
+
'utf8',
|
|
980
|
+
),
|
|
981
|
+
).toBe('deep\n')
|
|
982
|
+
})
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
// ── Prune decisions ──────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
const PRUNE_CONTENT = {
|
|
988
|
+
agentsShipped: 'shipped skill\n',
|
|
989
|
+
agentsStale: 'stale skill\n',
|
|
990
|
+
agentsModifiedOriginal: 'modified skill v1\n',
|
|
991
|
+
agentsModifiedLocal: 'local edits must survive\n',
|
|
992
|
+
opencodeShipped: 'shipped opencode\n',
|
|
993
|
+
opencodeStale: 'stale opencode\n',
|
|
994
|
+
scriptsShipped: '// memory cli shipped\n',
|
|
995
|
+
scriptsStale: '// old tool\n',
|
|
996
|
+
rootAgents: '# package agents\n',
|
|
997
|
+
mcpExample: '{ "mcp": {} }\n',
|
|
998
|
+
rootStale: 'stale root\n',
|
|
999
|
+
rootModifiedOriginal: 'root original\n',
|
|
1000
|
+
rootModifiedLocal: 'root local edits\n',
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function createPruneFixture() {
|
|
1004
|
+
const { agentsShipped, opencodeShipped, scriptsShipped, rootAgents, mcpExample } =
|
|
1005
|
+
PRUNE_CONTENT
|
|
1006
|
+
|
|
1007
|
+
const packageRoot = createPackage({
|
|
1008
|
+
agents: { 'skills/shipped/SKILL.md': agentsShipped },
|
|
1009
|
+
opencode: { 'agents/shipped.md': opencodeShipped },
|
|
1010
|
+
scripts: { 'memory-cli.js': scriptsShipped },
|
|
1011
|
+
root: { 'AGENTS.md': rootAgents },
|
|
1012
|
+
mcpExample,
|
|
1013
|
+
})
|
|
1014
|
+
const consumerRoot = createTempDir()
|
|
1015
|
+
|
|
1016
|
+
writeFixtureFile(
|
|
1017
|
+
consumerRoot,
|
|
1018
|
+
'.agents/skills/shipped/SKILL.md',
|
|
1019
|
+
agentsShipped,
|
|
1020
|
+
)
|
|
1021
|
+
writeFixtureFile(consumerRoot, '.agents/skills/stale/SKILL.md', PRUNE_CONTENT.agentsStale)
|
|
1022
|
+
writeFixtureFile(
|
|
1023
|
+
consumerRoot,
|
|
1024
|
+
'.agents/skills/modified/SKILL.md',
|
|
1025
|
+
PRUNE_CONTENT.agentsModifiedLocal,
|
|
1026
|
+
)
|
|
1027
|
+
mkdirSync(join(consumerRoot, '.agents', 'skills', 'dir', 'SKILL.md'), {
|
|
1028
|
+
recursive: true,
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
writeFixtureFile(
|
|
1032
|
+
consumerRoot,
|
|
1033
|
+
'.opencode/agents/shipped.md',
|
|
1034
|
+
opencodeShipped,
|
|
1035
|
+
)
|
|
1036
|
+
writeFixtureFile(
|
|
1037
|
+
consumerRoot,
|
|
1038
|
+
'.opencode/agents/stale.md',
|
|
1039
|
+
PRUNE_CONTENT.opencodeStale,
|
|
1040
|
+
)
|
|
1041
|
+
writeFixtureFile(consumerRoot, '.opencode/node_modules/dep.js', 'dep\n')
|
|
1042
|
+
writeFixtureFile(consumerRoot, '.opencode/package.json', '{}\n')
|
|
1043
|
+
|
|
1044
|
+
writeFixtureFile(consumerRoot, 'scripts/memory-cli.js', scriptsShipped)
|
|
1045
|
+
writeFixtureFile(consumerRoot, 'scripts/old-tool.js', PRUNE_CONTENT.scriptsStale)
|
|
1046
|
+
|
|
1047
|
+
writeFixtureFile(consumerRoot, 'AGENTS.md', rootAgents)
|
|
1048
|
+
writeFixtureFile(consumerRoot, 'opencode.json', mcpExample)
|
|
1049
|
+
writeFixtureFile(consumerRoot, 'stale-root.md', PRUNE_CONTENT.rootStale)
|
|
1050
|
+
writeFixtureFile(consumerRoot, 'untracked.txt', 'untracked\n')
|
|
1051
|
+
writeFixtureFile(consumerRoot, 'empty-hash.txt', 'empty hash\n')
|
|
1052
|
+
writeFixtureFile(
|
|
1053
|
+
consumerRoot,
|
|
1054
|
+
'modified-root.md',
|
|
1055
|
+
PRUNE_CONTENT.rootModifiedLocal,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
const manifest = {
|
|
1059
|
+
version: 1,
|
|
1060
|
+
files: {
|
|
1061
|
+
'skills/shipped/SKILL.md': sha256(agentsShipped),
|
|
1062
|
+
'skills/stale/SKILL.md': sha256(PRUNE_CONTENT.agentsStale),
|
|
1063
|
+
'skills/modified/SKILL.md': sha256(PRUNE_CONTENT.agentsModifiedOriginal),
|
|
1064
|
+
'skills/gone/SKILL.md': sha256('gone'),
|
|
1065
|
+
'skills/dir/SKILL.md': sha256('directory placeholder'),
|
|
1066
|
+
'instructions/learned-knowledge.instructions.md': sha256('excluded'),
|
|
1067
|
+
'../escape.txt': sha256('escape'),
|
|
1068
|
+
'..': sha256('parent'),
|
|
1069
|
+
'./.': sha256('self'),
|
|
1070
|
+
'': sha256('empty'),
|
|
1071
|
+
'__memory-bank__/projectbrief.md': sha256('scaffold'),
|
|
1072
|
+
'__agents-instructions__/learned-knowledge.instructions.md': sha256(
|
|
1073
|
+
'scaffold',
|
|
1074
|
+
),
|
|
1075
|
+
'__future__/thing.md': sha256('future'),
|
|
1076
|
+
'__opencode__/agents/shipped.md': sha256(opencodeShipped),
|
|
1077
|
+
'__opencode__/agents/stale.md': sha256(PRUNE_CONTENT.opencodeStale),
|
|
1078
|
+
'__opencode__/node_modules/dep.js': sha256('dep'),
|
|
1079
|
+
'__opencode__/package.json': sha256('pkg'),
|
|
1080
|
+
'__opencode__/': sha256('empty'),
|
|
1081
|
+
'__opencode__/agents/missing.md': sha256('missing'),
|
|
1082
|
+
'__scripts__/memory-cli.js': sha256(scriptsShipped),
|
|
1083
|
+
'__scripts__/old-tool.js': sha256(PRUNE_CONTENT.scriptsStale),
|
|
1084
|
+
'__scripts__/': sha256('empty'),
|
|
1085
|
+
'__root__/AGENTS.md': sha256(rootAgents),
|
|
1086
|
+
'__root__/opencode.json': sha256(mcpExample),
|
|
1087
|
+
'__root__/stale-root.md': sha256(PRUNE_CONTENT.rootStale),
|
|
1088
|
+
'__root__/gone-root.md': sha256('gone'),
|
|
1089
|
+
'__root__/untracked.txt': null,
|
|
1090
|
+
'__root__/empty-hash.txt': '',
|
|
1091
|
+
'__root__/modified-root.md': sha256(PRUNE_CONTENT.rootModifiedOriginal),
|
|
1092
|
+
},
|
|
1093
|
+
}
|
|
1094
|
+
writeFixtureFile(
|
|
1095
|
+
consumerRoot,
|
|
1096
|
+
'.agents-sync-manifest.json',
|
|
1097
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
return { packageRoot, consumerRoot, manifest }
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
describe('--prune decision matrix', () => {
|
|
1104
|
+
it('prunes only unmodified files whose package source is gone', () => {
|
|
1105
|
+
const { packageRoot, consumerRoot } = createPruneFixture()
|
|
1106
|
+
|
|
1107
|
+
const result = runSync({
|
|
1108
|
+
packageRoot,
|
|
1109
|
+
consumerRoot,
|
|
1110
|
+
argv: ['--prune'],
|
|
1111
|
+
env: {},
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
expect(result.pruned).toBe(4)
|
|
1115
|
+
expect(result.pruneSkipped).toBe(4)
|
|
1116
|
+
|
|
1117
|
+
// Pruned: agents stale, opencode stale, scripts old-tool, root stale-root.
|
|
1118
|
+
for (const relativePath of [
|
|
1119
|
+
'.agents/skills/stale/SKILL.md',
|
|
1120
|
+
'.opencode/agents/stale.md',
|
|
1121
|
+
'scripts/old-tool.js',
|
|
1122
|
+
'stale-root.md',
|
|
1123
|
+
]) {
|
|
1124
|
+
expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
|
|
1125
|
+
false,
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Kept: package-shipped files, locally modified files, directories.
|
|
1130
|
+
for (const relativePath of [
|
|
1131
|
+
'.agents/skills/shipped/SKILL.md',
|
|
1132
|
+
'.agents/skills/modified/SKILL.md',
|
|
1133
|
+
'.opencode/agents/shipped.md',
|
|
1134
|
+
'scripts/memory-cli.js',
|
|
1135
|
+
'AGENTS.md',
|
|
1136
|
+
'opencode.json',
|
|
1137
|
+
'modified-root.md',
|
|
1138
|
+
'untracked.txt',
|
|
1139
|
+
'empty-hash.txt',
|
|
1140
|
+
]) {
|
|
1141
|
+
expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
|
|
1142
|
+
true,
|
|
1143
|
+
)
|
|
1144
|
+
}
|
|
1145
|
+
expect(
|
|
1146
|
+
readFileSync(
|
|
1147
|
+
join(consumerRoot, '.agents/skills/modified/SKILL.md'),
|
|
1148
|
+
'utf8',
|
|
1149
|
+
),
|
|
1150
|
+
).toBe(PRUNE_CONTENT.agentsModifiedLocal)
|
|
1151
|
+
expect(
|
|
1152
|
+
readFileSync(join(consumerRoot, 'modified-root.md'), 'utf8'),
|
|
1153
|
+
).toBe(PRUNE_CONTENT.rootModifiedLocal)
|
|
1154
|
+
|
|
1155
|
+
// Directories are never deleted, and an emptied parent is reported.
|
|
1156
|
+
expect(existsSync(join(consumerRoot, '.agents/skills/dir/SKILL.md'))).toBe(
|
|
1157
|
+
true,
|
|
1158
|
+
)
|
|
1159
|
+
expect(existsSync(join(consumerRoot, '.agents/skills/stale'))).toBe(true)
|
|
1160
|
+
expect(outputOf(logSpy)).toContain(
|
|
1161
|
+
'Left empty directory in place: .agents/skills/stale',
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1165
|
+
// Pruned entries and entries whose consumer copy was already gone are dropped.
|
|
1166
|
+
for (const key of [
|
|
1167
|
+
'skills/stale/SKILL.md',
|
|
1168
|
+
'skills/gone/SKILL.md',
|
|
1169
|
+
'__opencode__/agents/stale.md',
|
|
1170
|
+
'__opencode__/agents/missing.md',
|
|
1171
|
+
'__scripts__/old-tool.js',
|
|
1172
|
+
'__root__/stale-root.md',
|
|
1173
|
+
'__root__/gone-root.md',
|
|
1174
|
+
]) {
|
|
1175
|
+
expect(manifest.files[key], key).toBeUndefined()
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Everything kept retains its bookkeeping entry, including defensive nulls.
|
|
1179
|
+
for (const key of [
|
|
1180
|
+
'skills/shipped/SKILL.md',
|
|
1181
|
+
'skills/modified/SKILL.md',
|
|
1182
|
+
'skills/dir/SKILL.md',
|
|
1183
|
+
'instructions/learned-knowledge.instructions.md',
|
|
1184
|
+
'../escape.txt',
|
|
1185
|
+
'..',
|
|
1186
|
+
'./.',
|
|
1187
|
+
'',
|
|
1188
|
+
'__memory-bank__/projectbrief.md',
|
|
1189
|
+
'__agents-instructions__/learned-knowledge.instructions.md',
|
|
1190
|
+
'__future__/thing.md',
|
|
1191
|
+
'__opencode__/agents/shipped.md',
|
|
1192
|
+
'__opencode__/node_modules/dep.js',
|
|
1193
|
+
'__opencode__/package.json',
|
|
1194
|
+
'__opencode__/',
|
|
1195
|
+
'__scripts__/memory-cli.js',
|
|
1196
|
+
'__scripts__/',
|
|
1197
|
+
'__root__/AGENTS.md',
|
|
1198
|
+
'__root__/opencode.json',
|
|
1199
|
+
'__root__/untracked.txt',
|
|
1200
|
+
'__root__/empty-hash.txt',
|
|
1201
|
+
'__root__/modified-root.md',
|
|
1202
|
+
]) {
|
|
1203
|
+
expect(manifest.files, key).toHaveProperty([key])
|
|
1204
|
+
}
|
|
1205
|
+
expect(manifest.files['skills/modified/SKILL.md']).toBe(
|
|
1206
|
+
sha256(PRUNE_CONTENT.agentsModifiedOriginal),
|
|
1207
|
+
)
|
|
1208
|
+
expect(manifest.files['__root__/untracked.txt']).toBeNull()
|
|
1209
|
+
expect(manifest.files['__root__/empty-hash.txt']).toBe('')
|
|
1210
|
+
|
|
1211
|
+
const warnings = outputOf(warnSpy)
|
|
1212
|
+
expect(warnings).toContain(
|
|
1213
|
+
'Skipped pruning locally modified file: .agents/skills/modified/SKILL.md',
|
|
1214
|
+
)
|
|
1215
|
+
expect(warnings).toContain(
|
|
1216
|
+
'Skipped pruning locally modified file: modified-root.md',
|
|
1217
|
+
)
|
|
1218
|
+
expect(warnings).toContain(
|
|
1219
|
+
'Skipped pruning untracked file: untracked.txt',
|
|
1220
|
+
)
|
|
1221
|
+
expect(warnings).toContain(
|
|
1222
|
+
'Skipped pruning untracked file: empty-hash.txt',
|
|
1223
|
+
)
|
|
1224
|
+
expect(outputOf(logSpy)).toContain(
|
|
1225
|
+
'Pruned 4 stale file(s); skipped 4 locally modified.',
|
|
1226
|
+
)
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it('reports prune candidates in --dry-run without deleting or persisting', () => {
|
|
1230
|
+
const { packageRoot, consumerRoot } = createPruneFixture()
|
|
1231
|
+
const manifestPath = join(consumerRoot, '.agents-sync-manifest.json')
|
|
1232
|
+
const manifestBefore = readFileSync(manifestPath, 'utf8')
|
|
1233
|
+
|
|
1234
|
+
const result = runSync({
|
|
1235
|
+
packageRoot,
|
|
1236
|
+
consumerRoot,
|
|
1237
|
+
argv: ['--prune', '--dry-run'],
|
|
1238
|
+
env: {},
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
expect(result.pruned).toBe(4)
|
|
1242
|
+
expect(result.pruneSkipped).toBe(4)
|
|
1243
|
+
|
|
1244
|
+
for (const relativePath of [
|
|
1245
|
+
'.agents/skills/stale/SKILL.md',
|
|
1246
|
+
'.opencode/agents/stale.md',
|
|
1247
|
+
'scripts/old-tool.js',
|
|
1248
|
+
'stale-root.md',
|
|
1249
|
+
]) {
|
|
1250
|
+
expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
|
|
1251
|
+
true,
|
|
1252
|
+
)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// The manifest on disk is byte-for-byte unchanged.
|
|
1256
|
+
expect(readFileSync(manifestPath, 'utf8')).toBe(manifestBefore)
|
|
1257
|
+
|
|
1258
|
+
const logs = outputOf(logSpy)
|
|
1259
|
+
expect(logs).toContain('Would prune: .agents/skills/stale/SKILL.md')
|
|
1260
|
+
expect(logs).toContain('Would prune: .opencode/agents/stale.md')
|
|
1261
|
+
expect(logs).toContain('Would prune: scripts/old-tool.js')
|
|
1262
|
+
expect(logs).toContain('Would prune: stale-root.md')
|
|
1263
|
+
expect(logs).toContain(
|
|
1264
|
+
'Would prune 4 stale file(s); skipped 4 locally modified.',
|
|
1265
|
+
)
|
|
1266
|
+
})
|
|
1267
|
+
|
|
1268
|
+
it('does not drop stale entries for missing consumer files in --dry-run', () => {
|
|
1269
|
+
const { packageRoot, consumerRoot } = createPruneFixture()
|
|
1270
|
+
|
|
1271
|
+
const result = runSync({
|
|
1272
|
+
packageRoot,
|
|
1273
|
+
consumerRoot,
|
|
1274
|
+
argv: ['--prune', '--dry-run'],
|
|
1275
|
+
env: {},
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
expect(result.exitCode).toBe(0)
|
|
1279
|
+
// Dry-run must not drop the entries either; the file is simply reported as-is.
|
|
1280
|
+
expect(readConsumerManifest(consumerRoot).files['skills/gone/SKILL.md']).toBeDefined()
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
it('keeps scaffold-only namespaces out of the prune path', () => {
|
|
1284
|
+
const { packageRoot, consumerRoot } = createPruneFixture()
|
|
1285
|
+
|
|
1286
|
+
runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
|
|
1287
|
+
|
|
1288
|
+
expect(existsSync(join(consumerRoot, 'memory-bank/projectbrief.md'))).toBe(
|
|
1289
|
+
true,
|
|
1290
|
+
)
|
|
1291
|
+
expect(
|
|
1292
|
+
existsSync(
|
|
1293
|
+
join(
|
|
1294
|
+
consumerRoot,
|
|
1295
|
+
'.agents/instructions/learned-knowledge.instructions.md',
|
|
1296
|
+
),
|
|
1297
|
+
),
|
|
1298
|
+
).toBe(true)
|
|
1299
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1300
|
+
expect(manifest.files['__memory-bank__/projectbrief.md']).toBeDefined()
|
|
1301
|
+
expect(
|
|
1302
|
+
manifest.files['__agents-instructions__/learned-knowledge.instructions.md'],
|
|
1303
|
+
).toBeDefined()
|
|
1304
|
+
})
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
// ── Manifest snapshot semantics ──────────────────────────────────────────────
|
|
1308
|
+
|
|
1309
|
+
describe('prune snapshot semantics', () => {
|
|
1310
|
+
it('never prunes a file scaffolded during the same run', () => {
|
|
1311
|
+
const packageRoot = createPackage({
|
|
1312
|
+
agents: {},
|
|
1313
|
+
mcpExample: MCP_EXAMPLE,
|
|
1314
|
+
})
|
|
1315
|
+
const consumerRoot = createTempDir()
|
|
1316
|
+
|
|
1317
|
+
// First run scaffolds memory-bank + opencode.json and records them.
|
|
1318
|
+
runSync({ packageRoot, consumerRoot })
|
|
1319
|
+
// Second run with --prune: source package has no memory-bank, but the keys
|
|
1320
|
+
// were present before this run and must not be treated as stale.
|
|
1321
|
+
runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
|
|
1322
|
+
|
|
1323
|
+
expect(existsSync(join(consumerRoot, 'memory-bank', 'projectbrief.md'))).toBe(
|
|
1324
|
+
true,
|
|
1325
|
+
)
|
|
1326
|
+
expect(existsSync(join(consumerRoot, 'opencode.json'))).toBe(true)
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
it('keeps manifest bookkeeping for an emptied directory path', () => {
|
|
1330
|
+
const { packageRoot, consumerRoot } = createPruneFixture()
|
|
1331
|
+
|
|
1332
|
+
runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
|
|
1333
|
+
|
|
1334
|
+
const manifest = readConsumerManifest(consumerRoot)
|
|
1335
|
+
expect(manifest.files['skills/dir/SKILL.md']).toBe(
|
|
1336
|
+
sha256('directory placeholder'),
|
|
1337
|
+
)
|
|
1338
|
+
expect(readdirSync(join(consumerRoot, '.agents/skills/dir'))).toContain(
|
|
1339
|
+
'SKILL.md',
|
|
1340
|
+
)
|
|
1341
|
+
})
|
|
1342
|
+
})
|