happyskills 1.12.0 → 1.13.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 +13 -0
- package/package.json +1 -1
- package/src/api/client.js +13 -3
- package/src/commands/list.js +177 -94
- package/src/commands/update.js +155 -94
- package/src/constants/next_step_by_error_code.js +18 -5
- package/src/constants/next_step_by_error_code.test.js +17 -1
- package/src/index.js +23 -0
- package/src/integration/list_scopes.test.js +171 -0
- package/src/integration/update_scopes.test.js +100 -0
- package/src/ui/envelope.js +21 -1
- package/src/ui/envelope.test.js +63 -0
- package/src/utils/skill_update_check.js +150 -0
- package/src/utils/skill_update_check.test.js +118 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills list --all-scopes`.
|
|
4
|
+
*
|
|
5
|
+
* `list` and `list -g` each show ONE scope (project or global). `--all-scopes`
|
|
6
|
+
* merges both into a single view, tagging every entry with:
|
|
7
|
+
* - `scope` → "local" (project) | "global" (~/.agents)
|
|
8
|
+
* - `native` → true for HappySkills-managed/draft skills, false for
|
|
9
|
+
* manually-added foreign skills (external / agent-orphan)
|
|
10
|
+
*
|
|
11
|
+
* The global scope is isolated per-test by pointing $HOME at a temp dir — Node's
|
|
12
|
+
* os.homedir() honours $HOME on POSIX, and paths.js derives ~/.agents from it.
|
|
13
|
+
*
|
|
14
|
+
* Guards:
|
|
15
|
+
* - --all-scopes returns `data.skills` as an ARRAY carrying both scopes.
|
|
16
|
+
* - every entry carries the right `scope` + `native` flags.
|
|
17
|
+
* - foreign (no skill.json) skills land in `external` with native:false.
|
|
18
|
+
* - the default (single-scope) shape is unchanged: `data.skills` is an OBJECT
|
|
19
|
+
* and a global-only skill does NOT leak into the project listing.
|
|
20
|
+
*/
|
|
21
|
+
const { describe, it } = require('node:test')
|
|
22
|
+
const assert = require('node:assert/strict')
|
|
23
|
+
const { spawnSync } = require('child_process')
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
const { parse_envelope, assert_success_envelope } = require('../schema/envelope_test_helpers')
|
|
28
|
+
|
|
29
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
30
|
+
const NODE = process.execPath
|
|
31
|
+
|
|
32
|
+
const make_tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `happyskills-${tag}-`))
|
|
33
|
+
|
|
34
|
+
const run = (args, opts) => {
|
|
35
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
36
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
37
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
|
|
41
|
+
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
42
|
+
CI: '',
|
|
43
|
+
...(opts?.env || {}),
|
|
44
|
+
},
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
cwd: opts?.cwd,
|
|
48
|
+
})
|
|
49
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parse_json = (stdout, label) => {
|
|
53
|
+
const env = parse_envelope(stdout, label)
|
|
54
|
+
assert_success_envelope(env, label)
|
|
55
|
+
return env
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Write a HappySkills-shaped (native) skill onto disk under <skills_base>/<short>.
|
|
59
|
+
const write_native_skill = (skills_base, short, version) => {
|
|
60
|
+
const dir = path.join(skills_base, short)
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: test skill\n---\nBody\n`)
|
|
63
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify({ name: short, version, type: 'skill', description: 'test skill' }, null, '\t'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Write a foreign skill (SKILL.md, NO skill.json) → classified external/native:false.
|
|
67
|
+
const write_foreign_skill = (skills_base, short) => {
|
|
68
|
+
const dir = path.join(skills_base, short)
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
70
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: foreign skill\n---\nBody\n`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const write_lock = (lock_path, entries) => {
|
|
74
|
+
const skills = {}
|
|
75
|
+
for (const [full, version] of entries) {
|
|
76
|
+
skills[full] = {
|
|
77
|
+
version, type: 'skill', ref: `refs/tags/v${version}`, commit: 'c',
|
|
78
|
+
integrity: null, base_commit: 'c', base_integrity: null,
|
|
79
|
+
requested_by: ['__root__'], dependencies: {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fs.writeFileSync(lock_path, JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Scaffold both scopes: a project root (cwd) and an isolated home ($HOME).
|
|
86
|
+
const scaffold = () => {
|
|
87
|
+
const project_root = make_tmp('list-proj')
|
|
88
|
+
const home_dir = make_tmp('list-home')
|
|
89
|
+
|
|
90
|
+
// Project scope: one managed native skill + one foreign skill.
|
|
91
|
+
const proj_skills = path.join(project_root, '.agents', 'skills')
|
|
92
|
+
write_native_skill(proj_skills, 'local-skill', '1.0.0')
|
|
93
|
+
write_foreign_skill(proj_skills, 'foreign-local')
|
|
94
|
+
write_lock(path.join(project_root, 'skills-lock.json'), [['acme/local-skill', '1.0.0']])
|
|
95
|
+
|
|
96
|
+
// Global scope: one managed native skill (lock lives at ~/.agents/skills-lock.json).
|
|
97
|
+
const global_skills = path.join(home_dir, '.agents', 'skills')
|
|
98
|
+
write_native_skill(global_skills, 'global-skill', '2.1.0')
|
|
99
|
+
write_lock(path.join(home_dir, '.agents', 'skills-lock.json'), [['acme/global-skill', '2.1.0']])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
project_root,
|
|
103
|
+
home_dir,
|
|
104
|
+
cleanup: () => {
|
|
105
|
+
fs.rmSync(project_root, { recursive: true, force: true })
|
|
106
|
+
fs.rmSync(home_dir, { recursive: true, force: true })
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('list --all-scopes', () => {
|
|
112
|
+
it('merges project + global skills into an array, each tagged with scope + native', () => {
|
|
113
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
114
|
+
try {
|
|
115
|
+
const { code, stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
116
|
+
assert.strictEqual(code, 0, 'list --all-scopes should exit 0')
|
|
117
|
+
const out = parse_json(stdout, 'list --all-scopes --json')
|
|
118
|
+
|
|
119
|
+
assert.ok(Array.isArray(out.data.skills), 'data.skills must be an ARRAY in --all-scopes mode')
|
|
120
|
+
|
|
121
|
+
const local = out.data.skills.find(s => s.name === 'acme/local-skill')
|
|
122
|
+
assert.ok(local, 'project-local managed skill must be present')
|
|
123
|
+
assert.strictEqual(local.scope, 'local')
|
|
124
|
+
assert.strictEqual(local.native, true)
|
|
125
|
+
assert.strictEqual(local.version, '1.0.0')
|
|
126
|
+
|
|
127
|
+
const global = out.data.skills.find(s => s.name === 'acme/global-skill')
|
|
128
|
+
assert.ok(global, 'global managed skill must be present in the SAME view')
|
|
129
|
+
assert.strictEqual(global.scope, 'global')
|
|
130
|
+
assert.strictEqual(global.native, true)
|
|
131
|
+
assert.strictEqual(global.version, '2.1.0')
|
|
132
|
+
} finally { cleanup() }
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('classifies a foreign (no skill.json) skill as external + native:false, scoped local', () => {
|
|
136
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
137
|
+
try {
|
|
138
|
+
const { stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
139
|
+
const out = parse_json(stdout, 'list --all-scopes --json external')
|
|
140
|
+
assert.ok(Array.isArray(out.data.external))
|
|
141
|
+
const foreign = out.data.external.find(s => s.name === 'foreign-local')
|
|
142
|
+
assert.ok(foreign, 'foreign skill must appear under data.external')
|
|
143
|
+
assert.strictEqual(foreign.scope, 'local')
|
|
144
|
+
assert.strictEqual(foreign.native, false)
|
|
145
|
+
} finally { cleanup() }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('default (single-scope) list keeps the object shape and does NOT leak global skills', () => {
|
|
149
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = run(['list', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
152
|
+
const out = parse_json(stdout, 'list --json default')
|
|
153
|
+
assert.ok(!Array.isArray(out.data.skills) && typeof out.data.skills === 'object', 'default data.skills must remain an OBJECT')
|
|
154
|
+
assert.ok(out.data.skills['acme/local-skill'], 'project skill present')
|
|
155
|
+
assert.ok(!out.data.skills['acme/global-skill'], 'global skill must NOT appear in the project-scoped listing')
|
|
156
|
+
// Backward-compat: no scope/native fields injected in single-scope mode.
|
|
157
|
+
assert.strictEqual(out.data.skills['acme/local-skill'].scope, undefined)
|
|
158
|
+
assert.strictEqual(out.data.skills['acme/local-skill'].native, undefined)
|
|
159
|
+
} finally { cleanup() }
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('list -g shows only the global scope (object shape)', () => {
|
|
163
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = run(['list', '-g', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
166
|
+
const out = parse_json(stdout, 'list -g --json')
|
|
167
|
+
assert.ok(out.data.skills['acme/global-skill'], 'global skill present under -g')
|
|
168
|
+
assert.ok(!out.data.skills['acme/local-skill'], 'project skill must NOT appear under -g')
|
|
169
|
+
} finally { cleanup() }
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills update --all-scopes`.
|
|
4
|
+
*
|
|
5
|
+
* `update` / `update -g` act on a single scope. `--all-scopes` runs the update
|
|
6
|
+
* for BOTH project-local and global skills (local first, then global) and
|
|
7
|
+
* reports a per-scope result envelope — the mission's "transparency over magic"
|
|
8
|
+
* applied to a cross-scope write: the caller can see exactly what happened in
|
|
9
|
+
* each scope, including partial failure.
|
|
10
|
+
*
|
|
11
|
+
* The global scope is isolated per-test via $HOME (os.homedir() honours it on
|
|
12
|
+
* POSIX). With no skills installed in either scope, the update short-circuits
|
|
13
|
+
* before any registry call, so these tests are deterministic and offline.
|
|
14
|
+
*
|
|
15
|
+
* Guards:
|
|
16
|
+
* - --all-scopes returns `{ all_scopes: true, scopes: [local, global], ... }`.
|
|
17
|
+
* - each scope entry is tagged with its `scope` and carries the per-scope payload.
|
|
18
|
+
* - the single-scope shape is unchanged (no `all_scopes` / `scopes` keys).
|
|
19
|
+
*/
|
|
20
|
+
const { describe, it } = require('node:test')
|
|
21
|
+
const assert = require('node:assert/strict')
|
|
22
|
+
const { spawnSync } = require('child_process')
|
|
23
|
+
const fs = require('fs')
|
|
24
|
+
const os = require('os')
|
|
25
|
+
const path = require('path')
|
|
26
|
+
const { parse_envelope, assert_success_envelope } = require('../schema/envelope_test_helpers')
|
|
27
|
+
|
|
28
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
29
|
+
const NODE = process.execPath
|
|
30
|
+
|
|
31
|
+
const make_tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `happyskills-${tag}-`))
|
|
32
|
+
|
|
33
|
+
const run = (args, opts) => {
|
|
34
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
35
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
36
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
|
|
40
|
+
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
41
|
+
CI: '',
|
|
42
|
+
...(opts?.env || {}),
|
|
43
|
+
},
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
timeout: 10000,
|
|
46
|
+
cwd: opts?.cwd,
|
|
47
|
+
})
|
|
48
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parse_json = (stdout, label) => {
|
|
52
|
+
const env = parse_envelope(stdout, label)
|
|
53
|
+
assert_success_envelope(env, label)
|
|
54
|
+
return env
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('update --all-scopes', () => {
|
|
58
|
+
it('returns a per-scope envelope covering both local and global', () => {
|
|
59
|
+
const project_root = make_tmp('up-proj')
|
|
60
|
+
const home_dir = make_tmp('up-home')
|
|
61
|
+
try {
|
|
62
|
+
const { code, stdout } = run(['update', '--all', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
63
|
+
assert.strictEqual(code, 0, 'update --all-scopes should exit 0')
|
|
64
|
+
const out = parse_json(stdout, 'update --all --all-scopes --json')
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(out.data.all_scopes, true)
|
|
67
|
+
assert.ok(Array.isArray(out.data.scopes), 'data.scopes must be an array')
|
|
68
|
+
assert.strictEqual(out.data.scopes.length, 2)
|
|
69
|
+
assert.deepStrictEqual(out.data.scopes.map(s => s.scope), ['local', 'global'])
|
|
70
|
+
// Each scope carries its own per-scope payload.
|
|
71
|
+
for (const s of out.data.scopes) {
|
|
72
|
+
assert.ok(Array.isArray(s.results), `scope ${s.scope} must carry results[]`)
|
|
73
|
+
assert.strictEqual(typeof s.outdated_count, 'number')
|
|
74
|
+
}
|
|
75
|
+
// Convenience aggregates.
|
|
76
|
+
assert.strictEqual(out.data.updated_count, 0)
|
|
77
|
+
assert.strictEqual(out.data.outdated_count, 0)
|
|
78
|
+
} finally {
|
|
79
|
+
fs.rmSync(project_root, { recursive: true, force: true })
|
|
80
|
+
fs.rmSync(home_dir, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('single-scope update keeps the historical shape (no all_scopes / scopes keys)', () => {
|
|
85
|
+
const project_root = make_tmp('up-proj')
|
|
86
|
+
const home_dir = make_tmp('up-home')
|
|
87
|
+
try {
|
|
88
|
+
const { code, stdout } = run(['update', '--all', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
89
|
+
assert.strictEqual(code, 0)
|
|
90
|
+
const out = parse_json(stdout, 'update --all --json single')
|
|
91
|
+
assert.strictEqual(out.data.all_scopes, undefined, 'single-scope output must not carry all_scopes')
|
|
92
|
+
assert.strictEqual(out.data.scopes, undefined, 'single-scope output must not carry scopes[]')
|
|
93
|
+
assert.ok(Array.isArray(out.data.results))
|
|
94
|
+
assert.strictEqual(out.data.outdated_count, 0)
|
|
95
|
+
} finally {
|
|
96
|
+
fs.rmSync(project_root, { recursive: true, force: true })
|
|
97
|
+
fs.rmSync(home_dir, { recursive: true, force: true })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/ui/envelope.js
CHANGED
|
@@ -18,11 +18,23 @@ const { validate_envelope } = require('../schema/envelope_validator')
|
|
|
18
18
|
let command_name = '<unknown>'
|
|
19
19
|
let workspace = null
|
|
20
20
|
let request_id = null
|
|
21
|
+
// Cross-cutting drift verdict — set once by index.js (from the cached, fire-and-forget
|
|
22
|
+
// skill-update probe) and appended as a warnings[] entry to EVERY command's envelope.
|
|
23
|
+
// Same module-state pattern as command/workspace: avoids threading it through call
|
|
24
|
+
// sites and is what makes the nudge fire "regardless of command". null = nothing to say.
|
|
25
|
+
let skill_drift = null
|
|
21
26
|
const set_command = (name) => { command_name = name || '<unknown>' }
|
|
22
27
|
const set_workspace = (slug) => { workspace = slug || null }
|
|
23
28
|
const set_request_id = (id) => { request_id = id || null }
|
|
29
|
+
const set_skill_drift = (verdict) => {
|
|
30
|
+
skill_drift = (verdict && Array.isArray(verdict.outdated) && verdict.outdated.length) ? verdict : null
|
|
31
|
+
}
|
|
24
32
|
const get_command = () => command_name
|
|
25
33
|
|
|
34
|
+
// Human-readable advisory for the SKILLS_UPDATE_AVAILABLE warning — shared with the
|
|
35
|
+
// stderr nudge in index.js so the two faces of the verdict never diverge.
|
|
36
|
+
const { format_skill_drift } = require('../utils/skill_update_check')
|
|
37
|
+
|
|
26
38
|
const is_plain_object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
|
|
27
39
|
|
|
28
40
|
// Generic instructions per action — used to auto-fill the dependentRequired
|
|
@@ -102,6 +114,13 @@ const build_envelope = ({
|
|
|
102
114
|
const raw_next_step = is_plain_object(next_step) && Object.keys(next_step).length > 0 ? next_step : {}
|
|
103
115
|
const safe_next_step = enrich_next_step(raw_next_step)
|
|
104
116
|
const safe_warnings = Array.isArray(warnings) ? warnings : []
|
|
117
|
+
// Append the cross-cutting drift advisory (if any) to the command's own
|
|
118
|
+
// warnings — never touches `data` (the command's actual response), so it
|
|
119
|
+
// cannot collide with it. `code` is a free-form Warning string, not a closed
|
|
120
|
+
// enum, so this needs no constants mirror and no schema-version bump.
|
|
121
|
+
const drift_warnings = skill_drift
|
|
122
|
+
? [{ code: 'SKILLS_UPDATE_AVAILABLE', message: format_skill_drift(skill_drift) }]
|
|
123
|
+
: []
|
|
105
124
|
|
|
106
125
|
const ok = !('code' in safe_error)
|
|
107
126
|
const exit_code = ok
|
|
@@ -125,7 +144,7 @@ const build_envelope = ({
|
|
|
125
144
|
data: safe_data,
|
|
126
145
|
error: safe_error,
|
|
127
146
|
next_step: safe_next_step,
|
|
128
|
-
warnings: safe_warnings,
|
|
147
|
+
warnings: [...safe_warnings, ...drift_warnings],
|
|
129
148
|
meta,
|
|
130
149
|
}
|
|
131
150
|
|
|
@@ -167,6 +186,7 @@ module.exports = {
|
|
|
167
186
|
set_command,
|
|
168
187
|
set_workspace,
|
|
169
188
|
set_request_id,
|
|
189
|
+
set_skill_drift,
|
|
170
190
|
get_command,
|
|
171
191
|
json_active,
|
|
172
192
|
ENVELOPE_SCHEMA_VERSION,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const { test } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
|
|
4
|
+
const { build_envelope, set_skill_drift } = require('./envelope')
|
|
5
|
+
|
|
6
|
+
// set_skill_drift mutates module state shared across the process, so every test
|
|
7
|
+
// resets it to null on the way out to stay independent of ordering.
|
|
8
|
+
const with_drift = (verdict, fn) => {
|
|
9
|
+
set_skill_drift(verdict)
|
|
10
|
+
try { fn() }
|
|
11
|
+
finally { set_skill_drift(null) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('no drift set → warnings stays exactly as the command passed it', () => {
|
|
15
|
+
with_drift(null, () => {
|
|
16
|
+
const env = build_envelope({ data: { skills: {} } })
|
|
17
|
+
assert.deepStrictEqual(env.warnings, [])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('drift set → appends a SKILLS_UPDATE_AVAILABLE warning without touching data', () => {
|
|
22
|
+
const verdict = { outdated: [
|
|
23
|
+
{ skill: 'acme/x', installed: '1.0.0', latest: '1.1.0' },
|
|
24
|
+
{ skill: 'acme/y', installed: '2.0.0', latest: '2.1.0' },
|
|
25
|
+
] }
|
|
26
|
+
with_drift(verdict, () => {
|
|
27
|
+
const env = build_envelope({ data: { skills: { a: 1 } } })
|
|
28
|
+
// data (the command's real response) is untouched
|
|
29
|
+
assert.deepStrictEqual(env.data, { skills: { a: 1 } })
|
|
30
|
+
// the envelope still has exactly the six canonical keys
|
|
31
|
+
assert.deepStrictEqual(Object.keys(env).sort(), ['data', 'error', 'meta', 'next_step', 'ok', 'warnings'])
|
|
32
|
+
// the advisory rode along in warnings
|
|
33
|
+
assert.strictEqual(env.warnings.length, 1)
|
|
34
|
+
assert.strictEqual(env.warnings[0].code, 'SKILLS_UPDATE_AVAILABLE')
|
|
35
|
+
assert.match(env.warnings[0].message, /2 installed skill\(s\) behind the registry/)
|
|
36
|
+
assert.match(env.warnings[0].message, /acme\/x, acme\/y/)
|
|
37
|
+
assert.match(env.warnings[0].message, /npx happyskills update --all/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('drift advisory is appended after the command-supplied warnings', () => {
|
|
42
|
+
with_drift({ outdated: [{ skill: 'acme/x', installed: '1.0.0', latest: '1.1.0' }] }, () => {
|
|
43
|
+
const env = build_envelope({ warnings: [{ code: 'OWN_WARNING', message: 'from the command' }] })
|
|
44
|
+
assert.strictEqual(env.warnings.length, 2)
|
|
45
|
+
assert.strictEqual(env.warnings[0].code, 'OWN_WARNING')
|
|
46
|
+
assert.strictEqual(env.warnings[1].code, 'SKILLS_UPDATE_AVAILABLE')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('more than three outdated skills collapse to a "+N more" summary', () => {
|
|
51
|
+
const outdated = ['a', 'b', 'c', 'd', 'e'].map(n => ({ skill: `acme/${n}`, installed: '1.0.0', latest: '1.1.0' }))
|
|
52
|
+
with_drift({ outdated }, () => {
|
|
53
|
+
const env = build_envelope({})
|
|
54
|
+
assert.match(env.warnings[0].message, /\+2 more/)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('set_skill_drift ignores an empty outdated list', () => {
|
|
59
|
+
with_drift({ outdated: [] }, () => {
|
|
60
|
+
const env = build_envelope({})
|
|
61
|
+
assert.deepStrictEqual(env.warnings, [])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const os = require('os')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
// Passive, non-blocking drift awareness for INSTALLED skills — the registry-side
|
|
6
|
+
// analogue of utils/update_check.js (which checks npm for a newer CLI). The shown
|
|
7
|
+
// verdict is ALWAYS read synchronously from a 24h cache (zero latency); a stale
|
|
8
|
+
// cache fires a fire-and-forget background refresh whose result is used next time.
|
|
9
|
+
// The current command never waits on the network. Mirrors update_check.js: this is
|
|
10
|
+
// the second sanctioned exception to the catch_errors golden rule — every failure
|
|
11
|
+
// path is swallowed because a drift probe must never break the command it rode in on.
|
|
12
|
+
|
|
13
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
14
|
+
|
|
15
|
+
const get_cache_path = () => {
|
|
16
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
17
|
+
? path.join(process.env.XDG_CONFIG_HOME, 'happyskills')
|
|
18
|
+
: path.join(os.homedir(), '.config', 'happyskills')
|
|
19
|
+
return path.join(base, 'skill-update-check.json')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The cache is a map keyed by scope, because a project's installed skills differ
|
|
23
|
+
// from the global set and from another project's — unlike the CLI self-check, which
|
|
24
|
+
// is one-version-per-machine. Each scope entry: { outdated: [...], checked_at }.
|
|
25
|
+
const scope_key = ({ is_global, project_root }) => is_global ? 'global' : `project:${project_root}`
|
|
26
|
+
|
|
27
|
+
const read_cache = () => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(get_cache_path(), 'utf8'))
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const write_scope = (key, outdated) => {
|
|
36
|
+
try {
|
|
37
|
+
const cache = read_cache() || {}
|
|
38
|
+
cache[key] = { outdated, checked_at: Date.now() }
|
|
39
|
+
const cache_path = get_cache_path()
|
|
40
|
+
fs.mkdirSync(path.dirname(cache_path), { recursive: true })
|
|
41
|
+
fs.writeFileSync(cache_path, JSON.stringify(cache), 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
// silently ignore write errors
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
|
|
48
|
+
// (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
|
|
49
|
+
// — unwanted noise from a silent background task), batch-checks root skills against
|
|
50
|
+
// the registry, and records ONLY the genuinely-outdated ones. It never reads the
|
|
51
|
+
// working tree, so it can never mistake an `ahead` (locally-bumped, unpublished) or
|
|
52
|
+
// regression-drifted skill for "outdated": a local bump leaves base_commit untouched,
|
|
53
|
+
// so only a real registry advance is recorded (the §2.4 guardrail, enforced here).
|
|
54
|
+
const refresh_cache = (key, opts) => {
|
|
55
|
+
;(async () => {
|
|
56
|
+
const { read_json } = require('./fs')
|
|
57
|
+
const { lock_file_path, lock_root } = require('../config/paths')
|
|
58
|
+
const repos_api = require('../api/repos')
|
|
59
|
+
|
|
60
|
+
const lock_path = lock_file_path(lock_root(opts.is_global, opts.project_root))
|
|
61
|
+
const [, lock_data] = await read_json(lock_path)
|
|
62
|
+
const skills = (lock_data && lock_data.skills) || {}
|
|
63
|
+
const candidates = Object.entries(skills)
|
|
64
|
+
.filter(([, d]) => d && Array.isArray(d.requested_by) && d.requested_by.includes('__root__'))
|
|
65
|
+
|
|
66
|
+
if (candidates.length === 0) { write_scope(key, []); return }
|
|
67
|
+
|
|
68
|
+
const [err, batch] = await repos_api.check_updates(candidates.map(([name]) => name))
|
|
69
|
+
if (err) return // leave the cache untouched; retry on the next stale invocation
|
|
70
|
+
|
|
71
|
+
const outdated = []
|
|
72
|
+
for (const [name, data] of candidates) {
|
|
73
|
+
const info = batch && batch.results && batch.results[name]
|
|
74
|
+
if (!info || info.access_denied || !info.latest_version) continue
|
|
75
|
+
const registry_advanced = data.base_commit && info.commit
|
|
76
|
+
? data.base_commit !== info.commit
|
|
77
|
+
: info.latest_version !== data.version // fallback for pre-base_commit locks
|
|
78
|
+
if (registry_advanced) outdated.push({ skill: name, installed: data.version, latest: info.latest_version })
|
|
79
|
+
}
|
|
80
|
+
write_scope(key, outdated)
|
|
81
|
+
})().catch(() => {})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Synchronous — reads the cache and returns the verdict immediately, then fires a
|
|
85
|
+
// background refresh for any stale scope. ALWAYS checks BOTH the project-local and the
|
|
86
|
+
// global lock, because a globally-installed constellation is "always there" and should
|
|
87
|
+
// be nudged about even during project-scoped work (and vice versa). The shown verdict
|
|
88
|
+
// is the merged, name-deduped set of outdated skills across both scopes; `has_local` /
|
|
89
|
+
// `has_global` record which scopes contributed, so the message can point at the right
|
|
90
|
+
// command (`update --all` vs `update --all -g`). Returns null when nothing is outdated.
|
|
91
|
+
const check_skills_for_update = (opts) => {
|
|
92
|
+
if (process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK === '1') return null
|
|
93
|
+
// Integration tests spawn the CLI against http://localhost:0; never probe there
|
|
94
|
+
// (and never let a developer's real cache leak into an envelope-shape assertion).
|
|
95
|
+
const api = process.env.HAPPYSKILLS_API_URL || ''
|
|
96
|
+
if (api.includes('localhost:0') || api.includes('127.0.0.1:0')) return null
|
|
97
|
+
|
|
98
|
+
const project_root = opts.project_root
|
|
99
|
+
const cache = read_cache() || {}
|
|
100
|
+
const scopes = [
|
|
101
|
+
{ is_global: false, project_root },
|
|
102
|
+
{ is_global: true, project_root },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
// Tag each outdated item with the scope it came from BEFORE deduping, so
|
|
106
|
+
// has_local / has_global stay accurate even for a skill outdated in both.
|
|
107
|
+
const raw = []
|
|
108
|
+
for (const s of scopes) {
|
|
109
|
+
const key = scope_key(s)
|
|
110
|
+
const entry = cache[key]
|
|
111
|
+
const is_stale = !entry || !entry.checked_at || (Date.now() - entry.checked_at > CACHE_TTL_MS)
|
|
112
|
+
if (is_stale) refresh_cache(key, s)
|
|
113
|
+
if (entry && Array.isArray(entry.outdated)) {
|
|
114
|
+
const label = s.is_global ? 'global' : 'local'
|
|
115
|
+
for (const item of entry.outdated) raw.push({ ...item, scope: label })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const has_local = raw.some(i => i.scope === 'local')
|
|
120
|
+
const has_global = raw.some(i => i.scope === 'global')
|
|
121
|
+
|
|
122
|
+
// Dedupe by skill name for the display list / count (a skill installed in both
|
|
123
|
+
// scopes is one concern); has_local/has_global above already captured both.
|
|
124
|
+
const seen = new Set()
|
|
125
|
+
const outdated = []
|
|
126
|
+
for (const item of raw) {
|
|
127
|
+
if (seen.has(item.skill)) continue
|
|
128
|
+
seen.add(item.skill)
|
|
129
|
+
outdated.push(item)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return outdated.length ? { outdated, has_local, has_global } : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Shared human-readable advisory — used by BOTH the stderr nudge (index.js, text mode)
|
|
136
|
+
// and the SKILLS_UPDATE_AVAILABLE envelope warning (ui/envelope.js, JSON mode), so the
|
|
137
|
+
// two faces never drift. The command hint is scope-aware: `-g` is added only when the
|
|
138
|
+
// outdated set actually includes globally-installed skills.
|
|
139
|
+
const format_skill_drift = (verdict) => {
|
|
140
|
+
const items = verdict.outdated
|
|
141
|
+
const names = items.slice(0, 3).map(s => s.skill).join(', ')
|
|
142
|
+
const more = items.length > 3 ? `, +${items.length - 3} more` : ''
|
|
143
|
+
let cmd
|
|
144
|
+
if (verdict.has_global && verdict.has_local) cmd = 'npx happyskills update --all (and add -g for the global ones)'
|
|
145
|
+
else if (verdict.has_global) cmd = 'npx happyskills update --all -g'
|
|
146
|
+
else cmd = 'npx happyskills update --all'
|
|
147
|
+
return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmd}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { check_skills_for_update, format_skill_drift, get_cache_path, scope_key }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const { test } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const { check_skills_for_update, format_skill_drift, get_cache_path, scope_key } = require('./skill_update_check')
|
|
8
|
+
|
|
9
|
+
const PROJECT_ROOT = '/proj'
|
|
10
|
+
const PKEY = scope_key({ is_global: false, project_root: PROJECT_ROOT })
|
|
11
|
+
const GKEY = scope_key({ is_global: true, project_root: PROJECT_ROOT })
|
|
12
|
+
|
|
13
|
+
// Isolate the cache into a temp XDG dir, run fn, restore env. Also neutralises the
|
|
14
|
+
// two short-circuit guards (env opt-out + localhost sentinel) so the cache path runs.
|
|
15
|
+
const with_tmp = (fn) => {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-skilldrift-'))
|
|
17
|
+
const saved = {
|
|
18
|
+
xdg: process.env.XDG_CONFIG_HOME,
|
|
19
|
+
api: process.env.HAPPYSKILLS_API_URL,
|
|
20
|
+
off: process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK,
|
|
21
|
+
}
|
|
22
|
+
process.env.XDG_CONFIG_HOME = dir
|
|
23
|
+
process.env.HAPPYSKILLS_API_URL = 'https://api.example.test'
|
|
24
|
+
delete process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK
|
|
25
|
+
try { fn(dir) }
|
|
26
|
+
finally {
|
|
27
|
+
const restore = (k, v) => { if (v === undefined) delete process.env[k]; else process.env[k] = v }
|
|
28
|
+
restore('XDG_CONFIG_HOME', saved.xdg)
|
|
29
|
+
restore('HAPPYSKILLS_API_URL', saved.api)
|
|
30
|
+
restore('HAPPYSKILLS_NO_SKILL_UPDATE_CHECK', saved.off)
|
|
31
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Seed BOTH scopes FRESH (checked_at = now) so the sync read returns them without
|
|
36
|
+
// firing any background network refresh. Pass the outdated arrays per scope.
|
|
37
|
+
const seed = ({ local = [], global = [] }) => {
|
|
38
|
+
const p = get_cache_path()
|
|
39
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
40
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
41
|
+
[PKEY]: { outdated: local, checked_at: Date.now() },
|
|
42
|
+
[GKEY]: { outdated: global, checked_at: Date.now() },
|
|
43
|
+
}), 'utf8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const item = (skill) => ({ skill, installed: '1.0.0', latest: '1.1.0' })
|
|
47
|
+
|
|
48
|
+
test('project-only drift → verdict flagged local, no -g in the message', () => {
|
|
49
|
+
with_tmp(() => {
|
|
50
|
+
seed({ local: [item('acme/x')], global: [] })
|
|
51
|
+
const v = check_skills_for_update({ project_root: PROJECT_ROOT })
|
|
52
|
+
assert.ok(v)
|
|
53
|
+
assert.strictEqual(v.outdated.length, 1)
|
|
54
|
+
assert.strictEqual(v.has_local, true)
|
|
55
|
+
assert.strictEqual(v.has_global, false)
|
|
56
|
+
assert.match(format_skill_drift(v), /update --all$/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('global-only drift → verdict flagged global, message uses -g', () => {
|
|
61
|
+
with_tmp(() => {
|
|
62
|
+
seed({ local: [], global: [item('happyskillsai/happyskills')] })
|
|
63
|
+
const v = check_skills_for_update({ project_root: PROJECT_ROOT })
|
|
64
|
+
assert.ok(v)
|
|
65
|
+
assert.strictEqual(v.has_local, false)
|
|
66
|
+
assert.strictEqual(v.has_global, true)
|
|
67
|
+
assert.match(format_skill_drift(v), /update --all -g$/)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('drift in BOTH scopes → merged, both flags set, message mentions -g for global', () => {
|
|
72
|
+
with_tmp(() => {
|
|
73
|
+
seed({ local: [item('acme/x')], global: [item('happyskillsai/happyskills')] })
|
|
74
|
+
const v = check_skills_for_update({ project_root: PROJECT_ROOT })
|
|
75
|
+
assert.strictEqual(v.outdated.length, 2)
|
|
76
|
+
assert.strictEqual(v.has_local, true)
|
|
77
|
+
assert.strictEqual(v.has_global, true)
|
|
78
|
+
assert.match(format_skill_drift(v), /add -g for the global ones/)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a skill outdated in both scopes is name-deduped but both flags stay accurate', () => {
|
|
83
|
+
with_tmp(() => {
|
|
84
|
+
seed({ local: [item('acme/x')], global: [item('acme/x')] })
|
|
85
|
+
const v = check_skills_for_update({ project_root: PROJECT_ROOT })
|
|
86
|
+
assert.strictEqual(v.outdated.length, 1) // counted once
|
|
87
|
+
assert.strictEqual(v.has_local, true)
|
|
88
|
+
assert.strictEqual(v.has_global, true) // ...but both scopes still acknowledged
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('nothing outdated in either scope → null', () => {
|
|
93
|
+
with_tmp(() => {
|
|
94
|
+
seed({ local: [], global: [] })
|
|
95
|
+
assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('format collapses more than three skills to a "+N more" summary', () => {
|
|
100
|
+
const outdated = ['a', 'b', 'c', 'd', 'e'].map(n => ({ ...item(`acme/${n}`), scope: 'local' }))
|
|
101
|
+
assert.match(format_skill_drift({ outdated, has_local: true, has_global: false }), /\+2 more/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('env opt-out short-circuits to null', () => {
|
|
105
|
+
with_tmp(() => {
|
|
106
|
+
seed({ local: [item('acme/x')], global: [] })
|
|
107
|
+
process.env.HAPPYSKILLS_NO_SKILL_UPDATE_CHECK = '1'
|
|
108
|
+
assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('localhost:0 test API sentinel short-circuits to null', () => {
|
|
113
|
+
with_tmp(() => {
|
|
114
|
+
seed({ local: [item('acme/x')], global: [] })
|
|
115
|
+
process.env.HAPPYSKILLS_API_URL = 'http://localhost:0'
|
|
116
|
+
assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
|
|
117
|
+
})
|
|
118
|
+
})
|