@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/memory-server",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
5
5
  "author": "Sean Donahoe",
6
6
  "license": "MIT",
@@ -7,24 +7,40 @@
7
7
  * - installExtension when opts.activate is set
8
8
  */
9
9
 
10
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
10
+ import { readFile, writeFile, unlink, mkdir, readdir, stat } from 'node:fs/promises';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { randomBytes } from 'node:crypto';
14
14
 
15
+ import { resetExtensionQuotas } from './extension-quota-tracker.js';
16
+
15
17
  const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
16
18
 
19
+ // B18 — stale last-seen-by-<ide>.json files older than this get cleaned on read.
20
+ const LAST_SEEN_STALE_MS = 30 * 24 * 60 * 60 * 1000;
21
+
22
+ // Valid IDE id pattern (matches ide-detect.js). Keep in sync.
23
+ const IDE_ID_PATTERN = /^[a-z0-9-]+$/;
24
+
17
25
  function statePath(home) {
18
26
  return join(home || homedir(), ...STATE_PATH_REL);
19
27
  }
20
28
 
29
+ function lastSeenPath(home, ideId) {
30
+ return join(home || homedir(), '.ijfw', 'state', `last-seen-by-${ideId}.json`);
31
+ }
32
+
33
+ function stateDir(home) {
34
+ return join(home || homedir(), '.ijfw', 'state');
35
+ }
36
+
21
37
  /**
22
38
  * Write the active-extension state file from a manifest + scope.
23
39
  * Validates required fields before write. Atomic write via tmp+rename.
24
40
  *
25
41
  * @param {{ name: string, permissions: { reads: string[], writes: string[] } }} manifest
26
42
  * @param {'project'|'org'|'user'} scope
27
- * @param {{ homeDir?: string }} [opts]
43
+ * @param {{ homeDir?: string, ideId?: string|null }} [opts]
28
44
  * @returns {Promise<{ ok: boolean, path?: string, error?: string }>}
29
45
  */
30
46
  export async function writeActiveExtension(manifest, scope, opts = {}) {
@@ -42,12 +58,48 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
42
58
  }
43
59
  const reads = Array.isArray(manifest.permissions.reads) ? manifest.permissions.reads : [];
44
60
  const writes = Array.isArray(manifest.permissions.writes) ? manifest.permissions.writes : [];
61
+ const activatedAt = new Date().toISOString();
62
+ // B18: stamp activated_by_ide + activated_by_pid when ideId is provided.
63
+ // Caller (CLI) is responsible for calling detectIde() and threading the
64
+ // value in. When opts.ideId is null/undefined, fields are omitted (so the
65
+ // file stays back-compatible with v1.4.1 readers).
66
+ const ideId = (typeof opts.ideId === 'string' && IDE_ID_PATTERN.test(opts.ideId))
67
+ ? opts.ideId
68
+ : null;
45
69
  const out = {
46
70
  name: manifest.name,
47
71
  scope,
48
72
  permissions: { reads, writes },
49
- activated_at: new Date().toISOString(),
73
+ activated_at: activatedAt,
50
74
  };
75
+ if (ideId) {
76
+ out.activated_by_ide = ideId;
77
+ out.activated_by_pid = process.pid;
78
+ }
79
+ // R12-H-01: persist manifest.quotas so the tier-2 hook
80
+ // (extension-permission-check.mjs) can enforce quotas on Edit/Write/Bash
81
+ // dispatch. Without this the tier-2 hook reads `active.quotas` as undefined
82
+ // and silently bypasses the v1.4.3 quota gate that the server-side
83
+ // gatePermissionAndQuota path enforces. Schema (extension-manifest-schema.js):
84
+ // optional object whose values are positive integers — currently
85
+ // max_files_written / max_bytes_written / max_wall_clock_ms (forward-compat:
86
+ // unknown dimensions are kept as-is — schema rejects unknowns at install).
87
+ if (
88
+ manifest.quotas !== undefined &&
89
+ manifest.quotas !== null &&
90
+ typeof manifest.quotas === 'object' &&
91
+ !Array.isArray(manifest.quotas)
92
+ ) {
93
+ const cleanQuotas = {};
94
+ let copied = 0;
95
+ for (const [k, v] of Object.entries(manifest.quotas)) {
96
+ if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
97
+ cleanQuotas[k] = v;
98
+ copied++;
99
+ }
100
+ }
101
+ if (copied > 0) out.quotas = cleanQuotas;
102
+ }
51
103
  const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
52
104
  const path = statePath(home);
53
105
  await mkdir(dirname(path), { recursive: true });
@@ -55,6 +107,14 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
55
107
  await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
56
108
  const { rename } = await import('node:fs/promises');
57
109
  await rename(tmp, path);
110
+ // B16/SEC-M-02: reset quota counters on activate; stamp activated_at so
111
+ // wall_clock_ms can be computed against this activation window.
112
+ try {
113
+ await resetExtensionQuotas(manifest.name, { homeDir: home, activated_at: activatedAt });
114
+ } catch {
115
+ // Quota reset failure must not block activation. Counters will self-heal
116
+ // on next deactivate or the next activate of the same name.
117
+ }
58
118
  return { ok: true, path };
59
119
  }
60
120
 
@@ -66,11 +126,38 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
66
126
  */
67
127
  export async function clearActiveExtension(opts = {}) {
68
128
  const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
129
+ // B16/SEC-M-02: read the active extension name BEFORE unlinking so we can
130
+ // clear its quota counters. Best-effort: if the file is missing or
131
+ // malformed, deactivate still succeeds.
132
+ let extName = null;
133
+ try {
134
+ const raw = await readFile(statePath(home), 'utf8');
135
+ const parsed = JSON.parse(raw);
136
+ if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') {
137
+ extName = parsed.name;
138
+ }
139
+ } catch {
140
+ // ignore — extName stays null
141
+ }
69
142
  try {
70
143
  await unlink(statePath(home));
144
+ if (extName) {
145
+ try {
146
+ await resetExtensionQuotas(extName, { homeDir: home });
147
+ } catch {
148
+ // best-effort
149
+ }
150
+ }
71
151
  return { ok: true, removed: true };
72
152
  } catch (err) {
73
- if (err && err.code === 'ENOENT') return { ok: true, removed: false };
153
+ if (err && err.code === 'ENOENT') {
154
+ if (extName) {
155
+ try {
156
+ await resetExtensionQuotas(extName, { homeDir: home });
157
+ } catch { /* best-effort */ }
158
+ }
159
+ return { ok: true, removed: false };
160
+ }
74
161
  return { ok: false, removed: false };
75
162
  }
76
163
  }
@@ -140,3 +227,196 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
140
227
 
141
228
  return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
142
229
  }
230
+
231
+ // ============================================================================
232
+ // B18 — Cross-IDE Conflict Detection
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Write the current IDE's last-seen marker. Best-effort: never throws.
237
+ * Atomic via tmp+rename.
238
+ *
239
+ * @param {string} ideId
240
+ * @param {{ homeDir?: string }} [opts]
241
+ */
242
+ async function writeLastSeen(ideId, opts = {}) {
243
+ if (!ideId || typeof ideId !== 'string' || !IDE_ID_PATTERN.test(ideId)) return;
244
+ if (ideId === 'unknown') return;
245
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
246
+ const path = lastSeenPath(home, ideId);
247
+ try {
248
+ await mkdir(dirname(path), { recursive: true });
249
+ const body = JSON.stringify({ ide: ideId, last_seen_at: new Date().toISOString() }, null, 2) + '\n';
250
+ const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
251
+ await writeFile(tmp, body, 'utf8');
252
+ const { rename } = await import('node:fs/promises');
253
+ await rename(tmp, path);
254
+ } catch {
255
+ // best-effort
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Read another IDE's last-seen marker. Returns null on any read error or
261
+ * missing file.
262
+ *
263
+ * @param {string} ideId
264
+ * @param {{ homeDir?: string }} [opts]
265
+ * @returns {Promise<{ ide: string, last_seen_at: string }|null>}
266
+ */
267
+ async function readLastSeen(ideId, opts = {}) {
268
+ if (!ideId || !IDE_ID_PATTERN.test(ideId)) return null;
269
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
270
+ try {
271
+ const raw = await readFile(lastSeenPath(home, ideId), 'utf8');
272
+ const parsed = JSON.parse(raw);
273
+ if (!parsed || typeof parsed !== 'object') return null;
274
+ if (typeof parsed.last_seen_at !== 'string') return null;
275
+ return parsed;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Scan ~/.ijfw/state/ for last-seen-by-<ide>.json files older than 30 days
283
+ * and unlink them. Best-effort: never throws. Returns the number removed.
284
+ *
285
+ * @param {{ homeDir?: string, now?: number }} [opts]
286
+ * @returns {Promise<number>}
287
+ */
288
+ async function cleanupStaleLastSeen(opts = {}) {
289
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
290
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
291
+ let removed = 0;
292
+ let entries;
293
+ try {
294
+ entries = await readdir(stateDir(home));
295
+ } catch {
296
+ return 0;
297
+ }
298
+ for (const entry of entries) {
299
+ if (!entry.startsWith('last-seen-by-') || !entry.endsWith('.json')) continue;
300
+ const full = join(stateDir(home), entry);
301
+ try {
302
+ const st = await stat(full);
303
+ if (now - st.mtimeMs > LAST_SEEN_STALE_MS) {
304
+ await unlink(full);
305
+ removed++;
306
+ }
307
+ } catch {
308
+ // best-effort
309
+ }
310
+ }
311
+ return removed;
312
+ }
313
+
314
+ /**
315
+ * Detect divergence between the IDE that wrote active.json and the current IDE.
316
+ *
317
+ * Semantics:
318
+ * - active.json missing OR no activated_by_ide field (pre-v1.4.3) → not divergent
319
+ * - active.activated_by_ide === current_ide → not divergent
320
+ * - active.activated_by_ide !== current_ide AND current_ide has a last-seen
321
+ * file AND active.activated_at is OLDER than current_ide's last_seen_at →
322
+ * divergent (this IDE was here before; a different IDE has since taken over
323
+ * stale state)
324
+ * - otherwise → not divergent (legitimate cross-IDE hand-off)
325
+ *
326
+ * Side effects:
327
+ * - writes current_ide's last-seen marker
328
+ * - cleans up stale last-seen files (>30 days)
329
+ *
330
+ * @param {{ homeDir?: string, currentIde?: string, now?: number }} [opts]
331
+ * @returns {Promise<{ divergent: boolean, last_writer: string|null, current_ide: string, age_seconds: number|null, reason?: string }>}
332
+ */
333
+ export async function detectCrossIdeDivergence(opts = {}) {
334
+ const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
335
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
336
+ // Lazy import to avoid a hard module dependency cycle in test scaffolds.
337
+ let currentIde = opts.currentIde;
338
+ if (!currentIde) {
339
+ try {
340
+ const { detectIde } = await import('./ide-detect.js');
341
+ currentIde = detectIde();
342
+ } catch {
343
+ currentIde = 'unknown';
344
+ }
345
+ }
346
+
347
+ // Best-effort stale cleanup on every divergence check.
348
+ await cleanupStaleLastSeen({ homeDir: home, now });
349
+
350
+ // Read active.json. Missing or unreadable → not divergent.
351
+ let active;
352
+ try {
353
+ const raw = await readFile(statePath(home), 'utf8');
354
+ active = JSON.parse(raw);
355
+ } catch {
356
+ return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'no active extension' };
357
+ }
358
+
359
+ // Pre-v1.4.3 active.json — silently no-divergence.
360
+ if (!active || typeof active !== 'object' || typeof active.activated_by_ide !== 'string') {
361
+ return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'pre-v1.4.3 active.json' };
362
+ }
363
+
364
+ const lastWriter = active.activated_by_ide;
365
+ const activatedAtMs = typeof active.activated_at === 'string' ? Date.parse(active.activated_at) : NaN;
366
+ const ageSeconds = Number.isFinite(activatedAtMs) ? Math.max(0, Math.floor((now - activatedAtMs) / 1000)) : null;
367
+
368
+ // CRITICAL ORDERING: read prior last-seen BEFORE overwriting it. Otherwise
369
+ // the divergence comparison degrades to "now vs activated_at" which is
370
+ // always non-divergent.
371
+ let priorSeen = null;
372
+ if (lastWriter !== currentIde && currentIde !== 'unknown') {
373
+ priorSeen = await readLastSeen(currentIde, { homeDir: home });
374
+ }
375
+
376
+ // Now write our own last-seen marker (best-effort; we ARE the current IDE).
377
+ if (currentIde !== 'unknown') {
378
+ await writeLastSeen(currentIde, { homeDir: home });
379
+ }
380
+
381
+ if (lastWriter === currentIde) {
382
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'same ide' };
383
+ }
384
+
385
+ // If current_ide is 'unknown', divergence detection is disabled.
386
+ if (currentIde === 'unknown') {
387
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'detection disabled' };
388
+ }
389
+
390
+ if (!priorSeen) {
391
+ // Current IDE has no prior history → legitimate first-time hand-off.
392
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'first-time current ide' };
393
+ }
394
+ const seenMs = Date.parse(priorSeen.last_seen_at);
395
+ if (!Number.isFinite(seenMs) || !Number.isFinite(activatedAtMs)) {
396
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'unparseable timestamps' };
397
+ }
398
+
399
+ // Design rule (per B18 spec):
400
+ // divergent iff active.activated_by_ide != currentIde
401
+ // AND active.activated_at < currentIde.last_seen
402
+ //
403
+ // Reading: the slot says some other IDE wrote it, but the current IDE has
404
+ // a more recent last-seen — i.e., the current IDE has been touching state
405
+ // more recently than the write, yet a different IDE's name is on it. Stale
406
+ // cross-IDE state divergence.
407
+ if (activatedAtMs < seenMs) {
408
+ return { divergent: true, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'stale active.json: current ide last-seen is more recent than active.activated_at' };
409
+ }
410
+
411
+ // active was written AFTER current ide's last_seen — legitimate hand-off
412
+ // (another IDE took over while current was away).
413
+ return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'legitimate handoff: foreign ide wrote after current ide was last here' };
414
+ }
415
+
416
+ // Internal helpers exported for tests only.
417
+ export const __testing = Object.freeze({
418
+ writeLastSeen,
419
+ readLastSeen,
420
+ cleanupStaleLastSeen,
421
+ LAST_SEEN_STALE_MS,
422
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dashboard-aggregator.js — IJFW v1.4.3 W9-C (B19)
3
+ *
4
+ * Server-side aggregation of ~/.ijfw/state/permission-events.jsonl for the
5
+ * dashboard's per-tool audit charts. Reads only the last TAIL_CHUNK bytes
6
+ * (same 2MB cap as the events endpoint) so this is bounded-memory even
7
+ * across rotations.
8
+ *
9
+ * Cache: 60s OR until the events file mtime changes, whichever first.
10
+ * Malformed JSONL lines are dropped silently — never crash the dashboard
11
+ * on a partial write.
12
+ *
13
+ * Returned shape:
14
+ * {
15
+ * hourly: { [hourISO]: count },
16
+ * by_extension: { [ext]: { allowed: number, denied: number } },
17
+ * by_tool_denied:{ [tool]: count }
18
+ * }
19
+ *
20
+ * Helper `computeWarnBashBypass(manifest)` implements ARCH-M-01: an extension
21
+ * with `tool:bash` or `tool:exec` in writes AND a strict files/bytes quota
22
+ * declared in the manifest gets a warning chip in the dashboard. The chip is
23
+ * an information channel — quota enforcement still applies, but bash content
24
+ * bypasses per-file accounting at the API surface.
25
+ */
26
+
27
+ import { existsSync, statSync, readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ // Match dashboard-server's TAIL_CHUNK. Kept here so this module is
31
+ // self-contained for the test harness.
32
+ export const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
33
+ const CACHE_TTL_MS = 60_000;
34
+
35
+ // Module-level cache. Key = canonical path; value = { mtimeMs, builtAt, result }.
36
+ const _cache = new Map();
37
+
38
+ export function _resetAggregatorCacheForTest() {
39
+ _cache.clear();
40
+ }
41
+
42
+ function _readTailLines(eventsPath) {
43
+ if (!existsSync(eventsPath)) return { lines: [], mtimeMs: 0 };
44
+ let st;
45
+ try { st = statSync(eventsPath); } catch { return { lines: [], mtimeMs: 0 }; }
46
+ if (!st.size) return { lines: [], mtimeMs: st.mtimeMs };
47
+ let buf;
48
+ try { buf = readFileSync(eventsPath); } catch { return { lines: [], mtimeMs: st.mtimeMs }; }
49
+ const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
50
+ let lines = slice.toString('utf8').split('\n').filter(Boolean);
51
+ // If we sliced mid-line, drop the partial leading element.
52
+ if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
53
+ return { lines, mtimeMs: st.mtimeMs };
54
+ }
55
+
56
+ function _hourBucket(tsMs) {
57
+ const d = new Date(tsMs);
58
+ d.setUTCMinutes(0, 0, 0);
59
+ return d.toISOString();
60
+ }
61
+
62
+ /**
63
+ * Aggregate permission events within `windowMs` of `now`.
64
+ *
65
+ * @param {string} eventsPath absolute path to permission-events.jsonl
66
+ * @param {{ windowMs?: number, now?: number }} [opts]
67
+ */
68
+ export async function aggregateEvents(eventsPath, opts = {}) {
69
+ const windowMs = (opts && typeof opts.windowMs === 'number') ? opts.windowMs : 24 * 3600 * 1000;
70
+ const now = (opts && typeof opts.now === 'number') ? opts.now : Date.now();
71
+
72
+ const { lines, mtimeMs } = _readTailLines(eventsPath);
73
+
74
+ const cached = _cache.get(eventsPath);
75
+ if (cached
76
+ && cached.mtimeMs === mtimeMs
77
+ && (now - cached.builtAt) < CACHE_TTL_MS
78
+ && cached.windowMs === windowMs) {
79
+ return cached.result;
80
+ }
81
+
82
+ const cutoff = now - windowMs;
83
+ const hourly = Object.create(null);
84
+ const byExt = Object.create(null);
85
+ const byToolDenied = Object.create(null);
86
+
87
+ for (const line of lines) {
88
+ let obj;
89
+ try { obj = JSON.parse(line); } catch { continue; }
90
+ if (!obj || typeof obj !== 'object') continue;
91
+ const t = typeof obj.ts === 'string' ? Date.parse(obj.ts) : (typeof obj.ts === 'number' ? obj.ts : NaN);
92
+ if (!Number.isFinite(t)) continue;
93
+ if (t < cutoff) continue;
94
+
95
+ const ext = (typeof obj.extension === 'string' && obj.extension) ? obj.extension : '<unknown>';
96
+ const tool = (typeof obj.tool === 'string' && obj.tool) ? obj.tool : (typeof obj.action === 'string' ? obj.action : '<unknown>');
97
+ const allowed = obj.allowed !== false; // anything other than explicit false is allowed.
98
+
99
+ // hourly
100
+ const hk = _hourBucket(t);
101
+ hourly[hk] = (hourly[hk] || 0) + 1;
102
+
103
+ // by_extension
104
+ if (!byExt[ext]) byExt[ext] = { allowed: 0, denied: 0 };
105
+ if (allowed) byExt[ext].allowed += 1;
106
+ else byExt[ext].denied += 1;
107
+
108
+ // by_tool_denied
109
+ if (!allowed) {
110
+ byToolDenied[tool] = (byToolDenied[tool] || 0) + 1;
111
+ }
112
+ }
113
+
114
+ const result = { hourly, by_extension: byExt, by_tool_denied: byToolDenied };
115
+ _cache.set(eventsPath, { mtimeMs, builtAt: now, windowMs, result });
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * ARCH-M-01: compute whether an extension's manifest combines a bash/exec
121
+ * write permission with a strict files/bytes quota.
122
+ *
123
+ * Returns true iff:
124
+ * manifest.permissions.writes includes "tool:bash" or "tool:exec"
125
+ * AND (quotas.max_files_written OR quotas.max_bytes_written is set)
126
+ */
127
+ export function computeWarnBashBypass(manifest) {
128
+ if (!manifest || typeof manifest !== 'object') return false;
129
+ const perms = manifest.permissions || {};
130
+ const writes = Array.isArray(perms.writes) ? perms.writes : [];
131
+ const hasBashOrExec = writes.some((w) => w === 'tool:bash' || w === 'tool:exec');
132
+ if (!hasBashOrExec) return false;
133
+ const q = manifest.quotas || {};
134
+ const hasStrictQuota =
135
+ (typeof q.max_files_written === 'number' && Number.isFinite(q.max_files_written)) ||
136
+ (typeof q.max_bytes_written === 'number' && Number.isFinite(q.max_bytes_written));
137
+ return Boolean(hasStrictQuota);
138
+ }
139
+
140
+ /**
141
+ * Resolve `<scope>/<name>/manifest.json` and read it. Returns the parsed
142
+ * manifest object or `null` if the file is missing/unreadable/malformed.
143
+ *
144
+ * Scope→path map mirrors `active-extension-writer.js`:
145
+ * project: <projectRoot>/.ijfw/extensions/<name>/manifest.json
146
+ * org: <home>/.ijfw/extensions-org/<name>/manifest.json
147
+ * user: <home>/.ijfw/extensions-user/<name>/manifest.json
148
+ */
149
+ export function readActiveManifest({ scope, name, home, projectRoot }) {
150
+ if (!scope || !name) return null;
151
+ let path = null;
152
+ if (scope === 'project' && projectRoot) {
153
+ path = join(projectRoot, '.ijfw', 'extensions', name, 'manifest.json');
154
+ } else if (scope === 'org' && home) {
155
+ path = join(home, '.ijfw', 'extensions-org', name, 'manifest.json');
156
+ } else if (scope === 'user' && home) {
157
+ path = join(home, '.ijfw', 'extensions-user', name, 'manifest.json');
158
+ }
159
+ if (!path) return null;
160
+ try {
161
+ return JSON.parse(readFileSync(path, 'utf8'));
162
+ } catch {
163
+ return null;
164
+ }
165
+ }