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.
- package/CHANGELOG.md +66 -0
- package/package.json +5 -1
- package/src/api/client.js +5 -2
- package/src/api/feedback.js +71 -0
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/feedback.js +260 -0
- 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 +5 -1
- package/src/index.js +4 -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/utils/image_compression.js +70 -0
- package/src/utils/scrub_secrets.js +49 -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
|
+
})
|
|
@@ -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 }
|