@ijfw/memory-server 1.3.0 → 1.4.1
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 +67 -0
- package/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +142 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +353 -1
- package/src/dashboard-server.js +318 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +637 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1269 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +905 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +372 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +207 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- package/src/vectors.js +7 -8
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/extension.js
|
|
3
|
+
*
|
|
4
|
+
* IJFW v1.4.0 / W3/t16 — extension colon-namespace dispatch handler.
|
|
5
|
+
*
|
|
6
|
+
* Routes `extension:<command>` from colon-syntax dispatch and
|
|
7
|
+
* `ijfw extension <command>` from the CLI to the W2 extension-installer
|
|
8
|
+
* primitives.
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* add <source> [scope] — install (npm name | local path | https:// git url)
|
|
12
|
+
* list — aggregate extensions across project+org+user scopes
|
|
13
|
+
* remove <name> [scope] — uninstall + cleanup
|
|
14
|
+
* audit — registry + per-extension permission summary
|
|
15
|
+
* deploy-lazy — (W6/S12) walk ~/.ijfw/extensions-{org,user}/ and
|
|
16
|
+
* deploy each registered extension's skills to the
|
|
17
|
+
* current project's platform dirs. Fired by the
|
|
18
|
+
* session-start hook so org/user-scoped extensions
|
|
19
|
+
* become available in every project session.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
installExtension,
|
|
24
|
+
uninstallExtension,
|
|
25
|
+
listExtensions,
|
|
26
|
+
} from '../extension-installer.js';
|
|
27
|
+
import {
|
|
28
|
+
generatePublisherKeypair,
|
|
29
|
+
addTrustedPublisher,
|
|
30
|
+
removeTrustedPublisher,
|
|
31
|
+
readTrustedPublishers,
|
|
32
|
+
loadPublisherKeypair,
|
|
33
|
+
signRotationToken,
|
|
34
|
+
verifyRotationToken,
|
|
35
|
+
} from '../extension-signer.js';
|
|
36
|
+
import {
|
|
37
|
+
refreshTrustFromRegistry,
|
|
38
|
+
readCachedRegistry,
|
|
39
|
+
verifyRegistry,
|
|
40
|
+
keygenMeta,
|
|
41
|
+
signRegistry,
|
|
42
|
+
verifyRegistryFile,
|
|
43
|
+
DEFAULT_REGISTRY_URL,
|
|
44
|
+
} from '../extension-registry.js';
|
|
45
|
+
import {
|
|
46
|
+
deployExtensionSkillsToPlatforms,
|
|
47
|
+
deployExtensionToAgentsMd,
|
|
48
|
+
} from '../../../installer/src/install-helpers.js';
|
|
49
|
+
import { promises as fs } from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import os from 'node:os';
|
|
52
|
+
|
|
53
|
+
const VALID_SCOPES = new Set(['project', 'org', 'user']);
|
|
54
|
+
|
|
55
|
+
function parseScope(rawScope, fallback = 'project') {
|
|
56
|
+
return VALID_SCOPES.has(rawScope) ? rawScope : fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse `<source> [scope]` allowing the source to contain whitespace when
|
|
61
|
+
* wrapped in single or double quotes (paths with spaces, etc.).
|
|
62
|
+
*
|
|
63
|
+
* Rules:
|
|
64
|
+
* - If args starts with `"` or `'`, source is the body between matching
|
|
65
|
+
* quotes; whatever follows the close quote is candidate scope.
|
|
66
|
+
* - Otherwise: if the LAST whitespace-separated token matches the scope
|
|
67
|
+
* enum (project|org|user), source is the greedy join of everything
|
|
68
|
+
* before it. Else source is the whole trimmed args (no scope).
|
|
69
|
+
*
|
|
70
|
+
* Returns { source, scope } where scope is the parsed raw token (caller
|
|
71
|
+
* still runs it through parseScope to coerce to default).
|
|
72
|
+
*/
|
|
73
|
+
function parseSourceAndScope(args) {
|
|
74
|
+
const raw = String(args || '');
|
|
75
|
+
const trimmed = raw.replace(/^\s+/, '');
|
|
76
|
+
if (!trimmed) return { source: '', scope: undefined };
|
|
77
|
+
|
|
78
|
+
const first = trimmed[0];
|
|
79
|
+
if (first === '"' || first === "'") {
|
|
80
|
+
const close = trimmed.indexOf(first, 1);
|
|
81
|
+
if (close > 0) {
|
|
82
|
+
const source = trimmed.slice(1, close);
|
|
83
|
+
const rest = trimmed.slice(close + 1).trim();
|
|
84
|
+
const scope = rest.split(/\s+/).filter(Boolean)[0];
|
|
85
|
+
return { source, scope };
|
|
86
|
+
}
|
|
87
|
+
// unmatched quote — fall through to non-quoted parse using the raw text
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
91
|
+
if (tokens.length === 0) return { source: '', scope: undefined };
|
|
92
|
+
const last = tokens[tokens.length - 1];
|
|
93
|
+
// W6.2/R5-M-01: only strip a trailing scope token when the input is exactly
|
|
94
|
+
// 2 tokens (`<source> <scope>`). Paths with internal whitespace must be
|
|
95
|
+
// quoted (handled above); a single token is always pure source. This is
|
|
96
|
+
// narrower than W6.1's looksLikePath heuristic, which broke the common
|
|
97
|
+
// `ijfw extension add ./local-pkg user` case. The original Gemini-med
|
|
98
|
+
// case (`/my/project` → source/scope mishandle) is now covered by the
|
|
99
|
+
// single-token path falling through to "no scope".
|
|
100
|
+
if (tokens.length === 2 && VALID_SCOPES.has(last)) {
|
|
101
|
+
return { source: tokens[0], scope: last };
|
|
102
|
+
}
|
|
103
|
+
return { source: tokens.join(' '), scope: undefined };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strip recognised flag tokens from an args string and return both the
|
|
108
|
+
* cleaned args and a flags object. Flags are positional-agnostic.
|
|
109
|
+
*
|
|
110
|
+
* --allow-unsigned -> opts.allowUnsigned = true
|
|
111
|
+
* --accept-untrusted -> opts.acceptUntrusted = true
|
|
112
|
+
* --activate -> opts.activate = true
|
|
113
|
+
*/
|
|
114
|
+
function extractAddFlags(args) {
|
|
115
|
+
const tokens = String(args || '').split(/\s+/).filter(Boolean);
|
|
116
|
+
const flags = { allowUnsigned: false, acceptUntrusted: false, activate: false };
|
|
117
|
+
const keep = [];
|
|
118
|
+
for (const t of tokens) {
|
|
119
|
+
if (t === '--allow-unsigned') { flags.allowUnsigned = true; continue; }
|
|
120
|
+
if (t === '--accept-untrusted') { flags.acceptUntrusted = true; continue; }
|
|
121
|
+
if (t === '--activate') { flags.activate = true; continue; }
|
|
122
|
+
keep.push(t);
|
|
123
|
+
}
|
|
124
|
+
return { args: keep.join(' '), flags };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function cmdAdd({ args, projectRoot }) {
|
|
128
|
+
const { args: stripped, flags } = extractAddFlags(args);
|
|
129
|
+
const { source, scope: rawScope } = parseSourceAndScope(stripped);
|
|
130
|
+
if (!source) return { ok: false, command: 'add', error: 'missing source (npm name, path, or https:// git url)' };
|
|
131
|
+
const scope = parseScope(rawScope);
|
|
132
|
+
try {
|
|
133
|
+
const r = await installExtension(source, {
|
|
134
|
+
scope,
|
|
135
|
+
projectRoot,
|
|
136
|
+
allowUnsigned: flags.allowUnsigned,
|
|
137
|
+
acceptUntrusted: flags.acceptUntrusted,
|
|
138
|
+
activate: flags.activate,
|
|
139
|
+
});
|
|
140
|
+
return { ok: !!r.ok, command: 'add', result: r };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { ok: false, command: 'add', error: err.message };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function cmdKeygen({ args }) {
|
|
147
|
+
const authorName = String(args || '').trim();
|
|
148
|
+
if (!authorName) return { ok: false, command: 'keygen', error: 'missing author name' };
|
|
149
|
+
try {
|
|
150
|
+
const kp = await generatePublisherKeypair(authorName);
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
command: 'keygen',
|
|
154
|
+
result: {
|
|
155
|
+
keyId: kp.keyId,
|
|
156
|
+
publicKey: kp.publicKey,
|
|
157
|
+
dir: kp.dir,
|
|
158
|
+
// private key is on disk at <dir>/private.pem; never echo to log.
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, command: 'keygen', error: err.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function cmdTrust({ args }) {
|
|
167
|
+
// Args shape: "<keyId> <publicKeyPemMultiline>"
|
|
168
|
+
// The PEM body almost certainly contains spaces and newlines — accept
|
|
169
|
+
// everything after the first whitespace as the public key.
|
|
170
|
+
const raw = String(args || '');
|
|
171
|
+
const idx = raw.search(/\s/);
|
|
172
|
+
if (idx < 0) return { ok: false, command: 'trust', error: 'usage: trust <keyId> <publicKeyPem>' };
|
|
173
|
+
const keyId = raw.slice(0, idx).trim();
|
|
174
|
+
const publicKey = raw.slice(idx + 1).trim();
|
|
175
|
+
if (!keyId || !publicKey) return { ok: false, command: 'trust', error: 'usage: trust <keyId> <publicKeyPem>' };
|
|
176
|
+
try {
|
|
177
|
+
const r = await addTrustedPublisher(keyId, publicKey);
|
|
178
|
+
return { ok: !!r.ok, command: 'trust', result: r };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { ok: false, command: 'trust', error: err.message };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function cmdUntrust({ args }) {
|
|
185
|
+
const keyId = String(args || '').trim();
|
|
186
|
+
if (!keyId) return { ok: false, command: 'untrust', error: 'missing keyId' };
|
|
187
|
+
try {
|
|
188
|
+
const r = await removeTrustedPublisher(keyId);
|
|
189
|
+
return { ok: !!r.ok, command: 'untrust', result: { removed: r.removed } };
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return { ok: false, command: 'untrust', error: err.message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cmdTrusted() {
|
|
196
|
+
try {
|
|
197
|
+
const store = await readTrustedPublishers();
|
|
198
|
+
const publishers = Object.entries(store.publishers || {}).map(([keyId, v]) => ({
|
|
199
|
+
keyId,
|
|
200
|
+
name: v.name ?? null,
|
|
201
|
+
added_at: v.added_at ?? null,
|
|
202
|
+
}));
|
|
203
|
+
return { ok: true, command: 'trusted', result: { publishers, count: publishers.length } };
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { ok: false, command: 'trusted', error: err.message };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function cmdRemove({ args, projectRoot }) {
|
|
210
|
+
const { source: name, scope: rawScope } = parseSourceAndScope(args);
|
|
211
|
+
if (!name) return { ok: false, command: 'remove', error: 'missing extension name' };
|
|
212
|
+
const scope = parseScope(rawScope);
|
|
213
|
+
try {
|
|
214
|
+
const r = await uninstallExtension(name, { scope, projectRoot });
|
|
215
|
+
return { ok: !!r.ok, command: 'remove', result: r };
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return { ok: false, command: 'remove', error: err.message };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function cmdList({ projectRoot }) {
|
|
222
|
+
try {
|
|
223
|
+
const r = await listExtensions(projectRoot);
|
|
224
|
+
const extensions = Array.isArray(r) ? r : (r?.extensions ?? []);
|
|
225
|
+
return { ok: true, command: 'list', result: { extensions, count: extensions.length } };
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { ok: false, command: 'list', error: err.message };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdAudit({ projectRoot }) {
|
|
232
|
+
try {
|
|
233
|
+
const r = await listExtensions(projectRoot);
|
|
234
|
+
const extensions = Array.isArray(r) ? r : (r?.extensions ?? []);
|
|
235
|
+
const summary = extensions.map(e => ({
|
|
236
|
+
name: e.name,
|
|
237
|
+
version: e.version,
|
|
238
|
+
scope: e.scope,
|
|
239
|
+
status: e.status ?? 'active',
|
|
240
|
+
last_trident_verdict: e.last_trident_verdict ?? null,
|
|
241
|
+
// listExtensions now returns `permissions` and `description` directly
|
|
242
|
+
// on each entry (W6B-1). Reading them at the top level keeps the
|
|
243
|
+
// dispatch independent of the registry's internal manifest shape.
|
|
244
|
+
permissions: e.permissions ?? null,
|
|
245
|
+
description: e.description ?? null,
|
|
246
|
+
}));
|
|
247
|
+
return { ok: true, command: 'audit', result: { summary, count: summary.length } };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ok: false, command: 'audit', error: err.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* cmdDeployLazy — W6/S12.
|
|
255
|
+
*
|
|
256
|
+
* Org/user-scoped extensions install to ~/.ijfw/extensions-{org,user}/<name>/
|
|
257
|
+
* but the platform skill dirs are project-local. So the bundled `installExtension`
|
|
258
|
+
* only deploys to platforms for project-scope installs. For org/user scopes,
|
|
259
|
+
* the skill files become available in any given project by way of THIS function,
|
|
260
|
+
* fired by the session-start hook.
|
|
261
|
+
*
|
|
262
|
+
* Walks both scope dirs, reads each extension's manifest.json, and calls the
|
|
263
|
+
* existing platform-deploy helper to copy skills into the current project's
|
|
264
|
+
* platform skill dirs + inject the AGENTS.md fence. Idempotent: re-running is
|
|
265
|
+
* safe (deploy helpers are atomic + AGENTS.md inject is fenced).
|
|
266
|
+
*
|
|
267
|
+
* Errors per-extension are captured in `failed[]` and do NOT abort the rest.
|
|
268
|
+
*/
|
|
269
|
+
// W6.1/C4-M-01: validate extension dir name at the readdir boundary.
|
|
270
|
+
// readdir limits entries to single-segment names (no traversal possible)
|
|
271
|
+
// but a hand-placed dir like `Weird Name With Spaces/` would still flow
|
|
272
|
+
// through to deployExtensionSkillsToPlatforms which would create
|
|
273
|
+
// `ext-Weird Name With Spaces` dirs across every platform.
|
|
274
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- anchored, bounded npm name shape; no nested ambiguous repetition
|
|
275
|
+
const EXTENSION_NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
|
|
276
|
+
|
|
277
|
+
// W6.2/R5-H-02: scoped extensions live at `<root>/@scope/pkg/` (two-level).
|
|
278
|
+
// Flat extensions live at `<root>/pkg/` (one-level). Enumerate both shapes
|
|
279
|
+
// and yield canonical `[name, extDir]` pairs.
|
|
280
|
+
async function* enumerateExtensions(root, scope, skipped) {
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (err.code === 'ENOENT') return;
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
// Non-directory entries are skipped at the top level (incl. symlinks).
|
|
290
|
+
if (!entry.isDirectory()) {
|
|
291
|
+
skipped.push({ scope, name: entry.name, reason: 'not-a-directory' });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (entry.name.startsWith('@')) {
|
|
295
|
+
// Scoped: recurse one level. Combined name is `@scope/pkg`.
|
|
296
|
+
const scopedRoot = path.join(root, entry.name);
|
|
297
|
+
let inner;
|
|
298
|
+
try {
|
|
299
|
+
inner = await fs.readdir(scopedRoot, { withFileTypes: true });
|
|
300
|
+
} catch {
|
|
301
|
+
skipped.push({ scope, name: entry.name, reason: 'scoped-readdir-failed' });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const sub of inner) {
|
|
305
|
+
if (!sub.isDirectory()) {
|
|
306
|
+
skipped.push({ scope, name: `${entry.name}/${sub.name}`, reason: 'not-a-directory' });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const combined = `${entry.name}/${sub.name}`;
|
|
310
|
+
if (!EXTENSION_NAME_PATTERN.test(combined)) {
|
|
311
|
+
skipped.push({ scope, name: combined, reason: 'invalid-extension-name' });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
yield [combined, path.join(scopedRoot, sub.name)];
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!EXTENSION_NAME_PATTERN.test(entry.name)) {
|
|
319
|
+
skipped.push({ scope, name: entry.name, reason: 'invalid-extension-name' });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
yield [entry.name, path.join(root, entry.name)];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function cmdDeployLazy({ projectRoot }) {
|
|
327
|
+
const result = { ok: true, command: 'deploy-lazy', result: { deployed: [], failed: [], skipped: [] } };
|
|
328
|
+
const scopeRoots = [
|
|
329
|
+
{ scope: 'org', root: path.join(os.homedir(), '.ijfw', 'extensions-org') },
|
|
330
|
+
{ scope: 'user', root: path.join(os.homedir(), '.ijfw', 'extensions-user') },
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
for (const { scope, root } of scopeRoots) {
|
|
334
|
+
try {
|
|
335
|
+
for await (const [name, extDir] of enumerateExtensions(root, scope, result.result.skipped)) {
|
|
336
|
+
await deployOneExtension({ scope, name, extDir, projectRoot, result });
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
result.result.failed.push({ scope, name: null, error: `readdir ${root}: ${err.message}` });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function deployOneExtension({ scope, name, extDir, projectRoot, result }) {
|
|
346
|
+
const manifestPath = path.join(extDir, 'manifest.json');
|
|
347
|
+
let manifest;
|
|
348
|
+
try {
|
|
349
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
350
|
+
manifest = JSON.parse(raw);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
result.result.failed.push({ scope, name, error: `manifest read: ${err.message}` });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const skills = Array.isArray(manifest.skills) ? manifest.skills : [];
|
|
356
|
+
try {
|
|
357
|
+
// W6.1/R4-H-02: pass the org/user-scope source dir explicitly so the
|
|
358
|
+
// helper reads from `~/.ijfw/extensions-{org,user}/<name>/skills`
|
|
359
|
+
// (or `~/.ijfw/extensions-{org,user}/@scope/pkg/skills` for scoped
|
|
360
|
+
// extensions per W6.2/R5-H-02) instead of the default project path.
|
|
361
|
+
const sourceDir = path.join(extDir, 'skills');
|
|
362
|
+
const dep = await deployExtensionSkillsToPlatforms(name, skills, projectRoot, { sourceDir });
|
|
363
|
+
await deployExtensionToAgentsMd(name, skills, projectRoot);
|
|
364
|
+
result.result.deployed.push({ scope, name, version: manifest.version, deployed: dep.deployed?.length ?? 0 });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
result.result.failed.push({ scope, name, error: `deploy: ${err.message}` });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// === B6: Registry commands ================================================
|
|
371
|
+
|
|
372
|
+
async function cmdTrustRegistry({ args }) {
|
|
373
|
+
const url = args.trim() || DEFAULT_REGISTRY_URL;
|
|
374
|
+
try {
|
|
375
|
+
const r = await refreshTrustFromRegistry(url);
|
|
376
|
+
if (!r.ok) return { ok: false, command: 'trust-registry', error: r.error };
|
|
377
|
+
const lines = [];
|
|
378
|
+
if (r.fromCache) {
|
|
379
|
+
lines.push('(loaded from cache — network unavailable)');
|
|
380
|
+
} else if (r.diff) {
|
|
381
|
+
for (const kid of r.diff.added) lines.push(`+ added: ${kid}`);
|
|
382
|
+
for (const kid of r.diff.removed) lines.push(`- removed (revoked): ${kid}`);
|
|
383
|
+
for (const kid of r.diff.unchanged) lines.push(` unchanged: ${kid}`);
|
|
384
|
+
for (const kid of r.diff.rejected) lines.push(`! rejected: ${kid}`);
|
|
385
|
+
if (lines.length === 0) lines.push('registry applied — no changes');
|
|
386
|
+
}
|
|
387
|
+
for (const w of (r.warnings || [])) lines.push(`[warn] ${w}`);
|
|
388
|
+
return { ok: true, command: 'trust-registry', result: { url, diff: r.diff, fromCache: r.fromCache, lines } };
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return { ok: false, command: 'trust-registry', error: err.message };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cmdRegistryStatus() {
|
|
395
|
+
try {
|
|
396
|
+
const cached = await readCachedRegistry();
|
|
397
|
+
if (!cached.registry) {
|
|
398
|
+
return { ok: true, command: 'registry-status', result: { cached: false, message: 'no cached registry' } };
|
|
399
|
+
}
|
|
400
|
+
const ageMs = cached.cachedAt ? Date.now() - cached.cachedAt : null;
|
|
401
|
+
const ageHours = ageMs !== null ? (ageMs / 3600000).toFixed(1) : null;
|
|
402
|
+
const body = JSON.stringify(cached.registry);
|
|
403
|
+
const sizeBytes = Buffer.byteLength(body, 'utf8');
|
|
404
|
+
const sigStatus = verifyRegistry(body);
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
command: 'registry-status',
|
|
408
|
+
result: {
|
|
409
|
+
cached: true,
|
|
410
|
+
stale: cached.stale,
|
|
411
|
+
cached_at: cached.cachedAt ? new Date(cached.cachedAt).toISOString() : null,
|
|
412
|
+
age_hours: ageHours,
|
|
413
|
+
size_bytes: sizeBytes,
|
|
414
|
+
signature_valid: sigStatus.valid,
|
|
415
|
+
signature_reason: sigStatus.reason,
|
|
416
|
+
publisher_count: Object.keys(cached.registry.publishers || {}).length,
|
|
417
|
+
revoked_count: (cached.registry.revoked || []).length,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return { ok: false, command: 'registry-status', error: err.message };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function cmdKeygenMeta({ args }) {
|
|
426
|
+
const author = args.trim();
|
|
427
|
+
if (!author) return { ok: false, command: 'keygen-meta', error: 'missing author name; usage: keygen-meta <author>' };
|
|
428
|
+
try {
|
|
429
|
+
const r = await keygenMeta(author);
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
command: 'keygen-meta',
|
|
433
|
+
result: { keyId: r.keyId, publicKey: r.publicKey, dir: r.dir },
|
|
434
|
+
};
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return { ok: false, command: 'keygen-meta', error: err.message };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function cmdSignRegistry({ args }) {
|
|
441
|
+
const registryPath = args.trim();
|
|
442
|
+
if (!registryPath) return { ok: false, command: 'sign-registry', error: 'missing path; usage: sign-registry <path>' };
|
|
443
|
+
try {
|
|
444
|
+
const r = await signRegistry(registryPath);
|
|
445
|
+
return r.ok
|
|
446
|
+
? { ok: true, command: 'sign-registry', result: { path: registryPath } }
|
|
447
|
+
: { ok: false, command: 'sign-registry', error: r.error };
|
|
448
|
+
} catch (err) {
|
|
449
|
+
return { ok: false, command: 'sign-registry', error: err.message };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function cmdVerifyRegistry({ args }) {
|
|
454
|
+
const registryPath = args.trim();
|
|
455
|
+
if (!registryPath) return { ok: false, command: 'verify-registry', error: 'missing path; usage: verify-registry <path>' };
|
|
456
|
+
try {
|
|
457
|
+
const r = await verifyRegistryFile(registryPath);
|
|
458
|
+
return {
|
|
459
|
+
ok: r.ok,
|
|
460
|
+
command: 'verify-registry',
|
|
461
|
+
result: { path: registryPath, valid: r.valid, reason: r.reason },
|
|
462
|
+
};
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return { ok: false, command: 'verify-registry', error: err.message };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// === B8: Key rotation + revocation =========================================
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* rotate-keys <oldKeyId> <newKeyId> [--out <file>]
|
|
472
|
+
*
|
|
473
|
+
* Loads both keypairs from ~/.ijfw/keys/<keyId>/, produces a rotation token
|
|
474
|
+
* signed by the old private key, writes JSON to --out or stdout.
|
|
475
|
+
*/
|
|
476
|
+
async function cmdRotateKeys({ args }) {
|
|
477
|
+
const tokens = String(args || '').split(/\s+/).filter(Boolean);
|
|
478
|
+
|
|
479
|
+
// Extract --out flag
|
|
480
|
+
let outFile = null;
|
|
481
|
+
const keep = [];
|
|
482
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
483
|
+
if (tokens[i] === '--out' && tokens[i + 1]) {
|
|
484
|
+
outFile = tokens[i + 1];
|
|
485
|
+
i++;
|
|
486
|
+
} else {
|
|
487
|
+
keep.push(tokens[i]);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const [oldKeyId, newKeyId] = keep;
|
|
492
|
+
if (!oldKeyId || !newKeyId) {
|
|
493
|
+
return { ok: false, command: 'rotate-keys', error: 'usage: rotate-keys <oldKeyId> <newKeyId> [--out <file>]' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const oldKp = await loadPublisherKeypair(oldKeyId);
|
|
497
|
+
if (!oldKp) return { ok: false, command: 'rotate-keys', error: `old keypair not found: ${oldKeyId}` };
|
|
498
|
+
|
|
499
|
+
const newKp = await loadPublisherKeypair(newKeyId);
|
|
500
|
+
if (!newKp) return { ok: false, command: 'rotate-keys', error: `new keypair not found: ${newKeyId}` };
|
|
501
|
+
|
|
502
|
+
let token;
|
|
503
|
+
try {
|
|
504
|
+
token = signRotationToken(oldKp.privateKey, newKp.publicKey);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
return { ok: false, command: 'rotate-keys', error: `sign failed: ${err.message}` };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const json = JSON.stringify(token, null, 2) + '\n';
|
|
510
|
+
|
|
511
|
+
if (outFile) {
|
|
512
|
+
try {
|
|
513
|
+
await fs.writeFile(path.resolve(outFile), json, 'utf8');
|
|
514
|
+
} catch (err) {
|
|
515
|
+
return { ok: false, command: 'rotate-keys', error: `write failed: ${err.message}` };
|
|
516
|
+
}
|
|
517
|
+
return { ok: true, command: 'rotate-keys', result: { token, out: path.resolve(outFile) } };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { ok: true, command: 'rotate-keys', result: { token } };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* verify-rotation-token <file>
|
|
525
|
+
*
|
|
526
|
+
* Reads a rotation token JSON, looks up the old public key from the local
|
|
527
|
+
* trusted-publishers store (or ~/.ijfw/keys/<oldKeyId>/public.pem as fallback),
|
|
528
|
+
* calls verifyRotationToken, prints verdict.
|
|
529
|
+
*/
|
|
530
|
+
async function cmdVerifyRotationToken({ args }) {
|
|
531
|
+
const filePath = String(args || '').trim();
|
|
532
|
+
if (!filePath) {
|
|
533
|
+
return { ok: false, command: 'verify-rotation-token', error: 'usage: verify-rotation-token <file>' };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let raw;
|
|
537
|
+
try {
|
|
538
|
+
raw = await fs.readFile(path.resolve(filePath), 'utf8');
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return { ok: false, command: 'verify-rotation-token', error: `read failed: ${err.message}` };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let token;
|
|
544
|
+
try {
|
|
545
|
+
token = JSON.parse(raw);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
return { ok: false, command: 'verify-rotation-token', error: `JSON parse failed: ${err.message}` };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const oldKeyId = token && token.old_key_id;
|
|
551
|
+
if (!oldKeyId) {
|
|
552
|
+
return { ok: false, command: 'verify-rotation-token', error: 'token missing old_key_id' };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Look up old public key: trusted-publishers store first, then key-dir fallback.
|
|
556
|
+
let oldPublicKey = null;
|
|
557
|
+
const store = await readTrustedPublishers();
|
|
558
|
+
const entry = store.publishers && store.publishers[oldKeyId];
|
|
559
|
+
if (entry && entry.publicKey) {
|
|
560
|
+
oldPublicKey = entry.publicKey;
|
|
561
|
+
} else {
|
|
562
|
+
// Fallback: key may be on disk even if not in trust store (already revoked).
|
|
563
|
+
const kp = await loadPublisherKeypair(oldKeyId);
|
|
564
|
+
if (kp) oldPublicKey = kp.publicKey;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!oldPublicKey) {
|
|
568
|
+
return { ok: false, command: 'verify-rotation-token', error: `old public key not found for keyId: ${oldKeyId}` };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const verdict = verifyRotationToken(token, oldPublicKey);
|
|
572
|
+
return {
|
|
573
|
+
ok: verdict.valid,
|
|
574
|
+
command: 'verify-rotation-token',
|
|
575
|
+
result: {
|
|
576
|
+
valid: verdict.valid,
|
|
577
|
+
reason: verdict.reason,
|
|
578
|
+
old_key_id: token.old_key_id,
|
|
579
|
+
new_key_id: token.new_key_id,
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function cmdActivate({ args, projectRoot }) {
|
|
585
|
+
const name = args && args.trim();
|
|
586
|
+
if (!name) return { ok: false, command: 'activate', error: 'missing extension name; usage: activate <name>' };
|
|
587
|
+
try {
|
|
588
|
+
const { findInstalledManifest, writeActiveExtension } = await import('../active-extension-writer.js');
|
|
589
|
+
const lookup = await findInstalledManifest(name, projectRoot);
|
|
590
|
+
if (!lookup.ok) return { ok: false, command: 'activate', error: lookup.error };
|
|
591
|
+
const result = await writeActiveExtension(lookup.manifest, lookup.scope);
|
|
592
|
+
if (!result.ok) return { ok: false, command: 'activate', error: result.error };
|
|
593
|
+
return { ok: true, command: 'activate', result: { name, scope: lookup.scope, path: result.path } };
|
|
594
|
+
} catch (err) {
|
|
595
|
+
return { ok: false, command: 'activate', error: err.message };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function cmdDeactivate() {
|
|
600
|
+
try {
|
|
601
|
+
const { clearActiveExtension } = await import('../active-extension-writer.js');
|
|
602
|
+
const r = await clearActiveExtension();
|
|
603
|
+
return { ok: r.ok, command: 'deactivate', result: { removed: r.removed } };
|
|
604
|
+
} catch (err) {
|
|
605
|
+
return { ok: false, command: 'deactivate', error: err.message };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export async function extensionDispatch({ command, args = '', projectRoot }) {
|
|
610
|
+
const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
|
|
611
|
+
switch (command) {
|
|
612
|
+
case 'add': return cmdAdd(ctx);
|
|
613
|
+
case 'list': return cmdList(ctx);
|
|
614
|
+
case 'remove': return cmdRemove(ctx);
|
|
615
|
+
case 'audit': return cmdAudit(ctx);
|
|
616
|
+
case 'deploy-lazy': return cmdDeployLazy(ctx);
|
|
617
|
+
case 'keygen': return cmdKeygen(ctx);
|
|
618
|
+
case 'trust': return cmdTrust(ctx);
|
|
619
|
+
case 'untrust': return cmdUntrust(ctx);
|
|
620
|
+
case 'trusted': return cmdTrusted(ctx);
|
|
621
|
+
case 'activate': return cmdActivate(ctx);
|
|
622
|
+
case 'deactivate': return cmdDeactivate(ctx);
|
|
623
|
+
case 'trust-registry': return cmdTrustRegistry(ctx);
|
|
624
|
+
case 'registry-status': return cmdRegistryStatus(ctx);
|
|
625
|
+
case 'keygen-meta': return cmdKeygenMeta(ctx);
|
|
626
|
+
case 'sign-registry': return cmdSignRegistry(ctx);
|
|
627
|
+
case 'verify-registry': return cmdVerifyRegistry(ctx);
|
|
628
|
+
case 'rotate-keys': return cmdRotateKeys(ctx);
|
|
629
|
+
case 'verify-rotation-token': return cmdVerifyRotationToken(ctx);
|
|
630
|
+
default:
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
command,
|
|
634
|
+
error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate | trust-registry | registry-status | keygen-meta | sign-registry | verify-registry | rotate-keys | verify-rotation-token`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|