happyskills 1.16.3 → 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 +9 -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/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/integration/skills-config.test.js +98 -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/prompt.js +16 -1
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
|
@@ -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 }
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { resolve_skill_config, deep_merge, author_defaults } = require('./resolve')
|
|
9
|
+
const { set_skill_config_entry } = require('./writer')
|
|
10
|
+
|
|
11
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-resolve-'))
|
|
12
|
+
|
|
13
|
+
const MANIFEST = {
|
|
14
|
+
name: 'slack-notify',
|
|
15
|
+
config: {
|
|
16
|
+
channel: { type: 'string', default: '#general' },
|
|
17
|
+
max_chars: { type: 'integer', default: 500 }
|
|
18
|
+
},
|
|
19
|
+
env: {
|
|
20
|
+
SLACK_BOT_TOKEN: { required: true, secret: true },
|
|
21
|
+
DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let root
|
|
26
|
+
beforeEach(() => { root = make_tmp() })
|
|
27
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
28
|
+
|
|
29
|
+
describe('deep_merge + author_defaults', () => {
|
|
30
|
+
it('project wins over global wins over defaults', () => {
|
|
31
|
+
const merged = deep_merge(deep_merge({ a: 1, b: 1, c: 1 }, { b: 2, c: 2 }), { c: 3 })
|
|
32
|
+
assert.deepStrictEqual(merged, { a: 1, b: 2, c: 3 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('author_defaults pulls config defaults + NON-secret env defaults, never secret defaults', () => {
|
|
36
|
+
const d = author_defaults(MANIFEST)
|
|
37
|
+
assert.strictEqual(d.channel, '#general')
|
|
38
|
+
assert.strictEqual(d.max_chars, 500)
|
|
39
|
+
assert.strictEqual(d.DEPLOY_TIMEOUT, 300)
|
|
40
|
+
assert.ok(!('SLACK_BOT_TOKEN' in d))
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('resolve_skill_config — cascade + secrets', () => {
|
|
45
|
+
it('merges project ⊕ global ⊕ author-default and returns required secret NAMES only', async () => {
|
|
46
|
+
await set_skill_config_entry(root, 'acme/slack-notify', {
|
|
47
|
+
envFile: './secrets/slack-notify.env',
|
|
48
|
+
config: { channel: '#deploys' } // project override
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
52
|
+
project_root: root,
|
|
53
|
+
manifest: MANIFEST,
|
|
54
|
+
global_config: { 'acme/slack-notify': { config: { max_chars: 900 } } } // global override
|
|
55
|
+
})
|
|
56
|
+
assert.ifError(err)
|
|
57
|
+
|
|
58
|
+
// project channel wins; global max_chars wins over default; DEPLOY_TIMEOUT falls to default
|
|
59
|
+
assert.strictEqual(resolved.config.channel, '#deploys')
|
|
60
|
+
assert.strictEqual(resolved.config.max_chars, 900)
|
|
61
|
+
assert.strictEqual(resolved.config.DEPLOY_TIMEOUT, 300)
|
|
62
|
+
|
|
63
|
+
// required secret NAME only — never a value
|
|
64
|
+
assert.deepStrictEqual(resolved.requiredSecrets, ['SLACK_BOT_TOKEN'])
|
|
65
|
+
assert.strictEqual(resolved.secretsPresent, false) // .env has no value
|
|
66
|
+
|
|
67
|
+
// resolver returns the ABSOLUTE envFile internally; the command relativizes it to the
|
|
68
|
+
// caller's cwd for output (see skills-config.test.js)
|
|
69
|
+
assert.strictEqual(resolved.envFile, path.resolve(root, 'secrets/slack-notify.env'))
|
|
70
|
+
|
|
71
|
+
// the whole resolved object never contains a secret value
|
|
72
|
+
assert.ok(!JSON.stringify(resolved).includes('xoxb'))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reports secretsPresent:true when the secret is set in the .env', async () => {
|
|
76
|
+
fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
|
|
77
|
+
fs.writeFileSync(path.join(root, 'secrets', 'slack-notify.env'), 'SLACK_BOT_TOKEN=xoxb-real\n')
|
|
78
|
+
await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/slack-notify.env', config: {} })
|
|
79
|
+
|
|
80
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
81
|
+
project_root: root, manifest: MANIFEST, global_config: null
|
|
82
|
+
})
|
|
83
|
+
assert.ifError(err)
|
|
84
|
+
assert.strictEqual(resolved.secretsPresent, true)
|
|
85
|
+
// value is read for presence, but never surfaced
|
|
86
|
+
assert.ok(!JSON.stringify(resolved).includes('xoxb'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('NEVER surfaces a secret value even when the author wrongly gives a secret a default (§0 hard rule)', async () => {
|
|
90
|
+
const bad_manifest = {
|
|
91
|
+
name: 'leaky',
|
|
92
|
+
env: { API_KEY: { required: true, secret: true, default: 'sk-LEAKED-DEFAULT' } }
|
|
93
|
+
}
|
|
94
|
+
const [err, resolved] = await resolve_skill_config('acme/leaky', {
|
|
95
|
+
project_root: root, manifest: bad_manifest, global_config: null
|
|
96
|
+
})
|
|
97
|
+
assert.ifError(err)
|
|
98
|
+
assert.ok(!('API_KEY' in resolved.config), 'secret default must not appear in resolved config')
|
|
99
|
+
assert.ok(!JSON.stringify(resolved).includes('sk-LEAKED-DEFAULT'), 'secret value must never appear anywhere in the resolved object')
|
|
100
|
+
assert.deepStrictEqual(resolved.requiredSecrets, ['API_KEY'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('resolves to author defaults when no consumer config exists', async () => {
|
|
104
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
105
|
+
project_root: root, manifest: MANIFEST, global_config: null
|
|
106
|
+
})
|
|
107
|
+
assert.ifError(err)
|
|
108
|
+
assert.strictEqual(resolved.config.channel, '#general')
|
|
109
|
+
assert.strictEqual(resolved.envFile, null)
|
|
110
|
+
assert.strictEqual(resolved.secretsPresent, false) // required secret, none set
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { read_skills_config, get_skill_config_entry } = require('./reader')
|
|
9
|
+
const { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry } = require('./writer')
|
|
10
|
+
const { SKILLS_CONFIG_FILE } = require('../constants')
|
|
11
|
+
|
|
12
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-skills-config-'))
|
|
13
|
+
const read_raw = (root) => fs.readFileSync(path.join(root, SKILLS_CONFIG_FILE), 'utf-8')
|
|
14
|
+
|
|
15
|
+
let root
|
|
16
|
+
beforeEach(() => { root = make_tmp() })
|
|
17
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
18
|
+
|
|
19
|
+
describe('skills_config reader', () => {
|
|
20
|
+
it('returns null when the file does not exist', async () => {
|
|
21
|
+
const [err, data] = await read_skills_config(root)
|
|
22
|
+
assert.ifError(err)
|
|
23
|
+
assert.strictEqual(data, null)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('reads back a written entry', async () => {
|
|
27
|
+
await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/x.env', config: { channel: '#deploys' } })
|
|
28
|
+
const [err, data] = await read_skills_config(root)
|
|
29
|
+
assert.ifError(err)
|
|
30
|
+
const entry = get_skill_config_entry(data, 'acme/slack-notify')
|
|
31
|
+
assert.strictEqual(entry.envFile, './secrets/x.env')
|
|
32
|
+
assert.deepStrictEqual(entry.config, { channel: '#deploys' })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('skills_config writer — key-scoped writes', () => {
|
|
37
|
+
it('adding skill B leaves skill A byte-identical', async () => {
|
|
38
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './secrets/a.env', config: { x: 1 } })
|
|
39
|
+
const raw_a_before = read_raw(root)
|
|
40
|
+
const block_a_before = JSON.stringify(JSON.parse(raw_a_before)['acme/aaa'])
|
|
41
|
+
|
|
42
|
+
await set_skill_config_entry(root, 'acme/bbb', { envFile: './secrets/b.env', config: { y: 2 } })
|
|
43
|
+
const parsed = JSON.parse(read_raw(root))
|
|
44
|
+
assert.strictEqual(JSON.stringify(parsed['acme/aaa']), block_a_before)
|
|
45
|
+
assert.ok(parsed['acme/bbb'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('produces sorted, byte-stable output across two identical writes', async () => {
|
|
49
|
+
await set_skill_config_entry(root, 'acme/zzz', { envFile: './z.env', config: { b: 2, a: 1 } })
|
|
50
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
|
|
51
|
+
const first = read_raw(root)
|
|
52
|
+
|
|
53
|
+
// Top-level keys sorted: acme/aaa before acme/zzz
|
|
54
|
+
assert.ok(first.indexOf('acme/aaa') < first.indexOf('acme/zzz'))
|
|
55
|
+
// Nested config keys sorted too
|
|
56
|
+
assert.ok(first.indexOf('"c"') < first.indexOf('"d"'))
|
|
57
|
+
|
|
58
|
+
// Re-writing the same entry yields identical bytes
|
|
59
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
|
|
60
|
+
assert.strictEqual(read_raw(root), first)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never persists a secret value — build_config_entry drops everything but envFile and config', () => {
|
|
64
|
+
const entry = build_config_entry({
|
|
65
|
+
envFile: './secrets/x.env',
|
|
66
|
+
config: { channel: '#deploys' },
|
|
67
|
+
SLACK_BOT_TOKEN: 'xoxb-super-secret',
|
|
68
|
+
secrets: { SLACK_BOT_TOKEN: 'xoxb-super-secret' }
|
|
69
|
+
})
|
|
70
|
+
assert.deepStrictEqual(Object.keys(entry).sort(), ['config', 'envFile'])
|
|
71
|
+
assert.ok(!JSON.stringify(entry).includes('xoxb-super-secret'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('upsert with null removes the key', () => {
|
|
75
|
+
const next = upsert_skill_config({ 'acme/a': { config: {} }, 'acme/b': { config: {} } }, 'acme/a', null)
|
|
76
|
+
assert.deepStrictEqual(Object.keys(next), ['acme/b'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refuses to clobber an existing but corrupt skills-config.json (data-loss guard)', async () => {
|
|
80
|
+
fs.writeFileSync(path.join(root, SKILLS_CONFIG_FILE), '{ this is not valid json ')
|
|
81
|
+
const [err] = await set_skill_config_entry(root, 'acme/new', { config: { x: 1 } })
|
|
82
|
+
assert.ok(err, 'a corrupt existing file must surface an error, not be silently overwritten')
|
|
83
|
+
// original corrupt content is preserved, not replaced
|
|
84
|
+
assert.strictEqual(read_raw(root), '{ this is not valid json ')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('write_skills_config sorts keys deeply', async () => {
|
|
88
|
+
await write_skills_config(root, { 'z/z': { config: { b: 1, a: 2 } }, 'a/a': { envFile: './a.env' } })
|
|
89
|
+
const raw = read_raw(root)
|
|
90
|
+
assert.ok(raw.indexOf('a/a') < raw.indexOf('z/z'))
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { atomic_write_json, read_json, file_exists } = require('../utils/fs')
|
|
3
|
+
const { skills_config_file_path } = require('../config/paths')
|
|
4
|
+
|
|
5
|
+
// Deterministic serialization: recursively sort object keys so concurrent git additions
|
|
6
|
+
// merge cleanly and re-writing an unchanged entry is byte-stable. Arrays and scalars pass
|
|
7
|
+
// through untouched.
|
|
8
|
+
const sort_keys_deep = (value) => {
|
|
9
|
+
if (Array.isArray(value)) return value.map(sort_keys_deep)
|
|
10
|
+
if (value === null || typeof value !== 'object') return value
|
|
11
|
+
const out = {}
|
|
12
|
+
for (const key of Object.keys(value).sort()) out[key] = sort_keys_deep(value[key])
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A skills-config.json entry carries ONLY a non-secret `envFile` pointer and non-secret
|
|
17
|
+
// `config` overrides. This helper is the structural guard that a secret value can never
|
|
18
|
+
// leak into the committed file — anything but envFile/config is dropped here.
|
|
19
|
+
const build_config_entry = ({ envFile, config } = {}) => {
|
|
20
|
+
const entry = {}
|
|
21
|
+
if (envFile !== undefined) entry.envFile = envFile
|
|
22
|
+
if (config !== undefined) entry.config = config
|
|
23
|
+
return entry
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const upsert_skill_config = (existing, key, entry) => {
|
|
27
|
+
const out = { ...(existing || {}) }
|
|
28
|
+
if (entry === null) delete out[key]
|
|
29
|
+
else out[key] = entry
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const write_skills_config = (project_root, config_data) => catch_errors('Failed to write skills-config.json', async () => {
|
|
34
|
+
const config_path = skills_config_file_path(project_root)
|
|
35
|
+
const [errors] = await atomic_write_json(config_path, sort_keys_deep(config_data || {}))
|
|
36
|
+
if (errors) throw errors[0]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Read-modify-write a single `owner/name` subtree: read the current file, replace only that
|
|
40
|
+
// key's block (leaving every other skill's block untouched), and atomically re-serialize with
|
|
41
|
+
// sorted keys. Pass `entry: null` to remove the key.
|
|
42
|
+
const set_skill_config_entry = (project_root, key, entry) => catch_errors('Failed to set skills-config.json entry', async () => {
|
|
43
|
+
const config_path = skills_config_file_path(project_root)
|
|
44
|
+
const [read_errors, existing] = await read_json(config_path)
|
|
45
|
+
// Distinguish "no file yet" (start fresh) from "file exists but is corrupt". Clobbering a
|
|
46
|
+
// corrupt file would silently discard every OTHER skill's block — the exact opposite of the
|
|
47
|
+
// key-scoped guarantee. On a corrupt existing file, fail loud instead of overwriting.
|
|
48
|
+
if (read_errors) {
|
|
49
|
+
const [, exists] = await file_exists(config_path)
|
|
50
|
+
if (exists) throw new Error(`skills-config.json exists but could not be parsed at ${config_path} — refusing to overwrite it. Fix or remove the file, then retry.`)
|
|
51
|
+
}
|
|
52
|
+
const current = read_errors ? {} : (existing || {})
|
|
53
|
+
const next = upsert_skill_config(current, key, entry)
|
|
54
|
+
const [write_errors] = await atomic_write_json(config_path, sort_keys_deep(next))
|
|
55
|
+
if (write_errors) throw write_errors[0]
|
|
56
|
+
return next
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
module.exports = { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry, sort_keys_deep }
|
package/src/utils/prompt.js
CHANGED
|
@@ -13,4 +13,19 @@ const confirm_prompt = (message) => new Promise((resolve) => {
|
|
|
13
13
|
})
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Free-text prompt. Returns the trimmed answer, or '' when there is no TTY (so callers
|
|
17
|
+
// fall back to the schema default). Writes to stderr to keep stdout envelope-clean.
|
|
18
|
+
const question_prompt = (message) => new Promise((resolve) => {
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
resolve('')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
25
|
+
rl.question(`${message} `, (answer) => {
|
|
26
|
+
rl.close()
|
|
27
|
+
resolve(answer.trim())
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
module.exports = { confirm_prompt, question_prompt }
|