happyskills 1.16.3 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/commands/install.js +7 -1
- package/src/commands/skills-config.js +140 -0
- package/src/commands/update.js +6 -2
- package/src/commands/validate.js +4 -1
- 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 +19 -1
- package/src/engine/uninstaller.js +24 -1
- package/src/integration/skills-config.test.js +98 -0
- package/src/skills_config/cleanup.js +107 -0
- package/src/skills_config/cleanup.test.js +123 -0
- 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/fs.js +5 -1
- package/src/utils/prompt.js +16 -1
- package/src/validation/config_usage_rules.js +86 -0
- package/src/validation/config_usage_rules.test.js +61 -0
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = 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 { cleanup_uninstalled_configs, strip_env_var_lines, read_skill_secret_names } = require('./cleanup')
|
|
9
|
+
|
|
10
|
+
describe('strip_env_var_lines (pure)', () => {
|
|
11
|
+
it('removes only the named assignment lines, keeps the rest', () => {
|
|
12
|
+
const content = 'SLACK_BOT_TOKEN=xoxb-123\nDB_URL=postgres://x\n'
|
|
13
|
+
const r = strip_env_var_lines(content, ['SLACK_BOT_TOKEN'])
|
|
14
|
+
assert.equal(r.changed, true)
|
|
15
|
+
assert.ok(r.next.includes('DB_URL=postgres://x'))
|
|
16
|
+
assert.ok(!r.next.includes('SLACK_BOT_TOKEN'))
|
|
17
|
+
assert.equal(r.all_blank, false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reports all_blank when nothing but whitespace remains', () => {
|
|
21
|
+
const r = strip_env_var_lines('SLACK_BOT_TOKEN=xoxb\n', ['SLACK_BOT_TOKEN'])
|
|
22
|
+
assert.equal(r.changed, true)
|
|
23
|
+
assert.equal(r.all_blank, true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('no-op when no named line is present', () => {
|
|
27
|
+
const r = strip_env_var_lines('DB_URL=x\n', ['NOPE'])
|
|
28
|
+
assert.equal(r.changed, false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Build a fake project: base_dir holds installed skill dirs (each with skill.json),
|
|
33
|
+
// config_root holds skills-config.json + secrets/*.env + .env.example.
|
|
34
|
+
const scaffold = (root, { skills, config, envFiles, example }) => {
|
|
35
|
+
const base_dir = path.join(root, 'skills')
|
|
36
|
+
for (const [key, manifest] of Object.entries(skills)) {
|
|
37
|
+
const dir = path.join(base_dir, key.split('/')[1])
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
39
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest))
|
|
40
|
+
}
|
|
41
|
+
fs.writeFileSync(path.join(root, 'skills-config.json'), JSON.stringify(config))
|
|
42
|
+
fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
|
|
43
|
+
for (const [rel, body] of Object.entries(envFiles || {})) {
|
|
44
|
+
fs.writeFileSync(path.join(root, rel), body)
|
|
45
|
+
}
|
|
46
|
+
if (example !== undefined) fs.writeFileSync(path.join(root, '.env.example'), example)
|
|
47
|
+
return base_dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('cleanup_uninstalled_configs', () => {
|
|
51
|
+
let root
|
|
52
|
+
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-cfg-cleanup-')) })
|
|
53
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
54
|
+
|
|
55
|
+
it('removes the uninstalled skill config entry and deletes its now-empty secrets file', async () => {
|
|
56
|
+
const base_dir = scaffold(root, {
|
|
57
|
+
skills: {
|
|
58
|
+
'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true, required: true } } },
|
|
59
|
+
'acme/keep': { name: 'keep', env: { DB_URL: { secret: true } } }
|
|
60
|
+
},
|
|
61
|
+
config: {
|
|
62
|
+
'acme/slack': { envFile: './secrets/slack.env', config: { channel: '#ops' } },
|
|
63
|
+
'acme/keep': { envFile: './secrets/keep.env' }
|
|
64
|
+
},
|
|
65
|
+
envFiles: { 'secrets/slack.env': 'SLACK_BOT_TOKEN=xoxb-123\n', 'secrets/keep.env': 'DB_URL=postgres://x\n' },
|
|
66
|
+
example: 'SLACK_BOT_TOKEN=\nDB_URL=\n'
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const [err] = await cleanup_uninstalled_configs({
|
|
70
|
+
config_root: root,
|
|
71
|
+
base_dir,
|
|
72
|
+
removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN'] }],
|
|
73
|
+
surviving_keys: ['acme/keep']
|
|
74
|
+
})
|
|
75
|
+
assert.ifError(err)
|
|
76
|
+
|
|
77
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'skills-config.json'), 'utf-8'))
|
|
78
|
+
assert.ok(!cfg['acme/slack'], 'removed skill entry should be gone')
|
|
79
|
+
assert.ok(cfg['acme/keep'], 'surviving skill entry should remain')
|
|
80
|
+
assert.equal(fs.existsSync(path.join(root, 'secrets/slack.env')), false, 'empty secrets file should be deleted')
|
|
81
|
+
assert.equal(fs.existsSync(path.join(root, 'secrets/keep.env')), true, 'other skill secrets file untouched')
|
|
82
|
+
const example = fs.readFileSync(path.join(root, '.env.example'), 'utf-8')
|
|
83
|
+
assert.ok(!example.includes('SLACK_BOT_TOKEN'), 'removed secret name stripped from .env.example')
|
|
84
|
+
assert.ok(example.includes('DB_URL'), 'surviving secret name kept in .env.example')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('preserves a secret still declared by a surviving skill (shared envFile)', async () => {
|
|
88
|
+
const base_dir = scaffold(root, {
|
|
89
|
+
skills: {
|
|
90
|
+
'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, SHARED: { secret: true } } },
|
|
91
|
+
'acme/keep': { name: 'keep', env: { SHARED: { secret: true } } }
|
|
92
|
+
},
|
|
93
|
+
config: {
|
|
94
|
+
'acme/slack': { envFile: './secrets/shared.env' },
|
|
95
|
+
'acme/keep': { envFile: './secrets/shared.env' }
|
|
96
|
+
},
|
|
97
|
+
envFiles: { 'secrets/shared.env': 'SLACK_BOT_TOKEN=xoxb\nSHARED=zzz\n' }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const [err] = await cleanup_uninstalled_configs({
|
|
101
|
+
config_root: root,
|
|
102
|
+
base_dir,
|
|
103
|
+
removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN', 'SHARED'] }],
|
|
104
|
+
surviving_keys: ['acme/keep']
|
|
105
|
+
})
|
|
106
|
+
assert.ifError(err)
|
|
107
|
+
|
|
108
|
+
const env = fs.readFileSync(path.join(root, 'secrets/shared.env'), 'utf-8')
|
|
109
|
+
assert.ok(!env.includes('SLACK_BOT_TOKEN'), 'unshared secret stripped')
|
|
110
|
+
assert.ok(env.includes('SHARED=zzz'), 'secret shared with a surviving skill is preserved')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('read_skill_secret_names returns the manifest secret var names', async () => {
|
|
114
|
+
const base_dir = scaffold(root, {
|
|
115
|
+
skills: { 'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, PUBLIC: { secret: false } } } },
|
|
116
|
+
config: {},
|
|
117
|
+
envFiles: {}
|
|
118
|
+
})
|
|
119
|
+
const [err, names] = await read_skill_secret_names(base_dir, 'acme/slack')
|
|
120
|
+
assert.ifError(err)
|
|
121
|
+
assert.deepEqual(names, ['SLACK_BOT_TOKEN'])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -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 }
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = 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 { configure_installed_skills } = require('./configure')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-configure-'))
|
|
11
|
+
|
|
12
|
+
const write_skill = (base_dir, name, manifest) => {
|
|
13
|
+
const dir = path.join(base_dir, name)
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
15
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }, null, '\t'))
|
|
16
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `# ${name}\n`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const read = (p) => fs.readFileSync(p, 'utf-8')
|
|
20
|
+
|
|
21
|
+
let base_dir, config_root
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
base_dir = make_tmp()
|
|
24
|
+
config_root = make_tmp()
|
|
25
|
+
})
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(base_dir, { recursive: true, force: true })
|
|
28
|
+
fs.rmSync(config_root, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const SLACK = {
|
|
32
|
+
config: { channel: { type: 'string', default: '#general', description: 'Channel' } },
|
|
33
|
+
env: {
|
|
34
|
+
SLACK_BOT_TOKEN: { required: true, secret: true, description: 'Slack token' },
|
|
35
|
+
DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('configure_installed_skills — non-interactive scaffold', () => {
|
|
40
|
+
it('scaffolds env + writes an envFile pointer with NO secret value', async () => {
|
|
41
|
+
write_skill(base_dir, 'slack-notify', SLACK)
|
|
42
|
+
const [err, result] = await configure_installed_skills(
|
|
43
|
+
[{ skill: 'acme/slack-notify' }],
|
|
44
|
+
{ base_dir, config_root, interactive: false }
|
|
45
|
+
)
|
|
46
|
+
assert.ifError(err)
|
|
47
|
+
|
|
48
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
49
|
+
const entry = cfg['acme/slack-notify']
|
|
50
|
+
assert.strictEqual(entry.envFile, './secrets/slack-notify.env')
|
|
51
|
+
// no config overrides written in non-interactive mode
|
|
52
|
+
assert.ok(!entry.config || Object.keys(entry.config).length === 0)
|
|
53
|
+
|
|
54
|
+
// .env scaffolded (gitignored), .env.example committed with NAME only
|
|
55
|
+
assert.ok(fs.existsSync(path.join(config_root, 'secrets', 'slack-notify.env')))
|
|
56
|
+
assert.match(read(path.join(config_root, '.env.example')), /SLACK_BOT_TOKEN=/)
|
|
57
|
+
// no secret value anywhere in committed files
|
|
58
|
+
assert.ok(!read(path.join(config_root, '.env.example')).match(/SLACK_BOT_TOKEN=\S/))
|
|
59
|
+
|
|
60
|
+
// .gitignore ignores the env glob
|
|
61
|
+
assert.match(read(path.join(config_root, '.gitignore')), /secrets\/\*\.env/)
|
|
62
|
+
|
|
63
|
+
// warning names the required, unset secret
|
|
64
|
+
assert.ok(result.warnings.some(w => w.includes('SLACK_BOT_TOKEN')))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is idempotent — re-running yields byte-identical skills-config.json', async () => {
|
|
68
|
+
write_skill(base_dir, 'slack-notify', SLACK)
|
|
69
|
+
await configure_installed_skills([{ skill: 'acme/slack-notify' }], { base_dir, config_root, interactive: false })
|
|
70
|
+
const first = read(path.join(config_root, 'skills-config.json'))
|
|
71
|
+
await configure_installed_skills([{ skill: 'acme/slack-notify' }], { base_dir, config_root, interactive: false })
|
|
72
|
+
assert.strictEqual(read(path.join(config_root, 'skills-config.json')), first)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('warns when a required NON-secret field (no default) is left unset — fail-loud, §4.3.2', async () => {
|
|
76
|
+
write_skill(base_dir, 'deployer', {
|
|
77
|
+
config: { api_url: { type: 'string', required: true, description: 'API base URL' } },
|
|
78
|
+
env: { TIMEOUT: { required: true, secret: false, type: 'integer' } }
|
|
79
|
+
})
|
|
80
|
+
const [err, result] = await configure_installed_skills(
|
|
81
|
+
[{ skill: 'acme/deployer' }],
|
|
82
|
+
{ base_dir, config_root, interactive: false }
|
|
83
|
+
)
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
assert.ok(result.warnings.some(w => w.includes('api_url')), 'required non-secret config field must be surfaced')
|
|
86
|
+
assert.ok(result.warnings.some(w => w.includes('TIMEOUT')), 'required non-secret env field must be surfaced')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does NOT warn for a required field that has a default (the default is its value)', async () => {
|
|
90
|
+
write_skill(base_dir, 'has-default', {
|
|
91
|
+
config: { retries: { type: 'integer', required: true, default: 3 } }
|
|
92
|
+
})
|
|
93
|
+
const [err, result] = await configure_installed_skills(
|
|
94
|
+
[{ skill: 'acme/has-default' }],
|
|
95
|
+
{ base_dir, config_root, interactive: false }
|
|
96
|
+
)
|
|
97
|
+
assert.ifError(err)
|
|
98
|
+
assert.ok(!result.warnings.some(w => w.includes('retries')))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('writes nothing for a skill with no config/env block', async () => {
|
|
102
|
+
write_skill(base_dir, 'plain', {})
|
|
103
|
+
await configure_installed_skills([{ skill: 'acme/plain' }], { base_dir, config_root, interactive: false })
|
|
104
|
+
assert.ok(!fs.existsSync(path.join(config_root, 'skills-config.json')))
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('configure_installed_skills — interactive overrides', () => {
|
|
109
|
+
it('writes only non-default answers to the config map, never the secret value', async () => {
|
|
110
|
+
write_skill(base_dir, 'slack-notify', SLACK)
|
|
111
|
+
const answers = { channel: '#deploys', DEPLOY_TIMEOUT: '600' }
|
|
112
|
+
const ask = async (msg) => {
|
|
113
|
+
const field = msg.split(/[\s—[]/)[0].trim()
|
|
114
|
+
return answers[field] ?? ''
|
|
115
|
+
}
|
|
116
|
+
const [err] = await configure_installed_skills(
|
|
117
|
+
[{ skill: 'acme/slack-notify' }],
|
|
118
|
+
{ base_dir, config_root, interactive: true, ask }
|
|
119
|
+
)
|
|
120
|
+
assert.ifError(err)
|
|
121
|
+
|
|
122
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
123
|
+
const entry = cfg['acme/slack-notify']
|
|
124
|
+
assert.strictEqual(entry.config.channel, '#deploys')
|
|
125
|
+
assert.strictEqual(entry.config.DEPLOY_TIMEOUT, 600) // coerced to integer
|
|
126
|
+
assert.ok(!JSON.stringify(cfg).includes('xoxb')) // never prompted/stored a secret value
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('configure_installed_skills — reconcile on update (only_required)', () => {
|
|
131
|
+
it('re-prompts exactly the newly-required field and leaves existing values untouched', async () => {
|
|
132
|
+
// v1: only `channel` config. Consumer sets it.
|
|
133
|
+
write_skill(base_dir, 'slack-notify', {
|
|
134
|
+
config: { channel: { type: 'string', default: '#general', description: 'Channel' } }
|
|
135
|
+
})
|
|
136
|
+
await configure_installed_skills(
|
|
137
|
+
[{ skill: 'acme/slack-notify' }],
|
|
138
|
+
{ base_dir, config_root, interactive: true, ask: async () => '#deploys' }
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// v2: adds a new REQUIRED config field `retries`.
|
|
142
|
+
write_skill(base_dir, 'slack-notify', {
|
|
143
|
+
config: {
|
|
144
|
+
channel: { type: 'string', default: '#general', description: 'Channel' },
|
|
145
|
+
retries: { type: 'integer', required: true, description: 'Retry count' }
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const prompted = []
|
|
150
|
+
const ask = async (msg) => {
|
|
151
|
+
prompted.push(msg.split(/[\s—[]/)[0].trim())
|
|
152
|
+
return '3'
|
|
153
|
+
}
|
|
154
|
+
await configure_installed_skills(
|
|
155
|
+
[{ skill: 'acme/slack-notify' }],
|
|
156
|
+
{ base_dir, config_root, interactive: true, only_required: true, ask }
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// Only the new required field was asked
|
|
160
|
+
assert.deepStrictEqual(prompted, ['retries'])
|
|
161
|
+
const entry = JSON.parse(read(path.join(config_root, 'skills-config.json')))['acme/slack-notify']
|
|
162
|
+
assert.strictEqual(entry.config.channel, '#deploys') // untouched
|
|
163
|
+
assert.strictEqual(entry.config.retries, 3) // merged, coerced
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { read_json } = require('../utils/fs')
|
|
3
|
+
const { skills_config_file_path, global_skills_config_file_path } = require('../config/paths')
|
|
4
|
+
|
|
5
|
+
// skills-config.json is a flat map of `owner/name` -> { envFile?, config? }. Unlike the
|
|
6
|
+
// lock file it has no envelope wrapper — the top-level keys ARE the skill keys. A missing
|
|
7
|
+
// file is not an error: it means no skill in this project has been configured yet.
|
|
8
|
+
const read_skills_config = (project_root) => catch_errors('Failed to read skills-config.json', async () => {
|
|
9
|
+
const config_path = skills_config_file_path(project_root)
|
|
10
|
+
const [errors, data] = await read_json(config_path)
|
|
11
|
+
if (errors) return null
|
|
12
|
+
return data
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const read_global_skills_config = () => catch_errors('Failed to read global skills-config.json', async () => {
|
|
16
|
+
const config_path = global_skills_config_file_path()
|
|
17
|
+
const [errors, data] = await read_json(config_path)
|
|
18
|
+
if (errors) return null
|
|
19
|
+
return data
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const get_skill_config_entry = (config_data, key) => {
|
|
23
|
+
if (!config_data || typeof config_data !== 'object') return null
|
|
24
|
+
return config_data[key] || null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { read_skills_config, read_global_skills_config, get_skill_config_entry }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { read_json, file_exists, read_file } = require('../utils/fs')
|
|
4
|
+
const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
5
|
+
const { read_skills_config, read_global_skills_config, get_skill_config_entry } = require('./reader')
|
|
6
|
+
const { SKILL_JSON } = require('../constants')
|
|
7
|
+
|
|
8
|
+
const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
9
|
+
|
|
10
|
+
// project ⊕ global ⊕ default deep-merge; later argument wins on key collision.
|
|
11
|
+
const deep_merge = (a, b) => {
|
|
12
|
+
const out = { ...(as_object(a) || {}) }
|
|
13
|
+
for (const [k, v] of Object.entries(as_object(b) || {})) {
|
|
14
|
+
if (as_object(v) && as_object(out[k])) out[k] = deep_merge(out[k], v)
|
|
15
|
+
else out[k] = v
|
|
16
|
+
}
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Author defaults = config field defaults + NON-secret env defaults. Secrets never carry a
|
|
21
|
+
// committed default (validator warns on it), so they are structurally excluded here too —
|
|
22
|
+
// the resolver can never surface a secret value even via a stray default.
|
|
23
|
+
const author_defaults = (manifest) => {
|
|
24
|
+
const defaults = {}
|
|
25
|
+
const config = as_object(manifest && manifest.config)
|
|
26
|
+
if (config) for (const [field, spec] of Object.entries(config)) {
|
|
27
|
+
if (spec && spec.default !== undefined) defaults[field] = spec.default
|
|
28
|
+
}
|
|
29
|
+
const env = as_object(manifest && manifest.env)
|
|
30
|
+
if (env) for (const [name, spec] of Object.entries(env)) {
|
|
31
|
+
if (spec && !spec.secret && spec.default !== undefined) defaults[name] = spec.default
|
|
32
|
+
}
|
|
33
|
+
return defaults
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const required_secret_names = (manifest) => {
|
|
37
|
+
const env = as_object(manifest && manifest.env)
|
|
38
|
+
if (!env) return []
|
|
39
|
+
return Object.entries(env)
|
|
40
|
+
.filter(([, spec]) => spec && spec.secret === true && spec.required === true)
|
|
41
|
+
.map(([name]) => name)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read the installed skill.json — project scope first, then global. Best-effort: an
|
|
45
|
+
// unresolved manifest just means no author defaults / no declared secrets.
|
|
46
|
+
const find_installed_manifest = (name, project_root) => catch_errors('Failed to read installed skill.json', async () => {
|
|
47
|
+
const candidates = [
|
|
48
|
+
skill_install_dir(skills_dir(false, project_root), name),
|
|
49
|
+
skill_install_dir(skills_dir(true), name)
|
|
50
|
+
]
|
|
51
|
+
for (const dir of candidates) {
|
|
52
|
+
const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
|
|
53
|
+
if (manifest) return manifest
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// A required secret counts as present when it is a non-empty value in ambient process.env
|
|
59
|
+
// (which takes precedence at load time) OR a non-empty value in the resolved .env. The VALUE
|
|
60
|
+
// is read only to decide presence — it is never returned.
|
|
61
|
+
const secret_present = (name, env_abs_path) => catch_errors('Failed to check secret presence', async () => {
|
|
62
|
+
if (process.env[name] && process.env[name].trim() !== '') return true
|
|
63
|
+
if (!env_abs_path) return false
|
|
64
|
+
const [, exists] = await file_exists(env_abs_path)
|
|
65
|
+
if (!exists) return false
|
|
66
|
+
const [, content] = await read_file(env_abs_path)
|
|
67
|
+
for (const line of (content || '').split('\n')) {
|
|
68
|
+
const idx = line.indexOf('=')
|
|
69
|
+
if (idx === -1) continue
|
|
70
|
+
if (line.slice(0, idx).trim() === name && line.slice(idx + 1).trim() !== '') return true
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// The deterministic resolver behind `happyskills skills-config get`. Merges the cascade and
|
|
76
|
+
// reports required-secret NAMES + a present/absent boolean. NEVER reads-and-returns a secret
|
|
77
|
+
// value (§0 hard rule). `options.manifest` / `options.global_config` are injectable for tests.
|
|
78
|
+
const resolve_skill_config = (key, options = {}) => catch_errors('Failed to resolve skill config', async () => {
|
|
79
|
+
const { project_root = process.cwd() } = options
|
|
80
|
+
const name = key.split('/')[1]
|
|
81
|
+
|
|
82
|
+
const manifest = options.manifest !== undefined
|
|
83
|
+
? options.manifest
|
|
84
|
+
: (await find_installed_manifest(name, project_root))[1]
|
|
85
|
+
|
|
86
|
+
const [, project_file] = await read_skills_config(project_root)
|
|
87
|
+
const global_file = options.global_config !== undefined
|
|
88
|
+
? options.global_config
|
|
89
|
+
: (await read_global_skills_config())[1]
|
|
90
|
+
|
|
91
|
+
const project_entry = get_skill_config_entry(project_file, key)
|
|
92
|
+
const global_entry = get_skill_config_entry(global_file, key)
|
|
93
|
+
|
|
94
|
+
const merged = deep_merge(
|
|
95
|
+
deep_merge(author_defaults(manifest), as_object(global_entry && global_entry.config) || {}),
|
|
96
|
+
as_object(project_entry && project_entry.config) || {}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// envFile: whichever layer declared it wins (project first), resolved against THAT layer's
|
|
100
|
+
// root so a global pointer stays correct even when read from a project cwd.
|
|
101
|
+
let env_file_resolved = null
|
|
102
|
+
if (project_entry && project_entry.envFile) {
|
|
103
|
+
env_file_resolved = path.resolve(project_root, project_entry.envFile)
|
|
104
|
+
} else if (global_entry && global_entry.envFile) {
|
|
105
|
+
env_file_resolved = path.resolve(lock_root(true), global_entry.envFile)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const requiredSecrets = required_secret_names(manifest)
|
|
109
|
+
let secretsPresent = true
|
|
110
|
+
for (const secret of requiredSecrets) {
|
|
111
|
+
const [, present] = await secret_present(secret, env_file_resolved)
|
|
112
|
+
if (!present) { secretsPresent = false; break }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
config: merged,
|
|
117
|
+
// Absolute internally — the agent-facing command relativizes this to the caller's cwd so
|
|
118
|
+
// the emitted path is usable from wherever the agent runs and leaks no absolute local path.
|
|
119
|
+
envFile: env_file_resolved,
|
|
120
|
+
requiredSecrets,
|
|
121
|
+
secretsPresent,
|
|
122
|
+
manifestFound: !!manifest
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
module.exports = { resolve_skill_config, deep_merge, author_defaults, required_secret_names }
|