happyskills 0.39.0 → 0.40.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 +14 -0
- package/package.json +1 -1
- package/src/api/push.js +8 -3
- package/src/commands/publish.js +11 -1
- package/src/commands/publish.test.js +21 -0
- package/src/commands/pull.js +1 -1
- package/src/commands/status.js +2 -1
- package/src/commands/update.js +1 -1
- package/src/manifest/validator.js +5 -1
- package/src/manifest/validator.test.js +22 -0
- package/src/merge/detector.js +73 -13
- package/src/merge/detector.test.js +122 -8
- package/src/merge/file_diff.js +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.40.0] - 2026-05-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Propagate the fork relationship to the server on the first `publish` after `happyskills fork`. The CLI now reads `manifest.forked_from.repo` (an `owner/name` string written by the fork command), splits it into `{ workspace, name }`, and includes it in the push payload across all three transport modes: direct push, archive upload, and per-file presigned upload. The API resolves the parent and persists `repos.forked_from`, which feeds the duplicate-detection heuristic (forks-of-active-parents are ineligible to be elected source-of-truth, and the publish-time `duplicates` warning is suppressed when the only match is the fork's parent). Subsequent publishes of an existing repo are unaffected — the field is honored only on the create-repo path. Requires API ≥ 2.4.0.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Fix `publish` crashing with a `ReferenceError`/TDZ on `visibility` when validation runs before the spinner update — the `const visibility = args.flags.public ? 'public' : 'private'` declaration was below its first use. Move the declaration ahead of all reads.
|
|
17
|
+
- Fix `validate_manifest` rejecting kit names like `_kit-foo` ("Invalid name … Must start with a letter or number"). Kits are required by `init --kit` and `validate_skill_json` to start with `_kit-`, so the leading-character rule is now carved out for `manifest.type === 'kit'` via a separate `KIT_NAME_PATTERN`. Bumping a kit no longer fails its own pre-publish manifest validation.
|
|
18
|
+
|
|
19
|
+
## [0.39.1] - 2026-05-01
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Fix `status` command falsely reporting `local_modified: true` and `status: diverged` for skills whose on-disk content is byte-for-byte identical to the registry baseline. The previous single-stage aggregate-integrity check could disagree with `diff` whenever the lock's `base_integrity` drifted from what `hash_directory` produces today (e.g. server-side normalization of the file set, or differences between the JSON-clone and archive install paths). `detect_status` now falls through to a per-file content comparison against the registry manifest at `base_commit` when the aggregate hash disagrees: zero real diffs → reports clean and silently auto-heals the lock entry's `integrity` / `base_integrity`; one or more real diffs → reports modified and includes the list of differing paths in the new `modified_files` field of the JSON output. Fixes the false-positive that blocked `update` (and required `--force`) and `pull` for skills with stale lock entries. The fast path is unchanged when the aggregate hash matches.
|
|
23
|
+
|
|
10
24
|
## [0.39.0] - 2026-05-01
|
|
11
25
|
|
|
12
26
|
### Removed
|
package/package.json
CHANGED
package/src/api/push.js
CHANGED
|
@@ -13,7 +13,7 @@ const estimate_payload_size = (files) => {
|
|
|
13
13
|
return size
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
|
|
16
|
+
const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
|
|
17
17
|
catch_errors('Archive push failed', async () => {
|
|
18
18
|
const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
|
|
19
19
|
|
|
@@ -28,6 +28,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
28
28
|
archive: true, archive_sha: archive.sha, archive_size: archive.size
|
|
29
29
|
}
|
|
30
30
|
if (parent_shas) init_body.parent_shas = parent_shas
|
|
31
|
+
if (forked_from) init_body.forked_from = forked_from
|
|
31
32
|
const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
|
|
32
33
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
33
34
|
|
|
@@ -44,6 +45,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
44
45
|
version, message, files: file_meta, base_commit, force
|
|
45
46
|
}
|
|
46
47
|
if (parent_shas) complete_body.parent_shas = parent_shas
|
|
48
|
+
if (forked_from) complete_body.forked_from = forked_from
|
|
47
49
|
const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
|
|
48
50
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
49
51
|
|
|
@@ -62,7 +64,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
62
64
|
}
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
|
|
67
|
+
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
|
|
66
68
|
catch_errors('Smart push failed', async () => {
|
|
67
69
|
const payload_size = estimate_payload_size(files)
|
|
68
70
|
|
|
@@ -70,6 +72,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
70
72
|
// Small payload — use direct push
|
|
71
73
|
const body = { version, message, files, visibility, base_commit, force }
|
|
72
74
|
if (parent_shas) body.parent_shas = parent_shas
|
|
75
|
+
if (forked_from) body.forked_from = forked_from
|
|
73
76
|
const [err, data] = await repos_api.push(owner, repo, body)
|
|
74
77
|
if (err) throw e('Direct push failed', err)
|
|
75
78
|
return data
|
|
@@ -77,7 +80,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
77
80
|
|
|
78
81
|
// Large payload — prefer archive upload if source_dir is available
|
|
79
82
|
if (source_dir) {
|
|
80
|
-
return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress)
|
|
83
|
+
return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// Fallback: per-file presigned upload
|
|
@@ -86,6 +89,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
86
89
|
// Step 1: Initiate
|
|
87
90
|
const init_body = { version, message, files: file_meta, visibility, base_commit, force }
|
|
88
91
|
if (parent_shas) init_body.parent_shas = parent_shas
|
|
92
|
+
if (forked_from) init_body.forked_from = forked_from
|
|
89
93
|
const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
|
|
90
94
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
91
95
|
|
|
@@ -111,6 +115,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
111
115
|
// Step 3: Complete
|
|
112
116
|
const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
|
|
113
117
|
if (parent_shas) complete_body.parent_shas = parent_shas
|
|
118
|
+
if (forked_from) complete_body.forked_from = forked_from
|
|
114
119
|
const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
|
|
115
120
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
116
121
|
|
package/src/commands/publish.js
CHANGED
|
@@ -135,6 +135,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
135
135
|
|
|
136
136
|
const spinner = create_spinner('Preparing to publish...')
|
|
137
137
|
|
|
138
|
+
const visibility = args.flags.public ? 'public' : 'private'
|
|
139
|
+
|
|
138
140
|
let owner = args.flags.workspace
|
|
139
141
|
if (!owner) {
|
|
140
142
|
const [, resolved_owner] = await resolve_skill_owner(skill_name)
|
|
@@ -221,7 +223,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
221
223
|
spinner.update(`Uploading files (${completed}/${total})...`)
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
|
-
const visibility = args.flags.public ? 'public' : 'private'
|
|
225
226
|
const push_options = {
|
|
226
227
|
version: manifest.version,
|
|
227
228
|
message: `Release ${manifest.version}`,
|
|
@@ -234,6 +235,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
234
235
|
if (merge_parents && !force) {
|
|
235
236
|
push_options.parent_shas = merge_parents
|
|
236
237
|
}
|
|
238
|
+
|
|
239
|
+
// Propagate fork relationship to the server on the first publish.
|
|
240
|
+
// Only the create-repo path uses this; existing repos already have forked_from set.
|
|
241
|
+
if (manifest.forked_from && typeof manifest.forked_from.repo === 'string' && manifest.forked_from.repo.includes('/')) {
|
|
242
|
+
const [parent_workspace, parent_name] = manifest.forked_from.repo.split('/')
|
|
243
|
+
if (parent_workspace && parent_name) {
|
|
244
|
+
push_options.forked_from = { workspace: parent_workspace, name: parent_name }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
237
247
|
const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
|
|
238
248
|
if (push_err) {
|
|
239
249
|
const last = push_err[push_err.length - 1]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
|
|
6
|
+
describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () => {
|
|
7
|
+
const src = fs.readFileSync(path.join(__dirname, 'publish.js'), 'utf8')
|
|
8
|
+
|
|
9
|
+
it('declares `visibility` before passing it to validate_dependencies', () => {
|
|
10
|
+
const decl_idx = src.search(/const\s+visibility\s*=\s*args\.flags\.public/)
|
|
11
|
+
const use_idx = src.search(/validate_dependencies\([\s\S]*?visibility/)
|
|
12
|
+
assert.notStrictEqual(decl_idx, -1, '`const visibility = args.flags.public ? ...` declaration not found')
|
|
13
|
+
assert.notStrictEqual(use_idx, -1, '`validate_dependencies(... visibility ...)` call not found')
|
|
14
|
+
assert.ok(
|
|
15
|
+
decl_idx < use_idx,
|
|
16
|
+
`visibility must be declared (idx ${decl_idx}) before it is read inside validate_dependencies (idx ${use_idx}). ` +
|
|
17
|
+
'The current ordering causes `ReferenceError: Cannot access \'visibility\' before initialization` ' +
|
|
18
|
+
'whenever skill.json declares one or more dependencies.'
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
})
|
package/src/commands/pull.js
CHANGED
|
@@ -152,7 +152,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
152
152
|
const spinner = create_spinner(`Pulling ${skill_name}...`)
|
|
153
153
|
|
|
154
154
|
// 2. Detect local modifications
|
|
155
|
-
const [det_err, det] = await detect_status(lock_entry, skill_dir)
|
|
155
|
+
const [det_err, det] = await detect_status(lock_entry, skill_dir, { skill_name, project_root, is_global })
|
|
156
156
|
if (det_err) { spinner.fail('Failed to detect local status'); throw det_err[0] }
|
|
157
157
|
|
|
158
158
|
// 3. Compare with remote
|
package/src/commands/status.js
CHANGED
|
@@ -74,7 +74,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
74
74
|
if (!data) return { name, data, det: null }
|
|
75
75
|
const short_name = name.split('/')[1] || name
|
|
76
76
|
const dir = skill_install_dir(base_dir, short_name)
|
|
77
|
-
return detect_status(data, dir).then(([, det]) => ({ name, data, det }))
|
|
77
|
+
return detect_status(data, dir, { skill_name: name, project_root, is_global }).then(([, det]) => ({ name, data, det }))
|
|
78
78
|
}))
|
|
79
79
|
|
|
80
80
|
const results = detections.map(({ name, data, det }) => {
|
|
@@ -85,6 +85,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
85
85
|
base_version: data.version || null,
|
|
86
86
|
base_commit: data.base_commit || null,
|
|
87
87
|
local_modified: det?.local_modified || false,
|
|
88
|
+
modified_files: det?.modified_files || null,
|
|
88
89
|
// remote_updated is populated below after API call
|
|
89
90
|
remote_updated: false,
|
|
90
91
|
remote_version: null,
|
package/src/commands/update.js
CHANGED
|
@@ -242,7 +242,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
242
242
|
const lock_entry = skills[r.skill]
|
|
243
243
|
const short_name = r.skill.split('/')[1] || r.skill
|
|
244
244
|
const dir = skill_install_dir(base_dir, short_name)
|
|
245
|
-
return detect_status(lock_entry, dir).then(([, det]) => ({ r, det }))
|
|
245
|
+
return detect_status(lock_entry, dir, { skill_name: r.skill, project_root, is_global }).then(([, det]) => ({ r, det }))
|
|
246
246
|
}))
|
|
247
247
|
|
|
248
248
|
const skipped = []
|
|
@@ -2,6 +2,9 @@ const { valid } = require('../utils/semver')
|
|
|
2
2
|
|
|
3
3
|
const REQUIRED_FIELDS = ['name', 'version']
|
|
4
4
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
5
|
+
// Kits are required to start with `_kit-` (enforced by `init --kit` and
|
|
6
|
+
// validate_skill_json). Carve out the leading-character rule for them.
|
|
7
|
+
const KIT_NAME_PATTERN = /^_kit-[a-z0-9][a-z0-9-]*$/
|
|
5
8
|
|
|
6
9
|
const validate_manifest = (manifest) => {
|
|
7
10
|
const errors = []
|
|
@@ -12,7 +15,8 @@ const validate_manifest = (manifest) => {
|
|
|
12
15
|
}
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
const name_pattern = manifest.type === 'kit' ? KIT_NAME_PATTERN : NAME_PATTERN
|
|
19
|
+
if (manifest.name && !name_pattern.test(manifest.name)) {
|
|
16
20
|
errors.push(`Invalid name "${manifest.name}". Use lowercase letters, numbers, hyphens, and underscores. Must start with a letter or number.`)
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -98,4 +98,26 @@ describe('validate_manifest', () => {
|
|
|
98
98
|
const result = validate_manifest({ name: 'my-skill', version: '1.0.0' })
|
|
99
99
|
assert.strictEqual(result.valid, true)
|
|
100
100
|
})
|
|
101
|
+
|
|
102
|
+
// Bug 2 regression — kit names start with `_kit-` (enforced by `init --kit` and
|
|
103
|
+
// `validate_skill_json` for type==='kit'). The shared `validate_manifest` used
|
|
104
|
+
// by `bump` must accept this convention or the standard release workflow breaks.
|
|
105
|
+
describe('kit name convention', () => {
|
|
106
|
+
it('accepts a kit name beginning with _kit- when type is "kit"', () => {
|
|
107
|
+
const result = validate_manifest({ name: '_kit-mykit', version: '1.0.0', type: 'kit' })
|
|
108
|
+
assert.strictEqual(result.valid, true, `expected valid, got errors: ${result.errors.join(', ')}`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still rejects a non-kit manifest whose name starts with _', () => {
|
|
112
|
+
const result = validate_manifest({ name: '_oops', version: '1.0.0' })
|
|
113
|
+
assert.strictEqual(result.valid, false)
|
|
114
|
+
assert.ok(result.errors.some(e => e.includes('Invalid name')))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('still rejects a non-kit manifest whose name starts with _ even if type=skill', () => {
|
|
118
|
+
const result = validate_manifest({ name: '_oops', version: '1.0.0', type: 'skill' })
|
|
119
|
+
assert.strictEqual(result.valid, false)
|
|
120
|
+
assert.ok(result.errors.some(e => e.includes('Invalid name')))
|
|
121
|
+
})
|
|
122
|
+
})
|
|
101
123
|
})
|
package/src/merge/detector.js
CHANGED
|
@@ -1,31 +1,91 @@
|
|
|
1
1
|
const { error: { catch_errors } } = require('puffy-core')
|
|
2
2
|
const { hash_directory } = require('../lock/integrity')
|
|
3
|
+
const { find_modified_files } = require('./file_diff')
|
|
4
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
5
|
+
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
6
|
+
const { lock_root } = require('../config/paths')
|
|
7
|
+
|
|
8
|
+
// Serialize lock heals across concurrent detect_status calls so parallel
|
|
9
|
+
// read-modify-write sequences don't clobber each other's updates.
|
|
10
|
+
let heal_chain = Promise.resolve()
|
|
11
|
+
|
|
12
|
+
const queue_heal = (skill_name, current_integrity, project_root, is_global) => {
|
|
13
|
+
const next = heal_chain.then(async () => {
|
|
14
|
+
const root = lock_root(is_global, project_root)
|
|
15
|
+
const [, lock_data] = await read_lock(root)
|
|
16
|
+
if (!lock_data) return
|
|
17
|
+
const skills = get_all_locked_skills(lock_data)
|
|
18
|
+
const entry = skills[skill_name]
|
|
19
|
+
if (!entry) return
|
|
20
|
+
if (entry.base_integrity === current_integrity && entry.integrity === current_integrity) return
|
|
21
|
+
const updated = { ...entry, base_integrity: current_integrity, integrity: current_integrity }
|
|
22
|
+
const merged = update_lock_skills(lock_data, { [skill_name]: updated })
|
|
23
|
+
await write_lock(root, merged)
|
|
24
|
+
if (process.env.HAPPYSKILLS_DEBUG) {
|
|
25
|
+
process.stderr.write(`integrity drift auto-healed for ${skill_name}\n`)
|
|
26
|
+
}
|
|
27
|
+
}).catch(() => {})
|
|
28
|
+
heal_chain = next
|
|
29
|
+
return next
|
|
30
|
+
}
|
|
3
31
|
|
|
4
32
|
/**
|
|
5
33
|
* Detects whether a skill has been locally modified since install/pull.
|
|
6
34
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
35
|
+
* Two-stage check:
|
|
36
|
+
* 1. Fast path — compare hash_directory(disk) against lock.base_integrity.
|
|
37
|
+
* If they match, the skill is unmodified.
|
|
38
|
+
* 2. Fallback — when the aggregate hash disagrees, fetch the registry
|
|
39
|
+
* manifest at base_commit and compare per-file (git blob SHAs). The
|
|
40
|
+
* aggregate hash is only an optimization; the per-file comparison is
|
|
41
|
+
* the source of truth.
|
|
42
|
+
*
|
|
43
|
+
* - Zero files differ → the lock's integrity has drifted (e.g. server
|
|
44
|
+
* normalized which files are returned). Auto-heal the lock entry's
|
|
45
|
+
* integrity / base_integrity to the current value and report clean.
|
|
46
|
+
* - One or more files differ → real local modifications. Return them
|
|
47
|
+
* in `modified_files` for better diagnostics.
|
|
9
48
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
49
|
+
* The fallback requires `options.skill_name` and a base_commit on the lock;
|
|
50
|
+
* without them, the function falls back to the legacy pessimistic answer
|
|
51
|
+
* (local_modified: true on aggregate mismatch).
|
|
52
|
+
*
|
|
53
|
+
* @param {object} lock_entry
|
|
54
|
+
* @param {string} skill_dir
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string} [options.skill_name] - "owner/repo" (enables fallback)
|
|
57
|
+
* @param {string} [options.project_root] - Project root for lock heal
|
|
58
|
+
* @param {boolean} [options.is_global] - Whether to heal the global lock
|
|
13
59
|
*/
|
|
14
|
-
const detect_status = (lock_entry, skill_dir) => catch_errors('Failed to detect status', async () => {
|
|
60
|
+
const detect_status = (lock_entry, skill_dir, options = {}) => catch_errors('Failed to detect status', async () => {
|
|
15
61
|
const base_commit = lock_entry?.base_commit || null
|
|
16
62
|
const base_integrity = lock_entry?.base_integrity || null
|
|
17
63
|
|
|
18
|
-
if (!base_integrity) return { local_modified: false, current_integrity: null, base_integrity: null, base_commit }
|
|
64
|
+
if (!base_integrity) return { local_modified: false, current_integrity: null, base_integrity: null, base_commit, modified_files: null }
|
|
19
65
|
|
|
20
66
|
const [hash_err, current_integrity] = await hash_directory(skill_dir)
|
|
21
|
-
if (hash_err) return { local_modified: false, current_integrity: null, base_integrity, base_commit }
|
|
67
|
+
if (hash_err) return { local_modified: false, current_integrity: null, base_integrity, base_commit, modified_files: null }
|
|
68
|
+
|
|
69
|
+
if (current_integrity === base_integrity) {
|
|
70
|
+
return { local_modified: false, current_integrity, base_integrity, base_commit, modified_files: [] }
|
|
71
|
+
}
|
|
22
72
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
current_integrity,
|
|
26
|
-
base_integrity,
|
|
27
|
-
base_commit
|
|
73
|
+
const { skill_name = null, project_root = null, is_global = false } = options
|
|
74
|
+
if (!skill_name || !base_commit) {
|
|
75
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files: null }
|
|
28
76
|
}
|
|
77
|
+
|
|
78
|
+
const [, modified_files] = await find_modified_files(skill_name, base_commit, skill_dir)
|
|
79
|
+
if (modified_files === null) {
|
|
80
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files: null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (modified_files.length === 0) {
|
|
84
|
+
if (project_root) await queue_heal(skill_name, current_integrity, project_root, is_global)
|
|
85
|
+
return { local_modified: false, current_integrity, base_integrity: current_integrity, base_commit, modified_files: [], healed: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files }
|
|
29
89
|
})
|
|
30
90
|
|
|
31
91
|
module.exports = { detect_status }
|
|
@@ -1,22 +1,40 @@
|
|
|
1
|
-
const { describe, it, afterEach } = require('node:test')
|
|
1
|
+
const { describe, it, afterEach, before, after } = require('node:test')
|
|
2
2
|
const assert = require('node:assert/strict')
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
-
const { detect_status } = require('./detector')
|
|
7
6
|
const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
|
|
7
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Stub repos_api before file_diff/detector load it. find_modified_files
|
|
10
|
+
// uses repos_api.clone, so we control its return value via the stub state.
|
|
11
|
+
const repos_api_path = require.resolve('../api/repos')
|
|
12
|
+
const stub_state = { files: [], err: null }
|
|
13
|
+
require.cache[repos_api_path] = {
|
|
14
|
+
id: repos_api_path,
|
|
15
|
+
filename: repos_api_path,
|
|
16
|
+
loaded: true,
|
|
17
|
+
exports: {
|
|
18
|
+
clone: async () => stub_state.err
|
|
19
|
+
? [[stub_state.err], null]
|
|
20
|
+
: [null, { files: stub_state.files }]
|
|
21
|
+
}
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
const { detect_status } = require('./detector')
|
|
25
|
+
|
|
26
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'detector-test-'))
|
|
14
27
|
const rm = (dir) => { try { fs.rmSync(dir, { recursive: true }) } catch (_) {} }
|
|
15
28
|
|
|
16
29
|
describe('detect_status', () => {
|
|
17
30
|
let tmp_dir
|
|
18
31
|
|
|
19
|
-
afterEach(() => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (tmp_dir) { rm(tmp_dir); tmp_dir = null }
|
|
34
|
+
stub_state.files = []
|
|
35
|
+
stub_state.err = null
|
|
36
|
+
clear_integrity_cache()
|
|
37
|
+
})
|
|
20
38
|
|
|
21
39
|
it('returns local_modified: false when integrity matches', async () => {
|
|
22
40
|
tmp_dir = make_tmp()
|
|
@@ -33,13 +51,12 @@ describe('detect_status', () => {
|
|
|
33
51
|
assert.equal(result.base_commit, 'abc123')
|
|
34
52
|
})
|
|
35
53
|
|
|
36
|
-
it('returns local_modified: true when integrity differs', async () => {
|
|
54
|
+
it('returns local_modified: true when integrity differs and no fallback context is given', async () => {
|
|
37
55
|
tmp_dir = make_tmp()
|
|
38
56
|
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'hello')
|
|
39
57
|
|
|
40
58
|
const [, integrity] = await hash_directory(tmp_dir)
|
|
41
59
|
|
|
42
|
-
// Modify the file and clear hash cache (cache is per-command-invocation optimization)
|
|
43
60
|
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'modified')
|
|
44
61
|
clear_integrity_cache()
|
|
45
62
|
|
|
@@ -48,6 +65,7 @@ describe('detect_status', () => {
|
|
|
48
65
|
assert.equal(err, null)
|
|
49
66
|
assert.equal(result.local_modified, true)
|
|
50
67
|
assert.notEqual(result.current_integrity, integrity)
|
|
68
|
+
assert.equal(result.modified_files, null)
|
|
51
69
|
})
|
|
52
70
|
|
|
53
71
|
it('returns local_modified: false when base_integrity is missing', async () => {
|
|
@@ -75,4 +93,100 @@ describe('detect_status', () => {
|
|
|
75
93
|
assert.equal(err, null)
|
|
76
94
|
assert.equal(result.local_modified, false)
|
|
77
95
|
})
|
|
96
|
+
|
|
97
|
+
it('auto-heals the lock when aggregate integrity drifts but per-file content matches the registry', async () => {
|
|
98
|
+
tmp_dir = make_tmp()
|
|
99
|
+
const project_root = make_tmp()
|
|
100
|
+
|
|
101
|
+
const skill_content = 'hello'
|
|
102
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), skill_content)
|
|
103
|
+
|
|
104
|
+
// Stub registry to return the same content the disk has (same git blob sha).
|
|
105
|
+
stub_state.files = [{ path: 'SKILL.md', sha: hash_blob(Buffer.from(skill_content)) }]
|
|
106
|
+
|
|
107
|
+
// Lock has a stale base_integrity that does NOT match the current hash_directory.
|
|
108
|
+
const stale_integrity = 'sha256-stale0000000000000000000000000000000000000000000000000000000000'
|
|
109
|
+
const lock_path = path.join(project_root, 'skills-lock.json')
|
|
110
|
+
fs.writeFileSync(lock_path, JSON.stringify({
|
|
111
|
+
lockVersion: 2,
|
|
112
|
+
generatedAt: new Date().toISOString(),
|
|
113
|
+
skills: {
|
|
114
|
+
'acme/test-skill': {
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
base_commit: 'abc123',
|
|
117
|
+
base_integrity: stale_integrity,
|
|
118
|
+
integrity: stale_integrity
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
const lock_entry = { base_integrity: stale_integrity, base_commit: 'abc123' }
|
|
124
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
125
|
+
skill_name: 'acme/test-skill',
|
|
126
|
+
project_root,
|
|
127
|
+
is_global: false
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
assert.equal(err, null)
|
|
131
|
+
assert.equal(result.local_modified, false)
|
|
132
|
+
assert.equal(result.healed, true)
|
|
133
|
+
assert.deepEqual(result.modified_files, [])
|
|
134
|
+
|
|
135
|
+
// Wait for the heal write to complete (queued via promise chain).
|
|
136
|
+
await new Promise(r => setTimeout(r, 50))
|
|
137
|
+
const lock_after = JSON.parse(fs.readFileSync(lock_path, 'utf-8'))
|
|
138
|
+
assert.equal(lock_after.skills['acme/test-skill'].base_integrity, result.current_integrity)
|
|
139
|
+
assert.equal(lock_after.skills['acme/test-skill'].integrity, result.current_integrity)
|
|
140
|
+
|
|
141
|
+
rm(project_root)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reports real local modifications with a list of differing files when fallback runs', async () => {
|
|
145
|
+
tmp_dir = make_tmp()
|
|
146
|
+
const project_root = make_tmp()
|
|
147
|
+
|
|
148
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'edited locally')
|
|
149
|
+
fs.writeFileSync(path.join(tmp_dir, 'skill.json'), '{}')
|
|
150
|
+
|
|
151
|
+
// Registry says SKILL.md should have different content; skill.json matches.
|
|
152
|
+
stub_state.files = [
|
|
153
|
+
{ path: 'SKILL.md', sha: hash_blob(Buffer.from('original')) },
|
|
154
|
+
{ path: 'skill.json', sha: hash_blob(Buffer.from('{}')) }
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
fs.writeFileSync(path.join(project_root, 'skills-lock.json'), JSON.stringify({
|
|
158
|
+
lockVersion: 2,
|
|
159
|
+
generatedAt: new Date().toISOString(),
|
|
160
|
+
skills: {}
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
const lock_entry = { base_integrity: 'sha256-stale', base_commit: 'abc123' }
|
|
164
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
165
|
+
skill_name: 'acme/test-skill',
|
|
166
|
+
project_root,
|
|
167
|
+
is_global: false
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.equal(err, null)
|
|
171
|
+
assert.equal(result.local_modified, true)
|
|
172
|
+
assert.deepEqual(result.modified_files, ['SKILL.md'])
|
|
173
|
+
|
|
174
|
+
rm(project_root)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('falls back to pessimistic local_modified: true when registry fetch fails', async () => {
|
|
178
|
+
tmp_dir = make_tmp()
|
|
179
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'hello')
|
|
180
|
+
|
|
181
|
+
stub_state.err = new Error('network down')
|
|
182
|
+
|
|
183
|
+
const lock_entry = { base_integrity: 'sha256-stale', base_commit: 'abc123' }
|
|
184
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
185
|
+
skill_name: 'acme/test-skill'
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
assert.equal(err, null)
|
|
189
|
+
assert.equal(result.local_modified, true)
|
|
190
|
+
assert.equal(result.modified_files, null)
|
|
191
|
+
})
|
|
78
192
|
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const repos_api = require('../api/repos')
|
|
5
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
6
|
+
|
|
7
|
+
const build_local_file_shas = (skill_dir) => catch_errors('Failed to build local file shas', async () => {
|
|
8
|
+
const entries = []
|
|
9
|
+
const walk = async (dir, prefix) => {
|
|
10
|
+
let items
|
|
11
|
+
try {
|
|
12
|
+
items = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
13
|
+
} catch {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
if (item.name.startsWith('.')) continue
|
|
18
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name
|
|
19
|
+
const full = path.join(dir, item.name)
|
|
20
|
+
if (item.isDirectory()) {
|
|
21
|
+
await walk(full, rel)
|
|
22
|
+
} else if (item.isFile()) {
|
|
23
|
+
const content = await fs.promises.readFile(full)
|
|
24
|
+
entries.push({ path: rel, sha: hash_blob(content) })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await walk(skill_dir, '')
|
|
29
|
+
return entries
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compares disk content against the registry baseline at base_commit and
|
|
34
|
+
* returns the list of paths whose content actually differs. An empty array
|
|
35
|
+
* means the disk is byte-identical to the registry (per Git blob hashing).
|
|
36
|
+
*
|
|
37
|
+
* Returns null when the comparison cannot be performed (network failure,
|
|
38
|
+
* missing inputs) so callers can fall back to a pessimistic answer.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} skill_name - "owner/repo"
|
|
41
|
+
* @param {string} base_commit
|
|
42
|
+
* @param {string} skill_dir - Absolute path to the on-disk skill
|
|
43
|
+
* @returns {[errors, string[]|null]}
|
|
44
|
+
*/
|
|
45
|
+
const find_modified_files = (skill_name, base_commit, skill_dir) => catch_errors('Failed to compare disk to registry', async () => {
|
|
46
|
+
if (!skill_name || !skill_name.includes('/') || !base_commit) return null
|
|
47
|
+
|
|
48
|
+
const [owner, repo] = skill_name.split('/')
|
|
49
|
+
const [clone_err, clone_data] = await repos_api.clone(owner, repo, null, { commit: base_commit })
|
|
50
|
+
if (clone_err) return null
|
|
51
|
+
|
|
52
|
+
const base_files = clone_data.files || []
|
|
53
|
+
const base_map = new Map(base_files.map(f => [f.path, f.sha]))
|
|
54
|
+
|
|
55
|
+
const [local_err, local_entries] = await build_local_file_shas(skill_dir)
|
|
56
|
+
if (local_err) return null
|
|
57
|
+
const local_map = new Map(local_entries.map(e => [e.path, e.sha]))
|
|
58
|
+
|
|
59
|
+
const all_paths = new Set([...base_map.keys(), ...local_map.keys()])
|
|
60
|
+
const modified = []
|
|
61
|
+
for (const p of all_paths) {
|
|
62
|
+
if (base_map.get(p) !== local_map.get(p)) modified.push(p)
|
|
63
|
+
}
|
|
64
|
+
modified.sort()
|
|
65
|
+
return modified
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
module.exports = { find_modified_files, build_local_file_shas }
|