release-skill 0.1.1 → 0.1.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/INSTALL.md +179 -5
- package/INSTALL.zh-CN.md +320 -0
- package/README.md +347 -67
- package/README.zh-CN.md +318 -59
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/skills/release-help/SKILL.md +7 -4
- package/adapters/claude/skills/release-prepare/SKILL.md +11 -1
- package/adapters/claude/skills/release-publish/SKILL.md +6 -3
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/claude/skills/release-setup/SKILL.md +111 -0
- package/adapters/claude/skills/release-verify/SKILL.md +5 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/skills/release-help/SKILL.md +7 -4
- package/adapters/codex/skills/release-prepare/SKILL.md +11 -1
- package/adapters/codex/skills/release-publish/SKILL.md +6 -3
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/skills/release-setup/SKILL.md +111 -0
- package/adapters/codex/skills/release-verify/SKILL.md +5 -2
- package/bin/release-skill.mjs +65 -9
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +22 -2
- package/native/safe-write/src/safe_write.cc +11 -2
- package/package.json +3 -1
- package/references/02-project-config.md +54 -3
- package/references/05-evidence-and-errors.md +6 -2
- package/schemas/release-plan.schema.json +550 -65
- package/schemas/release-project.schema.json +398 -29
- package/schemas/release-run.schema.json +165 -18
- package/skills/release-help/SKILL.md +7 -4
- package/skills/release-prepare/SKILL.md +11 -1
- package/skills/release-publish/SKILL.md +6 -3
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills/release-setup/SKILL.md +111 -0
- package/skills/release-verify/SKILL.md +5 -2
- package/skills-src/release-help/SKILL.md +7 -4
- package/skills-src/release-prepare/SKILL.md +11 -1
- package/skills-src/release-publish/SKILL.md +6 -3
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-setup/SKILL.md +111 -0
- package/skills-src/release-verify/SKILL.md +5 -2
- package/src/adapters/contract.mjs +3 -0
- package/src/adapters/git-github.mjs +84 -2
- package/src/adapters/plugin-marketplace.mjs +65 -21
- package/src/adapters/push-snapshot.mjs +84 -17
- package/src/commands/prepare.mjs +223 -20
- package/src/commands/publish.mjs +45 -0
- package/src/commands/reconcile.mjs +152 -0
- package/src/commands/setup.mjs +886 -0
- package/src/commands/verify.mjs +122 -26
- package/src/core/config.mjs +34 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/plan.mjs +123 -0
- package/src/core/previous-public-baseline.mjs +21 -1
- package/src/core/verification-gates.mjs +451 -0
- package/src/snapshot/frozen.mjs +89 -5
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-use setup discovery and create-once configuration bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Dry-run is the default. Human-owned files are never regenerated: write
|
|
5
|
+
* mode can only create an absent `.release-skill/project.yaml` after the
|
|
6
|
+
* caller confirms the exact digest of the current facts and answers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { createReadStream } from 'node:fs';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import {
|
|
14
|
+
lstat,
|
|
15
|
+
readFile,
|
|
16
|
+
readdir,
|
|
17
|
+
realpath,
|
|
18
|
+
} from 'node:fs/promises';
|
|
19
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import YAML from 'yaml';
|
|
22
|
+
import Ajv from 'ajv';
|
|
23
|
+
import addFormats from 'ajv-formats';
|
|
24
|
+
|
|
25
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
26
|
+
import { acquireProjectLock } from '../artifacts/project-lock.mjs';
|
|
27
|
+
import {
|
|
28
|
+
CONFIG_EXISTS,
|
|
29
|
+
CONFIG_INVALID,
|
|
30
|
+
ReleaseError,
|
|
31
|
+
SETUP_DIGEST_MISMATCH,
|
|
32
|
+
} from '../core/errors.mjs';
|
|
33
|
+
|
|
34
|
+
const execFile = promisify(execFileCb);
|
|
35
|
+
const SKIP_DIRS = new Set([
|
|
36
|
+
'.git', '.release-skill', '.worktrees', '.claude', '.codex', '.cache', '.tmp',
|
|
37
|
+
'.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', '.venv', 'venv',
|
|
38
|
+
'node_modules', 'dist', 'coverage', 'build', 'out', 'tmp', 'temp',
|
|
39
|
+
'runs', 'test', 'tests', 'test-fixtures', 'fixtures', 'examples',
|
|
40
|
+
]);
|
|
41
|
+
const MAX_JSON_BYTES = 1024 * 1024;
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const schema = JSON.parse(await readFile(resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json'), 'utf8'));
|
|
44
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
45
|
+
addFormats(ajv);
|
|
46
|
+
const validateProjectConfig = ajv.compile(schema);
|
|
47
|
+
|
|
48
|
+
function setupError(code, message, details = {}) {
|
|
49
|
+
return new ReleaseError(code, message, details);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeRelative(root, path) {
|
|
53
|
+
const rel = relative(root, path).split('\\').join('/');
|
|
54
|
+
return rel || '.';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readJsonBounded(path, label) {
|
|
58
|
+
const stat = await lstat(path);
|
|
59
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JSON_BYTES) {
|
|
60
|
+
throw setupError(CONFIG_INVALID, `${label} must be a regular JSON file no larger than 1 MiB`, { path });
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw setupError(CONFIG_INVALID, `${label} is not valid JSON: ${error.message}`, { path });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function walkDiscoveryFiles(root, maxDepth = 5) {
|
|
70
|
+
const found = [];
|
|
71
|
+
async function walk(directory, depth) {
|
|
72
|
+
if (depth > maxDepth) return;
|
|
73
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
74
|
+
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
75
|
+
for (const child of children) {
|
|
76
|
+
if (child.isSymbolicLink()) continue;
|
|
77
|
+
const absolute = join(directory, child.name);
|
|
78
|
+
if (child.isDirectory()) {
|
|
79
|
+
if (!SKIP_DIRS.has(child.name)) await walk(absolute, depth + 1);
|
|
80
|
+
} else if (
|
|
81
|
+
child.isFile() &&
|
|
82
|
+
(child.name === 'package.json' ||
|
|
83
|
+
child.name === 'public-release.json' ||
|
|
84
|
+
/^README(?:\.|$)/i.test(child.name) ||
|
|
85
|
+
/^LICENSE(?:\.|$)/i.test(child.name) ||
|
|
86
|
+
/^CHANGELOG(?:\.|$)/i.test(child.name) ||
|
|
87
|
+
absolute.endsWith('/.claude-plugin/plugin.json') ||
|
|
88
|
+
absolute.endsWith('/.codex-plugin/plugin.json') ||
|
|
89
|
+
absolute.endsWith('/.claude-plugin/marketplace.json') ||
|
|
90
|
+
absolute.endsWith('/.codex-plugin/marketplace.json'))
|
|
91
|
+
) {
|
|
92
|
+
found.push(absolute);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await walk(root, 0);
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function digestFile(path) {
|
|
101
|
+
const before = await lstat(path);
|
|
102
|
+
if (!before.isFile() || before.isSymbolicLink()) throw setupError(CONFIG_INVALID, 'discovered file must be regular', { path });
|
|
103
|
+
const hash = createHash('sha256');
|
|
104
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
105
|
+
const after = await lstat(path);
|
|
106
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino) {
|
|
107
|
+
throw setupError(CONFIG_INVALID, 'discovered file changed while setup was reading it', { path });
|
|
108
|
+
}
|
|
109
|
+
return { size: after.size, sha256: hash.digest('hex') };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseGithubRepo(value) {
|
|
113
|
+
if (!value) return null;
|
|
114
|
+
const raw = typeof value === 'string' ? value : value.url;
|
|
115
|
+
if (typeof raw !== 'string') return null;
|
|
116
|
+
const match = raw.match(/github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:#.*)?$/);
|
|
117
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeUnitId(pkg, relDir) {
|
|
121
|
+
const fromName = typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : '';
|
|
122
|
+
const fallback = relDir === '.' ? 'root' : basename(relDir);
|
|
123
|
+
const candidate = (fromName || fallback).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
124
|
+
return candidate || 'release-unit';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function optionalString(value) {
|
|
128
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stringList(value) {
|
|
132
|
+
return Array.isArray(value)
|
|
133
|
+
? value.filter((item) => typeof item === 'string' && item.length > 0)
|
|
134
|
+
: [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function summarizeLegacyReleaseConfig(value, path) {
|
|
138
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
139
|
+
throw setupError(CONFIG_INVALID, 'public-release.json must contain a JSON object', { path });
|
|
140
|
+
}
|
|
141
|
+
const topLevelRepo = optionalString(value.repoId) ?? parseGithubRepo(value.publicRepoUrl);
|
|
142
|
+
const topLevelSource = optionalString(value.publicSourceDir) ?? stringList(value.publicRoots)[0] ?? '.';
|
|
143
|
+
const declaredRepos = Array.isArray(value.repos) ? value.repos : [];
|
|
144
|
+
const releaseUnits = declaredRepos
|
|
145
|
+
.filter((repo) => repo && typeof repo === 'object' && !Array.isArray(repo))
|
|
146
|
+
.map((repo, index) => ({
|
|
147
|
+
id: optionalString(repo.id) ?? optionalString(repo.name) ?? `legacy-unit-${index + 1}`,
|
|
148
|
+
source: optionalString(repo.source) ?? '.',
|
|
149
|
+
publicRepo: optionalString(repo.publicRepo),
|
|
150
|
+
tagPrefix: optionalString(repo.tagPrefix),
|
|
151
|
+
npmPackage: optionalString(repo.npmPackage),
|
|
152
|
+
npmPackageDeclared: Object.hasOwn(repo, 'npmPackage'),
|
|
153
|
+
docsSource: optionalString(repo.docsSource),
|
|
154
|
+
requiredPathCandidates: stringList(repo.requiredPackagePaths),
|
|
155
|
+
snapshotCommands: Array.isArray(repo.snapshotCommands) ? repo.snapshotCommands : [],
|
|
156
|
+
}));
|
|
157
|
+
if (releaseUnits.length === 0 && (topLevelRepo || value.plugins || value.snapshotCommands)) {
|
|
158
|
+
const plugins = Array.isArray(value.plugins) ? value.plugins : [];
|
|
159
|
+
const pluginName = plugins
|
|
160
|
+
.filter((plugin) => plugin && typeof plugin === 'object' && !Array.isArray(plugin))
|
|
161
|
+
.map((plugin) => optionalString(plugin.name))
|
|
162
|
+
.find(Boolean);
|
|
163
|
+
releaseUnits.push({
|
|
164
|
+
id: pluginName ?? basename(topLevelSource),
|
|
165
|
+
source: topLevelSource,
|
|
166
|
+
publicRepo: topLevelRepo,
|
|
167
|
+
tagPrefix: optionalString(value.tagPrefix),
|
|
168
|
+
npmPackage: plugins
|
|
169
|
+
.filter((plugin) => plugin && typeof plugin === 'object' && !Array.isArray(plugin))
|
|
170
|
+
.map((plugin) => optionalString(plugin.npmPackage))
|
|
171
|
+
.find(Boolean) ?? null,
|
|
172
|
+
npmPackageDeclared: plugins.some((plugin) => (
|
|
173
|
+
plugin && typeof plugin === 'object' && !Array.isArray(plugin) && Object.hasOwn(plugin, 'npmPackage')
|
|
174
|
+
)),
|
|
175
|
+
docsSource: null,
|
|
176
|
+
requiredPathCandidates: stringList(value.requiredPaths),
|
|
177
|
+
snapshotCommands: Array.isArray(value.snapshotCommands) ? value.snapshotCommands : [],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
path,
|
|
182
|
+
owner: optionalString(value.owner),
|
|
183
|
+
defaultBranch: optionalString(value.defaultBranch),
|
|
184
|
+
parentRepo: optionalString(value.parentRepo),
|
|
185
|
+
releaseUnits,
|
|
186
|
+
sharedFileCandidates: Array.isArray(value.sharedFiles)
|
|
187
|
+
? value.sharedFiles
|
|
188
|
+
.filter((item) => item && typeof item === 'object' && !Array.isArray(item))
|
|
189
|
+
.map((item) => ({ source: optionalString(item.source), target: optionalString(item.target) }))
|
|
190
|
+
.filter((item) => item.source && item.target)
|
|
191
|
+
: [],
|
|
192
|
+
docFileCandidates: stringList(value.docFiles),
|
|
193
|
+
forbiddenPathCandidates: [
|
|
194
|
+
...stringList(value.forbiddenPublicPaths),
|
|
195
|
+
...stringList(value.forbiddenPaths),
|
|
196
|
+
].sort(),
|
|
197
|
+
forbiddenContentPatternCandidates: stringList(value.forbiddenContentPatterns).sort(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeLegacyCommand(value) {
|
|
202
|
+
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
203
|
+
if (Array.isArray(value[1]) && value[1].every((item) => typeof item === 'string')) {
|
|
204
|
+
return [value[0], ...value[1]];
|
|
205
|
+
}
|
|
206
|
+
if (value.every((item) => typeof item === 'string')) return [...value];
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'string' && !/[|&;<>`$'"\\]/.test(value)) {
|
|
209
|
+
const tokens = value.trim().split(/\s+/).filter(Boolean);
|
|
210
|
+
return tokens.length > 0 ? tokens : null;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function classifyScript(name, command, unitId, distributionTypes) {
|
|
216
|
+
const normalized = `${name} ${command}`.toLowerCase();
|
|
217
|
+
const highCost = /(llm|real[-_: ]?smoke|end[-_: ]?to[-_: ]?end|\be2e\b|integration)/.test(normalized);
|
|
218
|
+
const mayWrite = /(build|generate|update|fix|format|codegen)/.test(normalized);
|
|
219
|
+
const networkLikely = /(llm|network|online|publish|release|deploy)/.test(normalized);
|
|
220
|
+
const isSmoke = /smoke/.test(normalized);
|
|
221
|
+
const distribution = distributionTypes.length === 1 ? distributionTypes[0] : null;
|
|
222
|
+
return {
|
|
223
|
+
id: `${unitId}-script-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`,
|
|
224
|
+
script: name,
|
|
225
|
+
command: ['npm', 'run', name],
|
|
226
|
+
recommendedPhase: isSmoke ? 'consumer-verify' : 'snapshot-verify',
|
|
227
|
+
scope: {
|
|
228
|
+
unit: unitId,
|
|
229
|
+
...(isSmoke && distribution ? { distribution } : {}),
|
|
230
|
+
},
|
|
231
|
+
...(
|
|
232
|
+
isSmoke && !distribution && distributionTypes.length > 1
|
|
233
|
+
? { distributionCandidates: [...distributionTypes] }
|
|
234
|
+
: {}
|
|
235
|
+
),
|
|
236
|
+
cost: highCost ? 'high' : /test|smoke/.test(normalized) ? 'medium' : 'low',
|
|
237
|
+
sideEffects: {
|
|
238
|
+
mayWriteFiles: mayWrite,
|
|
239
|
+
networkLikely,
|
|
240
|
+
unsandboxed: true,
|
|
241
|
+
},
|
|
242
|
+
reason: isSmoke
|
|
243
|
+
? '脚本名称表明它可能验证安装后的实际使用;必须人工确认后才能注册。'
|
|
244
|
+
: '项目已声明质量脚本,可在冻结快照副本上复用;不会自动注册。',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function discoverGit(root) {
|
|
249
|
+
const run = async (args) => {
|
|
250
|
+
try {
|
|
251
|
+
const { stdout } = await execFile('git', args, { cwd: root, shell: false, encoding: 'utf8', timeout: 5000 });
|
|
252
|
+
return stdout.trim();
|
|
253
|
+
} catch {
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const remoteLines = (await run(['remote', '-v'])).split('\n').filter(Boolean);
|
|
258
|
+
const remotes = [];
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
for (const line of remoteLines) {
|
|
261
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
262
|
+
if (!match) continue;
|
|
263
|
+
const key = `${match[1]}\0${match[2]}`;
|
|
264
|
+
if (seen.has(key)) continue;
|
|
265
|
+
seen.add(key);
|
|
266
|
+
remotes.push({ name: match[1], url: match[2], repo: parseGithubRepo(match[2]) });
|
|
267
|
+
}
|
|
268
|
+
remotes.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
269
|
+
return {
|
|
270
|
+
repository: Boolean(await run(['rev-parse', '--git-dir'])),
|
|
271
|
+
branch: await run(['branch', '--show-current']) || null,
|
|
272
|
+
head: await run(['rev-parse', 'HEAD']) || null,
|
|
273
|
+
tags: (await run(['tag', '--list'])).split('\n').filter(Boolean).sort(),
|
|
274
|
+
remotes,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function discoverFacts(root) {
|
|
279
|
+
const files = await walkDiscoveryFiles(root);
|
|
280
|
+
const packageFiles = files.filter((path) => (
|
|
281
|
+
basename(path) === 'package.json' &&
|
|
282
|
+
!/[\\/]adapters[\\/](?:claude|codex)[\\/]package\.json$/.test(path)
|
|
283
|
+
));
|
|
284
|
+
const pluginFiles = files.filter((path) => path.endsWith('/plugin.json'));
|
|
285
|
+
const marketplaceFiles = files.filter((path) => path.endsWith('/marketplace.json'));
|
|
286
|
+
const legacyReleaseFiles = files.filter((path) => basename(path) === 'public-release.json');
|
|
287
|
+
const fileDigests = [];
|
|
288
|
+
for (const path of files) {
|
|
289
|
+
fileDigests.push({ path: safeRelative(root, path), ...await digestFile(path) });
|
|
290
|
+
}
|
|
291
|
+
fileDigests.sort((a, b) => a.path.localeCompare(b.path));
|
|
292
|
+
const packages = [];
|
|
293
|
+
for (const path of packageFiles) {
|
|
294
|
+
const pkg = await readJsonBounded(path, 'discovered package.json');
|
|
295
|
+
const relPath = safeRelative(root, path);
|
|
296
|
+
const relDir = safeRelative(root, dirname(path));
|
|
297
|
+
packages.push({
|
|
298
|
+
path: relPath,
|
|
299
|
+
directory: relDir,
|
|
300
|
+
name: typeof pkg.name === 'string' ? pkg.name : null,
|
|
301
|
+
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
302
|
+
private: pkg.private === true,
|
|
303
|
+
repository: parseGithubRepo(pkg.repository),
|
|
304
|
+
publishRegistry: typeof pkg.publishConfig?.registry === 'string' ? pkg.publishConfig.registry : null,
|
|
305
|
+
files: Array.isArray(pkg.files) ? pkg.files.filter((item) => typeof item === 'string').sort() : [],
|
|
306
|
+
scripts: Object.fromEntries(Object.entries(pkg.scripts ?? {})
|
|
307
|
+
.filter(([, value]) => typeof value === 'string')
|
|
308
|
+
.sort(([a], [b]) => a.localeCompare(b))),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
packages.sort((a, b) => a.path.localeCompare(b.path));
|
|
312
|
+
|
|
313
|
+
const manifests = [];
|
|
314
|
+
for (const path of [...pluginFiles, ...marketplaceFiles].sort()) {
|
|
315
|
+
const value = await readJsonBounded(path, 'discovered plugin manifest');
|
|
316
|
+
manifests.push({
|
|
317
|
+
path: safeRelative(root, path),
|
|
318
|
+
host: path.includes('/.claude-plugin/') ? 'claude' : 'codex',
|
|
319
|
+
kind: path.endsWith('/marketplace.json') ? 'marketplace' : 'plugin',
|
|
320
|
+
name: typeof value.name === 'string' ? value.name : null,
|
|
321
|
+
version: typeof value.version === 'string' ? value.version : null,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const legacyReleaseConfigs = [];
|
|
326
|
+
for (const path of legacyReleaseFiles.sort()) {
|
|
327
|
+
const value = await readJsonBounded(path, 'discovered public-release.json');
|
|
328
|
+
legacyReleaseConfigs.push(summarizeLegacyReleaseConfig(value, safeRelative(root, path)));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { git: await discoverGit(root), packages, manifests, legacyReleaseConfigs, fileDigests };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildCandidates(facts) {
|
|
335
|
+
const gitRepos = facts.git.remotes.map((remote) => remote.repo).filter(Boolean);
|
|
336
|
+
const uniqueGitRepos = [...new Set(gitRepos)];
|
|
337
|
+
const units = [];
|
|
338
|
+
const gates = [];
|
|
339
|
+
const ids = new Set();
|
|
340
|
+
const knownFiles = new Set(facts.fileDigests.map((file) => file.path));
|
|
341
|
+
const manifestRoots = facts.manifests.map((manifest) => {
|
|
342
|
+
const match = manifest.path.match(/^(.*?)(?:\/)?(?:\.claude-plugin|\.codex-plugin)\/(?:plugin|marketplace)\.json$/);
|
|
343
|
+
return { ...manifest, root: match?.[1] || '.' };
|
|
344
|
+
});
|
|
345
|
+
const manifestOwners = new Map();
|
|
346
|
+
const legacyUnits = facts.legacyReleaseConfigs.flatMap((config) => config.releaseUnits);
|
|
347
|
+
const legacyDefaultBranches = [...new Set(facts.legacyReleaseConfigs
|
|
348
|
+
.map((config) => config.defaultBranch)
|
|
349
|
+
.filter(Boolean))];
|
|
350
|
+
for (const manifest of manifestRoots) {
|
|
351
|
+
const owners = facts.packages
|
|
352
|
+
.filter((pkg) => pkg.directory === '.' || manifest.root === pkg.directory || manifest.root.startsWith(`${pkg.directory}/`))
|
|
353
|
+
.sort((a, b) => b.directory.length - a.directory.length);
|
|
354
|
+
if (owners[0]) manifestOwners.set(manifest.path, owners[0].path);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const pkg of facts.packages) {
|
|
358
|
+
const sourceMatchedLegacyUnits = legacyUnits.filter((unit) => unit.source === pkg.directory);
|
|
359
|
+
const preferredLegacyId = sourceMatchedLegacyUnits.map((unit) => unit.id).find(Boolean);
|
|
360
|
+
let id = preferredLegacyId ?? safeUnitId(pkg, pkg.directory);
|
|
361
|
+
let suffix = 2;
|
|
362
|
+
while (ids.has(id)) id = `${safeUnitId(pkg, pkg.directory)}-${suffix++}`;
|
|
363
|
+
ids.add(id);
|
|
364
|
+
const matchingLegacyUnits = legacyUnits.filter((unit) => (
|
|
365
|
+
unit.id === id || unit.source === pkg.directory || unit.source === dirname(pkg.path)
|
|
366
|
+
));
|
|
367
|
+
const pluginHosts = facts.manifests
|
|
368
|
+
.filter((manifest) => manifestOwners.get(manifest.path) === pkg.path)
|
|
369
|
+
.filter((manifest) => manifest.kind === 'plugin')
|
|
370
|
+
.map((manifest) => manifest.host);
|
|
371
|
+
const distributions = [];
|
|
372
|
+
const legacyChannelsAreAuthoritative = matchingLegacyUnits.length > 0;
|
|
373
|
+
const npmExplicitlyDeclared = matchingLegacyUnits.some((unit) => (
|
|
374
|
+
unit.npmPackageDeclared && unit.npmPackage !== null
|
|
375
|
+
));
|
|
376
|
+
const npmExplicitlyForbidden = matchingLegacyUnits.some((unit) => (
|
|
377
|
+
unit.npmPackageDeclared && unit.npmPackage === null
|
|
378
|
+
));
|
|
379
|
+
if (
|
|
380
|
+
!pkg.private &&
|
|
381
|
+
pkg.name &&
|
|
382
|
+
!npmExplicitlyForbidden &&
|
|
383
|
+
(!legacyChannelsAreAuthoritative || npmExplicitlyDeclared)
|
|
384
|
+
) distributions.push('npm');
|
|
385
|
+
if (pluginHosts.includes('claude')) distributions.push('claude-plugin');
|
|
386
|
+
if (pluginHosts.includes('codex')) distributions.push('codex-plugin');
|
|
387
|
+
if (pkg.private && matchingLegacyUnits.length === 0 && facts.legacyReleaseConfigs.length > 0) continue;
|
|
388
|
+
if (pkg.private && distributions.length === 0) continue;
|
|
389
|
+
const repositoryCandidates = [...new Set([
|
|
390
|
+
pkg.repository,
|
|
391
|
+
...matchingLegacyUnits.map((unit) => unit.publicRepo),
|
|
392
|
+
...uniqueGitRepos,
|
|
393
|
+
].filter(Boolean))];
|
|
394
|
+
const legacyTagTemplates = matchingLegacyUnits
|
|
395
|
+
.map((unit) => unit.tagPrefix ? `${unit.tagPrefix}{version}` : null)
|
|
396
|
+
.filter(Boolean);
|
|
397
|
+
const branchCandidates = [...new Set([
|
|
398
|
+
...legacyDefaultBranches,
|
|
399
|
+
facts.git.branch,
|
|
400
|
+
].filter(Boolean))];
|
|
401
|
+
units.push({
|
|
402
|
+
id,
|
|
403
|
+
source: pkg.directory,
|
|
404
|
+
packagePath: pkg.path,
|
|
405
|
+
version: pkg.version,
|
|
406
|
+
publicRepoCandidates: repositoryCandidates,
|
|
407
|
+
distributionCandidates: distributions,
|
|
408
|
+
tagTemplateCandidates: [...new Set([
|
|
409
|
+
...legacyTagTemplates,
|
|
410
|
+
...(facts.git.tags.some((tag) => pkg.version && tag === `v${pkg.version}`) ? ['v{version}'] : []),
|
|
411
|
+
...(facts.git.tags.some((tag) => pkg.version && tag === `${id}-v${pkg.version}`)
|
|
412
|
+
? [`${id}-v{version}`]
|
|
413
|
+
: []),
|
|
414
|
+
])],
|
|
415
|
+
branchCandidates,
|
|
416
|
+
branchStrategyCandidates: repositoryCandidates.length > 0
|
|
417
|
+
? ['advance-existing-branch', 'create-release-branch', 'initialize-default-branch']
|
|
418
|
+
: [],
|
|
419
|
+
previousPublicBaselineStatus: repositoryCandidates.length === 0
|
|
420
|
+
? 'CHANNEL_MISSING'
|
|
421
|
+
: facts.git.tags.length > 0
|
|
422
|
+
? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
|
|
423
|
+
: 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION',
|
|
424
|
+
publicFileCandidates: [
|
|
425
|
+
pkg.path,
|
|
426
|
+
pkg.directory === '.' ? 'README.md' : `${pkg.directory}/README.md`,
|
|
427
|
+
pkg.directory === '.' ? 'README.zh-CN.md' : `${pkg.directory}/README.zh-CN.md`,
|
|
428
|
+
pkg.directory === '.' ? 'LICENSE' : `${pkg.directory}/LICENSE`,
|
|
429
|
+
...facts.manifests
|
|
430
|
+
.filter((manifest) => manifestOwners.get(manifest.path) === pkg.path)
|
|
431
|
+
.map((manifest) => manifest.path),
|
|
432
|
+
].filter((value, index, array) => array.indexOf(value) === index && knownFiles.has(value)).sort(),
|
|
433
|
+
legacyPublicFileHints: matchingLegacyUnits.flatMap((unit) => unit.requiredPathCandidates).sort(),
|
|
434
|
+
packageFilePatternCandidates: [...pkg.files],
|
|
435
|
+
});
|
|
436
|
+
for (const [script, command] of Object.entries(pkg.scripts)) {
|
|
437
|
+
if (/^(docs|build|test|typecheck|lint|check|validate|verify|smoke)(?:$|[:_-])/.test(script)) {
|
|
438
|
+
gates.push(classifyScript(script, command, id, distributions));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const legacy of matchingLegacyUnits) {
|
|
442
|
+
legacy.snapshotCommands.forEach((rawCommand, index) => {
|
|
443
|
+
const command = normalizeLegacyCommand(rawCommand);
|
|
444
|
+
gates.push({
|
|
445
|
+
id: `${id}-legacy-snapshot-${index + 1}`,
|
|
446
|
+
source: 'public-release.json snapshotCommands',
|
|
447
|
+
command,
|
|
448
|
+
recommendedPhase: 'snapshot-verify',
|
|
449
|
+
scope: { unit: id },
|
|
450
|
+
cost: 'medium',
|
|
451
|
+
sideEffects: { mayWriteFiles: true, networkLikely: false, unsandboxed: true },
|
|
452
|
+
requiresManualCommandArray: !command,
|
|
453
|
+
reason: '旧发布配置声明了快照校验;迁移为 gate 前必须人工确认命令数组、副作用和耗时。',
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A skill/plugin repository may intentionally have no package.json. Keep
|
|
460
|
+
// it discoverable as a plugin-only candidate instead of inventing npm.
|
|
461
|
+
const unownedPluginRoots = [...new Set(manifestRoots
|
|
462
|
+
.filter((manifest) => manifest.kind === 'plugin' && !manifestOwners.has(manifest.path))
|
|
463
|
+
.map((manifest) => manifest.root))];
|
|
464
|
+
for (const pluginRoot of unownedPluginRoots.sort()) {
|
|
465
|
+
const rootManifests = manifestRoots.filter((manifest) => manifest.root === pluginRoot && manifest.kind === 'plugin');
|
|
466
|
+
const name = rootManifests.map((manifest) => manifest.name).find(Boolean) || basename(pluginRoot);
|
|
467
|
+
const baseId = String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'plugin';
|
|
468
|
+
let id = baseId;
|
|
469
|
+
let suffix = 2;
|
|
470
|
+
while (ids.has(id)) id = `${baseId}-${suffix++}`;
|
|
471
|
+
ids.add(id);
|
|
472
|
+
units.push({
|
|
473
|
+
id,
|
|
474
|
+
source: pluginRoot,
|
|
475
|
+
packagePath: null,
|
|
476
|
+
version: rootManifests.map((manifest) => manifest.version).find(Boolean) ?? null,
|
|
477
|
+
publicRepoCandidates: [...uniqueGitRepos],
|
|
478
|
+
distributionCandidates: [...new Set(rootManifests.map((manifest) => `${manifest.host}-plugin`))].sort(),
|
|
479
|
+
tagTemplateCandidates: [],
|
|
480
|
+
branchCandidates: [...new Set([...legacyDefaultBranches, facts.git.branch].filter(Boolean))],
|
|
481
|
+
branchStrategyCandidates: uniqueGitRepos.length > 0
|
|
482
|
+
? ['advance-existing-branch', 'create-release-branch', 'initialize-default-branch']
|
|
483
|
+
: [],
|
|
484
|
+
previousPublicBaselineStatus: uniqueGitRepos.length > 0
|
|
485
|
+
? (facts.git.tags.length > 0
|
|
486
|
+
? 'BOUND_REQUIRES_ONLINE_OBSERVATION'
|
|
487
|
+
: 'FIRST_RELEASE_OR_BOUND_REQUIRES_HUMAN_DECISION')
|
|
488
|
+
: 'CHANNEL_MISSING',
|
|
489
|
+
publicFileCandidates: [...knownFiles]
|
|
490
|
+
.filter((path) => pluginRoot === '.' || path.startsWith(`${pluginRoot}/`))
|
|
491
|
+
.filter((path) => /(?:README|LICENSE|CHANGELOG|plugin\.json|marketplace\.json)/i.test(path))
|
|
492
|
+
.sort(),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
units.sort((a, b) => a.id.localeCompare(b.id));
|
|
496
|
+
gates.sort((a, b) => a.id.localeCompare(b.id));
|
|
497
|
+
return { units, gates };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function buildDecisionsRequired(candidates, localOnly) {
|
|
501
|
+
const decisions = [];
|
|
502
|
+
if (localOnly) {
|
|
503
|
+
decisions.push({
|
|
504
|
+
id: 'remote-channel',
|
|
505
|
+
description: '未发现 GitHub/npm 远端渠道;决定建立真实渠道,或保持 local-only 并暂停生产发布配置。',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
for (const unit of candidates.units) {
|
|
509
|
+
decisions.push({
|
|
510
|
+
id: `unit:${unit.id}:public-repo`,
|
|
511
|
+
description: unit.publicRepoCandidates.length === 1
|
|
512
|
+
? `确认公开仓候选 ${unit.publicRepoCandidates[0]},不得因唯一候选而跳过人工确认。`
|
|
513
|
+
: `从 ${JSON.stringify(unit.publicRepoCandidates)} 中选择公开仓;空列表表示必须先建立渠道。`,
|
|
514
|
+
});
|
|
515
|
+
decisions.push({
|
|
516
|
+
id: `unit:${unit.id}:tag-and-branch`,
|
|
517
|
+
description: `确认 tag 模板、目标分支和 branchStrategy;候选 tag=${JSON.stringify(unit.tagTemplateCandidates)},branch=${JSON.stringify(unit.branchCandidates)}。`,
|
|
518
|
+
});
|
|
519
|
+
decisions.push({
|
|
520
|
+
id: `unit:${unit.id}:previous-public-baseline`,
|
|
521
|
+
description: `当前状态 ${unit.previousPublicBaselineStatus};已有公开版本必须在线绑定精确 repo/ref/commit,只有确认不存在前序版本才使用 mode=none。`,
|
|
522
|
+
});
|
|
523
|
+
decisions.push({
|
|
524
|
+
id: `unit:${unit.id}:distributions-and-files`,
|
|
525
|
+
description: `逐项确认渠道 ${JSON.stringify(unit.distributionCandidates)}、公开文件边界和 requiredPublicFiles;候选不是授权。`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
decisions.push({
|
|
529
|
+
id: 'verification-gates',
|
|
530
|
+
description: '逐项选择要注册的 gate;发现脚本不等于授权,未选择时必须显式使用 selectedGateIds: []。',
|
|
531
|
+
});
|
|
532
|
+
return decisions;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function validateAnswers(answers, gateCandidates) {
|
|
536
|
+
if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
|
|
537
|
+
throw setupError(CONFIG_INVALID, 'setup answers must be a JSON object');
|
|
538
|
+
}
|
|
539
|
+
if (!answers.projectConfig || typeof answers.projectConfig !== 'object') {
|
|
540
|
+
throw setupError(CONFIG_INVALID, 'setup answers must contain projectConfig');
|
|
541
|
+
}
|
|
542
|
+
if (!Array.isArray(answers.selectedGateIds)) {
|
|
543
|
+
throw setupError(CONFIG_INVALID, 'setup answers must contain selectedGateIds array (use [] to select none)');
|
|
544
|
+
}
|
|
545
|
+
const selected = new Set(answers.selectedGateIds);
|
|
546
|
+
if (selected.size !== answers.selectedGateIds.length) {
|
|
547
|
+
throw setupError(CONFIG_INVALID, 'selectedGateIds must be unique');
|
|
548
|
+
}
|
|
549
|
+
const candidateIds = new Set(gateCandidates.map((gate) => gate.id));
|
|
550
|
+
for (const id of selected) {
|
|
551
|
+
if (!candidateIds.has(id)) throw setupError(CONFIG_INVALID, `selectedGateIds contains unknown candidate "${id}"`);
|
|
552
|
+
}
|
|
553
|
+
const configuredIds = (answers.projectConfig.verificationGates ?? []).map((gate) => gate.id).sort();
|
|
554
|
+
if (JSON.stringify([...selected].sort()) !== JSON.stringify(configuredIds)) {
|
|
555
|
+
throw setupError(
|
|
556
|
+
CONFIG_INVALID,
|
|
557
|
+
'selectedGateIds must exactly match projectConfig.verificationGates[].id',
|
|
558
|
+
{ selectedGateIds: [...selected].sort(), configuredGateIds: configuredIds },
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (!validateProjectConfig(answers.projectConfig)) {
|
|
562
|
+
const errors = validateProjectConfig.errors ?? [];
|
|
563
|
+
throw setupError(
|
|
564
|
+
CONFIG_INVALID,
|
|
565
|
+
`projectConfig in setup answers is invalid: ${errors.map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ')}`,
|
|
566
|
+
{ validationErrors: errors },
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function directoryIdentity(entry, label) {
|
|
572
|
+
if (
|
|
573
|
+
!entry || entry.type !== 'directory' ||
|
|
574
|
+
!Number.isInteger(entry.dev) || !Number.isInteger(entry.ino)
|
|
575
|
+
) {
|
|
576
|
+
throw setupError(CONFIG_INVALID, `${label} must be an identity-bound real directory`);
|
|
577
|
+
}
|
|
578
|
+
return { dev: entry.dev, ino: entry.ino };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function sameDirectoryIdentity(left, right) {
|
|
582
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function openBoundConfigDirectory(root, safeFs) {
|
|
586
|
+
const rootHandle = await safeFs.openRoot(root);
|
|
587
|
+
let releaseHandle;
|
|
588
|
+
try {
|
|
589
|
+
const rootIdentity = directoryIdentity(await rootHandle.readEntry('.'), 'project root');
|
|
590
|
+
let releaseEntry = await rootHandle.readEntry('.release-skill');
|
|
591
|
+
if (releaseEntry === null) {
|
|
592
|
+
await rootHandle.mkdir('.release-skill', 0o700);
|
|
593
|
+
releaseEntry = await rootHandle.readEntry('.release-skill');
|
|
594
|
+
}
|
|
595
|
+
const linkedIdentity = directoryIdentity(releaseEntry, '.release-skill');
|
|
596
|
+
releaseHandle = await rootHandle.openDir('.release-skill');
|
|
597
|
+
const openedIdentity = directoryIdentity(await releaseHandle.readEntry('.'), '.release-skill handle');
|
|
598
|
+
if (!sameDirectoryIdentity(linkedIdentity, openedIdentity)) {
|
|
599
|
+
throw setupError(CONFIG_INVALID, '.release-skill identity changed while setup opened it');
|
|
600
|
+
}
|
|
601
|
+
return { rootHandle, releaseHandle, rootIdentity, releaseIdentity: openedIdentity };
|
|
602
|
+
} catch (error) {
|
|
603
|
+
await releaseHandle?.close().catch(() => {});
|
|
604
|
+
await rootHandle.close().catch(() => {});
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function assertConfigDirectoryStillBound(root, safeFs, expected) {
|
|
610
|
+
const current = await openBoundConfigDirectory(root, safeFs);
|
|
611
|
+
try {
|
|
612
|
+
if (
|
|
613
|
+
!sameDirectoryIdentity(current.rootIdentity, expected.rootIdentity) ||
|
|
614
|
+
!sameDirectoryIdentity(current.releaseIdentity, expected.releaseIdentity)
|
|
615
|
+
) {
|
|
616
|
+
throw setupError(
|
|
617
|
+
CONFIG_INVALID,
|
|
618
|
+
'project root or .release-skill identity changed immediately before config creation',
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} finally {
|
|
622
|
+
await current.releaseHandle.close().catch(() => {});
|
|
623
|
+
await current.rootHandle.close().catch(() => {});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function createConfigOnce(root, config, { beforeRename } = {}) {
|
|
628
|
+
const { loadSafeFs } = await import('../artifacts/safe-fs.mjs');
|
|
629
|
+
const safeFs = await loadSafeFs();
|
|
630
|
+
const releaseDir = join(root, '.release-skill');
|
|
631
|
+
const target = join(releaseDir, 'project.yaml');
|
|
632
|
+
const bound = await openBoundConfigDirectory(root, safeFs);
|
|
633
|
+
let tempToken;
|
|
634
|
+
const bytes = Buffer.from(YAML.stringify(config, { lineWidth: 0 }), 'utf8');
|
|
635
|
+
try {
|
|
636
|
+
const existing = await bound.releaseHandle.readEntry('project.yaml');
|
|
637
|
+
if (existing !== null) {
|
|
638
|
+
throw setupError(CONFIG_EXISTS, 'configuration was created concurrently; setup did not overwrite it', { configPath: target });
|
|
639
|
+
}
|
|
640
|
+
tempToken = await bound.releaseHandle.createTemp('project.yaml', 0o600, bytes);
|
|
641
|
+
const commitAuthority = beforeRename ? await beforeRename() : null;
|
|
642
|
+
await assertConfigDirectoryStillBound(root, safeFs, bound);
|
|
643
|
+
try {
|
|
644
|
+
await bound.releaseHandle.rename(tempToken, 'project.yaml');
|
|
645
|
+
tempToken = null;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (await bound.releaseHandle.readEntry('project.yaml') !== null) {
|
|
648
|
+
throw setupError(CONFIG_EXISTS, 'configuration was created concurrently; setup did not overwrite it', { configPath: target });
|
|
649
|
+
}
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
await bound.releaseHandle.fsync();
|
|
653
|
+
await bound.rootHandle.fsync();
|
|
654
|
+
try {
|
|
655
|
+
await assertConfigDirectoryStillBound(root, safeFs, bound);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
const created = await bound.releaseHandle.readFile('project.yaml').catch(() => null);
|
|
658
|
+
if (created?.bytes?.equals(bytes)) {
|
|
659
|
+
await bound.releaseHandle.unlink('project.yaml').catch(() => {});
|
|
660
|
+
await bound.releaseHandle.fsync().catch(() => {});
|
|
661
|
+
}
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
const canonical = await bound.releaseHandle.readFile('project.yaml');
|
|
665
|
+
if (!canonical?.bytes?.equals(bytes)) {
|
|
666
|
+
throw setupError(CONFIG_INVALID, 'created configuration bytes do not match the confirmed setup answers');
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
path: target,
|
|
670
|
+
configSha256: sha256Hex(bytes),
|
|
671
|
+
commitAuthority,
|
|
672
|
+
};
|
|
673
|
+
} finally {
|
|
674
|
+
if (tempToken) await bound.releaseHandle.abortTemp(tempToken).catch(() => {});
|
|
675
|
+
await bound.releaseHandle.close().catch(() => {});
|
|
676
|
+
await bound.rootHandle.close().catch(() => {});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Run deterministic first-use discovery or create the confirmed config. */
|
|
681
|
+
export async function setupProject({ root, answersPath, write = false, confirmSetup, faultInjector } = {}) {
|
|
682
|
+
if (!root || typeof root !== 'string' || !isAbsolute(root)) {
|
|
683
|
+
throw setupError(CONFIG_INVALID, 'setup root must be an absolute path');
|
|
684
|
+
}
|
|
685
|
+
const rootReal = await realpath(root).catch((error) => {
|
|
686
|
+
throw setupError(CONFIG_INVALID, `cannot resolve setup root: ${error.message}`);
|
|
687
|
+
});
|
|
688
|
+
const configPath = join(rootReal, '.release-skill', 'project.yaml');
|
|
689
|
+
let configExists = false;
|
|
690
|
+
try {
|
|
691
|
+
const stat = await lstat(configPath);
|
|
692
|
+
configExists = true;
|
|
693
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
694
|
+
throw setupError(CONFIG_INVALID, 'existing project.yaml must be a regular file');
|
|
695
|
+
}
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (error.code !== 'ENOENT') throw error;
|
|
698
|
+
}
|
|
699
|
+
if (configExists) {
|
|
700
|
+
if (write) throw setupError(CONFIG_EXISTS, 'configuration already exists; setup never overwrites it', { configPath });
|
|
701
|
+
const [facts, configBytes] = await Promise.all([
|
|
702
|
+
discoverFacts(rootReal),
|
|
703
|
+
readFile(configPath, 'utf8'),
|
|
704
|
+
]);
|
|
705
|
+
const candidates = buildCandidates(facts);
|
|
706
|
+
let configuredUnitIds = [];
|
|
707
|
+
let configuredGateIds = [];
|
|
708
|
+
let parseError = null;
|
|
709
|
+
let validationErrors = [];
|
|
710
|
+
try {
|
|
711
|
+
const existing = YAML.parse(configBytes);
|
|
712
|
+
configuredUnitIds = (existing?.releaseUnits ?? []).map((unit) => unit?.id).filter(Boolean).sort();
|
|
713
|
+
configuredGateIds = (existing?.verificationGates ?? []).map((gate) => gate?.id).filter(Boolean).sort();
|
|
714
|
+
if (!validateProjectConfig(existing)) {
|
|
715
|
+
validationErrors = (validateProjectConfig.errors ?? []).map((error) => ({
|
|
716
|
+
instancePath: error.instancePath,
|
|
717
|
+
schemaPath: error.schemaPath,
|
|
718
|
+
keyword: error.keyword,
|
|
719
|
+
params: error.params,
|
|
720
|
+
message: error.message,
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
} catch (error) {
|
|
724
|
+
parseError = error.message;
|
|
725
|
+
}
|
|
726
|
+
const discoveredUnitIds = candidates.units.map((unit) => unit.id).sort();
|
|
727
|
+
const unconfiguredGateCandidateIds = candidates.gates
|
|
728
|
+
.map((gate) => gate.id)
|
|
729
|
+
.filter((id) => !configuredGateIds.includes(id))
|
|
730
|
+
.sort();
|
|
731
|
+
return {
|
|
732
|
+
setupVersion: 1,
|
|
733
|
+
status: 'ALREADY_CONFIGURED',
|
|
734
|
+
configPath,
|
|
735
|
+
existingConfigSha256: sha256Hex(configBytes),
|
|
736
|
+
facts,
|
|
737
|
+
releaseUnitCandidates: candidates.units,
|
|
738
|
+
gateCandidates: candidates.gates,
|
|
739
|
+
audit: {
|
|
740
|
+
configuredUnitIds,
|
|
741
|
+
discoveredUnitIds,
|
|
742
|
+
configuredGateIds,
|
|
743
|
+
unconfiguredGateCandidateIds,
|
|
744
|
+
...(parseError ? { parseError } : {}),
|
|
745
|
+
...(validationErrors.length > 0 ? { validationErrors } : {}),
|
|
746
|
+
patchSuggestions: [
|
|
747
|
+
...(parseError ? ['已有配置无法解析;先人工修复,再运行 release-assess。'] : []),
|
|
748
|
+
...(validationErrors.length > 0
|
|
749
|
+
? ['已有配置不符合 release-project schema;按 validationErrors 人工增量修复,不重新生成。']
|
|
750
|
+
: []),
|
|
751
|
+
...(canonicalJson(configuredUnitIds) !== canonicalJson(discoveredUnitIds)
|
|
752
|
+
? ['发现的发布单元与已有配置不同;人工比较后仅做增量编辑,不重新生成。']
|
|
753
|
+
: []),
|
|
754
|
+
...(unconfiguredGateCandidateIds.length > 0
|
|
755
|
+
? ['存在未配置的验证候选;逐项审阅副作用后决定是否人工注册。']
|
|
756
|
+
: []),
|
|
757
|
+
],
|
|
758
|
+
},
|
|
759
|
+
next: '运行 release-skill assess 审计已有配置;需要调整时依据建议人工增量编辑。',
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const facts = await discoverFacts(rootReal);
|
|
764
|
+
const candidates = buildCandidates(facts);
|
|
765
|
+
let answers = null;
|
|
766
|
+
if (answersPath) {
|
|
767
|
+
const resolvedAnswers = isAbsolute(answersPath) ? answersPath : resolve(rootReal, answersPath);
|
|
768
|
+
answers = await readJsonBounded(resolvedAnswers, 'setup answers');
|
|
769
|
+
validateAnswers(answers, candidates.gates);
|
|
770
|
+
}
|
|
771
|
+
const selectedGateIds = answers?.selectedGateIds ?? [];
|
|
772
|
+
const digestAuthority = {
|
|
773
|
+
setupVersion: 1,
|
|
774
|
+
facts,
|
|
775
|
+
releaseUnitCandidates: candidates.units,
|
|
776
|
+
gateCandidates: candidates.gates,
|
|
777
|
+
selectedGateIds,
|
|
778
|
+
projectConfig: answers?.projectConfig ?? null,
|
|
779
|
+
};
|
|
780
|
+
const setupDigest = sha256Hex(canonicalJson(digestAuthority));
|
|
781
|
+
const hasDiscoveredRemoteChannel = facts.git.remotes.some((remote) => remote.repo) ||
|
|
782
|
+
facts.packages.some((pkg) => pkg.publishRegistry);
|
|
783
|
+
const status = answers
|
|
784
|
+
? 'READY_TO_WRITE'
|
|
785
|
+
: hasDiscoveredRemoteChannel
|
|
786
|
+
? 'NEEDS_INPUT'
|
|
787
|
+
: 'LOCAL_ONLY_DETECTED';
|
|
788
|
+
const localOnly = status === 'LOCAL_ONLY_DETECTED';
|
|
789
|
+
const report = {
|
|
790
|
+
...digestAuthority,
|
|
791
|
+
status,
|
|
792
|
+
setupDigest,
|
|
793
|
+
productionReadiness: status === 'LOCAL_ONLY_DETECTED'
|
|
794
|
+
? 'LOCAL_ONLY'
|
|
795
|
+
: answers
|
|
796
|
+
? 'CONFIG_DRAFT_READY'
|
|
797
|
+
: 'HUMAN_DECISIONS_REQUIRED',
|
|
798
|
+
decisionsRequired: answers ? [] : buildDecisionsRequired(candidates, localOnly),
|
|
799
|
+
writeContract: {
|
|
800
|
+
default: 'dry-run',
|
|
801
|
+
requires: ['--write', `--confirm-setup ${setupDigest}`, '--answers <json>'],
|
|
802
|
+
target: '.release-skill/project.yaml',
|
|
803
|
+
overwrite: false,
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
if (!write) return report;
|
|
808
|
+
if (!answers) throw setupError(CONFIG_INVALID, 'setup --write requires --answers <json>');
|
|
809
|
+
if (confirmSetup !== setupDigest) {
|
|
810
|
+
throw setupError(
|
|
811
|
+
SETUP_DIGEST_MISMATCH,
|
|
812
|
+
'setup confirmation does not match the current facts and answers; rerun dry-run and review again',
|
|
813
|
+
{ expected: setupDigest, received: confirmSetup ?? null },
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
const lock = await acquireProjectLock({ root: rootReal, command: 'setup', mode: 'exclusive' });
|
|
817
|
+
let committedConfig;
|
|
818
|
+
try {
|
|
819
|
+
committedConfig = await lock.capture(async () => {
|
|
820
|
+
if (faultInjector) await faultInjector('before-config-commit');
|
|
821
|
+
const lockedFacts = await discoverFacts(rootReal);
|
|
822
|
+
const lockedCandidates = buildCandidates(lockedFacts);
|
|
823
|
+
const resolvedAnswers = isAbsolute(answersPath) ? answersPath : resolve(rootReal, answersPath);
|
|
824
|
+
const lockedAnswers = await readJsonBounded(resolvedAnswers, 'setup answers');
|
|
825
|
+
validateAnswers(lockedAnswers, lockedCandidates.gates);
|
|
826
|
+
const lockedAuthority = {
|
|
827
|
+
setupVersion: 1,
|
|
828
|
+
facts: lockedFacts,
|
|
829
|
+
releaseUnitCandidates: lockedCandidates.units,
|
|
830
|
+
gateCandidates: lockedCandidates.gates,
|
|
831
|
+
selectedGateIds: lockedAnswers.selectedGateIds,
|
|
832
|
+
projectConfig: lockedAnswers.projectConfig,
|
|
833
|
+
};
|
|
834
|
+
const lockedDigest = sha256Hex(canonicalJson(lockedAuthority));
|
|
835
|
+
if (lockedDigest !== confirmSetup) {
|
|
836
|
+
throw setupError(
|
|
837
|
+
SETUP_DIGEST_MISMATCH,
|
|
838
|
+
'project facts or setup answers changed immediately before config creation; rerun dry-run and review the new digest',
|
|
839
|
+
{ expected: lockedDigest, received: confirmSetup },
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
return createConfigOnce(rootReal, lockedAnswers.projectConfig, {
|
|
843
|
+
beforeRename: async () => {
|
|
844
|
+
if (faultInjector) await faultInjector('before-config-link');
|
|
845
|
+
const finalFacts = await discoverFacts(rootReal);
|
|
846
|
+
const finalCandidates = buildCandidates(finalFacts);
|
|
847
|
+
const finalAnswers = await readJsonBounded(resolvedAnswers, 'setup answers');
|
|
848
|
+
validateAnswers(finalAnswers, finalCandidates.gates);
|
|
849
|
+
const finalAuthority = {
|
|
850
|
+
setupVersion: 1,
|
|
851
|
+
facts: finalFacts,
|
|
852
|
+
releaseUnitCandidates: finalCandidates.units,
|
|
853
|
+
gateCandidates: finalCandidates.gates,
|
|
854
|
+
selectedGateIds: finalAnswers.selectedGateIds,
|
|
855
|
+
projectConfig: finalAnswers.projectConfig,
|
|
856
|
+
};
|
|
857
|
+
const finalDigest = sha256Hex(canonicalJson(finalAuthority));
|
|
858
|
+
if (finalDigest !== confirmSetup) {
|
|
859
|
+
throw setupError(
|
|
860
|
+
SETUP_DIGEST_MISMATCH,
|
|
861
|
+
'project facts or setup answers changed in the final create-once window; rerun dry-run and review the new digest',
|
|
862
|
+
{ expected: finalDigest, received: confirmSetup },
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
setupDigest: finalDigest,
|
|
867
|
+
factsDigest: sha256Hex(canonicalJson(finalFacts)),
|
|
868
|
+
answersDigest: sha256Hex(canonicalJson(finalAnswers)),
|
|
869
|
+
};
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
} finally {
|
|
874
|
+
await lock.release();
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
...report,
|
|
878
|
+
status: 'CONFIG_CREATED',
|
|
879
|
+
configPath: committedConfig.path,
|
|
880
|
+
configSha256: committedConfig.configSha256,
|
|
881
|
+
committedSetupDigest: committedConfig.commitAuthority.setupDigest,
|
|
882
|
+
committedFactsDigest: committedConfig.commitAuthority.factsDigest,
|
|
883
|
+
committedAnswersDigest: committedConfig.commitAuthority.answersDigest,
|
|
884
|
+
next: '运行 release-skill assess;再根据 gate 副作用决定 prepare/verify 的显式授权。',
|
|
885
|
+
};
|
|
886
|
+
}
|