happyskills 1.10.1 → 1.12.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 +24 -0
- package/package.json +9 -9
- package/src/commands/publish.js +34 -8
- package/src/commands/publish.test.js +30 -2
- package/src/commands/pull.js +95 -3
- package/src/commands/pull.test.js +199 -0
- package/src/commands/release.js +6 -2
- package/src/config/limits.js +4 -2
- package/src/config/paths.js +19 -4
- package/src/config/paths.test.js +15 -0
- package/src/engine/archive_installer.js +35 -14
- package/src/engine/archive_installer.test.js +52 -1
- package/src/merge/marker_resolve.js +68 -0
- package/src/merge/marker_resolve.test.js +69 -0
- package/src/merge/rebase.js +73 -12
- package/src/merge/rebase.test.js +102 -1
- package/src/ui/output.js +16 -8
- package/src/ui/output.test.js +23 -1
- package/src/utils/scrub_secrets.js +8 -5
- package/src/utils/scrub_secrets.test.js +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.12.0] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add a `--visibility <private|workspace|public>` flag to `publish` and `release`, making a workspace-visible first publish a single step. Previously the publish path could only emit `public` (via `--public`) or `private`, so shipping a skill as `workspace`-visible (discoverable and installable by every member of the owning workspace, but not public) required a second `visibility` command after publishing. The flag is honored on first publish only — later publishes preserve the existing visibility — and `--visibility` takes precedence over `--public`. `--public` is retained as a backward-compatible shorthand for `--visibility public`; the never-functional `--private` flag (always a silent no-op, since private is the default) is dropped from the documented examples.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- Cap `.tar.gz` decompression during install — enforce per-file size, total decompressed size, and entry-count limits mid-stream (and reject an oversized download by `Content-Length` before buffering), so a malicious highly-compressible archive can't inflate unbounded and exhaust the consumer's memory/disk. (SSRF-01/SUP-04)
|
|
19
|
+
- Scrub C0/C1 terminal control bytes (`ESC`, `CR`, `BEL`, `DEL`, …) from server-supplied strings at the print boundary, so a crafted skill name or API message can't spoof or attack the terminal. (NEW-C2)
|
|
20
|
+
- Guard server-derived install paths with an `assert_within` check, so a `..` or absolute skill name can't escape the skills directory on install. (NEW-C3)
|
|
21
|
+
- Expand the secret scrubber to redact GitHub tokens (`gh[pousr]_`), AWS access-key IDs (`AKIA`/`ASIA…`), and `postgres://`/`postgresql://` connection strings before feedback context is sent to the backend. (DATA-04)
|
|
22
|
+
- Pin all CLI runtime dependencies to exact locked versions for a reproducible published package. (SUP-09)
|
|
23
|
+
|
|
24
|
+
## [1.11.0] - 2026-06-12
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Add `--full-report` support to `pull --rebase`. A clean rebase now attaches the same review enrichment as the 3-way merge mode — per-file inline content (`base_content`, `local_content`, `remote_content`, `merged_content`) for every changed file plus a `resolution_steps` array — to the rebase result, so an agent can run a post-merge semantic-coherence review without extra file reads. Previously `--full-report` was accepted but silently ignored on the rebase path, so the LLM-preferred mode produced no review payload.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Fix re-pull conflict resolution being a silent no-op. After a conflicted `pull` wrote conflict markers, re-running `pull <skill> --theirs` / `--ours` / `--force` returned `up_to_date` and left the markers in place, so the documented conflict-resolution recipes could not work: a conflicted pull advances the lock's `base_commit` to the remote head, after which every re-pull sees no remote changes and short-circuits before the strategy logic runs. Re-pull now resolves pending conflicts before that short-circuit — `--theirs` / `--ours` (global or per-file) keep the chosen side, strip the markers, recompute integrity, and clear the resolved files from `conflict_files`; `--force` falls through to a fast-forward that overwrites even with conflicts pending; and a bare re-pull reports the pending conflict state (`status: conflicts` with the `conflict_files` list) instead of a misleading `up_to_date`.
|
|
33
|
+
|
|
10
34
|
## [1.10.1] - 2026-06-09
|
|
11
35
|
|
|
12
36
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Package manager for AI agent skills",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@jimp/core": "
|
|
47
|
-
"@jimp/js-jpeg": "
|
|
48
|
-
"@jimp/js-png": "
|
|
49
|
-
"@jimp/plugin-resize": "
|
|
50
|
-
"node-diff3": "
|
|
51
|
-
"puffy-core": "
|
|
52
|
-
"semver": "
|
|
53
|
-
"tar-stream": "
|
|
46
|
+
"@jimp/core": "1.6.1",
|
|
47
|
+
"@jimp/js-jpeg": "1.6.1",
|
|
48
|
+
"@jimp/js-png": "1.6.1",
|
|
49
|
+
"@jimp/plugin-resize": "1.6.1",
|
|
50
|
+
"node-diff3": "3.2.0",
|
|
51
|
+
"puffy-core": "1.3.1",
|
|
52
|
+
"semver": "7.7.4",
|
|
53
|
+
"tar-stream": "3.1.8"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"dotenv": "^17.2.4"
|
package/src/commands/publish.js
CHANGED
|
@@ -36,16 +36,40 @@ Arguments:
|
|
|
36
36
|
Options:
|
|
37
37
|
--bump <type> Auto-bump version before publishing (patch, minor, major)
|
|
38
38
|
--workspace <slug> Target workspace (overrides lock file owner)
|
|
39
|
-
--
|
|
39
|
+
--visibility <value> Visibility on first publish: private (default), workspace, or public
|
|
40
|
+
--public Shorthand for --visibility public
|
|
40
41
|
--force Bypass divergence check (may overwrite remote changes)
|
|
41
42
|
|
|
42
43
|
Aliases: pub
|
|
43
44
|
|
|
45
|
+
Visibility (set on first publish; preserved on later publishes):
|
|
46
|
+
private (default) Only people you explicitly grant access to.
|
|
47
|
+
workspace Every member of the owning workspace can find and install it. Not public.
|
|
48
|
+
public Anyone — listed in the public registry.
|
|
49
|
+
|
|
44
50
|
Examples:
|
|
45
51
|
happyskills publish my-skill
|
|
46
52
|
happyskills publish deploy-aws --bump patch
|
|
53
|
+
happyskills publish team-deploy --workspace acme --visibility workspace
|
|
47
54
|
happyskills pub my-skill --workspace myorg`
|
|
48
55
|
|
|
56
|
+
const VALID_VISIBILITIES = ['public', 'private', 'workspace']
|
|
57
|
+
|
|
58
|
+
// Resolve the target visibility from flags. --visibility wins when present
|
|
59
|
+
// (validated against the closed set); --public stays as a backward-compatible
|
|
60
|
+
// shorthand; the default is private. Visibility only takes effect on FIRST
|
|
61
|
+
// publish — later publishes preserve whatever the registry already has.
|
|
62
|
+
const resolve_visibility = (flags) => {
|
|
63
|
+
if (flags.visibility) {
|
|
64
|
+
if (!VALID_VISIBILITIES.includes(flags.visibility)) {
|
|
65
|
+
throw new UsageError(`Invalid visibility "${flags.visibility}". Must be one of: ${VALID_VISIBILITIES.join(', ')}.`)
|
|
66
|
+
}
|
|
67
|
+
return flags.visibility
|
|
68
|
+
}
|
|
69
|
+
if (flags.public) return 'public'
|
|
70
|
+
return 'private'
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
const choose_workspace = (workspaces, preferred) => {
|
|
50
74
|
if (preferred) {
|
|
51
75
|
const found = workspaces.find(w => w.slug === preferred)
|
|
@@ -136,7 +160,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
136
160
|
|
|
137
161
|
const spinner = create_spinner('Preparing to publish...')
|
|
138
162
|
|
|
139
|
-
const visibility = args.flags
|
|
163
|
+
const visibility = resolve_visibility(args.flags)
|
|
140
164
|
|
|
141
165
|
let owner = args.flags.workspace
|
|
142
166
|
if (!owner) {
|
|
@@ -348,11 +372,12 @@ const schema = {
|
|
|
348
372
|
input: {
|
|
349
373
|
positional: [ { name: 'skill', required: true, type: 'string', description: 'Name of the installed skill' } ],
|
|
350
374
|
flags: [
|
|
351
|
-
{ name: 'bump',
|
|
352
|
-
{ name: 'workspace',
|
|
353
|
-
{ name: '
|
|
354
|
-
{ name: '
|
|
355
|
-
{ name: '
|
|
375
|
+
{ name: 'bump', type: 'string', default: undefined, description: 'Auto-bump version before publishing (patch, minor, major)' },
|
|
376
|
+
{ name: 'workspace', type: 'string', default: undefined, description: 'Target workspace (overrides lock file owner)' },
|
|
377
|
+
{ name: 'visibility', type: 'string', default: undefined, description: 'Visibility on first publish: private (default), workspace, or public' },
|
|
378
|
+
{ name: 'public', type: 'boolean', default: false, description: 'Shorthand for --visibility public' },
|
|
379
|
+
{ name: 'force', type: 'boolean', default: false, description: 'Bypass divergence check (may overwrite remote changes)' },
|
|
380
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
356
381
|
],
|
|
357
382
|
},
|
|
358
383
|
output: {
|
|
@@ -375,7 +400,8 @@ const schema = {
|
|
|
375
400
|
examples: [
|
|
376
401
|
'happyskills publish my-skill',
|
|
377
402
|
'happyskills publish deploy-aws --bump patch',
|
|
403
|
+
'happyskills publish team-deploy --workspace acme --visibility workspace',
|
|
378
404
|
],
|
|
379
405
|
}
|
|
380
406
|
|
|
381
|
-
module.exports = { run, schema }
|
|
407
|
+
module.exports = { run, schema, resolve_visibility }
|
|
@@ -2,14 +2,15 @@ const { describe, it } = require('node:test')
|
|
|
2
2
|
const assert = require('node:assert')
|
|
3
3
|
const fs = require('node:fs')
|
|
4
4
|
const path = require('node:path')
|
|
5
|
+
const { resolve_visibility } = require('./publish')
|
|
5
6
|
|
|
6
7
|
describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () => {
|
|
7
8
|
const src = fs.readFileSync(path.join(__dirname, 'publish.js'), 'utf8')
|
|
8
9
|
|
|
9
10
|
it('declares `visibility` before passing it to validate_dependencies', () => {
|
|
10
|
-
const decl_idx = src.search(/const\s+visibility\s*=\s*
|
|
11
|
+
const decl_idx = src.search(/const\s+visibility\s*=\s*resolve_visibility\(/)
|
|
11
12
|
const use_idx = src.search(/validate_dependencies\([\s\S]*?visibility/)
|
|
12
|
-
assert.notStrictEqual(decl_idx, -1, '`const visibility =
|
|
13
|
+
assert.notStrictEqual(decl_idx, -1, '`const visibility = resolve_visibility(...)` declaration not found')
|
|
13
14
|
assert.notStrictEqual(use_idx, -1, '`validate_dependencies(... visibility ...)` call not found')
|
|
14
15
|
assert.ok(
|
|
15
16
|
decl_idx < use_idx,
|
|
@@ -19,3 +20,30 @@ describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () =>
|
|
|
19
20
|
)
|
|
20
21
|
})
|
|
21
22
|
})
|
|
23
|
+
|
|
24
|
+
describe('publish.js — resolve_visibility', () => {
|
|
25
|
+
it('defaults to private when no visibility flags are given', () => {
|
|
26
|
+
assert.strictEqual(resolve_visibility({}), 'private')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('maps --public to public (backward-compatible shorthand)', () => {
|
|
30
|
+
assert.strictEqual(resolve_visibility({ public: true }), 'public')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts --visibility workspace', () => {
|
|
34
|
+
assert.strictEqual(resolve_visibility({ visibility: 'workspace' }), 'workspace')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('accepts --visibility private and --visibility public', () => {
|
|
38
|
+
assert.strictEqual(resolve_visibility({ visibility: 'private' }), 'private')
|
|
39
|
+
assert.strictEqual(resolve_visibility({ visibility: 'public' }), 'public')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('lets --visibility win over --public when both are present', () => {
|
|
43
|
+
assert.strictEqual(resolve_visibility({ visibility: 'workspace', public: true }), 'workspace')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws a usage error on an unknown visibility value', () => {
|
|
47
|
+
assert.throws(() => resolve_visibility({ visibility: 'team' }), /Invalid visibility/)
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/commands/pull.js
CHANGED
|
@@ -7,6 +7,7 @@ const { detect_status } = require('../merge/detector')
|
|
|
7
7
|
const { classify_changes } = require('../merge/comparator')
|
|
8
8
|
const { build_report, enrich_file_content, build_resolution_steps } = require('../merge/report')
|
|
9
9
|
const { three_way_merge } = require('../merge/text_merge')
|
|
10
|
+
const { resolve_markers } = require('../merge/marker_resolve')
|
|
10
11
|
const { merge_skill_json } = require('../merge/json_merge')
|
|
11
12
|
const { merge_changelog } = require('../merge/changelog_merge')
|
|
12
13
|
const { hash_blob } = require('../utils/git_hash')
|
|
@@ -117,7 +118,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
117
118
|
// first capture/fast-forward/reapply flow with structured rejection envelopes.
|
|
118
119
|
if (args.flags.rebase) {
|
|
119
120
|
const { rebase_pull } = require('../merge/rebase')
|
|
120
|
-
const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global })
|
|
121
|
+
const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global, full_report })
|
|
121
122
|
if (err) throw err
|
|
122
123
|
if (args.flags.json) {
|
|
123
124
|
print_json({
|
|
@@ -194,6 +195,35 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
194
195
|
const base_dir = skills_dir(is_global, project_root)
|
|
195
196
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
196
197
|
|
|
198
|
+
// Resolve pending conflict markers from a prior pull — local, no registry.
|
|
199
|
+
// A conflicted pull advanced base_commit to the remote head, so a re-pull
|
|
200
|
+
// sees no remote changes and would short-circuit to up_to_date, stranding
|
|
201
|
+
// the markers in place. When the operator supplies a side (--theirs/--ours,
|
|
202
|
+
// global or per-file), resolve those files here, before the remote
|
|
203
|
+
// comparison runs. --force is excluded — it means "re-clone the head", which
|
|
204
|
+
// the fast-forward block below handles.
|
|
205
|
+
const pending_conflicts = Array.isArray(lock_entry.conflict_files) ? lock_entry.conflict_files : []
|
|
206
|
+
const has_pending_resolution = pending_conflicts.length > 0 && strategy !== 'force' &&
|
|
207
|
+
pending_conflicts.some(f => { const s = file_strategy(f); return s === 'theirs' || s === 'ours' })
|
|
208
|
+
if (has_pending_resolution) {
|
|
209
|
+
const [res_err, result] = await resolve_pending_conflicts({
|
|
210
|
+
skill_dir, lock_entry, lock_data, skill_name, file_strategy,
|
|
211
|
+
is_global, project_root, conflict_files: pending_conflicts
|
|
212
|
+
})
|
|
213
|
+
if (res_err) throw res_err[0]
|
|
214
|
+
if (args.flags.json) {
|
|
215
|
+
print_json({ data: result })
|
|
216
|
+
} else {
|
|
217
|
+
print_success(`Resolved ${result.resolved.length} conflicted file(s) in ${skill_name}`)
|
|
218
|
+
if (result.conflict_files.length > 0) {
|
|
219
|
+
print_warn(`${result.conflict_files.length} file(s) still have unresolved conflicts:`)
|
|
220
|
+
for (const f of result.conflict_files) console.error(` - ${f}`)
|
|
221
|
+
print_hint(`Supply a side for them with ${code('--theirs')}/${code('--ours')}, or resolve the markers by hand.`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
const spinner = create_spinner(`Pulling ${skill_name}...`)
|
|
198
228
|
|
|
199
229
|
// 2. Detect local modifications
|
|
@@ -208,8 +238,24 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
208
238
|
const no_remote_changes = cmp_data.added.length === 0 && cmp_data.removed.length === 0 && cmp_data.modified.length === 0
|
|
209
239
|
|
|
210
240
|
// 4. Already up to date?
|
|
211
|
-
|
|
241
|
+
// When --force is set with conflicts still pending, do NOT short-circuit —
|
|
242
|
+
// fall through to the fast-forward block below, which re-clones the head and
|
|
243
|
+
// overwrites (the true meaning of --force).
|
|
244
|
+
if (no_remote_changes && !(strategy === 'force' && pending_conflicts.length > 0)) {
|
|
212
245
|
spinner.stop()
|
|
246
|
+
if (pending_conflicts.length > 0) {
|
|
247
|
+
// Markers from a prior pull are still pending and no per-file
|
|
248
|
+
// resolution strategy was supplied — surface that instead of a
|
|
249
|
+
// misleading up_to_date so the operator knows resolution is pending.
|
|
250
|
+
if (args.flags.json) {
|
|
251
|
+
print_json({ data: { status: 'conflicts', skill: skill_name, conflict_files: pending_conflicts } })
|
|
252
|
+
} else {
|
|
253
|
+
print_warn(`${skill_name} has unresolved conflict markers in ${pending_conflicts.length} file(s):`)
|
|
254
|
+
for (const f of pending_conflicts) console.error(` - ${f}`)
|
|
255
|
+
print_hint(`Re-pull with ${code('--theirs')}/${code('--ours')} to pick a side, or resolve the markers by hand.`)
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
213
259
|
if (args.flags.json) {
|
|
214
260
|
print_json({ data: { status: 'up_to_date', skill: skill_name } })
|
|
215
261
|
} else {
|
|
@@ -611,6 +657,52 @@ const reconcile_dependencies = (skill_dir, skill_name, lock_data, is_global, pro
|
|
|
611
657
|
}
|
|
612
658
|
})
|
|
613
659
|
|
|
660
|
+
// ─── Pending-conflict resolution (marker-based re-pull) ─────────────────────────
|
|
661
|
+
|
|
662
|
+
// Resolve pending conflict markers locally by picking a side per file. Pure
|
|
663
|
+
// disk + lock work — no registry interaction, because the conflicted pull that
|
|
664
|
+
// wrote the markers already advanced base_commit to the remote head. Mirrors
|
|
665
|
+
// the 3-way path's lock-update: recompute integrity, then clear resolved files
|
|
666
|
+
// from conflict_files (deleting the key when none remain → status returns to
|
|
667
|
+
// clean, matching post-merge behavior). Files listed in conflict_files but
|
|
668
|
+
// missing on disk, or without a theirs/ours strategy, stay in conflict_files.
|
|
669
|
+
const resolve_pending_conflicts = ({ skill_dir, lock_entry, lock_data, skill_name, file_strategy, is_global, project_root, conflict_files }) =>
|
|
670
|
+
catch_errors('Failed to resolve pending conflicts', async () => {
|
|
671
|
+
const resolved = []
|
|
672
|
+
const unresolved = []
|
|
673
|
+
for (const rel of conflict_files) {
|
|
674
|
+
const strat = file_strategy(rel)
|
|
675
|
+
if (strat !== 'theirs' && strat !== 'ours') { unresolved.push(rel); continue }
|
|
676
|
+
const full = path.join(skill_dir, rel)
|
|
677
|
+
let text
|
|
678
|
+
try {
|
|
679
|
+
text = await fs.promises.readFile(full, 'utf-8')
|
|
680
|
+
} catch {
|
|
681
|
+
// Listed in conflict_files but missing on disk — cannot resolve here.
|
|
682
|
+
unresolved.push(rel)
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
const side = strat === 'theirs' ? 'remote' : 'local'
|
|
686
|
+
await fs.promises.writeFile(full, resolve_markers(text, side))
|
|
687
|
+
resolved.push(rel)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const [, new_integrity] = await hash_directory(skill_dir)
|
|
691
|
+
const updated_entry = {
|
|
692
|
+
...lock_entry,
|
|
693
|
+
integrity: new_integrity || lock_entry.integrity,
|
|
694
|
+
base_integrity: new_integrity || lock_entry.base_integrity
|
|
695
|
+
}
|
|
696
|
+
if (unresolved.length > 0) updated_entry.conflict_files = unresolved
|
|
697
|
+
else delete updated_entry.conflict_files
|
|
698
|
+
const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
699
|
+
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
700
|
+
if (wl_err) throw wl_err[0]
|
|
701
|
+
|
|
702
|
+
const status = unresolved.length > 0 ? 'conflicts' : 'merged'
|
|
703
|
+
return { status, skill: skill_name, resolved, conflict_files: unresolved }
|
|
704
|
+
})
|
|
705
|
+
|
|
614
706
|
const schema = {
|
|
615
707
|
name: 'pull',
|
|
616
708
|
audience: 'consumer',
|
|
@@ -657,4 +749,4 @@ const schema = {
|
|
|
657
749
|
]
|
|
658
750
|
}
|
|
659
751
|
|
|
660
|
-
module.exports = { run, schema }
|
|
752
|
+
module.exports = { run, schema, resolve_pending_conflicts }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const repos_api = require('../api/repos')
|
|
9
|
+
const pull = require('./pull')
|
|
10
|
+
|
|
11
|
+
// ─── Harness ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const MARKERED_SKILL_MD = [
|
|
14
|
+
'# Deploy AWS',
|
|
15
|
+
'',
|
|
16
|
+
'<<<<<<< LOCAL',
|
|
17
|
+
'local instructions',
|
|
18
|
+
'=======',
|
|
19
|
+
'remote instructions',
|
|
20
|
+
'>>>>>>> REMOTE',
|
|
21
|
+
'',
|
|
22
|
+
'trailing section',
|
|
23
|
+
''
|
|
24
|
+
].join('\n')
|
|
25
|
+
|
|
26
|
+
// Stub the registry surface used by `run`. compare reports no remote changes
|
|
27
|
+
// (base_commit == head), which is the post-conflict state: a prior conflicted
|
|
28
|
+
// pull already advanced base_commit to head. clone returns base files so the
|
|
29
|
+
// pre-fix detect_status fallback (find_modified_files) stays hermetic.
|
|
30
|
+
const with_stubbed_registry = async (head_commit, fn) => {
|
|
31
|
+
const orig = { compare: repos_api.compare, clone: repos_api.clone, get_blob: repos_api.get_blob }
|
|
32
|
+
repos_api.compare = async () => [null, { added: [], removed: [], modified: [], head_commit, head_version: '1.0.1' }]
|
|
33
|
+
repos_api.clone = async () => [null, { ref: 'refs/tags/v1.0.0', commit: head_commit, files: [
|
|
34
|
+
{ path: 'SKILL.md', content: Buffer.from('# Deploy AWS\n').toString('base64'), sha: 'sha-base-skillmd' },
|
|
35
|
+
{ path: 'skill.json', content: Buffer.from('{"name":"deploy-aws","version":"1.0.0"}').toString('base64'), sha: 'sha-base-json' }
|
|
36
|
+
] }]
|
|
37
|
+
repos_api.get_blob = async () => [null, { content: Buffer.from('').toString('base64') }]
|
|
38
|
+
try { return await fn() } finally { Object.assign(repos_api, orig) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const capture_stdout = async (fn) => {
|
|
42
|
+
const orig = console.log
|
|
43
|
+
const lines = []
|
|
44
|
+
console.log = (...a) => lines.push(a.map(String).join(' '))
|
|
45
|
+
try { await fn() } finally { console.log = orig }
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const setup_conflicted_skill = (head_commit, opts = {}) => {
|
|
50
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-pull-test-'))
|
|
51
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
52
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
53
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), opts.skill_md || MARKERED_SKILL_MD)
|
|
54
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'deploy-aws', version: '1.0.0' }, null, '\t'))
|
|
55
|
+
const lock = {
|
|
56
|
+
lockVersion: 2,
|
|
57
|
+
generatedAt: '2026-06-10T00:00:00.000Z',
|
|
58
|
+
skills: {
|
|
59
|
+
'acme/deploy-aws': {
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
ref: 'refs/tags/v1.0.0',
|
|
62
|
+
commit: head_commit,
|
|
63
|
+
integrity: 'sha256-stale',
|
|
64
|
+
base_commit: head_commit,
|
|
65
|
+
base_integrity: 'sha256-stale',
|
|
66
|
+
conflict_files: opts.conflict_files || ['SKILL.md'],
|
|
67
|
+
requested_by: ['__root__'],
|
|
68
|
+
dependencies: {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify(lock, null, 2))
|
|
73
|
+
return { root, skill_dir }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const read_lock_entry = (root) =>
|
|
77
|
+
JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
78
|
+
|
|
79
|
+
const run_pull = (root, head, flags) => with_stubbed_registry(head, () => capture_stdout(async () => {
|
|
80
|
+
const orig_cwd = process.cwd()
|
|
81
|
+
process.chdir(root)
|
|
82
|
+
try { await pull.run({ _: ['acme/deploy-aws'], flags }) } finally { process.chdir(orig_cwd) }
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
// ─── §4.1 repro: dead re-pull paths ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('pull §4.1 — re-pull resolves pending conflict markers', () => {
|
|
88
|
+
it('--theirs resolves markers to the remote side, clears conflict_files, recomputes integrity', async () => {
|
|
89
|
+
const head = 'head_commit_abc'
|
|
90
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
91
|
+
try {
|
|
92
|
+
const out = await run_pull(root, head, { theirs: true, json: true })
|
|
93
|
+
const env = JSON.parse(out)
|
|
94
|
+
assert.strictEqual(env.data.status, 'merged', 'status should be merged after full resolution')
|
|
95
|
+
|
|
96
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
97
|
+
assert.ok(!skill_md.includes('<<<<<<<'), 'LOCAL marker must be gone')
|
|
98
|
+
assert.ok(!skill_md.includes('>>>>>>>'), 'REMOTE marker must be gone')
|
|
99
|
+
assert.ok(!skill_md.includes('======='), 'separator must be gone')
|
|
100
|
+
assert.ok(skill_md.includes('remote instructions'), 'remote side kept')
|
|
101
|
+
assert.ok(!skill_md.includes('local instructions'), 'local side dropped')
|
|
102
|
+
|
|
103
|
+
const entry = read_lock_entry(root)
|
|
104
|
+
assert.ok(!entry.conflict_files, 'conflict_files cleared from lock')
|
|
105
|
+
assert.notStrictEqual(entry.integrity, 'sha256-stale', 'integrity recalculated')
|
|
106
|
+
assert.strictEqual(entry.integrity, entry.base_integrity, 'integrity and base_integrity match (clean)')
|
|
107
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('--ours resolves markers to the local side', async () => {
|
|
111
|
+
const head = 'head_commit_def'
|
|
112
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
113
|
+
try {
|
|
114
|
+
const out = await run_pull(root, head, { ours: true, json: true })
|
|
115
|
+
const env = JSON.parse(out)
|
|
116
|
+
assert.strictEqual(env.data.status, 'merged')
|
|
117
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
118
|
+
assert.ok(skill_md.includes('local instructions'), 'local side kept')
|
|
119
|
+
assert.ok(!skill_md.includes('remote instructions'), 'remote side dropped')
|
|
120
|
+
assert.ok(!skill_md.includes('<<<<<<<'))
|
|
121
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('bare re-pull surfaces the pending conflict state instead of up_to_date', async () => {
|
|
125
|
+
const head = 'head_commit_ghi'
|
|
126
|
+
const { root, skill_dir } = setup_conflicted_skill(head)
|
|
127
|
+
try {
|
|
128
|
+
const out = await run_pull(root, head, { json: true })
|
|
129
|
+
const env = JSON.parse(out)
|
|
130
|
+
assert.strictEqual(env.data.status, 'conflicts', 'bare re-pull must not say up_to_date when conflicts pending')
|
|
131
|
+
assert.deepEqual(env.data.conflict_files, ['SKILL.md'])
|
|
132
|
+
// Markers untouched — nothing was resolved.
|
|
133
|
+
const skill_md = fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8')
|
|
134
|
+
assert.ok(skill_md.includes('<<<<<<<'), 'markers remain on a bare re-pull')
|
|
135
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ─── §4.1 wiring: resolve_pending_conflicts ──────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('pull.resolve_pending_conflicts', () => {
|
|
142
|
+
const make_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-resolve-test-'))
|
|
143
|
+
const block = (l, r) => `<<<<<<< LOCAL\n${l}\n=======\n${r}\n>>>>>>> REMOTE\n`
|
|
144
|
+
|
|
145
|
+
it('applies a per-file strategy mix (theirs for one, ours for another)', async () => {
|
|
146
|
+
const root = make_dir()
|
|
147
|
+
try {
|
|
148
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
149
|
+
fs.mkdirSync(path.join(skill_dir, 'references'), { recursive: true })
|
|
150
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), block('skill-local', 'skill-remote'))
|
|
151
|
+
fs.writeFileSync(path.join(skill_dir, 'references', 'foo.md'), block('foo-local', 'foo-remote'))
|
|
152
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
153
|
+
|
|
154
|
+
const lock_entry = { version: '1.0.0', base_commit: 'h', integrity: 'sha256-stale', base_integrity: 'sha256-stale', conflict_files: ['SKILL.md', 'references/foo.md'] }
|
|
155
|
+
const lock_data = { lockVersion: 2, skills: { 'acme/deploy-aws': lock_entry } }
|
|
156
|
+
const file_strategy = (f) => f === 'SKILL.md' ? 'theirs' : f === 'references/foo.md' ? 'ours' : null
|
|
157
|
+
|
|
158
|
+
const [err, result] = await pull.resolve_pending_conflicts({
|
|
159
|
+
skill_dir, lock_entry, lock_data, skill_name: 'acme/deploy-aws',
|
|
160
|
+
file_strategy, is_global: false, project_root: root, conflict_files: ['SKILL.md', 'references/foo.md']
|
|
161
|
+
})
|
|
162
|
+
assert.ok(!err, 'no error')
|
|
163
|
+
assert.strictEqual(result.status, 'merged')
|
|
164
|
+
assert.deepEqual(result.resolved.sort(), ['SKILL.md', 'references/foo.md'])
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'), 'skill-remote\n', 'theirs → remote')
|
|
167
|
+
assert.strictEqual(fs.readFileSync(path.join(skill_dir, 'references', 'foo.md'), 'utf-8'), 'foo-local\n', 'ours → local')
|
|
168
|
+
|
|
169
|
+
const entry = JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
170
|
+
assert.ok(!entry.conflict_files, 'conflict_files cleared')
|
|
171
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('leaves a file listed in conflict_files but missing on disk unresolved', async () => {
|
|
175
|
+
const root = make_dir()
|
|
176
|
+
try {
|
|
177
|
+
const skill_dir = path.join(root, '.agents', 'skills', 'deploy-aws')
|
|
178
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
179
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), block('a', 'b'))
|
|
180
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{"name":"deploy-aws","version":"1.0.0"}')
|
|
181
|
+
|
|
182
|
+
const lock_entry = { version: '1.0.0', base_commit: 'h', integrity: 'sha256-stale', base_integrity: 'sha256-stale', conflict_files: ['SKILL.md', 'references/gone.md'] }
|
|
183
|
+
const lock_data = { lockVersion: 2, skills: { 'acme/deploy-aws': lock_entry } }
|
|
184
|
+
const file_strategy = () => 'theirs'
|
|
185
|
+
|
|
186
|
+
const [err, result] = await pull.resolve_pending_conflicts({
|
|
187
|
+
skill_dir, lock_entry, lock_data, skill_name: 'acme/deploy-aws',
|
|
188
|
+
file_strategy, is_global: false, project_root: root, conflict_files: ['SKILL.md', 'references/gone.md']
|
|
189
|
+
})
|
|
190
|
+
assert.ok(!err)
|
|
191
|
+
assert.strictEqual(result.status, 'conflicts', 'unresolved file keeps status conflicts')
|
|
192
|
+
assert.deepEqual(result.resolved, ['SKILL.md'])
|
|
193
|
+
assert.deepEqual(result.conflict_files, ['references/gone.md'])
|
|
194
|
+
|
|
195
|
+
const entry = JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')).skills['acme/deploy-aws']
|
|
196
|
+
assert.deepEqual(entry.conflict_files, ['references/gone.md'], 'missing file stays in conflict_files')
|
|
197
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
198
|
+
})
|
|
199
|
+
})
|
package/src/commands/release.js
CHANGED
|
@@ -34,13 +34,15 @@ Options:
|
|
|
34
34
|
--no-bump Refuse to bump; require disk to be already ahead
|
|
35
35
|
--changelog-from <auto|file> Source for the new CHANGELOG entry (default: read from CHANGELOG.md)
|
|
36
36
|
--workspace <slug> Target workspace
|
|
37
|
-
--
|
|
37
|
+
--visibility <value> Visibility on first publish: private (default), workspace, or public
|
|
38
|
+
--public Shorthand for --visibility public
|
|
38
39
|
--dry-run Validate + check status, do not mutate
|
|
39
40
|
--json Output as JSON
|
|
40
41
|
|
|
41
42
|
Examples:
|
|
42
43
|
happyskills release my-skill --workspace acme --json
|
|
43
44
|
happyskills release my-skill --bump patch --workspace acme --json
|
|
45
|
+
happyskills release team-deploy --workspace acme --visibility workspace --json
|
|
44
46
|
happyskills release my-skill --no-bump --json # disk is already ahead`
|
|
45
47
|
|
|
46
48
|
const envelope_error = (code_str, message, extra = {}) => ({ error: { code: code_str, message, ...extra } })
|
|
@@ -384,6 +386,7 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
384
386
|
|
|
385
387
|
const { spawn } = require('child_process')
|
|
386
388
|
const publish_args = [path.resolve(__dirname, '../../bin/happyskills.js'), 'publish', skill_name, '--workspace', resolved_workspace, '--json']
|
|
389
|
+
if (args.flags.visibility) publish_args.push('--visibility', args.flags.visibility)
|
|
387
390
|
if (args.flags.public) publish_args.push('--public')
|
|
388
391
|
if (args.flags.force) publish_args.push('--force')
|
|
389
392
|
|
|
@@ -494,7 +497,8 @@ const schema = {
|
|
|
494
497
|
{ name: 'no-bump', type: 'boolean', default: false, description: 'Refuse to bump; require disk to already be ahead' },
|
|
495
498
|
{ name: 'changelog-from', type: 'string', default: undefined, description: 'Source for the new CHANGELOG entry (auto or file path)' },
|
|
496
499
|
{ name: 'workspace', type: 'string', default: undefined, description: 'Target workspace slug' },
|
|
497
|
-
{ name: '
|
|
500
|
+
{ name: 'visibility', type: 'string', default: undefined, description: 'Visibility on first publish: private (default), workspace, or public' },
|
|
501
|
+
{ name: 'public', type: 'boolean', default: false, description: 'Shorthand for --visibility public' },
|
|
498
502
|
{ name: 'dry-run', type: 'boolean', default: false, description: 'Validate and check status without mutating' },
|
|
499
503
|
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
500
504
|
],
|
package/src/config/limits.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// validation will be rejected by the API.
|
|
4
4
|
|
|
5
5
|
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB per file
|
|
6
|
-
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle
|
|
6
|
+
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle (also the decompressed-archive cap — SUP-04)
|
|
7
|
+
const MAX_FILE_COUNT = 1000 // max entries per archive (zip-bomb / DoS guard; real skills have <100 files) — SUP-04
|
|
7
8
|
|
|
8
9
|
module.exports = {
|
|
9
10
|
MAX_FILE_SIZE,
|
|
10
|
-
MAX_TOTAL_SIZE
|
|
11
|
+
MAX_TOTAL_SIZE,
|
|
12
|
+
MAX_FILE_COUNT
|
|
11
13
|
}
|
package/src/config/paths.js
CHANGED
|
@@ -22,6 +22,18 @@ const skills_dir = (global = false, project_root) => {
|
|
|
22
22
|
return global ? global_skills_dir() : project_skills_dir(project_root)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// NEW-C3 (spec 260603-02 §4 Tier 3). The install-dir name is server-derived; a
|
|
26
|
+
// `..` or absolute component would let it escape the skills directory and write
|
|
27
|
+
// elsewhere on disk. Defense-in-depth: confirm the joined path stays under base.
|
|
28
|
+
const assert_within = (base, full) => {
|
|
29
|
+
const r_base = path.resolve(base)
|
|
30
|
+
const r_full = path.resolve(full)
|
|
31
|
+
if (r_full !== r_base && !r_full.startsWith(r_base + path.sep)) {
|
|
32
|
+
throw new Error(`Unsafe install path "${full}": escapes the skills directory ${base}`)
|
|
33
|
+
}
|
|
34
|
+
return full
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const tmp_dir = (base_skills_dir) => path.join(base_skills_dir, '.tmp')
|
|
26
38
|
|
|
27
39
|
const install_lock_path = (base_skills_dir) => path.join(base_skills_dir, '.install.lock')
|
|
@@ -32,7 +44,7 @@ const lock_root = (is_global, project_root) => {
|
|
|
32
44
|
|
|
33
45
|
const lock_file_path = (project_root = process.cwd()) => path.join(project_root, 'skills-lock.json')
|
|
34
46
|
|
|
35
|
-
const skill_install_dir = (base_skills_dir, name) => path.join(base_skills_dir, name)
|
|
47
|
+
const skill_install_dir = (base_skills_dir, name) => assert_within(base_skills_dir, path.join(base_skills_dir, name))
|
|
36
48
|
|
|
37
49
|
const find_project_root = (start_dir = process.cwd()) => path.resolve(start_dir)
|
|
38
50
|
|
|
@@ -41,8 +53,10 @@ const agent_skills_dir = (agent, global = false, project_root) => {
|
|
|
41
53
|
return path.join(project_root || process.cwd(), agent.skills_dir)
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
const agent_skill_install_dir = (agent, global, project_root, skill_name) =>
|
|
45
|
-
|
|
56
|
+
const agent_skill_install_dir = (agent, global, project_root, skill_name) => {
|
|
57
|
+
const base = agent_skills_dir(agent, global, project_root)
|
|
58
|
+
return assert_within(base, path.join(base, skill_name))
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
module.exports = {
|
|
48
62
|
home_dir,
|
|
@@ -59,5 +73,6 @@ module.exports = {
|
|
|
59
73
|
skill_install_dir,
|
|
60
74
|
find_project_root,
|
|
61
75
|
agent_skills_dir,
|
|
62
|
-
agent_skill_install_dir
|
|
76
|
+
agent_skill_install_dir,
|
|
77
|
+
assert_within
|
|
63
78
|
}
|
package/src/config/paths.test.js
CHANGED
|
@@ -143,4 +143,19 @@ describe('paths', () => {
|
|
|
143
143
|
assert.strictEqual(paths.find_project_root(), path.resolve(process.cwd()))
|
|
144
144
|
})
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
// NEW-C3 — the server-derived install-dir name must not escape the skills dir.
|
|
148
|
+
describe('install-dir path safety (NEW-C3)', () => {
|
|
149
|
+
const base = path.join(os.tmpdir(), 'hs-skills-base')
|
|
150
|
+
|
|
151
|
+
it('allows a normal (possibly nested) skill dir under base', () => {
|
|
152
|
+
assert.strictEqual(paths.skill_install_dir(base, 'owner/skill'), path.join(base, 'owner/skill'))
|
|
153
|
+
assert.strictEqual(paths.assert_within(base, path.join(base, 'a', 'b')), path.join(base, 'a', 'b'))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('rejects a name that traverses out of base', () => {
|
|
157
|
+
assert.throws(() => paths.skill_install_dir(base, '../../etc/evil'), /escapes the skills directory/)
|
|
158
|
+
assert.throws(() => paths.assert_within(base, '/etc/passwd'), /escapes/)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
146
161
|
})
|