@ijfw/memory-server 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +201 -0
- package/src/dashboard-server.js +107 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +38 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/active-cli.js — IJFW v1.4.3 W9-B (B18)
|
|
3
|
+
*
|
|
4
|
+
* Frozen CLI module contract:
|
|
5
|
+
* export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
|
|
6
|
+
* export const subcommandHelp — { '<subcommand>': 'one-line description' }
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* active --check — report current active extension + last-writer IDE + divergence
|
|
10
|
+
* activate <name> [--ide <id>] [--strict-ide]
|
|
11
|
+
* — activate <name> stamping the host IDE id; refuse
|
|
12
|
+
* activation when --strict-ide is set AND a different
|
|
13
|
+
* IDE is the current writer of stale state.
|
|
14
|
+
*
|
|
15
|
+
* Phase D wires these into `dispatch/extension.js`'s main switch by iterating
|
|
16
|
+
* Object.entries(handlers). Until then, this module is callable in isolation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
findInstalledManifest,
|
|
25
|
+
writeActiveExtension,
|
|
26
|
+
detectCrossIdeDivergence,
|
|
27
|
+
} from '../active-extension-writer.js';
|
|
28
|
+
import { detectIde } from '../ide-detect.js';
|
|
29
|
+
|
|
30
|
+
function homeFromCtx(ctx) {
|
|
31
|
+
if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
|
|
32
|
+
if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectRootFromCtx(ctx) {
|
|
37
|
+
if (ctx && typeof ctx.projectRoot === 'string') return ctx.projectRoot;
|
|
38
|
+
return process.cwd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(args) {
|
|
42
|
+
// Accept either a token array (preferred per registry-cli contract) or a
|
|
43
|
+
// raw string (fallback for direct callers).
|
|
44
|
+
let tokens;
|
|
45
|
+
if (Array.isArray(args)) {
|
|
46
|
+
tokens = args.slice();
|
|
47
|
+
} else if (typeof args === 'string') {
|
|
48
|
+
tokens = args.split(/\s+/).filter(Boolean);
|
|
49
|
+
} else {
|
|
50
|
+
tokens = [];
|
|
51
|
+
}
|
|
52
|
+
const flags = { check: false, strictIde: false, ide: null };
|
|
53
|
+
const positional = [];
|
|
54
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
55
|
+
const t = tokens[i];
|
|
56
|
+
if (t === '--check') { flags.check = true; continue; }
|
|
57
|
+
if (t === '--strict-ide') { flags.strictIde = true; continue; }
|
|
58
|
+
if (t === '--ide' && tokens[i + 1]) {
|
|
59
|
+
flags.ide = tokens[i + 1];
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
positional.push(t);
|
|
64
|
+
}
|
|
65
|
+
return { positional, flags };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function activeHandler(args, ctx) {
|
|
69
|
+
const { flags } = parseArgs(args);
|
|
70
|
+
const home = homeFromCtx(ctx) || process.env.HOME || homedir();
|
|
71
|
+
if (!flags.check) {
|
|
72
|
+
return { ok: false, error: "active: --check required (usage: active --check)" };
|
|
73
|
+
}
|
|
74
|
+
// Read current active.json (best-effort).
|
|
75
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
76
|
+
let active = null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(activePath, 'utf8');
|
|
79
|
+
active = JSON.parse(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
// null
|
|
82
|
+
}
|
|
83
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: home });
|
|
84
|
+
const out = {
|
|
85
|
+
active: active ? {
|
|
86
|
+
name: active.name ?? null,
|
|
87
|
+
scope: active.scope ?? null,
|
|
88
|
+
activated_at: active.activated_at ?? null,
|
|
89
|
+
activated_by_ide: active.activated_by_ide ?? null,
|
|
90
|
+
activated_by_pid: active.activated_by_pid ?? null,
|
|
91
|
+
} : null,
|
|
92
|
+
current_ide: verdict.current_ide,
|
|
93
|
+
divergent: !!verdict.divergent,
|
|
94
|
+
last_writer: verdict.last_writer ?? null,
|
|
95
|
+
age_seconds: verdict.age_seconds ?? null,
|
|
96
|
+
};
|
|
97
|
+
return { ok: true, output: JSON.stringify(out, null, 2) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function activateHandler(args, ctx) {
|
|
101
|
+
const { positional, flags } = parseArgs(args);
|
|
102
|
+
const name = positional[0];
|
|
103
|
+
if (!name || typeof name !== 'string') {
|
|
104
|
+
return { ok: false, error: 'activate: extension name required (usage: activate <name> [--ide <id>] [--strict-ide])' };
|
|
105
|
+
}
|
|
106
|
+
const home = homeFromCtx(ctx) || process.env.HOME || homedir();
|
|
107
|
+
const projectRoot = projectRootFromCtx(ctx);
|
|
108
|
+
|
|
109
|
+
const ideId = flags.ide && /^[a-z0-9-]+$/.test(flags.ide) ? flags.ide : detectIde();
|
|
110
|
+
|
|
111
|
+
// Strict-IDE gate: if active.json was last touched by a different IDE AND
|
|
112
|
+
// divergence semantic flags it, refuse before writing.
|
|
113
|
+
if (flags.strictIde) {
|
|
114
|
+
const verdict = await detectCrossIdeDivergence({ homeDir: home, currentIde: ideId });
|
|
115
|
+
if (verdict && verdict.divergent) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `[ijfw] activate refused: --strict-ide and active extension last activated by '${verdict.last_writer}'`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lookup = await findInstalledManifest(name, projectRoot, { homeDir: home });
|
|
124
|
+
if (!lookup.ok) return { ok: false, error: lookup.error };
|
|
125
|
+
const result = await writeActiveExtension(lookup.manifest, lookup.scope, { homeDir: home, ideId });
|
|
126
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
output: JSON.stringify({ name, scope: lookup.scope, activated_by_ide: ideId, path: result.path }, null, 2),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const handlers = Object.freeze({
|
|
134
|
+
'active': activeHandler,
|
|
135
|
+
'activate': activateHandler,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const subcommandHelp = Object.freeze({
|
|
139
|
+
'active': 'active --check — report current active extension + cross-IDE divergence status',
|
|
140
|
+
'activate': 'activate <name> [--ide <id>] [--strict-ide] — activate extension and stamp host IDE',
|
|
141
|
+
});
|
|
@@ -606,8 +606,46 @@ async function cmdDeactivate() {
|
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// v1.4.3 Phase D — CLI module registry. Each module exports the frozen
|
|
610
|
+
// { handlers, subcommandHelp } shape; we union their handlers into a single
|
|
611
|
+
// lookup and consult it BEFORE the legacy switch, so new --ide / --backend /
|
|
612
|
+
// quota / federation subcommands take precedence.
|
|
613
|
+
let _v143Handlers = null;
|
|
614
|
+
async function loadV143Handlers() {
|
|
615
|
+
if (_v143Handlers !== null) return _v143Handlers;
|
|
616
|
+
const [registry, signer, quota, active] = await Promise.all([
|
|
617
|
+
import('./registry-cli.js'),
|
|
618
|
+
import('./signer-cli.js'),
|
|
619
|
+
import('./quota-cli.js'),
|
|
620
|
+
import('./active-cli.js'),
|
|
621
|
+
]);
|
|
622
|
+
_v143Handlers = Object.assign(
|
|
623
|
+
Object.create(null),
|
|
624
|
+
registry.handlers || {},
|
|
625
|
+
signer.handlers || {},
|
|
626
|
+
quota.handlers || {},
|
|
627
|
+
active.handlers || {},
|
|
628
|
+
);
|
|
629
|
+
return _v143Handlers;
|
|
630
|
+
}
|
|
631
|
+
|
|
609
632
|
export async function extensionDispatch({ command, args = '', projectRoot }) {
|
|
610
633
|
const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
|
|
634
|
+
|
|
635
|
+
// v1.4.3 Phase D — CLI modules take precedence over legacy switch.
|
|
636
|
+
const handlers = await loadV143Handlers();
|
|
637
|
+
if (typeof handlers[command] === 'function') {
|
|
638
|
+
try {
|
|
639
|
+
const r = await handlers[command](ctx.args, ctx);
|
|
640
|
+
if (r && r.ok === false) {
|
|
641
|
+
return { ok: false, command, error: r.error || 'unknown error' };
|
|
642
|
+
}
|
|
643
|
+
return { ok: true, command, result: r };
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return { ok: false, command, error: err && err.message ? err.message : String(err) };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
611
649
|
switch (command) {
|
|
612
650
|
case 'add': return cmdAdd(ctx);
|
|
613
651
|
case 'list': return cmdList(ctx);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/quota-cli.js — IJFW v1.4.3 W9-A3 (B16)
|
|
3
|
+
*
|
|
4
|
+
* Frozen CLI module contract:
|
|
5
|
+
* export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
|
|
6
|
+
* export const subcommandHelp — { '<subcommand>': 'one-line description' }
|
|
7
|
+
*
|
|
8
|
+
* Phase D wires these into `dispatch/extension.js`'s main switch by iterating
|
|
9
|
+
* Object.entries(handlers). Until then, this module is callable in isolation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getQuotaUsage, resetExtensionQuotas } from '../extension-quota-tracker.js';
|
|
13
|
+
|
|
14
|
+
function homeFromCtx(ctx) {
|
|
15
|
+
if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
|
|
16
|
+
if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
|
|
17
|
+
return undefined; // tracker will fall back to env HOME / homedir()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const handlers = Object.freeze({
|
|
21
|
+
'quota-status': async (args, ctx) => {
|
|
22
|
+
const name = Array.isArray(args) ? args[0] : undefined;
|
|
23
|
+
if (!name || typeof name !== 'string') {
|
|
24
|
+
return { ok: false, error: 'quota-status: extension name required' };
|
|
25
|
+
}
|
|
26
|
+
const usage = await getQuotaUsage(name, { homeDir: homeFromCtx(ctx) });
|
|
27
|
+
return { ok: true, output: JSON.stringify(usage, null, 2) };
|
|
28
|
+
},
|
|
29
|
+
'quota-reset': async (args, ctx) => {
|
|
30
|
+
const name = Array.isArray(args) ? args[0] : undefined;
|
|
31
|
+
if (!name || typeof name !== 'string') {
|
|
32
|
+
return { ok: false, error: 'quota-reset: extension name required' };
|
|
33
|
+
}
|
|
34
|
+
await resetExtensionQuotas(name, { homeDir: homeFromCtx(ctx) });
|
|
35
|
+
return { ok: true, output: 'reset' };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const subcommandHelp = Object.freeze({
|
|
40
|
+
'quota-status': 'quota-status [<ext-name>] — print current usage vs limits',
|
|
41
|
+
'quota-reset': 'quota-reset <ext-name> — admin: manually reset counters',
|
|
42
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/registry-cli.js — IJFW v1.4.3/B14 + B17 registry CLI handlers.
|
|
3
|
+
*
|
|
4
|
+
* Frozen export contract (Wave A invariants):
|
|
5
|
+
* export const handlers = {
|
|
6
|
+
* '<subcommand>': async (args, ctx) => ({ ok, output?, error? }),
|
|
7
|
+
* ...
|
|
8
|
+
* };
|
|
9
|
+
* export const subcommandHelp = {
|
|
10
|
+
* '<subcommand>': 'one-line description',
|
|
11
|
+
* ...
|
|
12
|
+
* };
|
|
13
|
+
*
|
|
14
|
+
* Subcommands owned by this module:
|
|
15
|
+
* - registry-list
|
|
16
|
+
* - registry-add <name> <url> [<meta-key-path>]
|
|
17
|
+
* - registry-remove <name>
|
|
18
|
+
* - registry-prioritize <name> <position>
|
|
19
|
+
* - registry-status
|
|
20
|
+
* - trust-registry --emergency [<url>]
|
|
21
|
+
*
|
|
22
|
+
* Phase D will wire these into dispatch/extension.js via
|
|
23
|
+
* `Object.entries(handlers)`. Each handler accepts a token array (already
|
|
24
|
+
* tokenized + dequoted) and the dispatch ctx (cwd, homedir, etc.).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { homedir as osHomedir } from 'node:os';
|
|
30
|
+
import { createPublicKey } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
loadRegistrySources,
|
|
34
|
+
refreshTrustFromAllRegistries,
|
|
35
|
+
readSourceCache,
|
|
36
|
+
RegistrySourcesError,
|
|
37
|
+
META_KEY_SENTINEL,
|
|
38
|
+
IJFW_REGISTRY_META_KEY_PEM,
|
|
39
|
+
SOURCE_NAME_PATTERN,
|
|
40
|
+
} from '../extension-registry.js';
|
|
41
|
+
|
|
42
|
+
function homedir(ctx) {
|
|
43
|
+
return (ctx && ctx.homedir) || osHomedir();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function registriesConfigPath(ctx) {
|
|
47
|
+
return join(homedir(ctx), '.ijfw', 'registries.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRegistriesFile(ctx) {
|
|
51
|
+
const path = registriesConfigPath(ctx);
|
|
52
|
+
try {
|
|
53
|
+
const raw = await readFile(path, 'utf8');
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.registries)) {
|
|
56
|
+
throw new Error('registries.json: invalid shape');
|
|
57
|
+
}
|
|
58
|
+
return { path, doc: parsed };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err && err.code === 'ENOENT') {
|
|
61
|
+
return {
|
|
62
|
+
path,
|
|
63
|
+
doc: { schema_version: '1.0', registries: [] },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function writeRegistriesFile(ctx, doc) {
|
|
71
|
+
const path = registriesConfigPath(ctx);
|
|
72
|
+
await mkdir(join(homedir(ctx), '.ijfw'), { recursive: true });
|
|
73
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
74
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
75
|
+
const { rename } = await import('node:fs/promises');
|
|
76
|
+
await rename(tmp, path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tokenize(args) {
|
|
80
|
+
if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
|
|
81
|
+
const s = String(args || '').trim();
|
|
82
|
+
if (!s) return [];
|
|
83
|
+
// Simple whitespace split for CLI surface.
|
|
84
|
+
return s.split(/\s+/);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Handlers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* registry-list — print all configured registries in priority order.
|
|
93
|
+
*/
|
|
94
|
+
async function registryList(_args, _ctx) {
|
|
95
|
+
let sources;
|
|
96
|
+
try {
|
|
97
|
+
sources = await loadRegistrySources();
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof RegistrySourcesError) {
|
|
100
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
const lines = ['IJFW Registry Sources (priority order):', ''];
|
|
105
|
+
for (const src of sources) {
|
|
106
|
+
const usingEmbedded =
|
|
107
|
+
src.meta_key_pem === IJFW_REGISTRY_META_KEY_PEM ? ' [meta_key=<embedded>]' : '';
|
|
108
|
+
lines.push(` [${src.priority}] ${src.name}`);
|
|
109
|
+
lines.push(` url: ${src.url}`);
|
|
110
|
+
lines.push(
|
|
111
|
+
` ttl: publishers=${Math.round(src.publisher_ttl_ms / 1000)}s revocation=${Math.round(src.revocation_ttl_ms / 1000)}s${usingEmbedded}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, output: lines.join('\n') };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* registry-add <name> <url> [<meta-key-path>]
|
|
119
|
+
*
|
|
120
|
+
* Appends to ~/.ijfw/registries.json. Validates name/url/PEM. Refuses
|
|
121
|
+
* duplicate names. Persists in priority-append order.
|
|
122
|
+
*/
|
|
123
|
+
async function registryAdd(args, ctx) {
|
|
124
|
+
const tokens = tokenize(args);
|
|
125
|
+
if (tokens.length < 2) {
|
|
126
|
+
return { ok: false, error: 'usage: registry-add <name> <url> [<meta-key-path>]' };
|
|
127
|
+
}
|
|
128
|
+
const [name, url, metaKeyPath] = tokens;
|
|
129
|
+
|
|
130
|
+
if (!SOURCE_NAME_PATTERN.test(name)) {
|
|
131
|
+
return { ok: false, error: `name must match /^[a-z0-9_-]+$/, got '${name}'` };
|
|
132
|
+
}
|
|
133
|
+
let parsedUrl;
|
|
134
|
+
try {
|
|
135
|
+
parsedUrl = new URL(url);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, error: `invalid URL: ${url}` };
|
|
138
|
+
}
|
|
139
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
140
|
+
return { ok: false, error: `url must use HTTPS, got ${parsedUrl.protocol}` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let metaKeyPem = META_KEY_SENTINEL;
|
|
144
|
+
if (metaKeyPath) {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = await readFile(metaKeyPath, 'utf8');
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { ok: false, error: `cannot read meta-key at ${metaKeyPath}: ${err.message}` };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
createPublicKey(raw);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { ok: false, error: `meta-key PEM parse failed: ${err.message}` };
|
|
155
|
+
}
|
|
156
|
+
metaKeyPem = raw;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
160
|
+
if (doc.registries.some((r) => r.name === name)) {
|
|
161
|
+
return { ok: false, error: `registry '${name}' already exists` };
|
|
162
|
+
}
|
|
163
|
+
const nextPriority =
|
|
164
|
+
doc.registries.reduce((max, r) => (typeof r.priority === 'number' ? Math.max(max, r.priority) : max), -1) + 1;
|
|
165
|
+
doc.registries.push({
|
|
166
|
+
name,
|
|
167
|
+
url,
|
|
168
|
+
meta_key_pem: metaKeyPem,
|
|
169
|
+
priority: nextPriority,
|
|
170
|
+
publisher_ttl_ms: 24 * 60 * 60 * 1000,
|
|
171
|
+
revocation_ttl_ms: 5 * 60 * 1000,
|
|
172
|
+
});
|
|
173
|
+
await writeRegistriesFile(ctx, doc);
|
|
174
|
+
return { ok: true, output: `added registry '${name}' at priority ${nextPriority}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* registry-remove <name>
|
|
179
|
+
*/
|
|
180
|
+
async function registryRemove(args, ctx) {
|
|
181
|
+
const tokens = tokenize(args);
|
|
182
|
+
if (tokens.length < 1) {
|
|
183
|
+
return { ok: false, error: 'usage: registry-remove <name>' };
|
|
184
|
+
}
|
|
185
|
+
const name = tokens[0];
|
|
186
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
187
|
+
const idx = doc.registries.findIndex((r) => r.name === name);
|
|
188
|
+
if (idx === -1) {
|
|
189
|
+
return { ok: false, error: `registry '${name}' not found` };
|
|
190
|
+
}
|
|
191
|
+
doc.registries.splice(idx, 1);
|
|
192
|
+
await writeRegistriesFile(ctx, doc);
|
|
193
|
+
return { ok: true, output: `removed registry '${name}'` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* registry-prioritize <name> <position>
|
|
198
|
+
*/
|
|
199
|
+
async function registryPrioritize(args, ctx) {
|
|
200
|
+
const tokens = tokenize(args);
|
|
201
|
+
if (tokens.length < 2) {
|
|
202
|
+
return { ok: false, error: 'usage: registry-prioritize <name> <position>' };
|
|
203
|
+
}
|
|
204
|
+
const [name, posRaw] = tokens;
|
|
205
|
+
const position = Number.parseInt(posRaw, 10);
|
|
206
|
+
if (!Number.isFinite(position)) {
|
|
207
|
+
return { ok: false, error: `position must be a non-negative integer, got '${posRaw}'` };
|
|
208
|
+
}
|
|
209
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
210
|
+
const target = doc.registries.find((r) => r.name === name);
|
|
211
|
+
if (!target) {
|
|
212
|
+
return { ok: false, error: `registry '${name}' not found` };
|
|
213
|
+
}
|
|
214
|
+
target.priority = position;
|
|
215
|
+
doc.registries.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
216
|
+
// Renumber to ensure unique priorities.
|
|
217
|
+
doc.registries.forEach((r, i) => {
|
|
218
|
+
r.priority = i;
|
|
219
|
+
});
|
|
220
|
+
await writeRegistriesFile(ctx, doc);
|
|
221
|
+
return { ok: true, output: `set '${name}' to priority ${position} (renumbered)` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* registry-status — per-source cache age + fetch state.
|
|
226
|
+
*/
|
|
227
|
+
async function registryStatus(_args, _ctx) {
|
|
228
|
+
let sources;
|
|
229
|
+
try {
|
|
230
|
+
sources = await loadRegistrySources();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err instanceof RegistrySourcesError) {
|
|
233
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
const lines = ['IJFW Registry Status:', ''];
|
|
238
|
+
for (const src of sources) {
|
|
239
|
+
const { cache, corrupt, reason } = await readSourceCache(src);
|
|
240
|
+
lines.push(` [${src.priority}] ${src.name} (${src.url})`);
|
|
241
|
+
if (corrupt) {
|
|
242
|
+
lines.push(` CORRUPT (${reason}) — next refresh will rebuild`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const pubAt = cache.publishers_fetched_at || '(never)';
|
|
246
|
+
const revAt = cache.revocation_fetched_at || '(never)';
|
|
247
|
+
const pubCount = cache.publishers ? Object.keys(cache.publishers).length : 0;
|
|
248
|
+
const revCount = Array.isArray(cache.revoked) ? cache.revoked.length : 0;
|
|
249
|
+
lines.push(` publishers_fetched_at: ${pubAt} (${pubCount} entries)`);
|
|
250
|
+
lines.push(` revocation_fetched_at: ${revAt} (${revCount} revoked)`);
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, output: lines.join('\n') };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* trust-registry --emergency [<url>] — bypass caches; force fresh fetch.
|
|
257
|
+
*
|
|
258
|
+
* Without --emergency, fall through to the regular split-TTL path.
|
|
259
|
+
*/
|
|
260
|
+
async function trustRegistry(args, _ctx) {
|
|
261
|
+
const tokens = tokenize(args);
|
|
262
|
+
const emergency = tokens.includes('--emergency');
|
|
263
|
+
const remaining = tokens.filter((t) => t !== '--emergency');
|
|
264
|
+
|
|
265
|
+
// Optional URL arg post-emergency. With a URL we target only that single
|
|
266
|
+
// source via loadRegistrySources()'s back-compat fall-back. Without, full
|
|
267
|
+
// federation refresh.
|
|
268
|
+
const url = remaining[0];
|
|
269
|
+
|
|
270
|
+
const opts = { emergency };
|
|
271
|
+
if (url) {
|
|
272
|
+
// Synthesize a one-shot source descriptor.
|
|
273
|
+
opts.sources = [
|
|
274
|
+
{
|
|
275
|
+
name: 'cli',
|
|
276
|
+
url,
|
|
277
|
+
meta_key_pem: IJFW_REGISTRY_META_KEY_PEM,
|
|
278
|
+
priority: 0,
|
|
279
|
+
publisher_ttl_ms: 24 * 60 * 60 * 1000,
|
|
280
|
+
revocation_ttl_ms: 5 * 60 * 1000,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
result = await refreshTrustFromAllRegistries(opts);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof RegistrySourcesError) {
|
|
290
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
291
|
+
}
|
|
292
|
+
return { ok: false, error: err.message };
|
|
293
|
+
}
|
|
294
|
+
if (!result.ok) {
|
|
295
|
+
return { ok: false, error: result.error || 'refresh failed' };
|
|
296
|
+
}
|
|
297
|
+
const summary = result.multi
|
|
298
|
+
? `sources=${result.multi.sources.length} global_revocations=${result.multi.global_revocations.length} conflicts=${result.multi.conflicts.length}`
|
|
299
|
+
: 'no diff';
|
|
300
|
+
const warnings = result.warnings && result.warnings.length > 0
|
|
301
|
+
? `\nwarnings:\n${result.warnings.map((w) => ` - ${w}`).join('\n')}`
|
|
302
|
+
: '';
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
output: `trust-registry${emergency ? ' --emergency' : ''}: ${summary}${warnings}`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Frozen exports (Wave-A invariant)
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export const handlers = Object.freeze({
|
|
314
|
+
'registry-list': registryList,
|
|
315
|
+
'registry-add': registryAdd,
|
|
316
|
+
'registry-remove': registryRemove,
|
|
317
|
+
'registry-prioritize': registryPrioritize,
|
|
318
|
+
'registry-status': registryStatus,
|
|
319
|
+
'trust-registry': trustRegistry,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
export const subcommandHelp = Object.freeze({
|
|
323
|
+
'registry-list': 'list configured registries in priority order',
|
|
324
|
+
'registry-add': 'add <name> <url> [<meta-key-path>] — append a registry source',
|
|
325
|
+
'registry-remove': 'remove <name> — remove a registry source by name',
|
|
326
|
+
'registry-prioritize': 'prioritize <name> <position> — change a source\'s priority',
|
|
327
|
+
'registry-status': 'show per-source cache state (publishers + revocation TTLs)',
|
|
328
|
+
'trust-registry': 'refresh trust from all sources; --emergency bypasses cache',
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Helper for tests
|
|
332
|
+
export const _testInternals = Object.freeze({
|
|
333
|
+
registriesConfigPath,
|
|
334
|
+
readRegistriesFile,
|
|
335
|
+
writeRegistriesFile,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Avoid an "unused" lint hit on `stat` (kept for future use).
|
|
339
|
+
void stat;
|