happyskills 0.16.0 → 0.18.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 +27 -0
- package/package.json +1 -1
- package/src/api/push.js +4 -4
- package/src/api/repos.js +19 -4
- package/src/commands/diff.js +178 -0
- package/src/commands/publish.js +42 -6
- package/src/commands/pull.js +339 -0
- package/src/commands/status.js +153 -0
- package/src/commands/update.js +18 -2
- package/src/constants.js +6 -1
- package/src/engine/installer.js +2 -0
- package/src/index.js +3 -0
- package/src/merge/comparator.js +98 -0
- package/src/merge/comparator.test.js +161 -0
- package/src/merge/detector.js +31 -0
- package/src/merge/detector.test.js +77 -0
- package/src/merge/report.js +59 -0
- package/src/merge/report.test.js +85 -0
- package/src/utils/file_collector.js +2 -2
- package/src/utils/git_hash.js +9 -0
- package/src/utils/git_hash.test.js +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.18.0] - 2026-03-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `pull` command for pulling remote changes and merging with local files — supports `--theirs`, `--ours`, and `--force` strategies with three-way file classification and dependency reconciliation
|
|
14
|
+
- Add `diff` command (`d` alias) for file-level difference display — supports `--remote` (base vs remote), `--full` (three-way), and default local vs base modes
|
|
15
|
+
- Add `cli/src/merge/comparator.js` module with `classify_changes()` for three-way file classification into 8 categories
|
|
16
|
+
- Add `cli/src/merge/report.js` module with `build_report()` for structured JSON merge reports (v1 contract)
|
|
17
|
+
- Add `compare()` and `get_blob()` to CLI API client for server-side tree comparison and individual blob downloads
|
|
18
|
+
- Extend `clone()` API client to accept `options.commit` for cloning at a specific commit SHA
|
|
19
|
+
|
|
20
|
+
## [0.17.0] - 2026-03-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Add `status` command (`st` alias) for divergence detection — shows whether installed skills have local modifications, remote updates, or both
|
|
24
|
+
- Add `--force` flag to `publish` to bypass divergence check when deliberately overwriting remote changes
|
|
25
|
+
- Add `--force` flag to `update` to overwrite skills with local modifications
|
|
26
|
+
- Add `base_commit` and `base_integrity` fields to lock file entries — tracks the install-time commit SHA and integrity hash for divergence detection
|
|
27
|
+
- Add `cli/src/merge/detector.js` module with `detect_status()` for local modification detection via integrity comparison
|
|
28
|
+
- Add `cli/src/utils/git_hash.js` with Git-format blob hashing (`sha256("blob <size>\0" + content)`)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- Bump lock file version from 1 to 2 (new `base_commit`/`base_integrity` fields)
|
|
32
|
+
- Switch file hashing in `file_collector.js` from raw SHA-256 to Git blob format (`hash_blob`) for compatibility with the registry's Git object format
|
|
33
|
+
- Send `base_commit` and `force` fields with push requests for server-side divergence checking
|
|
34
|
+
- Update `publish` to read `base_commit` from lock file, handle `409 DIVERGED` responses, and update `base_commit`/`base_integrity` in lock on success
|
|
35
|
+
- Update `update` to refuse overwriting skills with local modifications unless `--force` is passed
|
|
36
|
+
|
|
10
37
|
## [0.16.0] - 2026-03-28
|
|
11
38
|
|
|
12
39
|
### Added
|
package/package.json
CHANGED
package/src/api/push.js
CHANGED
|
@@ -12,13 +12,13 @@ const estimate_payload_size = (files) => {
|
|
|
12
12
|
return size
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const smart_push = (owner, repo, { version, message, files, visibility }, on_progress) =>
|
|
15
|
+
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force }, on_progress) =>
|
|
16
16
|
catch_errors('Smart push failed', async () => {
|
|
17
17
|
const payload_size = estimate_payload_size(files)
|
|
18
18
|
|
|
19
19
|
if (payload_size < DIRECT_PUSH_THRESHOLD) {
|
|
20
20
|
// Small payload — use direct push
|
|
21
|
-
const [err, data] = await repos_api.push(owner, repo, { version, message, files, visibility })
|
|
21
|
+
const [err, data] = await repos_api.push(owner, repo, { version, message, files, visibility, base_commit, force })
|
|
22
22
|
if (err) throw e('Direct push failed', err)
|
|
23
23
|
return data
|
|
24
24
|
}
|
|
@@ -28,7 +28,7 @@ const smart_push = (owner, repo, { version, message, files, visibility }, on_pro
|
|
|
28
28
|
|
|
29
29
|
// Step 1: Initiate
|
|
30
30
|
const [init_err, init_data] = await initiate_upload(owner, repo, {
|
|
31
|
-
version, message, files: file_meta, visibility
|
|
31
|
+
version, message, files: file_meta, visibility, base_commit, force
|
|
32
32
|
})
|
|
33
33
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
34
34
|
|
|
@@ -53,7 +53,7 @@ const smart_push = (owner, repo, { version, message, files, visibility }, on_pro
|
|
|
53
53
|
|
|
54
54
|
// Step 3: Complete
|
|
55
55
|
const [complete_err, complete_data] = await complete_upload(owner, repo, {
|
|
56
|
-
upload_id, version, message, files: file_meta
|
|
56
|
+
upload_id, version, message, files: file_meta, base_commit, force
|
|
57
57
|
})
|
|
58
58
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
59
59
|
|
package/src/api/repos.js
CHANGED
|
@@ -22,9 +22,12 @@ const resolve_dependencies = (skill, version, installed = {}) => catch_errors('D
|
|
|
22
22
|
return data
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
const clone = (owner, repo, ref) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
|
|
26
|
-
const params =
|
|
27
|
-
|
|
25
|
+
const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
|
|
26
|
+
const params = new URLSearchParams()
|
|
27
|
+
if (options.commit) params.set('commit', options.commit)
|
|
28
|
+
else if (ref) params.set('ref', ref)
|
|
29
|
+
const qs = params.toString() ? `?${params}` : ''
|
|
30
|
+
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
|
|
28
31
|
if (errors) throw errors[errors.length - 1]
|
|
29
32
|
return data
|
|
30
33
|
})
|
|
@@ -65,4 +68,16 @@ const patch_repo = (owner, name, fields) => catch_errors(`Failed to update ${own
|
|
|
65
68
|
return data
|
|
66
69
|
})
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${repo} failed`, async () => {
|
|
72
|
+
const [errors, data] = await client.post(`/repos/${owner}/${repo}/compare`, { base_commit })
|
|
73
|
+
if (errors) throw errors[errors.length - 1]
|
|
74
|
+
return data
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/${sha} failed`, async () => {
|
|
78
|
+
const [errors, data] = await client.get(`/repos/${owner}/${repo}/blob/${sha}`)
|
|
79
|
+
if (errors) throw errors[errors.length - 1]
|
|
80
|
+
return data
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
4
|
+
const repos_api = require('../api/repos')
|
|
5
|
+
const { detect_status } = require('../merge/detector')
|
|
6
|
+
const { classify_changes } = require('../merge/comparator')
|
|
7
|
+
const { build_report } = require('../merge/report')
|
|
8
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
9
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
10
|
+
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
11
|
+
const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
|
|
12
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
13
|
+
const { EXIT_CODES } = require('../constants')
|
|
14
|
+
|
|
15
|
+
const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
|
|
16
|
+
|
|
17
|
+
Show file-level differences for a skill.
|
|
18
|
+
|
|
19
|
+
Arguments:
|
|
20
|
+
owner/skill Skill to diff (required)
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--remote Show base vs remote changes (default: local vs base)
|
|
24
|
+
--full Show three-way diff (base vs local vs remote)
|
|
25
|
+
-g, --global Diff globally installed skill
|
|
26
|
+
--json Output as JSON
|
|
27
|
+
|
|
28
|
+
Aliases: d
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
happyskills diff acme/deploy-aws
|
|
32
|
+
happyskills diff acme/deploy-aws --remote
|
|
33
|
+
happyskills diff acme/deploy-aws --full`
|
|
34
|
+
|
|
35
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const build_local_entries = (skill_dir) => catch_errors('Failed to build local entries', async () => {
|
|
38
|
+
const entries = []
|
|
39
|
+
const walk = async (dir, prefix) => {
|
|
40
|
+
const items = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (item.name.startsWith('.')) continue
|
|
43
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name
|
|
44
|
+
const full = path.join(dir, item.name)
|
|
45
|
+
if (item.isDirectory()) {
|
|
46
|
+
await walk(full, rel)
|
|
47
|
+
} else {
|
|
48
|
+
const content = await fs.promises.readFile(full)
|
|
49
|
+
entries.push({ path: rel, sha: hash_blob(content) })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(skill_dir, '')
|
|
54
|
+
return entries
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const STATUS_LABELS = {
|
|
58
|
+
remote_only_modified: 'M (remote)',
|
|
59
|
+
local_only_modified: 'M (local)',
|
|
60
|
+
both_modified: 'M (both)',
|
|
61
|
+
remote_only_added: 'A (remote)',
|
|
62
|
+
local_only_added: 'A (local)',
|
|
63
|
+
remote_only_deleted: 'D (remote)',
|
|
64
|
+
local_only_deleted: 'D (local)'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const print_file_table = (classified) => {
|
|
68
|
+
const lines = []
|
|
69
|
+
for (const [category, label] of Object.entries(STATUS_LABELS)) {
|
|
70
|
+
for (const entry of (classified[category] || [])) {
|
|
71
|
+
lines.push({ status: label, path: entry.path })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (lines.length === 0) {
|
|
76
|
+
print_info('No differences found.')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const max_status = Math.max(...lines.map(l => l.status.length))
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const padded = line.status + ' '.repeat(max_status - line.status.length)
|
|
83
|
+
console.log(` ${padded} ${line.path}`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const run = (args) => catch_errors('Diff failed', async () => {
|
|
90
|
+
if (args.flags._show_help) {
|
|
91
|
+
print_help(HELP_TEXT)
|
|
92
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const skill_name = args._[0]
|
|
96
|
+
if (!skill_name || !skill_name.includes('/')) {
|
|
97
|
+
throw new UsageError('Skill name required in owner/name format. Example: happyskills diff acme/deploy-aws')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const is_global = args.flags.global || false
|
|
101
|
+
const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
|
|
102
|
+
const project_root = find_project_root()
|
|
103
|
+
|
|
104
|
+
// Read lock
|
|
105
|
+
const [lock_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
106
|
+
if (lock_err || !lock_data) throw new UsageError('No lock file found.')
|
|
107
|
+
|
|
108
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
109
|
+
const lock_entry = all_skills[skill_name]
|
|
110
|
+
if (!lock_entry) throw new UsageError(`${skill_name} is not installed.`)
|
|
111
|
+
if (!lock_entry.base_commit) throw new UsageError(`${skill_name} has no base_commit. Run ${code('happyskills install --fresh')} to upgrade the lock file.`)
|
|
112
|
+
|
|
113
|
+
const [owner, repo] = skill_name.split('/')
|
|
114
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
115
|
+
const skill_dir = skill_install_dir(base_dir, repo)
|
|
116
|
+
|
|
117
|
+
// Always need base files
|
|
118
|
+
const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
|
|
119
|
+
if (base_err) throw e('Failed to fetch base files', base_err)
|
|
120
|
+
const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
121
|
+
|
|
122
|
+
if (mode === 'local') {
|
|
123
|
+
// Local vs base — use classify_changes with base as the "remote" side
|
|
124
|
+
const [local_err, local_files] = await build_local_entries(skill_dir)
|
|
125
|
+
if (local_err) throw e('Failed to read local files', local_err)
|
|
126
|
+
|
|
127
|
+
const classified = classify_changes(base_files, local_files, base_files)
|
|
128
|
+
|
|
129
|
+
if (args.flags.json) {
|
|
130
|
+
const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
|
|
131
|
+
print_json({ data: { mode, report } })
|
|
132
|
+
} else {
|
|
133
|
+
print_file_table(classified)
|
|
134
|
+
}
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (mode === 'remote') {
|
|
139
|
+
// Base vs remote — use classify_changes with base as the "local" side
|
|
140
|
+
const [remote_err, remote_clone] = await repos_api.clone(owner, repo, null)
|
|
141
|
+
if (remote_err) throw e('Failed to fetch remote files', remote_err)
|
|
142
|
+
const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
143
|
+
|
|
144
|
+
const classified = classify_changes(base_files, base_files, remote_files)
|
|
145
|
+
|
|
146
|
+
if (args.flags.json) {
|
|
147
|
+
const report = build_report(skill_name, lock_entry.version, null, classified)
|
|
148
|
+
print_json({ data: { mode, report } })
|
|
149
|
+
} else {
|
|
150
|
+
print_file_table(classified)
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Full three-way diff
|
|
156
|
+
const [local_err, local_files] = await build_local_entries(skill_dir)
|
|
157
|
+
if (local_err) throw e('Failed to read local files', local_err)
|
|
158
|
+
|
|
159
|
+
const [remote_err, remote_clone] = await repos_api.clone(owner, repo, null)
|
|
160
|
+
if (remote_err) throw e('Failed to fetch remote files', remote_err)
|
|
161
|
+
const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
162
|
+
|
|
163
|
+
const classified = classify_changes(base_files, local_files, remote_files)
|
|
164
|
+
const report = build_report(skill_name, lock_entry.version, null, classified)
|
|
165
|
+
|
|
166
|
+
if (args.flags.json) {
|
|
167
|
+
print_json({ data: { mode, report } })
|
|
168
|
+
} else {
|
|
169
|
+
print_file_table(classified)
|
|
170
|
+
if (report.summary.conflicted > 0) {
|
|
171
|
+
console.error('')
|
|
172
|
+
print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
177
|
+
|
|
178
|
+
module.exports = { run }
|
package/src/commands/publish.js
CHANGED
|
@@ -32,6 +32,7 @@ Options:
|
|
|
32
32
|
--bump <type> Auto-bump version before publishing (patch, minor, major)
|
|
33
33
|
--workspace <slug> Target workspace (overrides lock file owner)
|
|
34
34
|
--public Publish as public (default is private)
|
|
35
|
+
--force Bypass divergence check (may overwrite remote changes)
|
|
35
36
|
|
|
36
37
|
Aliases: pub
|
|
37
38
|
|
|
@@ -153,6 +154,26 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// Read base_commit from lock file for divergence check
|
|
158
|
+
const full_name_pre = `${workspace.slug}/${manifest.name}`
|
|
159
|
+
const project_root = find_project_root()
|
|
160
|
+
const [lock_err, lock_data] = await read_lock(project_root)
|
|
161
|
+
let base_commit = null
|
|
162
|
+
if (!lock_err && lock_data) {
|
|
163
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
164
|
+
const suffix = `/${skill_name}`
|
|
165
|
+
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
166
|
+
if (lock_key && all_skills[lock_key]) {
|
|
167
|
+
base_commit = all_skills[lock_key].base_commit || null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const force = !!args.flags.force
|
|
172
|
+
if (force) {
|
|
173
|
+
print_warn('Force publishing — this may overwrite remote changes that haven\'t been merged.')
|
|
174
|
+
print_hint(`Consider ${code('happyskills pull')} first to merge safely.`)
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
spinner.update('Packaging skill...')
|
|
157
178
|
const [collect_err, skill_files] = await collect_files(dir)
|
|
158
179
|
if (collect_err) { spinner.fail('Failed to collect files'); throw e('File collection failed', collect_err) }
|
|
@@ -166,15 +187,26 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
166
187
|
version: manifest.version,
|
|
167
188
|
message: `Release ${manifest.version}`,
|
|
168
189
|
files: skill_files,
|
|
169
|
-
visibility
|
|
190
|
+
visibility,
|
|
191
|
+
base_commit: force ? null : base_commit,
|
|
192
|
+
force
|
|
170
193
|
}, on_progress)
|
|
171
|
-
if (push_err) {
|
|
194
|
+
if (push_err) {
|
|
195
|
+
const last = push_err[push_err.length - 1]
|
|
196
|
+
if (last?.message?.includes('diverged') || last?.message?.includes('DIVERGED')) {
|
|
197
|
+
spinner.fail('Remote has diverged')
|
|
198
|
+
print_error('Remote has newer changes. Run \'happyskills pull\' to merge, then publish again.')
|
|
199
|
+
print_hint(`Or use ${code('happyskills publish ' + skill_name + ' --force')} to overwrite remote changes.`)
|
|
200
|
+
return process.exit(EXIT_CODES.ERROR)
|
|
201
|
+
}
|
|
202
|
+
spinner.fail('Publish failed')
|
|
203
|
+
throw e('Push failed', push_err)
|
|
204
|
+
}
|
|
172
205
|
|
|
173
206
|
spinner.succeed(`Published ${workspace.slug}/${manifest.name}@${manifest.version}`)
|
|
174
207
|
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const [lock_err, lock_data] = await read_lock(project_root)
|
|
208
|
+
// Update lock file: set base_commit and base_integrity to new values
|
|
209
|
+
const full_name = full_name_pre
|
|
178
210
|
if (!lock_err && lock_data) {
|
|
179
211
|
const all_skills = get_all_locked_skills(lock_data)
|
|
180
212
|
const suffix = `/${skill_name}`
|
|
@@ -185,7 +217,9 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
185
217
|
...all_skills[lock_key],
|
|
186
218
|
version: manifest.version,
|
|
187
219
|
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
188
|
-
commit: push_data?.commit || null
|
|
220
|
+
commit: push_data?.commit || null,
|
|
221
|
+
base_commit: push_data?.commit || null,
|
|
222
|
+
base_integrity: (!hash_err && integrity) ? integrity : null
|
|
189
223
|
}
|
|
190
224
|
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
191
225
|
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
@@ -196,6 +230,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
196
230
|
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
197
231
|
commit: push_data?.commit || null,
|
|
198
232
|
integrity: (!hash_err && integrity) ? integrity : null,
|
|
233
|
+
base_commit: push_data?.commit || null,
|
|
234
|
+
base_integrity: (!hash_err && integrity) ? integrity : null,
|
|
199
235
|
requested_by: ['__root__'],
|
|
200
236
|
dependencies: manifest.dependencies || {}
|
|
201
237
|
}
|