release-skill 0.1.9 → 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 +53 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +18 -33
- package/README.zh-CN.md +17 -23
- 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 +2740 -1844
- 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 +2740 -1844
- 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 +2740 -1844
- 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 +2740 -1844
- 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 -730
- 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 +16 -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
- package/src/snapshot/frozen.mjs +29 -5
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform registry — the single source of truth for consumer-platform
|
|
3
|
+
* knowledge (claude / codex / kimi).
|
|
4
|
+
*
|
|
5
|
+
* T2.2 converges platform facts that were scattered across plugin-marketplace.mjs,
|
|
6
|
+
* build-adapters.mjs, prepare.mjs, plan.mjs and project.yaml into this module.
|
|
7
|
+
* Data that is purely declarative (paths, argv templates, env construction,
|
|
8
|
+
* manifest layout, host-artifact exemptions, schema required fields) lives in
|
|
9
|
+
* each platform's descriptor; protocol LOGIC differences (claude `list` returns
|
|
10
|
+
* an array vs codex returns `{installed:[]}`) live in per-platform strategy
|
|
11
|
+
* functions referenced from the descriptor. Data in tables, logic in functions
|
|
12
|
+
* — neither scattered.
|
|
13
|
+
*
|
|
14
|
+
* Adding a platform is meant to converge on ~3 hand edits: a descriptor +
|
|
15
|
+
* strategy here, a schema enum bump (the schema-contract test turns red until
|
|
16
|
+
* it is done), and a regeneration of the manifest/adapter producers.
|
|
17
|
+
*
|
|
18
|
+
* `assertRegistry()` runs at module load: a malformed registry throws at import
|
|
19
|
+
* time, never at runtime.
|
|
20
|
+
*
|
|
21
|
+
* NOTE (T2.2 step 2): the claude/codex strategy functions (pure list-parse /
|
|
22
|
+
* install-path / identity extraction / list cross-validation) live in this
|
|
23
|
+
* file; kimi's side-effecting manual-requirement and manifest-reading
|
|
24
|
+
* strategies live in ./kimi.mjs (§4.2's per-platform-module option — the only
|
|
25
|
+
* non-trivial strategies) and are referenced from the kimi descriptor below.
|
|
26
|
+
* plugin-marketplace.mjs consumes every platform difference through this
|
|
27
|
+
* registry; see the t2-2 deviation record for the step-2 wiring details.
|
|
28
|
+
*
|
|
29
|
+
* @module platforms/registry
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
executeKimiManualRequirement,
|
|
36
|
+
readKimiManifest,
|
|
37
|
+
KIMI_MANIFEST_CANDIDATES,
|
|
38
|
+
} from './kimi.mjs';
|
|
39
|
+
|
|
40
|
+
// --- claude strategy -------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function claudeParseListOutput(listOutput, pluginId) {
|
|
43
|
+
// Claude `plugin list --json` returns a top-level array; installPath is read
|
|
44
|
+
// from the matching entry.
|
|
45
|
+
if (!Array.isArray(listOutput)) {
|
|
46
|
+
return { ok: false, error: 'Claude plugin list did not return an array' };
|
|
47
|
+
}
|
|
48
|
+
const found = listOutput.find((p) => p.id === pluginId);
|
|
49
|
+
if (!found) {
|
|
50
|
+
return { ok: false, error: `plugin "${pluginId}" not found in Claude plugin list` };
|
|
51
|
+
}
|
|
52
|
+
if (!found.installPath) {
|
|
53
|
+
return { ok: false, error: `plugin "${pluginId}" found but missing installPath` };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, found, installPath: found.installPath };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function claudeExtractInstallPath({ listParsed }) {
|
|
59
|
+
// Claude reports no installedPath at install time; the install path comes from
|
|
60
|
+
// the parsed list entry. Callers only invoke this after parseListOutput
|
|
61
|
+
// returned ok, which guarantees a non-empty installPath (fail-closed there).
|
|
62
|
+
return { ok: true, installPath: listParsed?.installPath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function claudeExtractListIdentity(found) {
|
|
66
|
+
// Claude list may not have name; extract plugin/marketplace from id.
|
|
67
|
+
// Key order (plugin, marketplace, version) is observation-serialization
|
|
68
|
+
// relevant and mirrors the legacy observe backfill byte-for-byte.
|
|
69
|
+
const idParts = found.id.split('@');
|
|
70
|
+
const identity = { plugin: idParts[0], marketplace: idParts.slice(1).join('@') };
|
|
71
|
+
if (found.version) identity.version = found.version;
|
|
72
|
+
return identity;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- codex strategy --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function codexParseListOutput(listOutput, pluginId) {
|
|
78
|
+
// Codex `plugin list --json` returns `{installed: [...]}`; the list does NOT
|
|
79
|
+
// carry installedPath (that comes from the install evidence).
|
|
80
|
+
const installed = listOutput?.installed;
|
|
81
|
+
if (!Array.isArray(installed)) {
|
|
82
|
+
return { ok: false, error: 'Codex plugin list did not return {installed: [...]}' };
|
|
83
|
+
}
|
|
84
|
+
const found = installed.find((p) => p.pluginId === pluginId);
|
|
85
|
+
if (!found) {
|
|
86
|
+
return { ok: false, error: `plugin "${pluginId}" not found in Codex installed list` };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, found };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function codexExtractInstallPath({ execEvidence }) {
|
|
92
|
+
const installPath = execEvidence?.installOutput?.installedPath;
|
|
93
|
+
if (!installPath) {
|
|
94
|
+
return { ok: false, error: 'evidence install JSON missing installedPath' };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, installPath };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function codexExtractListIdentity(found) {
|
|
100
|
+
// Every field is conditional in the codex list output. Key order (plugin,
|
|
101
|
+
// marketplace, version) mirrors the legacy observe backfill byte-for-byte.
|
|
102
|
+
const identity = {};
|
|
103
|
+
if (found.name) identity.plugin = found.name;
|
|
104
|
+
if (found.marketplaceName) identity.marketplace = found.marketplaceName;
|
|
105
|
+
if (found.version) identity.version = found.version;
|
|
106
|
+
return identity;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function codexCrossValidateListEntry(found, action) {
|
|
110
|
+
// Cross-validate: list fields must match evidence/action.
|
|
111
|
+
if (found.name !== action.plugin) {
|
|
112
|
+
return { ok: false, error: `list name "${found.name}" does not match action plugin "${action.plugin}"` };
|
|
113
|
+
}
|
|
114
|
+
if (found.marketplaceName !== action.marketplace) {
|
|
115
|
+
return { ok: false, error: `list marketplaceName "${found.marketplaceName}" does not match action marketplace "${action.marketplace}"` };
|
|
116
|
+
}
|
|
117
|
+
if (found.version !== action.version) {
|
|
118
|
+
return { ok: false, error: `list version "${found.version}" does not match action version "${action.version}"` };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- platform descriptors --------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const CLAUDE = Object.freeze({
|
|
126
|
+
id: 'claude',
|
|
127
|
+
distributionType: 'claude-plugin',
|
|
128
|
+
actionType: 'claude-marketplace-install',
|
|
129
|
+
// Runtime adapter implementing this platform's marketplace-install action.
|
|
130
|
+
// prepare.mjs stamps it onto generated actions; plan.mjs derives its
|
|
131
|
+
// actionType -> adapter map from it (T2.2 step 3).
|
|
132
|
+
adapter: 'plugin-marketplace',
|
|
133
|
+
automatable: true,
|
|
134
|
+
cli: Object.freeze({
|
|
135
|
+
binary: 'claude',
|
|
136
|
+
marketplaceAdd: (repo, ref) => ['plugin', 'marketplace', 'add', `${repo}@${ref}`],
|
|
137
|
+
install: (plugin, marketplace) => ['plugin', 'install', `${plugin}@${marketplace}`],
|
|
138
|
+
list: () => ['plugin', 'list', '--json'],
|
|
139
|
+
}),
|
|
140
|
+
jsonProtocol: Object.freeze({
|
|
141
|
+
listOutput: 'array',
|
|
142
|
+
installPathSource: 'list',
|
|
143
|
+
// claude marketplace add / plugin install emit no validated JSON output.
|
|
144
|
+
marketplaceAddOutput: null,
|
|
145
|
+
pluginInstallOutput: null,
|
|
146
|
+
}),
|
|
147
|
+
isolationEnv: (home) => ({ HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude') }),
|
|
148
|
+
// execute pre-creates the claude config dir under the isolated HOME.
|
|
149
|
+
isolationSubdirs: Object.freeze(['.claude']),
|
|
150
|
+
manifestPaths: Object.freeze({
|
|
151
|
+
plugin: '.claude-plugin/plugin.json',
|
|
152
|
+
marketplace: '.claude-plugin/marketplace.json',
|
|
153
|
+
}),
|
|
154
|
+
marketplaceSourceForm: 'string',
|
|
155
|
+
// Claude carries the authoritative version in the marketplace entry: the
|
|
156
|
+
// preflight binds entry.version to the action version.
|
|
157
|
+
marketplaceEntryCarriesVersion: true,
|
|
158
|
+
knownHostArtifacts: Object.freeze(['.in_use']),
|
|
159
|
+
schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
|
|
160
|
+
skillRendering: Object.freeze({ mode: 'verbatim', preamble: null, placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
|
|
161
|
+
buildAdapter: Object.freeze({
|
|
162
|
+
pluginDirName: '.claude-plugin',
|
|
163
|
+
templateFileName: 'plugin.json',
|
|
164
|
+
marketplaceFileName: 'marketplace.json',
|
|
165
|
+
hasMarketplace: true,
|
|
166
|
+
}),
|
|
167
|
+
strategy: Object.freeze({
|
|
168
|
+
parseListOutput: claudeParseListOutput,
|
|
169
|
+
extractInstallPath: claudeExtractInstallPath,
|
|
170
|
+
extractListIdentity: claudeExtractListIdentity,
|
|
171
|
+
crossValidateListEntry: null,
|
|
172
|
+
buildManualRequirement: null,
|
|
173
|
+
readManifest: null,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const CODEX = Object.freeze({
|
|
178
|
+
id: 'codex',
|
|
179
|
+
distributionType: 'codex-plugin',
|
|
180
|
+
actionType: 'codex-marketplace-install',
|
|
181
|
+
adapter: 'plugin-marketplace',
|
|
182
|
+
automatable: true,
|
|
183
|
+
cli: Object.freeze({
|
|
184
|
+
binary: 'codex',
|
|
185
|
+
marketplaceAdd: (repo, ref) => ['plugin', 'marketplace', 'add', repo, '--ref', ref, '--json'],
|
|
186
|
+
install: (plugin, marketplace) => ['plugin', 'add', `${plugin}@${marketplace}`, '--json'],
|
|
187
|
+
list: () => ['plugin', 'list', '--json'],
|
|
188
|
+
}),
|
|
189
|
+
jsonProtocol: Object.freeze({
|
|
190
|
+
listOutput: 'installed-object',
|
|
191
|
+
installPathSource: 'install-output',
|
|
192
|
+
// codex emits validated JSON for both marketplace add and plugin add.
|
|
193
|
+
marketplaceAddOutput: 'json',
|
|
194
|
+
pluginInstallOutput: 'json',
|
|
195
|
+
}),
|
|
196
|
+
isolationEnv: (home) => ({ HOME: home, CODEX_HOME: home }),
|
|
197
|
+
// execute pre-creates the codex state dir under the isolated HOME.
|
|
198
|
+
isolationSubdirs: Object.freeze(['.codex']),
|
|
199
|
+
manifestPaths: Object.freeze({
|
|
200
|
+
plugin: '.codex-plugin/plugin.json',
|
|
201
|
+
marketplace: '.agents/plugins/marketplace.json',
|
|
202
|
+
}),
|
|
203
|
+
marketplaceSourceForm: 'local-path-object',
|
|
204
|
+
// Codex keeps the authoritative version in .codex-plugin/plugin.json; the
|
|
205
|
+
// marketplace entry version is not bound to the action version.
|
|
206
|
+
marketplaceEntryCarriesVersion: false,
|
|
207
|
+
knownHostArtifacts: Object.freeze(['.git', '.codex-plugin/migrated-command-skills']),
|
|
208
|
+
schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
|
|
209
|
+
skillRendering: Object.freeze({ mode: 'substitute', preamble: 'codex', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
|
|
210
|
+
buildAdapter: Object.freeze({
|
|
211
|
+
pluginDirName: '.codex-plugin',
|
|
212
|
+
templateFileName: 'plugin.json',
|
|
213
|
+
marketplaceFileName: null,
|
|
214
|
+
hasMarketplace: false,
|
|
215
|
+
}),
|
|
216
|
+
strategy: Object.freeze({
|
|
217
|
+
parseListOutput: codexParseListOutput,
|
|
218
|
+
extractInstallPath: codexExtractInstallPath,
|
|
219
|
+
extractListIdentity: codexExtractListIdentity,
|
|
220
|
+
crossValidateListEntry: codexCrossValidateListEntry,
|
|
221
|
+
buildManualRequirement: null,
|
|
222
|
+
readManifest: null,
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const KIMI = Object.freeze({
|
|
227
|
+
id: 'kimi',
|
|
228
|
+
distributionType: 'kimi-plugin',
|
|
229
|
+
actionType: 'kimi-marketplace-install',
|
|
230
|
+
adapter: 'plugin-marketplace',
|
|
231
|
+
automatable: false,
|
|
232
|
+
cli: null,
|
|
233
|
+
jsonProtocol: Object.freeze({
|
|
234
|
+
listOutput: null,
|
|
235
|
+
installPathSource: null,
|
|
236
|
+
marketplaceAddOutput: null,
|
|
237
|
+
pluginInstallOutput: null,
|
|
238
|
+
}),
|
|
239
|
+
isolationEnv: (home) => ({ HOME: home, KIMI_CODE_HOME: home }),
|
|
240
|
+
// kimi never execs a CLI; its stable home is created by the
|
|
241
|
+
// manual-requirement strategy under the attestation authority.
|
|
242
|
+
isolationSubdirs: Object.freeze([]),
|
|
243
|
+
manifestPaths: Object.freeze({
|
|
244
|
+
// kimi.plugin.json takes precedence over .kimi-plugin/plugin.json
|
|
245
|
+
// (strategy.readManifest / readKimiManifest in ./kimi.mjs).
|
|
246
|
+
pluginCandidates: KIMI_MANIFEST_CANDIDATES,
|
|
247
|
+
marketplace: null,
|
|
248
|
+
}),
|
|
249
|
+
marketplaceSourceForm: null,
|
|
250
|
+
// kimi has no marketplace at all, so the entry-version binding does not
|
|
251
|
+
// apply (null = N/A, never consulted: kimi preflight reads its manifest via
|
|
252
|
+
// strategy.readManifest).
|
|
253
|
+
marketplaceEntryCarriesVersion: null,
|
|
254
|
+
knownHostArtifacts: Object.freeze(['.git']),
|
|
255
|
+
schemaRequiredFields: Object.freeze(['plugin', 'entrySkill']),
|
|
256
|
+
skillRendering: Object.freeze({ mode: 'substitute', preamble: 'kimi', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
|
|
257
|
+
buildAdapter: Object.freeze({
|
|
258
|
+
pluginDirName: '.kimi-plugin',
|
|
259
|
+
templateFileName: 'plugin.json',
|
|
260
|
+
marketplaceFileName: null,
|
|
261
|
+
hasMarketplace: false,
|
|
262
|
+
}),
|
|
263
|
+
strategy: Object.freeze({
|
|
264
|
+
parseListOutput: null,
|
|
265
|
+
extractInstallPath: null,
|
|
266
|
+
extractListIdentity: null,
|
|
267
|
+
crossValidateListEntry: null,
|
|
268
|
+
// kimi's side-effecting manual-requirement builder and manifest reader
|
|
269
|
+
// live in ./kimi.mjs (§4.2 per-platform-module option).
|
|
270
|
+
buildManualRequirement: executeKimiManualRequirement,
|
|
271
|
+
readManifest: readKimiManifest,
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/** Ordered platform registry. Order is significant (claude, codex, kimi). */
|
|
276
|
+
export const PLATFORMS = Object.freeze([CLAUDE, CODEX, KIMI]);
|
|
277
|
+
|
|
278
|
+
const VALID_DISTRIBUTION_TYPES = new Set(['claude-plugin', 'codex-plugin', 'kimi-plugin']);
|
|
279
|
+
const VALID_ACTION_TYPES = new Set(['claude-marketplace-install', 'codex-marketplace-install', 'kimi-marketplace-install']);
|
|
280
|
+
const VALID_SOURCE_FORMS = new Set(['string', 'local-path-object', null]);
|
|
281
|
+
const VALID_LIST_OUTPUTS = new Set(['array', 'installed-object', null]);
|
|
282
|
+
const VALID_CLI_OUTPUTS = new Set(['json', null]);
|
|
283
|
+
const VALID_ENTRY_VERSION_BINDING = new Set([true, false, null]);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Look up a platform descriptor by id.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} id
|
|
289
|
+
* @returns {object}
|
|
290
|
+
*/
|
|
291
|
+
export function getPlatform(id) {
|
|
292
|
+
const platform = PLATFORMS.find((p) => p.id === id);
|
|
293
|
+
if (!platform) throw new Error(`unknown platform: ${id}`);
|
|
294
|
+
return platform;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Validate a registry (defaults to the module registry). Throws on the first
|
|
299
|
+
* malformed description so a registry typo fails at import time.
|
|
300
|
+
*
|
|
301
|
+
* @param {object[]} [registry] - Platform descriptors to validate.
|
|
302
|
+
*/
|
|
303
|
+
export function assertRegistry(registry = PLATFORMS) {
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
for (const platform of registry) {
|
|
306
|
+
const label = platform?.id ?? '<missing id>';
|
|
307
|
+
if (typeof platform?.id !== 'string' || platform.id.length === 0) {
|
|
308
|
+
throw new Error('platform registry: every platform needs a string id');
|
|
309
|
+
}
|
|
310
|
+
if (seen.has(platform.id)) {
|
|
311
|
+
throw new Error(`platform registry: ids must be unique, found duplicate "${platform.id}"`);
|
|
312
|
+
}
|
|
313
|
+
seen.add(platform.id);
|
|
314
|
+
|
|
315
|
+
if (!VALID_DISTRIBUTION_TYPES.has(platform.distributionType)) {
|
|
316
|
+
throw new Error(`platform registry: ${label} has illegal distributionType "${platform.distributionType}"`);
|
|
317
|
+
}
|
|
318
|
+
if (!VALID_ACTION_TYPES.has(platform.actionType)) {
|
|
319
|
+
throw new Error(`platform registry: ${label} has illegal actionType "${platform.actionType}"`);
|
|
320
|
+
}
|
|
321
|
+
if (typeof platform.adapter !== 'string' || platform.adapter.length === 0) {
|
|
322
|
+
throw new Error(`platform registry: ${label} needs a non-empty adapter id`);
|
|
323
|
+
}
|
|
324
|
+
if (!VALID_ENTRY_VERSION_BINDING.has(platform.marketplaceEntryCarriesVersion)) {
|
|
325
|
+
throw new Error(`platform registry: ${label} has illegal marketplaceEntryCarriesVersion "${platform.marketplaceEntryCarriesVersion}"`);
|
|
326
|
+
}
|
|
327
|
+
if (typeof platform.automatable !== 'boolean') {
|
|
328
|
+
throw new Error(`platform registry: ${label} automatable must be boolean`);
|
|
329
|
+
}
|
|
330
|
+
if (typeof platform.isolationEnv !== 'function') {
|
|
331
|
+
throw new Error(`platform registry: ${label} isolationEnv must be a function`);
|
|
332
|
+
}
|
|
333
|
+
if (!VALID_SOURCE_FORMS.has(platform.marketplaceSourceForm)) {
|
|
334
|
+
throw new Error(`platform registry: ${label} has illegal marketplaceSourceForm "${platform.marketplaceSourceForm}"`);
|
|
335
|
+
}
|
|
336
|
+
if (!Array.isArray(platform.knownHostArtifacts)) {
|
|
337
|
+
throw new Error(`platform registry: ${label} knownHostArtifacts must be an array`);
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(platform.schemaRequiredFields)) {
|
|
340
|
+
throw new Error(`platform registry: ${label} schemaRequiredFields must be an array`);
|
|
341
|
+
}
|
|
342
|
+
if (!platform.manifestPaths || typeof platform.manifestPaths !== 'object') {
|
|
343
|
+
throw new Error(`platform registry: ${label} manifestPaths must be an object`);
|
|
344
|
+
}
|
|
345
|
+
if (!platform.buildAdapter || typeof platform.buildAdapter !== 'object') {
|
|
346
|
+
throw new Error(`platform registry: ${label} buildAdapter must be an object`);
|
|
347
|
+
}
|
|
348
|
+
if (!VALID_LIST_OUTPUTS.has(platform.jsonProtocol?.listOutput)) {
|
|
349
|
+
throw new Error(`platform registry: ${label} has illegal jsonProtocol.listOutput "${platform.jsonProtocol?.listOutput}"`);
|
|
350
|
+
}
|
|
351
|
+
if (!VALID_CLI_OUTPUTS.has(platform.jsonProtocol?.marketplaceAddOutput)) {
|
|
352
|
+
throw new Error(`platform registry: ${label} has illegal jsonProtocol.marketplaceAddOutput "${platform.jsonProtocol?.marketplaceAddOutput}"`);
|
|
353
|
+
}
|
|
354
|
+
if (!VALID_CLI_OUTPUTS.has(platform.jsonProtocol?.pluginInstallOutput)) {
|
|
355
|
+
throw new Error(`platform registry: ${label} has illegal jsonProtocol.pluginInstallOutput "${platform.jsonProtocol?.pluginInstallOutput}"`);
|
|
356
|
+
}
|
|
357
|
+
if (!Array.isArray(platform.isolationSubdirs)) {
|
|
358
|
+
throw new Error(`platform registry: ${label} isolationSubdirs must be an array`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (platform.automatable) {
|
|
362
|
+
if (!platform.cli || typeof platform.cli.marketplaceAdd !== 'function'
|
|
363
|
+
|| typeof platform.cli.install !== 'function' || typeof platform.cli.list !== 'function') {
|
|
364
|
+
throw new Error(`platform registry: automatable platform ${label} needs cli template functions`);
|
|
365
|
+
}
|
|
366
|
+
if (typeof platform.cli.binary !== 'string' || platform.cli.binary.length === 0) {
|
|
367
|
+
throw new Error(`platform registry: automatable platform ${label} needs a non-empty cli.binary executable name`);
|
|
368
|
+
}
|
|
369
|
+
if (!platform.strategy || typeof platform.strategy.parseListOutput !== 'function') {
|
|
370
|
+
throw new Error(`platform registry: automatable platform ${label} needs strategy.parseListOutput`);
|
|
371
|
+
}
|
|
372
|
+
if (typeof platform.strategy.extractInstallPath !== 'function') {
|
|
373
|
+
throw new Error(`platform registry: automatable platform ${label} needs strategy.extractInstallPath`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
if (platform.cli !== null) {
|
|
377
|
+
throw new Error(`platform registry: non-automatable platform ${label} must have cli === null`);
|
|
378
|
+
}
|
|
379
|
+
// The manual-requirement path IS the execute for a non-automatable
|
|
380
|
+
// platform; without it (and its manifest reader) the checkpoint has no
|
|
381
|
+
// execute behaviour at all.
|
|
382
|
+
if (!platform.strategy || typeof platform.strategy.buildManualRequirement !== 'function') {
|
|
383
|
+
throw new Error(`platform registry: non-automatable platform ${label} needs strategy.buildManualRequirement`);
|
|
384
|
+
}
|
|
385
|
+
if (typeof platform.strategy.readManifest !== 'function') {
|
|
386
|
+
throw new Error(`platform registry: non-automatable platform ${label} needs strategy.readManifest`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Self-validate at import time: a malformed registry never reaches runtime.
|
|
393
|
+
assertRegistry();
|
|
@@ -22,6 +22,7 @@ import { join, dirname, relative, isAbsolute, sep, resolve, posix as pathPosix }
|
|
|
22
22
|
import { createHash } from 'node:crypto';
|
|
23
23
|
|
|
24
24
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
25
|
+
import { PLATFORMS as PLATFORM_REGISTRY } from '../platforms/registry.mjs';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Assert that a resolved real path is contained within the allowed root.
|
|
@@ -190,28 +191,19 @@ export function computeBuildAdaptersDigest(sourceBytes) {
|
|
|
190
191
|
return `sha256:${h.digest('hex')}`;
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
// Platform definitions
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
hasMarketplace: false,
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
name: 'kimi',
|
|
210
|
-
pluginDirName: '.kimi-plugin',
|
|
211
|
-
templateFileName: 'plugin.json',
|
|
212
|
-
hasMarketplace: false,
|
|
213
|
-
},
|
|
214
|
-
];
|
|
194
|
+
// Platform definitions, derived from the platform registry (the single source
|
|
195
|
+
// of platform knowledge). The build shape mirrors the legacy inline table
|
|
196
|
+
// byte-for-byte: marketplaceFileName is present only when the platform
|
|
197
|
+
// co-locates a marketplace manifest in its plugin dir (claude).
|
|
198
|
+
export const PLATFORMS = PLATFORM_REGISTRY.map((platform) => ({
|
|
199
|
+
name: platform.id,
|
|
200
|
+
pluginDirName: platform.buildAdapter.pluginDirName,
|
|
201
|
+
templateFileName: platform.buildAdapter.templateFileName,
|
|
202
|
+
...(platform.buildAdapter.hasMarketplace
|
|
203
|
+
? { marketplaceFileName: platform.buildAdapter.marketplaceFileName }
|
|
204
|
+
: {}),
|
|
205
|
+
hasMarketplace: platform.buildAdapter.hasMarketplace,
|
|
206
|
+
}));
|
|
215
207
|
|
|
216
208
|
const REQUIRED_SCHEMA_FILES = Object.freeze([
|
|
217
209
|
'approval-record.schema.json',
|
package/src/snapshot/frozen.mjs
CHANGED
|
@@ -83,9 +83,14 @@ async function readStableRegularFile(filePath, displayPath) {
|
|
|
83
83
|
*
|
|
84
84
|
* `excludeRootEntries` is reserved for consumer-owned transport metadata
|
|
85
85
|
* that is not part of the published payload: Codex's root `.git` checkout
|
|
86
|
-
* metadata
|
|
87
|
-
*
|
|
88
|
-
*
|
|
86
|
+
* metadata, Codex's CLI-generated `.codex-plugin/migrated-command-skills/`
|
|
87
|
+
* subtree, and Claude's root `.in_use` in-use plugin marker. Single-segment
|
|
88
|
+
* entries only match direct children of the root (historical behavior);
|
|
89
|
+
* multi-segment entries name an exact relative path whose subtree is
|
|
90
|
+
* skipped. Every other payload path retains the normal fail-closed file
|
|
91
|
+
* checks — the exemption must never widen to whole directories or
|
|
92
|
+
* arbitrary extra files. Malformed entries (absolute, "..", backslash)
|
|
93
|
+
* fail closed: a bad exclusion list is a caller bug, not transport metadata.
|
|
89
94
|
*/
|
|
90
95
|
export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries = [] } = {}) {
|
|
91
96
|
const root = await realpath(snapshotDir);
|
|
@@ -95,14 +100,33 @@ export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries =
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
const entries = [];
|
|
98
|
-
const
|
|
103
|
+
const excludedRootNames = new Set();
|
|
104
|
+
const excludedPathPrefixes = [];
|
|
105
|
+
for (const entry of excludeRootEntries) {
|
|
106
|
+
if (typeof entry !== 'string') {
|
|
107
|
+
throw frozenError('frozen snapshot exclusion entries must be strings');
|
|
108
|
+
}
|
|
109
|
+
const segments = entry.split('/').filter((segment) => segment !== '' && segment !== '.');
|
|
110
|
+
if (
|
|
111
|
+
segments.length === 0 || entry.startsWith('/') || entry.includes('\\') ||
|
|
112
|
+
segments.some((segment) => segment === '..')
|
|
113
|
+
) {
|
|
114
|
+
throw frozenError(`frozen snapshot exclusion entry is not a safe relative path: ${JSON.stringify(entry)}`);
|
|
115
|
+
}
|
|
116
|
+
if (segments.length === 1) {
|
|
117
|
+
excludedRootNames.add(segments[0]);
|
|
118
|
+
} else {
|
|
119
|
+
excludedPathPrefixes.push(segments.join('/'));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
99
122
|
async function walk(dir) {
|
|
100
123
|
const children = await readdir(dir, { withFileTypes: true });
|
|
101
124
|
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
102
125
|
for (const child of children) {
|
|
103
|
-
if (dir === root &&
|
|
126
|
+
if (dir === root && excludedRootNames.has(child.name)) continue;
|
|
104
127
|
const absolute = join(dir, child.name);
|
|
105
128
|
const rel = relative(root, absolute).split('\\').join('/');
|
|
129
|
+
if (excludedPathPrefixes.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`))) continue;
|
|
106
130
|
const st = await lstat(absolute);
|
|
107
131
|
if (st.isSymbolicLink()) {
|
|
108
132
|
throw frozenError(`frozen snapshot contains symlink: ${rel}`);
|