happyskills 1.16.1 → 1.17.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 +22 -0
- package/package.json +1 -1
- package/src/commands/fork.js +3 -1
- package/src/commands/init.js +6 -2
- package/src/commands/init.test.js +29 -0
- package/src/commands/install.js +7 -1
- package/src/commands/skills-config.js +140 -0
- package/src/commands/update.js +22 -10
- package/src/commands/update.test.js +36 -0
- package/src/config/paths.js +28 -0
- package/src/config/paths.test.js +36 -0
- package/src/constants.js +3 -0
- package/src/engine/installer.js +34 -4
- package/src/integration/install_dep_closure.test.js +122 -0
- package/src/integration/skills-config.test.js +98 -0
- package/src/lock/verify.js +54 -1
- package/src/lock/verify.test.js +109 -1
- package/src/skills_config/configure.js +172 -0
- package/src/skills_config/configure.test.js +165 -0
- package/src/skills_config/reader.js +27 -0
- package/src/skills_config/resolve.js +126 -0
- package/src/skills_config/resolve.test.js +112 -0
- package/src/skills_config/skills_config.test.js +92 -0
- package/src/skills_config/writer.js +59 -0
- package/src/utils/prompt.js +16 -1
- package/src/utils/skill_update_check.js +1 -1
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration test — install must not report "already installed" for a skill whose
|
|
4
|
+
* locked dependency closure is not materialized on disk.
|
|
5
|
+
*
|
|
6
|
+
* The production bug: a `skills-lock.json` recorded a kit as an installed root
|
|
7
|
+
* skill (`requested_by: ['__root__']`, valid integrity, present on disk) with a
|
|
8
|
+
* populated `dependencies` map — but those dependencies were never resolved into
|
|
9
|
+
* their own lock entries and never landed under `.agents/skills/`. Every
|
|
10
|
+
* subsequent `install` then hit the pre-resolve skip check, which inspected only
|
|
11
|
+
* the kit's own directory + integrity and returned "already installed" BEFORE
|
|
12
|
+
* dependency resolution ran. The missing deps were never backfilled: the lock and
|
|
13
|
+
* the on-disk `.agents/skills/` were permanently out of sync.
|
|
14
|
+
*
|
|
15
|
+
* These tests run the REAL CLI with an unreachable registry (localhost:0). That
|
|
16
|
+
* isolates the skip decision from the network:
|
|
17
|
+
* - If the skip fires, the command SUCCEEDS with "already installed" and never
|
|
18
|
+
* touches the network (the buggy behavior).
|
|
19
|
+
* - If the skip is correctly declined, the command falls through to resolution
|
|
20
|
+
* and FAILS on the unreachable registry (the fixed behavior).
|
|
21
|
+
* We assert on that difference, plus the inverse: a fully-materialized closure
|
|
22
|
+
* must still skip (no over-triggering, fast-path preserved).
|
|
23
|
+
*/
|
|
24
|
+
const { describe, it } = require('node:test')
|
|
25
|
+
const assert = require('node:assert/strict')
|
|
26
|
+
const { spawnSync } = require('child_process')
|
|
27
|
+
const fs = require('fs')
|
|
28
|
+
const os = require('os')
|
|
29
|
+
const path = require('path')
|
|
30
|
+
|
|
31
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
32
|
+
const NODE = process.execPath
|
|
33
|
+
|
|
34
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-dep-closure-test-'))
|
|
35
|
+
const run = (args, opts) => {
|
|
36
|
+
const result = spawnSync(NODE, [CLI, ...args, '--text'], {
|
|
37
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
timeout: 15000,
|
|
40
|
+
cwd: opts?.cwd
|
|
41
|
+
})
|
|
42
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Write a skill directory under <root>/.agents/skills/<name> and return the dir.
|
|
46
|
+
const write_skill = (root, name, manifest) => {
|
|
47
|
+
const dir = path.join(root, '.agents', 'skills', name)
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
49
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: dep-closure test skill\n---\nbody\n`)
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
51
|
+
return dir
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build a project whose lock records `kit` as a __root__ skill (valid integrity,
|
|
55
|
+
// on disk) with the given dependency map. `present_deps` names the deps that are
|
|
56
|
+
// ALSO created on disk + locked; any dep in the map but not in present_deps is
|
|
57
|
+
// declared-but-missing.
|
|
58
|
+
const scaffold = async ({ kit, kit_deps, present_deps }) => {
|
|
59
|
+
const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
|
|
60
|
+
const root = make_tmp()
|
|
61
|
+
const skills = {}
|
|
62
|
+
|
|
63
|
+
const kit_dir = write_skill(root, kit, {
|
|
64
|
+
name: kit, version: '1.0.0', type: 'kit',
|
|
65
|
+
description: 'dep-closure test kit',
|
|
66
|
+
dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*']))
|
|
67
|
+
})
|
|
68
|
+
clear_integrity_cache()
|
|
69
|
+
const [, kit_integrity] = await hash_directory(kit_dir)
|
|
70
|
+
skills[`acme/${kit}`] = {
|
|
71
|
+
version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: kit_integrity,
|
|
72
|
+
base_commit: 'c', base_integrity: kit_integrity,
|
|
73
|
+
requested_by: ['__root__'],
|
|
74
|
+
dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*'])),
|
|
75
|
+
type: 'kit'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const dep of present_deps) {
|
|
79
|
+
const dep_dir = write_skill(root, dep, { name: dep, version: '1.0.0', type: 'skill', description: 'dep' })
|
|
80
|
+
clear_integrity_cache()
|
|
81
|
+
const [, dep_integrity] = await hash_directory(dep_dir)
|
|
82
|
+
skills[`acme/${dep}`] = {
|
|
83
|
+
version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: dep_integrity,
|
|
84
|
+
base_commit: 'c', base_integrity: dep_integrity,
|
|
85
|
+
requested_by: [`acme/${kit}`], dependencies: {}, type: 'skill'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(root, 'skills-lock.json'),
|
|
91
|
+
JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t')
|
|
92
|
+
)
|
|
93
|
+
return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('install — dependency-closure skip guard', () => {
|
|
97
|
+
it('does NOT report "already installed" when a locked dependency was never materialized', async () => {
|
|
98
|
+
// kit declares acme/dep-a but dep-a is neither locked nor on disk.
|
|
99
|
+
const ctx = await scaffold({ kit: 'thekit', kit_deps: ['dep-a'], present_deps: [] })
|
|
100
|
+
try {
|
|
101
|
+
const { code, stdout, stderr } = run(['install', 'acme/thekit'], { cwd: ctx.root })
|
|
102
|
+
const out = stdout + stderr
|
|
103
|
+
// Fixed behavior: the skip is declined, so install falls through to
|
|
104
|
+
// resolution and fails against the unreachable registry.
|
|
105
|
+
assert.doesNotMatch(out, /already installed/i,
|
|
106
|
+
'must not claim already-installed while a declared dependency is missing')
|
|
107
|
+
assert.notStrictEqual(code, 0, 'must attempt resolution (and fail on the unreachable registry), not skip')
|
|
108
|
+
} finally { ctx.cleanup() }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still skips (already installed, no network) when the whole closure is present', async () => {
|
|
112
|
+
// Regression guard: a fully-materialized closure must keep the fast path —
|
|
113
|
+
// skip without any resolution/network call, even with the registry down.
|
|
114
|
+
const ctx = await scaffold({ kit: 'fullkit', kit_deps: ['dep-a'], present_deps: ['dep-a'] })
|
|
115
|
+
try {
|
|
116
|
+
const { code, stdout, stderr } = run(['install', 'acme/fullkit'], { cwd: ctx.root })
|
|
117
|
+
const out = stdout + stderr
|
|
118
|
+
assert.match(out, /already installed/i, 'a complete closure must still short-circuit')
|
|
119
|
+
assert.strictEqual(code, 0, 'skip path must not touch the network')
|
|
120
|
+
} finally { ctx.cleanup() }
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Conformance test for `happyskills skills-config get` — spec 260704-01 Tier 3 §4.4.
|
|
3
|
+
// Exercises the real command end-to-end (spawned CLI) against a tmp project: envelope
|
|
4
|
+
// conformance (STRICT), owner/name validation, and the §0 hard rule that no secret VALUE
|
|
5
|
+
// ever reaches stdout/stderr.
|
|
6
|
+
|
|
7
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
const { spawnSync } = require('child_process')
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const os = require('os')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
|
|
14
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
15
|
+
const NODE = process.execPath
|
|
16
|
+
|
|
17
|
+
const run = (args, cwd) => {
|
|
18
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '' },
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
timeout: 10000,
|
|
23
|
+
})
|
|
24
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SECRET = 'xoxb-SUPER-SECRET-DO-NOT-LEAK'
|
|
28
|
+
|
|
29
|
+
let proj
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
proj = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-sc-cmd-'))
|
|
32
|
+
const skill_dir = path.join(proj, '.agents', 'skills', 'slack-notify')
|
|
33
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
34
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
|
|
35
|
+
name: 'slack-notify', version: '1.0.0',
|
|
36
|
+
config: { channel: { type: 'string', default: '#general' }, max_chars: { type: 'integer', default: 500 } },
|
|
37
|
+
env: { SLACK_BOT_TOKEN: { required: true, secret: true }, DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 } }
|
|
38
|
+
}))
|
|
39
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '# slack\n')
|
|
40
|
+
fs.writeFileSync(path.join(proj, 'skills-config.json'), JSON.stringify({
|
|
41
|
+
'acme/slack-notify': { envFile: './secrets/slack-notify.env', config: { channel: '#deploys' } }
|
|
42
|
+
}))
|
|
43
|
+
fs.mkdirSync(path.join(proj, 'secrets'), { recursive: true })
|
|
44
|
+
fs.writeFileSync(path.join(proj, 'secrets', 'slack-notify.env'), `SLACK_BOT_TOKEN=${SECRET}\n`)
|
|
45
|
+
})
|
|
46
|
+
afterEach(() => { fs.rmSync(proj, { recursive: true, force: true }) })
|
|
47
|
+
|
|
48
|
+
describe('happyskills skills-config get', () => {
|
|
49
|
+
it('emits a valid success envelope with the resolved non-secret config — and NO secret value', () => {
|
|
50
|
+
const { stdout, stderr, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], proj)
|
|
51
|
+
assert.strictEqual(code, 0)
|
|
52
|
+
const env = JSON.parse(stdout)
|
|
53
|
+
assert.strictEqual(env.ok, true)
|
|
54
|
+
assert.strictEqual(env.meta.command, 'skills-config')
|
|
55
|
+
assert.strictEqual(typeof env.data, 'object')
|
|
56
|
+
assert.ok(!Array.isArray(env.data))
|
|
57
|
+
assert.strictEqual(env.data.config.channel, '#deploys') // project override wins
|
|
58
|
+
assert.strictEqual(env.data.config.max_chars, 500) // author default
|
|
59
|
+
assert.deepStrictEqual(env.data.requiredSecrets, ['SLACK_BOT_TOKEN'])
|
|
60
|
+
assert.strictEqual(env.data.secretsPresent, true)
|
|
61
|
+
assert.strictEqual(env.data.envFile, './secrets/slack-notify.env')
|
|
62
|
+
|
|
63
|
+
// §0 hard rule: the secret VALUE must never appear in any output stream
|
|
64
|
+
assert.ok(!stdout.includes(SECRET), 'secret leaked to stdout')
|
|
65
|
+
assert.ok(!stderr.includes(SECRET), 'secret leaked to stderr')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves from a SUBDIRECTORY by walking up to the project root (§6 #7)', () => {
|
|
69
|
+
const sub = path.join(proj, 'services', 'api')
|
|
70
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
71
|
+
const { stdout, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], sub)
|
|
72
|
+
assert.strictEqual(code, 0)
|
|
73
|
+
const env = JSON.parse(stdout)
|
|
74
|
+
assert.strictEqual(env.ok, true)
|
|
75
|
+
// same resolution as from the root — proves the CLI now matches the file-fallback walk-up
|
|
76
|
+
assert.strictEqual(env.data.config.channel, '#deploys')
|
|
77
|
+
assert.strictEqual(env.data.secretsPresent, true)
|
|
78
|
+
// envFile is relative to the caller's cwd (the subdir), so it stays usable from here
|
|
79
|
+
assert.strictEqual(env.data.envFile, '../../secrets/slack-notify.env')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('rejects a non-owner/name argument with a USAGE_ERROR envelope', () => {
|
|
83
|
+
const { stdout, code } = run(['skills-config', 'get', 'not-a-scoped-name', '--json'], proj)
|
|
84
|
+
assert.strictEqual(code, 2)
|
|
85
|
+
const env = JSON.parse(stdout)
|
|
86
|
+
assert.strictEqual(env.ok, false)
|
|
87
|
+
assert.strictEqual(env.error.code, 'USAGE_ERROR')
|
|
88
|
+
assert.strictEqual(typeof env.data, 'object')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects an unknown subcommand', () => {
|
|
92
|
+
const { stdout, code } = run(['skills-config', 'wat', '--json'], proj)
|
|
93
|
+
assert.strictEqual(code, 2)
|
|
94
|
+
const env = JSON.parse(stdout)
|
|
95
|
+
assert.strictEqual(env.ok, false)
|
|
96
|
+
assert.strictEqual(env.error.code, 'USAGE_ERROR')
|
|
97
|
+
})
|
|
98
|
+
})
|
package/src/lock/verify.js
CHANGED
|
@@ -118,6 +118,58 @@ const detect_ahead_state = (lock_entry, install_dir) => catch_errors('Failed to
|
|
|
118
118
|
}
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
// dependency_closure_satisfied — walk the locked dependency graph starting at
|
|
122
|
+
// `skill` and confirm every reachable skill is BOTH present in the lock AND
|
|
123
|
+
// materialized on disk. Returns { satisfied: bool, missing: [full skill names] }.
|
|
124
|
+
//
|
|
125
|
+
// This closes the "lock declares deps that were never installed" gap. A lock
|
|
126
|
+
// entry can carry a populated `dependencies` map whose referenced skills have no
|
|
127
|
+
// lock entry and no install directory — e.g. an entry authored by `publish`
|
|
128
|
+
// (which records `manifest.dependencies` but installs nothing), or any install
|
|
129
|
+
// that recorded a root skill while its transitive deps were skipped. In that
|
|
130
|
+
// state the lock and `.agents/skills/` are out of sync.
|
|
131
|
+
//
|
|
132
|
+
// The install skip-check ("already installed") uses this to decide whether a
|
|
133
|
+
// locked-and-present root is actually COMPLETE. If any transitive dependency is
|
|
134
|
+
// missing, the caller must decline the skip and re-resolve so the missing deps
|
|
135
|
+
// get backfilled — the lock must always match what is on disk.
|
|
136
|
+
//
|
|
137
|
+
// `resolve_dir(full_skill_name)` maps `owner/name` to its install directory.
|
|
138
|
+
// Cycle-safe via a visited set. A dependency that is referenced but absent from
|
|
139
|
+
// the lock, or locked but missing on disk, is reported in `missing`.
|
|
140
|
+
const dependency_closure_satisfied = (lock_data, skill, resolve_dir) => catch_errors('Failed to verify dependency closure', async () => {
|
|
141
|
+
const skills = (lock_data && lock_data.skills) || {}
|
|
142
|
+
const missing = []
|
|
143
|
+
const visited = new Set()
|
|
144
|
+
const queue = [skill]
|
|
145
|
+
|
|
146
|
+
while (queue.length > 0) {
|
|
147
|
+
const current = queue.shift()
|
|
148
|
+
if (visited.has(current)) continue
|
|
149
|
+
visited.add(current)
|
|
150
|
+
|
|
151
|
+
const entry = skills[current]
|
|
152
|
+
if (!entry) {
|
|
153
|
+
// Referenced as a dependency but never locked.
|
|
154
|
+
missing.push(current)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [, dir_present] = await file_exists(resolve_dir(current))
|
|
159
|
+
if (!dir_present) {
|
|
160
|
+
// Locked but not materialized on disk.
|
|
161
|
+
missing.push(current)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const dep_name of Object.keys(entry.dependencies || {})) {
|
|
166
|
+
if (!visited.has(dep_name)) queue.push(dep_name)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { satisfied: missing.length === 0, missing }
|
|
171
|
+
})
|
|
172
|
+
|
|
121
173
|
// Plain-language description of a drift result, suitable for the principal-facing
|
|
122
174
|
// table cell (status column). Returns null when ok or ahead.
|
|
123
175
|
const describe_drift = (verify_result) => {
|
|
@@ -179,5 +231,6 @@ module.exports = {
|
|
|
179
231
|
detect_ahead_state,
|
|
180
232
|
describe_drift,
|
|
181
233
|
classify_lock_disk,
|
|
182
|
-
parse_changelog_top_version
|
|
234
|
+
parse_changelog_top_version,
|
|
235
|
+
dependency_closure_satisfied
|
|
183
236
|
}
|
package/src/lock/verify.test.js
CHANGED
|
@@ -10,7 +10,8 @@ const {
|
|
|
10
10
|
detect_ahead_state,
|
|
11
11
|
describe_drift,
|
|
12
12
|
classify_lock_disk,
|
|
13
|
-
parse_changelog_top_version
|
|
13
|
+
parse_changelog_top_version,
|
|
14
|
+
dependency_closure_satisfied
|
|
14
15
|
} = require('./verify')
|
|
15
16
|
|
|
16
17
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
|
|
@@ -276,6 +277,113 @@ describe('classify_lock_disk', () => {
|
|
|
276
277
|
})
|
|
277
278
|
})
|
|
278
279
|
|
|
280
|
+
describe('dependency_closure_satisfied', () => {
|
|
281
|
+
// A skill directory is "present" iff it exists on disk. We model the disk with a
|
|
282
|
+
// temp root and create a sub-dir per skill that is supposed to be installed.
|
|
283
|
+
const make_root = () => make_tmp()
|
|
284
|
+
const present = (root, skill) => {
|
|
285
|
+
const dir = path.join(root, skill.replace('/', '__'))
|
|
286
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
287
|
+
return dir
|
|
288
|
+
}
|
|
289
|
+
const resolver = (root) => (skill) => path.join(root, skill.replace('/', '__'))
|
|
290
|
+
|
|
291
|
+
const lock = (skills) => ({ lockVersion: 2, skills })
|
|
292
|
+
|
|
293
|
+
it('is satisfied when the root and its whole locked dep closure are present on disk', async () => {
|
|
294
|
+
const root = make_root()
|
|
295
|
+
try {
|
|
296
|
+
present(root, 'acme/kit')
|
|
297
|
+
present(root, 'acme/dep-a')
|
|
298
|
+
present(root, 'acme/dep-b')
|
|
299
|
+
const data = lock({
|
|
300
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } },
|
|
301
|
+
'acme/dep-a': { version: '1.0.0', dependencies: {} },
|
|
302
|
+
'acme/dep-b': { version: '1.0.0', dependencies: {} }
|
|
303
|
+
})
|
|
304
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
305
|
+
assert.strictEqual(err, null)
|
|
306
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
307
|
+
} finally { cleanup(root) }
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('is NOT satisfied when a declared dependency has no lock entry (the kit-deps-never-installed bug)', async () => {
|
|
311
|
+
// This is the exact production shape: the kit is locked + on disk, its
|
|
312
|
+
// `dependencies` map is populated, but those deps were never resolved into
|
|
313
|
+
// their own lock entries and never landed on disk.
|
|
314
|
+
const root = make_root()
|
|
315
|
+
try {
|
|
316
|
+
present(root, 'acme/kit')
|
|
317
|
+
const data = lock({
|
|
318
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } }
|
|
319
|
+
})
|
|
320
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
321
|
+
assert.strictEqual(err, null)
|
|
322
|
+
assert.strictEqual(result.satisfied, false)
|
|
323
|
+
assert.deepEqual(result.missing.sort(), ['acme/dep-a', 'acme/dep-b'])
|
|
324
|
+
} finally { cleanup(root) }
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('is NOT satisfied when a dependency is locked but its directory is missing on disk', async () => {
|
|
328
|
+
const root = make_root()
|
|
329
|
+
try {
|
|
330
|
+
present(root, 'acme/kit')
|
|
331
|
+
// acme/dep-a is locked but NOT created on disk.
|
|
332
|
+
const data = lock({
|
|
333
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
|
|
334
|
+
'acme/dep-a': { version: '1.0.0', dependencies: {} }
|
|
335
|
+
})
|
|
336
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
337
|
+
assert.strictEqual(err, null)
|
|
338
|
+
assert.strictEqual(result.satisfied, false)
|
|
339
|
+
assert.deepEqual(result.missing, ['acme/dep-a'])
|
|
340
|
+
} finally { cleanup(root) }
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('walks transitively — a missing dep-of-a-dep still fails the closure', async () => {
|
|
344
|
+
const root = make_root()
|
|
345
|
+
try {
|
|
346
|
+
present(root, 'acme/kit')
|
|
347
|
+
present(root, 'acme/dep-a')
|
|
348
|
+
// acme/dep-a depends on acme/deep, which is neither locked nor on disk.
|
|
349
|
+
const data = lock({
|
|
350
|
+
'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
|
|
351
|
+
'acme/dep-a': { version: '1.0.0', dependencies: { 'acme/deep': '*' } }
|
|
352
|
+
})
|
|
353
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
|
|
354
|
+
assert.strictEqual(err, null)
|
|
355
|
+
assert.strictEqual(result.satisfied, false)
|
|
356
|
+
assert.deepEqual(result.missing, ['acme/deep'])
|
|
357
|
+
} finally { cleanup(root) }
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('is cycle-safe (A → B → A) and does not loop forever', async () => {
|
|
361
|
+
const root = make_root()
|
|
362
|
+
try {
|
|
363
|
+
present(root, 'acme/a')
|
|
364
|
+
present(root, 'acme/b')
|
|
365
|
+
const data = lock({
|
|
366
|
+
'acme/a': { version: '1.0.0', dependencies: { 'acme/b': '*' } },
|
|
367
|
+
'acme/b': { version: '1.0.0', dependencies: { 'acme/a': '*' } }
|
|
368
|
+
})
|
|
369
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/a', resolver(root))
|
|
370
|
+
assert.strictEqual(err, null)
|
|
371
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
372
|
+
} finally { cleanup(root) }
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('is satisfied for a leaf skill with no dependencies', async () => {
|
|
376
|
+
const root = make_root()
|
|
377
|
+
try {
|
|
378
|
+
present(root, 'acme/solo')
|
|
379
|
+
const data = lock({ 'acme/solo': { version: '1.0.0', dependencies: {} } })
|
|
380
|
+
const [err, result] = await dependency_closure_satisfied(data, 'acme/solo', resolver(root))
|
|
381
|
+
assert.strictEqual(err, null)
|
|
382
|
+
assert.deepEqual(result, { satisfied: true, missing: [] })
|
|
383
|
+
} finally { cleanup(root) }
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
279
387
|
describe('describe_drift', () => {
|
|
280
388
|
it('returns null for ok results', () => {
|
|
281
389
|
assert.strictEqual(describe_drift({ ok: true }), null)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { file_exists, read_json, ensure_dir, read_file, write_file } = require('../utils/fs')
|
|
4
|
+
const { skill_install_dir } = require('../config/paths')
|
|
5
|
+
const { read_skills_config, get_skill_config_entry } = require('./reader')
|
|
6
|
+
const { set_skill_config_entry, build_config_entry } = require('./writer')
|
|
7
|
+
const { question_prompt } = require('../utils/prompt')
|
|
8
|
+
const { SKILL_JSON } = require('../constants')
|
|
9
|
+
|
|
10
|
+
const to_posix = (p) => p.split(path.sep).join('/')
|
|
11
|
+
|
|
12
|
+
const coerce = (raw, type) => {
|
|
13
|
+
if (type === 'integer' || type === 'number') {
|
|
14
|
+
const n = Number(raw)
|
|
15
|
+
return Number.isNaN(n) ? raw : n
|
|
16
|
+
}
|
|
17
|
+
if (type === 'boolean') return /^(y|yes|true|1)$/i.test(raw)
|
|
18
|
+
return raw
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const as_object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null)
|
|
22
|
+
|
|
23
|
+
// Append missing `NAME=` lines to an env file, never touching lines that already exist —
|
|
24
|
+
// so a value the consumer already set is preserved on reinstall/update.
|
|
25
|
+
const ensure_env_names = (file_path, names) => catch_errors('Failed to scaffold env file', async () => {
|
|
26
|
+
const [, exists] = await file_exists(file_path)
|
|
27
|
+
let content = ''
|
|
28
|
+
if (exists) { const [, c] = await read_file(file_path); content = c || '' }
|
|
29
|
+
|
|
30
|
+
const present = new Set(content.split('\n').map(l => l.split('=')[0].trim()).filter(Boolean))
|
|
31
|
+
const additions = names.filter(n => !present.has(n)).map(n => `${n}=`)
|
|
32
|
+
if (!additions.length && exists) return
|
|
33
|
+
|
|
34
|
+
await ensure_dir(path.dirname(file_path))
|
|
35
|
+
const base = content.length && !content.endsWith('\n') ? content + '\n' : content
|
|
36
|
+
await write_file(file_path, `${base}${additions.join('\n')}${additions.length ? '\n' : ''}`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const ensure_gitignore_entry = (root, pattern) => catch_errors('Failed to update .gitignore', async () => {
|
|
40
|
+
const gitignore = path.join(root, '.gitignore')
|
|
41
|
+
const [, exists] = await file_exists(gitignore)
|
|
42
|
+
let content = ''
|
|
43
|
+
if (exists) { const [, c] = await read_file(gitignore); content = c || '' }
|
|
44
|
+
|
|
45
|
+
const lines = content.split('\n').map(l => l.trim())
|
|
46
|
+
if (lines.includes(pattern)) return
|
|
47
|
+
|
|
48
|
+
const base = content.length && !content.endsWith('\n') ? content + '\n' : content
|
|
49
|
+
await write_file(gitignore, `${base}${pattern}\n`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// A required secret is "unset" unless it is present with a non-empty value in ambient
|
|
53
|
+
// process.env or in the scaffolded .env file. Presence gates the fail-loud warning.
|
|
54
|
+
const secret_is_set = (env_abs, name) => catch_errors('Failed to check secret presence', async () => {
|
|
55
|
+
if (process.env[name] && process.env[name].trim() !== '') return true
|
|
56
|
+
const [, exists] = await file_exists(env_abs)
|
|
57
|
+
if (!exists) return false
|
|
58
|
+
const [, content] = await read_file(env_abs)
|
|
59
|
+
for (const line of (content || '').split('\n')) {
|
|
60
|
+
const idx = line.indexOf('=')
|
|
61
|
+
if (idx === -1) continue
|
|
62
|
+
if (line.slice(0, idx).trim() === name && line.slice(idx + 1).trim() !== '') return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// For every installed package that declares a `config`/`env` schema, resolve the consumer's
|
|
68
|
+
// choices and persist them: non-secret overrides → skills-config.json `config`; secret names →
|
|
69
|
+
// the gitignored .env (+ committed .env.example, name only); the pointer → `envFile`. Secret
|
|
70
|
+
// VALUES are never written anywhere by this function — build_config_entry structurally drops
|
|
71
|
+
// anything but envFile/config. Returns warnings naming required secrets still unset.
|
|
72
|
+
const configure_installed_skills = (packages, options = {}) => catch_errors('Failed to configure installed skills', async () => {
|
|
73
|
+
const { base_dir, config_root, interactive = false, only_required = false, ask = question_prompt } = options
|
|
74
|
+
const warnings = []
|
|
75
|
+
|
|
76
|
+
const [, existing_config_file] = await read_skills_config(config_root)
|
|
77
|
+
|
|
78
|
+
for (const pkg of packages || []) {
|
|
79
|
+
const key = pkg.skill
|
|
80
|
+
const name = key.split('/')[1]
|
|
81
|
+
const install_dir = skill_install_dir(base_dir, name)
|
|
82
|
+
const [, manifest] = await read_json(path.join(install_dir, SKILL_JSON))
|
|
83
|
+
if (!manifest) continue
|
|
84
|
+
|
|
85
|
+
const config_schema = as_object(manifest.config)
|
|
86
|
+
const env_schema = as_object(manifest.env)
|
|
87
|
+
if (!config_schema && !env_schema) continue
|
|
88
|
+
|
|
89
|
+
// Existing consumer choices are preserved verbatim; we only ever ADD to them.
|
|
90
|
+
// `only_required` (update/reconcile) narrows prompting to newly-required fields
|
|
91
|
+
// absent from the existing entry, so unrelated values are never re-asked.
|
|
92
|
+
const existing_entry = get_skill_config_entry(existing_config_file, key)
|
|
93
|
+
const existing_overrides = as_object(existing_entry && existing_entry.config) || {}
|
|
94
|
+
const overrides = { ...existing_overrides }
|
|
95
|
+
|
|
96
|
+
const maybe_prompt = async (field, spec) => {
|
|
97
|
+
if (!interactive) return
|
|
98
|
+
const already_set = Object.prototype.hasOwnProperty.call(existing_overrides, field)
|
|
99
|
+
const is_required = spec && spec.required === true
|
|
100
|
+
if (already_set) return
|
|
101
|
+
if (only_required && !is_required) return
|
|
102
|
+
const def = spec && spec.default
|
|
103
|
+
const label = `${field}${spec && spec.description ? ` — ${spec.description}` : ''} [${def ?? ''}]:`
|
|
104
|
+
const answer = await ask(label)
|
|
105
|
+
if (answer !== '' && answer !== String(def ?? '')) overrides[field] = coerce(answer, spec && spec.type)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// config fields — prompt (per the rules above); store non-default answers.
|
|
109
|
+
for (const [field, spec] of Object.entries(config_schema || {})) {
|
|
110
|
+
await maybe_prompt(field, spec)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// env fields — secrets route to the .env; non-secret env is treated like config.
|
|
114
|
+
const secret_names = []
|
|
115
|
+
for (const [var_name, spec] of Object.entries(env_schema || {})) {
|
|
116
|
+
if (spec && spec.secret) {
|
|
117
|
+
secret_names.push({ name: var_name, required: spec.required === true })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
await maybe_prompt(var_name, spec)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let env_file_rel = (existing_entry && existing_entry.envFile) || null
|
|
124
|
+
if (secret_names.length) {
|
|
125
|
+
env_file_rel = env_file_rel || `./secrets/${name}.env`
|
|
126
|
+
// Resolve the (possibly consumer-chosen) envFile relative to the config root.
|
|
127
|
+
const env_abs = path.resolve(config_root, env_file_rel)
|
|
128
|
+
const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
|
|
129
|
+
if (scaffold_err) throw scaffold_err[0]
|
|
130
|
+
const [example_err] = await ensure_env_names(path.join(config_root, '.env.example'), secret_names.map(s => s.name))
|
|
131
|
+
if (example_err) throw example_err[0]
|
|
132
|
+
|
|
133
|
+
const dir_rel = to_posix(path.relative(config_root, path.dirname(env_abs)))
|
|
134
|
+
const [gi_err] = await ensure_gitignore_entry(config_root, dir_rel ? `${dir_rel}/*.env` : '*.env')
|
|
135
|
+
if (gi_err) throw gi_err[0]
|
|
136
|
+
|
|
137
|
+
for (const secret of secret_names.filter(s => s.required)) {
|
|
138
|
+
const [, is_set] = await secret_is_set(env_abs, secret.name)
|
|
139
|
+
if (!is_set) warnings.push(`Required secret ${secret.name} for ${key} is not set — add it to ${env_file_rel}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fail-loud (§4.3.2): a required NON-secret config/env field with no value and no
|
|
144
|
+
// author default would be silently undefined at runtime. Surface it as a warning —
|
|
145
|
+
// the same discipline the secret loop above applies to required secrets.
|
|
146
|
+
const warn_required_unset = (field, spec) => {
|
|
147
|
+
if (!spec || spec.required !== true) return
|
|
148
|
+
if (spec.default !== undefined) return
|
|
149
|
+
if (Object.prototype.hasOwnProperty.call(overrides, field)) return
|
|
150
|
+
warnings.push(`Required config "${field}" for ${key} is not set — no value provided and the skill declares no default.`)
|
|
151
|
+
}
|
|
152
|
+
for (const [field, spec] of Object.entries(config_schema || {})) warn_required_unset(field, spec)
|
|
153
|
+
for (const [var_name, spec] of Object.entries(env_schema || {})) {
|
|
154
|
+
if (spec && spec.secret) continue
|
|
155
|
+
warn_required_unset(var_name, spec)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const entry = build_config_entry({
|
|
159
|
+
...(env_file_rel ? { envFile: env_file_rel } : {}),
|
|
160
|
+
...(Object.keys(overrides).length ? { config: overrides } : {})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (Object.keys(entry).length) {
|
|
164
|
+
const [set_err] = await set_skill_config_entry(config_root, key, entry)
|
|
165
|
+
if (set_err) throw set_err[0]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { warnings }
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
module.exports = { configure_installed_skills }
|