@ijfw/memory-server 1.4.0 → 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/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- 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-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
package/src/dashboard-server.js
CHANGED
|
@@ -22,6 +22,8 @@ import { searchMemory } from './memory/search.js';
|
|
|
22
22
|
import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
|
|
23
23
|
import { PLACEHOLDER_HTML } from './design-companion.js';
|
|
24
24
|
import { listExtensions } from './extension-installer.js';
|
|
25
|
+
import { aggregateEvents, computeWarnBashBypass, readActiveManifest } from './dashboard-aggregator.js';
|
|
26
|
+
import { getQuotaUsage } from './extension-quota-tracker.js';
|
|
25
27
|
|
|
26
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
29
|
// REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
|
|
@@ -688,6 +690,354 @@ export async function startServer(options = {}) {
|
|
|
688
690
|
res.end(JSON.stringify(config));
|
|
689
691
|
}],
|
|
690
692
|
|
|
693
|
+
// ---------- extensions: installed (B9) ----------
|
|
694
|
+
// Enumerate ~/.ijfw/state-org/extension-registry.json,
|
|
695
|
+
// ~/.ijfw/state-user/extension-registry.json, and project-scope registry.
|
|
696
|
+
// Returns JSON list with name, scope, version, publisher_keyId, permissions,
|
|
697
|
+
// last_activated_time. Path-traversal defence: resolve + assert under HOME.
|
|
698
|
+
['/api/extensions/installed', async (req, res) => {
|
|
699
|
+
try {
|
|
700
|
+
const home = homedir();
|
|
701
|
+
// realpath both sides — on macOS /var/folders -> /private/var/folders is a symlink,
|
|
702
|
+
// so the registry's realpathed path won't show as under un-realpathed HOME.
|
|
703
|
+
let homeCanon;
|
|
704
|
+
try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
|
|
705
|
+
function isUnderHome(p) {
|
|
706
|
+
try {
|
|
707
|
+
const canon = realpathSync(p);
|
|
708
|
+
const rel = relative(homeCanon, canon);
|
|
709
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
710
|
+
} catch { return false; }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const REGISTRY_FILENAME = 'extension-registry.json';
|
|
714
|
+
const registryPaths = [
|
|
715
|
+
{ scope: 'org', path: join(home, '.ijfw', 'state-org', REGISTRY_FILENAME) },
|
|
716
|
+
{ scope: 'user', path: join(home, '.ijfw', 'state-user', REGISTRY_FILENAME) },
|
|
717
|
+
{ scope: 'project', path: join(REPO_ROOT, '.ijfw', 'state', REGISTRY_FILENAME) },
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
const seen = new Map();
|
|
721
|
+
for (const { scope, path: regPath } of registryPaths) {
|
|
722
|
+
// Path-traversal check on each registry path.
|
|
723
|
+
const resolvedReg = resolve(regPath);
|
|
724
|
+
const underHome = isUnderHome(resolvedReg) ||
|
|
725
|
+
resolvedReg.startsWith(resolve(REPO_ROOT));
|
|
726
|
+
if (!underHome) continue;
|
|
727
|
+
if (!existsSync(resolvedReg)) continue;
|
|
728
|
+
let registry;
|
|
729
|
+
try {
|
|
730
|
+
const raw = readFileSync(resolvedReg, 'utf8');
|
|
731
|
+
const parsed = JSON.parse(raw);
|
|
732
|
+
registry = Array.isArray(parsed.extensions) ? parsed.extensions : [];
|
|
733
|
+
} catch { continue; }
|
|
734
|
+
|
|
735
|
+
for (const e of registry) {
|
|
736
|
+
if (!e || !e.name || !e.version) continue;
|
|
737
|
+
const key = `${e.name}@${e.version}`;
|
|
738
|
+
if (seen.has(key)) continue;
|
|
739
|
+
const manifest = (e.manifest && typeof e.manifest === 'object') ? e.manifest : null;
|
|
740
|
+
seen.set(key, {
|
|
741
|
+
name: e.name,
|
|
742
|
+
scope,
|
|
743
|
+
version: e.version,
|
|
744
|
+
publisher_keyId: manifest ? (manifest.publisher_keyId || null) : null,
|
|
745
|
+
permissions: manifest ? (manifest.permissions || null) : null,
|
|
746
|
+
last_activated_time: e.last_activated_time || null,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
751
|
+
res.end(JSON.stringify({ extensions: Array.from(seen.values()) }));
|
|
752
|
+
} catch (err) {
|
|
753
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
754
|
+
res.end(JSON.stringify({ extensions: [], error: err.message }));
|
|
755
|
+
}
|
|
756
|
+
}],
|
|
757
|
+
|
|
758
|
+
// ---------- extensions: active (B9) ----------
|
|
759
|
+
['/api/extensions/active', async (req, res) => {
|
|
760
|
+
try {
|
|
761
|
+
const home = homedir();
|
|
762
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
763
|
+
const resolvedActive = resolve(activePath);
|
|
764
|
+
// Return {active:null} BEFORE realpath — realpathSync throws on non-existent paths.
|
|
765
|
+
if (!existsSync(resolvedActive)) {
|
|
766
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
767
|
+
res.end(JSON.stringify({ active: null }));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
// Path-traversal: realpath BOTH home and target so macOS /private symlinks
|
|
771
|
+
// (e.g. /var/folders -> /private/var/folders) resolve correctly AND symlinks
|
|
772
|
+
// pointing outside HOME are rejected.
|
|
773
|
+
let homeCanon;
|
|
774
|
+
try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
|
|
775
|
+
let activeCanon;
|
|
776
|
+
try {
|
|
777
|
+
activeCanon = realpathSync(resolvedActive);
|
|
778
|
+
} catch {
|
|
779
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
780
|
+
res.end(JSON.stringify({ error: 'path traversal rejected' }));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const rel = relative(homeCanon, activeCanon);
|
|
784
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
785
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
786
|
+
res.end(JSON.stringify({ error: 'path traversal rejected' }));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const raw = readFileSync(activeCanon, 'utf8');
|
|
790
|
+
const parsed = JSON.parse(raw);
|
|
791
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
792
|
+
res.end(JSON.stringify({ active: parsed }));
|
|
793
|
+
} catch (err) {
|
|
794
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
795
|
+
res.end(JSON.stringify({ active: null, error: err.message }));
|
|
796
|
+
}
|
|
797
|
+
}],
|
|
798
|
+
|
|
799
|
+
// ---------- extensions: events (B9) ----------
|
|
800
|
+
// Tail-streams ~/.ijfw/state/permission-events.jsonl with optional filters.
|
|
801
|
+
// Allowlisted query params: limit, extension, tool, denied.
|
|
802
|
+
// SSE mode: Accept: text/event-stream. JSON array otherwise.
|
|
803
|
+
// Never reads full file into memory: streams line-by-line.
|
|
804
|
+
['/api/extensions/events', async (req, res, url) => {
|
|
805
|
+
const ALLOWED_FILTER_KEYS = new Set(['limit', 'extension', 'tool', 'denied']);
|
|
806
|
+
// Reject any non-allowlisted filter key.
|
|
807
|
+
for (const key of url.searchParams.keys()) {
|
|
808
|
+
if (!ALLOWED_FILTER_KEYS.has(key)) {
|
|
809
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
810
|
+
res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const home = homedir();
|
|
816
|
+
const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
|
|
817
|
+
|
|
818
|
+
const rawLimit = url.searchParams.get('limit');
|
|
819
|
+
let limit = 200;
|
|
820
|
+
if (rawLimit !== null) {
|
|
821
|
+
const n = safeIntegerParam(rawLimit, 10_000);
|
|
822
|
+
if (n === null) {
|
|
823
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
824
|
+
res.end(JSON.stringify({ error: 'invalid limit' }));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
limit = n;
|
|
828
|
+
}
|
|
829
|
+
const filterExtension = url.searchParams.get('extension') || null;
|
|
830
|
+
const filterTool = url.searchParams.get('tool') || null;
|
|
831
|
+
const filterDenied = url.searchParams.has('denied')
|
|
832
|
+
? (url.searchParams.get('denied') !== 'false')
|
|
833
|
+
: null;
|
|
834
|
+
|
|
835
|
+
// Stream-tail: read only the last TAIL_CHUNK bytes, never slurp the full file.
|
|
836
|
+
// For permission-events.jsonl which rotates at 10_000 lines, 2MB covers
|
|
837
|
+
// many thousands of events without loading the entire file into memory.
|
|
838
|
+
const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
|
|
839
|
+
function tailEvents() {
|
|
840
|
+
if (!existsSync(eventsPath)) return [];
|
|
841
|
+
let st;
|
|
842
|
+
try { st = statSync(eventsPath); } catch { return []; }
|
|
843
|
+
if (st.size === 0) return [];
|
|
844
|
+
let lines = [];
|
|
845
|
+
try {
|
|
846
|
+
const fullBuf = readFileSync(eventsPath);
|
|
847
|
+
const slice = fullBuf.subarray(Math.max(0, fullBuf.length - TAIL_CHUNK));
|
|
848
|
+
const text = slice.toString('utf8');
|
|
849
|
+
lines = text.split('\n').filter(Boolean);
|
|
850
|
+
// If we sliced mid-line, the first element may be truncated — drop it.
|
|
851
|
+
if (fullBuf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
852
|
+
} catch { return []; }
|
|
853
|
+
|
|
854
|
+
const results = [];
|
|
855
|
+
for (let i = lines.length - 1; i >= 0 && results.length < limit; i--) {
|
|
856
|
+
let obj;
|
|
857
|
+
try { obj = JSON.parse(lines[i]); } catch { continue; }
|
|
858
|
+
if (filterExtension !== null && obj.extension !== filterExtension) continue;
|
|
859
|
+
if (filterTool !== null && obj.tool !== filterTool) continue;
|
|
860
|
+
if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
|
|
861
|
+
results.unshift(obj);
|
|
862
|
+
}
|
|
863
|
+
return results;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const isSSE = (req.headers['accept'] || '').includes('text/event-stream');
|
|
867
|
+
if (isSSE) {
|
|
868
|
+
res.writeHead(200, {
|
|
869
|
+
'Content-Type': 'text/event-stream',
|
|
870
|
+
'Cache-Control': 'no-cache',
|
|
871
|
+
'Connection': 'keep-alive',
|
|
872
|
+
'X-Accel-Buffering': 'no',
|
|
873
|
+
});
|
|
874
|
+
res.write(': connected\n\n');
|
|
875
|
+
// Send current tail as initial batch.
|
|
876
|
+
const initial = tailEvents();
|
|
877
|
+
for (const evt of initial) {
|
|
878
|
+
try { res.write(`data: ${JSON.stringify(evt)}\n\n`); } catch { break; }
|
|
879
|
+
}
|
|
880
|
+
// Watch for new events. lastLineCount must match what the watcher
|
|
881
|
+
// measures (tail-chunk lines), NOT the limited initial batch length —
|
|
882
|
+
// mismatching them causes a replay storm when the file is bigger than
|
|
883
|
+
// the limit.
|
|
884
|
+
let evtWatcher = null;
|
|
885
|
+
let lastLineCount = 0;
|
|
886
|
+
try {
|
|
887
|
+
const buf0 = readFileSync(eventsPath);
|
|
888
|
+
const slice0 = buf0.subarray(Math.max(0, buf0.length - TAIL_CHUNK));
|
|
889
|
+
let lines0 = slice0.toString('utf8').split('\n').filter(Boolean);
|
|
890
|
+
if (buf0.length > TAIL_CHUNK) lines0 = lines0.slice(1);
|
|
891
|
+
lastLineCount = lines0.length;
|
|
892
|
+
} catch { /* eventsPath missing or unreadable — start from 0 */ }
|
|
893
|
+
try {
|
|
894
|
+
evtWatcher = watch(existsSync(eventsPath) ? eventsPath : join(home, '.ijfw', 'state'), () => {
|
|
895
|
+
if (!existsSync(eventsPath)) return;
|
|
896
|
+
try {
|
|
897
|
+
// Use the tail-chunk reader (bounded read) rather than slurping the
|
|
898
|
+
// full file. At 10K lines × ~1-2KB each = 10-20MB sync read per watch
|
|
899
|
+
// event, which is unacceptable for a long-lived SSE connection.
|
|
900
|
+
try { statSync(eventsPath); } catch { return; }
|
|
901
|
+
const buf = (() => {
|
|
902
|
+
try { return readFileSync(eventsPath); } catch { return null; }
|
|
903
|
+
})();
|
|
904
|
+
if (!buf) return;
|
|
905
|
+
const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
|
|
906
|
+
const text = slice.toString('utf8');
|
|
907
|
+
let lines = text.split('\n').filter(Boolean);
|
|
908
|
+
if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
909
|
+
if (lines.length > lastLineCount) {
|
|
910
|
+
const newLines = lines.slice(lastLineCount);
|
|
911
|
+
lastLineCount = lines.length;
|
|
912
|
+
for (const line of newLines) {
|
|
913
|
+
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
914
|
+
if (filterExtension !== null && obj.extension !== filterExtension) continue;
|
|
915
|
+
if (filterTool !== null && obj.tool !== filterTool) continue;
|
|
916
|
+
if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
|
|
917
|
+
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
} catch {}
|
|
921
|
+
});
|
|
922
|
+
if (evtWatcher) evtWatcher.on('error', () => {});
|
|
923
|
+
} catch {}
|
|
924
|
+
req.on('close', () => {
|
|
925
|
+
if (evtWatcher) { try { evtWatcher.close(); } catch {} }
|
|
926
|
+
});
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// JSON array response.
|
|
931
|
+
const events = tailEvents();
|
|
932
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
933
|
+
res.end(JSON.stringify(events));
|
|
934
|
+
}],
|
|
935
|
+
|
|
936
|
+
// ---------- extensions: aggregates (B19) ----------
|
|
937
|
+
// Server-side aggregation of permission-events.jsonl for the per-tool
|
|
938
|
+
// audit charts. Filters are strictly allowlisted.
|
|
939
|
+
// ?window=24h|30m|7d (regex: \d+[hmd])
|
|
940
|
+
// ?kind=hourly|by_ext|by_tool|quotas
|
|
941
|
+
['/api/extensions/aggregates', async (req, res, url) => {
|
|
942
|
+
const ALLOWED_KINDS = new Set(['hourly', 'by_ext', 'by_tool', 'quotas']);
|
|
943
|
+
const WINDOW_RE = /^\d+(h|m|d)$/;
|
|
944
|
+
const ALLOWED_KEYS = new Set(['window', 'kind']);
|
|
945
|
+
try {
|
|
946
|
+
for (const key of url.searchParams.keys()) {
|
|
947
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
948
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
949
|
+
res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const kind = url.searchParams.get('kind') || 'hourly';
|
|
954
|
+
if (!ALLOWED_KINDS.has(kind)) {
|
|
955
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
956
|
+
res.end(JSON.stringify({ error: `invalid kind: ${kind}` }));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const rawWindow = url.searchParams.get('window') || '24h';
|
|
960
|
+
if (!WINDOW_RE.test(rawWindow)) {
|
|
961
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify({ error: `invalid window: ${rawWindow}` }));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
// Parse window into ms.
|
|
966
|
+
const num = parseInt(rawWindow.slice(0, -1), 10);
|
|
967
|
+
const unit = rawWindow.slice(-1);
|
|
968
|
+
const mult = unit === 'h' ? 3600_000 : unit === 'm' ? 60_000 : 86_400_000;
|
|
969
|
+
const windowMs = num * mult;
|
|
970
|
+
|
|
971
|
+
const home = homedir();
|
|
972
|
+
const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
|
|
973
|
+
|
|
974
|
+
if (kind === 'quotas') {
|
|
975
|
+
// Walk the active extension state and compute per-extension usage.
|
|
976
|
+
let active = null;
|
|
977
|
+
try {
|
|
978
|
+
const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
|
|
979
|
+
if (existsSync(activePath)) {
|
|
980
|
+
active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
981
|
+
}
|
|
982
|
+
} catch { active = null; }
|
|
983
|
+
const rows = [];
|
|
984
|
+
if (active && typeof active === 'object') {
|
|
985
|
+
// active may be a single record or a map keyed by name.
|
|
986
|
+
const entries = Array.isArray(active.extensions)
|
|
987
|
+
? active.extensions
|
|
988
|
+
: (active.name ? [active] : []);
|
|
989
|
+
for (const ent of entries) {
|
|
990
|
+
if (!ent || !ent.name) continue;
|
|
991
|
+
const scope = ent.scope || 'user';
|
|
992
|
+
const manifest = readActiveManifest({ scope, name: ent.name, home, projectRoot: REPO_ROOT });
|
|
993
|
+
const quotas = (manifest && manifest.quotas) || {};
|
|
994
|
+
const usage = await getQuotaUsage(ent.name, { homeDir: home, limits: quotas });
|
|
995
|
+
rows.push({
|
|
996
|
+
...usage,
|
|
997
|
+
scope,
|
|
998
|
+
warn_bash_bypass: computeWarnBashBypass(manifest),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1003
|
+
res.end(JSON.stringify({ rows }));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const agg = await aggregateEvents(eventsPath, { windowMs });
|
|
1008
|
+
|
|
1009
|
+
if (kind === 'hourly') {
|
|
1010
|
+
const buckets = Object.entries(agg.hourly)
|
|
1011
|
+
.map(([hour, count]) => ({ hour, count }))
|
|
1012
|
+
.sort((a, b) => a.hour.localeCompare(b.hour));
|
|
1013
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1014
|
+
res.end(JSON.stringify({ buckets }));
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (kind === 'by_ext') {
|
|
1019
|
+
const rows = Object.entries(agg.by_extension)
|
|
1020
|
+
.map(([ext, v]) => ({ ext, allowed: v.allowed, denied: v.denied }))
|
|
1021
|
+
.sort((a, b) => b.denied - a.denied);
|
|
1022
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1023
|
+
res.end(JSON.stringify({ rows }));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// kind === 'by_tool'
|
|
1028
|
+
const rows = Object.entries(agg.by_tool_denied)
|
|
1029
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
1030
|
+
.sort((a, b) => b.count - a.count)
|
|
1031
|
+
.slice(0, 10);
|
|
1032
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1033
|
+
res.end(JSON.stringify({ rows }));
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
process.stderr.write(`[ijfw-mcp] /api/extensions/aggregates: ${err && err.message ? err.message : err}\n`);
|
|
1036
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1037
|
+
res.end(JSON.stringify({ buckets: [], rows: [], error: 'aggregation failed' }));
|
|
1038
|
+
}
|
|
1039
|
+
}],
|
|
1040
|
+
|
|
691
1041
|
// ---------- extensions health (W3/t15) ----------
|
|
692
1042
|
// Reads .ijfw/state/extension-registry.json (project) plus org/user via
|
|
693
1043
|
// listExtensions(). Missing or malformed registry yields {extensions: []}
|
|
@@ -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
|
+
});
|