@mc-and-his-agents/loom-installer 0.1.79 → 0.1.80
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/README.md +9 -0
- package/README.zh-CN.md +9 -0
- package/dist/src/cli.js +3 -1
- package/dist/src/index.js +289 -12
- package/dist/src/utils.js +5 -0
- package/dist/test/installer.test.js +146 -1
- package/package.json +1 -1
- package/payload/manifest.json +3 -3
package/README.md
CHANGED
|
@@ -20,6 +20,13 @@ npx @mc-and-his-agents/loom-installer add skill <skill-id> --host codex
|
|
|
20
20
|
npx @mc-and-his-agents/loom-installer add skill <skill-id> --host claude
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Read-only upgrade rehearsal and verification:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @mc-and-his-agents/loom-installer upgrade-plan plugin --host codex --json
|
|
27
|
+
npx @mc-and-his-agents/loom-installer verify-upgrade plugin --host codex --json
|
|
28
|
+
```
|
|
29
|
+
|
|
23
30
|
You can also pin the installer first:
|
|
24
31
|
|
|
25
32
|
```bash
|
|
@@ -47,6 +54,8 @@ Generated payload directories are not committed to git. The build step recreates
|
|
|
47
54
|
|
|
48
55
|
Installer JSON output reports `distribution_layer`, `version_context`, and `failed_layer` so callers can distinguish host adapter plugin installs from generated single-skill installs.
|
|
49
56
|
|
|
57
|
+
Installer-managed layers also write `loom-installed-surface-status/v1` metadata. `upgrade-plan` and `verify-upgrade` read that metadata, compare it to the package payload, and report `upgrade_eligibility`, `changed_paths`, `drift`, `rollback_path`, and fail-closed reasons without mutating the target repository. See `docs/adoption/installed-loom-status.md` for the status contract.
|
|
58
|
+
|
|
50
59
|
## Release Notes
|
|
51
60
|
|
|
52
61
|
Publishing only happens from `main`.
|
package/README.zh-CN.md
CHANGED
|
@@ -20,6 +20,13 @@ npx @mc-and-his-agents/loom-installer add skill <skill-id> --host codex
|
|
|
20
20
|
npx @mc-and-his-agents/loom-installer add skill <skill-id> --host claude
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
只读升级演练与验证:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @mc-and-his-agents/loom-installer upgrade-plan plugin --host codex --json
|
|
27
|
+
npx @mc-and-his-agents/loom-installer verify-upgrade plugin --host codex --json
|
|
28
|
+
```
|
|
29
|
+
|
|
23
30
|
也可以先固定 installer 版本:
|
|
24
31
|
|
|
25
32
|
```bash
|
|
@@ -47,6 +54,8 @@ Options:
|
|
|
47
54
|
|
|
48
55
|
Installer JSON output 会报告 `distribution_layer`、`version_context` 和 `failed_layer`,让调用方区分 host adapter plugin install 与 generated single-skill install。
|
|
49
56
|
|
|
57
|
+
Installer 管理的 layer 也会写入 `loom-installed-surface-status/v1` metadata。`upgrade-plan` 和 `verify-upgrade` 只读取该 metadata,并与 package payload 比对,报告 `upgrade_eligibility`、`changed_paths`、`drift`、`rollback_path` 和 fail-closed reason;它们不会修改目标仓库。状态合同见 `docs/adoption/installed-loom-status.md`。
|
|
58
|
+
|
|
50
59
|
## Release Notes
|
|
51
60
|
|
|
52
61
|
发布只会从 `main` 进行。
|
package/dist/src/cli.js
CHANGED
|
@@ -3,10 +3,12 @@ import { formatResult, parseCli, runInstaller } from './index.js';
|
|
|
3
3
|
import { InstallerError } from './utils.js';
|
|
4
4
|
function errorResult(message) {
|
|
5
5
|
return {
|
|
6
|
+
schema_version: 'loom-installer-result/v1',
|
|
7
|
+
operation: 'add',
|
|
6
8
|
mode: 'plugin',
|
|
7
9
|
host: 'codex',
|
|
8
10
|
distribution_layer: 'host-adapter-plugin',
|
|
9
|
-
status: '
|
|
11
|
+
status: 'blocked',
|
|
10
12
|
installed_paths: [],
|
|
11
13
|
verification: [],
|
|
12
14
|
warnings: [],
|
package/dist/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { dirname, join, resolve } from 'node:path';
|
|
1
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { InstallerError, assert, dirExists, ensureTargetWritable, runCommand } from './utils.js';
|
|
3
|
+
import { InstallerError, assert, dirExists, ensureTargetExists, ensureTargetWritable, fileExists, readJson, runCommand, sha256, writeJson } from './utils.js';
|
|
4
4
|
import { loadPayloadManifest, resolveSkillRecord, verifyPayload } from './payload.js';
|
|
5
5
|
import { installCodexPlugin, installCodexSkill } from './codex.js';
|
|
6
6
|
import { installClaudePlugin, installClaudeSkill } from './claude.js';
|
|
@@ -52,8 +52,8 @@ export function parseCli(argv) {
|
|
|
52
52
|
const subject = argv[1];
|
|
53
53
|
const third = argv[2];
|
|
54
54
|
const rest = subject === 'plugin' ? argv.slice(2) : argv.slice(3);
|
|
55
|
-
if (command !== 'add') {
|
|
56
|
-
throw new InstallerError('usage: loom-installer add plugin|skill <skill-id> [--host ...] [--target ...] [--force] [--json]');
|
|
55
|
+
if (command !== 'add' && command !== 'upgrade-plan' && command !== 'verify-upgrade') {
|
|
56
|
+
throw new InstallerError('usage: loom-installer add|upgrade-plan|verify-upgrade plugin|skill <skill-id> [--host ...] [--target ...] [--force] [--json]');
|
|
57
57
|
}
|
|
58
58
|
const options = { ...DEFAULT_OPTIONS };
|
|
59
59
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -88,21 +88,23 @@ export function parseCli(argv) {
|
|
|
88
88
|
}
|
|
89
89
|
if (subject === 'plugin') {
|
|
90
90
|
return {
|
|
91
|
+
operation: command,
|
|
91
92
|
mode: 'plugin',
|
|
92
93
|
options,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
if (subject === 'skill') {
|
|
96
97
|
if (!third) {
|
|
97
|
-
throw new InstallerError(
|
|
98
|
+
throw new InstallerError(`usage: loom-installer ${command} skill <skill-id>`);
|
|
98
99
|
}
|
|
99
100
|
return {
|
|
101
|
+
operation: command,
|
|
100
102
|
mode: 'skill',
|
|
101
103
|
skillId: third,
|
|
102
104
|
options,
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
|
-
throw new InstallerError(`unknown
|
|
107
|
+
throw new InstallerError(`unknown ${command} target: ${String(subject)}`);
|
|
106
108
|
}
|
|
107
109
|
export function checkPython(env) {
|
|
108
110
|
const result = runCommand(env.pythonBin, ['-c', 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}")'], process.env);
|
|
@@ -124,8 +126,8 @@ export function checkPython(env) {
|
|
|
124
126
|
}
|
|
125
127
|
return warnings;
|
|
126
128
|
}
|
|
127
|
-
export function ensureClaudeCliWhenNeeded(host, mode, env) {
|
|
128
|
-
if (host !== 'claude' || mode !== 'plugin') {
|
|
129
|
+
export function ensureClaudeCliWhenNeeded(host, mode, operation, env) {
|
|
130
|
+
if (operation !== 'add' || host !== 'claude' || mode !== 'plugin') {
|
|
129
131
|
return;
|
|
130
132
|
}
|
|
131
133
|
const result = runCommand(env.claudeBin, ['--version'], process.env);
|
|
@@ -136,8 +138,17 @@ export function ensureClaudeCliWhenNeeded(host, mode, env) {
|
|
|
136
138
|
export function formatResult(result) {
|
|
137
139
|
const version = result.version_context;
|
|
138
140
|
return [
|
|
139
|
-
`${result.host} ${result.mode}: ${result.status}`,
|
|
141
|
+
`${result.host} ${result.mode} ${result.operation ?? 'add'}: ${result.status}`,
|
|
140
142
|
`layer: ${result.distribution_layer}`,
|
|
143
|
+
...(result.installed_status
|
|
144
|
+
? [
|
|
145
|
+
`runtime_state: ${result.installed_status.runtime_state}`,
|
|
146
|
+
`upgrade_eligibility: ${result.installed_status.upgrade_eligibility}`,
|
|
147
|
+
]
|
|
148
|
+
: []),
|
|
149
|
+
...(result.changed_paths?.length ? [`changed_paths: ${result.changed_paths.length}`] : []),
|
|
150
|
+
...(result.drift?.length ? [`drift: ${result.drift.length}`] : []),
|
|
151
|
+
...(result.rollback_path ? [`rollback_path: ${result.rollback_path}`] : []),
|
|
141
152
|
...(version
|
|
142
153
|
? [
|
|
143
154
|
`versions: repo=${version.repo_version} installer=${version.installer_package_version} plugin=${version.plugin_surface_version} registry=${version.skills_registry_version}`,
|
|
@@ -150,13 +161,33 @@ export function formatResult(result) {
|
|
|
150
161
|
export function runInstaller(parsed, envSource = process.env, packageRoot) {
|
|
151
162
|
const resolvedEnv = resolveEnvironment(envSource);
|
|
152
163
|
const host = selectHost(parsed.options.host, resolvedEnv);
|
|
164
|
+
const operation = parsed.operation ?? 'add';
|
|
153
165
|
const resolvedPackageRoot = packageRoot ?? packageRootFromUrl(import.meta.url);
|
|
154
166
|
const targetRoot = resolve(parsed.options.target);
|
|
155
|
-
|
|
167
|
+
if (operation === 'add') {
|
|
168
|
+
ensureTargetWritable(targetRoot);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
ensureTargetExists(targetRoot);
|
|
172
|
+
}
|
|
156
173
|
const manifest = loadPayloadManifest(resolvedPackageRoot);
|
|
157
174
|
verifyPayload(resolvedPackageRoot, manifest);
|
|
158
175
|
const warnings = checkPython(resolvedEnv);
|
|
159
|
-
ensureClaudeCliWhenNeeded(host, parsed.mode, resolvedEnv);
|
|
176
|
+
ensureClaudeCliWhenNeeded(host, parsed.mode, operation, resolvedEnv);
|
|
177
|
+
const skill = parsed.mode === 'skill' ? resolveSkillRecord(manifest, parsed.skillId ?? '') : undefined;
|
|
178
|
+
if (operation !== 'add') {
|
|
179
|
+
const result = inspectInstalledSurface({
|
|
180
|
+
operation,
|
|
181
|
+
host,
|
|
182
|
+
parsed,
|
|
183
|
+
packageRoot: resolvedPackageRoot,
|
|
184
|
+
targetRoot,
|
|
185
|
+
manifest,
|
|
186
|
+
skill,
|
|
187
|
+
});
|
|
188
|
+
result.warnings.unshift(...warnings);
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
160
191
|
const result = installForHost({
|
|
161
192
|
host,
|
|
162
193
|
parsed,
|
|
@@ -166,7 +197,15 @@ export function runInstaller(parsed, envSource = process.env, packageRoot) {
|
|
|
166
197
|
manifest,
|
|
167
198
|
});
|
|
168
199
|
result.warnings.unshift(...warnings);
|
|
169
|
-
|
|
200
|
+
const withContext = withVersionContext(result, manifest, skill);
|
|
201
|
+
writeInstalledSurfaceStatus({
|
|
202
|
+
result: withContext,
|
|
203
|
+
host,
|
|
204
|
+
mode: parsed.mode,
|
|
205
|
+
skill,
|
|
206
|
+
targetRoot,
|
|
207
|
+
});
|
|
208
|
+
return withContext;
|
|
170
209
|
}
|
|
171
210
|
function payloadVersionContext(manifest, skill) {
|
|
172
211
|
return {
|
|
@@ -186,10 +225,248 @@ function payloadVersionContext(manifest, skill) {
|
|
|
186
225
|
}
|
|
187
226
|
function withVersionContext(result, manifest, skill) {
|
|
188
227
|
return {
|
|
228
|
+
schema_version: 'loom-installer-result/v1',
|
|
229
|
+
operation: 'add',
|
|
189
230
|
...result,
|
|
190
231
|
version_context: payloadVersionContext(manifest, skill),
|
|
191
232
|
};
|
|
192
233
|
}
|
|
234
|
+
function distributionLayer(mode) {
|
|
235
|
+
return mode === 'plugin' ? 'host-adapter-plugin' : 'generated-single-skill';
|
|
236
|
+
}
|
|
237
|
+
function skillDirName(host, skillId) {
|
|
238
|
+
if (host === 'codex') {
|
|
239
|
+
return skillId.startsWith('loom-') ? skillId : `loom-${skillId}`;
|
|
240
|
+
}
|
|
241
|
+
return skillId;
|
|
242
|
+
}
|
|
243
|
+
function installedRoot(targetRoot, host, mode, skill) {
|
|
244
|
+
if (mode === 'plugin') {
|
|
245
|
+
return host === 'codex'
|
|
246
|
+
? join(targetRoot, 'plugins', 'loom')
|
|
247
|
+
: join(targetRoot, '.claude', 'marketplaces', 'loom-local', 'plugins', 'loom');
|
|
248
|
+
}
|
|
249
|
+
assert(skill, 'skill install requires a skill record');
|
|
250
|
+
return host === 'codex'
|
|
251
|
+
? join(targetRoot, '.agents', 'skills', skillDirName(host, skill.id))
|
|
252
|
+
: join(targetRoot, '.claude', 'skills', skillDirName(host, skill.id));
|
|
253
|
+
}
|
|
254
|
+
function installedStatusPath(root) {
|
|
255
|
+
return join(root, '.loom-install-status.json');
|
|
256
|
+
}
|
|
257
|
+
function writeInstalledSurfaceStatus(input) {
|
|
258
|
+
const root = installedRoot(input.targetRoot, input.host, input.mode, input.skill);
|
|
259
|
+
const statusPath = installedStatusPath(root);
|
|
260
|
+
const status = {
|
|
261
|
+
schema_version: 'loom-installed-surface-status/v1',
|
|
262
|
+
installed_layer: input.result.distribution_layer,
|
|
263
|
+
host_adapter: input.host,
|
|
264
|
+
mode: input.mode,
|
|
265
|
+
...(input.skill ? { skill_id: input.skill.id } : {}),
|
|
266
|
+
version_context: input.result.version_context,
|
|
267
|
+
runtime_state: 'ready',
|
|
268
|
+
upgrade_eligibility: 'current',
|
|
269
|
+
evidence: [`installed surface metadata at ${statusPath}`],
|
|
270
|
+
failed_layer: null,
|
|
271
|
+
fail_closed_reason: null,
|
|
272
|
+
};
|
|
273
|
+
writeJson(statusPath, status);
|
|
274
|
+
input.result.installed_paths.push(statusPath);
|
|
275
|
+
input.result.installed_status = status;
|
|
276
|
+
}
|
|
277
|
+
function sourcePrefix(mode, manifest, skill) {
|
|
278
|
+
if (mode === 'plugin') {
|
|
279
|
+
return manifest.plugin.relative_path.replace(/\/$/, '');
|
|
280
|
+
}
|
|
281
|
+
assert(skill, 'skill install requires a skill record');
|
|
282
|
+
return skill.relative_path.replace(/\/$/, '');
|
|
283
|
+
}
|
|
284
|
+
function targetRelativePath(mode, host, sourcePath, manifest, skill) {
|
|
285
|
+
const prefix = sourcePrefix(mode, manifest, skill);
|
|
286
|
+
const suffix = sourcePath.slice(prefix.length).replace(/^\//, '');
|
|
287
|
+
if (mode === 'plugin') {
|
|
288
|
+
return host === 'codex'
|
|
289
|
+
? join('plugins', 'loom', suffix)
|
|
290
|
+
: join('.claude', 'marketplaces', 'loom-local', 'plugins', 'loom', suffix);
|
|
291
|
+
}
|
|
292
|
+
assert(skill, 'skill install requires a skill record');
|
|
293
|
+
return host === 'codex'
|
|
294
|
+
? join('.agents', 'skills', skillDirName(host, skill.id), suffix)
|
|
295
|
+
: join('.claude', 'skills', skillDirName(host, skill.id), suffix);
|
|
296
|
+
}
|
|
297
|
+
function compareInstalledPayload(input) {
|
|
298
|
+
const prefix = `${sourcePrefix(input.mode, input.manifest, input.skill)}/`;
|
|
299
|
+
const changed = [];
|
|
300
|
+
for (const file of input.manifest.files) {
|
|
301
|
+
if (!file.path.startsWith(prefix)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const source = join(input.packageRoot, 'payload', file.path);
|
|
305
|
+
const targetRelative = targetRelativePath(input.mode, input.host, file.path, input.manifest, input.skill);
|
|
306
|
+
const target = join(input.targetRoot, targetRelative);
|
|
307
|
+
if (!fileExists(target) || sha256(source) !== sha256(target)) {
|
|
308
|
+
changed.push(targetRelative);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return changed.sort();
|
|
312
|
+
}
|
|
313
|
+
function statusFailureResult(input) {
|
|
314
|
+
const available = payloadVersionContext(input.manifest, input.skill);
|
|
315
|
+
const rollbackPath = input.rollbackPath ?? null;
|
|
316
|
+
const installedStatus = {
|
|
317
|
+
schema_version: 'loom-installed-surface-status/v1',
|
|
318
|
+
installed_layer: distributionLayer(input.mode),
|
|
319
|
+
host_adapter: input.host,
|
|
320
|
+
mode: input.mode,
|
|
321
|
+
...(input.skill ? { skill_id: input.skill.id } : {}),
|
|
322
|
+
version_context: null,
|
|
323
|
+
runtime_state: 'blocked',
|
|
324
|
+
upgrade_eligibility: 'incompatible',
|
|
325
|
+
evidence: input.evidence,
|
|
326
|
+
failed_layer: 'installed-surface',
|
|
327
|
+
fail_closed_reason: input.reason,
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
schema_version: 'loom-installer-result/v1',
|
|
331
|
+
operation: input.operation,
|
|
332
|
+
mode: input.mode,
|
|
333
|
+
host: input.host,
|
|
334
|
+
distribution_layer: distributionLayer(input.mode),
|
|
335
|
+
status: 'blocked',
|
|
336
|
+
installed_paths: rollbackPath ? [rollbackPath] : [],
|
|
337
|
+
verification: input.evidence,
|
|
338
|
+
warnings: [],
|
|
339
|
+
version_context: null,
|
|
340
|
+
installed_status: installedStatus,
|
|
341
|
+
available_version_context: available,
|
|
342
|
+
changed_paths: [],
|
|
343
|
+
drift: [],
|
|
344
|
+
rollback_path: rollbackPath,
|
|
345
|
+
rehearsal: {
|
|
346
|
+
schema_version: 'loom-upgrade-rehearsal/v1',
|
|
347
|
+
mutates_target: false,
|
|
348
|
+
changed_paths: [],
|
|
349
|
+
drift: [],
|
|
350
|
+
rollback_path: rollbackPath,
|
|
351
|
+
},
|
|
352
|
+
failed_layer: 'installed-surface',
|
|
353
|
+
fail_closed_reason: input.reason,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function sameVersionContext(left, right) {
|
|
357
|
+
if (!left) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const keys = [
|
|
361
|
+
'repo_version',
|
|
362
|
+
'installer_package_version',
|
|
363
|
+
'plugin_surface_version',
|
|
364
|
+
'host_adapter_version',
|
|
365
|
+
'skills_registry_version',
|
|
366
|
+
'runtime_core_version',
|
|
367
|
+
'skill_package_version',
|
|
368
|
+
'skill_contract_version',
|
|
369
|
+
'skill_package_id',
|
|
370
|
+
];
|
|
371
|
+
return keys.every((key) => left[key] === right[key]);
|
|
372
|
+
}
|
|
373
|
+
function inspectInstalledSurface(input) {
|
|
374
|
+
const root = installedRoot(input.targetRoot, input.host, input.parsed.mode, input.skill);
|
|
375
|
+
const statusPath = installedStatusPath(root);
|
|
376
|
+
const available = payloadVersionContext(input.manifest, input.skill);
|
|
377
|
+
if (!fileExists(statusPath)) {
|
|
378
|
+
return statusFailureResult({
|
|
379
|
+
operation: input.operation,
|
|
380
|
+
host: input.host,
|
|
381
|
+
mode: input.parsed.mode,
|
|
382
|
+
skill: input.skill,
|
|
383
|
+
manifest: input.manifest,
|
|
384
|
+
rollbackPath: root,
|
|
385
|
+
reason: `installed Loom status metadata is missing: ${relative(input.targetRoot, statusPath)}`,
|
|
386
|
+
evidence: [`missing installed status metadata at ${statusPath}`],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
let installedStatus = readJson(statusPath);
|
|
390
|
+
if (installedStatus.schema_version !== 'loom-installed-surface-status/v1' ||
|
|
391
|
+
installedStatus.host_adapter !== input.host ||
|
|
392
|
+
installedStatus.mode !== input.parsed.mode ||
|
|
393
|
+
installedStatus.installed_layer !== distributionLayer(input.parsed.mode) ||
|
|
394
|
+
!installedStatus.version_context) {
|
|
395
|
+
return statusFailureResult({
|
|
396
|
+
operation: input.operation,
|
|
397
|
+
host: input.host,
|
|
398
|
+
mode: input.parsed.mode,
|
|
399
|
+
skill: input.skill,
|
|
400
|
+
manifest: input.manifest,
|
|
401
|
+
rollbackPath: root,
|
|
402
|
+
reason: `installed Loom status metadata is inconsistent: ${relative(input.targetRoot, statusPath)}`,
|
|
403
|
+
evidence: [`inconsistent installed status metadata at ${statusPath}`],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (input.skill && installedStatus.skill_id !== input.skill.id) {
|
|
407
|
+
return statusFailureResult({
|
|
408
|
+
operation: input.operation,
|
|
409
|
+
host: input.host,
|
|
410
|
+
mode: input.parsed.mode,
|
|
411
|
+
skill: input.skill,
|
|
412
|
+
manifest: input.manifest,
|
|
413
|
+
rollbackPath: root,
|
|
414
|
+
reason: `installed Loom skill metadata does not match ${input.skill.id}`,
|
|
415
|
+
evidence: [`skill metadata mismatch at ${statusPath}`],
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const changedPaths = compareInstalledPayload({
|
|
419
|
+
host: input.host,
|
|
420
|
+
mode: input.parsed.mode,
|
|
421
|
+
packageRoot: input.packageRoot,
|
|
422
|
+
targetRoot: input.targetRoot,
|
|
423
|
+
manifest: input.manifest,
|
|
424
|
+
skill: input.skill,
|
|
425
|
+
});
|
|
426
|
+
const versionMatches = sameVersionContext(installedStatus.version_context, available);
|
|
427
|
+
const eligibility = changedPaths.length === 0 && versionMatches ? 'current' : versionMatches ? 'drift' : 'upgrade-available';
|
|
428
|
+
const drift = eligibility === 'drift' ? changedPaths : [];
|
|
429
|
+
const runtimeState = eligibility === 'drift' ? 'blocked' : 'ready';
|
|
430
|
+
const rollbackPath = changedPaths.length > 0 ? root : null;
|
|
431
|
+
const failClosedReason = eligibility === 'drift' ? 'installed Loom payload drifted from its recorded version context' : null;
|
|
432
|
+
installedStatus = {
|
|
433
|
+
...installedStatus,
|
|
434
|
+
runtime_state: runtimeState,
|
|
435
|
+
upgrade_eligibility: eligibility,
|
|
436
|
+
evidence: [`read installed status metadata at ${statusPath}`, `compared installed payload under ${root}`],
|
|
437
|
+
failed_layer: failClosedReason ? 'installed-surface' : null,
|
|
438
|
+
fail_closed_reason: failClosedReason,
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
schema_version: 'loom-installer-result/v1',
|
|
442
|
+
operation: input.operation,
|
|
443
|
+
mode: input.parsed.mode,
|
|
444
|
+
host: input.host,
|
|
445
|
+
distribution_layer: distributionLayer(input.parsed.mode),
|
|
446
|
+
status: input.operation === 'verify-upgrade' ? (failClosedReason ? 'blocked' : 'verified') : failClosedReason ? 'blocked' : 'planned',
|
|
447
|
+
installed_paths: [root, statusPath],
|
|
448
|
+
verification: [
|
|
449
|
+
`read installed status metadata at ${statusPath}`,
|
|
450
|
+
`compared ${changedPaths.length} changed path(s) without mutating target`,
|
|
451
|
+
],
|
|
452
|
+
warnings: [],
|
|
453
|
+
version_context: installedStatus.version_context,
|
|
454
|
+
installed_status: installedStatus,
|
|
455
|
+
available_version_context: available,
|
|
456
|
+
changed_paths: changedPaths,
|
|
457
|
+
drift,
|
|
458
|
+
rollback_path: rollbackPath,
|
|
459
|
+
rehearsal: {
|
|
460
|
+
schema_version: 'loom-upgrade-rehearsal/v1',
|
|
461
|
+
mutates_target: false,
|
|
462
|
+
changed_paths: changedPaths,
|
|
463
|
+
drift,
|
|
464
|
+
rollback_path: rollbackPath,
|
|
465
|
+
},
|
|
466
|
+
failed_layer: failClosedReason ? 'installed-surface' : null,
|
|
467
|
+
fail_closed_reason: failClosedReason,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
193
470
|
function installForHost(input) {
|
|
194
471
|
const { host, parsed, env, packageRoot, targetRoot, manifest } = input;
|
|
195
472
|
if (parsed.mode === 'plugin') {
|
package/dist/src/utils.js
CHANGED
|
@@ -37,6 +37,11 @@ export function ensureTargetWritable(path) {
|
|
|
37
37
|
throw new InstallerError(`target path is not writable: ${path}`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
export function ensureTargetExists(path) {
|
|
41
|
+
if (!existsSync(path)) {
|
|
42
|
+
throw new InstallerError(`target path does not exist: ${path}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
40
45
|
export function copyTree(source, target, force) {
|
|
41
46
|
if (existsSync(target)) {
|
|
42
47
|
if (!force) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
@@ -56,13 +56,22 @@ test('detectHosts and selectHost fail closed on conflicts', () => {
|
|
|
56
56
|
});
|
|
57
57
|
test('parseCli supports plugin and skill flows', () => {
|
|
58
58
|
const plugin = parseCli(['add', 'plugin', '--host', 'codex', '--json']);
|
|
59
|
+
assert.equal(plugin.operation, 'add');
|
|
59
60
|
assert.equal(plugin.mode, 'plugin');
|
|
60
61
|
assert.equal(plugin.options.host, 'codex');
|
|
61
62
|
assert.equal(plugin.options.json, true);
|
|
62
63
|
const skill = parseCli(['add', 'skill', 'loom-review', '--target', '/tmp/repo']);
|
|
64
|
+
assert.equal(skill.operation, 'add');
|
|
63
65
|
assert.equal(skill.mode, 'skill');
|
|
64
66
|
assert.equal(skill.skillId, 'loom-review');
|
|
65
67
|
assert.equal(skill.options.target, '/tmp/repo');
|
|
68
|
+
const plan = parseCli(['upgrade-plan', 'plugin', '--host', 'codex', '--target', '/tmp/repo', '--json']);
|
|
69
|
+
assert.equal(plan.operation, 'upgrade-plan');
|
|
70
|
+
assert.equal(plan.mode, 'plugin');
|
|
71
|
+
const verify = parseCli(['verify-upgrade', 'skill', 'loom-init', '--host', 'claude']);
|
|
72
|
+
assert.equal(verify.operation, 'verify-upgrade');
|
|
73
|
+
assert.equal(verify.mode, 'skill');
|
|
74
|
+
assert.equal(verify.skillId, 'loom-init');
|
|
66
75
|
});
|
|
67
76
|
test('payload manifest excludes python cache artifacts', () => {
|
|
68
77
|
const manifest = JSON.parse(readFileSync(join(packageRoot(), 'payload', 'manifest.json'), 'utf8'));
|
|
@@ -119,9 +128,145 @@ test('codex plugin install writes marketplace entry', () => {
|
|
|
119
128
|
assert.equal(result.host, 'codex');
|
|
120
129
|
assert.equal(result.distribution_layer, 'host-adapter-plugin');
|
|
121
130
|
assert.equal(result.version_context?.plugin_surface_version, '0.4.0');
|
|
131
|
+
assert.equal(result.installed_status?.schema_version, 'loom-installed-surface-status/v1');
|
|
132
|
+
assert.equal(result.installed_status?.runtime_state, 'ready');
|
|
122
133
|
assert.equal(marketplace.plugins[0].name, 'loom');
|
|
123
134
|
assert.equal(marketplace.plugins[0].source.path, './plugins/loom');
|
|
124
135
|
});
|
|
136
|
+
test('upgrade-plan reports current installed plugin without mutating target', () => {
|
|
137
|
+
const base = fixtureRoot();
|
|
138
|
+
const envSource = prepareEnv(base);
|
|
139
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
140
|
+
const repoRoot = join(base, 'repo');
|
|
141
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
142
|
+
runInstaller({
|
|
143
|
+
mode: 'plugin',
|
|
144
|
+
options: {
|
|
145
|
+
host: 'codex',
|
|
146
|
+
target: repoRoot,
|
|
147
|
+
force: false,
|
|
148
|
+
json: false,
|
|
149
|
+
},
|
|
150
|
+
}, envSource, packageRoot());
|
|
151
|
+
const plan = runInstaller({
|
|
152
|
+
operation: 'upgrade-plan',
|
|
153
|
+
mode: 'plugin',
|
|
154
|
+
options: {
|
|
155
|
+
host: 'codex',
|
|
156
|
+
target: repoRoot,
|
|
157
|
+
force: false,
|
|
158
|
+
json: false,
|
|
159
|
+
},
|
|
160
|
+
}, envSource, packageRoot());
|
|
161
|
+
assert.equal(plan.operation, 'upgrade-plan');
|
|
162
|
+
assert.equal(plan.status, 'planned');
|
|
163
|
+
assert.equal(plan.installed_status?.upgrade_eligibility, 'current');
|
|
164
|
+
assert.deepEqual(plan.changed_paths, []);
|
|
165
|
+
assert.deepEqual(plan.drift, []);
|
|
166
|
+
assert.equal(plan.rehearsal?.mutates_target, false);
|
|
167
|
+
assert.equal(plan.rollback_path, null);
|
|
168
|
+
});
|
|
169
|
+
test('upgrade-plan reports available payload and changed paths', () => {
|
|
170
|
+
const base = fixtureRoot();
|
|
171
|
+
const envSource = prepareEnv(base);
|
|
172
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
173
|
+
const repoRoot = join(base, 'repo');
|
|
174
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
175
|
+
runInstaller({
|
|
176
|
+
mode: 'plugin',
|
|
177
|
+
options: {
|
|
178
|
+
host: 'codex',
|
|
179
|
+
target: repoRoot,
|
|
180
|
+
force: false,
|
|
181
|
+
json: false,
|
|
182
|
+
},
|
|
183
|
+
}, envSource, packageRoot());
|
|
184
|
+
const statusPath = join(repoRoot, 'plugins', 'loom', '.loom-install-status.json');
|
|
185
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
186
|
+
status.version_context.installer_package_version = '0.0.0';
|
|
187
|
+
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
188
|
+
writeFileSync(join(repoRoot, 'plugins', 'loom', 'skills', 'registry.json'), '{"registry_version":"0.0.0"}\n', 'utf8');
|
|
189
|
+
const plan = runInstaller({
|
|
190
|
+
operation: 'upgrade-plan',
|
|
191
|
+
mode: 'plugin',
|
|
192
|
+
options: {
|
|
193
|
+
host: 'codex',
|
|
194
|
+
target: repoRoot,
|
|
195
|
+
force: false,
|
|
196
|
+
json: false,
|
|
197
|
+
},
|
|
198
|
+
}, envSource, packageRoot());
|
|
199
|
+
assert.equal(plan.status, 'planned');
|
|
200
|
+
assert.equal(plan.installed_status?.upgrade_eligibility, 'upgrade-available');
|
|
201
|
+
assert.equal(plan.changed_paths?.includes(join('plugins', 'loom', 'skills', 'registry.json')), true);
|
|
202
|
+
assert.equal(plan.rollback_path, join(repoRoot, 'plugins', 'loom'));
|
|
203
|
+
});
|
|
204
|
+
test('verify-upgrade fails closed when installed payload drifts from recorded metadata', () => {
|
|
205
|
+
const base = fixtureRoot();
|
|
206
|
+
const envSource = prepareEnv(base);
|
|
207
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
208
|
+
const repoRoot = join(base, 'repo');
|
|
209
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
210
|
+
runInstaller({
|
|
211
|
+
mode: 'plugin',
|
|
212
|
+
options: {
|
|
213
|
+
host: 'codex',
|
|
214
|
+
target: repoRoot,
|
|
215
|
+
force: false,
|
|
216
|
+
json: false,
|
|
217
|
+
},
|
|
218
|
+
}, envSource, packageRoot());
|
|
219
|
+
writeFileSync(join(repoRoot, 'plugins', 'loom', 'skills', 'registry.json'), '{"registry_version":"drift"}\n', 'utf8');
|
|
220
|
+
const verify = runInstaller({
|
|
221
|
+
operation: 'verify-upgrade',
|
|
222
|
+
mode: 'plugin',
|
|
223
|
+
options: {
|
|
224
|
+
host: 'codex',
|
|
225
|
+
target: repoRoot,
|
|
226
|
+
force: false,
|
|
227
|
+
json: false,
|
|
228
|
+
},
|
|
229
|
+
}, envSource, packageRoot());
|
|
230
|
+
assert.equal(verify.status, 'blocked');
|
|
231
|
+
assert.equal(verify.installed_status?.runtime_state, 'blocked');
|
|
232
|
+
assert.equal(verify.installed_status?.upgrade_eligibility, 'drift');
|
|
233
|
+
assert.equal(verify.drift?.includes(join('plugins', 'loom', 'skills', 'registry.json')), true);
|
|
234
|
+
assert.equal(verify.failed_layer, 'installed-surface');
|
|
235
|
+
assert.match(verify.fail_closed_reason ?? '', /drifted/);
|
|
236
|
+
assert.equal(verify.rollback_path, join(repoRoot, 'plugins', 'loom'));
|
|
237
|
+
});
|
|
238
|
+
test('verify-upgrade fails closed when installed version metadata is missing', () => {
|
|
239
|
+
const base = fixtureRoot();
|
|
240
|
+
const envSource = prepareEnv(base);
|
|
241
|
+
mkdirSync(envSource.CODEX_HOME, { recursive: true });
|
|
242
|
+
const repoRoot = join(base, 'repo');
|
|
243
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
244
|
+
runInstaller({
|
|
245
|
+
mode: 'plugin',
|
|
246
|
+
options: {
|
|
247
|
+
host: 'codex',
|
|
248
|
+
target: repoRoot,
|
|
249
|
+
force: false,
|
|
250
|
+
json: false,
|
|
251
|
+
},
|
|
252
|
+
}, envSource, packageRoot());
|
|
253
|
+
rmSync(join(repoRoot, 'plugins', 'loom', '.loom-install-status.json'));
|
|
254
|
+
const verify = runInstaller({
|
|
255
|
+
operation: 'verify-upgrade',
|
|
256
|
+
mode: 'plugin',
|
|
257
|
+
options: {
|
|
258
|
+
host: 'codex',
|
|
259
|
+
target: repoRoot,
|
|
260
|
+
force: false,
|
|
261
|
+
json: false,
|
|
262
|
+
},
|
|
263
|
+
}, envSource, packageRoot());
|
|
264
|
+
assert.equal(verify.status, 'blocked');
|
|
265
|
+
assert.equal(verify.installed_status?.upgrade_eligibility, 'incompatible');
|
|
266
|
+
assert.equal(verify.failed_layer, 'installed-surface');
|
|
267
|
+
assert.match(verify.fail_closed_reason ?? '', /metadata is missing/);
|
|
268
|
+
assert.equal(verify.rollback_path, join(repoRoot, 'plugins', 'loom'));
|
|
269
|
+
});
|
|
125
270
|
test('codex plugin install fails closed on marketplace conflicts without force', () => {
|
|
126
271
|
const base = fixtureRoot();
|
|
127
272
|
const envSource = prepareEnv(base);
|
package/package.json
CHANGED
package/payload/manifest.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"schema_version": "loom-installer-payload/v1",
|
|
3
3
|
"loom_version": "v0.6.0",
|
|
4
4
|
"source_repository": "https://github.com/MC-and-his-Agents/Loom",
|
|
5
|
-
"source_commit": "
|
|
5
|
+
"source_commit": "0b80a513948efe0fa2fbfa37c20f151fa6ed66e6",
|
|
6
6
|
"source_ref": "main",
|
|
7
|
-
"built_at": "2026-05-
|
|
7
|
+
"built_at": "2026-05-09T04:16:59+08:00",
|
|
8
8
|
"version_context": {
|
|
9
9
|
"repo_version": "v0.6.0",
|
|
10
|
-
"installer_package_version": "0.1.
|
|
10
|
+
"installer_package_version": "0.1.80",
|
|
11
11
|
"plugin_surface_version": "0.4.0",
|
|
12
12
|
"host_adapter_version": "1.0.0",
|
|
13
13
|
"skills_registry_version": "1.4.0",
|