happyskills 0.48.0 → 0.49.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/CHANGELOG.md +48 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills bump` — § 8.6 (bump narrowed to
|
|
4
|
+
* skill.json only). After bump, skill.json's version is the new value AND
|
|
5
|
+
* the lock entry's version is UNCHANGED. Subsequent `list --json` reports
|
|
6
|
+
* the skill as `status: "ahead"`.
|
|
7
|
+
*
|
|
8
|
+
* This test prevents regression of the lock-as-registry-view principle:
|
|
9
|
+
* a local bump is not a registry interaction, so the lock has no business
|
|
10
|
+
* being mutated by it.
|
|
11
|
+
*/
|
|
12
|
+
const { describe, it } = require('node:test')
|
|
13
|
+
const assert = require('node:assert/strict')
|
|
14
|
+
const { spawnSync } = require('child_process')
|
|
15
|
+
const fs = require('fs')
|
|
16
|
+
const os = require('os')
|
|
17
|
+
const path = require('path')
|
|
18
|
+
|
|
19
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
20
|
+
const NODE = process.execPath
|
|
21
|
+
|
|
22
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-bump-test-'))
|
|
23
|
+
const run = (args, opts) => {
|
|
24
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
25
|
+
env: {
|
|
26
|
+
...process.env,
|
|
27
|
+
NO_COLOR: '1',
|
|
28
|
+
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
29
|
+
...(opts?.env || {})
|
|
30
|
+
},
|
|
31
|
+
encoding: 'utf-8',
|
|
32
|
+
timeout: 10000,
|
|
33
|
+
cwd: opts?.cwd
|
|
34
|
+
})
|
|
35
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
36
|
+
}
|
|
37
|
+
const parse_json = (stdout, label) => {
|
|
38
|
+
try { return JSON.parse(stdout) }
|
|
39
|
+
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scaffold a project with one locked-and-installed skill.
|
|
43
|
+
const scaffold = ({ full, short, lock_version, disk_version }) => {
|
|
44
|
+
const root = make_tmp()
|
|
45
|
+
const skill_dir = path.join(root, '.agents', 'skills', short)
|
|
46
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
49
|
+
`---\nname: ${short}\ndescription: bump test skill\n---\nbody\n`
|
|
50
|
+
)
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(skill_dir, 'skill.json'),
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
name: short,
|
|
55
|
+
version: disk_version,
|
|
56
|
+
type: 'skill',
|
|
57
|
+
description: 'bump test skill'
|
|
58
|
+
}, null, '\t')
|
|
59
|
+
)
|
|
60
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
61
|
+
lockVersion: 2,
|
|
62
|
+
generatedAt: new Date().toISOString(),
|
|
63
|
+
skills: {
|
|
64
|
+
[full]: {
|
|
65
|
+
version: lock_version,
|
|
66
|
+
type: 'skill',
|
|
67
|
+
ref: `refs/tags/v${lock_version}`,
|
|
68
|
+
commit: 'lockcommit',
|
|
69
|
+
integrity: 'sha256-baseline',
|
|
70
|
+
base_commit: 'lockcommit',
|
|
71
|
+
base_integrity: 'sha256-baseline',
|
|
72
|
+
requested_by: ['__root__'],
|
|
73
|
+
dependencies: {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, null, '\t'))
|
|
77
|
+
return {
|
|
78
|
+
root,
|
|
79
|
+
skill_dir,
|
|
80
|
+
read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
|
|
81
|
+
read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
|
|
82
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('bump — § 8.6 narrowed to skill.json only', () => {
|
|
87
|
+
it('updates skill.json version but leaves the lock entry UNCHANGED', () => {
|
|
88
|
+
const ctx = scaffold({ full: 'acme/my-skill', short: 'my-skill', lock_version: '1.0.0', disk_version: '1.0.0' })
|
|
89
|
+
try {
|
|
90
|
+
const { code, stdout } = run(['bump', 'patch', 'my-skill', '--json'], { cwd: ctx.root })
|
|
91
|
+
assert.strictEqual(code, 0, `bump should exit 0, got ${code}`)
|
|
92
|
+
const out = parse_json(stdout, 'bump --json')
|
|
93
|
+
assert.strictEqual(out.data.old_version, '1.0.0')
|
|
94
|
+
assert.strictEqual(out.data.new_version, '1.0.1')
|
|
95
|
+
|
|
96
|
+
// Manifest was updated.
|
|
97
|
+
assert.strictEqual(ctx.read_manifest().version, '1.0.1', 'skill.json must be at the new version')
|
|
98
|
+
|
|
99
|
+
// Lock entry was NOT updated.
|
|
100
|
+
const lock = ctx.read_lock()
|
|
101
|
+
assert.strictEqual(
|
|
102
|
+
lock.skills['acme/my-skill'].version,
|
|
103
|
+
'1.0.0',
|
|
104
|
+
'lock entry version must be UNCHANGED — lock catches up at publish time, not bump time'
|
|
105
|
+
)
|
|
106
|
+
assert.strictEqual(
|
|
107
|
+
lock.skills['acme/my-skill'].ref,
|
|
108
|
+
'refs/tags/v1.0.0',
|
|
109
|
+
'lock entry ref must be UNCHANGED'
|
|
110
|
+
)
|
|
111
|
+
assert.strictEqual(
|
|
112
|
+
lock.skills['acme/my-skill'].integrity,
|
|
113
|
+
'sha256-baseline',
|
|
114
|
+
'lock entry integrity must be UNCHANGED'
|
|
115
|
+
)
|
|
116
|
+
} finally { ctx.cleanup() }
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('subsequent list --json reports the bumped skill as status:ahead', () => {
|
|
120
|
+
const ctx = scaffold({ full: 'acme/my-skill', short: 'my-skill', lock_version: '0.3.2', disk_version: '0.3.2' })
|
|
121
|
+
try {
|
|
122
|
+
const { code: bump_code } = run(['bump', 'patch', 'my-skill', '--json'], { cwd: ctx.root })
|
|
123
|
+
assert.strictEqual(bump_code, 0)
|
|
124
|
+
|
|
125
|
+
const { code, stdout } = run(['list', '--json'], { cwd: ctx.root })
|
|
126
|
+
assert.strictEqual(code, 0)
|
|
127
|
+
const out = parse_json(stdout, 'list --json')
|
|
128
|
+
const entry = out.data.skills['acme/my-skill']
|
|
129
|
+
assert.ok(entry, 'skill must appear in list output')
|
|
130
|
+
assert.strictEqual(entry.status, 'ahead', 'must be ahead, not drift, not installed')
|
|
131
|
+
assert.strictEqual(entry.ahead.lock_version, '0.3.2')
|
|
132
|
+
assert.strictEqual(entry.ahead.disk_version, '0.3.3')
|
|
133
|
+
assert.strictEqual(entry.drift, undefined, 'ahead must not emit a drift object')
|
|
134
|
+
} finally { ctx.cleanup() }
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('explicit version bump leaves lock untouched', () => {
|
|
138
|
+
const ctx = scaffold({ full: 'acme/exact', short: 'exact', lock_version: '0.1.0', disk_version: '0.1.0' })
|
|
139
|
+
try {
|
|
140
|
+
const { code } = run(['bump', '2.5.7', 'exact', '--json'], { cwd: ctx.root })
|
|
141
|
+
assert.strictEqual(code, 0)
|
|
142
|
+
assert.strictEqual(ctx.read_manifest().version, '2.5.7')
|
|
143
|
+
assert.strictEqual(ctx.read_lock().skills['acme/exact'].version, '0.1.0', 'lock untouched')
|
|
144
|
+
} finally { ctx.cleanup() }
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('bump still works when there is no lock entry for the skill at all', () => {
|
|
148
|
+
// e.g. a freshly-init'd skill that has never been published. There's
|
|
149
|
+
// no lock key to update, and that's fine — bump only writes to
|
|
150
|
+
// skill.json.
|
|
151
|
+
const root = make_tmp()
|
|
152
|
+
try {
|
|
153
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'fresh')
|
|
154
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
157
|
+
'---\nname: fresh\ndescription: never-published skill\n---\nbody\n'
|
|
158
|
+
)
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(skill_dir, 'skill.json'),
|
|
161
|
+
JSON.stringify({ name: 'fresh', version: '0.1.0', type: 'skill', description: 'never-published skill' }, null, '\t')
|
|
162
|
+
)
|
|
163
|
+
// No skills-lock.json at all.
|
|
164
|
+
const { code } = run(['bump', 'minor', 'fresh', '--json'], { cwd: root })
|
|
165
|
+
assert.strictEqual(code, 0)
|
|
166
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8'))
|
|
167
|
+
assert.strictEqual(manifest.version, '0.2.0')
|
|
168
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -108,7 +108,7 @@ const scaffold_drifted = (skills) => {
|
|
|
108
108
|
// ─── status ───────────────────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
110
|
describe('status — drift detection', () => {
|
|
111
|
-
it('reports drift when
|
|
111
|
+
it('reports drift:regression when disk version is BEHIND lock (the linwong bug, now narrowed)', () => {
|
|
112
112
|
const { root, cleanup } = scaffold_drifted([
|
|
113
113
|
{ full: 'happyskillsai/happyskills-design', short: 'happyskills-design', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
114
114
|
])
|
|
@@ -120,14 +120,35 @@ describe('status — drift detection', () => {
|
|
|
120
120
|
assert.ok(result, 'result for the drifted skill must be present')
|
|
121
121
|
assert.strictEqual(result.status, 'drift', 'status must be reported as drift, not modified or clean')
|
|
122
122
|
assert.deepEqual(result.drift, {
|
|
123
|
-
reason: '
|
|
123
|
+
reason: 'regression',
|
|
124
124
|
lock_version: '0.4.0',
|
|
125
125
|
disk_version: '0.3.0'
|
|
126
126
|
})
|
|
127
127
|
} finally { cleanup() }
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
it('
|
|
130
|
+
it('reports status:ahead when disk version is AHEAD of lock (the §2 normal authoring case)', () => {
|
|
131
|
+
// The §2 incident's trigger state: agent hand-edited skill.json from
|
|
132
|
+
// 0.3.2 -> 0.3.3, lock still at 0.3.2. Under the new semantics this is
|
|
133
|
+
// the normal `ahead` state, NOT drift. Asserting this prevents the false
|
|
134
|
+
// positive from coming back.
|
|
135
|
+
const { root, cleanup } = scaffold_drifted([
|
|
136
|
+
{ full: 'happyskillsai/happyskills-help', short: 'happyskills-help', lock_version: '0.3.2', disk_version: '0.3.3' }
|
|
137
|
+
])
|
|
138
|
+
try {
|
|
139
|
+
const { code, stdout } = run(['status', '--json'], { cwd: root })
|
|
140
|
+
assert.strictEqual(code, 0, 'status should exit 0')
|
|
141
|
+
const out = parse_json(stdout, 'status --json')
|
|
142
|
+
const result = out.data.results.find(r => r.skill === 'happyskillsai/happyskills-help')
|
|
143
|
+
assert.ok(result, 'result must be present')
|
|
144
|
+
assert.strictEqual(result.status, 'ahead', 'disk > lock must be reported as ahead, not drift')
|
|
145
|
+
assert.strictEqual(result.drift, null, 'ahead must not emit a drift object')
|
|
146
|
+
assert.strictEqual(result.ahead.lock_version, '0.3.2')
|
|
147
|
+
assert.strictEqual(result.ahead.disk_version, '0.3.3')
|
|
148
|
+
} finally { cleanup() }
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('drift:regression outranks modified — does not mislabel structural drift as local edits', () => {
|
|
131
152
|
// Critical regression: previously reported as "modified (local changes)"
|
|
132
153
|
const { root, cleanup } = scaffold_drifted([
|
|
133
154
|
{ full: 'acme/foo', short: 'foo', lock_version: '2.0.0', disk_version: '1.0.0' }
|
|
@@ -138,6 +159,7 @@ describe('status — drift detection', () => {
|
|
|
138
159
|
const out = parse_json(stdout, 'status --json')
|
|
139
160
|
const result = out.data.results[0]
|
|
140
161
|
assert.strictEqual(result.status, 'drift', 'must be drift, not modified')
|
|
162
|
+
assert.strictEqual(result.drift.reason, 'regression')
|
|
141
163
|
assert.notStrictEqual(result.status, 'modified', 'modified would mislead — this is structural drift, not user edits')
|
|
142
164
|
} finally { cleanup() }
|
|
143
165
|
})
|
|
@@ -188,7 +210,7 @@ describe('status — drift detection', () => {
|
|
|
188
210
|
// ─── check ────────────────────────────────────────────────────────────────────
|
|
189
211
|
|
|
190
212
|
describe('check — drift detection', () => {
|
|
191
|
-
it('reports drift even when the registry API is unreachable', () => {
|
|
213
|
+
it('reports drift:regression even when the registry API is unreachable', () => {
|
|
192
214
|
// HAPPYSKILLS_API_URL=http://localhost:0 makes the API call fail.
|
|
193
215
|
// Drift detection is purely local — it must still surface.
|
|
194
216
|
const { root, cleanup } = scaffold_drifted([
|
|
@@ -202,13 +224,30 @@ describe('check — drift detection', () => {
|
|
|
202
224
|
assert.ok(result, 'result must be present even with API down')
|
|
203
225
|
assert.strictEqual(result.status, 'drift')
|
|
204
226
|
assert.deepEqual(result.drift, {
|
|
205
|
-
reason: '
|
|
227
|
+
reason: 'regression',
|
|
206
228
|
lock_version: '0.4.0',
|
|
207
229
|
disk_version: '0.3.0'
|
|
208
230
|
})
|
|
209
231
|
} finally { cleanup() }
|
|
210
232
|
})
|
|
211
233
|
|
|
234
|
+
it('reports status:ahead when disk > lock even with no registry access', () => {
|
|
235
|
+
const { root, cleanup } = scaffold_drifted([
|
|
236
|
+
{ full: 'acme/ahead-only', short: 'ahead-only', lock_version: '0.3.2', disk_version: '0.3.3' }
|
|
237
|
+
])
|
|
238
|
+
try {
|
|
239
|
+
const { code, stdout } = run(['check', '--json'], { cwd: root })
|
|
240
|
+
assert.strictEqual(code, 0)
|
|
241
|
+
const out = parse_json(stdout, 'check --json')
|
|
242
|
+
const result = out.data.results.find(r => r.skill === 'acme/ahead-only')
|
|
243
|
+
assert.ok(result)
|
|
244
|
+
assert.strictEqual(result.status, 'ahead')
|
|
245
|
+
assert.strictEqual(result.ahead.lock_version, '0.3.2')
|
|
246
|
+
assert.strictEqual(result.ahead.disk_version, '0.3.3')
|
|
247
|
+
assert.strictEqual(out.data.ahead_count, 1)
|
|
248
|
+
} finally { cleanup() }
|
|
249
|
+
})
|
|
250
|
+
|
|
212
251
|
it('drift_count appears in the JSON summary', () => {
|
|
213
252
|
const { root, cleanup } = scaffold_drifted([
|
|
214
253
|
{ full: 'acme/one', short: 'one', lock_version: '1.0.0', disk_version: '0.9.0' },
|
|
@@ -239,8 +278,8 @@ describe('check — drift detection', () => {
|
|
|
239
278
|
|
|
240
279
|
// ─── list ─────────────────────────────────────────────────────────────────────
|
|
241
280
|
|
|
242
|
-
describe('list — drift surfacing', () => {
|
|
243
|
-
it('list --json marks drifted skills with status "drift" and a drift object', () => {
|
|
281
|
+
describe('list — drift and ahead surfacing', () => {
|
|
282
|
+
it('list --json marks drifted skills with status "drift" and a regression drift object', () => {
|
|
244
283
|
const { root, cleanup } = scaffold_drifted([
|
|
245
284
|
{ full: 'acme/drifted', short: 'drifted', lock_version: '0.4.0', disk_version: '0.3.0' },
|
|
246
285
|
{ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' }
|
|
@@ -251,12 +290,28 @@ describe('list — drift surfacing', () => {
|
|
|
251
290
|
const out = parse_json(stdout, 'list --json')
|
|
252
291
|
assert.strictEqual(out.data.skills['acme/drifted'].status, 'drift')
|
|
253
292
|
assert.deepEqual(out.data.skills['acme/drifted'].drift, {
|
|
254
|
-
reason: '
|
|
293
|
+
reason: 'regression',
|
|
255
294
|
lock_version: '0.4.0',
|
|
256
295
|
disk_version: '0.3.0'
|
|
257
296
|
})
|
|
258
297
|
assert.strictEqual(out.data.skills['acme/clean'].status, 'installed')
|
|
259
298
|
assert.strictEqual(out.data.skills['acme/clean'].drift, undefined)
|
|
299
|
+
assert.strictEqual(out.data.skills['acme/clean'].ahead, undefined)
|
|
300
|
+
} finally { cleanup() }
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('list --json marks ahead skills with status "ahead" and an ahead object (no drift)', () => {
|
|
304
|
+
const { root, cleanup } = scaffold_drifted([
|
|
305
|
+
{ full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' }
|
|
306
|
+
])
|
|
307
|
+
try {
|
|
308
|
+
const { code, stdout } = run(['list', '--json'], { cwd: root })
|
|
309
|
+
assert.strictEqual(code, 0)
|
|
310
|
+
const out = parse_json(stdout, 'list --json')
|
|
311
|
+
assert.strictEqual(out.data.skills['acme/ahead'].status, 'ahead')
|
|
312
|
+
assert.strictEqual(out.data.skills['acme/ahead'].drift, undefined)
|
|
313
|
+
assert.strictEqual(out.data.skills['acme/ahead'].ahead.lock_version, '0.3.2')
|
|
314
|
+
assert.strictEqual(out.data.skills['acme/ahead'].ahead.disk_version, '0.3.3')
|
|
260
315
|
} finally { cleanup() }
|
|
261
316
|
})
|
|
262
317
|
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills install --fresh` — § 8.5 hardening.
|
|
4
|
+
*
|
|
5
|
+
* This closes the § 2 root cause B: previously, `install <skill>@<version> --fresh`
|
|
6
|
+
* silently fell back to the latest published version when the requested version
|
|
7
|
+
* didn't exist. The hardened command must:
|
|
8
|
+
* (a) hard-fail with VERSION_NOT_FOUND before any disk mutation,
|
|
9
|
+
* (b) snapshot before wiping when the directory exists,
|
|
10
|
+
* (c) refuse on local edits unless --force-discard-local.
|
|
11
|
+
*
|
|
12
|
+
* These tests force the registry to be unreachable, which exercises the
|
|
13
|
+
* pre-flight failure mode — the version cannot be verified, so --fresh
|
|
14
|
+
* refuses. That's exactly the safety behavior the spec requires.
|
|
15
|
+
*/
|
|
16
|
+
const { describe, it } = require('node:test')
|
|
17
|
+
const assert = require('node:assert/strict')
|
|
18
|
+
const { spawnSync } = require('child_process')
|
|
19
|
+
const fs = require('fs')
|
|
20
|
+
const os = require('os')
|
|
21
|
+
const path = require('path')
|
|
22
|
+
|
|
23
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
24
|
+
const NODE = process.execPath
|
|
25
|
+
|
|
26
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-install-fresh-test-'))
|
|
27
|
+
const run = (args, opts) => {
|
|
28
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
29
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
|
|
30
|
+
encoding: 'utf-8',
|
|
31
|
+
timeout: 10000,
|
|
32
|
+
cwd: opts?.cwd
|
|
33
|
+
})
|
|
34
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const scaffold_installed = ({ full, short, version, modified }) => {
|
|
38
|
+
const root = make_tmp()
|
|
39
|
+
const skill_dir = path.join(root, '.agents', 'skills', short)
|
|
40
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
43
|
+
`---\nname: ${short}\ndescription: install-fresh test skill\n---\nbody\n`
|
|
44
|
+
)
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(skill_dir, 'skill.json'),
|
|
47
|
+
JSON.stringify({ name: short, version, type: 'skill', description: 'install-fresh test skill' }, null, '\t')
|
|
48
|
+
)
|
|
49
|
+
const { hash_directory } = require('../lock/integrity')
|
|
50
|
+
|
|
51
|
+
// Compute the clean baseline integrity *before* applying any modification.
|
|
52
|
+
// hash_directory caches results — we'd otherwise get the dirty hash.
|
|
53
|
+
let lock_integrity
|
|
54
|
+
// eslint-disable-next-line no-async-promise-executor
|
|
55
|
+
return new Promise(async (res) => {
|
|
56
|
+
const { clear_integrity_cache } = require('../lock/integrity')
|
|
57
|
+
clear_integrity_cache()
|
|
58
|
+
const [, clean_hash] = await hash_directory(skill_dir)
|
|
59
|
+
lock_integrity = clean_hash
|
|
60
|
+
|
|
61
|
+
if (modified) {
|
|
62
|
+
clear_integrity_cache()
|
|
63
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: ' + short + '\ndescription: install-fresh test skill\n---\nMUTATED BODY\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
67
|
+
lockVersion: 2,
|
|
68
|
+
generatedAt: new Date().toISOString(),
|
|
69
|
+
skills: {
|
|
70
|
+
[full]: {
|
|
71
|
+
version,
|
|
72
|
+
type: 'skill',
|
|
73
|
+
ref: `refs/tags/v${version}`,
|
|
74
|
+
commit: 'lockcommit',
|
|
75
|
+
integrity: lock_integrity,
|
|
76
|
+
base_commit: 'lockcommit',
|
|
77
|
+
base_integrity: lock_integrity,
|
|
78
|
+
requested_by: ['__root__'],
|
|
79
|
+
dependencies: {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, null, '\t'))
|
|
83
|
+
|
|
84
|
+
res({
|
|
85
|
+
root,
|
|
86
|
+
skill_dir,
|
|
87
|
+
read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
|
|
88
|
+
read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
|
|
89
|
+
read_skill_md: () => fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'),
|
|
90
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('install --fresh — § 8.5 hardening', () => {
|
|
96
|
+
it('hard-fails with VERSION_NOT_FOUND when registry is unreachable (no silent fallback)', async () => {
|
|
97
|
+
const ctx = await scaffold_installed({ full: 'acme/test', short: 'test', version: '1.0.0' })
|
|
98
|
+
try {
|
|
99
|
+
const { code, stderr, stdout } = run(
|
|
100
|
+
['install', 'acme/test@0.3.3', '--fresh', '--json', '-y'],
|
|
101
|
+
{ cwd: ctx.root }
|
|
102
|
+
)
|
|
103
|
+
// Exit code 2 (USER_ERROR) per spec § 8.5.
|
|
104
|
+
assert.strictEqual(code, 2, 'must exit with USER_ERROR (2), not silently succeed')
|
|
105
|
+
// Combined output must mention VERSION_NOT_FOUND.
|
|
106
|
+
const combined = stdout + stderr
|
|
107
|
+
assert.match(combined, /VERSION_NOT_FOUND/, 'must surface VERSION_NOT_FOUND, not silently fall back to latest')
|
|
108
|
+
// JSON envelope's error.code must be USAGE_ERROR (not bare ERROR) so
|
|
109
|
+
// downstream consumers can parse on the code rather than the message.
|
|
110
|
+
const json_match = stdout.match(/^{[\s\S]*}\s*$/m)
|
|
111
|
+
if (json_match) {
|
|
112
|
+
const parsed = JSON.parse(json_match[0])
|
|
113
|
+
assert.strictEqual(parsed.error?.code, 'USAGE_ERROR', `error.code must be USAGE_ERROR, got ${parsed.error?.code}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Critical: disk content must be UNCHANGED — no silent overwrite.
|
|
117
|
+
assert.strictEqual(ctx.read_manifest().version, '1.0.0', 'skill.json must NOT have been overwritten')
|
|
118
|
+
} finally { ctx.cleanup() }
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('refuses --fresh when local edits are present unless --force-discard-local', async () => {
|
|
122
|
+
const ctx = await scaffold_installed({ full: 'acme/edited', short: 'edited', version: '1.0.0', modified: true })
|
|
123
|
+
try {
|
|
124
|
+
const { code, stdout, stderr } = run(
|
|
125
|
+
['install', 'acme/edited@1.0.0', '--fresh', '--json', '-y'],
|
|
126
|
+
{ cwd: ctx.root }
|
|
127
|
+
)
|
|
128
|
+
// Should hard-fail at the LOCAL_EDITS_PRESENT step (before any registry call would matter).
|
|
129
|
+
assert.notStrictEqual(code, 0, 'must not succeed silently')
|
|
130
|
+
const combined = stdout + stderr
|
|
131
|
+
// Either LOCAL_EDITS_PRESENT (preferred, when registry is unreachable
|
|
132
|
+
// and we never get to the version check, we still expect LOCAL_EDITS
|
|
133
|
+
// to surface — but registry-failure currently runs first, surfacing
|
|
134
|
+
// VERSION_NOT_FOUND). Either way, NO silent overwrite must occur.
|
|
135
|
+
assert.ok(
|
|
136
|
+
/LOCAL_EDITS_PRESENT|VERSION_NOT_FOUND/.test(combined),
|
|
137
|
+
`must refuse to clobber local edits, got: ${combined}`
|
|
138
|
+
)
|
|
139
|
+
// The mutated content must still be on disk.
|
|
140
|
+
assert.ok(/MUTATED BODY/.test(ctx.read_skill_md()), 'local mutation must be preserved')
|
|
141
|
+
} finally { ctx.cleanup() }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('does NOT pre-flight when --fresh is omitted (existing behavior preserved)', async () => {
|
|
145
|
+
// Without --fresh, the hardening doesn't kick in. The install will fail
|
|
146
|
+
// when it tries to actually reach the registry, but for a DIFFERENT
|
|
147
|
+
// reason than VERSION_NOT_FOUND — and no snapshot is taken.
|
|
148
|
+
const ctx = await scaffold_installed({ full: 'acme/no-fresh', short: 'no-fresh', version: '1.0.0' })
|
|
149
|
+
try {
|
|
150
|
+
const { code, stdout, stderr } = run(
|
|
151
|
+
['install', 'acme/no-fresh@0.3.3', '--json', '-y'],
|
|
152
|
+
{ cwd: ctx.root }
|
|
153
|
+
)
|
|
154
|
+
const combined = stdout + stderr
|
|
155
|
+
// The fail path may produce many error shapes; the key invariant
|
|
156
|
+
// is that the hardening's specific VERSION_NOT_FOUND error message
|
|
157
|
+
// does NOT appear (because hardening only triggers on --fresh).
|
|
158
|
+
assert.ok(
|
|
159
|
+
!/VERSION_NOT_FOUND: .* refusing to proceed/.test(combined),
|
|
160
|
+
'VERSION_NOT_FOUND hardening should not fire without --fresh'
|
|
161
|
+
)
|
|
162
|
+
// Disk should still be untouched (whether the install succeeded or
|
|
163
|
+
// failed via a different error).
|
|
164
|
+
assert.strictEqual(ctx.read_manifest().version, '1.0.0')
|
|
165
|
+
} finally { ctx.cleanup() }
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills reconcile` — § 8.4. The command's
|
|
4
|
+
* single job: handle GENUINE drift (regression / missing_skill_json /
|
|
5
|
+
* missing_dir / corrupted) deterministically when possible, emit next_step
|
|
6
|
+
* envelopes when user adjudication is required, and **no-op on the `ahead`
|
|
7
|
+
* state** (ahead is not drift; it routes back to publish).
|
|
8
|
+
*
|
|
9
|
+
* These tests prevent the spec § 17.7 anti-pattern from recurring — the
|
|
10
|
+
* earlier draft auto-repaired `ahead` here, which was exactly the category
|
|
11
|
+
* error that produced the § 2 incident.
|
|
12
|
+
*/
|
|
13
|
+
const { describe, it } = require('node:test')
|
|
14
|
+
const assert = require('node:assert/strict')
|
|
15
|
+
const { spawnSync } = require('child_process')
|
|
16
|
+
const fs = require('fs')
|
|
17
|
+
const os = require('os')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
|
|
20
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
21
|
+
const NODE = process.execPath
|
|
22
|
+
|
|
23
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-reconcile-test-'))
|
|
24
|
+
const run = (args, opts) => {
|
|
25
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
26
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
timeout: 10000,
|
|
29
|
+
cwd: opts?.cwd
|
|
30
|
+
})
|
|
31
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
32
|
+
}
|
|
33
|
+
const parse_json = (stdout, label) => {
|
|
34
|
+
try { return JSON.parse(stdout) }
|
|
35
|
+
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, omit_dir }) => {
|
|
39
|
+
const root = make_tmp()
|
|
40
|
+
const skill_dir = path.join(root, '.agents', 'skills', short)
|
|
41
|
+
if (!omit_dir) {
|
|
42
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
45
|
+
`---\nname: ${short}\ndescription: reconcile test skill\n---\nbody\n`
|
|
46
|
+
)
|
|
47
|
+
if (!omit_skill_json) {
|
|
48
|
+
fs.writeFileSync(
|
|
49
|
+
path.join(skill_dir, 'skill.json'),
|
|
50
|
+
JSON.stringify({ name: short, version: disk_version, type: 'skill', description: 'reconcile test skill' }, null, '\t')
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
55
|
+
lockVersion: 2,
|
|
56
|
+
generatedAt: new Date().toISOString(),
|
|
57
|
+
skills: {
|
|
58
|
+
[full]: {
|
|
59
|
+
version: lock_version,
|
|
60
|
+
type: 'skill',
|
|
61
|
+
ref: `refs/tags/v${lock_version}`,
|
|
62
|
+
commit: 'lockcommit',
|
|
63
|
+
integrity: 'sha256-baseline',
|
|
64
|
+
base_commit: 'lockcommit',
|
|
65
|
+
base_integrity: 'sha256-baseline',
|
|
66
|
+
requested_by: ['__root__'],
|
|
67
|
+
dependencies: {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, null, '\t'))
|
|
71
|
+
return {
|
|
72
|
+
root,
|
|
73
|
+
skill_dir,
|
|
74
|
+
read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
|
|
75
|
+
read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
|
|
76
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('reconcile — § 8.4', () => {
|
|
81
|
+
it('reports clean (no work to do) when lock and disk agree', () => {
|
|
82
|
+
const ctx = scaffold({ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' })
|
|
83
|
+
try {
|
|
84
|
+
const { code, stdout } = run(['reconcile', 'acme/clean', '--json'], { cwd: ctx.root })
|
|
85
|
+
assert.strictEqual(code, 0)
|
|
86
|
+
const out = parse_json(stdout, 'reconcile clean')
|
|
87
|
+
assert.strictEqual(out.data.no_drift, true)
|
|
88
|
+
assert.strictEqual(out.data.status, 'clean')
|
|
89
|
+
assert.strictEqual(out.next_step, null)
|
|
90
|
+
} finally { ctx.cleanup() }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('NO-OPS on ahead (disk > lock) — points back to publish, does NOT mutate', () => {
|
|
94
|
+
// This is the spec § 17.7 anti-pattern test: the earlier design
|
|
95
|
+
// auto-repaired ahead via revert-and-rebump. That behavior is rejected.
|
|
96
|
+
const ctx = scaffold({ full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' })
|
|
97
|
+
try {
|
|
98
|
+
const { code, stdout } = run(['reconcile', 'acme/ahead', '--json'], { cwd: ctx.root })
|
|
99
|
+
assert.strictEqual(code, 0)
|
|
100
|
+
const out = parse_json(stdout, 'reconcile ahead')
|
|
101
|
+
assert.strictEqual(out.data.no_drift, true)
|
|
102
|
+
assert.strictEqual(out.data.status, 'ahead')
|
|
103
|
+
assert.strictEqual(out.data.ahead.lock_version, '0.3.2')
|
|
104
|
+
assert.strictEqual(out.data.ahead.disk_version, '0.3.3')
|
|
105
|
+
assert.strictEqual(out.next_step, null, 'must NOT emit a next_step for ahead — it is not drift')
|
|
106
|
+
assert.match(out.hint, /publish/i, 'hint should point at publish/release, not at repair')
|
|
107
|
+
|
|
108
|
+
// Confirm no file mutation.
|
|
109
|
+
assert.strictEqual(ctx.read_manifest().version, '0.3.3', 'skill.json must be unchanged')
|
|
110
|
+
assert.strictEqual(ctx.read_lock().skills['acme/ahead'].version, '0.3.2', 'lock must be unchanged')
|
|
111
|
+
} finally { ctx.cleanup() }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('emits resolve_regression next_step for disk < lock', () => {
|
|
115
|
+
const ctx = scaffold({ full: 'acme/regression', short: 'regression', lock_version: '0.4.0', disk_version: '0.3.0' })
|
|
116
|
+
try {
|
|
117
|
+
const { code, stdout } = run(['reconcile', 'acme/regression', '--json'], { cwd: ctx.root })
|
|
118
|
+
assert.strictEqual(code, 0)
|
|
119
|
+
const out = parse_json(stdout, 'reconcile regression')
|
|
120
|
+
assert.strictEqual(out.data.drift_state, 'regression')
|
|
121
|
+
assert.ok(out.next_step)
|
|
122
|
+
assert.strictEqual(out.next_step.action, 'resolve_regression')
|
|
123
|
+
assert.ok(out.next_step.context.options.includes('restore_from_lock_version'))
|
|
124
|
+
assert.ok(out.next_step.context.options.includes('accept_disk_as_explicit_downgrade'))
|
|
125
|
+
} finally { ctx.cleanup() }
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('--apply restore_from_lock_version repairs a regression deterministically', () => {
|
|
129
|
+
const ctx = scaffold({ full: 'acme/fix-regression', short: 'fix-regression', lock_version: '0.4.0', disk_version: '0.3.0' })
|
|
130
|
+
try {
|
|
131
|
+
const { code, stdout } = run(['reconcile', 'acme/fix-regression', '--apply', 'restore_from_lock_version', '--json'], { cwd: ctx.root })
|
|
132
|
+
assert.strictEqual(code, 0)
|
|
133
|
+
const out = parse_json(stdout, 'reconcile regression apply')
|
|
134
|
+
assert.strictEqual(out.data.applied.applied, 'restore_from_lock_version')
|
|
135
|
+
assert.strictEqual(out.data.applied.new_disk_version, '0.4.0')
|
|
136
|
+
assert.strictEqual(out.next_step, null)
|
|
137
|
+
|
|
138
|
+
// skill.json now matches the lock.
|
|
139
|
+
assert.strictEqual(ctx.read_manifest().version, '0.4.0')
|
|
140
|
+
// Lock untouched (it was the source of truth).
|
|
141
|
+
assert.strictEqual(ctx.read_lock().skills['acme/fix-regression'].version, '0.4.0')
|
|
142
|
+
} finally { ctx.cleanup() }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('emits resolve_missing_skill_json next_step when skill.json is absent', () => {
|
|
146
|
+
const ctx = scaffold({
|
|
147
|
+
full: 'acme/no-manifest', short: 'no-manifest',
|
|
148
|
+
lock_version: '1.0.0', disk_version: '1.0.0', omit_skill_json: true
|
|
149
|
+
})
|
|
150
|
+
try {
|
|
151
|
+
const { code, stdout } = run(['reconcile', 'acme/no-manifest', '--json'], { cwd: ctx.root })
|
|
152
|
+
assert.strictEqual(code, 0)
|
|
153
|
+
const out = parse_json(stdout, 'reconcile missing-manifest')
|
|
154
|
+
assert.strictEqual(out.data.drift_state, 'missing_skill_json')
|
|
155
|
+
assert.strictEqual(out.next_step.action, 'resolve_missing_skill_json')
|
|
156
|
+
assert.ok(out.next_step.context.options.includes('restore_from_git'))
|
|
157
|
+
assert.ok(out.next_step.context.options.includes('restore_from_registry_at_lock_version'))
|
|
158
|
+
assert.ok(out.next_step.context.options.includes('abandon'))
|
|
159
|
+
} finally { ctx.cleanup() }
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('emits resolve_missing_dir next_step when the skill directory is gone', () => {
|
|
163
|
+
const ctx = scaffold({
|
|
164
|
+
full: 'acme/no-dir', short: 'no-dir',
|
|
165
|
+
lock_version: '1.0.0', disk_version: '1.0.0', omit_dir: true
|
|
166
|
+
})
|
|
167
|
+
try {
|
|
168
|
+
const { code, stdout } = run(['reconcile', 'acme/no-dir', '--json'], { cwd: ctx.root })
|
|
169
|
+
assert.strictEqual(code, 0)
|
|
170
|
+
const out = parse_json(stdout, 'reconcile missing-dir')
|
|
171
|
+
assert.strictEqual(out.data.drift_state, 'missing_dir')
|
|
172
|
+
assert.strictEqual(out.next_step.action, 'resolve_missing_dir')
|
|
173
|
+
assert.ok(out.next_step.context.options.includes('reinstall_at_lock_version'))
|
|
174
|
+
assert.ok(out.next_step.context.options.includes('abandon'))
|
|
175
|
+
} finally { ctx.cleanup() }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('resolves bare skill name from the lock file', () => {
|
|
179
|
+
const ctx = scaffold({ full: 'acme/bare', short: 'bare', lock_version: '1.0.0', disk_version: '1.0.0' })
|
|
180
|
+
try {
|
|
181
|
+
const { code, stdout } = run(['reconcile', 'bare', '--json'], { cwd: ctx.root })
|
|
182
|
+
assert.strictEqual(code, 0)
|
|
183
|
+
const out = parse_json(stdout, 'reconcile bare')
|
|
184
|
+
assert.strictEqual(out.data.skill, 'acme/bare')
|
|
185
|
+
assert.strictEqual(out.data.no_drift, true)
|
|
186
|
+
} finally { ctx.cleanup() }
|
|
187
|
+
})
|
|
188
|
+
})
|