happyskills 0.43.0 → 0.44.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 +23 -0
- package/package.json +1 -1
- package/src/commands/bump.js +14 -1
- package/src/commands/check.js +38 -5
- package/src/commands/convert.js +12 -0
- package/src/commands/diff.js +14 -0
- package/src/commands/list.js +30 -6
- package/src/commands/publish.js +18 -0
- package/src/commands/pull.js +31 -0
- package/src/commands/status.js +47 -18
- package/src/commands/update.js +32 -7
- package/src/engine/installer.js +19 -0
- package/src/integration/drift.test.js +276 -0
- package/src/lock/verify.js +48 -0
- package/src/lock/verify.test.js +137 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.44.0] - 2026-05-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Detect lock-vs-disk drift across every CLI command that reads or reports installed-skill state. A new `drift` status is reported whenever the version recorded in `skills-lock.json` does not agree with the version field in the skill's on-disk `skill.json` (or when the install directory or its `skill.json` is missing). The probe is a single `skill.json` read per skill — no API call, no full directory hash. Drift outranks every other status (`clean`/`modified`/`outdated`/`diverged`/`conflicts` for `status`; `up-to-date`/`outdated`/`conflicts` for `check`/`update`) because every other status compares against the lock as a baseline, and drift means the baseline itself is broken. Each result includes a structured `drift: { reason, lock_version, disk_version }` object in `--json` output (`reason` is one of `version_mismatch`, `missing_skill_json`, `missing_dir`). Wired into:
|
|
14
|
+
- `status` — the headline read-side fix; previously masked drift as `modified (local changes)`.
|
|
15
|
+
- `check` — previously silent (reported `up-to-date` because it never read disk).
|
|
16
|
+
- `list` — previously reported the lock's `version` as the installed version, lying when drift existed.
|
|
17
|
+
- `update` — pre-check now refuses to silently overwrite drift; surfaces it as `drift` in the table and skips affected skills with a remediation hint.
|
|
18
|
+
- `diff` — refuses with a `UsageError` when the target skill is drifted (the lock's `base_commit` is no longer a coherent comparison baseline; diffing against it produces noise, not signal).
|
|
19
|
+
- Add post-write verification to every command that mutates `skills-lock.json` and skill files together. After the lock is written, each command verifies that the new lock entry agrees with the on-disk `skill.json` and fails loudly (or warns, where appropriate) if they disagree. Closes the prevention half of the drift class — every write path now confirms lock+disk agreement before reporting success. Wired into:
|
|
20
|
+
- `install` (engine) — throws on mismatch with a `--fresh` remediation hint.
|
|
21
|
+
- `pull` — throws on the fast-forward path; warns on the three-way merge path (the merger can legitimately resolve `version` to either side, and the user already has conflict review work to do).
|
|
22
|
+
- `bump` — throws on mismatch; bump touches both files in sequence and is the most direct place where an interruption creates drift.
|
|
23
|
+
- `publish` — warns on mismatch; the push has already succeeded server-side, so a structural mismatch in the local lock should be surfaced but not block the success report.
|
|
24
|
+
- `convert` — throws on mismatch; convert generates both files together, so any disagreement is a fresh structural bug.
|
|
25
|
+
- New `cli/src/lock/verify.js` module exporting `verify_lock_disk_consistency(lock_entry, install_dir)` and `describe_drift(result)`. Single source of truth for the lock-vs-disk consistency probe; consumed by `status`, `check`, `list`, `diff`, `update`, `installer`, `pull`, `bump`, `publish`, and `convert`.
|
|
26
|
+
- New regression integration test file `cli/src/integration/drift.test.js` (10 tests, separate from `cli.test.js` to keep that file under the 750-line refactor threshold). Tests reconstruct the exact linwong scenario (lock 0.4.0, disk 0.3.0) and assert that `status`, `check`, `list`, and `diff` all surface drift via the structured `drift` field. Includes coverage for `missing_skill_json`, the "drift outranks modified" invariant, and the "drift overrides up-to-date" invariant. If any of these break, the bug class is back.
|
|
27
|
+
|
|
28
|
+
## [0.43.1] - 2026-05-11
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Fix `status` (no skill argument) filtering out transitive dependencies. The command's help text describes it as "Show divergence status for installed skills," but the previous behavior filtered `data?.requested_by?.includes('__root__')` — so only direct installs appeared, hiding diverged or outdated transitive deps from the result set. The empty-state message ("No root-level skills found") leaked the filter as a user-facing concept. Now returns all entries in `skills-lock.json` (matching the help text and `list`'s behavior), and the empty-state message reads "No installed skills found." Targeted status (`happyskills status owner/name`) is unaffected — that path never went through the filter.
|
|
32
|
+
|
|
10
33
|
## [0.43.0] - 2026-05-07
|
|
11
34
|
|
|
12
35
|
### Added
|
package/package.json
CHANGED
package/src/commands/bump.js
CHANGED
|
@@ -8,8 +8,9 @@ const { find_project_root } = require('../config/paths')
|
|
|
8
8
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
9
9
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
10
10
|
const { hash_directory } = require('../lock/integrity')
|
|
11
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
11
12
|
const repos_api = require('../api/repos')
|
|
12
|
-
const { print_help, print_success, print_warn, print_json } = require('../ui/output')
|
|
13
|
+
const { print_help, print_success, print_warn, print_json, code } = require('../ui/output')
|
|
13
14
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
14
15
|
const { EXIT_CODES } = require('../constants')
|
|
15
16
|
|
|
@@ -99,6 +100,18 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
99
100
|
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
100
101
|
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
101
102
|
await write_lock(project_root, updated_skills)
|
|
103
|
+
|
|
104
|
+
// Post-write verification — bump touches both files, so confirm they
|
|
105
|
+
// agree before declaring success. An interrupted bump (e.g. process
|
|
106
|
+
// killed between write_manifest and write_lock above) is exactly the
|
|
107
|
+
// failure mode this guard exists to catch.
|
|
108
|
+
const [, verify_result] = await verify_lock_disk_consistency(updated_entry, dir)
|
|
109
|
+
if (verify_result && !verify_result.ok) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Bump completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
|
|
112
|
+
`Re-run ${code(`happyskills install ${lock_key} --fresh`)} to restore.`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
if (args.flags.json) {
|
package/src/commands/check.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
|
|
3
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
3
4
|
const repos_api = require('../api/repos')
|
|
4
5
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
5
6
|
const { green, yellow, red } = require('../ui/colors')
|
|
6
7
|
const { exit_with_error } = require('../utils/errors')
|
|
7
|
-
const { find_project_root } = require('../config/paths')
|
|
8
|
+
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
8
9
|
const { EXIT_CODES } = require('../constants')
|
|
9
10
|
|
|
10
11
|
const HELP_TEXT = `Usage: happyskills check [owner/skill] [options]
|
|
@@ -59,17 +60,37 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
59
60
|
const skill_names = to_check.map(([name]) => name)
|
|
60
61
|
const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
|
|
61
62
|
|
|
63
|
+
// Verify lock-vs-disk consistency for every skill before classifying. Drift
|
|
64
|
+
// must outrank up-to-date / outdated / conflicts because all those statuses
|
|
65
|
+
// trust the lock as a baseline — and drift means the baseline is broken.
|
|
66
|
+
const base_dir = skills_dir(false, project_root)
|
|
67
|
+
const drift_by_skill = {}
|
|
68
|
+
await Promise.all(to_check.map(async ([name, data]) => {
|
|
69
|
+
const short_name = name.split('/')[1] || name
|
|
70
|
+
const dir = skill_install_dir(base_dir, short_name)
|
|
71
|
+
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
72
|
+
if (drift && !drift.ok) drift_by_skill[name] = drift
|
|
73
|
+
}))
|
|
74
|
+
|
|
62
75
|
const results = []
|
|
63
76
|
if (batch_err) {
|
|
64
77
|
for (const [name, data] of to_check) {
|
|
65
|
-
|
|
78
|
+
const drift = drift_by_skill[name]
|
|
79
|
+
if (drift) {
|
|
80
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'drift', via: get_via(data), drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
|
|
81
|
+
} else {
|
|
82
|
+
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
|
|
83
|
+
}
|
|
66
84
|
}
|
|
67
85
|
} else {
|
|
68
86
|
for (const [name, data] of to_check) {
|
|
69
87
|
const info = batch_data?.results?.[name]
|
|
70
88
|
const via = get_via(data)
|
|
71
89
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
72
|
-
|
|
90
|
+
const drift = drift_by_skill[name]
|
|
91
|
+
if (drift) {
|
|
92
|
+
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', via, drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
|
|
93
|
+
} else if (has_conflicts) {
|
|
73
94
|
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts', via })
|
|
74
95
|
} else if (info?.access_denied) {
|
|
75
96
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access', via })
|
|
@@ -90,7 +111,8 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
90
111
|
const outdated_count = results.filter(r => r.status === 'outdated').length
|
|
91
112
|
const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
|
|
92
113
|
const conflicts_count = results.filter(r => r.status === 'conflicts').length
|
|
93
|
-
|
|
114
|
+
const drift_count = results.filter(r => r.status === 'drift').length
|
|
115
|
+
print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count, drift_count } })
|
|
94
116
|
return
|
|
95
117
|
}
|
|
96
118
|
|
|
@@ -98,6 +120,7 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
98
120
|
'up-to-date': green,
|
|
99
121
|
'outdated': yellow,
|
|
100
122
|
'conflicts': red,
|
|
123
|
+
'drift': red,
|
|
101
124
|
'no-access': yellow,
|
|
102
125
|
'error': red,
|
|
103
126
|
'unknown': (s) => s
|
|
@@ -121,7 +144,17 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
121
144
|
|
|
122
145
|
const outdated = results.filter(r => r.status === 'outdated')
|
|
123
146
|
const conflicts = results.filter(r => r.status === 'conflicts')
|
|
147
|
+
const drifted = results.filter(r => r.status === 'drift')
|
|
124
148
|
const no_access = results.filter(r => r.status === 'no-access')
|
|
149
|
+
if (drifted.length > 0) {
|
|
150
|
+
console.log()
|
|
151
|
+
print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
152
|
+
for (const d of drifted) {
|
|
153
|
+
const disk = d.drift?.disk_version || 'none'
|
|
154
|
+
console.error(` - ${d.skill} (lock ${d.drift?.lock_version}, disk ${disk})`)
|
|
155
|
+
}
|
|
156
|
+
print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
|
|
157
|
+
}
|
|
125
158
|
if (conflicts.length > 0) {
|
|
126
159
|
console.log()
|
|
127
160
|
print_warn(`${conflicts.length} skill(s) have unresolved merge conflicts.`)
|
|
@@ -130,7 +163,7 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
130
163
|
if (outdated.length > 0) {
|
|
131
164
|
console.log()
|
|
132
165
|
print_info(`Run ${code('happyskills update --all')} to upgrade ${outdated.length} skill(s).`)
|
|
133
|
-
} else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
|
|
166
|
+
} else if (conflicts.length === 0 && drifted.length === 0 && results.every(r => r.status === 'up-to-date')) {
|
|
134
167
|
console.log()
|
|
135
168
|
print_success('All skills are up to date.')
|
|
136
169
|
}
|
package/src/commands/convert.js
CHANGED
|
@@ -11,6 +11,7 @@ const { write_manifest } = require('../manifest/writer')
|
|
|
11
11
|
const { read_lock } = require('../lock/reader')
|
|
12
12
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
13
13
|
const { hash_directory } = require('../lock/integrity')
|
|
14
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
14
15
|
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
15
16
|
const { file_exists } = require('../utils/fs')
|
|
16
17
|
const { resolve_agents, link_to_agents } = require('../agents')
|
|
@@ -201,6 +202,17 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
201
202
|
const [lock_err] = await write_lock(lock_dir, new_skills)
|
|
202
203
|
if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
|
|
203
204
|
|
|
205
|
+
// Post-write verification — convert wrote both skill.json and the lock,
|
|
206
|
+
// so confirm they agree before declaring success.
|
|
207
|
+
const [, verify_result] = await verify_lock_disk_consistency(updates[full_name], skill_dir)
|
|
208
|
+
if (verify_result && !verify_result.ok) {
|
|
209
|
+
pub_spinner.fail('Convert verification failed')
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Convert completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
|
|
212
|
+
`Re-run convert or fix skill.json manually before publishing.`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
// Link to detected agents (non-fatal — warnings only)
|
|
205
217
|
const linked_agents = []
|
|
206
218
|
const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
|
package/src/commands/diff.js
CHANGED
|
@@ -7,6 +7,7 @@ const { build_report } = require('../merge/report')
|
|
|
7
7
|
const { hash_blob } = require('../utils/git_hash')
|
|
8
8
|
const { unified_diff } = require('../utils/text_diff')
|
|
9
9
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
10
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
10
11
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
11
12
|
const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
|
|
12
13
|
const { red, green, cyan, bold } = require('../ui/colors')
|
|
@@ -248,6 +249,19 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
248
249
|
const base_dir = skills_dir(is_global, project_root)
|
|
249
250
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
250
251
|
|
|
252
|
+
// A drifted skill makes diff incoherent: the lock claims one version is
|
|
253
|
+
// installed, the disk has a different version, and `base_commit` points at
|
|
254
|
+
// the lock-claimed version. Diffing local-vs-base would show the entire
|
|
255
|
+
// disk content as "modified" — noise, not signal. Refuse with a clear
|
|
256
|
+
// remediation rather than show a misleading report.
|
|
257
|
+
const [, drift] = await verify_lock_disk_consistency(lock_entry, skill_dir)
|
|
258
|
+
if (drift && !drift.ok) {
|
|
259
|
+
throw new UsageError(
|
|
260
|
+
`${skill_name} has drift: lock ${drift.expected}, disk ${drift.actual || 'none'}. ` +
|
|
261
|
+
`Run ${code(`happyskills install ${skill_name} --fresh`)} to restore the install record before diffing.`
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
251
265
|
// Always need base files — keep full clone response for content diffs
|
|
252
266
|
const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
|
|
253
267
|
if (base_err) throw e('Failed to fetch base files', base_err)
|
package/src/commands/list.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
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
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
3
4
|
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
4
5
|
const { file_exists, read_json } = require('../utils/fs')
|
|
5
6
|
const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
|
|
6
7
|
const { AGENTS } = require('../agents/registry')
|
|
7
8
|
const { resolve_agents } = require('../agents/detector')
|
|
8
9
|
const { get_skills_enabled_map } = require('../agents/status')
|
|
9
|
-
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
10
|
-
const { green, yellow } = require('../ui/colors')
|
|
10
|
+
const { print_help, print_table, print_json, print_info, print_warn, print_hint, code } = require('../ui/output')
|
|
11
|
+
const { green, yellow, red } = require('../ui/colors')
|
|
11
12
|
const { exit_with_error } = require('../utils/errors')
|
|
12
13
|
const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
13
14
|
|
|
@@ -75,17 +76,30 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
75
76
|
return manifest?.type || SKILL_TYPES.SKILL
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Compute drift up-front for every managed entry. Cheap (one skill.json read
|
|
80
|
+
// per skill) and lets both the JSON and human paths report it identically.
|
|
81
|
+
const drift_by_skill = {}
|
|
82
|
+
await Promise.all(managed_entries.map(async ([name, data]) => {
|
|
83
|
+
const short = name.split('/')[1]
|
|
84
|
+
const dir = skill_install_dir(base_dir, short)
|
|
85
|
+
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
86
|
+
if (drift && !drift.ok) drift_by_skill[name] = drift
|
|
87
|
+
}))
|
|
88
|
+
|
|
78
89
|
if (args.flags.json) {
|
|
79
90
|
const skills_map = {}
|
|
80
91
|
for (const [name, data] of managed_entries) {
|
|
81
92
|
const short = name.split('/')[1]
|
|
82
93
|
const dir = skill_install_dir(base_dir, short)
|
|
83
94
|
const [, exists] = await file_exists(dir)
|
|
84
|
-
const
|
|
95
|
+
const drift = drift_by_skill[name]
|
|
96
|
+
const status = drift ? 'drift' : (exists ? 'installed' : 'missing')
|
|
85
97
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
86
98
|
const type = await resolve_type(name, data)
|
|
87
99
|
const enabled = enabled_map?.get(short) ?? true
|
|
88
|
-
|
|
100
|
+
const entry = { version: data.version, type, source, status, enabled }
|
|
101
|
+
if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
|
|
102
|
+
skills_map[name] = entry
|
|
89
103
|
}
|
|
90
104
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
91
105
|
const agent_orphan_list = orphan_skills.map(s => ({
|
|
@@ -102,13 +116,16 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
102
116
|
const short = name.split('/')[1]
|
|
103
117
|
const dir = skill_install_dir(base_dir, short)
|
|
104
118
|
const [, exists] = await file_exists(dir)
|
|
105
|
-
const
|
|
119
|
+
const drift = drift_by_skill[name]
|
|
120
|
+
const status_label = drift
|
|
121
|
+
? red(`drift (disk ${drift.actual || 'none'})`)
|
|
122
|
+
: (exists ? 'installed' : yellow('missing'))
|
|
106
123
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
107
124
|
const type = await resolve_type(name, data)
|
|
108
125
|
const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
|
|
109
126
|
const enabled = enabled_map?.get(short) ?? true
|
|
110
127
|
const enabled_label = enabled ? green('enabled') : yellow('disabled')
|
|
111
|
-
rows.push([display_name, data.version, source,
|
|
128
|
+
rows.push([display_name, data.version, source, status_label, enabled_label])
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
for (const s of external_skills) {
|
|
@@ -121,6 +138,13 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
121
138
|
}
|
|
122
139
|
|
|
123
140
|
print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
|
|
141
|
+
|
|
142
|
+
const drift_count = Object.keys(drift_by_skill).length
|
|
143
|
+
if (drift_count > 0) {
|
|
144
|
+
console.log()
|
|
145
|
+
print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
146
|
+
print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
|
|
147
|
+
}
|
|
124
148
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
125
149
|
|
|
126
150
|
module.exports = { run }
|
package/src/commands/publish.js
CHANGED
|
@@ -13,6 +13,7 @@ const { find_project_root } = require('../config/paths')
|
|
|
13
13
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
14
14
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
15
15
|
const { hash_directory } = require('../lock/integrity')
|
|
16
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
16
17
|
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
17
18
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
18
19
|
const { validate_cross } = require('../validation/cross_rules')
|
|
@@ -261,6 +262,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
261
262
|
|
|
262
263
|
// Update lock file: set base_commit and base_integrity to new values
|
|
263
264
|
const full_name = full_name_pre
|
|
265
|
+
let post_publish_entry = null
|
|
264
266
|
if (!lock_err && lock_data) {
|
|
265
267
|
const all_skills = get_all_locked_skills(lock_data)
|
|
266
268
|
const suffix = `/${skill_name}`
|
|
@@ -281,6 +283,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
281
283
|
delete updated_entry.conflict_files
|
|
282
284
|
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
283
285
|
await write_lock(project_root, updated_skills)
|
|
286
|
+
post_publish_entry = updated_entry
|
|
284
287
|
} else {
|
|
285
288
|
const new_entry = {
|
|
286
289
|
version: manifest.version,
|
|
@@ -294,6 +297,21 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
294
297
|
}
|
|
295
298
|
const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
|
|
296
299
|
await write_lock(project_root, updated_skills)
|
|
300
|
+
post_publish_entry = new_entry
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Post-write verification — confirm the lock entry now agrees with the
|
|
305
|
+
// disk skill.json (the version we just published). The push has already
|
|
306
|
+
// succeeded server-side, so a mismatch here is a structural bug we want
|
|
307
|
+
// to surface immediately rather than silently bake into the lock.
|
|
308
|
+
if (post_publish_entry) {
|
|
309
|
+
const [, verify_result] = await verify_lock_disk_consistency(post_publish_entry, dir)
|
|
310
|
+
if (verify_result && !verify_result.ok) {
|
|
311
|
+
print_warn(
|
|
312
|
+
`Publish succeeded but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
|
|
313
|
+
`Run ${code(`happyskills install ${full_name} --fresh`)} to resync.`
|
|
314
|
+
)
|
|
297
315
|
}
|
|
298
316
|
}
|
|
299
317
|
|
package/src/commands/pull.js
CHANGED
|
@@ -14,6 +14,7 @@ const { satisfies } = require('../utils/semver')
|
|
|
14
14
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
15
15
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
16
16
|
const { hash_directory } = require('../lock/integrity')
|
|
17
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
17
18
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
18
19
|
const { ensure_dir } = require('../utils/fs')
|
|
19
20
|
const { create_spinner } = require('../ui/spinner')
|
|
@@ -211,6 +212,17 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
211
212
|
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
212
213
|
if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
|
|
213
214
|
|
|
215
|
+
// Post-write verification — see installer.js for the rationale.
|
|
216
|
+
const [, ff_verify] = await verify_lock_disk_consistency(updated_entry, skill_dir)
|
|
217
|
+
if (ff_verify && !ff_verify.ok) {
|
|
218
|
+
spinner.fail(`Pull verification failed: ${skill_name}`)
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Pull of ${skill_name} completed but lock and disk disagree ` +
|
|
221
|
+
`(lock ${ff_verify.expected}, disk ${ff_verify.actual || 'none'}). ` +
|
|
222
|
+
`Re-run with happyskills install ${skill_name} --fresh to restore.`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
214
226
|
spinner.stop()
|
|
215
227
|
if (args.flags.json) {
|
|
216
228
|
print_json({ data: { status: 'fast_forward', skill: skill_name, version: cmp_data.head_version } })
|
|
@@ -451,11 +463,26 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
451
463
|
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
452
464
|
if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
|
|
453
465
|
|
|
466
|
+
// Post-merge verification — surface any disagreement between the new lock
|
|
467
|
+
// entry and the on-disk skill.json. A 3-way merge can legitimately resolve
|
|
468
|
+
// the version field to either side, so this is a warning, not a hard fail
|
|
469
|
+
// (the user already has conflict_files to review). For fast-forward see
|
|
470
|
+
// the matching block above which throws instead.
|
|
471
|
+
const [, merge_verify] = await verify_lock_disk_consistency(updated_entry, skill_dir)
|
|
472
|
+
const post_merge_drift = merge_verify && !merge_verify.ok ? merge_verify : null
|
|
473
|
+
|
|
454
474
|
spinner.stop()
|
|
455
475
|
|
|
456
476
|
if (args.flags.json) {
|
|
457
477
|
const status = conflict_files.length > 0 ? 'conflicts' : 'merged'
|
|
458
478
|
const output = { status, report, conflict_files, json_conflicts }
|
|
479
|
+
if (post_merge_drift) {
|
|
480
|
+
output.drift = {
|
|
481
|
+
reason: post_merge_drift.reason,
|
|
482
|
+
lock_version: post_merge_drift.expected,
|
|
483
|
+
disk_version: post_merge_drift.actual
|
|
484
|
+
}
|
|
485
|
+
}
|
|
459
486
|
if (full_report) output.resolution_steps = build_resolution_steps(report, json_conflicts)
|
|
460
487
|
print_json({ data: output })
|
|
461
488
|
} else {
|
|
@@ -484,6 +511,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
484
511
|
console.error(` - ${f}`)
|
|
485
512
|
}
|
|
486
513
|
}
|
|
514
|
+
if (post_merge_drift) {
|
|
515
|
+
print_warn(`Lock and on-disk skill.json disagree after merge: lock ${post_merge_drift.expected}, disk ${post_merge_drift.actual || 'none'}.`)
|
|
516
|
+
print_hint(`Update skill.json's version to match the lock, or run ${code(`happyskills install ${skill_name} --fresh`)} to re-sync.`)
|
|
517
|
+
}
|
|
487
518
|
}
|
|
488
519
|
|
|
489
520
|
// 7. Dependency reconciliation
|
package/src/commands/status.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
5
|
const repos_api = require('../api/repos')
|
|
5
|
-
const { print_help, print_info, print_json } = require('../ui/output')
|
|
6
|
+
const { print_help, print_info, print_json, print_warn, print_hint, code } = require('../ui/output')
|
|
6
7
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
7
8
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
8
9
|
const { EXIT_CODES } = require('../constants')
|
|
@@ -25,7 +26,11 @@ Examples:
|
|
|
25
26
|
happyskills st acme/deploy-aws
|
|
26
27
|
happyskills status --json`
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
// Drift outranks every other status: when the lock and the on-disk skill.json
|
|
30
|
+
// disagree about what's installed, every other comparison (modified/outdated/
|
|
31
|
+
// diverged/conflicts) is computed against an untrustworthy baseline.
|
|
32
|
+
const classify = (drift, local_modified, remote_updated, has_conflicts) => {
|
|
33
|
+
if (drift && !drift.ok) return 'drift'
|
|
29
34
|
if (has_conflicts) return 'conflicts'
|
|
30
35
|
if (local_modified && remote_updated) return 'diverged'
|
|
31
36
|
if (local_modified) return 'modified'
|
|
@@ -56,28 +61,33 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
56
61
|
const all_skills = get_all_locked_skills(lock_data)
|
|
57
62
|
const entries = target_skill
|
|
58
63
|
? [[target_skill, all_skills[target_skill]]]
|
|
59
|
-
: Object.entries(all_skills).filter(([, data]) => data
|
|
64
|
+
: Object.entries(all_skills).filter(([, data]) => data !== null)
|
|
60
65
|
|
|
61
66
|
if (entries.length === 0) {
|
|
62
67
|
if (args.flags.json) {
|
|
63
68
|
print_json({ data: { results: [] } })
|
|
64
69
|
return
|
|
65
70
|
}
|
|
66
|
-
print_info('No
|
|
71
|
+
print_info('No installed skills found.')
|
|
67
72
|
return
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
const base_dir = skills_dir(is_global, project_root)
|
|
71
76
|
|
|
72
|
-
// Detect
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
// Detect drift (lock-vs-disk version mismatch) and local modifications in parallel.
|
|
78
|
+
// The drift probe is cheap (one skill.json read + version compare); detect_status
|
|
79
|
+
// is the heavier integrity check. Running both lets us distinguish "user edited
|
|
80
|
+
// content" from "structural baseline broken".
|
|
81
|
+
const detections = await Promise.all(entries.map(async ([name, data]) => {
|
|
82
|
+
if (!data) return { name, data, det: null, drift: null }
|
|
75
83
|
const short_name = name.split('/')[1] || name
|
|
76
84
|
const dir = skill_install_dir(base_dir, short_name)
|
|
77
|
-
|
|
85
|
+
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
86
|
+
const [, det] = await detect_status(data, dir, { skill_name: name, project_root, is_global })
|
|
87
|
+
return { name, data, det, drift }
|
|
78
88
|
}))
|
|
79
89
|
|
|
80
|
-
const results = detections.map(({ name, data, det }) => {
|
|
90
|
+
const results = detections.map(({ name, data, det, drift }) => {
|
|
81
91
|
if (!data) return { skill: name, status: 'not_found', local_modified: false, remote_updated: false }
|
|
82
92
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
83
93
|
return {
|
|
@@ -91,7 +101,10 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
91
101
|
remote_version: null,
|
|
92
102
|
remote_commit: null,
|
|
93
103
|
conflict_files: data.conflict_files || [],
|
|
94
|
-
|
|
104
|
+
drift: drift && !drift.ok
|
|
105
|
+
? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
|
|
106
|
+
: null,
|
|
107
|
+
status: drift && !drift.ok ? 'drift' : (has_conflicts ? 'conflicts' : 'clean')
|
|
95
108
|
}
|
|
96
109
|
})
|
|
97
110
|
|
|
@@ -113,10 +126,12 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
|
|
116
|
-
// Classify each result
|
|
129
|
+
// Classify each result. The drift field is the canonical structural-baseline
|
|
130
|
+
// signal; pass it to classify so it can outrank everything else.
|
|
117
131
|
for (const r of results) {
|
|
118
132
|
if (r.status !== 'not_found') {
|
|
119
|
-
|
|
133
|
+
const drift_check = r.drift ? { ok: false } : { ok: true }
|
|
134
|
+
r.status = classify(drift_check, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
|
|
@@ -135,12 +150,18 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
135
150
|
skill: r.skill,
|
|
136
151
|
base: r.base_version || '?',
|
|
137
152
|
remote: r.remote_version || '?',
|
|
138
|
-
status: r.status === '
|
|
139
|
-
:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
status: r.status === 'drift' ? describe_drift({
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: r.drift?.reason,
|
|
156
|
+
expected: r.drift?.lock_version,
|
|
157
|
+
actual: r.drift?.disk_version
|
|
158
|
+
})
|
|
159
|
+
: r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
|
|
160
|
+
: r.status === 'diverged' ? 'diverged (local + remote changes)'
|
|
161
|
+
: r.status === 'modified' ? 'modified (local changes)'
|
|
162
|
+
: r.status === 'outdated' ? 'outdated (remote changes)'
|
|
163
|
+
: r.status === 'not_found' ? 'not found'
|
|
164
|
+
: 'clean'
|
|
144
165
|
}))
|
|
145
166
|
|
|
146
167
|
const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
|
|
@@ -153,6 +174,14 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
153
174
|
for (const r of rows) {
|
|
154
175
|
console.log(`${pad(r.skill, w_skill)} ${pad(r.base, w_base)} ${pad(r.remote, w_remote)} ${r.status}`)
|
|
155
176
|
}
|
|
177
|
+
|
|
178
|
+
const drifted = results.filter(r => r.status === 'drift')
|
|
179
|
+
if (drifted.length > 0) {
|
|
180
|
+
console.log()
|
|
181
|
+
print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
182
|
+
print_hint(`Restore install record: ${code('happyskills install <skill> --fresh')}`)
|
|
183
|
+
print_hint(`Or accept the on-disk version: bump the lock by reinstalling at the disk version.`)
|
|
184
|
+
}
|
|
156
185
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
157
186
|
|
|
158
187
|
module.exports = { run }
|
package/src/commands/update.js
CHANGED
|
@@ -3,6 +3,7 @@ const { install } = require('../engine/installer')
|
|
|
3
3
|
const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
|
|
4
4
|
const { read_manifest } = require('../manifest/reader')
|
|
5
5
|
const { detect_status } = require('../merge/detector')
|
|
6
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
6
7
|
const repos_api = require('../api/repos')
|
|
7
8
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
8
9
|
const { green, yellow, red } = require('../ui/colors')
|
|
@@ -140,18 +141,27 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
140
141
|
} else {
|
|
141
142
|
spinner?.succeed('Checked for updates')
|
|
142
143
|
|
|
143
|
-
// Read installed manifests in parallel
|
|
144
|
+
// Read installed manifests + drift in parallel. Drift detection here
|
|
145
|
+
// matches the read-side surfacing in status/check — update should not
|
|
146
|
+
// silently treat a drifted skill as "up-to-date" or auto-overwrite it.
|
|
144
147
|
const manifest_map = {}
|
|
145
|
-
|
|
148
|
+
const drift_by_skill = {}
|
|
149
|
+
await Promise.all(candidates.map(async ([name, data]) => {
|
|
146
150
|
const short_name = name.split('/')[1] || name
|
|
147
151
|
const dir = skill_install_dir(base_dir, short_name)
|
|
148
152
|
const [, manifest] = await read_manifest(dir)
|
|
149
153
|
if (manifest) manifest_map[name] = manifest
|
|
154
|
+
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
155
|
+
if (drift && !drift.ok) drift_by_skill[name] = drift
|
|
150
156
|
}))
|
|
151
157
|
|
|
152
158
|
for (const [name, data] of candidates) {
|
|
153
159
|
const info = batch_data?.results?.[name]
|
|
154
|
-
|
|
160
|
+
const drift = drift_by_skill[name]
|
|
161
|
+
if (drift) {
|
|
162
|
+
// Drift outranks every other status — fix the baseline before updating.
|
|
163
|
+
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
|
|
164
|
+
} else if (info?.access_denied) {
|
|
155
165
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
156
166
|
} else if (!info || !info.latest_version) {
|
|
157
167
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
@@ -171,6 +181,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
171
181
|
|
|
172
182
|
const outdated = results.filter(r => r.status === 'outdated')
|
|
173
183
|
const up_to_date = results.filter(r => r.status === 'up-to-date')
|
|
184
|
+
const drifted = results.filter(r => r.status === 'drift')
|
|
174
185
|
|
|
175
186
|
// Verify and repair symlinks (even when nothing is outdated)
|
|
176
187
|
const [, agents_data] = await resolve_agents(args.flags.agents)
|
|
@@ -202,14 +213,20 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
202
213
|
// If nothing to update, report and exit
|
|
203
214
|
if (outdated.length === 0) {
|
|
204
215
|
if (args.flags.json) {
|
|
205
|
-
print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
|
|
216
|
+
print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, drift_count: drifted.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
|
|
206
217
|
return
|
|
207
218
|
}
|
|
208
219
|
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
209
|
-
r.skill, r.installed, r.latest, green(r.status)
|
|
220
|
+
r.skill, r.installed, r.latest, r.status === 'drift' ? red('drift') : green(r.status)
|
|
210
221
|
]))
|
|
211
222
|
console.log()
|
|
212
|
-
if (
|
|
223
|
+
if (drifted.length > 0) {
|
|
224
|
+
print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
225
|
+
for (const r of drifted) {
|
|
226
|
+
console.error(` - ${r.skill} (lock ${r.drift?.lock_version}, disk ${r.drift?.disk_version || 'none'})`)
|
|
227
|
+
}
|
|
228
|
+
print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} before updating.`)
|
|
229
|
+
} else if (symlink_repairs.length > 0) {
|
|
213
230
|
print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
|
|
214
231
|
} else {
|
|
215
232
|
print_success('All skills are up to date.')
|
|
@@ -219,11 +236,18 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
219
236
|
|
|
220
237
|
// Show results table (non-json)
|
|
221
238
|
if (!args.flags.json) {
|
|
222
|
-
const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
|
|
239
|
+
const status_colors = { 'up-to-date': green, 'outdated': yellow, 'drift': red, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
|
|
223
240
|
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
224
241
|
r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
|
|
225
242
|
]))
|
|
226
243
|
console.log()
|
|
244
|
+
if (drifted.length > 0) {
|
|
245
|
+
print_warn(`${drifted.length} skill(s) drifted — these will be skipped:`)
|
|
246
|
+
for (const r of drifted) {
|
|
247
|
+
console.error(` - ${r.skill} (lock ${r.drift?.lock_version}, disk ${r.drift?.disk_version || 'none'})`)
|
|
248
|
+
}
|
|
249
|
+
print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} to update them.`)
|
|
250
|
+
}
|
|
227
251
|
print_info(`${outdated.length} skill(s) can be updated.`)
|
|
228
252
|
}
|
|
229
253
|
|
|
@@ -281,6 +305,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
281
305
|
results,
|
|
282
306
|
outdated_count: outdated.length,
|
|
283
307
|
up_to_date_count: up_to_date.length,
|
|
308
|
+
drift_count: drifted.length,
|
|
284
309
|
updated,
|
|
285
310
|
skipped,
|
|
286
311
|
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
package/src/engine/installer.js
CHANGED
|
@@ -8,6 +8,7 @@ const { check_system_dependencies } = require('./system_deps')
|
|
|
8
8
|
const { hash_directory, verify_integrity } = require('../lock/integrity')
|
|
9
9
|
const { read_lock, get_locked_skill } = require('../lock/reader')
|
|
10
10
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
11
|
+
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
11
12
|
const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
12
13
|
const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
|
|
13
14
|
const { SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
@@ -256,6 +257,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
256
257
|
const [lock_errors] = await write_lock(lock_dir, new_skills)
|
|
257
258
|
if (lock_errors) { spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_errors) }
|
|
258
259
|
|
|
260
|
+
// Post-write verification: confirm every newly-written lock entry agrees
|
|
261
|
+
// with the on-disk skill.json. Catches any drift the install pipeline
|
|
262
|
+
// would have created (interrupted rename, resolver-vs-disk version skew,
|
|
263
|
+
// extractor regression, etc.) before declaring success.
|
|
264
|
+
for (const { pkg } of downloaded) {
|
|
265
|
+
const name = pkg.skill.split('/')[1]
|
|
266
|
+
const final_dir = skill_install_dir(base_dir, name)
|
|
267
|
+
const [, verify_result] = await verify_lock_disk_consistency(updates[pkg.skill], final_dir)
|
|
268
|
+
if (verify_result && !verify_result.ok) {
|
|
269
|
+
spinner.fail(`Install verification failed: ${pkg.skill}`)
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Install of ${pkg.skill} completed but lock and disk disagree ` +
|
|
272
|
+
`(lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
|
|
273
|
+
`Re-run with --fresh to retry.`
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
259
278
|
spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
|
|
260
279
|
|
|
261
280
|
const linked_count = downloaded.length - disabled_skills.size
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for the lock-vs-disk drift bug class.
|
|
4
|
+
*
|
|
5
|
+
* Background: an interrupted `update`/`install`, a manual edit to skill.json,
|
|
6
|
+
* or a partial refresh can leave the lock entry's `version` field disagreeing
|
|
7
|
+
* with the on-disk skill.json's `version`. Before this fix:
|
|
8
|
+
* - `check` reported `up-to-date` (it never read the disk)
|
|
9
|
+
* - `status` reported `modified (local changes)` (it noticed the integrity
|
|
10
|
+
* hash differed but mislabeled the cause as user edits)
|
|
11
|
+
* - `list` reported the lock's version as the installed version (lying)
|
|
12
|
+
* - `diff` used the lock's `base_commit` as a comparison baseline that no
|
|
13
|
+
* longer matched what was on disk (incoherent diff)
|
|
14
|
+
* - `update` could decide a drifted skill was up-to-date and skip it
|
|
15
|
+
*
|
|
16
|
+
* These tests reconstruct the exact linwong scenario that surfaced the bug
|
|
17
|
+
* (lock 0.4.0, disk 0.3.0) and assert every drift-aware command surfaces it
|
|
18
|
+
* via the structured `drift` field. If any of these break, the bug class is
|
|
19
|
+
* back.
|
|
20
|
+
*/
|
|
21
|
+
const { describe, it } = require('node:test')
|
|
22
|
+
const assert = require('node:assert/strict')
|
|
23
|
+
const { spawnSync } = require('child_process')
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
|
|
28
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
29
|
+
const NODE = process.execPath
|
|
30
|
+
|
|
31
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'happyskills-drift-test-'))
|
|
32
|
+
|
|
33
|
+
const run = (args, opts) => {
|
|
34
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
NO_COLOR: '1',
|
|
38
|
+
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
39
|
+
...(opts?.env || {})
|
|
40
|
+
},
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
timeout: 10000,
|
|
43
|
+
cwd: opts?.cwd
|
|
44
|
+
})
|
|
45
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parse_json = (stdout, label) => {
|
|
49
|
+
try { return JSON.parse(stdout) }
|
|
50
|
+
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Scaffold a project with an explicitly drifted skill: lock entry says one
|
|
55
|
+
* version, on-disk skill.json says another. Mirrors the linwong bug exactly.
|
|
56
|
+
*
|
|
57
|
+
* @param {Array<{full, short, lock_version, disk_version, integrity?}>} skills
|
|
58
|
+
*/
|
|
59
|
+
const scaffold_drifted = (skills) => {
|
|
60
|
+
const root = make_tmp()
|
|
61
|
+
const lock_skills = {}
|
|
62
|
+
|
|
63
|
+
for (const s of skills) {
|
|
64
|
+
const skill_dir = path.join(root, '.agents', 'skills', s.short)
|
|
65
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
68
|
+
`---\nname: ${s.short}\ndescription: test skill for drift regression\n---\nTest body\n`
|
|
69
|
+
)
|
|
70
|
+
// disk_version is what skill.json claims — may differ from lock
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
path.join(skill_dir, 'skill.json'),
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
name: s.short,
|
|
75
|
+
version: s.disk_version,
|
|
76
|
+
type: 'skill',
|
|
77
|
+
description: 'test skill for drift regression'
|
|
78
|
+
}, null, '\t')
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
lock_skills[s.full] = {
|
|
82
|
+
version: s.lock_version,
|
|
83
|
+
type: 'skill',
|
|
84
|
+
ref: `refs/tags/v${s.lock_version}`,
|
|
85
|
+
commit: 'lockcommit',
|
|
86
|
+
integrity: s.integrity || null,
|
|
87
|
+
base_commit: 'lockcommit',
|
|
88
|
+
base_integrity: s.integrity || null,
|
|
89
|
+
requested_by: ['__root__'],
|
|
90
|
+
dependencies: {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
95
|
+
lockVersion: 2,
|
|
96
|
+
generatedAt: new Date().toISOString(),
|
|
97
|
+
skills: lock_skills
|
|
98
|
+
}, null, '\t'))
|
|
99
|
+
|
|
100
|
+
return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── status ───────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('status — drift detection', () => {
|
|
106
|
+
it('reports drift when lock version differs from disk skill.json version (the linwong bug)', () => {
|
|
107
|
+
const { root, cleanup } = scaffold_drifted([
|
|
108
|
+
{ full: 'happyskillsai/happyskills-design', short: 'happyskills-design', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
109
|
+
])
|
|
110
|
+
try {
|
|
111
|
+
const { code, stdout } = run(['status', '--json'], { cwd: root })
|
|
112
|
+
assert.strictEqual(code, 0, 'status should exit 0')
|
|
113
|
+
const out = parse_json(stdout, 'status --json')
|
|
114
|
+
const result = out.data.results.find(r => r.skill === 'happyskillsai/happyskills-design')
|
|
115
|
+
assert.ok(result, 'result for the drifted skill must be present')
|
|
116
|
+
assert.strictEqual(result.status, 'drift', 'status must be reported as drift, not modified or clean')
|
|
117
|
+
assert.deepEqual(result.drift, {
|
|
118
|
+
reason: 'version_mismatch',
|
|
119
|
+
lock_version: '0.4.0',
|
|
120
|
+
disk_version: '0.3.0'
|
|
121
|
+
})
|
|
122
|
+
} finally { cleanup() }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('drift outranks modified — does not mislabel structural drift as local edits', () => {
|
|
126
|
+
// Critical regression: previously reported as "modified (local changes)"
|
|
127
|
+
const { root, cleanup } = scaffold_drifted([
|
|
128
|
+
{ full: 'acme/foo', short: 'foo', lock_version: '2.0.0', disk_version: '1.0.0' }
|
|
129
|
+
])
|
|
130
|
+
try {
|
|
131
|
+
const { code, stdout } = run(['status', '--json'], { cwd: root })
|
|
132
|
+
assert.strictEqual(code, 0)
|
|
133
|
+
const out = parse_json(stdout, 'status --json')
|
|
134
|
+
const result = out.data.results[0]
|
|
135
|
+
assert.strictEqual(result.status, 'drift', 'must be drift, not modified')
|
|
136
|
+
assert.notStrictEqual(result.status, 'modified', 'modified would mislead — this is structural drift, not user edits')
|
|
137
|
+
} finally { cleanup() }
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('reports clean when lock and disk agree', () => {
|
|
141
|
+
const { root, cleanup } = scaffold_drifted([
|
|
142
|
+
{ full: 'acme/agree', short: 'agree', lock_version: '1.0.0', disk_version: '1.0.0' }
|
|
143
|
+
])
|
|
144
|
+
try {
|
|
145
|
+
const { code, stdout } = run(['status', '--json'], { cwd: root })
|
|
146
|
+
assert.strictEqual(code, 0)
|
|
147
|
+
const out = parse_json(stdout, 'status --json')
|
|
148
|
+
const result = out.data.results[0]
|
|
149
|
+
assert.strictEqual(result.drift, null)
|
|
150
|
+
assert.notStrictEqual(result.status, 'drift')
|
|
151
|
+
} finally { cleanup() }
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('detects missing_skill_json drift when skill.json is gone', () => {
|
|
155
|
+
const { root, cleanup } = scaffold_drifted([
|
|
156
|
+
{ full: 'acme/headless', short: 'headless', lock_version: '1.0.0', disk_version: '1.0.0' }
|
|
157
|
+
])
|
|
158
|
+
try {
|
|
159
|
+
fs.unlinkSync(path.join(root, '.agents', 'skills', 'headless', 'skill.json'))
|
|
160
|
+
const { code, stdout } = run(['status', '--json'], { cwd: root })
|
|
161
|
+
assert.strictEqual(code, 0)
|
|
162
|
+
const out = parse_json(stdout, 'status --json')
|
|
163
|
+
const result = out.data.results[0]
|
|
164
|
+
assert.strictEqual(result.status, 'drift')
|
|
165
|
+
assert.strictEqual(result.drift.reason, 'missing_skill_json')
|
|
166
|
+
} finally { cleanup() }
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('human output includes the drift cell with both versions', () => {
|
|
170
|
+
const { root, cleanup } = scaffold_drifted([
|
|
171
|
+
{ full: 'acme/show', short: 'show', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
172
|
+
])
|
|
173
|
+
try {
|
|
174
|
+
const { code, stdout } = run(['status'], { cwd: root })
|
|
175
|
+
assert.strictEqual(code, 0)
|
|
176
|
+
assert.ok(stdout.includes('drift'), 'status table must contain "drift"')
|
|
177
|
+
assert.ok(stdout.includes('0.4.0'), 'must show lock version')
|
|
178
|
+
assert.ok(stdout.includes('0.3.0'), 'must show disk version')
|
|
179
|
+
} finally { cleanup() }
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ─── check ────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('check — drift detection', () => {
|
|
186
|
+
it('reports drift even when the registry API is unreachable', () => {
|
|
187
|
+
// HAPPYSKILLS_API_URL=http://localhost:0 makes the API call fail.
|
|
188
|
+
// Drift detection is purely local — it must still surface.
|
|
189
|
+
const { root, cleanup } = scaffold_drifted([
|
|
190
|
+
{ full: 'acme/local-only', short: 'local-only', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
191
|
+
])
|
|
192
|
+
try {
|
|
193
|
+
const { code, stdout } = run(['check', '--json'], { cwd: root })
|
|
194
|
+
assert.strictEqual(code, 0)
|
|
195
|
+
const out = parse_json(stdout, 'check --json')
|
|
196
|
+
const result = out.data.results.find(r => r.skill === 'acme/local-only')
|
|
197
|
+
assert.ok(result, 'result must be present even with API down')
|
|
198
|
+
assert.strictEqual(result.status, 'drift')
|
|
199
|
+
assert.deepEqual(result.drift, {
|
|
200
|
+
reason: 'version_mismatch',
|
|
201
|
+
lock_version: '0.4.0',
|
|
202
|
+
disk_version: '0.3.0'
|
|
203
|
+
})
|
|
204
|
+
} finally { cleanup() }
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('drift_count appears in the JSON summary', () => {
|
|
208
|
+
const { root, cleanup } = scaffold_drifted([
|
|
209
|
+
{ full: 'acme/one', short: 'one', lock_version: '1.0.0', disk_version: '0.9.0' },
|
|
210
|
+
{ full: 'acme/two', short: 'two', lock_version: '2.0.0', disk_version: '1.0.0' }
|
|
211
|
+
])
|
|
212
|
+
try {
|
|
213
|
+
const { code, stdout } = run(['check', '--json'], { cwd: root })
|
|
214
|
+
assert.strictEqual(code, 0)
|
|
215
|
+
const out = parse_json(stdout, 'check --json')
|
|
216
|
+
assert.strictEqual(out.data.drift_count, 2)
|
|
217
|
+
} finally { cleanup() }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('drift overrides up-to-date — drift cannot be silently masked', () => {
|
|
221
|
+
const { root, cleanup } = scaffold_drifted([
|
|
222
|
+
{ full: 'acme/silent', short: 'silent', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
223
|
+
])
|
|
224
|
+
try {
|
|
225
|
+
const { code, stdout } = run(['check', '--json'], { cwd: root })
|
|
226
|
+
assert.strictEqual(code, 0)
|
|
227
|
+
const out = parse_json(stdout, 'check --json')
|
|
228
|
+
const result = out.data.results[0]
|
|
229
|
+
assert.notStrictEqual(result.status, 'up-to-date', 'must not silently report up-to-date when drifted')
|
|
230
|
+
assert.strictEqual(result.status, 'drift')
|
|
231
|
+
} finally { cleanup() }
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ─── list ─────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe('list — drift surfacing', () => {
|
|
238
|
+
it('list --json marks drifted skills with status "drift" and a drift object', () => {
|
|
239
|
+
const { root, cleanup } = scaffold_drifted([
|
|
240
|
+
{ full: 'acme/drifted', short: 'drifted', lock_version: '0.4.0', disk_version: '0.3.0' },
|
|
241
|
+
{ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' }
|
|
242
|
+
])
|
|
243
|
+
try {
|
|
244
|
+
const { code, stdout } = run(['list', '--json'], { cwd: root })
|
|
245
|
+
assert.strictEqual(code, 0)
|
|
246
|
+
const out = parse_json(stdout, 'list --json')
|
|
247
|
+
assert.strictEqual(out.data.skills['acme/drifted'].status, 'drift')
|
|
248
|
+
assert.deepEqual(out.data.skills['acme/drifted'].drift, {
|
|
249
|
+
reason: 'version_mismatch',
|
|
250
|
+
lock_version: '0.4.0',
|
|
251
|
+
disk_version: '0.3.0'
|
|
252
|
+
})
|
|
253
|
+
assert.strictEqual(out.data.skills['acme/clean'].status, 'installed')
|
|
254
|
+
assert.strictEqual(out.data.skills['acme/clean'].drift, undefined)
|
|
255
|
+
} finally { cleanup() }
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ─── diff ─────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('diff — drift refusal', () => {
|
|
262
|
+
it('diff refuses with a clear UsageError when the skill is drifted', () => {
|
|
263
|
+
// diff against an inconsistent baseline produces noise, not signal —
|
|
264
|
+
// refusing is the principal-friendly choice.
|
|
265
|
+
const { root, cleanup } = scaffold_drifted([
|
|
266
|
+
{ full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
267
|
+
])
|
|
268
|
+
try {
|
|
269
|
+
const { code, stderr } = run(['diff', 'acme/cant-diff'], { cwd: root })
|
|
270
|
+
assert.strictEqual(code, 2, 'should exit with usage error code')
|
|
271
|
+
assert.ok(stderr.toLowerCase().includes('drift'), 'error must mention drift')
|
|
272
|
+
assert.ok(stderr.includes('0.4.0'), 'should mention lock version')
|
|
273
|
+
assert.ok(stderr.includes('0.3.0'), 'should mention disk version')
|
|
274
|
+
} finally { cleanup() }
|
|
275
|
+
})
|
|
276
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { read_json, file_exists } = require('../utils/fs')
|
|
4
|
+
const { SKILL_JSON } = require('../constants')
|
|
5
|
+
|
|
6
|
+
// Cheapest possible drift probe: read the on-disk skill.json and compare its
|
|
7
|
+
// version field to the lock entry's version. Catches the most common class of
|
|
8
|
+
// lock/disk drift (interrupted install, manual file edit, partial update)
|
|
9
|
+
// without paying for a full directory hash.
|
|
10
|
+
//
|
|
11
|
+
// Result shapes:
|
|
12
|
+
// { ok: true }
|
|
13
|
+
// { ok: false, reason: 'missing_dir', expected, actual: null }
|
|
14
|
+
// { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
15
|
+
// { ok: false, reason: 'version_mismatch', expected, actual }
|
|
16
|
+
const verify_lock_disk_consistency = (lock_entry, install_dir) => catch_errors('Failed to verify lock/disk consistency', async () => {
|
|
17
|
+
const expected = lock_entry?.version || null
|
|
18
|
+
|
|
19
|
+
// Nothing in the lock to check against — caller has nothing to verify.
|
|
20
|
+
if (!expected) return { ok: true }
|
|
21
|
+
|
|
22
|
+
const [, dir_exists] = await file_exists(install_dir)
|
|
23
|
+
if (!dir_exists) return { ok: false, reason: 'missing_dir', expected, actual: null }
|
|
24
|
+
|
|
25
|
+
const manifest_path = path.join(install_dir, SKILL_JSON)
|
|
26
|
+
const [, manifest_exists] = await file_exists(manifest_path)
|
|
27
|
+
if (!manifest_exists) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
28
|
+
|
|
29
|
+
const [read_err, manifest] = await read_json(manifest_path)
|
|
30
|
+
if (read_err) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
|
|
31
|
+
|
|
32
|
+
const actual = manifest?.version || null
|
|
33
|
+
if (actual !== expected) return { ok: false, reason: 'version_mismatch', expected, actual }
|
|
34
|
+
|
|
35
|
+
return { ok: true }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Plain-language description of a drift result, suitable for the principal-facing
|
|
39
|
+
// table cell (status column). Returns null when ok.
|
|
40
|
+
const describe_drift = (verify_result) => {
|
|
41
|
+
if (!verify_result || verify_result.ok) return null
|
|
42
|
+
if (verify_result.reason === 'missing_dir') return `drift (lock ${verify_result.expected}, skill not on disk)`
|
|
43
|
+
if (verify_result.reason === 'missing_skill_json') return `drift (lock ${verify_result.expected}, no skill.json on disk)`
|
|
44
|
+
if (verify_result.reason === 'version_mismatch') return `drift (lock ${verify_result.expected}, disk ${verify_result.actual})`
|
|
45
|
+
return 'drift'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { verify_lock_disk_consistency, describe_drift }
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
|
|
8
|
+
const { verify_lock_disk_consistency, describe_drift } = require('./verify')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
const write_skill_json = (dir, manifest) => {
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
15
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('verify_lock_disk_consistency', () => {
|
|
19
|
+
it('returns ok when lock version matches disk version', async () => {
|
|
20
|
+
const dir = make_tmp()
|
|
21
|
+
try {
|
|
22
|
+
const install_dir = path.join(dir, 'my-skill')
|
|
23
|
+
write_skill_json(install_dir, { name: 'my-skill', version: '1.2.0' })
|
|
24
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '1.2.0' }, install_dir)
|
|
25
|
+
assert.strictEqual(err, null)
|
|
26
|
+
assert.deepEqual(result, { ok: true })
|
|
27
|
+
} finally { cleanup(dir) }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('detects version_mismatch when disk skill.json has a different version (the linwong bug)', async () => {
|
|
31
|
+
const dir = make_tmp()
|
|
32
|
+
try {
|
|
33
|
+
const install_dir = path.join(dir, 'happyskills-design')
|
|
34
|
+
write_skill_json(install_dir, { name: 'happyskills-design', version: '0.3.0' })
|
|
35
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '0.4.0' }, install_dir)
|
|
36
|
+
assert.strictEqual(err, null)
|
|
37
|
+
assert.deepEqual(result, { ok: false, reason: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' })
|
|
38
|
+
} finally { cleanup(dir) }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('detects missing_dir when the install directory does not exist', async () => {
|
|
42
|
+
const dir = make_tmp()
|
|
43
|
+
try {
|
|
44
|
+
const install_dir = path.join(dir, 'never-installed')
|
|
45
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
|
|
46
|
+
assert.strictEqual(err, null)
|
|
47
|
+
assert.deepEqual(result, { ok: false, reason: 'missing_dir', expected: '1.0.0', actual: null })
|
|
48
|
+
} finally { cleanup(dir) }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('detects missing_skill_json when the directory exists but skill.json is gone', async () => {
|
|
52
|
+
const dir = make_tmp()
|
|
53
|
+
try {
|
|
54
|
+
const install_dir = path.join(dir, 'broken-skill')
|
|
55
|
+
fs.mkdirSync(install_dir)
|
|
56
|
+
fs.writeFileSync(path.join(install_dir, 'SKILL.md'), '# stub')
|
|
57
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
|
|
58
|
+
assert.strictEqual(err, null)
|
|
59
|
+
assert.deepEqual(result, { ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null })
|
|
60
|
+
} finally { cleanup(dir) }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('detects missing_skill_json when skill.json is unreadable JSON', async () => {
|
|
64
|
+
const dir = make_tmp()
|
|
65
|
+
try {
|
|
66
|
+
const install_dir = path.join(dir, 'corrupt-skill')
|
|
67
|
+
fs.mkdirSync(install_dir)
|
|
68
|
+
fs.writeFileSync(path.join(install_dir, 'skill.json'), '{ this is not json')
|
|
69
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
|
|
70
|
+
assert.strictEqual(err, null)
|
|
71
|
+
assert.deepEqual(result, { ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null })
|
|
72
|
+
} finally { cleanup(dir) }
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('detects version_mismatch when disk skill.json has no version field', async () => {
|
|
76
|
+
const dir = make_tmp()
|
|
77
|
+
try {
|
|
78
|
+
const install_dir = path.join(dir, 'no-version')
|
|
79
|
+
write_skill_json(install_dir, { name: 'no-version' })
|
|
80
|
+
const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
|
|
81
|
+
assert.strictEqual(err, null)
|
|
82
|
+
assert.deepEqual(result, { ok: false, reason: 'version_mismatch', expected: '1.0.0', actual: null })
|
|
83
|
+
} finally { cleanup(dir) }
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns ok when lock entry has no version (nothing to verify against)', async () => {
|
|
87
|
+
const dir = make_tmp()
|
|
88
|
+
try {
|
|
89
|
+
const install_dir = path.join(dir, 'unversioned')
|
|
90
|
+
const [err, result] = await verify_lock_disk_consistency({}, install_dir)
|
|
91
|
+
assert.strictEqual(err, null)
|
|
92
|
+
assert.deepEqual(result, { ok: true })
|
|
93
|
+
} finally { cleanup(dir) }
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns ok when lock entry is null (nothing to verify)', async () => {
|
|
97
|
+
const dir = make_tmp()
|
|
98
|
+
try {
|
|
99
|
+
const install_dir = path.join(dir, 'no-lock')
|
|
100
|
+
const [err, result] = await verify_lock_disk_consistency(null, install_dir)
|
|
101
|
+
assert.strictEqual(err, null)
|
|
102
|
+
assert.deepEqual(result, { ok: true })
|
|
103
|
+
} finally { cleanup(dir) }
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('describe_drift', () => {
|
|
108
|
+
it('returns null for ok results', () => {
|
|
109
|
+
assert.strictEqual(describe_drift({ ok: true }), null)
|
|
110
|
+
assert.strictEqual(describe_drift(null), null)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('describes version_mismatch with both versions', () => {
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
describe_drift({ ok: false, reason: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' }),
|
|
116
|
+
'drift (lock 0.4.0, disk 0.3.0)'
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('describes missing_dir', () => {
|
|
121
|
+
assert.strictEqual(
|
|
122
|
+
describe_drift({ ok: false, reason: 'missing_dir', expected: '1.0.0', actual: null }),
|
|
123
|
+
'drift (lock 1.0.0, skill not on disk)'
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('describes missing_skill_json', () => {
|
|
128
|
+
assert.strictEqual(
|
|
129
|
+
describe_drift({ ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null }),
|
|
130
|
+
'drift (lock 1.0.0, no skill.json on disk)'
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('falls back to bare drift for unknown reasons', () => {
|
|
135
|
+
assert.strictEqual(describe_drift({ ok: false, reason: 'something_new' }), 'drift')
|
|
136
|
+
})
|
|
137
|
+
})
|