code-abyss 2.0.6 → 2.0.8

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.
Files changed (47) hide show
  1. package/README.md +129 -58
  2. package/bin/adapters/claude.js +16 -12
  3. package/bin/adapters/codex.js +110 -37
  4. package/bin/adapters/gemini.js +92 -0
  5. package/bin/install.js +521 -130
  6. package/bin/lib/ccline.js +18 -8
  7. package/bin/lib/gstack-claude.js +164 -0
  8. package/bin/lib/gstack-codex.js +347 -0
  9. package/bin/lib/gstack-gemini.js +140 -0
  10. package/bin/lib/pack-bootstrap.js +92 -0
  11. package/bin/lib/pack-docs.js +61 -0
  12. package/bin/lib/pack-registry.js +400 -0
  13. package/bin/lib/pack-reports.js +87 -0
  14. package/bin/lib/pack-vendor.js +82 -0
  15. package/bin/lib/style-registry.js +29 -7
  16. package/bin/lib/target-registry.js +74 -0
  17. package/bin/lib/utils.js +69 -6
  18. package/bin/lib/vendor-providers/archive.js +56 -0
  19. package/bin/lib/vendor-providers/git.js +59 -0
  20. package/bin/lib/vendor-providers/index.js +91 -0
  21. package/bin/lib/vendor-providers/local-dir.js +55 -0
  22. package/bin/lib/vendor-providers/shared.js +86 -0
  23. package/bin/packs.js +581 -0
  24. package/config/AGENTS.md +0 -2
  25. package/config/CLAUDE.md +43 -326
  26. package/config/codex-config.example.toml +26 -20
  27. package/config/gemini-settings.example.json +6 -0
  28. package/config/instruction.md +87 -0
  29. package/output-styles/abyss-command.md +7 -30
  30. package/output-styles/abyss-concise.md +11 -52
  31. package/output-styles/abyss-cultivator.md +20 -249
  32. package/output-styles/abyss-ritual.md +11 -45
  33. package/output-styles/index.json +20 -4
  34. package/package.json +26 -14
  35. package/packs/abyss/manifest.json +25 -0
  36. package/packs/gstack/manifest.json +151 -0
  37. package/skills/SKILL.md +33 -237
  38. package/skills/domains/frontend-design/agents/openai.yaml +1 -1
  39. package/skills/run_skill.js +25 -3
  40. package/skills/tools/gen-docs/agents/openai.yaml +1 -1
  41. package/skills/tools/verify-change/agents/openai.yaml +1 -1
  42. package/skills/tools/verify-module/agents/openai.yaml +1 -1
  43. package/skills/tools/verify-quality/agents/openai.yaml +1 -1
  44. package/skills/tools/verify-security/agents/openai.yaml +1 -1
  45. package/skills/tools/override-refusal/SKILL.md +0 -81
  46. package/skills/tools/override-refusal/agents/openai.yaml +0 -4
  47. package/skills/tools/override-refusal/scripts/refusal_rewriter.js +0 -340
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { copyRecursive, rmSafe } = require('./utils');
7
+ const {
8
+ getGstackConfig,
9
+ extractNameAndDescription,
10
+ listTopLevelSkillDirs,
11
+ resolveGstackSource,
12
+ condenseDescription,
13
+ copySkillRuntimeFiles,
14
+ } = require('./gstack-codex');
15
+
16
+ function rewriteGeminiPaths(content) {
17
+ return (getGstackConfig('gemini').pathRewrites || [])
18
+ .reduce((current, [from, to]) => current.split(from).join(to), content);
19
+ }
20
+
21
+ function transformGeminiSkillContent(content) {
22
+ return rewriteGeminiPaths(content);
23
+ }
24
+
25
+ function buildGeminiCommand(name, description, skillPath) {
26
+ const shortDescription = condenseDescription(description, 180).replace(/"/g, '\\"');
27
+ const prompt = [
28
+ `Read \`${skillPath}\` before acting.`,
29
+ '',
30
+ 'If Gemini CLI appended raw arguments after this command name, parse them before acting.',
31
+ '',
32
+ 'Use that skill as the authoritative playbook for the task.',
33
+ 'Respond with concrete actions instead of generic advice.',
34
+ ].join('\n');
35
+
36
+ return [
37
+ `description = "${shortDescription}"`,
38
+ 'prompt = """',
39
+ prompt,
40
+ '"""',
41
+ '',
42
+ ].join('\n');
43
+ }
44
+
45
+ function backupPathIfExists(targetPath, backupPath, manifest, rootName, relPath, info) {
46
+ if (!fs.existsSync(targetPath)) return false;
47
+ rmSafe(backupPath);
48
+ copyRecursive(targetPath, backupPath);
49
+ manifest.backups.push({ root: rootName, path: relPath });
50
+ info(`备份: ${rootName}/${relPath}`);
51
+ return true;
52
+ }
53
+
54
+ function installGstackGeminiPack({
55
+ HOME,
56
+ backupDir,
57
+ manifest,
58
+ info = () => {},
59
+ ok = () => {},
60
+ warn = () => {},
61
+ env = process.env,
62
+ sourceMode = 'pinned',
63
+ projectRoot = null,
64
+ fallback = false,
65
+ }) {
66
+ const resolved = resolveGstackSource({ HOME, env, warn, sourceMode, projectRoot, hostName: 'gemini', fallback });
67
+ const sourceRoot = resolved.sourceRoot;
68
+ if (!sourceRoot) return { installed: false, sourceMode: resolved.mode, reason: resolved.reason };
69
+
70
+ const config = getGstackConfig('gemini');
71
+ const skillRoot = path.join(HOME, '.gemini', 'skills', 'gstack');
72
+ const commandsRoot = path.join(HOME, '.gemini', 'commands');
73
+
74
+ backupPathIfExists(
75
+ skillRoot,
76
+ path.join(backupDir, 'gemini', 'skills', 'gstack'),
77
+ manifest,
78
+ 'gemini',
79
+ 'skills/gstack',
80
+ info
81
+ );
82
+
83
+ rmSafe(skillRoot);
84
+ fs.mkdirSync(skillRoot, { recursive: true });
85
+ fs.mkdirSync(commandsRoot, { recursive: true });
86
+
87
+ config.runtimeDirs.forEach((dirName) => {
88
+ const src = path.join(sourceRoot, dirName);
89
+ if (!fs.existsSync(src)) return;
90
+ copyRecursive(src, path.join(skillRoot, dirName));
91
+ });
92
+
93
+ config.runtimeFiles.forEach((fileName) => {
94
+ const src = path.join(sourceRoot, fileName);
95
+ if (!fs.existsSync(src)) return;
96
+ if (fileName === 'SKILL.md') {
97
+ const transformed = transformGeminiSkillContent(fs.readFileSync(src, 'utf8'));
98
+ fs.writeFileSync(path.join(skillRoot, fileName), transformed);
99
+ return;
100
+ }
101
+ copyRecursive(src, path.join(skillRoot, fileName));
102
+ });
103
+
104
+ listTopLevelSkillDirs(sourceRoot, 'gemini').forEach((skillDirName) => {
105
+ const srcDir = path.join(sourceRoot, skillDirName);
106
+ const destDir = path.join(skillRoot, skillDirName);
107
+ copySkillRuntimeFiles(srcDir, destDir, {
108
+ transformSkill: transformGeminiSkillContent,
109
+ });
110
+
111
+ const content = fs.readFileSync(path.join(srcDir, 'SKILL.md'), 'utf8');
112
+ const { name, description } = extractNameAndDescription(content);
113
+ const skillPath = `~/.gemini/skills/gstack/${skillDirName}/SKILL.md`;
114
+ const commandNames = [name, ...((config.commandAliases && config.commandAliases[name]) || [])];
115
+
116
+ commandNames.forEach((commandName) => {
117
+ const relPath = `commands/${commandName}.toml`;
118
+ backupPathIfExists(
119
+ path.join(commandsRoot, `${commandName}.toml`),
120
+ path.join(backupDir, 'gemini', relPath),
121
+ manifest,
122
+ 'gemini',
123
+ relPath,
124
+ info
125
+ );
126
+ fs.writeFileSync(path.join(commandsRoot, `${commandName}.toml`), buildGeminiCommand(commandName, description, skillPath));
127
+ manifest.installed.push({ root: 'gemini', path: relPath });
128
+ });
129
+ });
130
+
131
+ manifest.installed.push({ root: 'gemini', path: 'skills/gstack' });
132
+ ok('gemini/skills/gstack (gstack runtime)');
133
+ return { installed: true, sourceMode: resolved.mode, reason: null };
134
+ }
135
+
136
+ module.exports = {
137
+ buildGeminiCommand,
138
+ transformGeminiSkillContent,
139
+ installGstackGeminiPack,
140
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const {
7
+ applySnippetToFile,
8
+ hasSnippetBlock,
9
+ } = require('./pack-docs');
10
+ const { listTargetNames } = require('./target-registry');
11
+
12
+ function renderReadmeSnippet(lock) {
13
+ const lines = [
14
+ '## AI Pack Bootstrap',
15
+ '',
16
+ 'This repository declares Code Abyss packs in `.code-abyss/packs.lock.json`.',
17
+ '',
18
+ ];
19
+
20
+ listTargetNames().forEach((host) => {
21
+ const cfg = lock.hosts[host];
22
+ lines.push(`- ${host}: required=[${cfg.required.join(', ') || 'none'}], optional=[${cfg.optional.join(', ') || 'none'}], optional_policy=${cfg.optional_policy}`);
23
+ });
24
+
25
+ const installCommands = listTargetNames().map((host) => `npx code-abyss --target ${host} -y`);
26
+ lines.push(
27
+ '',
28
+ 'Recommended install:',
29
+ '',
30
+ '```bash',
31
+ ...installCommands,
32
+ '```',
33
+ ''
34
+ );
35
+ return lines.join('\n');
36
+ }
37
+
38
+ function renderContributingSnippet(lock) {
39
+ const targetNames = listTargetNames();
40
+ return [
41
+ '## AI Tooling',
42
+ '',
43
+ 'This repository uses `.code-abyss/packs.lock.json` to declare AI packs.',
44
+ '',
45
+ '- Update the lock with `npm run packs:update -- [flags]`.',
46
+ '- Validate it with `npm run packs:check`.',
47
+ `- Re-run \`npx code-abyss --target ${targetNames.join('|')} -y\` after pack changes.`,
48
+ '',
49
+ `Current host policies: ${targetNames.map((host) => `${host}=${lock.hosts[host].optional_policy}`).join(', ')}`,
50
+ '',
51
+ ].join('\n');
52
+ }
53
+
54
+ function writeBootstrapSnippets(projectRoot, lock) {
55
+ const snippetDir = path.join(projectRoot, '.code-abyss', 'snippets');
56
+ fs.mkdirSync(snippetDir, { recursive: true });
57
+ fs.writeFileSync(path.join(snippetDir, 'README.packs.md'), `${renderReadmeSnippet(lock)}\n`);
58
+ fs.writeFileSync(path.join(snippetDir, 'CONTRIBUTING.packs.md'), `${renderContributingSnippet(lock)}\n`);
59
+ return snippetDir;
60
+ }
61
+
62
+ function applyBootstrapDocs(projectRoot, lock, mode = 'all') {
63
+ const operations = [
64
+ { filePath: path.join(projectRoot, 'README.md'), kind: 'readme', content: renderReadmeSnippet(lock) },
65
+ { filePath: path.join(projectRoot, 'CONTRIBUTING.md'), kind: 'contributing', content: renderContributingSnippet(lock) },
66
+ ];
67
+
68
+ const results = [];
69
+ operations.forEach((op) => {
70
+ if (mode === 'markers-only' && !hasSnippetBlock(op.filePath, op.kind)) {
71
+ results.push({ filePath: op.filePath, action: 'skipped' });
72
+ return;
73
+ }
74
+ results.push(applySnippetToFile(op.filePath, op.kind, op.content));
75
+ });
76
+
77
+ return results;
78
+ }
79
+
80
+ function syncProjectBootstrapArtifacts(projectRoot, lock) {
81
+ const snippetDir = writeBootstrapSnippets(projectRoot, lock);
82
+ const docs = applyBootstrapDocs(projectRoot, lock, 'markers-only');
83
+ return { snippetDir, docs };
84
+ }
85
+
86
+ module.exports = {
87
+ renderReadmeSnippet,
88
+ renderContributingSnippet,
89
+ writeBootstrapSnippets,
90
+ applyBootstrapDocs,
91
+ syncProjectBootstrapArtifacts,
92
+ };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MARKERS = {
7
+ readme: {
8
+ start: '<!-- code-abyss:packs:readme:start -->',
9
+ end: '<!-- code-abyss:packs:readme:end -->',
10
+ },
11
+ contributing: {
12
+ start: '<!-- code-abyss:packs:contributing:start -->',
13
+ end: '<!-- code-abyss:packs:contributing:end -->',
14
+ },
15
+ };
16
+
17
+ function wrapSnippet(kind, content) {
18
+ const marker = MARKERS[kind];
19
+ return `${marker.start}\n${content.trim()}\n${marker.end}\n`;
20
+ }
21
+
22
+ function hasSnippetBlock(filePath, kind) {
23
+ if (!fs.existsSync(filePath)) return false;
24
+ const marker = MARKERS[kind];
25
+ const current = fs.readFileSync(filePath, 'utf8');
26
+ return current.includes(marker.start) && current.includes(marker.end);
27
+ }
28
+
29
+ function applySnippetToFile(filePath, kind, content) {
30
+ const wrapped = wrapSnippet(kind, content);
31
+ const marker = MARKERS[kind];
32
+ const exists = fs.existsSync(filePath);
33
+ const current = exists ? fs.readFileSync(filePath, 'utf8') : '';
34
+
35
+ const blockRe = new RegExp(`${marker.start}[\\s\\S]*?${marker.end}\\n?`, 'm');
36
+ let next;
37
+ let action;
38
+
39
+ if (blockRe.test(current)) {
40
+ next = current.replace(blockRe, wrapped);
41
+ action = 'updated';
42
+ } else if (current.trim().length === 0) {
43
+ next = wrapped;
44
+ action = 'created';
45
+ } else {
46
+ const suffix = current.endsWith('\n') ? '\n' : '\n\n';
47
+ next = `${current}${suffix}${wrapped}`;
48
+ action = 'appended';
49
+ }
50
+
51
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
52
+ fs.writeFileSync(filePath, next);
53
+ return { filePath, action };
54
+ }
55
+
56
+ module.exports = {
57
+ MARKERS,
58
+ wrapSnippet,
59
+ hasSnippetBlock,
60
+ applySnippetToFile,
61
+ };
@@ -0,0 +1,400 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { listVendorProviderNames } = require('./vendor-providers');
6
+ const { listTargetNames } = require('./target-registry');
7
+
8
+ const PROJECT_PACKS_LOCK_REL = path.join('.code-abyss', 'packs.lock.json');
9
+ const OPTIONAL_POLICIES = new Set(['auto', 'prompt', 'off']);
10
+ const PACK_SOURCE_MODES = new Set(['pinned', 'local', 'disabled']);
11
+ const HOST_NAMES = listTargetNames();
12
+
13
+ function validatePackManifest(manifest, manifestPath, projectRoot = null) {
14
+ const errors = [];
15
+
16
+ if (!manifest || typeof manifest !== 'object') {
17
+ errors.push('manifest 必须是对象');
18
+ } else {
19
+ if (typeof manifest.name !== 'string' || manifest.name.trim() === '') {
20
+ errors.push('name 必填');
21
+ }
22
+ if (typeof manifest.description !== 'string' || manifest.description.trim() === '') {
23
+ errors.push('description 必填');
24
+ }
25
+ if (!manifest.hosts || typeof manifest.hosts !== 'object') {
26
+ errors.push('hosts 必填');
27
+ } else {
28
+ Object.entries(manifest.hosts).forEach(([host, hostConfig]) => {
29
+ if (!HOST_NAMES.includes(host)) errors.push(`未知 host: ${host}`);
30
+ if (!hostConfig || typeof hostConfig !== 'object') {
31
+ errors.push(`host ${host} 配置无效`);
32
+ return;
33
+ }
34
+ if (hostConfig.files && !Array.isArray(hostConfig.files)) {
35
+ errors.push(`host ${host}.files 必须是数组`);
36
+ }
37
+ (hostConfig.files || []).forEach((file, index) => {
38
+ if (!file.src || !file.dest || !file.root) {
39
+ errors.push(`host ${host}.files[${index}] 缺少 src/dest/root`);
40
+ }
41
+ });
42
+ if (hostConfig.uninstall) {
43
+ if (!hostConfig.uninstall.runtimeRoot) {
44
+ errors.push(`host ${host}.uninstall 缺少 runtimeRoot`);
45
+ } else if (!hostConfig.uninstall.runtimeRoot.root || !hostConfig.uninstall.runtimeRoot.path) {
46
+ errors.push(`host ${host}.uninstall.runtimeRoot 缺少 root/path`);
47
+ }
48
+ }
49
+ });
50
+ }
51
+ if (manifest.reporting && typeof manifest.reporting !== 'object') {
52
+ errors.push('reporting 必须是对象');
53
+ }
54
+ if (manifest.reporting && manifest.reporting.label && typeof manifest.reporting.label !== 'string') {
55
+ errors.push('reporting.label 必须是字符串');
56
+ }
57
+ if (manifest.reporting && manifest.reporting.artifactPrefix && typeof manifest.reporting.artifactPrefix !== 'string') {
58
+ errors.push('reporting.artifactPrefix 必须是字符串');
59
+ }
60
+ if (manifest.upstream) {
61
+ const provider = manifest.upstream.provider || 'git';
62
+ const providerNames = new Set(listVendorProviderNames(projectRoot));
63
+ if (!providerNames.has(provider)) {
64
+ errors.push(`upstream.provider 非法: ${provider}`);
65
+ }
66
+ if (provider === 'git' && (!manifest.upstream.repo || !manifest.upstream.commit)) {
67
+ errors.push('upstream.provider=git 时必须提供 repo 和 commit');
68
+ }
69
+ if ((provider === 'local-dir' || provider === 'archive') && !manifest.upstream.path) {
70
+ errors.push(`upstream.provider=${provider} 时必须提供 path`);
71
+ }
72
+ }
73
+ }
74
+
75
+ if (errors.length > 0) {
76
+ throw new Error(`pack manifest 无效: ${manifestPath}\n- ${errors.join('\n- ')}`);
77
+ }
78
+ }
79
+
80
+ function getPacksRoot(projectRoot) {
81
+ return path.join(projectRoot, 'packs');
82
+ }
83
+
84
+ function listPackNames(projectRoot) {
85
+ const packsRoot = getPacksRoot(projectRoot);
86
+ if (!fs.existsSync(packsRoot)) return [];
87
+
88
+ return fs.readdirSync(packsRoot, { withFileTypes: true })
89
+ .filter((entry) => entry.isDirectory())
90
+ .map((entry) => entry.name)
91
+ .filter((name) => fs.existsSync(path.join(packsRoot, name, 'manifest.json')))
92
+ .sort();
93
+ }
94
+
95
+ function readPackManifest(projectRoot, packName) {
96
+ const manifestPath = path.join(getPacksRoot(projectRoot), packName, 'manifest.json');
97
+ if (!fs.existsSync(manifestPath)) {
98
+ throw new Error(`未找到 pack manifest: ${manifestPath}`);
99
+ }
100
+
101
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
102
+ if (!parsed || typeof parsed !== 'object') {
103
+ throw new Error(`pack manifest 无效: ${manifestPath}`);
104
+ }
105
+ validatePackManifest(parsed, manifestPath, projectRoot);
106
+ if (parsed.name !== packName) {
107
+ throw new Error(`pack manifest 名称不匹配: ${manifestPath}`);
108
+ }
109
+ return parsed;
110
+ }
111
+
112
+ function listPacks(projectRoot) {
113
+ return listPackNames(projectRoot).map((name) => readPackManifest(projectRoot, name));
114
+ }
115
+
116
+ function getPack(projectRoot, packName) {
117
+ return readPackManifest(projectRoot, packName);
118
+ }
119
+
120
+ function getPackHostConfig(projectRoot, packName, hostName) {
121
+ const manifest = getPack(projectRoot, packName);
122
+ return (manifest.hosts && manifest.hosts[hostName]) || null;
123
+ }
124
+
125
+ function getPackReporting(projectRoot, packName) {
126
+ const manifest = getPack(projectRoot, packName);
127
+ return {
128
+ label: (manifest.reporting && manifest.reporting.label) || packName,
129
+ artifactPrefix: (manifest.reporting && manifest.reporting.artifactPrefix) || packName,
130
+ };
131
+ }
132
+
133
+ function getPackHostFiles(projectRoot, packName, hostName) {
134
+ const host = getPackHostConfig(projectRoot, packName, hostName);
135
+ if (!host || !Array.isArray(host.files)) return [];
136
+
137
+ return host.files.map((entry) => ({
138
+ src: entry.src,
139
+ dest: entry.dest,
140
+ root: entry.root,
141
+ }));
142
+ }
143
+
144
+ function findProjectPacksLock(startDir = process.cwd()) {
145
+ let current = path.resolve(startDir);
146
+
147
+ while (true) {
148
+ const candidate = path.join(current, PROJECT_PACKS_LOCK_REL);
149
+ if (fs.existsSync(candidate)) return candidate;
150
+ const parent = path.dirname(current);
151
+ if (parent === current) return null;
152
+ current = parent;
153
+ }
154
+ }
155
+
156
+ function readProjectPackLock(startDir = process.cwd()) {
157
+ const lockPath = findProjectPacksLock(startDir);
158
+ if (!lockPath) return null;
159
+
160
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
161
+ if (!parsed || typeof parsed !== 'object') {
162
+ throw new Error(`project packs lock 无效: ${lockPath}`);
163
+ }
164
+ if (parsed.version !== 1) {
165
+ throw new Error(`project packs lock version 不支持: ${lockPath}`);
166
+ }
167
+ return {
168
+ path: lockPath,
169
+ root: path.dirname(path.dirname(lockPath)),
170
+ lock: parsed,
171
+ };
172
+ }
173
+
174
+ function resolveProjectPacks(startDir = process.cwd(), hostName) {
175
+ const payload = readProjectPackLock(startDir);
176
+ if (!payload) {
177
+ return {
178
+ root: null,
179
+ path: null,
180
+ required: [],
181
+ optional: [],
182
+ };
183
+ }
184
+
185
+ const normalized = normalizeProjectPackLock(payload.lock);
186
+ const hostConfig = normalized.hosts && normalized.hosts[hostName];
187
+ const required = Array.isArray(hostConfig && hostConfig.required) ? hostConfig.required.slice() : [];
188
+ const optional = Array.isArray(hostConfig && hostConfig.optional) ? hostConfig.optional.slice() : [];
189
+ const optionalPolicy = (hostConfig && hostConfig.optional_policy) || 'auto';
190
+
191
+ return {
192
+ root: payload.root,
193
+ path: payload.path,
194
+ required,
195
+ optional,
196
+ optionalPolicy,
197
+ sources: { ...(hostConfig && hostConfig.sources ? hostConfig.sources : {}) },
198
+ };
199
+ }
200
+
201
+ function uniqueSorted(values) {
202
+ return [...new Set((values || []).filter(Boolean))].sort();
203
+ }
204
+
205
+ function normalizeHostPackConfig(hostConfig = {}) {
206
+ const sources = {};
207
+ Object.entries(hostConfig.sources || {}).forEach(([pack, source]) => {
208
+ sources[pack] = PACK_SOURCE_MODES.has(source) ? source : 'pinned';
209
+ });
210
+ return {
211
+ required: uniqueSorted(hostConfig.required),
212
+ optional: uniqueSorted(hostConfig.optional),
213
+ optional_policy: OPTIONAL_POLICIES.has(hostConfig.optional_policy) ? hostConfig.optional_policy : 'auto',
214
+ sources,
215
+ };
216
+ }
217
+
218
+ function normalizeProjectPackLock(lock = {}) {
219
+ const hosts = {};
220
+ HOST_NAMES.forEach((host) => {
221
+ hosts[host] = normalizeHostPackConfig(lock.hosts && lock.hosts[host]);
222
+ });
223
+
224
+ return {
225
+ version: 1,
226
+ hosts,
227
+ };
228
+ }
229
+
230
+ function buildDefaultProjectPackLock(projectRoot, hostNames = HOST_NAMES) {
231
+ const packs = listPacks(projectRoot);
232
+ const lock = normalizeProjectPackLock({ version: 1, hosts: {} });
233
+
234
+ hostNames.forEach((host) => {
235
+ const required = [];
236
+ const optional = [];
237
+ const sources = {};
238
+
239
+ packs.forEach((pack) => {
240
+ const defaultMode = pack.projectDefaults && pack.projectDefaults[host];
241
+ if (defaultMode === 'required') {
242
+ required.push(pack.name);
243
+ sources[pack.name] = 'pinned';
244
+ }
245
+ if (defaultMode === 'optional') {
246
+ optional.push(pack.name);
247
+ sources[pack.name] = 'pinned';
248
+ }
249
+ });
250
+
251
+ lock.hosts[host] = {
252
+ required: uniqueSorted(required),
253
+ optional: uniqueSorted(optional),
254
+ optional_policy: 'auto',
255
+ sources,
256
+ };
257
+ });
258
+
259
+ return lock;
260
+ }
261
+
262
+ function validateProjectPackLock(lock, projectRoot) {
263
+ const normalized = normalizeProjectPackLock(lock);
264
+ const available = new Set(listPackNames(projectRoot));
265
+ const errors = [];
266
+
267
+ HOST_NAMES.forEach((host) => {
268
+ const rawHost = (lock.hosts && lock.hosts[host]) || {};
269
+ const hostConfig = normalized.hosts[host];
270
+ const listed = new Set([...hostConfig.required, ...hostConfig.optional]);
271
+ hostConfig.required.forEach((pack) => {
272
+ if (!available.has(pack)) errors.push(`[${host}] 未知 required pack: ${pack}`);
273
+ if (pack === 'abyss') errors.push(`[${host}] core pack 'abyss' 不应写入 packs.lock`);
274
+ if (hostConfig.sources[pack] === 'disabled') errors.push(`[${host}] required pack 不能设置 source=disabled: ${pack}`);
275
+ });
276
+ hostConfig.optional.forEach((pack) => {
277
+ if (!available.has(pack)) errors.push(`[${host}] 未知 optional pack: ${pack}`);
278
+ if (pack === 'abyss') errors.push(`[${host}] core pack 'abyss' 不应写入 packs.lock`);
279
+ if (hostConfig.required.includes(pack)) errors.push(`[${host}] pack 同时出现在 required 和 optional: ${pack}`);
280
+ });
281
+ if (rawHost.optional_policy && !OPTIONAL_POLICIES.has(rawHost.optional_policy)) {
282
+ errors.push(`[${host}] optional_policy 非法: ${rawHost.optional_policy}`);
283
+ }
284
+ Object.entries(rawHost.sources || {}).forEach(([pack, source]) => {
285
+ if (!PACK_SOURCE_MODES.has(source)) {
286
+ errors.push(`[${host}] source 非法: ${pack}=${source}`);
287
+ }
288
+ if (!listed.has(pack)) {
289
+ errors.push(`[${host}] source 指向未声明 pack: ${pack}`);
290
+ }
291
+ });
292
+ });
293
+
294
+ return errors;
295
+ }
296
+
297
+ async function selectProjectPacksForInstall(projectPacks, { autoYes = false, confirm = null } = {}) {
298
+ const sources = { ...(projectPacks.sources || {}) };
299
+ const required = uniqueSorted(projectPacks.required).filter((pack) => sources[pack] !== 'disabled');
300
+ const optional = uniqueSorted(projectPacks.optional).filter((pack) => sources[pack] !== 'disabled');
301
+ const policy = projectPacks.optionalPolicy || 'auto';
302
+
303
+ if (policy === 'off' || optional.length === 0) {
304
+ return { selected: required, optionalSelected: [], optionalPolicy: policy, sources };
305
+ }
306
+
307
+ if (policy === 'auto') {
308
+ return {
309
+ selected: uniqueSorted([...required, ...optional]),
310
+ optionalSelected: optional,
311
+ optionalPolicy: policy,
312
+ sources,
313
+ };
314
+ }
315
+
316
+ if (autoYes || typeof confirm !== 'function') {
317
+ return {
318
+ selected: uniqueSorted([...required, ...optional]),
319
+ optionalSelected: optional,
320
+ optionalPolicy: policy,
321
+ sources,
322
+ };
323
+ }
324
+
325
+ const accepted = await confirm(optional);
326
+ return {
327
+ selected: accepted ? uniqueSorted([...required, ...optional]) : required,
328
+ optionalSelected: accepted ? optional : [],
329
+ optionalPolicy: policy,
330
+ sources,
331
+ };
332
+ }
333
+
334
+ function diffProjectPackLocks(currentLock, defaultLock) {
335
+ const current = normalizeProjectPackLock(currentLock);
336
+ const defaults = normalizeProjectPackLock(defaultLock);
337
+ const diffs = [];
338
+
339
+ HOST_NAMES.forEach((host) => {
340
+ const now = current.hosts[host];
341
+ const base = defaults.hosts[host];
342
+
343
+ const addedRequired = now.required.filter((pack) => !base.required.includes(pack));
344
+ const removedRequired = base.required.filter((pack) => !now.required.includes(pack));
345
+ const addedOptional = now.optional.filter((pack) => !base.optional.includes(pack));
346
+ const removedOptional = base.optional.filter((pack) => !now.optional.includes(pack));
347
+ const sourceChanges = {};
348
+
349
+ uniqueSorted([...Object.keys(now.sources), ...Object.keys(base.sources)]).forEach((pack) => {
350
+ const from = base.sources[pack] || null;
351
+ const to = now.sources[pack] || null;
352
+ if (from !== to) sourceChanges[pack] = { from, to };
353
+ });
354
+
355
+ if (
356
+ addedRequired.length > 0
357
+ || removedRequired.length > 0
358
+ || addedOptional.length > 0
359
+ || removedOptional.length > 0
360
+ || now.optional_policy !== base.optional_policy
361
+ || Object.keys(sourceChanges).length > 0
362
+ ) {
363
+ diffs.push({
364
+ host,
365
+ addedRequired,
366
+ removedRequired,
367
+ addedOptional,
368
+ removedOptional,
369
+ optionalPolicy: { from: base.optional_policy, to: now.optional_policy },
370
+ sourceChanges,
371
+ });
372
+ }
373
+ });
374
+
375
+ return diffs;
376
+ }
377
+
378
+ module.exports = {
379
+ getPacksRoot,
380
+ listPackNames,
381
+ listPacks,
382
+ getPack,
383
+ getPackHostConfig,
384
+ getPackReporting,
385
+ getPackHostFiles,
386
+ PROJECT_PACKS_LOCK_REL,
387
+ HOST_NAMES,
388
+ OPTIONAL_POLICIES,
389
+ PACK_SOURCE_MODES,
390
+ validatePackManifest,
391
+ normalizeHostPackConfig,
392
+ normalizeProjectPackLock,
393
+ buildDefaultProjectPackLock,
394
+ validateProjectPackLock,
395
+ findProjectPacksLock,
396
+ readProjectPackLock,
397
+ resolveProjectPacks,
398
+ selectProjectPacksForInstall,
399
+ diffProjectPackLocks,
400
+ };