release-skill 0.1.10 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +17 -32
- package/README.zh-CN.md +17 -25
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2721 -1843
- package/package.json +8 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +462 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -736
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +5 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +14 -22
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-public-files.mjs
|
|
4
|
+
*
|
|
5
|
+
* Generate the `publicFiles` and `requiredPublicFiles` arrays of
|
|
6
|
+
* `.release-skill/project.yaml` from the platform registry and the actual
|
|
7
|
+
* package directory contents. This replaces the hand-maintained ~600-line
|
|
8
|
+
* file lists with a registry-driven generator.
|
|
9
|
+
*
|
|
10
|
+
* The generator scans `packages/release-skill/` for all regular files,
|
|
11
|
+
* excludes the `adapters/` subtree (generated by build-adapters), then adds:
|
|
12
|
+
* 1. Platform manifest files derived from the registry (manifestPaths +
|
|
13
|
+
* buildAdapter fields).
|
|
14
|
+
* 2. All files under `adapters/{platform.id}/` for each platform.
|
|
15
|
+
*
|
|
16
|
+
* Usage (run from the package directory):
|
|
17
|
+
* node scripts/sync-public-files.mjs # rewrite drifted arrays
|
|
18
|
+
* node scripts/sync-public-files.mjs --check # read-only; exit 1 on drift
|
|
19
|
+
*
|
|
20
|
+
* Zero runtime dependencies; Node.js standard library only.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFile, writeFile, readdir, stat, realpath } from 'node:fs/promises';
|
|
24
|
+
import { join, relative } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Platform registry import
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import { PLATFORMS } from '../src/platforms/registry.mjs';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const SCRIPT_DIR = new URL('.', import.meta.url).pathname;
|
|
38
|
+
const PKG_ROOT = process.env.RELEASE_SKILL_PKG_ROOT ?? join(SCRIPT_DIR, '..');
|
|
39
|
+
const PROJECT_YAML = join(PKG_ROOT, '..', '..', '.release-skill', 'project.yaml');
|
|
40
|
+
const SOURCE_PREFIX = 'packages/release-skill/';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// File scanning
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Recursively collect all regular file paths (relative to `baseDir`) under
|
|
48
|
+
* `baseDir`, sorted lexicographically.
|
|
49
|
+
*/
|
|
50
|
+
async function scanFiles(baseDir, relPrefix = '') {
|
|
51
|
+
const results = [];
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await readdir(baseDir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
60
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
results.push(...await scanFiles(join(baseDir, entry.name), relPath));
|
|
63
|
+
} else if (entry.isFile()) {
|
|
64
|
+
results.push(relPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Platform manifest path derivation
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Derive all manifest file paths for a platform from the registry.
|
|
76
|
+
* Returns paths relative to the package root.
|
|
77
|
+
*/
|
|
78
|
+
function deriveManifestPaths(platform) {
|
|
79
|
+
const paths = [];
|
|
80
|
+
const mp = platform.manifestPaths;
|
|
81
|
+
|
|
82
|
+
// plugin manifest
|
|
83
|
+
if (mp.plugin && typeof mp.plugin === 'string') {
|
|
84
|
+
paths.push(mp.plugin);
|
|
85
|
+
}
|
|
86
|
+
// kimi has pluginCandidates (array) instead of a single plugin path
|
|
87
|
+
if (Array.isArray(mp.pluginCandidates)) {
|
|
88
|
+
for (const candidate of mp.pluginCandidates) {
|
|
89
|
+
paths.push(candidate);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// marketplace manifest (codex root marketplace)
|
|
93
|
+
if (mp.marketplace && typeof mp.marketplace === 'string') {
|
|
94
|
+
paths.push(mp.marketplace);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return paths;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Derive all adapter-relative file paths for a platform from the registry.
|
|
102
|
+
* These are the static structure inside adapters/{name}/ that is NOT generated
|
|
103
|
+
* by build-adapters (i.e., plugin manifests). The rest of the adapter
|
|
104
|
+
* directory is scanned from disk.
|
|
105
|
+
*/
|
|
106
|
+
function deriveAdapterManifestPaths(platform) {
|
|
107
|
+
const paths = [];
|
|
108
|
+
const ba = platform.buildAdapter;
|
|
109
|
+
|
|
110
|
+
// Plugin manifest directory
|
|
111
|
+
if (ba.pluginDirName) {
|
|
112
|
+
paths.push(`${ba.pluginDirName}/${ba.templateFileName}`);
|
|
113
|
+
if (ba.marketplaceFileName) {
|
|
114
|
+
paths.push(`${ba.pluginDirName}/${ba.marketplaceFileName}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return paths;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// YAML manipulation
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find the line range of a YAML top-level array property.
|
|
127
|
+
* Returns { start, end } (0-indexed, inclusive) or null if not found.
|
|
128
|
+
* `start` is the line of the property key; `end` is the last line of the array.
|
|
129
|
+
*/
|
|
130
|
+
function findArrayRange(lines, key, parentIndent = 4) {
|
|
131
|
+
const keyLine = `${' '.repeat(parentIndent)}${key}:`;
|
|
132
|
+
let start = -1;
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
if (lines[i] === keyLine || lines[i] === `${keyLine} []`) {
|
|
135
|
+
start = i;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (start < 0) return null;
|
|
140
|
+
|
|
141
|
+
// If the key line ends with `[]`, the array is empty and on the same line.
|
|
142
|
+
if (lines[start].endsWith('[]')) {
|
|
143
|
+
return { start, end: start };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Find the end of the array: the last line that is indented more than the
|
|
147
|
+
// key line, or is a `- ` item at the array indentation level.
|
|
148
|
+
const arrayIndent = parentIndent + 2; // typical: key at 4, items at 6
|
|
149
|
+
let end = start;
|
|
150
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
151
|
+
const line = lines[i];
|
|
152
|
+
// Empty lines within the array
|
|
153
|
+
if (line.trim() === '') {
|
|
154
|
+
// Check if next non-empty line is still part of the array
|
|
155
|
+
let nextNonEmpty = i + 1;
|
|
156
|
+
while (nextNonEmpty < lines.length && lines[nextNonEmpty].trim() === '') {
|
|
157
|
+
nextNonEmpty++;
|
|
158
|
+
}
|
|
159
|
+
if (nextNonEmpty < lines.length && isIndentedAtLeast(lines[nextNonEmpty], arrayIndent)) {
|
|
160
|
+
end = i;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
if (isIndentedAtLeast(line, arrayIndent)) {
|
|
166
|
+
end = i;
|
|
167
|
+
} else {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { start, end };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isIndentedAtLeast(line, indent) {
|
|
175
|
+
if (line.trim() === '') return false;
|
|
176
|
+
let count = 0;
|
|
177
|
+
for (const ch of line) {
|
|
178
|
+
if (ch === ' ') count++;
|
|
179
|
+
else break;
|
|
180
|
+
}
|
|
181
|
+
return count >= indent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// YAML generation
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
function buildPublicFilesYaml(entries, indent = 6) {
|
|
189
|
+
const prefix = ' '.repeat(indent);
|
|
190
|
+
const lines = [];
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
lines.push(`${prefix}- from: ${SOURCE_PREFIX}${entry}`);
|
|
193
|
+
lines.push(`${prefix} to: ${entry}`);
|
|
194
|
+
lines.push(`${prefix} mode: preserve`);
|
|
195
|
+
}
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildRequiredPublicFilesYaml(entries, indent = 6) {
|
|
200
|
+
const prefix = ' '.repeat(indent);
|
|
201
|
+
return entries.map((e) => `${prefix}- ${e}`).join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Entry collection
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
async function collectEntries() {
|
|
209
|
+
// 1. Scan all files in the package directory
|
|
210
|
+
const allFiles = await scanFiles(PKG_ROOT);
|
|
211
|
+
const allSet = new Set(allFiles);
|
|
212
|
+
|
|
213
|
+
// 2. Build the adapter exclusion set (all files under adapters/)
|
|
214
|
+
const adapterFiles = new Set();
|
|
215
|
+
for (const f of allFiles) {
|
|
216
|
+
if (f.startsWith('adapters/')) {
|
|
217
|
+
adapterFiles.add(f);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 3. Base files = all files except adapters/ and non-public directories/files.
|
|
222
|
+
// Excluded:
|
|
223
|
+
// - test/ (test files, not shipped)
|
|
224
|
+
// - test-fixtures/ (test fixtures, not shipped)
|
|
225
|
+
// - release-notes/ (consumed by docs-refresh, not a public artifact)
|
|
226
|
+
// - native/*/build/ (native build artifacts, only prebuilds are shipped)
|
|
227
|
+
// - scripts/* (mostly dev-only; explicit whitelist below)
|
|
228
|
+
const baseFiles = allFiles.filter((f) => {
|
|
229
|
+
if (f.startsWith('adapters/')) return false;
|
|
230
|
+
if (f.startsWith('test/')) return false;
|
|
231
|
+
if (f.startsWith('test-fixtures/')) return false;
|
|
232
|
+
if (f.startsWith('release-notes/')) return false;
|
|
233
|
+
if (f.startsWith('native/') && f.includes('/build/')) return false;
|
|
234
|
+
if (f.startsWith('scripts/')) return false;
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// 4. Public scripts: explicit whitelist (most scripts are dev-only).
|
|
239
|
+
const PUBLIC_SCRIPTS = Object.freeze([
|
|
240
|
+
'scripts/build-bundle.mjs',
|
|
241
|
+
'scripts/sync-public-files.mjs',
|
|
242
|
+
]);
|
|
243
|
+
for (const script of PUBLIC_SCRIPTS) {
|
|
244
|
+
if (allSet.has(script)) {
|
|
245
|
+
baseFiles.push(script);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 4. Collect platform-specific files
|
|
250
|
+
const platformManifestFiles = [];
|
|
251
|
+
const platformAdapterFiles = [];
|
|
252
|
+
|
|
253
|
+
for (const platform of PLATFORMS) {
|
|
254
|
+
// Root-level manifest files
|
|
255
|
+
for (const mp of deriveManifestPaths(platform)) {
|
|
256
|
+
if (allSet.has(mp)) {
|
|
257
|
+
platformManifestFiles.push(mp);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Adapter directory files
|
|
262
|
+
const adapterDir = `adapters/${platform.id}`;
|
|
263
|
+
const adapterDirPrefix = `${adapterDir}/`;
|
|
264
|
+
for (const f of allFiles) {
|
|
265
|
+
if (f.startsWith(adapterDirPrefix)) {
|
|
266
|
+
platformAdapterFiles.push(f);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 5. Combine, deduplicate, sort
|
|
272
|
+
const combined = [...baseFiles, ...platformManifestFiles, ...platformAdapterFiles];
|
|
273
|
+
const unique = [...new Set(combined)];
|
|
274
|
+
unique.sort();
|
|
275
|
+
|
|
276
|
+
return unique;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Check / write
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async function readProjectYaml() {
|
|
284
|
+
return await readFile(PROJECT_YAML, 'utf8');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function extractExistingEntries(yamlText, key) {
|
|
288
|
+
const lines = yamlText.split('\n');
|
|
289
|
+
const range = findArrayRange(lines, key);
|
|
290
|
+
if (!range) return [];
|
|
291
|
+
|
|
292
|
+
if (range.start === range.end && lines[range.start].endsWith('[]')) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const entries = [];
|
|
297
|
+
for (let i = range.start + 1; i <= range.end; i++) {
|
|
298
|
+
const line = lines[i];
|
|
299
|
+
// Match `- from: ...` pattern (publicFiles)
|
|
300
|
+
const fromMatch = line.match(/^ +- from: (.+)$/);
|
|
301
|
+
if (fromMatch) {
|
|
302
|
+
// The `to` is on the next line
|
|
303
|
+
for (let j = i + 1; j <= Math.min(i + 2, range.end); j++) {
|
|
304
|
+
const toMatch = lines[j].match(/^ +to: (.+)$/);
|
|
305
|
+
if (toMatch) {
|
|
306
|
+
entries.push(toMatch[1]);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// Match `- <value>` pattern (requiredPublicFiles)
|
|
313
|
+
const simpleMatch = line.match(/^ +- (.+)$/);
|
|
314
|
+
if (simpleMatch) {
|
|
315
|
+
entries.push(simpleMatch[1]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return entries;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function replaceArrayInYaml(yamlText, key, newContent) {
|
|
322
|
+
const lines = yamlText.split('\n');
|
|
323
|
+
const range = findArrayRange(lines, key);
|
|
324
|
+
if (!range) {
|
|
325
|
+
throw new Error(`sync-public-files: cannot find "${key}" in project.yaml`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Replace the array content (everything after the key line through the end)
|
|
329
|
+
const before = lines.slice(0, range.start + 1);
|
|
330
|
+
const after = lines.slice(range.end + 1);
|
|
331
|
+
|
|
332
|
+
// If newContent is empty, use `[]` notation
|
|
333
|
+
if (newContent.trim() === '') {
|
|
334
|
+
return [...before.map((l) => l), `${' '.repeat(4)}${key}: []`, ...after].join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return [...before, newContent, ...after].join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Public API
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Sync the publicFiles and requiredPublicFiles arrays in project.yaml.
|
|
346
|
+
*
|
|
347
|
+
* @param {object} options
|
|
348
|
+
* @param {string} options.packageDir - Package directory.
|
|
349
|
+
* @param {boolean} [options.check] - Read-only drift detection.
|
|
350
|
+
* @returns {Promise<{mode: string, clean?: boolean, diff?: object}>}
|
|
351
|
+
*/
|
|
352
|
+
export async function syncPublicFiles({ packageDir, check = false } = {}) {
|
|
353
|
+
if (typeof packageDir !== 'string' || packageDir.length === 0) {
|
|
354
|
+
const err = new Error('sync-public-files: packageDir is required');
|
|
355
|
+
err.code = 'SYNC_PUBLIC_FILES_INVALID_ARGUMENT';
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const entries = await collectEntries();
|
|
360
|
+
const yamlText = await readProjectYaml();
|
|
361
|
+
|
|
362
|
+
const existingPublic = extractExistingEntries(yamlText, 'publicFiles');
|
|
363
|
+
const existingRequired = extractExistingEntries(yamlText, 'requiredPublicFiles');
|
|
364
|
+
|
|
365
|
+
// Compare sets (order-independent)
|
|
366
|
+
const publicSet = new Set(entries);
|
|
367
|
+
const existingPublicSet = new Set(existingPublic);
|
|
368
|
+
const requiredSet = new Set(entries);
|
|
369
|
+
const existingRequiredSet = new Set(existingRequired);
|
|
370
|
+
|
|
371
|
+
const publicMissing = entries.filter((e) => !existingPublicSet.has(e));
|
|
372
|
+
const publicExtra = existingPublic.filter((e) => !publicSet.has(e));
|
|
373
|
+
const requiredMissing = entries.filter((e) => !existingRequiredSet.has(e));
|
|
374
|
+
const requiredExtra = existingRequired.filter((e) => !requiredSet.has(e));
|
|
375
|
+
|
|
376
|
+
const isClean = publicMissing.length === 0 && publicExtra.length === 0
|
|
377
|
+
&& requiredMissing.length === 0 && requiredExtra.length === 0;
|
|
378
|
+
|
|
379
|
+
if (check) {
|
|
380
|
+
return {
|
|
381
|
+
mode: 'check',
|
|
382
|
+
entryCount: entries.length,
|
|
383
|
+
clean: isClean,
|
|
384
|
+
diff: isClean ? undefined : {
|
|
385
|
+
publicFiles: { missing: publicMissing, extra: publicExtra },
|
|
386
|
+
requiredPublicFiles: { missing: requiredMissing, extra: requiredExtra },
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (isClean) {
|
|
392
|
+
return { mode: 'write', entryCount: entries.length, changed: false };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Generate new YAML content
|
|
396
|
+
const publicYaml = buildPublicFilesYaml(entries);
|
|
397
|
+
const requiredYaml = buildRequiredPublicFilesYaml(entries);
|
|
398
|
+
|
|
399
|
+
let newYaml = replaceArrayInYaml(yamlText, 'publicFiles', publicYaml);
|
|
400
|
+
newYaml = replaceArrayInYaml(newYaml, 'requiredPublicFiles', requiredYaml);
|
|
401
|
+
|
|
402
|
+
await writeFile(PROJECT_YAML, newYaml, 'utf8');
|
|
403
|
+
|
|
404
|
+
// Verify write
|
|
405
|
+
const reread = await readFile(PROJECT_YAML, 'utf8');
|
|
406
|
+
if (reread !== newYaml) {
|
|
407
|
+
const err = new Error('sync-public-files: post-write verification failed');
|
|
408
|
+
err.code = 'SYNC_PUBLIC_FILES_WRITE_VERIFY_FAILED';
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { mode: 'write', entryCount: entries.length, changed: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// CLI
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
const invokedPath = process.argv[1]
|
|
420
|
+
? await realpath(process.argv[1]).catch(() => process.argv[1])
|
|
421
|
+
: '';
|
|
422
|
+
const isMain = invokedPath.length > 0 && fileURLToPath(import.meta.url) === invokedPath;
|
|
423
|
+
|
|
424
|
+
if (isMain) {
|
|
425
|
+
const checkMode = process.argv.includes('--check');
|
|
426
|
+
try {
|
|
427
|
+
const result = await syncPublicFiles({ packageDir: PKG_ROOT, check: checkMode });
|
|
428
|
+
if (checkMode) {
|
|
429
|
+
if (result.clean) {
|
|
430
|
+
console.log(`[sync-public-files] OK: all ${result.entryCount} entries in sync.`);
|
|
431
|
+
} else {
|
|
432
|
+
console.error(`[sync-public-files] drift detected:`);
|
|
433
|
+
const d = result.diff;
|
|
434
|
+
if (d.publicFiles.missing.length > 0) {
|
|
435
|
+
console.error(` publicFiles missing (${d.publicFiles.missing.length}):`);
|
|
436
|
+
for (const e of d.publicFiles.missing) console.error(` + ${e}`);
|
|
437
|
+
}
|
|
438
|
+
if (d.publicFiles.extra.length > 0) {
|
|
439
|
+
console.error(` publicFiles extra (${d.publicFiles.extra.length}):`);
|
|
440
|
+
for (const e of d.publicFiles.extra) console.error(` - ${e}`);
|
|
441
|
+
}
|
|
442
|
+
if (d.requiredPublicFiles.missing.length > 0) {
|
|
443
|
+
console.error(` requiredPublicFiles missing (${d.requiredPublicFiles.missing.length}):`);
|
|
444
|
+
for (const e of d.requiredPublicFiles.missing) console.error(` + ${e}`);
|
|
445
|
+
}
|
|
446
|
+
if (d.requiredPublicFiles.extra.length > 0) {
|
|
447
|
+
console.error(` requiredPublicFiles extra (${d.requiredPublicFiles.extra.length}):`);
|
|
448
|
+
for (const e of d.requiredPublicFiles.extra) console.error(` - ${e}`);
|
|
449
|
+
}
|
|
450
|
+
console.error('Run "node scripts/sync-public-files.mjs" from the package directory to sync.');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
} else if (!result.changed) {
|
|
454
|
+
console.log(`[sync-public-files] already in sync (${result.entryCount} entries).`);
|
|
455
|
+
} else {
|
|
456
|
+
console.log(`[sync-public-files] synced ${result.entryCount} entries.`);
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
console.error(error.message);
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* @module adapters/contract
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { writeFile, rename, rm } from 'node:fs/promises';
|
|
17
|
+
|
|
16
18
|
import { ReleaseError, AUTH_MISSING } from '../core/errors.mjs';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -146,6 +148,64 @@ export function matchObservation(expected, observation) {
|
|
|
146
148
|
return { matches: mismatches.length === 0, mismatches };
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/** Safe identifier pattern: lowercase alphanumeric, hyphens, dots, underscores. */
|
|
152
|
+
export const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve and validate the frozen timeoutMs from the expanded adapter action.
|
|
156
|
+
*
|
|
157
|
+
* The publish/reconcile/verify call path expands plan actions as
|
|
158
|
+
* `{ actionType, ...action.parameters }`, so `parameters.timeoutMs` in the
|
|
159
|
+
* plan becomes `action.timeoutMs` at the adapter level. This function reads
|
|
160
|
+
* from the top-level action, not from a nested `parameters` sub-object.
|
|
161
|
+
*
|
|
162
|
+
* Rules:
|
|
163
|
+
* - Missing field (undefined): returns 300000 default (legacy compatibility).
|
|
164
|
+
* - Present but null/invalid (null, string, NaN, Infinity, non-integer,
|
|
165
|
+
* out of range): fail-closed, throws.
|
|
166
|
+
* - Valid integer in [30000, 900000]: returns the value as-is.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} action - The expanded adapter action (top-level).
|
|
169
|
+
* @returns {number} Validated timeout in milliseconds.
|
|
170
|
+
* @throws {Error} If the value is present but invalid.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveTimeoutMs(action) {
|
|
173
|
+
const raw = action?.timeoutMs;
|
|
174
|
+
if (raw === undefined) {
|
|
175
|
+
return 300000;
|
|
176
|
+
}
|
|
177
|
+
if (raw === null || typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`action.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (raw < 30000 || raw > 900000) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`action.timeoutMs must be between 30000 and 900000, got: ${raw}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Atomically write a JSON evidence/requirement file (mode 0o600, exclusive
|
|
192
|
+
* create, rename into place). Crash-safe: a partial write never replaces an
|
|
193
|
+
* existing file.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} filePath
|
|
196
|
+
* @param {object} value
|
|
197
|
+
*/
|
|
198
|
+
export async function writeEvidenceAtomic(filePath, value) {
|
|
199
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
200
|
+
try {
|
|
201
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
202
|
+
await rename(tempPath, filePath);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
149
209
|
/**
|
|
150
210
|
* Adapter interface type documentation (not enforced at runtime, but all
|
|
151
211
|
* adapters must follow this shape):
|