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,237 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
5
|
+
const { ensure_dir, read_json, file_exists } = require('../utils/fs')
|
|
6
|
+
const { hash_directory } = require('../lock/integrity')
|
|
7
|
+
const {
|
|
8
|
+
skill_snapshots_dir,
|
|
9
|
+
snapshot_dir,
|
|
10
|
+
snapshot_manifest_path,
|
|
11
|
+
snapshot_content_dir
|
|
12
|
+
} = require('./paths')
|
|
13
|
+
|
|
14
|
+
const DEFAULT_RETENTION = 10
|
|
15
|
+
|
|
16
|
+
// Combine the directory hash with a deterministic lock-entry serialization so
|
|
17
|
+
// the snapshot_state_hash uniquely identifies the full state being preserved.
|
|
18
|
+
// If two snapshots share the same hash, restoring either produces the same
|
|
19
|
+
// outcome — useful for list/dedup ergonomics.
|
|
20
|
+
const compute_skill_state_hash = (dir_integrity, lock_entry) => {
|
|
21
|
+
const lock_serialized = JSON.stringify(lock_entry || null, Object.keys(lock_entry || {}).sort())
|
|
22
|
+
const h = crypto.createHash('sha256')
|
|
23
|
+
h.update(dir_integrity || '')
|
|
24
|
+
h.update('|')
|
|
25
|
+
h.update(lock_serialized)
|
|
26
|
+
return `sha256-${h.digest('hex')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const make_snapshot_id = (state_hash) => {
|
|
30
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').replace('Z', 'Z')
|
|
31
|
+
const short = state_hash.replace(/^sha256-/, '').slice(0, 12)
|
|
32
|
+
return `snap_${ts}_${short}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Atomic recursive copy: copy into a sibling .tmp then rename into place.
|
|
36
|
+
// rename() is atomic on the same filesystem, so the snapshot is either fully
|
|
37
|
+
// present or absent — no half-copied states.
|
|
38
|
+
const copy_dir = async (src, dst) => {
|
|
39
|
+
await fs.promises.cp(src, dst, { recursive: true, force: true, errorOnExist: false })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const create = (options) => catch_errors('Failed to create snapshot', async () => {
|
|
43
|
+
const { skill_dir, workspace, skill, lock_entry, note, is_global, project_root, retention } = options
|
|
44
|
+
if (!workspace || !skill) throw new Error('workspace and skill are required')
|
|
45
|
+
if (!skill_dir) throw new Error('skill_dir is required')
|
|
46
|
+
|
|
47
|
+
const [, dir_present] = await file_exists(skill_dir)
|
|
48
|
+
if (!dir_present) throw new Error(`Cannot snapshot — skill directory does not exist: ${skill_dir}`)
|
|
49
|
+
|
|
50
|
+
const [hash_err, dir_integrity] = await hash_directory(skill_dir)
|
|
51
|
+
if (hash_err) throw e('Failed to hash skill directory', hash_err)
|
|
52
|
+
|
|
53
|
+
const state_hash = compute_skill_state_hash(dir_integrity, lock_entry)
|
|
54
|
+
const snapshot_id = make_snapshot_id(state_hash)
|
|
55
|
+
const dest = snapshot_dir(is_global, project_root, workspace, skill, snapshot_id)
|
|
56
|
+
const content_dest = snapshot_content_dir(dest)
|
|
57
|
+
|
|
58
|
+
const [parent_err] = await ensure_dir(path.dirname(dest))
|
|
59
|
+
if (parent_err) throw e('Failed to ensure snapshots parent dir', parent_err)
|
|
60
|
+
|
|
61
|
+
const tmp_dest = `${dest}.tmp-${process.pid}-${Date.now()}`
|
|
62
|
+
const tmp_content = path.join(tmp_dest, 'content')
|
|
63
|
+
await fs.promises.mkdir(tmp_dest, { recursive: true })
|
|
64
|
+
await copy_dir(skill_dir, tmp_content)
|
|
65
|
+
|
|
66
|
+
const manifest = {
|
|
67
|
+
snapshot_id,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
skill_state_hash: state_hash,
|
|
70
|
+
workspace,
|
|
71
|
+
skill,
|
|
72
|
+
dir_integrity,
|
|
73
|
+
note: note || null,
|
|
74
|
+
lock_entry: lock_entry || null
|
|
75
|
+
}
|
|
76
|
+
await fs.promises.writeFile(
|
|
77
|
+
path.join(tmp_dest, 'manifest.json'),
|
|
78
|
+
JSON.stringify(manifest, null, '\t') + '\n',
|
|
79
|
+
'utf-8'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
await fs.promises.rename(tmp_dest, dest)
|
|
83
|
+
|
|
84
|
+
const keep = typeof retention === 'number' ? retention : DEFAULT_RETENTION
|
|
85
|
+
const [, prune_result] = await prune({ workspace, skill, is_global, project_root, keep })
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
snapshot_id,
|
|
89
|
+
path: dest,
|
|
90
|
+
timestamp: manifest.timestamp,
|
|
91
|
+
skill_state_hash: state_hash,
|
|
92
|
+
note: manifest.note,
|
|
93
|
+
pruned: prune_result?.deleted_ids || []
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const list = (options) => catch_errors('Failed to list snapshots', async () => {
|
|
98
|
+
const { workspace, skill, is_global, project_root } = options
|
|
99
|
+
const skill_dir = skill_snapshots_dir(is_global, project_root, workspace, skill)
|
|
100
|
+
const [, present] = await file_exists(skill_dir)
|
|
101
|
+
if (!present) return { snapshots: [] }
|
|
102
|
+
const entries = await fs.promises.readdir(skill_dir, { withFileTypes: true })
|
|
103
|
+
const snapshots = []
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory()) continue
|
|
106
|
+
if (!entry.name.startsWith('snap_')) continue
|
|
107
|
+
const dest = path.join(skill_dir, entry.name)
|
|
108
|
+
const [, manifest] = await read_json(snapshot_manifest_path(dest))
|
|
109
|
+
if (!manifest) continue
|
|
110
|
+
snapshots.push({
|
|
111
|
+
snapshot_id: manifest.snapshot_id,
|
|
112
|
+
timestamp: manifest.timestamp,
|
|
113
|
+
skill_state_hash: manifest.skill_state_hash,
|
|
114
|
+
note: manifest.note,
|
|
115
|
+
path: dest
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
snapshots.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1))
|
|
119
|
+
return { snapshots }
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const find_snapshot = (snapshot_id, options) => catch_errors('Failed to find snapshot', async () => {
|
|
123
|
+
const { is_global, project_root } = options
|
|
124
|
+
const { snapshots_root } = require('./paths')
|
|
125
|
+
const root = snapshots_root(is_global, project_root)
|
|
126
|
+
const [, root_present] = await file_exists(root)
|
|
127
|
+
if (!root_present) return null
|
|
128
|
+
const workspaces = await fs.promises.readdir(root, { withFileTypes: true })
|
|
129
|
+
for (const ws of workspaces) {
|
|
130
|
+
if (!ws.isDirectory()) continue
|
|
131
|
+
const ws_dir = path.join(root, ws.name)
|
|
132
|
+
const skills = await fs.promises.readdir(ws_dir, { withFileTypes: true })
|
|
133
|
+
for (const sk of skills) {
|
|
134
|
+
if (!sk.isDirectory()) continue
|
|
135
|
+
const candidate = path.join(ws_dir, sk.name, snapshot_id)
|
|
136
|
+
const [, present] = await file_exists(candidate)
|
|
137
|
+
if (present) {
|
|
138
|
+
const [, manifest] = await read_json(snapshot_manifest_path(candidate))
|
|
139
|
+
if (manifest) return { manifest, path: candidate, workspace: ws.name, skill: sk.name }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Restore atomically: copy snapshot/content into a sibling .restore-tmp, then
|
|
147
|
+
// swap it with the live skill directory. The previous live directory is moved
|
|
148
|
+
// to a .pre-restore-* path next to it so the operation is fully reversible.
|
|
149
|
+
const restore = (snapshot_id, options) => catch_errors('Failed to restore snapshot', async () => {
|
|
150
|
+
const { skill_dir, is_global, project_root, verify_hash } = options
|
|
151
|
+
if (!skill_dir) throw new Error('skill_dir is required for restore')
|
|
152
|
+
|
|
153
|
+
const [find_err, found] = await find_snapshot(snapshot_id, { is_global, project_root })
|
|
154
|
+
if (find_err) throw e('Failed to look up snapshot', find_err)
|
|
155
|
+
if (!found) throw new Error(`Snapshot not found: ${snapshot_id}`)
|
|
156
|
+
|
|
157
|
+
const content_src = snapshot_content_dir(found.path)
|
|
158
|
+
const [, content_present] = await file_exists(content_src)
|
|
159
|
+
if (!content_present) throw new Error(`Snapshot content missing: ${content_src}`)
|
|
160
|
+
|
|
161
|
+
if (verify_hash !== false && found.manifest.dir_integrity) {
|
|
162
|
+
const [hash_err, current_snap_hash] = await hash_directory(content_src)
|
|
163
|
+
if (!hash_err && current_snap_hash !== found.manifest.dir_integrity) {
|
|
164
|
+
throw new Error(`SNAPSHOT_CORRUPTED: ${snapshot_id} content hash does not match manifest`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const parent = path.dirname(skill_dir)
|
|
169
|
+
await ensure_dir(parent)
|
|
170
|
+
const restore_tmp = `${skill_dir}.restore-tmp-${process.pid}-${Date.now()}`
|
|
171
|
+
await copy_dir(content_src, restore_tmp)
|
|
172
|
+
|
|
173
|
+
const [, live_present] = await file_exists(skill_dir)
|
|
174
|
+
const trash = `${skill_dir}.pre-restore-${process.pid}-${Date.now()}`
|
|
175
|
+
if (live_present) {
|
|
176
|
+
await fs.promises.rename(skill_dir, trash)
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await fs.promises.rename(restore_tmp, skill_dir)
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Best-effort rollback if rename of restored content fails.
|
|
182
|
+
if (live_present) {
|
|
183
|
+
await fs.promises.rename(trash, skill_dir).catch(() => {})
|
|
184
|
+
}
|
|
185
|
+
throw e('Failed to swap restored content into place', err)
|
|
186
|
+
}
|
|
187
|
+
if (live_present) {
|
|
188
|
+
await fs.promises.rm(trash, { recursive: true, force: true }).catch(() => {})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
restored: true,
|
|
193
|
+
snapshot_id,
|
|
194
|
+
workspace: found.workspace,
|
|
195
|
+
skill: found.skill,
|
|
196
|
+
path: found.path,
|
|
197
|
+
version: found.manifest.lock_entry?.version || null
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const remove = (snapshot_id, options) => catch_errors('Failed to delete snapshot', async () => {
|
|
202
|
+
const { is_global, project_root } = options
|
|
203
|
+
const [, found] = await find_snapshot(snapshot_id, { is_global, project_root })
|
|
204
|
+
if (!found) return { deleted: false, snapshot_id, reason: 'not_found' }
|
|
205
|
+
await fs.promises.rm(found.path, { recursive: true, force: true })
|
|
206
|
+
return { deleted: true, snapshot_id }
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const prune = (options) => catch_errors('Failed to prune snapshots', async () => {
|
|
210
|
+
const { workspace, skill, is_global, project_root, keep } = options
|
|
211
|
+
const k = typeof keep === 'number' && keep >= 0 ? keep : DEFAULT_RETENTION
|
|
212
|
+
const [, listing] = await list({ workspace, skill, is_global, project_root })
|
|
213
|
+
if (!listing || !listing.snapshots) return { kept: 0, deleted: 0, deleted_ids: [] }
|
|
214
|
+
const sorted = listing.snapshots
|
|
215
|
+
const to_keep = sorted.slice(0, k)
|
|
216
|
+
const to_delete = sorted.slice(k)
|
|
217
|
+
for (const snap of to_delete) {
|
|
218
|
+
await fs.promises.rm(snap.path, { recursive: true, force: true })
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
kept: to_keep.length,
|
|
222
|
+
deleted: to_delete.length,
|
|
223
|
+
deleted_ids: to_delete.map(s => s.snapshot_id)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
create,
|
|
229
|
+
list,
|
|
230
|
+
restore,
|
|
231
|
+
remove,
|
|
232
|
+
prune,
|
|
233
|
+
find_snapshot,
|
|
234
|
+
compute_skill_state_hash,
|
|
235
|
+
make_snapshot_id,
|
|
236
|
+
DEFAULT_RETENTION
|
|
237
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
|
|
8
|
+
const storage = require('./storage')
|
|
9
|
+
const { clear_integrity_cache } = require('../lock/integrity')
|
|
10
|
+
|
|
11
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-snapshot-test-'))
|
|
12
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
13
|
+
|
|
14
|
+
const write_skill = (skill_dir, files) => {
|
|
15
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
16
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
17
|
+
const full = path.join(skill_dir, rel)
|
|
18
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
19
|
+
fs.writeFileSync(full, content)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('snapshot.compute_skill_state_hash', () => {
|
|
24
|
+
it('is deterministic for identical inputs', () => {
|
|
25
|
+
const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x' })
|
|
26
|
+
const b = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x' })
|
|
27
|
+
assert.strictEqual(a, b)
|
|
28
|
+
assert.match(a, /^sha256-[0-9a-f]{64}$/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('differs when content hash changes', () => {
|
|
32
|
+
const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0' })
|
|
33
|
+
const b = storage.compute_skill_state_hash('sha256-def', { version: '1.0.0' })
|
|
34
|
+
assert.notStrictEqual(a, b)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('differs when lock entry changes', () => {
|
|
38
|
+
const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0' })
|
|
39
|
+
const b = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.1' })
|
|
40
|
+
assert.notStrictEqual(a, b)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('order of lock-entry keys does not affect the hash', () => {
|
|
44
|
+
const a = storage.compute_skill_state_hash('sha256-abc', { version: '1.0.0', commit: 'x', integrity: 'y' })
|
|
45
|
+
const b = storage.compute_skill_state_hash('sha256-abc', { integrity: 'y', commit: 'x', version: '1.0.0' })
|
|
46
|
+
assert.strictEqual(a, b)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('snapshot.create', () => {
|
|
51
|
+
it('captures skill directory contents and manifest', async () => {
|
|
52
|
+
const root = make_tmp()
|
|
53
|
+
clear_integrity_cache()
|
|
54
|
+
try {
|
|
55
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'my-skill')
|
|
56
|
+
write_skill(skill_dir, {
|
|
57
|
+
'skill.json': JSON.stringify({ name: 'my-skill', version: '1.0.0' }),
|
|
58
|
+
'SKILL.md': '---\nname: my-skill\ndescription: test\n---\nbody\n'
|
|
59
|
+
})
|
|
60
|
+
const lock_entry = { version: '1.0.0', commit: 'abc', integrity: 'sha256-x' }
|
|
61
|
+
const [err, result] = await storage.create({
|
|
62
|
+
skill_dir, workspace: 'acme', skill: 'my-skill', lock_entry,
|
|
63
|
+
is_global: false, project_root: root, note: 'pre-test'
|
|
64
|
+
})
|
|
65
|
+
assert.strictEqual(err, null)
|
|
66
|
+
assert.match(result.snapshot_id, /^snap_/)
|
|
67
|
+
assert.match(result.skill_state_hash, /^sha256-/)
|
|
68
|
+
assert.strictEqual(result.note, 'pre-test')
|
|
69
|
+
assert.ok(fs.existsSync(result.path), 'snapshot dir must exist')
|
|
70
|
+
assert.ok(fs.existsSync(path.join(result.path, 'manifest.json')), 'manifest must exist')
|
|
71
|
+
assert.ok(fs.existsSync(path.join(result.path, 'content', 'skill.json')), 'content/skill.json must exist')
|
|
72
|
+
assert.ok(fs.existsSync(path.join(result.path, 'content', 'SKILL.md')), 'content/SKILL.md must exist')
|
|
73
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(result.path, 'manifest.json'), 'utf-8'))
|
|
74
|
+
assert.strictEqual(manifest.workspace, 'acme')
|
|
75
|
+
assert.strictEqual(manifest.skill, 'my-skill')
|
|
76
|
+
assert.deepEqual(manifest.lock_entry, lock_entry)
|
|
77
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('fails when the skill directory does not exist', async () => {
|
|
81
|
+
const root = make_tmp()
|
|
82
|
+
clear_integrity_cache()
|
|
83
|
+
try {
|
|
84
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'ghost')
|
|
85
|
+
const [err] = await storage.create({
|
|
86
|
+
skill_dir, workspace: 'acme', skill: 'ghost',
|
|
87
|
+
is_global: false, project_root: root
|
|
88
|
+
})
|
|
89
|
+
assert.notStrictEqual(err, null)
|
|
90
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('snapshot.list', () => {
|
|
95
|
+
it('returns empty list when no snapshots exist', async () => {
|
|
96
|
+
const root = make_tmp()
|
|
97
|
+
try {
|
|
98
|
+
const [err, result] = await storage.list({
|
|
99
|
+
workspace: 'acme', skill: 'no-such', is_global: false, project_root: root
|
|
100
|
+
})
|
|
101
|
+
assert.strictEqual(err, null)
|
|
102
|
+
assert.deepEqual(result.snapshots, [])
|
|
103
|
+
} finally { cleanup(root) }
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('lists created snapshots newest-first', async () => {
|
|
107
|
+
const root = make_tmp()
|
|
108
|
+
clear_integrity_cache()
|
|
109
|
+
try {
|
|
110
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'multi')
|
|
111
|
+
write_skill(skill_dir, { 'skill.json': '{"name":"multi","version":"1.0.0"}' })
|
|
112
|
+
const [, first] = await storage.create({
|
|
113
|
+
skill_dir, workspace: 'acme', skill: 'multi', lock_entry: { version: '1.0.0' },
|
|
114
|
+
is_global: false, project_root: root, note: 'first'
|
|
115
|
+
})
|
|
116
|
+
// Force the integrity cache to recompute so the second snapshot has a
|
|
117
|
+
// distinct content hash and thus a distinct snapshot_id.
|
|
118
|
+
clear_integrity_cache()
|
|
119
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"multi","version":"1.0.1"}')
|
|
120
|
+
// Sleep 10ms to guarantee a different timestamp in the id.
|
|
121
|
+
await new Promise(r => setTimeout(r, 10))
|
|
122
|
+
const [, second] = await storage.create({
|
|
123
|
+
skill_dir, workspace: 'acme', skill: 'multi', lock_entry: { version: '1.0.1' },
|
|
124
|
+
is_global: false, project_root: root, note: 'second'
|
|
125
|
+
})
|
|
126
|
+
const [, listing] = await storage.list({
|
|
127
|
+
workspace: 'acme', skill: 'multi', is_global: false, project_root: root
|
|
128
|
+
})
|
|
129
|
+
assert.strictEqual(listing.snapshots.length, 2)
|
|
130
|
+
assert.strictEqual(listing.snapshots[0].snapshot_id, second.snapshot_id, 'newest is first')
|
|
131
|
+
assert.strictEqual(listing.snapshots[1].snapshot_id, first.snapshot_id)
|
|
132
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('snapshot.restore', () => {
|
|
137
|
+
it('restores a captured directory bit-for-bit', async () => {
|
|
138
|
+
const root = make_tmp()
|
|
139
|
+
clear_integrity_cache()
|
|
140
|
+
try {
|
|
141
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'restorable')
|
|
142
|
+
write_skill(skill_dir, {
|
|
143
|
+
'skill.json': '{"name":"restorable","version":"1.0.0"}',
|
|
144
|
+
'SKILL.md': 'original body\n',
|
|
145
|
+
'references/foo.md': 'original reference\n'
|
|
146
|
+
})
|
|
147
|
+
const [, snap] = await storage.create({
|
|
148
|
+
skill_dir, workspace: 'acme', skill: 'restorable',
|
|
149
|
+
lock_entry: { version: '1.0.0' },
|
|
150
|
+
is_global: false, project_root: root
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Mutate the live directory after snapshot.
|
|
154
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), 'mutated body\n')
|
|
155
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"restorable","version":"9.9.9"}')
|
|
156
|
+
fs.rmSync(path.join(skill_dir, 'references'), { recursive: true })
|
|
157
|
+
|
|
158
|
+
const [err, result] = await storage.restore(snap.snapshot_id, {
|
|
159
|
+
skill_dir, is_global: false, project_root: root
|
|
160
|
+
})
|
|
161
|
+
assert.strictEqual(err, null)
|
|
162
|
+
assert.strictEqual(result.restored, true)
|
|
163
|
+
assert.strictEqual(
|
|
164
|
+
fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'),
|
|
165
|
+
'original body\n'
|
|
166
|
+
)
|
|
167
|
+
assert.strictEqual(
|
|
168
|
+
fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8'),
|
|
169
|
+
'{"name":"restorable","version":"1.0.0"}'
|
|
170
|
+
)
|
|
171
|
+
assert.strictEqual(
|
|
172
|
+
fs.readFileSync(path.join(skill_dir, 'references', 'foo.md'), 'utf-8'),
|
|
173
|
+
'original reference\n'
|
|
174
|
+
)
|
|
175
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('refuses corrupted snapshots', async () => {
|
|
179
|
+
const root = make_tmp()
|
|
180
|
+
clear_integrity_cache()
|
|
181
|
+
try {
|
|
182
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'corrupt-snap')
|
|
183
|
+
write_skill(skill_dir, { 'skill.json': '{"name":"corrupt-snap","version":"1.0.0"}' })
|
|
184
|
+
const [, snap] = await storage.create({
|
|
185
|
+
skill_dir, workspace: 'acme', skill: 'corrupt-snap',
|
|
186
|
+
lock_entry: { version: '1.0.0' },
|
|
187
|
+
is_global: false, project_root: root
|
|
188
|
+
})
|
|
189
|
+
// Tamper with snapshot content but leave manifest's expected hash in place.
|
|
190
|
+
fs.writeFileSync(path.join(snap.path, 'content', 'skill.json'), '{"name":"tampered","version":"9.9.9"}')
|
|
191
|
+
|
|
192
|
+
const [err] = await storage.restore(snap.snapshot_id, {
|
|
193
|
+
skill_dir, is_global: false, project_root: root
|
|
194
|
+
})
|
|
195
|
+
assert.notStrictEqual(err, null)
|
|
196
|
+
const messages = err.map(x => x?.message || '').join('|')
|
|
197
|
+
assert.match(messages, /SNAPSHOT_CORRUPTED/)
|
|
198
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('throws when the snapshot id is unknown', async () => {
|
|
202
|
+
const root = make_tmp()
|
|
203
|
+
try {
|
|
204
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'whatever')
|
|
205
|
+
const [err] = await storage.restore('snap_doesnotexist_000000000000', {
|
|
206
|
+
skill_dir, is_global: false, project_root: root
|
|
207
|
+
})
|
|
208
|
+
assert.notStrictEqual(err, null)
|
|
209
|
+
} finally { cleanup(root) }
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('snapshot.remove', () => {
|
|
214
|
+
it('removes a snapshot directory', async () => {
|
|
215
|
+
const root = make_tmp()
|
|
216
|
+
clear_integrity_cache()
|
|
217
|
+
try {
|
|
218
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'rmable')
|
|
219
|
+
write_skill(skill_dir, { 'skill.json': '{"name":"rmable","version":"1.0.0"}' })
|
|
220
|
+
const [, snap] = await storage.create({
|
|
221
|
+
skill_dir, workspace: 'acme', skill: 'rmable', lock_entry: { version: '1.0.0' },
|
|
222
|
+
is_global: false, project_root: root
|
|
223
|
+
})
|
|
224
|
+
const [err, result] = await storage.remove(snap.snapshot_id, { is_global: false, project_root: root })
|
|
225
|
+
assert.strictEqual(err, null)
|
|
226
|
+
assert.strictEqual(result.deleted, true)
|
|
227
|
+
assert.ok(!fs.existsSync(snap.path), 'snapshot dir should be gone')
|
|
228
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('reports not_found for unknown ids', async () => {
|
|
232
|
+
const root = make_tmp()
|
|
233
|
+
try {
|
|
234
|
+
const [err, result] = await storage.remove('snap_nope_xxx', { is_global: false, project_root: root })
|
|
235
|
+
assert.strictEqual(err, null)
|
|
236
|
+
assert.strictEqual(result.deleted, false)
|
|
237
|
+
assert.strictEqual(result.reason, 'not_found')
|
|
238
|
+
} finally { cleanup(root) }
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('snapshot.prune', () => {
|
|
243
|
+
it('keeps the N most recent and deletes the rest', async () => {
|
|
244
|
+
const root = make_tmp()
|
|
245
|
+
clear_integrity_cache()
|
|
246
|
+
try {
|
|
247
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'prune-me')
|
|
248
|
+
// Make 5 snapshots, vary content so each gets a distinct id.
|
|
249
|
+
const created_ids = []
|
|
250
|
+
for (let i = 0; i < 5; i++) {
|
|
251
|
+
clear_integrity_cache()
|
|
252
|
+
write_skill(skill_dir, { 'skill.json': `{"name":"prune-me","version":"1.0.${i}"}` })
|
|
253
|
+
await new Promise(r => setTimeout(r, 5))
|
|
254
|
+
const [, snap] = await storage.create({
|
|
255
|
+
skill_dir, workspace: 'acme', skill: 'prune-me', lock_entry: { version: `1.0.${i}` },
|
|
256
|
+
is_global: false, project_root: root, retention: 100
|
|
257
|
+
})
|
|
258
|
+
created_ids.push(snap.snapshot_id)
|
|
259
|
+
}
|
|
260
|
+
const [err, result] = await storage.prune({
|
|
261
|
+
workspace: 'acme', skill: 'prune-me', is_global: false, project_root: root, keep: 2
|
|
262
|
+
})
|
|
263
|
+
assert.strictEqual(err, null)
|
|
264
|
+
assert.strictEqual(result.kept, 2)
|
|
265
|
+
assert.strictEqual(result.deleted, 3)
|
|
266
|
+
assert.strictEqual(result.deleted_ids.length, 3)
|
|
267
|
+
// The 3 oldest should be deleted (created_ids[0..2]).
|
|
268
|
+
for (const old of created_ids.slice(0, 3)) {
|
|
269
|
+
assert.ok(result.deleted_ids.includes(old), `oldest ${old} should be deleted`)
|
|
270
|
+
}
|
|
271
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('auto-prunes on create when default retention is hit', async () => {
|
|
275
|
+
const root = make_tmp()
|
|
276
|
+
clear_integrity_cache()
|
|
277
|
+
try {
|
|
278
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'auto-prune')
|
|
279
|
+
let last
|
|
280
|
+
for (let i = 0; i < 12; i++) {
|
|
281
|
+
clear_integrity_cache()
|
|
282
|
+
write_skill(skill_dir, { 'skill.json': `{"name":"auto-prune","version":"1.0.${i}"}` })
|
|
283
|
+
await new Promise(r => setTimeout(r, 5))
|
|
284
|
+
const [, snap] = await storage.create({
|
|
285
|
+
skill_dir, workspace: 'acme', skill: 'auto-prune', lock_entry: { version: `1.0.${i}` },
|
|
286
|
+
is_global: false, project_root: root
|
|
287
|
+
// no retention override — uses DEFAULT_RETENTION=10
|
|
288
|
+
})
|
|
289
|
+
last = snap
|
|
290
|
+
}
|
|
291
|
+
const [, listing] = await storage.list({
|
|
292
|
+
workspace: 'acme', skill: 'auto-prune', is_global: false, project_root: root
|
|
293
|
+
})
|
|
294
|
+
assert.strictEqual(listing.snapshots.length, 10, 'should keep exactly 10 (DEFAULT_RETENTION)')
|
|
295
|
+
assert.strictEqual(listing.snapshots[0].snapshot_id, last.snapshot_id, 'newest must survive')
|
|
296
|
+
} finally { cleanup(root); clear_integrity_cache() }
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const { MAX_FILE_SIZE, MAX_TOTAL_SIZE } = require('../config/limits')
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024)
|
|
7
|
+
const MAX_TOTAL_SIZE_MB = MAX_TOTAL_SIZE / (1024 * 1024)
|
|
6
8
|
|
|
7
|
-
const
|
|
9
|
+
const file_result = (file, actual_size) => ({
|
|
8
10
|
file,
|
|
9
11
|
field: null,
|
|
10
12
|
rule: 'max_file_size',
|
|
11
13
|
severity: 'error',
|
|
12
|
-
message: `File exceeds
|
|
14
|
+
message: `File exceeds ${MAX_FILE_SIZE_MB}MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const total_result = (actual_size) => ({
|
|
18
|
+
file: null,
|
|
19
|
+
field: null,
|
|
20
|
+
rule: 'max_total_size',
|
|
21
|
+
severity: 'error',
|
|
22
|
+
message: `Skill bundle exceeds ${MAX_TOTAL_SIZE_MB}MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
|
|
13
23
|
})
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Scans all files in a skill directory and checks
|
|
17
|
-
* Returns error results for each file that exceeds the limit.
|
|
26
|
+
* Scans all files in a skill directory and checks both per-file and total bundle size.
|
|
18
27
|
* Skips dotfiles and common non-publishable directories (consistent with hash_directory exclusions).
|
|
19
|
-
*
|
|
20
|
-
* @param {string} skill_dir - Absolute path to the skill directory
|
|
21
|
-
* @returns {[errors, results[]]} — results with severity 'error' for each oversized file
|
|
22
28
|
*/
|
|
23
29
|
const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file sizes', async () => {
|
|
24
30
|
const results = []
|
|
31
|
+
let total_size = 0
|
|
25
32
|
const walk = async (dir, prefix) => {
|
|
26
33
|
let items
|
|
27
34
|
try { items = await fs.promises.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
@@ -36,12 +43,16 @@ const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file si
|
|
|
36
43
|
let stat
|
|
37
44
|
try { stat = await fs.promises.stat(full) } catch { continue }
|
|
38
45
|
if (stat.size > MAX_FILE_SIZE) {
|
|
39
|
-
results.push(
|
|
46
|
+
results.push(file_result(rel, stat.size))
|
|
40
47
|
}
|
|
48
|
+
total_size += stat.size
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
await walk(skill_dir, '')
|
|
53
|
+
if (total_size > MAX_TOTAL_SIZE) {
|
|
54
|
+
results.push(total_result(total_size))
|
|
55
|
+
}
|
|
45
56
|
return results
|
|
46
57
|
})
|
|
47
58
|
|
|
@@ -4,8 +4,7 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const { validate_file_sizes } = require('./file_size_rules')
|
|
7
|
-
|
|
8
|
-
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
|
|
7
|
+
const { MAX_FILE_SIZE, MAX_TOTAL_SIZE } = require('../config/limits')
|
|
9
8
|
|
|
10
9
|
const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'file-size-test-'))
|
|
11
10
|
const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
@@ -16,41 +15,47 @@ describe('validate_file_sizes', () => {
|
|
|
16
15
|
beforeEach(() => { dir = make_temp_dir() })
|
|
17
16
|
afterEach(() => { clean(dir) })
|
|
18
17
|
|
|
19
|
-
it('returns empty for files under
|
|
18
|
+
it('returns empty for files under the limit', async () => {
|
|
20
19
|
fs.writeFileSync(path.join(dir, 'small.txt'), 'hello world')
|
|
21
20
|
const [err, results] = await validate_file_sizes(dir)
|
|
22
21
|
assert.strictEqual(err, null)
|
|
23
22
|
assert.strictEqual(results.length, 0)
|
|
24
23
|
})
|
|
25
24
|
|
|
26
|
-
it('returns error for file exceeding
|
|
25
|
+
it('returns max_file_size error for a single file exceeding MAX_FILE_SIZE', async () => {
|
|
27
26
|
fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
28
27
|
const [err, results] = await validate_file_sizes(dir)
|
|
29
28
|
assert.strictEqual(err, null)
|
|
30
|
-
|
|
31
|
-
assert.strictEqual(
|
|
32
|
-
assert.strictEqual(
|
|
33
|
-
assert.strictEqual(
|
|
29
|
+
const per_file = results.filter(r => r.rule === 'max_file_size')
|
|
30
|
+
assert.strictEqual(per_file.length, 1)
|
|
31
|
+
assert.strictEqual(per_file[0].severity, 'error')
|
|
32
|
+
assert.strictEqual(per_file[0].file, 'big.txt')
|
|
34
33
|
})
|
|
35
34
|
|
|
36
|
-
it('
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
it('returns max_total_size error when bundle exceeds MAX_TOTAL_SIZE', async () => {
|
|
36
|
+
// Split across two files so per-file check passes but total fails.
|
|
37
|
+
const half = Math.floor(MAX_FILE_SIZE / 2) + 1
|
|
38
|
+
const each = Math.min(half, MAX_FILE_SIZE)
|
|
39
|
+
const n = Math.ceil((MAX_TOTAL_SIZE + 1) / each)
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
fs.writeFileSync(path.join(dir, `f${i}.bin`), Buffer.alloc(each))
|
|
42
|
+
}
|
|
39
43
|
const [err, results] = await validate_file_sizes(dir)
|
|
40
44
|
assert.strictEqual(err, null)
|
|
41
|
-
|
|
42
|
-
assert.
|
|
43
|
-
assert.
|
|
45
|
+
const bundle = results.filter(r => r.rule === 'max_total_size')
|
|
46
|
+
assert.strictEqual(bundle.length, 1)
|
|
47
|
+
assert.strictEqual(bundle[0].severity, 'error')
|
|
44
48
|
})
|
|
45
49
|
|
|
46
|
-
it('reports correct relative path for nested files', async () => {
|
|
50
|
+
it('reports correct relative path for nested oversized files', async () => {
|
|
47
51
|
const sub = path.join(dir, 'sub')
|
|
48
52
|
fs.mkdirSync(sub)
|
|
49
53
|
fs.writeFileSync(path.join(sub, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
50
54
|
const [err, results] = await validate_file_sizes(dir)
|
|
51
55
|
assert.strictEqual(err, null)
|
|
52
|
-
|
|
53
|
-
assert.strictEqual(
|
|
56
|
+
const per_file = results.filter(r => r.rule === 'max_file_size')
|
|
57
|
+
assert.strictEqual(per_file.length, 1)
|
|
58
|
+
assert.strictEqual(per_file[0].file, 'sub/big.txt')
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
it('skips dotfiles', async () => {
|
|
@@ -69,7 +74,7 @@ describe('validate_file_sizes', () => {
|
|
|
69
74
|
assert.strictEqual(results.length, 0)
|
|
70
75
|
})
|
|
71
76
|
|
|
72
|
-
it('passes for file exactly at
|
|
77
|
+
it('passes for a file exactly at MAX_FILE_SIZE and bundle at MAX_TOTAL_SIZE', async () => {
|
|
73
78
|
fs.writeFileSync(path.join(dir, 'exact.txt'), Buffer.alloc(MAX_FILE_SIZE))
|
|
74
79
|
const [err, results] = await validate_file_sizes(dir)
|
|
75
80
|
assert.strictEqual(err, null)
|