happyskills 0.48.0 → 0.50.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.
@@ -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
+ })
@@ -0,0 +1,70 @@
1
+ // Spec 260524-01 § 8.3 — CLI-side image compression for feedback attachments.
2
+ //
3
+ // FOOTPRINT DISCIPLINE (D8): this module cherry-picks @jimp/core +
4
+ // @jimp/js-jpeg + @jimp/js-png + @jimp/plugin-resize from jimp 1.x. We
5
+ // deliberately do NOT depend on the `jimp` meta-package — that pulls in
6
+ // BMP, GIF, TIFF, blur, color, mask, threshold, dither, print, hash, blit,
7
+ // circle, contain, cover, crop, displace, fisheye, flip, mask, quantize,
8
+ // rotate, and a half-dozen more plugins we don't need.
9
+ //
10
+ // WEBP NOTE: jimp 1.x does NOT ship a WebP codec sub-package. CLI accepts
11
+ // PNG and JPEG only; the web surface still accepts WebP via
12
+ // `browser-image-compression`. Spec § 8.3's fallback explicitly anticipates
13
+ // this drop ("drop @jimp/webp" — but in practice there's no such package).
14
+ //
15
+ // LAZY-LOAD: this module is dynamically imported only when the feedback
16
+ // command actually has --attach arguments (see commands/feedback.js). Every
17
+ // other CLI command — install, search, publish, etc. — pays zero require
18
+ // cost for jimp.
19
+
20
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
21
+
22
+ const MAX_DIMENSION = 2000
23
+ const JPEG_QUALITY = 80
24
+
25
+ const get_default = (m) => (m && m.default !== undefined) ? m.default : m
26
+
27
+ let _Jimp = null
28
+
29
+ const get_jimp = () => {
30
+ if (_Jimp) return _Jimp
31
+ const { createJimp } = require('@jimp/core')
32
+ const jpeg = get_default(require('@jimp/js-jpeg'))
33
+ const png = get_default(require('@jimp/js-png'))
34
+ const resize = get_default(require('@jimp/plugin-resize'))
35
+ _Jimp = createJimp({ formats: [jpeg, png], plugins: [resize] })
36
+ return _Jimp
37
+ }
38
+
39
+ // Returns { buffer, bytes, width, height } — JPEG-encoded, longest side
40
+ // ≤ 2000 px, quality 80. Accepts PNG or JPEG input.
41
+ const compress_image = (file_path) => catch_errors('Image compression failed', async () => {
42
+ const Jimp = get_jimp()
43
+ let img
44
+ try {
45
+ img = await Jimp.read(file_path)
46
+ } catch (err) {
47
+ throw e(`Could not read image at ${file_path} — only PNG and JPEG are supported on the CLI`, [err])
48
+ }
49
+
50
+ const { width, height } = img.bitmap
51
+ if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
52
+ // scale proportionally so longest side = MAX_DIMENSION
53
+ if (width >= height) {
54
+ img.resize({ w: MAX_DIMENSION })
55
+ } else {
56
+ img.resize({ h: MAX_DIMENSION })
57
+ }
58
+ }
59
+
60
+ const buffer = await img.getBuffer('image/jpeg', { quality: JPEG_QUALITY })
61
+
62
+ return {
63
+ buffer,
64
+ bytes: buffer.length,
65
+ width: img.bitmap.width,
66
+ height: img.bitmap.height
67
+ }
68
+ })
69
+
70
+ module.exports = { compress_image, MAX_DIMENSION, JPEG_QUALITY }
@@ -0,0 +1,49 @@
1
+ // Spec 260524-01 § 12 — Secret scrubbing applied to feedback `client_context`
2
+ // before it leaves the CLI. Must match the rules in web/src/lib/scrub-secrets.js.
3
+ //
4
+ // Rules:
5
+ // 1. Strip OpenAI API keys (sk-...)
6
+ // 2. Strip GitHub tokens (ghp_..., gho_...)
7
+ // 3. Strip Cognito JWT-shaped tokens (eyJ...)
8
+ // 4. Strip values for env-var keys ending in _TOKEN, _KEY, _SECRET, _PASSWORD
9
+ //
10
+ // The replacement is the literal string `<redacted>` so callers can see that
11
+ // a value WAS present and got stripped — vs missing entirely.
12
+
13
+ const REDACTED = '<redacted>'
14
+
15
+ const PATTERNS = [
16
+ /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI keys
17
+ /ghp_[A-Za-z0-9]{30,}/g, // GitHub personal access tokens
18
+ /gho_[A-Za-z0-9]{30,}/g, // GitHub OAuth tokens
19
+ /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g // JWT shape
20
+ ]
21
+
22
+ const SENSITIVE_KEY = /(_TOKEN|_KEY|_SECRET|_PASSWORD|_API_KEY)$/i
23
+
24
+ const scrub_string = (value) => {
25
+ let out = value
26
+ for (const re of PATTERNS) out = out.replace(re, REDACTED)
27
+ return out
28
+ }
29
+
30
+ // Recursively walk an object, scrubbing string values and redacting values
31
+ // whose key looks sensitive (e.g. `OPENAI_API_KEY`).
32
+ const scrub = (value) => {
33
+ if (value == null) return value
34
+ if (typeof value === 'string') return scrub_string(value)
35
+ if (typeof value !== 'object') return value
36
+ if (Array.isArray(value)) return value.map(scrub)
37
+
38
+ const out = {}
39
+ for (const [key, v] of Object.entries(value)) {
40
+ if (typeof v === 'string' && SENSITIVE_KEY.test(key)) {
41
+ out[key] = REDACTED
42
+ } else {
43
+ out[key] = scrub(v)
44
+ }
45
+ }
46
+ return out
47
+ }
48
+
49
+ module.exports = { scrub, scrub_string, REDACTED }