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,209 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
|
|
5
|
+
const { determine_target_version, compute_bump } = require('./release')
|
|
6
|
+
|
|
7
|
+
describe('release.compute_bump', () => {
|
|
8
|
+
it('handles patch/minor/major shortcuts', () => {
|
|
9
|
+
assert.strictEqual(compute_bump('1.2.3', 'patch'), '1.2.4')
|
|
10
|
+
assert.strictEqual(compute_bump('1.2.3', 'minor'), '1.3.0')
|
|
11
|
+
assert.strictEqual(compute_bump('1.2.3', 'major'), '2.0.0')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('passes through explicit semver', () => {
|
|
15
|
+
assert.strictEqual(compute_bump('1.2.3', '5.0.0'), '5.0.0')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for invalid bump and missing base', () => {
|
|
19
|
+
assert.strictEqual(compute_bump('1.2.3', 'gibberish'), null)
|
|
20
|
+
assert.strictEqual(compute_bump(null, 'patch'), null)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
|
|
28
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
|
|
29
|
+
|
|
30
|
+
const write_skill = (dir, manifest, opts = {}) => {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), opts.skill_md || `---\nname: ${manifest.name}\ndescription: release-test skill\n---\nbody\n`)
|
|
34
|
+
if (opts.changelog) {
|
|
35
|
+
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), opts.changelog)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('release.determine_target_version', () => {
|
|
40
|
+
it('identifies ahead and uses the disk version', async () => {
|
|
41
|
+
const root = make_tmp()
|
|
42
|
+
try {
|
|
43
|
+
const dir = path.join(root, 'ahead')
|
|
44
|
+
write_skill(dir, { name: 'ahead', version: '0.3.3' })
|
|
45
|
+
const lock_entry = { version: '0.3.2' }
|
|
46
|
+
const r = await determine_target_version({
|
|
47
|
+
manifest: { name: 'ahead', version: '0.3.3' },
|
|
48
|
+
lock_entry,
|
|
49
|
+
skill_dir: dir,
|
|
50
|
+
bump_flag: null,
|
|
51
|
+
no_bump: false
|
|
52
|
+
})
|
|
53
|
+
assert.strictEqual(r.status, 'ahead')
|
|
54
|
+
assert.strictEqual(r.target, '0.3.3')
|
|
55
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('refuses ahead when --bump disagrees with disk', async () => {
|
|
59
|
+
const root = make_tmp()
|
|
60
|
+
try {
|
|
61
|
+
const dir = path.join(root, 'ahead-bump-disagree')
|
|
62
|
+
write_skill(dir, { name: 'ahead-bump-disagree', version: '0.5.0' })
|
|
63
|
+
const r = await determine_target_version({
|
|
64
|
+
manifest: { name: 'ahead-bump-disagree', version: '0.5.0' },
|
|
65
|
+
lock_entry: { version: '0.3.2' },
|
|
66
|
+
skill_dir: dir,
|
|
67
|
+
bump_flag: 'patch', // would compute 0.3.3, disk is 0.5.0
|
|
68
|
+
no_bump: false
|
|
69
|
+
})
|
|
70
|
+
assert.strictEqual(r.status, 'bump_disagreement')
|
|
71
|
+
assert.strictEqual(r.disk_version, '0.5.0')
|
|
72
|
+
assert.strictEqual(r.requested_bump, 'patch')
|
|
73
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('flags drift on regression (disk < lock)', async () => {
|
|
77
|
+
const root = make_tmp()
|
|
78
|
+
try {
|
|
79
|
+
const dir = path.join(root, 'reg')
|
|
80
|
+
write_skill(dir, { name: 'reg', version: '0.3.0' })
|
|
81
|
+
const r = await determine_target_version({
|
|
82
|
+
manifest: { name: 'reg', version: '0.3.0' },
|
|
83
|
+
lock_entry: { version: '0.4.0' },
|
|
84
|
+
skill_dir: dir,
|
|
85
|
+
bump_flag: null,
|
|
86
|
+
no_bump: false
|
|
87
|
+
})
|
|
88
|
+
assert.strictEqual(r.status, 'drift')
|
|
89
|
+
assert.strictEqual(r.drift.reason, 'regression')
|
|
90
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('clean + --bump patch produces the new version', async () => {
|
|
94
|
+
const root = make_tmp()
|
|
95
|
+
try {
|
|
96
|
+
const dir = path.join(root, 'clean-bump')
|
|
97
|
+
write_skill(dir, { name: 'clean-bump', version: '1.0.0' })
|
|
98
|
+
const r = await determine_target_version({
|
|
99
|
+
manifest: { name: 'clean-bump', version: '1.0.0' },
|
|
100
|
+
lock_entry: { version: '1.0.0' },
|
|
101
|
+
skill_dir: dir,
|
|
102
|
+
bump_flag: 'patch',
|
|
103
|
+
no_bump: false
|
|
104
|
+
})
|
|
105
|
+
assert.strictEqual(r.status, 'clean_with_bump')
|
|
106
|
+
assert.strictEqual(r.target, '1.0.1')
|
|
107
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('clean + no bump + no CHANGELOG-hint → specify_bump', async () => {
|
|
111
|
+
const root = make_tmp()
|
|
112
|
+
try {
|
|
113
|
+
const dir = path.join(root, 'clean-no-bump')
|
|
114
|
+
write_skill(dir, { name: 'clean-no-bump', version: '1.0.0' })
|
|
115
|
+
const r = await determine_target_version({
|
|
116
|
+
manifest: { name: 'clean-no-bump', version: '1.0.0' },
|
|
117
|
+
lock_entry: { version: '1.0.0' },
|
|
118
|
+
skill_dir: dir,
|
|
119
|
+
bump_flag: null,
|
|
120
|
+
no_bump: false
|
|
121
|
+
})
|
|
122
|
+
assert.strictEqual(r.status, 'specify_bump')
|
|
123
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('clean + CHANGELOG-hint > current → inferred_from_changelog', async () => {
|
|
127
|
+
const root = make_tmp()
|
|
128
|
+
try {
|
|
129
|
+
const dir = path.join(root, 'cl-hint')
|
|
130
|
+
write_skill(dir, { name: 'cl-hint', version: '1.0.0' }, { changelog: '# Changelog\n\n## [1.1.0]\n- new\n' })
|
|
131
|
+
const r = await determine_target_version({
|
|
132
|
+
manifest: { name: 'cl-hint', version: '1.0.0' },
|
|
133
|
+
lock_entry: { version: '1.0.0' },
|
|
134
|
+
skill_dir: dir,
|
|
135
|
+
bump_flag: null,
|
|
136
|
+
no_bump: false
|
|
137
|
+
})
|
|
138
|
+
assert.strictEqual(r.status, 'inferred_from_changelog')
|
|
139
|
+
assert.strictEqual(r.target, '1.1.0')
|
|
140
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('--no-bump on clean returns missing_version', async () => {
|
|
144
|
+
const root = make_tmp()
|
|
145
|
+
try {
|
|
146
|
+
const dir = path.join(root, 'no-bump-clean')
|
|
147
|
+
write_skill(dir, { name: 'no-bump-clean', version: '1.0.0' })
|
|
148
|
+
const r = await determine_target_version({
|
|
149
|
+
manifest: { name: 'no-bump-clean', version: '1.0.0' },
|
|
150
|
+
lock_entry: { version: '1.0.0' },
|
|
151
|
+
skill_dir: dir,
|
|
152
|
+
bump_flag: null,
|
|
153
|
+
no_bump: true
|
|
154
|
+
})
|
|
155
|
+
assert.strictEqual(r.status, 'missing_version')
|
|
156
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('first-publish (no lock entry) treats the disk version as ahead — no --bump required', async () => {
|
|
160
|
+
const root = make_tmp()
|
|
161
|
+
try {
|
|
162
|
+
const dir = path.join(root, 'first-publish')
|
|
163
|
+
write_skill(dir, { name: 'first-publish', version: '0.1.0' })
|
|
164
|
+
const r = await determine_target_version({
|
|
165
|
+
manifest: { name: 'first-publish', version: '0.1.0' },
|
|
166
|
+
lock_entry: null, // no lock entry yet
|
|
167
|
+
skill_dir: dir,
|
|
168
|
+
bump_flag: null,
|
|
169
|
+
no_bump: false
|
|
170
|
+
})
|
|
171
|
+
assert.strictEqual(r.status, 'ahead')
|
|
172
|
+
assert.strictEqual(r.target, '0.1.0')
|
|
173
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('first-publish with --no-bump works (E2E gap from real-registry test)', async () => {
|
|
177
|
+
const root = make_tmp()
|
|
178
|
+
try {
|
|
179
|
+
const dir = path.join(root, 'first-publish-no-bump')
|
|
180
|
+
write_skill(dir, { name: 'first-publish-no-bump', version: '0.1.0' })
|
|
181
|
+
const r = await determine_target_version({
|
|
182
|
+
manifest: { name: 'first-publish-no-bump', version: '0.1.0' },
|
|
183
|
+
lock_entry: null,
|
|
184
|
+
skill_dir: dir,
|
|
185
|
+
bump_flag: null,
|
|
186
|
+
no_bump: true // explicitly no bump — should NOT error now
|
|
187
|
+
})
|
|
188
|
+
assert.strictEqual(r.status, 'ahead', 'first-publish with --no-bump must NOT emit MISSING_VERSION')
|
|
189
|
+
assert.strictEqual(r.target, '0.1.0')
|
|
190
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('--bump with garbage value returns invalid_bump', async () => {
|
|
194
|
+
const root = make_tmp()
|
|
195
|
+
try {
|
|
196
|
+
const dir = path.join(root, 'bad-bump')
|
|
197
|
+
write_skill(dir, { name: 'bad-bump', version: '1.0.0' })
|
|
198
|
+
const r = await determine_target_version({
|
|
199
|
+
manifest: { name: 'bad-bump', version: '1.0.0' },
|
|
200
|
+
lock_entry: { version: '1.0.0' },
|
|
201
|
+
skill_dir: dir,
|
|
202
|
+
bump_flag: 'gibberish',
|
|
203
|
+
no_bump: false
|
|
204
|
+
})
|
|
205
|
+
assert.strictEqual(r.status, 'invalid_bump')
|
|
206
|
+
assert.strictEqual(r.bump, 'gibberish')
|
|
207
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
|
+
const storage = require('../snapshot/storage')
|
|
4
|
+
const { find_project_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
5
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
6
|
+
const { print_help, print_success, print_info, print_table, print_json, print_warn } = require('../ui/output')
|
|
7
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
8
|
+
const { EXIT_CODES } = require('../constants')
|
|
9
|
+
|
|
10
|
+
const HELP_TEXT = `Usage: happyskills snapshot <subcommand> [args] [options]
|
|
11
|
+
|
|
12
|
+
Capture and restore skill state — the safety net for every mutation.
|
|
13
|
+
|
|
14
|
+
Subcommands:
|
|
15
|
+
create <owner/skill> Capture skill directory + lock entry
|
|
16
|
+
list <owner/skill> List existing snapshots for a skill
|
|
17
|
+
restore <snapshot-id> Restore a skill from a snapshot
|
|
18
|
+
delete <snapshot-id> Delete a snapshot
|
|
19
|
+
prune <owner/skill> Keep only the N most recent snapshots
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--note <text> Attach a note to a new snapshot (create only)
|
|
23
|
+
--keep <n> Retention count for prune (default: 10)
|
|
24
|
+
-g, --global Operate on the global snapshot store
|
|
25
|
+
--json Output as JSON
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
happyskills snapshot create acme/deploy-aws --note "pre-release" --json
|
|
29
|
+
happyskills snapshot list acme/deploy-aws --json
|
|
30
|
+
happyskills snapshot restore snap_20260523T103045Z_abc123 --json
|
|
31
|
+
happyskills snapshot delete snap_20260523T103045Z_abc123 --json
|
|
32
|
+
happyskills snapshot prune acme/deploy-aws --keep 5 --json`
|
|
33
|
+
|
|
34
|
+
const parse_skill_arg = (raw) => {
|
|
35
|
+
if (!raw) return null
|
|
36
|
+
if (raw.includes('/')) {
|
|
37
|
+
const [workspace, ...rest] = raw.split('/')
|
|
38
|
+
return { workspace, skill: rest.join('/'), short: rest.join('/') }
|
|
39
|
+
}
|
|
40
|
+
return { workspace: null, skill: raw, short: raw }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resolve_from_lock = (short_name, project_root) => catch_errors('Failed to resolve skill from lock', async () => {
|
|
44
|
+
const [, lock_data] = await read_lock(project_root)
|
|
45
|
+
if (!lock_data) return null
|
|
46
|
+
const all = get_all_locked_skills(lock_data)
|
|
47
|
+
const suffix = `/${short_name}`
|
|
48
|
+
const key = Object.keys(all).find(k => k === short_name || k.endsWith(suffix))
|
|
49
|
+
if (!key) return null
|
|
50
|
+
const [workspace, name] = key.includes('/') ? key.split('/') : [null, key]
|
|
51
|
+
return { workspace, skill: name, lock_entry: all[key] }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const do_create = (args) => catch_errors('Snapshot create failed', async () => {
|
|
55
|
+
const raw = args._[1]
|
|
56
|
+
if (!raw) throw new UsageError('Usage: happyskills snapshot create <owner/skill> [--note <text>]')
|
|
57
|
+
|
|
58
|
+
const project_root = find_project_root()
|
|
59
|
+
const is_global = !!args.flags.global
|
|
60
|
+
const parsed = parse_skill_arg(raw)
|
|
61
|
+
let workspace = parsed.workspace
|
|
62
|
+
let skill = parsed.skill
|
|
63
|
+
let lock_entry = null
|
|
64
|
+
|
|
65
|
+
const [, resolved] = await resolve_from_lock(parsed.short, project_root)
|
|
66
|
+
if (resolved) {
|
|
67
|
+
if (!workspace) workspace = resolved.workspace
|
|
68
|
+
skill = resolved.skill
|
|
69
|
+
lock_entry = resolved.lock_entry
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!workspace) {
|
|
73
|
+
throw new UsageError(`Cannot determine workspace for "${raw}" — pass it as owner/skill or install via the registry first.`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
77
|
+
const skill_dir = skill_install_dir(base_dir, skill)
|
|
78
|
+
|
|
79
|
+
const [err, result] = await storage.create({
|
|
80
|
+
skill_dir, workspace, skill, lock_entry,
|
|
81
|
+
note: typeof args.flags.note === 'string' ? args.flags.note : null,
|
|
82
|
+
is_global, project_root
|
|
83
|
+
})
|
|
84
|
+
if (err) throw e('Failed to create snapshot', err)
|
|
85
|
+
|
|
86
|
+
if (args.flags.json) {
|
|
87
|
+
print_json({ data: result })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
print_success(`Snapshot ${result.snapshot_id}`)
|
|
91
|
+
print_info(` Path: ${result.path}`)
|
|
92
|
+
if (result.note) print_info(` Note: ${result.note}`)
|
|
93
|
+
if (result.pruned?.length > 0) print_info(` Pruned ${result.pruned.length} older snapshot(s)`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const do_list = (args) => catch_errors('Snapshot list failed', async () => {
|
|
97
|
+
const raw = args._[1]
|
|
98
|
+
if (!raw) throw new UsageError('Usage: happyskills snapshot list <owner/skill>')
|
|
99
|
+
|
|
100
|
+
const project_root = find_project_root()
|
|
101
|
+
const is_global = !!args.flags.global
|
|
102
|
+
const parsed = parse_skill_arg(raw)
|
|
103
|
+
let workspace = parsed.workspace
|
|
104
|
+
let skill = parsed.skill
|
|
105
|
+
|
|
106
|
+
if (!workspace) {
|
|
107
|
+
const [, resolved] = await resolve_from_lock(parsed.short, project_root)
|
|
108
|
+
if (resolved) {
|
|
109
|
+
workspace = resolved.workspace
|
|
110
|
+
skill = resolved.skill
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!workspace) {
|
|
115
|
+
if (args.flags.json) {
|
|
116
|
+
print_json({ data: { snapshots: [] } })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
print_info('No workspace resolved — no snapshots to list.')
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [err, result] = await storage.list({ workspace, skill, is_global, project_root })
|
|
124
|
+
if (err) throw e('Failed to list snapshots', err)
|
|
125
|
+
|
|
126
|
+
if (args.flags.json) {
|
|
127
|
+
print_json({ data: { workspace, skill, snapshots: result.snapshots } })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (result.snapshots.length === 0) {
|
|
132
|
+
print_info(`No snapshots for ${workspace}/${skill}.`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const rows = result.snapshots.map(s => [
|
|
136
|
+
s.snapshot_id,
|
|
137
|
+
s.timestamp,
|
|
138
|
+
(s.note || '').slice(0, 60)
|
|
139
|
+
])
|
|
140
|
+
print_table(['Snapshot ID', 'Timestamp', 'Note'], rows)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const do_restore = (args) => catch_errors('Snapshot restore failed', async () => {
|
|
144
|
+
const snapshot_id = args._[1]
|
|
145
|
+
if (!snapshot_id) throw new UsageError('Usage: happyskills snapshot restore <snapshot-id>')
|
|
146
|
+
|
|
147
|
+
const project_root = find_project_root()
|
|
148
|
+
const is_global = !!args.flags.global
|
|
149
|
+
|
|
150
|
+
const [find_err, found] = await storage.find_snapshot(snapshot_id, { is_global, project_root })
|
|
151
|
+
if (find_err) throw e('Failed to look up snapshot', find_err)
|
|
152
|
+
if (!found) {
|
|
153
|
+
throw new Error(`Snapshot not found: ${snapshot_id}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
157
|
+
const skill_dir = skill_install_dir(base_dir, found.skill)
|
|
158
|
+
|
|
159
|
+
const [err, result] = await storage.restore(snapshot_id, { skill_dir, is_global, project_root })
|
|
160
|
+
if (err) throw e('Failed to restore snapshot', err)
|
|
161
|
+
|
|
162
|
+
if (args.flags.json) {
|
|
163
|
+
print_json({ data: result })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
print_success(`Restored ${result.workspace}/${result.skill} from ${snapshot_id}`)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const do_delete = (args) => catch_errors('Snapshot delete failed', async () => {
|
|
170
|
+
const snapshot_id = args._[1]
|
|
171
|
+
if (!snapshot_id) throw new UsageError('Usage: happyskills snapshot delete <snapshot-id>')
|
|
172
|
+
const project_root = find_project_root()
|
|
173
|
+
const is_global = !!args.flags.global
|
|
174
|
+
|
|
175
|
+
const [err, result] = await storage.remove(snapshot_id, { is_global, project_root })
|
|
176
|
+
if (err) throw e('Failed to delete snapshot', err)
|
|
177
|
+
|
|
178
|
+
if (args.flags.json) {
|
|
179
|
+
print_json({ data: result })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (result.deleted) print_success(`Deleted ${snapshot_id}`)
|
|
183
|
+
else print_warn(`No snapshot deleted (${result.reason || 'unknown'}).`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const do_prune = (args) => catch_errors('Snapshot prune failed', async () => {
|
|
187
|
+
const raw = args._[1]
|
|
188
|
+
if (!raw) throw new UsageError('Usage: happyskills snapshot prune <owner/skill> [--keep <n>]')
|
|
189
|
+
|
|
190
|
+
const project_root = find_project_root()
|
|
191
|
+
const is_global = !!args.flags.global
|
|
192
|
+
const parsed = parse_skill_arg(raw)
|
|
193
|
+
let workspace = parsed.workspace
|
|
194
|
+
let skill = parsed.skill
|
|
195
|
+
|
|
196
|
+
if (!workspace) {
|
|
197
|
+
const [, resolved] = await resolve_from_lock(parsed.short, project_root)
|
|
198
|
+
if (resolved) {
|
|
199
|
+
workspace = resolved.workspace
|
|
200
|
+
skill = resolved.skill
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!workspace) {
|
|
205
|
+
throw new UsageError(`Cannot determine workspace for "${raw}" — pass it as owner/skill.`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const keep_raw = args.flags.keep
|
|
209
|
+
const keep = keep_raw === undefined || keep_raw === true ? storage.DEFAULT_RETENTION : parseInt(keep_raw, 10)
|
|
210
|
+
if (!Number.isFinite(keep) || keep < 0) {
|
|
211
|
+
throw new UsageError(`--keep must be a non-negative integer (got "${keep_raw}")`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const [err, result] = await storage.prune({ workspace, skill, is_global, project_root, keep })
|
|
215
|
+
if (err) throw e('Failed to prune snapshots', err)
|
|
216
|
+
|
|
217
|
+
if (args.flags.json) {
|
|
218
|
+
print_json({ data: result })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
print_success(`Kept ${result.kept}, deleted ${result.deleted}`)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const SUBCOMMANDS = {
|
|
225
|
+
create: do_create,
|
|
226
|
+
list: do_list,
|
|
227
|
+
restore: do_restore,
|
|
228
|
+
delete: do_delete,
|
|
229
|
+
rm: do_delete,
|
|
230
|
+
prune: do_prune
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const run = (args) => catch_errors('Snapshot failed', async () => {
|
|
234
|
+
if (args.flags._show_help) {
|
|
235
|
+
print_help(HELP_TEXT)
|
|
236
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const sub = args._[0]
|
|
240
|
+
if (!sub) {
|
|
241
|
+
throw new UsageError('Usage: happyskills snapshot <create|list|restore|delete|prune> ...')
|
|
242
|
+
}
|
|
243
|
+
const handler = SUBCOMMANDS[sub]
|
|
244
|
+
if (!handler) {
|
|
245
|
+
throw new UsageError(`Unknown snapshot subcommand: "${sub}". Run \`happyskills snapshot --help\`.`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const [err] = await handler(args)
|
|
249
|
+
if (err) throw err
|
|
250
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
251
|
+
|
|
252
|
+
module.exports = { run }
|
package/src/commands/status.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
3
|
const { detect_status } = require('../merge/detector')
|
|
4
|
-
const { verify_lock_disk_consistency, describe_drift } = require('../lock/verify')
|
|
4
|
+
const { verify_lock_disk_consistency, detect_ahead_state, describe_drift } = require('../lock/verify')
|
|
5
5
|
const repos_api = require('../api/repos')
|
|
6
6
|
const { print_help, print_info, print_json, print_warn, print_hint, code } = require('../ui/output')
|
|
7
7
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
@@ -26,12 +26,13 @@ Examples:
|
|
|
26
26
|
happyskills st acme/deploy-aws
|
|
27
27
|
happyskills status --json`
|
|
28
28
|
|
|
29
|
-
// Drift outranks every other status
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const classify = (drift, local_modified, remote_updated, has_conflicts) => {
|
|
29
|
+
// §10.5 — seven canonical states. Drift outranks every other status because
|
|
30
|
+
// the install-record baseline is broken; ahead is reported above local/remote
|
|
31
|
+
// composites because it represents authoring intent the system should preserve.
|
|
32
|
+
const classify = (drift, ahead, local_modified, remote_updated, has_conflicts) => {
|
|
33
33
|
if (drift && !drift.ok) return 'drift'
|
|
34
34
|
if (has_conflicts) return 'conflicts'
|
|
35
|
+
if (ahead) return 'ahead'
|
|
35
36
|
if (local_modified && remote_updated) return 'diverged'
|
|
36
37
|
if (local_modified) return 'modified'
|
|
37
38
|
if (remote_updated) return 'outdated'
|
|
@@ -92,20 +93,27 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
92
93
|
|
|
93
94
|
const base_dir = skills_dir(is_global, project_root)
|
|
94
95
|
|
|
95
|
-
// Detect drift
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// content" from "structural baseline broken".
|
|
96
|
+
// Detect drift, ahead, and local modifications in parallel. The drift probe
|
|
97
|
+
// is cheap (one skill.json read + version compare); detect_status is the
|
|
98
|
+
// heavier integrity check. Running them all lets us distinguish "user edited
|
|
99
|
+
// content" from "structural baseline broken" from "author bumped locally".
|
|
99
100
|
const detections = await Promise.all(entries.map(async ([name, data]) => {
|
|
100
|
-
if (!data) return { name, data, det: null, drift: null }
|
|
101
|
+
if (!data) return { name, data, det: null, drift: null, ahead: null }
|
|
101
102
|
const short_name = name.split('/')[1] || name
|
|
102
103
|
const dir = skill_install_dir(base_dir, short_name)
|
|
103
104
|
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
104
105
|
const [, det] = await detect_status(data, dir, { skill_name: name, project_root, is_global })
|
|
105
|
-
|
|
106
|
+
// Only check ahead when drift is clean — drift represents a broken
|
|
107
|
+
// baseline that should be reported first.
|
|
108
|
+
let ahead = null
|
|
109
|
+
if (drift && drift.ok) {
|
|
110
|
+
const [, ahead_state] = await detect_ahead_state(data, dir)
|
|
111
|
+
if (ahead_state && ahead_state.ahead) ahead = ahead_state
|
|
112
|
+
}
|
|
113
|
+
return { name, data, det, drift, ahead }
|
|
106
114
|
}))
|
|
107
115
|
|
|
108
|
-
const results = detections.map(({ name, data, det, drift }) => {
|
|
116
|
+
const results = detections.map(({ name, data, det, drift, ahead }) => {
|
|
109
117
|
if (!data) return { skill: name, status: 'not_found', local_modified: false, remote_updated: false }
|
|
110
118
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
111
119
|
return {
|
|
@@ -122,7 +130,13 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
122
130
|
drift: drift && !drift.ok
|
|
123
131
|
? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
|
|
124
132
|
: null,
|
|
125
|
-
|
|
133
|
+
ahead: ahead ? {
|
|
134
|
+
lock_version: ahead.lock_version,
|
|
135
|
+
disk_version: ahead.disk_version,
|
|
136
|
+
has_changelog_entry: ahead.has_changelog_entry || false,
|
|
137
|
+
changelog_version: ahead.changelog_version || null
|
|
138
|
+
} : null,
|
|
139
|
+
status: drift && !drift.ok ? 'drift' : (has_conflicts ? 'conflicts' : (ahead ? 'ahead' : 'clean'))
|
|
126
140
|
}
|
|
127
141
|
})
|
|
128
142
|
|
|
@@ -145,11 +159,13 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
// Classify each result. The drift field is the canonical structural-baseline
|
|
148
|
-
// signal; pass it to classify so it can outrank everything else.
|
|
162
|
+
// signal; pass it to classify so it can outrank everything else. The ahead
|
|
163
|
+
// field is the canonical author-intent signal — it preserves the local bump
|
|
164
|
+
// during the publish flow.
|
|
149
165
|
for (const r of results) {
|
|
150
166
|
if (r.status !== 'not_found') {
|
|
151
167
|
const drift_check = r.drift ? { ok: false } : { ok: true }
|
|
152
|
-
r.status = classify(drift_check, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
|
|
168
|
+
r.status = classify(drift_check, !!r.ahead, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
|
|
@@ -174,12 +190,13 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
174
190
|
expected: r.drift?.lock_version,
|
|
175
191
|
actual: r.drift?.disk_version
|
|
176
192
|
})
|
|
177
|
-
: r.status === '
|
|
178
|
-
: r.status === '
|
|
179
|
-
: r.status === '
|
|
180
|
-
: r.status === '
|
|
181
|
-
: r.status === '
|
|
182
|
-
: '
|
|
193
|
+
: r.status === 'ahead' ? `ahead (disk ${r.ahead?.disk_version}, lock ${r.ahead?.lock_version})`
|
|
194
|
+
: r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
|
|
195
|
+
: r.status === 'diverged' ? 'diverged (local + remote changes)'
|
|
196
|
+
: r.status === 'modified' ? 'modified (local changes)'
|
|
197
|
+
: r.status === 'outdated' ? 'outdated (remote changes)'
|
|
198
|
+
: r.status === 'not_found' ? 'not found'
|
|
199
|
+
: 'clean'
|
|
183
200
|
}))
|
|
184
201
|
|
|
185
202
|
const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
|
|
@@ -194,11 +211,16 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
194
211
|
}
|
|
195
212
|
|
|
196
213
|
const drifted = results.filter(r => r.status === 'drift')
|
|
214
|
+
const ahead_skills = results.filter(r => r.status === 'ahead')
|
|
197
215
|
if (drifted.length > 0) {
|
|
198
216
|
console.log()
|
|
199
217
|
print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
200
|
-
print_hint(`
|
|
201
|
-
|
|
218
|
+
print_hint(`Repair: ${code('happyskills reconcile <skill>')}`)
|
|
219
|
+
}
|
|
220
|
+
if (ahead_skills.length > 0) {
|
|
221
|
+
console.log()
|
|
222
|
+
print_info(`${ahead_skills.length} skill(s) ahead of lock — bumped locally, not yet published.`)
|
|
223
|
+
print_hint(`Publish: ${code('happyskills publish <skill>')} (lock catches up atomically)`)
|
|
202
224
|
}
|
|
203
225
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
204
226
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Size limits enforced before publish. Must stay in sync with
|
|
2
|
+
// api/app/config/limits.js — a drift means a publish that passes CLI
|
|
3
|
+
// validation will be rejected by the API.
|
|
4
|
+
|
|
5
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB per file
|
|
6
|
+
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
MAX_FILE_SIZE,
|
|
10
|
+
MAX_TOTAL_SIZE
|
|
11
|
+
}
|
package/src/constants.js
CHANGED
package/src/index.js
CHANGED
|
@@ -113,6 +113,9 @@ Commands:
|
|
|
113
113
|
login Authenticate with the registry
|
|
114
114
|
logout Clear stored credentials
|
|
115
115
|
whoami Show current user
|
|
116
|
+
snapshot <sub> Capture and restore skill state (create, list, restore, delete, prune)
|
|
117
|
+
reconcile <owner/skill> Diagnose and repair lock-vs-disk drift
|
|
118
|
+
release <skill-name> Atomic release: snapshot + validate + bump + publish
|
|
116
119
|
|
|
117
120
|
Global flags:
|
|
118
121
|
--help Show help for a command
|