happyskills 0.44.0 → 0.45.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 +12 -0
- package/package.json +1 -1
- package/src/api/repos.js +43 -0
- package/src/api/repos.test.js +171 -0
- package/src/commands/diff.js +15 -11
- package/src/commands/status.js +21 -3
- package/src/integration/drift.test.js +32 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.45.0] - 2026-05-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `diff`, `pull`, `install`, and every other command that calls `repos_api.clone(owner, repo, ref, { commit })` now refuses any response whose top-level `commit` field does not match the requested commit. The pre-fix CLI silently trusted whatever the registry returned, which produced false-positive `local_only_modified` diffs (and would have produced silent base-content corruption on `pull` and `install`) whenever a CDN cache key collision served one commit's response for a different commit's request. The check lives at the single `clone()` chokepoint in `cli/src/api/repos.js` so all 8 call sites are covered automatically — no per-command opt-in. The error message names the requested vs returned commit, attributes the most likely cause (stale CDN cache or upstream cache-key misconfig), and tells the user to retry. Companion API fix shipped in `happyskills-api@2.11.5` closes the root cause by adding `commit` and `format` to the CloudFront cache-key whitelist for `/repos/*/clone`; this CLI check stays as defense in depth so the next cache/MITM/origin bug surfaces as a hard error instead of a quiet false positive. Reproduction: with the broken cache, `happyskills diff happyskillsai/happyskills-design` against linwong's `e65040…` returned a tree pinned to `6533f944…` — a different commit than the lock asked for; the new check stops on that exact mismatch.
|
|
14
|
+
- `repos_api.clone()` and `repos_api.get_blob()` additionally verify per-file content integrity: every file's bytes must hash to the SHA the server advertises (git-blob SHA-256 via `hash_blob` in `cli/src/utils/git_hash.js`). Catches the second failure mode where SHA and content are mismatched within a single response (MITM corruption, partial transfers, origin bugs that produce inconsistent pairs). Skipped cleanly for `format=archive` responses (no inline content) and for metadata-only entries that omit `content`. Test coverage in `cli/src/api/repos.test.js` (9 tests across commit-pin, content-pin, archive passthrough, and ref-based clone).
|
|
15
|
+
- `status <bare-skill-name>` now resolves the bare name to its locked `owner/skill` key by suffix match — same disambiguation that `validate`, `bump`, and `resolve_skill_owner` already use. Previously, running `happyskills status happyskills-design` (without the `happyskillsai/` prefix) returned `status: not_found` because `all_skills[target_skill]` is a direct lookup and the lock keys are fully qualified. `diff` still requires owner/name explicitly because its API path depends on the workspace; `status` reads only the lock and can always resolve unambiguously.
|
|
16
|
+
|
|
17
|
+
## [0.44.1] - 2026-05-13
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `diff` no longer hard-blocks when the target skill has lock-vs-disk drift. Previously, running `happyskills diff <skill>` against a drifted skill threw a `UsageError` telling the user to run `happyskills install <skill> --fresh` before diffing — but `--fresh` overwrites the on-disk content, destroying the very local state the user was trying to inspect. Drift is exactly the case where diff is most useful as a diagnostic tool. The command now prints a one-line warning (`<skill> has drift: lock <X>, disk <Y>. Diff is shown against the lock-recorded base (<X>).`) and proceeds with the diff. JSON output gains a top-level `drift` field (same `{ reason, lock_version, disk_version }` shape used elsewhere) in `local` and `full` modes, or `null` when clean. `--remote` mode skips the drift probe entirely since it reads nothing from disk. Other drift-aware commands are unchanged — `status`/`check`/`list` still surface drift in their reports, `update` still skips drifted skills to avoid clobbering local work, and the post-write self-checks in `install`/`pull`/`bump`/`publish`/`convert` still throw on inconsistency.
|
|
21
|
+
|
|
10
22
|
## [0.44.0] - 2026-05-12
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
const { error: { catch_errors } } = require('puffy-core')
|
|
2
2
|
const client = require('./client')
|
|
3
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
4
|
+
|
|
5
|
+
// Verify a clone response against the request that produced it. Two checks:
|
|
6
|
+
//
|
|
7
|
+
// 1. Commit pin (the structural one): when the caller passed `options.commit`,
|
|
8
|
+
// the response's top-level `commit` field MUST equal that value. If the CDN
|
|
9
|
+
// cache key drops the `commit` query param (or any future proxy/cache does
|
|
10
|
+
// the same), one commit's response can be returned for a different commit's
|
|
11
|
+
// request — internally self-consistent, but pinned to the wrong tree.
|
|
12
|
+
// This is exactly how the May 2026 `clone_cache_policy` whitelist bug
|
|
13
|
+
// surfaced as false-positive `local_only_modified` diffs.
|
|
14
|
+
//
|
|
15
|
+
// 2. Per-file content address (defense in depth): every file's bytes must
|
|
16
|
+
// hash to the SHA the server advertises. Catches MITM corruption, partial
|
|
17
|
+
// responses, and origin bugs that produce mismatched (sha, content) pairs.
|
|
18
|
+
//
|
|
19
|
+
// Returns an error message string on failure, or null when the response is
|
|
20
|
+
// trustworthy. Caller throws.
|
|
21
|
+
const verify_clone_integrity = (data, owner, repo, requested_commit) => {
|
|
22
|
+
if (!data) return null
|
|
23
|
+
if (requested_commit && typeof data.commit === 'string' && data.commit !== requested_commit) {
|
|
24
|
+
return `Clone commit mismatch for ${owner}/${repo}: requested commit ${requested_commit} but the response is pinned to ${data.commit}. This indicates a stale CDN cache entry serving a different commit's response. Retry the command; if it persists, the registry's clone-cache policy is misconfigured (the commit query param is missing from the CloudFront cache key).`
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(data.files)) {
|
|
27
|
+
for (const f of data.files) {
|
|
28
|
+
if (!f || typeof f.sha !== 'string' || typeof f.content !== 'string') continue
|
|
29
|
+
const buf = Buffer.from(f.content, 'base64')
|
|
30
|
+
const computed = hash_blob(buf)
|
|
31
|
+
if (computed !== f.sha) {
|
|
32
|
+
return `Clone integrity check failed for ${owner}/${repo} file "${f.path}": server reported sha ${f.sha} but content hashes to ${computed}. This usually means a stale CDN cache entry is serving wrong content for the requested commit. Retry the command; if it persists, report to the registry maintainers.`
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
3
38
|
|
|
4
39
|
const search = (query, options = {}) => catch_errors('Search failed', async () => {
|
|
5
40
|
const params = new URLSearchParams()
|
|
@@ -29,6 +64,8 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
|
|
|
29
64
|
const qs = params.toString() ? `?${params}` : ''
|
|
30
65
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
|
|
31
66
|
if (errors) throw errors[errors.length - 1]
|
|
67
|
+
const integrity_msg = verify_clone_integrity(data, owner, repo, options.commit || null)
|
|
68
|
+
if (integrity_msg) throw new Error(integrity_msg)
|
|
32
69
|
return data
|
|
33
70
|
})
|
|
34
71
|
|
|
@@ -77,6 +114,12 @@ const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${r
|
|
|
77
114
|
const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/${sha} failed`, async () => {
|
|
78
115
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/blob/${sha}`)
|
|
79
116
|
if (errors) throw errors[errors.length - 1]
|
|
117
|
+
if (data && typeof data.content === 'string') {
|
|
118
|
+
const computed = hash_blob(Buffer.from(data.content, 'base64'))
|
|
119
|
+
if (computed !== sha) {
|
|
120
|
+
throw new Error(`Blob integrity check failed for ${owner}/${repo}@${sha}: content hashes to ${computed}. Possible stale CDN cache or registry corruption.`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
80
123
|
return data
|
|
81
124
|
})
|
|
82
125
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
|
|
5
|
+
// Compute the same git-blob SHA the registry advertises and the CLI verifies
|
|
6
|
+
// against. Mirrors cli/src/utils/git_hash.js so the test is self-contained.
|
|
7
|
+
const git_blob_sha = (buf) => {
|
|
8
|
+
const header = Buffer.from(`blob ${buf.length}\0`)
|
|
9
|
+
return crypto.createHash('sha256').update(header).update(buf).digest('hex')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Stub the API client before requiring the module under test. We swap in a
|
|
13
|
+
// per-test fake `get` so we control exactly what `clone()` and `get_blob()` see.
|
|
14
|
+
const stub_client = (response) => {
|
|
15
|
+
const client_path = require.resolve('./client')
|
|
16
|
+
require.cache[client_path] = {
|
|
17
|
+
id: client_path,
|
|
18
|
+
filename: client_path,
|
|
19
|
+
loaded: true,
|
|
20
|
+
exports: {
|
|
21
|
+
get: async () => [null, response],
|
|
22
|
+
post: async () => [null, null],
|
|
23
|
+
put: async () => [null, null],
|
|
24
|
+
patch: async () => [null, null],
|
|
25
|
+
del: async () => [null, null],
|
|
26
|
+
request: async () => [null, null],
|
|
27
|
+
get_base_url: () => 'http://test'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
delete require.cache[require.resolve('./repos')]
|
|
31
|
+
return require('./repos')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const restore = () => {
|
|
35
|
+
delete require.cache[require.resolve('./client')]
|
|
36
|
+
delete require.cache[require.resolve('./repos')]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('repos.clone — content integrity verification', () => {
|
|
40
|
+
afterEach(restore)
|
|
41
|
+
|
|
42
|
+
it('accepts a response whose file SHAs match the content', async () => {
|
|
43
|
+
const content = Buffer.from('hello world\n')
|
|
44
|
+
const sha = git_blob_sha(content)
|
|
45
|
+
const repos = stub_client({
|
|
46
|
+
ref: null,
|
|
47
|
+
commit: 'a'.repeat(64),
|
|
48
|
+
files: [{ path: 'README.md', content: content.toString('base64'), sha }]
|
|
49
|
+
})
|
|
50
|
+
const [err, data] = await repos.clone('acme', 'demo', null, { commit: 'a'.repeat(64) })
|
|
51
|
+
assert.strictEqual(err, null, 'clone should not error on a clean response')
|
|
52
|
+
assert.strictEqual(data.files.length, 1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects a response whose file SHA does not match the bytes (cache pollution)', async () => {
|
|
56
|
+
// The exact failure mode of the CloudFront cache-key bug: server claims
|
|
57
|
+
// SHA X for a file whose bytes hash to Y. Pre-fix CLI silently trusted X
|
|
58
|
+
// and produced false-positive diffs. Post-fix CLI must throw here.
|
|
59
|
+
const content = Buffer.from('this is the REAL content of the file at commit B')
|
|
60
|
+
const wrong_sha = git_blob_sha(Buffer.from('STALE content cached under a colliding key'))
|
|
61
|
+
const repos = stub_client({
|
|
62
|
+
ref: null,
|
|
63
|
+
commit: 'b'.repeat(64),
|
|
64
|
+
files: [{ path: 'SKILL.md', content: content.toString('base64'), sha: wrong_sha }]
|
|
65
|
+
})
|
|
66
|
+
const [err] = await repos.clone('acme', 'demo', null, { commit: 'b'.repeat(64) })
|
|
67
|
+
assert.ok(err, 'clone must error when content does not hash to the advertised SHA')
|
|
68
|
+
const msg = err.map(e => e.message).join(' | ')
|
|
69
|
+
assert.match(msg, /integrity check failed/i, `error should mention integrity: ${msg}`)
|
|
70
|
+
assert.match(msg, /SKILL\.md/, `error should name the offending file: ${msg}`)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('accepts an archive-format response with no per-file content', async () => {
|
|
74
|
+
// format=archive returns { format, ref, commit, url } — no `files` array
|
|
75
|
+
// to verify. The check must pass through cleanly so archive installs are
|
|
76
|
+
// not blocked.
|
|
77
|
+
const repos = stub_client({
|
|
78
|
+
format: 'archive',
|
|
79
|
+
ref: 'refs/tags/v1.0.0',
|
|
80
|
+
commit: 'c'.repeat(64),
|
|
81
|
+
url: 'https://s3.example.com/presigned'
|
|
82
|
+
})
|
|
83
|
+
const [err, data] = await repos.clone('acme', 'demo', 'refs/tags/v1.0.0', { format: 'archive' })
|
|
84
|
+
assert.strictEqual(err, null)
|
|
85
|
+
assert.strictEqual(data.format, 'archive')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects a response whose `commit` field does not match the requested commit (cache key collision)', async () => {
|
|
89
|
+
// The exact failure mode of the May 2026 CDN cache-key bug: CloudFront
|
|
90
|
+
// dropped `?commit=` from the cache key, so a clone response generated
|
|
91
|
+
// for commit X got served back for a request asking for commit Y. The
|
|
92
|
+
// response is internally consistent (SHAs match content), but pinned to
|
|
93
|
+
// the wrong tree. Pre-fix CLI silently used it as `base_commit`
|
|
94
|
+
// content and produced false-positive local_only_modified diffs.
|
|
95
|
+
const content = Buffer.from('// content of commit X\n')
|
|
96
|
+
const sha = git_blob_sha(content)
|
|
97
|
+
const repos = stub_client({
|
|
98
|
+
ref: null,
|
|
99
|
+
commit: 'x'.repeat(64), // server-pinned to X
|
|
100
|
+
files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
|
|
101
|
+
})
|
|
102
|
+
const requested = 'y'.repeat(64)
|
|
103
|
+
const [err] = await repos.clone('acme', 'demo', null, { commit: requested })
|
|
104
|
+
assert.ok(err, 'clone must error when response.commit !== options.commit')
|
|
105
|
+
const msg = err.map(e => e.message).join(' | ')
|
|
106
|
+
assert.match(msg, /commit mismatch/i, `error should mention commit mismatch: ${msg}`)
|
|
107
|
+
assert.match(msg, new RegExp(requested.slice(0, 12)), 'error should include requested commit')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('accepts a response when `commit` matches the requested commit', async () => {
|
|
111
|
+
const content = Buffer.from('matching content\n')
|
|
112
|
+
const sha = git_blob_sha(content)
|
|
113
|
+
const commit = 'a'.repeat(64)
|
|
114
|
+
const repos = stub_client({
|
|
115
|
+
ref: null,
|
|
116
|
+
commit,
|
|
117
|
+
files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
|
|
118
|
+
})
|
|
119
|
+
const [err] = await repos.clone('acme', 'demo', null, { commit })
|
|
120
|
+
assert.strictEqual(err, null)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not enforce commit pin when caller did not pass options.commit (ref-based clone)', async () => {
|
|
124
|
+
// When asking by `ref`, the caller does not know which commit will be
|
|
125
|
+
// resolved — that's the whole point. We can't pin in that direction;
|
|
126
|
+
// per-file SHA verification still applies.
|
|
127
|
+
const content = Buffer.from('content\n')
|
|
128
|
+
const sha = git_blob_sha(content)
|
|
129
|
+
const repos = stub_client({
|
|
130
|
+
ref: 'refs/tags/v1.0.0',
|
|
131
|
+
commit: 'z'.repeat(64),
|
|
132
|
+
files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
|
|
133
|
+
})
|
|
134
|
+
const [err] = await repos.clone('acme', 'demo', 'refs/tags/v1.0.0')
|
|
135
|
+
assert.strictEqual(err, null)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('skips entries that have no content (e.g. metadata-only responses)', async () => {
|
|
139
|
+
// Some endpoints return file metadata without inlined content; the check
|
|
140
|
+
// should not synthesize a false negative.
|
|
141
|
+
const repos = stub_client({
|
|
142
|
+
ref: null,
|
|
143
|
+
commit: 'd'.repeat(64),
|
|
144
|
+
files: [{ path: 'README.md', sha: 'whatever' }]
|
|
145
|
+
})
|
|
146
|
+
const [err] = await repos.clone('acme', 'demo', null, { commit: 'd'.repeat(64) })
|
|
147
|
+
assert.strictEqual(err, null)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('repos.get_blob — content integrity verification', () => {
|
|
152
|
+
afterEach(restore)
|
|
153
|
+
|
|
154
|
+
it('rejects a blob whose bytes do not hash to the requested SHA', async () => {
|
|
155
|
+
const content = Buffer.from('wrong bytes')
|
|
156
|
+
const requested_sha = git_blob_sha(Buffer.from('right bytes'))
|
|
157
|
+
const repos = stub_client({ content: content.toString('base64'), size: content.length })
|
|
158
|
+
const [err] = await repos.get_blob('acme', 'demo', requested_sha)
|
|
159
|
+
assert.ok(err, 'get_blob must error on hash mismatch')
|
|
160
|
+
assert.match(err.map(e => e.message).join(' | '), /integrity check failed/i)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('accepts a blob whose bytes hash to the requested SHA', async () => {
|
|
164
|
+
const content = Buffer.from('hello')
|
|
165
|
+
const sha = git_blob_sha(content)
|
|
166
|
+
const repos = stub_client({ content: content.toString('base64'), size: content.length })
|
|
167
|
+
const [err, data] = await repos.get_blob('acme', 'demo', sha)
|
|
168
|
+
assert.strictEqual(err, null)
|
|
169
|
+
assert.strictEqual(data.size, content.length)
|
|
170
|
+
})
|
|
171
|
+
})
|
package/src/commands/diff.js
CHANGED
|
@@ -249,16 +249,20 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
249
249
|
const base_dir = skills_dir(is_global, project_root)
|
|
250
250
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
251
251
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// the
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
252
|
+
// Drift (lock-vs-disk version mismatch) does NOT block diff — diff is the
|
|
253
|
+
// diagnostic tool a user reaches for precisely when things have diverged,
|
|
254
|
+
// and the obvious "remediation" (install --fresh) would destroy the local
|
|
255
|
+
// content they're trying to inspect. We probe drift only to warn the user
|
|
256
|
+
// that the "base" shown is the lock-recorded base (not the disk version's
|
|
257
|
+
// base). Skipped in --remote mode, which reads nothing from disk.
|
|
258
|
+
const drift = mode === 'remote'
|
|
259
|
+
? null
|
|
260
|
+
: (await verify_lock_disk_consistency(lock_entry, skill_dir))[1]
|
|
261
|
+
const has_drift = drift && !drift.ok
|
|
262
|
+
if (has_drift && !args.flags.json) {
|
|
263
|
+
print_warn(
|
|
260
264
|
`${skill_name} has drift: lock ${drift.expected}, disk ${drift.actual || 'none'}. ` +
|
|
261
|
-
`
|
|
265
|
+
`Diff is shown against the lock-recorded base (${lock_entry.version}).`
|
|
262
266
|
)
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -280,7 +284,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
280
284
|
}
|
|
281
285
|
|
|
282
286
|
if (args.flags.json) {
|
|
283
|
-
print_json({ data: { mode, report } })
|
|
287
|
+
print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
|
|
284
288
|
} else {
|
|
285
289
|
print_file_table(classified)
|
|
286
290
|
print_report_diffs(report)
|
|
@@ -333,7 +337,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
if (args.flags.json) {
|
|
336
|
-
print_json({ data: { mode, report } })
|
|
340
|
+
print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
|
|
337
341
|
} else {
|
|
338
342
|
print_file_table(classified)
|
|
339
343
|
print_report_diffs(report)
|
package/src/commands/status.js
CHANGED
|
@@ -59,9 +59,27 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const all_skills = get_all_locked_skills(lock_data)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// Accept either fully-qualified "owner/skill" or bare "skill" — find by
|
|
63
|
+
// suffix when bare, matching how `validate`, `bump`, and other commands
|
|
64
|
+
// disambiguate from the lock. `diff` requires owner/name explicitly because
|
|
65
|
+
// its API calls need the workspace; status reads only the lock and can
|
|
66
|
+
// always resolve a bare name to its single locked owner.
|
|
67
|
+
let entries
|
|
68
|
+
if (target_skill) {
|
|
69
|
+
let key = target_skill
|
|
70
|
+
let data = all_skills[key] || null
|
|
71
|
+
if (!data && !target_skill.includes('/')) {
|
|
72
|
+
const suffix = `/${target_skill}`
|
|
73
|
+
const found = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
74
|
+
if (found) {
|
|
75
|
+
key = found
|
|
76
|
+
data = all_skills[key] || null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
entries = [[key, data]]
|
|
80
|
+
} else {
|
|
81
|
+
entries = Object.entries(all_skills).filter(([, data]) => data !== null)
|
|
82
|
+
}
|
|
65
83
|
|
|
66
84
|
if (entries.length === 0) {
|
|
67
85
|
if (args.flags.json) {
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* - `list` reported the lock's version as the installed version (lying)
|
|
12
12
|
* - `diff` used the lock's `base_commit` as a comparison baseline that no
|
|
13
13
|
* longer matched what was on disk (incoherent diff)
|
|
14
|
+
*
|
|
15
|
+
* Note on `diff`: an earlier fix hard-blocked `diff` on drift. That was wrong
|
|
16
|
+
* — diff is the diagnostic tool a user reaches for *because* of drift, and
|
|
17
|
+
* the suggested "install --fresh" remediation would destroy the local
|
|
18
|
+
* content. The current behavior is to warn and proceed.
|
|
14
19
|
* - `update` could decide a drifted skill was up-to-date and skip it
|
|
15
20
|
*
|
|
16
21
|
* These tests reconstruct the exact linwong scenario that surfaced the bug
|
|
@@ -258,19 +263,38 @@ describe('list — drift surfacing', () => {
|
|
|
258
263
|
|
|
259
264
|
// ─── diff ─────────────────────────────────────────────────────────────────────
|
|
260
265
|
|
|
261
|
-
describe('diff — drift
|
|
262
|
-
it('diff
|
|
263
|
-
// diff
|
|
264
|
-
//
|
|
266
|
+
describe('diff — drift does not block', () => {
|
|
267
|
+
it('diff proceeds past the drift check (no UsageError) and warns the user', () => {
|
|
268
|
+
// Drift must not block diff: diff is the diagnostic tool for this case,
|
|
269
|
+
// and "install --fresh" would destroy the local content. We assert that
|
|
270
|
+
// the command moves past the drift gate — it will then fail trying to
|
|
271
|
+
// reach the fake API URL, but exit code 2 (UsageError) means the old
|
|
272
|
+
// pre-block is back.
|
|
265
273
|
const { root, cleanup } = scaffold_drifted([
|
|
266
274
|
{ full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
267
275
|
])
|
|
268
276
|
try {
|
|
269
277
|
const { code, stderr } = run(['diff', 'acme/cant-diff'], { cwd: root })
|
|
270
|
-
assert.
|
|
271
|
-
assert.ok(
|
|
272
|
-
|
|
273
|
-
|
|
278
|
+
assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
|
|
279
|
+
assert.ok(
|
|
280
|
+
stderr.toLowerCase().includes('drift') && stderr.includes('0.4.0') && stderr.includes('0.3.0'),
|
|
281
|
+
'should warn about drift with both versions'
|
|
282
|
+
)
|
|
283
|
+
assert.ok(
|
|
284
|
+
!/before diffing/i.test(stderr),
|
|
285
|
+
'must not tell the user to fix drift before diffing'
|
|
286
|
+
)
|
|
287
|
+
} finally { cleanup() }
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('diff --remote skips the drift probe entirely (drift is irrelevant when no disk is read)', () => {
|
|
291
|
+
const { root, cleanup } = scaffold_drifted([
|
|
292
|
+
{ full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
|
|
293
|
+
])
|
|
294
|
+
try {
|
|
295
|
+
const { code, stderr } = run(['diff', 'acme/cant-diff', '--remote'], { cwd: root })
|
|
296
|
+
assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
|
|
297
|
+
assert.ok(!/drift/i.test(stderr), '--remote mode should not surface drift at all')
|
|
274
298
|
} finally { cleanup() }
|
|
275
299
|
})
|
|
276
300
|
})
|