happyskills 1.17.0 → 1.19.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 +16 -0
- package/package.json +1 -1
- package/src/commands/validate.js +4 -1
- package/src/engine/uninstaller.js +24 -1
- package/src/skills_config/cleanup.js +107 -0
- package/src/skills_config/cleanup.test.js +123 -0
- package/src/skills_config/configure.js +46 -3
- package/src/skills_config/configure.test.js +98 -0
- package/src/utils/fs.js +5 -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 +35 -0
- package/src/validation/skill_json_rules.test.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.19.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add constellation-aware shared secrets. A non-kit skill that declares `sharedEnv: true` in `skill.json` now shares a single `./secrets/<core>.env` across itself and its skill-dependencies on install, instead of scaffolding an identical per-skill secrets file for every member (the redundant, drift-prone case for related skills). A consumer-set `envFile` still wins, standalone installs and kits are unaffected, and a collision guard warns when two shared members declare the same secret name with a different type. `happyskills validate` now checks `sharedEnv` (must be a boolean; error on a kit; warning when `true` without dependencies). Documented in `docs/skill-format.md` §4.6.
|
|
15
|
+
|
|
16
|
+
## [1.18.0] - 2026-07-05
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add a static config-usage lint to `happyskills validate`. For a skill whose `skill.json` declares a `config`/`env` block, `validate` now reads the skill's instruction text (SKILL.md + everything under `references/`) and emits advisory **warnings** — never blocking — when the instructions don't reference `happyskills skills-config get` (rule `config_resolution_guidance`, i.e. the skill should embed the one canonical resolution recipe rather than improvise a resolver) or when a declared `config` field / `env` variable is never referenced anywhere in the skill's text (rule `declared_unused`). The check is purely static — it never executes skill code.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `happyskills uninstall` now cleans up a skill's consumer-side configuration instead of leaving it behind. Previously install wrote a skill's `skills-config.json` block and scaffolded its `.env` secrets, but uninstall removed neither — the config entry and secret lines persisted after the skill was gone. Uninstall (for the named skill and every pruned orphan) now removes the `owner/name` entry from `skills-config.json` and surgically strips the skill's declared secret names from the resolved `envFile` and `.env.example`, deleting the `envFile` if it is left empty. The strip is shared-`envFile` safe — a secret still declared by a skill that remains installed is preserved. Cleanup is best-effort: a failure warns but never fails the uninstall.
|
|
25
|
+
|
|
10
26
|
## [1.17.0] - 2026-07-05
|
|
11
27
|
|
|
12
28
|
### Added
|
package/package.json
CHANGED
package/src/commands/validate.js
CHANGED
|
@@ -7,6 +7,7 @@ const { validate_no_conflict_markers } = require('../validation/conflict_marker_
|
|
|
7
7
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
8
8
|
const { validate_changelog_version } = require('../validation/changelog_rules')
|
|
9
9
|
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
10
|
+
const { validate_config_usage } = require('../validation/config_usage_rules')
|
|
10
11
|
const { file_exists, read_json } = require('../utils/fs')
|
|
11
12
|
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
12
13
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
@@ -178,6 +179,8 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
178
179
|
if (size_err) throw size_err
|
|
179
180
|
const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
|
|
180
181
|
if (cl_err) throw cl_err
|
|
182
|
+
const [cfg_err, cfg_results] = await validate_config_usage(skill_dir, json_data.manifest, md_data.content)
|
|
183
|
+
if (cfg_err) throw cfg_err
|
|
181
184
|
|
|
182
185
|
// Dependency validation — registry checks when authenticated
|
|
183
186
|
let dep_results = []
|
|
@@ -219,7 +222,7 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
|
|
225
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...(cfg_results || []), ...dep_results]
|
|
223
226
|
const type_label = is_kit ? ' [kit]' : ''
|
|
224
227
|
|
|
225
228
|
if (args.flags.json) {
|
|
@@ -4,7 +4,8 @@ const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
|
4
4
|
const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
5
5
|
const { remove_dir } = require('../utils/fs')
|
|
6
6
|
const { resolve_agents, unlink_from_agents } = require('../agents')
|
|
7
|
-
const {
|
|
7
|
+
const { cleanup_uninstalled_configs, read_skill_secret_names } = require('../skills_config/cleanup')
|
|
8
|
+
const { print_success, print_info, print_warn } = require('../ui/output')
|
|
8
9
|
|
|
9
10
|
const find_orphans = (skills, removed_skill) => {
|
|
10
11
|
// A skill is still needed if a SURVIVING skill (anything other than the one
|
|
@@ -98,6 +99,15 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
98
99
|
const orphans = find_orphans_cascading(all_skills, skill)
|
|
99
100
|
const to_remove = [skill, ...orphans]
|
|
100
101
|
|
|
102
|
+
// Capture each removed skill's declared secret NAMES BEFORE its install dir is deleted —
|
|
103
|
+
// the manifest is unreadable afterward, and the config cleanup below needs them to surgically
|
|
104
|
+
// strip the right .env lines. Non-secret / non-config skills yield an empty list.
|
|
105
|
+
const removed_config_info = []
|
|
106
|
+
for (const name of to_remove) {
|
|
107
|
+
const [, secret_names] = await read_skill_secret_names(base_dir, name)
|
|
108
|
+
removed_config_info.push({ key: name, name: name.split('/')[1], secret_names: secret_names || [] })
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
for (const name of to_remove) {
|
|
102
112
|
const install_dir = skill_install_dir(base_dir, name.split('/')[1])
|
|
103
113
|
const [rm_errors] = await remove_dir(install_dir)
|
|
@@ -120,6 +130,19 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
120
130
|
const [write_errors] = await write_lock(lock_dir, new_skills)
|
|
121
131
|
if (write_errors) throw e('Failed to update lock file', write_errors)
|
|
122
132
|
|
|
133
|
+
// Consumer-side config cleanup (spec 260704-01 Tier 6 — prune policy). Drop each removed
|
|
134
|
+
// skill's skills-config.json block and surgically strip its now-unused secret names from the
|
|
135
|
+
// .env / .env.example, preserving any secret a surviving skill still declares. Best-effort:
|
|
136
|
+
// a failure here warns but never fails the uninstall — the skill is already gone.
|
|
137
|
+
const surviving_keys = Object.keys(all_skills).filter(k => !to_remove.includes(k))
|
|
138
|
+
const [cleanup_err] = await cleanup_uninstalled_configs({
|
|
139
|
+
config_root: lock_dir,
|
|
140
|
+
base_dir,
|
|
141
|
+
removed: removed_config_info,
|
|
142
|
+
surviving_keys
|
|
143
|
+
})
|
|
144
|
+
if (cleanup_err) print_warn(`Config cleanup step failed: ${cleanup_err[0]?.message || 'unknown error'}`)
|
|
145
|
+
|
|
123
146
|
print_success(`Removed ${skill}`)
|
|
124
147
|
if (orphans.length > 0) {
|
|
125
148
|
print_info(`Pruned ${orphans.length} orphaned dependency(s): ${orphans.join(', ')}`)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { read_json, read_file, write_file, file_exists, remove_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 } = require('./writer')
|
|
7
|
+
const { SKILL_JSON } = require('../constants')
|
|
8
|
+
|
|
9
|
+
const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
10
|
+
|
|
11
|
+
// The env-var names an installed skill declares as SECRETS (secret: true). These are the only
|
|
12
|
+
// names install ever scaffolds into a .env, so they are the only names uninstall may strip.
|
|
13
|
+
const secret_names_from_manifest = (manifest) => {
|
|
14
|
+
const env = as_object(manifest && manifest.env) || {}
|
|
15
|
+
return Object.entries(env).filter(([, spec]) => spec && spec.secret).map(([name]) => name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Reads a skill's secret var names from its installed skill.json. Called BEFORE the install dir
|
|
19
|
+
// is removed (the manifest is unreadable afterward), so the uninstaller can hand them to cleanup.
|
|
20
|
+
const read_skill_secret_names = (base_dir, key) => catch_errors('Failed to read skill secret names', async () => {
|
|
21
|
+
const name = key.split('/')[1]
|
|
22
|
+
const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
|
|
23
|
+
return secret_names_from_manifest(manifest)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Pure: drop every `NAME=...` line whose key is in `names`, leaving all other lines (other
|
|
27
|
+
// assignments, comments, blanks) untouched. `all_blank` is true when nothing but whitespace
|
|
28
|
+
// survives — the signal to delete the file rather than leave an empty husk.
|
|
29
|
+
const strip_env_var_lines = (content, names) => {
|
|
30
|
+
const set = new Set(names)
|
|
31
|
+
const kept = []
|
|
32
|
+
let changed = false
|
|
33
|
+
for (const line of String(content || '').split('\n')) {
|
|
34
|
+
const idx = line.indexOf('=')
|
|
35
|
+
const key = idx === -1 ? null : line.slice(0, idx).trim()
|
|
36
|
+
if (key && set.has(key)) { changed = true; continue }
|
|
37
|
+
kept.push(line)
|
|
38
|
+
}
|
|
39
|
+
return { changed, next: kept.join('\n'), all_blank: kept.every(l => l.trim() === '') }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strip `names` from an env file in place. Deletes the file when it is left all-blank; otherwise
|
|
43
|
+
// rewrites it. A missing file or a no-op strip touches nothing.
|
|
44
|
+
const strip_names_from_env_file = (abs, names) => catch_errors('Failed to strip env file', async () => {
|
|
45
|
+
const [, exists] = await file_exists(abs)
|
|
46
|
+
if (!exists) return
|
|
47
|
+
const [, content] = await read_file(abs)
|
|
48
|
+
const { changed, next, all_blank } = strip_env_var_lines(content, names)
|
|
49
|
+
if (!changed) return
|
|
50
|
+
if (all_blank) { const [rm_err] = await remove_file(abs); if (rm_err) throw rm_err[0]; return }
|
|
51
|
+
const [w_err] = await write_file(abs, next.endsWith('\n') ? next : next + '\n')
|
|
52
|
+
if (w_err) throw w_err[0]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Uninstall-time counterpart to configure_installed_skills: for every removed skill, drop its
|
|
56
|
+
// `owner/name` block from skills-config.json and surgically strip its secret NAMES from the
|
|
57
|
+
// resolved envFile (and .env.example). A secret name still declared by a SURVIVING installed
|
|
58
|
+
// skill is preserved (shared-envFile safe). Never touches the .gitignore line. Best-effort —
|
|
59
|
+
// the caller treats a failure here as a warning, not an uninstall failure.
|
|
60
|
+
const cleanup_uninstalled_configs = ({ config_root, base_dir, removed, surviving_keys } = {}) => catch_errors('Failed to clean up skills-config on uninstall', async () => {
|
|
61
|
+
if (!config_root || !(removed && removed.length)) return { config_keys_removed: [], secrets_stripped: [] }
|
|
62
|
+
|
|
63
|
+
const [, existing] = await read_skills_config(config_root)
|
|
64
|
+
|
|
65
|
+
// Union of secret names declared by every skill that REMAINS installed — the shared-safe guard.
|
|
66
|
+
const surviving_secrets = new Set()
|
|
67
|
+
for (const key of surviving_keys || []) {
|
|
68
|
+
const name = key.split('/')[1]
|
|
69
|
+
const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
|
|
70
|
+
for (const secret of secret_names_from_manifest(manifest)) surviving_secrets.add(secret)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const config_keys_removed = []
|
|
74
|
+
const secrets_stripped = []
|
|
75
|
+
for (const { key, name, secret_names } of removed) {
|
|
76
|
+
const entry = get_skill_config_entry(existing, key)
|
|
77
|
+
|
|
78
|
+
// Remove the config block (committed, git-recoverable — safe to prune).
|
|
79
|
+
if (entry) {
|
|
80
|
+
const [set_err] = await set_skill_config_entry(config_root, key, null)
|
|
81
|
+
if (set_err) throw set_err[0]
|
|
82
|
+
config_keys_removed.push(key)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const names_to_strip = (secret_names || []).filter(n => !surviving_secrets.has(n))
|
|
86
|
+
if (!names_to_strip.length) continue
|
|
87
|
+
|
|
88
|
+
// envFile: the consumer's declared pointer, else the install-time default location.
|
|
89
|
+
const env_file_rel = (entry && entry.envFile) || `./secrets/${name}.env`
|
|
90
|
+
const env_abs = path.resolve(config_root, env_file_rel)
|
|
91
|
+
const [env_err] = await strip_names_from_env_file(env_abs, names_to_strip)
|
|
92
|
+
if (env_err) throw env_err[0]
|
|
93
|
+
// The committed name-only twin gets the same treatment.
|
|
94
|
+
const [ex_err] = await strip_names_from_env_file(path.join(config_root, '.env.example'), names_to_strip)
|
|
95
|
+
if (ex_err) throw ex_err[0]
|
|
96
|
+
secrets_stripped.push(...names_to_strip)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { config_keys_removed, secrets_stripped }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
cleanup_uninstalled_configs,
|
|
104
|
+
read_skill_secret_names,
|
|
105
|
+
strip_env_var_lines,
|
|
106
|
+
secret_names_from_manifest
|
|
107
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -75,11 +75,54 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
|
|
|
75
75
|
|
|
76
76
|
const [, existing_config_file] = await read_skills_config(config_root)
|
|
77
77
|
|
|
78
|
+
// Constellation shared-env pre-pass (spec: `sharedEnv`). A non-kit package that declares
|
|
79
|
+
// `sharedEnv: true` is a constellation root: it and every skill-dependency being installed
|
|
80
|
+
// share ONE derived `.env` (`./secrets/<root>.env`) instead of a redundant, drift-prone file
|
|
81
|
+
// each. Kits (unrelated bundles) are excluded and keep per-member files. Deterministic: sorted
|
|
82
|
+
// iteration, first root wins, and a consumer's own envFile pointer (honored in the loop below)
|
|
83
|
+
// always overrides the shared default.
|
|
84
|
+
const pkg_keys = new Set((packages || []).map(p => p.skill))
|
|
85
|
+
const manifest_by_key = new Map()
|
|
86
|
+
for (const pkg of packages || []) {
|
|
87
|
+
const pkg_name = pkg.skill.split('/')[1]
|
|
88
|
+
const [, m] = await read_json(path.join(skill_install_dir(base_dir, pkg_name), SKILL_JSON))
|
|
89
|
+
if (m) manifest_by_key.set(pkg.skill, m)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const shared_env_file_by_key = new Map()
|
|
93
|
+
for (const root_key of [...pkg_keys].sort()) {
|
|
94
|
+
const m = manifest_by_key.get(root_key)
|
|
95
|
+
if (!m || m.sharedEnv !== true || m.type === 'kit') continue
|
|
96
|
+
const shared_file = `./secrets/${root_key.split('/')[1]}.env`
|
|
97
|
+
const dep_keys = Object.keys(as_object(m.dependencies) || {})
|
|
98
|
+
for (const member_key of [root_key, ...dep_keys]) {
|
|
99
|
+
if (pkg_keys.has(member_key) && !shared_env_file_by_key.has(member_key)) shared_env_file_by_key.set(member_key, shared_file)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Collision guard: within one shared file, two members declaring the same secret NAME with a
|
|
104
|
+
// different `type` may not mean the same secret. Warn once so the author reconciles.
|
|
105
|
+
const shared_secret_seen = new Map() // `${file}\0${name}` -> { type, owner }
|
|
106
|
+
for (const member_key of [...shared_env_file_by_key.keys()].sort()) {
|
|
107
|
+
const shared_file = shared_env_file_by_key.get(member_key)
|
|
108
|
+
const env = as_object((manifest_by_key.get(member_key) || {}).env) || {}
|
|
109
|
+
for (const [var_name, spec] of Object.entries(env)) {
|
|
110
|
+
if (!spec || spec.secret !== true) continue
|
|
111
|
+
const seen_key = `${shared_file}\0${var_name}`
|
|
112
|
+
const this_type = spec.type === undefined ? null : spec.type
|
|
113
|
+
const prev = shared_secret_seen.get(seen_key)
|
|
114
|
+
if (prev) {
|
|
115
|
+
if (prev.type !== this_type) warnings.push(`Shared-env collision: secret "${var_name}" is declared by ${prev.owner} and ${member_key} with different types in ${shared_file} — verify they are the same secret.`)
|
|
116
|
+
} else {
|
|
117
|
+
shared_secret_seen.set(seen_key, { type: this_type, owner: member_key })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
78
122
|
for (const pkg of packages || []) {
|
|
79
123
|
const key = pkg.skill
|
|
80
124
|
const name = key.split('/')[1]
|
|
81
|
-
const
|
|
82
|
-
const [, manifest] = await read_json(path.join(install_dir, SKILL_JSON))
|
|
125
|
+
const manifest = manifest_by_key.get(key)
|
|
83
126
|
if (!manifest) continue
|
|
84
127
|
|
|
85
128
|
const config_schema = as_object(manifest.config)
|
|
@@ -122,7 +165,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
|
|
|
122
165
|
|
|
123
166
|
let env_file_rel = (existing_entry && existing_entry.envFile) || null
|
|
124
167
|
if (secret_names.length) {
|
|
125
|
-
env_file_rel = env_file_rel || `./secrets/${name}.env`
|
|
168
|
+
env_file_rel = env_file_rel || shared_env_file_by_key.get(key) || `./secrets/${name}.env`
|
|
126
169
|
// Resolve the (possibly consumer-chosen) envFile relative to the config root.
|
|
127
170
|
const env_abs = path.resolve(config_root, env_file_rel)
|
|
128
171
|
const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
|
|
@@ -127,6 +127,104 @@ describe('configure_installed_skills — interactive overrides', () => {
|
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
+
describe('configure_installed_skills — constellation sharedEnv', () => {
|
|
131
|
+
const TOKEN_ENV = { env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, description: 'CF token' } } }
|
|
132
|
+
|
|
133
|
+
it('routes a shared-env core and its dependency members to ONE derived envFile', async () => {
|
|
134
|
+
write_skill(base_dir, 'cloudflare', {
|
|
135
|
+
sharedEnv: true,
|
|
136
|
+
dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0', 'nicolasdao/cloudflare-config': '^0.1.0' },
|
|
137
|
+
...TOKEN_ENV
|
|
138
|
+
})
|
|
139
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
140
|
+
write_skill(base_dir, 'cloudflare-config', TOKEN_ENV)
|
|
141
|
+
|
|
142
|
+
const [err] = await configure_installed_skills(
|
|
143
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }, { skill: 'nicolasdao/cloudflare-config' }],
|
|
144
|
+
{ base_dir, config_root, interactive: false }
|
|
145
|
+
)
|
|
146
|
+
assert.ifError(err)
|
|
147
|
+
|
|
148
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
149
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
150
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare.env')
|
|
151
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-config'].envFile, './secrets/cloudflare.env')
|
|
152
|
+
|
|
153
|
+
// exactly ONE shared secrets file — the redundant per-member files are not created
|
|
154
|
+
assert.ok(fs.existsSync(path.join(config_root, 'secrets', 'cloudflare.env')))
|
|
155
|
+
assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-deploy.env')))
|
|
156
|
+
assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-config.env')))
|
|
157
|
+
|
|
158
|
+
// the shared file carries the token name exactly once
|
|
159
|
+
const env_content = read(path.join(config_root, 'secrets', 'cloudflare.env'))
|
|
160
|
+
assert.strictEqual((env_content.match(/CLOUDFLARE_API_TOKEN=/g) || []).length, 1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('respects a consumer-set envFile over the shared default', async () => {
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
path.join(config_root, 'skills-config.json'),
|
|
166
|
+
JSON.stringify({ 'nicolasdao/cloudflare-deploy': { envFile: './my-secrets/cf.env' } }, null, '\t')
|
|
167
|
+
)
|
|
168
|
+
write_skill(base_dir, 'cloudflare', { sharedEnv: true, dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
|
|
169
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
170
|
+
|
|
171
|
+
const [err] = await configure_installed_skills(
|
|
172
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
|
|
173
|
+
{ base_dir, config_root, interactive: false }
|
|
174
|
+
)
|
|
175
|
+
assert.ifError(err)
|
|
176
|
+
|
|
177
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
178
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './my-secrets/cf.env') // consumer override wins
|
|
179
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does NOT share when the core lacks sharedEnv (regression: per-skill files)', async () => {
|
|
183
|
+
write_skill(base_dir, 'cloudflare', { dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
|
|
184
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
185
|
+
|
|
186
|
+
const [err] = await configure_installed_skills(
|
|
187
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
|
|
188
|
+
{ base_dir, config_root, interactive: false }
|
|
189
|
+
)
|
|
190
|
+
assert.ifError(err)
|
|
191
|
+
|
|
192
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
193
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
194
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare-deploy.env')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('does NOT share for a kit (kit members keep isolated secrets)', async () => {
|
|
198
|
+
write_skill(base_dir, '_kit-x', { type: 'kit', sharedEnv: true, dependencies: { 'acme/a': '^1.0.0' } })
|
|
199
|
+
write_skill(base_dir, 'a', TOKEN_ENV)
|
|
200
|
+
|
|
201
|
+
const [err] = await configure_installed_skills(
|
|
202
|
+
[{ skill: 'acme/_kit-x' }, { skill: 'acme/a' }],
|
|
203
|
+
{ base_dir, config_root, interactive: false }
|
|
204
|
+
)
|
|
205
|
+
assert.ifError(err)
|
|
206
|
+
|
|
207
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
208
|
+
assert.strictEqual(cfg['acme/a'].envFile, './secrets/a.env') // NOT the kit's file
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('warns when two shared members declare the same secret with a different type', async () => {
|
|
212
|
+
write_skill(base_dir, 'core', {
|
|
213
|
+
sharedEnv: true,
|
|
214
|
+
dependencies: { 'acme/sat': '^1.0.0' },
|
|
215
|
+
env: { TOK: { required: true, secret: true, type: 'string' } }
|
|
216
|
+
})
|
|
217
|
+
write_skill(base_dir, 'sat', { env: { TOK: { required: true, secret: true, type: 'integer' } } })
|
|
218
|
+
|
|
219
|
+
const [err, result] = await configure_installed_skills(
|
|
220
|
+
[{ skill: 'acme/core' }, { skill: 'acme/sat' }],
|
|
221
|
+
{ base_dir, config_root, interactive: false }
|
|
222
|
+
)
|
|
223
|
+
assert.ifError(err)
|
|
224
|
+
assert.ok(result.warnings.some(w => /TOK/.test(w) && /shared/i.test(w)), 'a same-name/different-type shared secret must warn')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
130
228
|
describe('configure_installed_skills — reconcile on update (only_required)', () => {
|
|
131
229
|
it('re-prompts exactly the newly-required field and leaves existing values untouched', async () => {
|
|
132
230
|
// v1: only `channel` config. Consumer sets it.
|
package/src/utils/fs.js
CHANGED
|
@@ -38,6 +38,10 @@ const remove_dir = (dir_path) => catch_errors(`Failed to remove directory ${dir_
|
|
|
38
38
|
await fs.promises.rm(dir_path, { recursive: true, force: true })
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
const remove_file = (file_path) => catch_errors(`Failed to remove file ${file_path}`, async () => {
|
|
42
|
+
await fs.promises.rm(file_path, { force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
41
45
|
const read_file = (file_path) => catch_errors(`Failed to read file ${file_path}`, async () => {
|
|
42
46
|
return await fs.promises.readFile(file_path, 'utf-8')
|
|
43
47
|
})
|
|
@@ -46,4 +50,4 @@ const write_file = (file_path, content) => catch_errors(`Failed to write file ${
|
|
|
46
50
|
await fs.promises.writeFile(file_path, content, 'utf-8')
|
|
47
51
|
})
|
|
48
52
|
|
|
49
|
-
module.exports = { read_json, write_json, atomic_write_json, ensure_dir, file_exists, remove_dir, read_file, write_file }
|
|
53
|
+
module.exports = { read_json, write_json, atomic_write_json, ensure_dir, file_exists, remove_dir, remove_file, read_file, write_file }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Static config-usage lint — the deterministic authoring gate for config/secret skills.
|
|
3
|
+
//
|
|
4
|
+
// It CANNOT prove a skill's runtime resolver is correct (that needs execution, and we
|
|
5
|
+
// deliberately don't execute skill code). Instead it verifies, statically, that a skill which
|
|
6
|
+
// declares config/env ships the ONE correct resolution recipe rather than improvising: (a) its
|
|
7
|
+
// instructions reference `happyskills skills-config get` (the canonical resolver — the file
|
|
8
|
+
// fallback embedded alongside it is our vetted recipe, not a per-skill invention), and (b) every
|
|
9
|
+
// declared config field / env var is actually referenced in the skill's text. No code execution.
|
|
10
|
+
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
14
|
+
const { file_exists, read_file } = require('../utils/fs')
|
|
15
|
+
const { SKILL_JSON, SKILL_MD } = require('../constants')
|
|
16
|
+
|
|
17
|
+
const RESOLVER_HINT = 'skills-config get'
|
|
18
|
+
|
|
19
|
+
const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
20
|
+
|
|
21
|
+
const has_config_schema = (m) => {
|
|
22
|
+
const c = as_object(m && m.config)
|
|
23
|
+
const e = as_object(m && m.env)
|
|
24
|
+
return !!((c && Object.keys(c).length) || (e && Object.keys(e).length))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = (file, field, rule, severity, message, value) => ({
|
|
28
|
+
file, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Recursively collect files under a directory (best-effort; references are text/markdown).
|
|
32
|
+
const collect_files = (dir) => {
|
|
33
|
+
const out = []
|
|
34
|
+
const walk = (d) => {
|
|
35
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
36
|
+
const full = path.join(d, entry.name)
|
|
37
|
+
if (entry.isDirectory()) walk(full)
|
|
38
|
+
else out.push(full)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try { walk(dir) } catch { /* unreadable dir — nothing to scan */ }
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pure static lint over the manifest + the skill's full instruction text (SKILL.md + references).
|
|
46
|
+
const check_config_usage = (manifest, text) => {
|
|
47
|
+
if (!has_config_schema(manifest)) return []
|
|
48
|
+
const results = []
|
|
49
|
+
const body = String(text || '')
|
|
50
|
+
|
|
51
|
+
if (!body.includes(RESOLVER_HINT)) {
|
|
52
|
+
results.push(result(SKILL_MD, 'config', 'config_resolution_guidance', 'warning',
|
|
53
|
+
'Skill declares config/env but its instructions never reference `happyskills skills-config get`. Embed the canonical resolution snippet (skill-authoring.md § Configuration) so the agent resolves config deterministically instead of improvising — a hand-derived resolver risks reading a relative envFile against the wrong directory.'))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = as_object(manifest.config)
|
|
57
|
+
if (config) for (const field of Object.keys(config)) {
|
|
58
|
+
if (!body.includes(field)) results.push(result(SKILL_JSON, 'config', 'declared_unused', 'warning',
|
|
59
|
+
`config field "${field}" is declared in skill.json but never referenced in the skill's instructions — use it or remove the declaration.`, field))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const env = as_object(manifest.env)
|
|
63
|
+
if (env) for (const name of Object.keys(env)) {
|
|
64
|
+
if (!body.includes(name)) results.push(result(SKILL_JSON, 'env', 'declared_unused', 'warning',
|
|
65
|
+
`env variable "${name}" is declared in skill.json but never referenced in the skill's instructions — use it or remove the declaration.`, name))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reads SKILL.md (passed in) + every file under references/, then runs the static lint.
|
|
72
|
+
const validate_config_usage = (skill_dir, manifest, skill_md_content) => catch_errors('Failed to validate config usage', async () => {
|
|
73
|
+
if (!has_config_schema(manifest)) return []
|
|
74
|
+
let text = String(skill_md_content || '')
|
|
75
|
+
const refs_dir = path.join(skill_dir, 'references')
|
|
76
|
+
const [, refs_exists] = await file_exists(refs_dir)
|
|
77
|
+
if (refs_exists) {
|
|
78
|
+
for (const file of collect_files(refs_dir)) {
|
|
79
|
+
const [, content] = await read_file(file)
|
|
80
|
+
if (content) text += '\n' + content
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return check_config_usage(manifest, text)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
module.exports = { check_config_usage, validate_config_usage, has_config_schema }
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { check_config_usage, validate_config_usage } = require('./config_usage_rules')
|
|
9
|
+
|
|
10
|
+
const SLACK = {
|
|
11
|
+
name: 'slack-notify',
|
|
12
|
+
config: { channel: { type: 'string', default: '#general' } },
|
|
13
|
+
env: { SLACK_BOT_TOKEN: { required: true, secret: true } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('check_config_usage (pure)', () => {
|
|
17
|
+
it('warns when a config/env skill never references `skills-config get`', () => {
|
|
18
|
+
const r = check_config_usage(SLACK, 'Posts to a channel using a token.')
|
|
19
|
+
assert.ok(r.some(x => x.rule === 'config_resolution_guidance' && x.severity === 'warning'))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does NOT warn about resolution guidance when `skills-config get` is embedded', () => {
|
|
23
|
+
const text = 'Run `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.'
|
|
24
|
+
const r = check_config_usage(SLACK, text)
|
|
25
|
+
assert.ok(!r.some(x => x.rule === 'config_resolution_guidance'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('warns per declared config field / env var that is never referenced', () => {
|
|
29
|
+
// references the recipe + channel, but NOT SLACK_BOT_TOKEN
|
|
30
|
+
const text = 'happyskills skills-config get acme/slack-notify. Posts to channel.'
|
|
31
|
+
const r = check_config_usage(SLACK, text)
|
|
32
|
+
assert.ok(r.some(x => x.rule === 'declared_unused' && x.value === 'SLACK_BOT_TOKEN'), 'unused env var should warn')
|
|
33
|
+
assert.ok(!r.some(x => x.rule === 'declared_unused' && x.value === 'channel'), 'referenced config field should not warn')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns no results for a skill with no config/env block', () => {
|
|
37
|
+
assert.deepStrictEqual(check_config_usage({ name: 'plain' }, 'anything at all'), [])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('a well-formed, fully-referenced config skill yields no warnings', () => {
|
|
41
|
+
const text = 'Resolve via `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.'
|
|
42
|
+
const r = check_config_usage(SLACK, text)
|
|
43
|
+
assert.deepStrictEqual(r, [])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('validate_config_usage (reads SKILL.md + references/)', () => {
|
|
48
|
+
let dir
|
|
49
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-cfg-usage-')) })
|
|
50
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }) })
|
|
51
|
+
|
|
52
|
+
it('finds the recipe + token references across references/ files', async () => {
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Slack\nSee references for setup.\n')
|
|
54
|
+
fs.mkdirSync(path.join(dir, 'references'))
|
|
55
|
+
fs.writeFileSync(path.join(dir, 'references', 'setup.md'),
|
|
56
|
+
'Run `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.')
|
|
57
|
+
const [err, results] = await validate_config_usage(dir, SLACK, '# Slack\nSee references for setup.\n')
|
|
58
|
+
assert.ifError(err)
|
|
59
|
+
assert.deepStrictEqual(results, [], 'recipe + tokens in references/ should satisfy the lint')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -277,6 +277,40 @@ const validate_env = (manifest) => {
|
|
|
277
277
|
return results
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// sharedEnv (constellation-only). When a non-kit skill sets `sharedEnv: true`, the CLI points the
|
|
281
|
+
// skill AND its skill-dependencies at one shared `.env` on install — a constellation shares a
|
|
282
|
+
// config namespace, so its members should not each scaffold an identical secrets file. Forbidden
|
|
283
|
+
// on kits: a kit bundles unrelated skills, which keep isolated secrets. A no-op without deps.
|
|
284
|
+
const validate_shared_env = (manifest) => {
|
|
285
|
+
const results = []
|
|
286
|
+
if (manifest.sharedEnv === undefined) return results
|
|
287
|
+
|
|
288
|
+
if (typeof manifest.sharedEnv !== 'boolean') {
|
|
289
|
+
results.push(result('sharedEnv', 'type', 'error', 'sharedEnv must be a boolean'))
|
|
290
|
+
return results
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (manifest.sharedEnv !== true) {
|
|
294
|
+
results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv is false'))
|
|
295
|
+
return results
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (manifest.type === SKILL_TYPES.KIT) {
|
|
299
|
+
results.push(result('sharedEnv', 'kit_forbidden', 'error', 'sharedEnv is not allowed on a kit — kit members bundle unrelated skills and keep isolated secrets. Use sharedEnv only on a constellation core.'))
|
|
300
|
+
return results
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const deps = manifest.dependencies
|
|
304
|
+
const dep_count = deps && typeof deps === 'object' && !Array.isArray(deps) ? Object.keys(deps).length : 0
|
|
305
|
+
if (dep_count === 0) {
|
|
306
|
+
results.push(result('sharedEnv', 'no_dependencies', 'warning', 'sharedEnv: true has no effect without dependencies — it shares one .env across the skill and its skill-dependencies (a constellation).'))
|
|
307
|
+
return results
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv: true — the skill and its dependencies will share one .env on install'))
|
|
311
|
+
return results
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skill.json', async () => {
|
|
281
315
|
const results = []
|
|
282
316
|
const json_path = path.join(skill_dir, SKILL_JSON)
|
|
@@ -308,6 +342,7 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
|
|
|
308
342
|
results.push(...validate_system_dependencies(manifest))
|
|
309
343
|
results.push(...validate_config(manifest))
|
|
310
344
|
results.push(...validate_env(manifest))
|
|
345
|
+
results.push(...validate_shared_env(manifest))
|
|
311
346
|
|
|
312
347
|
const is_kit = manifest.type === SKILL_TYPES.KIT
|
|
313
348
|
if (is_kit) {
|
|
@@ -69,6 +69,40 @@ describe('validate_skill_json — name', () => {
|
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
describe('validate_skill_json — sharedEnv', () => {
|
|
73
|
+
it('errors when sharedEnv is not a boolean', async () => {
|
|
74
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: 'yes', dependencies: { 'a/b': '^1.0.0' } })
|
|
75
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
76
|
+
assert.ifError(err)
|
|
77
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'type')
|
|
78
|
+
assert.strictEqual(check.severity, 'error')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('errors when a kit declares sharedEnv: true', async () => {
|
|
82
|
+
write_json(tmp, { name: '_kit-x', version: '1.0.0', type: 'kit', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
|
|
83
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'kit_forbidden')
|
|
86
|
+
assert.strictEqual(check.severity, 'error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('warns when sharedEnv: true has no dependencies', async () => {
|
|
90
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true })
|
|
91
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
92
|
+
assert.ifError(err)
|
|
93
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'no_dependencies')
|
|
94
|
+
assert.strictEqual(check.severity, 'warning')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('passes for sharedEnv: true with dependencies', async () => {
|
|
98
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
|
|
99
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
100
|
+
assert.ifError(err)
|
|
101
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'valid')
|
|
102
|
+
assert.strictEqual(check.severity, 'pass')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
72
106
|
describe('validate_skill_json — version', () => {
|
|
73
107
|
it('errors when version is missing', async () => {
|
|
74
108
|
write_json(tmp, { name: 'my-skill' })
|