@ijfw/memory-server 1.3.0
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/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- package/templates/design/warm-organic.md +84 -0
|
@@ -0,0 +1,1885 @@
|
|
|
1
|
+
// cross-orchestrator-cli.js -- thin CLI for `ijfw cross <mode> <target>`.
|
|
2
|
+
//
|
|
3
|
+
// Commands:
|
|
4
|
+
// ijfw cross <mode> <target> [--confirm] [--with <id>] [--expand]
|
|
5
|
+
// ijfw status
|
|
6
|
+
// ijfw --help
|
|
7
|
+
//
|
|
8
|
+
// Zero external deps. Parse argv manually.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, readdirSync, rmSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { writeAtomic } from './lib/atomic-io.js';
|
|
16
|
+
import { runCrossOp } from './cross-orchestrator.js';
|
|
17
|
+
import { readReceipts, purgeReceipts } from './receipts.js';
|
|
18
|
+
import { renderHeroLine } from './hero-line.js';
|
|
19
|
+
import { ROSTER, isInstalled, isReachable } from './audit-roster.js';
|
|
20
|
+
import { aggregatePortfolioFindings } from './cross-project-search.js';
|
|
21
|
+
import { runImport, runImportAll, listImporters } from './importers/cli.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Auditor error translator (1.2.5)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Pattern-match common auditor failure signatures and turn the raw stderr
|
|
27
|
+
// into one actionable sentence. Returns a short string the degraded-auditor
|
|
28
|
+
// surface in cmdCross renders directly.
|
|
29
|
+
//
|
|
30
|
+
// Order matters: more specific patterns come first so a generic "401" or
|
|
31
|
+
// "timeout" doesn't shadow a tool-specific re-auth instruction.
|
|
32
|
+
export function translateAuditorError(id, status, stderr, exitCode) {
|
|
33
|
+
const s = String(stderr || '');
|
|
34
|
+
const lower = s.toLowerCase();
|
|
35
|
+
|
|
36
|
+
// Tool-specific re-auth signatures (highest specificity)
|
|
37
|
+
if (id === 'codex' && (
|
|
38
|
+
/failed to refre/i.test(s) ||
|
|
39
|
+
/codex_models_manager/i.test(s) ||
|
|
40
|
+
/token.*(expired|invalid)/i.test(s)
|
|
41
|
+
)) {
|
|
42
|
+
return 'Codex auth token expired or stale. Run `codex login` to refresh, then re-run.';
|
|
43
|
+
}
|
|
44
|
+
if (id === 'qwen' && /no auth type is selected/i.test(s)) {
|
|
45
|
+
return 'No auth configured. Run `qwen auth` and pick a provider, then re-run.';
|
|
46
|
+
}
|
|
47
|
+
if (id === 'gemini' && (/safety/i.test(s) || /blocked/i.test(s) || /BLOCKED_BY_SAFETY/i.test(s))) {
|
|
48
|
+
return 'Safety filter blocked output -- may be a false negative on this target. Try a different file or pass --with to swap auditor.';
|
|
49
|
+
}
|
|
50
|
+
if (id === 'claude' && /credit balance.*too low/i.test(lower)) {
|
|
51
|
+
return 'Anthropic credits low. Top up at console.anthropic.com or use the CLI path instead of API.';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Status-driven generic patterns
|
|
55
|
+
if (status === 'timeout' || /(operation was )?aborted due to timeout/i.test(s) || /etimedout/i.test(lower)) {
|
|
56
|
+
return 'API timed out. Check network, retry, or pass --with to drop this auditor on the next run.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Generic auth signatures
|
|
60
|
+
if (/\b(401|403)\b/.test(s) || /unauthorized/i.test(s) || /forbidden/i.test(s)) {
|
|
61
|
+
return `Authentication rejected (HTTP 401/403). Re-check the API key for ${id}, then re-run.`;
|
|
62
|
+
}
|
|
63
|
+
if (/(api[_ ]?key|auth(env)?)\s+not\s+set/i.test(s)) {
|
|
64
|
+
return `API key not set. Export the auth env var for ${id} in your shell, then re-run.`;
|
|
65
|
+
}
|
|
66
|
+
if (/\b429\b/.test(s) || /rate[ -]?limit/i.test(lower) || /quota/i.test(lower)) {
|
|
67
|
+
return 'Rate-limited or quota exhausted. Wait a minute or top up your provider, then re-run.';
|
|
68
|
+
}
|
|
69
|
+
if (/enotfound|econnrefused|getaddrinfo/i.test(s)) {
|
|
70
|
+
return 'Network unreachable. Check connectivity, then re-run.';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Empty + stderr usually means model emitted prose without the JSON fence.
|
|
74
|
+
if (status === 'empty') {
|
|
75
|
+
return 'Returned no parseable findings (model may have emitted prose without the JSON fence). Re-run or pass --with to swap auditor.';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Spawn / exec issues
|
|
79
|
+
if (/spawn .* enoent/i.test(lower) || /command not found/i.test(lower)) {
|
|
80
|
+
return `CLI binary not found on PATH. Install ${id} or set its API key to use the API fallback.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Catch-all: short raw, but suffix the right action
|
|
84
|
+
const head = s.split('\n')[0].slice(0, 80) || `exit ${exitCode}`;
|
|
85
|
+
return `Failed (${head}). Run \`ijfw doctor\` to diagnose, or pass --with to drop this auditor.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Findings printer
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
function printFindings(mode, merged) {
|
|
92
|
+
if (mode === 'audit') {
|
|
93
|
+
const items = Array.isArray(merged) ? merged : [];
|
|
94
|
+
if (items.length === 0) {
|
|
95
|
+
console.log(' Auditors returned no findings -- your target looks solid.');
|
|
96
|
+
console.log(' Run `ijfw cross audit <another-file>` to audit a different target.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log('');
|
|
100
|
+
items.forEach((item, i) => {
|
|
101
|
+
const sev = item.severity ? ` [${item.severity}]` : '';
|
|
102
|
+
const loc = item.location ? ` | ${item.location}` : '';
|
|
103
|
+
const issue = String(item.issue || '');
|
|
104
|
+
console.log(` Step 1.${i + 1} --${sev}${loc} -- ${issue}`);
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (mode === 'research') {
|
|
110
|
+
const { consensus = [], contested = [], synthesisPending } = merged || {};
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(` Consensus: ${consensus.length} | Contested: ${contested.length}`);
|
|
113
|
+
if (synthesisPending) console.log(' Note: synthesis pass pending -- lexical match only.');
|
|
114
|
+
consensus.slice(0, 5).forEach((item, i) => {
|
|
115
|
+
console.log(` Step 1.${i + 1} -- [consensus] ${String(item.claim || '')}`);
|
|
116
|
+
});
|
|
117
|
+
contested.slice(0, 3).forEach((item, i) => {
|
|
118
|
+
console.log(` Step 2.${i + 1} -- [contested] ${String(item.claim || '')}`);
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (mode === 'critique') {
|
|
124
|
+
const items = Array.isArray(merged) ? merged : [];
|
|
125
|
+
if (items.length === 0) {
|
|
126
|
+
console.log(' No counter-arguments surfaced -- argument appears well-supported.');
|
|
127
|
+
console.log(' Run `ijfw cross critique <another-target>` to challenge a different position.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
console.log('');
|
|
131
|
+
items.forEach((item, i) => {
|
|
132
|
+
const sev = item.severity ? ` [${item.severity}]` : '';
|
|
133
|
+
const arg = String(item.counterArg || '');
|
|
134
|
+
console.log(` Step 1.${i + 1} --${sev} ${arg}`);
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Arg parser
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// True when the caller wants machine-readable output: explicit --json flag,
|
|
144
|
+
// or stdout is not a TTY (gh-CLI convention -- piped/redirected output is
|
|
145
|
+
// presumed to be consumed by another process, including sub-agents shelling
|
|
146
|
+
// out via bash).
|
|
147
|
+
function wantsJson(opts) {
|
|
148
|
+
return Boolean(opts && opts.json) || !process.stdout.isTTY;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function emitJson(value) {
|
|
152
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseArgs(argv) {
|
|
156
|
+
const args = argv.slice(2); // strip node + script path
|
|
157
|
+
|
|
158
|
+
// Global --json flag: any command can be forced to JSON output regardless
|
|
159
|
+
// of TTY. Strip it before per-command parsing so it doesn't confuse
|
|
160
|
+
// positional argument handling.
|
|
161
|
+
let json = false;
|
|
162
|
+
const rest = [];
|
|
163
|
+
for (const a of args) {
|
|
164
|
+
if (a === '--json') json = true;
|
|
165
|
+
else rest.push(a);
|
|
166
|
+
}
|
|
167
|
+
const out = parseArgsInner(rest);
|
|
168
|
+
if (out && typeof out === 'object') out.json = json;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseArgsInner(args) {
|
|
173
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
174
|
+
return { cmd: 'help' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
178
|
+
return { cmd: 'version', verbose: args.includes('--verbose') };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (args[0] === 'version') {
|
|
182
|
+
return { cmd: 'version', verbose: args.includes('--verbose') };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (args[0] === 'help') {
|
|
186
|
+
return { cmd: 'guide', browser: args.includes('--browser') };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (args[0] === 'status') {
|
|
190
|
+
return { cmd: 'status' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (args[0] === 'demo') {
|
|
194
|
+
return { cmd: 'demo' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (args[0] === 'doctor') {
|
|
198
|
+
return { cmd: 'doctor' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (args[0] === 'update') {
|
|
202
|
+
const opts = { cmd: 'update' };
|
|
203
|
+
for (let i = 1; i < args.length; i++) {
|
|
204
|
+
const a = args[i];
|
|
205
|
+
if (a === '--check') opts.check = true;
|
|
206
|
+
else if (a === '--yes' || a === '-y') opts.yes = true;
|
|
207
|
+
else if (a === '--verify') opts.verify = true;
|
|
208
|
+
else if (a === '--changelog') opts.changelog = true;
|
|
209
|
+
else if (a === '--confirm' && args[i + 1]) { opts.confirm = args[++i]; }
|
|
210
|
+
else if (a === '--auto' && args[i + 1]) { opts.auto = args[++i]; }
|
|
211
|
+
}
|
|
212
|
+
return opts;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (args[0] === 'statusline') {
|
|
216
|
+
const opts = { cmd: 'statusline' };
|
|
217
|
+
for (let i = 1; i < args.length; i++) {
|
|
218
|
+
const a = args[i];
|
|
219
|
+
if (a === '--install') opts.sub = 'install';
|
|
220
|
+
else if (a === '--compose') opts.sub = 'compose';
|
|
221
|
+
else if (a === '--disable') opts.sub = 'disable';
|
|
222
|
+
else if (a === '--status') opts.sub = 'status';
|
|
223
|
+
else if (a === '--recompute') opts.sub = 'recompute';
|
|
224
|
+
}
|
|
225
|
+
if (!opts.sub) opts.sub = 'status';
|
|
226
|
+
return opts;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (args[0] === 'config') {
|
|
230
|
+
const opts = { cmd: 'config' };
|
|
231
|
+
for (let i = 1; i < args.length; i++) {
|
|
232
|
+
if (args[i] === '--audit') opts.sub = 'audit';
|
|
233
|
+
}
|
|
234
|
+
return opts;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (args[0] === 'insight') {
|
|
238
|
+
return { cmd: 'insight', sub: args[1] || 'start' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (args[0] === 'install') {
|
|
242
|
+
return { cmd: 'install' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (args[0] === 'uninstall' || (args[0] === 'off' && args.length === 1)) {
|
|
246
|
+
return { cmd: 'uninstall' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (args[0] === 'preflight') {
|
|
250
|
+
return { cmd: 'preflight' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (args[0] === 'dashboard') {
|
|
254
|
+
return { cmd: 'dashboard', sub: args[1] || 'status' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (args[0] === 'receipt') {
|
|
258
|
+
return { cmd: 'receipt', sub: args[1] || 'last' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (args[0] === '--purge-receipts') {
|
|
262
|
+
return { cmd: 'purge-receipts' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (args[0] === 'import') {
|
|
266
|
+
const tool = args[1];
|
|
267
|
+
let dryRun = false, force = false, includeMetrics = false, customPath = null, allMode = false, yes = false;
|
|
268
|
+
for (let i = 2; i < args.length; i++) {
|
|
269
|
+
if (args[i] === '--dry-run') dryRun = true;
|
|
270
|
+
else if (args[i] === '--force') force = true;
|
|
271
|
+
else if (args[i] === '--all') allMode = true;
|
|
272
|
+
else if (args[i] === '--yes' || args[i] === '-y') yes = true;
|
|
273
|
+
else if (args[i] === '--include-metrics') includeMetrics = true;
|
|
274
|
+
else if (args[i] === '--path' && args[i + 1]) customPath = args[++i];
|
|
275
|
+
}
|
|
276
|
+
return { cmd: 'import', tool, dryRun, force, includeMetrics, customPath, allMode, yes };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (args[0] === 'cross') {
|
|
280
|
+
const mode = args[1];
|
|
281
|
+
|
|
282
|
+
if (mode === 'project-audit') {
|
|
283
|
+
const rule = args[2];
|
|
284
|
+
let dryRun = false;
|
|
285
|
+
for (let i = 3; i < args.length; i++) {
|
|
286
|
+
if (args[i] === '--dry-run') dryRun = true;
|
|
287
|
+
}
|
|
288
|
+
return { cmd: 'cross-project-audit', rule, dryRun };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const target = args[2];
|
|
292
|
+
let only = null;
|
|
293
|
+
let confirm = false;
|
|
294
|
+
let expand = false;
|
|
295
|
+
|
|
296
|
+
for (let i = 3; i < args.length; i++) {
|
|
297
|
+
if (args[i] === '--confirm') { confirm = true; }
|
|
298
|
+
else if (args[i] === '--expand') { expand = true; }
|
|
299
|
+
else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { cmd: 'cross', mode, target, only, confirm, expand };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { cmd: 'unknown', raw: args[0] };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Commands
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function printUsage() {
|
|
313
|
+
console.log(`
|
|
314
|
+
ijfw -- It Just Fucking Works CLI
|
|
315
|
+
Fire 2-4 AIs at any target. Receipts logged. Cache hits tracked. Memory follows you.
|
|
316
|
+
|
|
317
|
+
Usage:
|
|
318
|
+
ijfw install
|
|
319
|
+
ijfw uninstall
|
|
320
|
+
ijfw preflight
|
|
321
|
+
ijfw dashboard [start|stop|status]
|
|
322
|
+
ijfw cross <mode> <target> [options]
|
|
323
|
+
ijfw cross project-audit <rule-file> [--dry-run]
|
|
324
|
+
ijfw import <tool> [--all] [--dry-run] [--force] [--path <p>]
|
|
325
|
+
ijfw status
|
|
326
|
+
ijfw doctor
|
|
327
|
+
ijfw update
|
|
328
|
+
ijfw receipt last
|
|
329
|
+
ijfw --purge-receipts
|
|
330
|
+
ijfw --help
|
|
331
|
+
|
|
332
|
+
Commands:
|
|
333
|
+
install Install IJFW into your AI coding agents.
|
|
334
|
+
uninstall Remove IJFW and revert AI-agent configs. Same as: ijfw off
|
|
335
|
+
preflight Run the 12-gate quality pipeline (blocking + advisory).
|
|
336
|
+
dashboard Control the dashboard server (start, stop, status).
|
|
337
|
+
demo 30-second live tour of the Trident (fires real auditors).
|
|
338
|
+
cross Fire external auditors at a target. Try: ijfw cross audit README.md
|
|
339
|
+
import Pull memory in from another tool. Try: ijfw import claude-mem --all
|
|
340
|
+
status Show recent cross-audit activity. Try: ijfw status
|
|
341
|
+
doctor Probe which CLIs and API keys are reachable. Try: ijfw doctor
|
|
342
|
+
update Pull latest IJFW + reinstall merge-safely. Try: ijfw update
|
|
343
|
+
update --check Non-invasive check. Exits 0 always; prints "update-available: <ver>" when an update exists (grep-safe).
|
|
344
|
+
receipt last Print a redacted, shareable block from the last Trident run.
|
|
345
|
+
--purge-receipts Clear the cross-runs receipt log. Try: ijfw --purge-receipts
|
|
346
|
+
|
|
347
|
+
Modes (for ijfw cross):
|
|
348
|
+
audit Adversarial review of a file, module, or path
|
|
349
|
+
research Multi-source research on a topic
|
|
350
|
+
critique Structured counter-argument generation
|
|
351
|
+
project-audit Run the same audit across every registered IJFW project
|
|
352
|
+
Usage: ijfw cross project-audit <rule-file> [--dry-run]
|
|
353
|
+
|
|
354
|
+
Options for ijfw cross:
|
|
355
|
+
--with <id> Force a specific auditor (comma-separated for multiple)
|
|
356
|
+
--confirm Prompt for confirmation before firing
|
|
357
|
+
--expand Include extended swarm when available
|
|
358
|
+
|
|
359
|
+
Global flags:
|
|
360
|
+
--json Emit JSON instead of human output. status and doctor auto-JSON
|
|
361
|
+
on non-TTY (gh-CLI convention); version stays one-line on pipe
|
|
362
|
+
and only JSON-ifies with explicit --json. Other commands ignore.
|
|
363
|
+
|
|
364
|
+
Environment:
|
|
365
|
+
IJFW_AUDIT_BUDGET_USD Session spend cap (default $2.00). First call is always
|
|
366
|
+
allowed (no cap). Cap enforced from the 2nd call on.
|
|
367
|
+
|
|
368
|
+
Examples:
|
|
369
|
+
ijfw demo
|
|
370
|
+
ijfw cross audit README.md
|
|
371
|
+
ijfw cross research "vector search approaches"
|
|
372
|
+
ijfw cross critique HEAD~3..HEAD
|
|
373
|
+
ijfw cross audit CLAUDE.md --with codex,gemini
|
|
374
|
+
ijfw status
|
|
375
|
+
ijfw doctor
|
|
376
|
+
`.trim());
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function cmdStatus(projectDir, opts = {}) {
|
|
380
|
+
const receipts = readReceipts(projectDir);
|
|
381
|
+
const last = receipts[receipts.length - 1];
|
|
382
|
+
|
|
383
|
+
if (wantsJson(opts)) {
|
|
384
|
+
emitJson({
|
|
385
|
+
runs: receipts.length,
|
|
386
|
+
last: last ? { mode: last.mode || 'cross', timestamp: last.timestamp || null } : null,
|
|
387
|
+
hero: receipts.length > 0 ? renderHeroLine(receipts) : null,
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (receipts.length === 0) {
|
|
393
|
+
console.log('No cross-audit runs recorded yet.');
|
|
394
|
+
console.log('Recommended next: `ijfw cross audit <file>` to run your first Trident audit.');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const hero = renderHeroLine(receipts);
|
|
398
|
+
const mode = last?.mode || 'cross';
|
|
399
|
+
const ts = last?.timestamp ? last.timestamp.slice(0, 10) : '';
|
|
400
|
+
console.log(`Trident -- run ${receipts.length} -- ${mode}${ts ? ' (' + ts + ')' : ''}`);
|
|
401
|
+
console.log('--');
|
|
402
|
+
console.log(hero);
|
|
403
|
+
console.log('--');
|
|
404
|
+
console.log(`${receipts.length} Trident run${receipts.length === 1 ? '' : 's'} on record.`);
|
|
405
|
+
console.log('Recommended next: `ijfw cross audit <file>`. Say no/alt to override.');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Demo
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
// Pre-flight: return true if any auditor is reachable via CLI or API key.
|
|
413
|
+
function _anyAuditorReachable() {
|
|
414
|
+
for (const entry of ROSTER) {
|
|
415
|
+
try {
|
|
416
|
+
if (isReachable(entry.id, process.env).any) return true;
|
|
417
|
+
} catch {
|
|
418
|
+
if (isInstalled(entry.id)) return true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function _printDemoFindings(picks, auditorResults) {
|
|
425
|
+
const attributed = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < picks.length; i++) {
|
|
428
|
+
const { status, parsed } = auditorResults[i];
|
|
429
|
+
const id = picks[i].id;
|
|
430
|
+
const capitalized = id.charAt(0).toUpperCase() + id.slice(1);
|
|
431
|
+
const items = Array.isArray(parsed?.items) ? parsed.items : [];
|
|
432
|
+
const hasFindings = (status === 'ok' || status === 'fallback-used') && items.length > 0;
|
|
433
|
+
if (!hasFindings) {
|
|
434
|
+
console.log(` ${capitalized}: no findings returned (status: ${status})`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
const issue = String(item.issue || '').slice(0, 80);
|
|
439
|
+
const sev = item.severity ? ` [${item.severity}]` : '';
|
|
440
|
+
console.log(` ${capitalized} found:${sev} ${issue}`);
|
|
441
|
+
attributed.push({ id, item });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return attributed;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function cmdDemo() {
|
|
448
|
+
const reachable = _anyAuditorReachable();
|
|
449
|
+
if (!reachable) {
|
|
450
|
+
console.log('No auditors reachable yet.');
|
|
451
|
+
console.log('Install codex or gemini, or set OPENAI_API_KEY / GEMINI_API_KEY, then run `ijfw demo`.');
|
|
452
|
+
console.log('Run `ijfw doctor` to see the full roster status.');
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
console.log('IJFW demo -- 30-second tour of the Trident');
|
|
457
|
+
console.log('');
|
|
458
|
+
|
|
459
|
+
const fixturePath = join(dirname(fileURLToPath(import.meta.url)), '../fixtures/demo-target.js');
|
|
460
|
+
if (!existsSync(fixturePath)) {
|
|
461
|
+
console.log('Demo fixture not found -- run `npm pack` or reinstall @ijfw/memory-server.');
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const target = readFileSync(fixturePath, 'utf8');
|
|
466
|
+
|
|
467
|
+
let result;
|
|
468
|
+
try {
|
|
469
|
+
// TODO post-merge: perAuditorTimeoutSec, minResponses, quiet are added by Item 2 agent.
|
|
470
|
+
// Passed through here; current orchestrator silently ignores unknown params.
|
|
471
|
+
result = await runCrossOp({
|
|
472
|
+
mode: 'audit',
|
|
473
|
+
target,
|
|
474
|
+
projectDir: process.cwd(),
|
|
475
|
+
perAuditorTimeoutSec: 30,
|
|
476
|
+
minResponses: 2,
|
|
477
|
+
quiet: true,
|
|
478
|
+
});
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.log(`Demo run encountered an issue: ${err.message}`);
|
|
481
|
+
process.exit(0);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { picks, auditorResults } = result;
|
|
485
|
+
|
|
486
|
+
if (!picks || picks.length === 0) {
|
|
487
|
+
console.log('No auditors responded this run.');
|
|
488
|
+
console.log('Install codex or gemini, or set OPENAI_API_KEY / GEMINI_API_KEY, then run `ijfw demo`.');
|
|
489
|
+
console.log('');
|
|
490
|
+
console.log('Run `ijfw cross audit <your-file>` when an auditor is reachable.');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
console.log('Findings:');
|
|
495
|
+
console.log('');
|
|
496
|
+
|
|
497
|
+
if (auditorResults && auditorResults.length === picks.length) {
|
|
498
|
+
// Per-auditor attribution (U11: read auditorResults pre-merge). The helper
|
|
499
|
+
// prints findings as a side effect; its return value is intentionally unused.
|
|
500
|
+
_printDemoFindings(picks, auditorResults);
|
|
501
|
+
} else {
|
|
502
|
+
// Graceful fallback to merged listing when auditorResults unavailable
|
|
503
|
+
const items = Array.isArray(result.merged) ? result.merged : [];
|
|
504
|
+
if (items.length === 0) {
|
|
505
|
+
console.log(' No findings returned.');
|
|
506
|
+
} else {
|
|
507
|
+
console.log(' Note: per-auditor attribution unavailable; showing merged findings.');
|
|
508
|
+
for (const item of items) {
|
|
509
|
+
const sev = item.severity ? ` [${item.severity}]` : '';
|
|
510
|
+
console.log(` ${sev} ${String(item.issue || '').slice(0, 80)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const allItems = Array.isArray(result.merged) ? result.merged : [];
|
|
516
|
+
const consensusCritical = allItems.filter(i => i.severity === 'critical' || i.severity === 'high').length;
|
|
517
|
+
console.log('');
|
|
518
|
+
console.log(`That was ${picks.length} AIs, one command. ${allItems.length} findings surfaced${consensusCritical > 0 ? `, ${consensusCritical} consensus-critical` : ''}.`);
|
|
519
|
+
console.log('Try `ijfw cross audit <your-file>` next.');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Doctor
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
// One-line install hints per auditor id. Used by cmdDoctor to tell the user
|
|
527
|
+
// the literal command, not just the dependency name.
|
|
528
|
+
const INSTALL_HINT = {
|
|
529
|
+
codex: 'npm install -g @openai/codex',
|
|
530
|
+
gemini: 'npm install -g @google/generative-ai-cli',
|
|
531
|
+
claude: 'npm install -g @anthropic-ai/claude-code',
|
|
532
|
+
copilot: 'gh extension install github/gh-copilot',
|
|
533
|
+
opencode: 'npm install -g opencode',
|
|
534
|
+
aider: 'pipx install aider-chat',
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// Integration depth definitions per platform.
|
|
538
|
+
// depth: list of detected capabilities that constitute "native" integration.
|
|
539
|
+
const INTEGRATION_DEPTH = {
|
|
540
|
+
claude: {
|
|
541
|
+
label: 'Claude Code',
|
|
542
|
+
checks: [
|
|
543
|
+
{ name: 'native plugin', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw')) || existsSync(join(homedir(), '.claude', 'settings.json')) },
|
|
544
|
+
{ name: 'skills', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'skills')) },
|
|
545
|
+
{ name: 'hooks', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'hooks')) },
|
|
546
|
+
{ name: 'agents', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'agents')) },
|
|
547
|
+
{ name: 'commands', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'commands')) },
|
|
548
|
+
{ name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory'] || s.enabledPlugins?.['ijfw-core@ijfw']); } catch { return false; } } },
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
codex: {
|
|
552
|
+
label: 'Codex',
|
|
553
|
+
checks: [
|
|
554
|
+
{ name: 'native skills', detect: () => existsSync(join(homedir(), '.codex', 'skills')) },
|
|
555
|
+
{ name: 'hooks', detect: () => existsSync(join(homedir(), '.codex', 'hooks.json')) },
|
|
556
|
+
{ name: 'context file', detect: () => existsSync(join(homedir(), '.codex', 'IJFW.md')) },
|
|
557
|
+
{ name: 'MCP', detect: () => { try { const t = readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8'); return t.includes('ijfw-memory'); } catch { return false; } } },
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
gemini: {
|
|
561
|
+
label: 'Gemini',
|
|
562
|
+
checks: [
|
|
563
|
+
{ name: 'native extension', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'gemini-extension.json')) },
|
|
564
|
+
{ name: 'skills', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'skills')) },
|
|
565
|
+
{ name: 'hooks', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'hooks', 'hooks.json')) },
|
|
566
|
+
{ name: 'commands', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'commands')) },
|
|
567
|
+
{ name: 'policy', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'policies', 'ijfw.toml')) },
|
|
568
|
+
{ name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.gemini', 'settings.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
cursor: {
|
|
572
|
+
label: 'Cursor',
|
|
573
|
+
checks: [
|
|
574
|
+
{ name: 'rules', detect: () => existsSync(join(process.cwd(), '.cursor', 'rules', 'ijfw.mdc')) },
|
|
575
|
+
{ name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(process.cwd(), '.cursor', 'mcp.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
windsurf: {
|
|
579
|
+
label: 'Windsurf',
|
|
580
|
+
checks: [
|
|
581
|
+
{ name: 'rules', detect: () => existsSync(join(process.cwd(), '.windsurfrules')) },
|
|
582
|
+
{ name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
|
|
583
|
+
],
|
|
584
|
+
},
|
|
585
|
+
copilot: {
|
|
586
|
+
label: 'Copilot',
|
|
587
|
+
checks: [
|
|
588
|
+
{ name: 'instructions', detect: () => existsSync(join(process.cwd(), '.github', 'copilot-instructions.md')) },
|
|
589
|
+
{ name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(process.cwd(), '.vscode', 'mcp.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
function cmdDoctor(opts = {}) {
|
|
595
|
+
const auditors = [];
|
|
596
|
+
for (const entry of ROSTER) {
|
|
597
|
+
isReachable(entry.id, process.env); // side effect: prime probe cache
|
|
598
|
+
const cli = isInstalled(entry.id);
|
|
599
|
+
const apiOk = Boolean(entry.apiFallback && process.env[entry.apiFallback.authEnv]);
|
|
600
|
+
auditors.push({
|
|
601
|
+
id: entry.id,
|
|
602
|
+
name: entry.name,
|
|
603
|
+
cli_installed: cli,
|
|
604
|
+
api_env: entry.apiFallback ? entry.apiFallback.authEnv : null,
|
|
605
|
+
api_set: apiOk,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const integrations = [];
|
|
610
|
+
for (const [id, def] of Object.entries(INTEGRATION_DEPTH)) {
|
|
611
|
+
const detected = def.checks
|
|
612
|
+
.filter(c => { try { return c.detect(); } catch { return false; } })
|
|
613
|
+
.map(c => c.name);
|
|
614
|
+
if (detected.length > 0) integrations.push({ id, label: def.label, components: detected });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const anyReachable = ROSTER.some(e => isReachable(e.id, process.env).any);
|
|
618
|
+
|
|
619
|
+
if (wantsJson(opts)) {
|
|
620
|
+
emitJson({ auditors, integrations, any_reachable: anyReachable });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
console.log('ijfw doctor -- roster + key probe');
|
|
625
|
+
console.log('');
|
|
626
|
+
|
|
627
|
+
for (const a of auditors) {
|
|
628
|
+
if (a.cli_installed) {
|
|
629
|
+
console.log(` [ ok ] ${a.id} CLI -- ${a.name} ready`);
|
|
630
|
+
} else {
|
|
631
|
+
const cmd = INSTALL_HINT[a.id] || `npm install -g ${a.id}`;
|
|
632
|
+
console.log(` [ .. ] ${a.id} CLI -- standing by`);
|
|
633
|
+
console.log(` fix: ${cmd}`);
|
|
634
|
+
}
|
|
635
|
+
if (a.api_env) {
|
|
636
|
+
if (a.api_set) {
|
|
637
|
+
console.log(` [ ok ] ${a.api_env} -- set`);
|
|
638
|
+
} else {
|
|
639
|
+
console.log(` [ .. ] ${a.api_env} -- standing by`);
|
|
640
|
+
console.log(` fix: export ${a.api_env}=<your-key> (or add to ~/.ijfw/env)`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
console.log('');
|
|
645
|
+
|
|
646
|
+
if (anyReachable) {
|
|
647
|
+
console.log('At least one auditor is reachable. Run `ijfw cross audit <file>` to start.');
|
|
648
|
+
} else {
|
|
649
|
+
console.log('IJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
console.log('');
|
|
653
|
+
console.log('Integration depth:');
|
|
654
|
+
if (integrations.length === 0) {
|
|
655
|
+
console.log(' Run `bash scripts/install.sh` in your IJFW repo to activate platform bundles.');
|
|
656
|
+
} else {
|
|
657
|
+
for (const i of integrations) console.log(` ${i.label}: ${i.components.join(' + ')}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// Purge receipts
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
function cmdPurgeReceipts(projectDir) {
|
|
666
|
+
const count = purgeReceipts(projectDir);
|
|
667
|
+
if (count === 0) {
|
|
668
|
+
console.log('Receipt log is already empty. Run `ijfw cross audit <file>` to generate entries.');
|
|
669
|
+
} else {
|
|
670
|
+
console.log(`Receipt log cleared -- ${count} entr${count === 1 ? 'y' : 'ies'} removed.`);
|
|
671
|
+
console.log('Run `ijfw cross audit <file>` to start fresh.');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Fixes issue #6: target path string was sent to auditors verbatim, causing
|
|
676
|
+
// hallucinated findings. If target resolves to a regular file on disk, read
|
|
677
|
+
// its contents (with a size cap) so auditors see real code. Topics, git
|
|
678
|
+
// ranges, and non-existent paths pass through unchanged.
|
|
679
|
+
const TARGET_FILE_SIZE_CAP = 64 * 1024; // 64 KB -- leaves prompt headroom
|
|
680
|
+
|
|
681
|
+
export function resolveTarget(raw, opts = {}) {
|
|
682
|
+
const cap = typeof opts.sizeCap === 'number' ? opts.sizeCap : TARGET_FILE_SIZE_CAP;
|
|
683
|
+
if (typeof raw !== 'string' || !raw) return raw;
|
|
684
|
+
|
|
685
|
+
let absPath;
|
|
686
|
+
try {
|
|
687
|
+
absPath = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
|
688
|
+
} catch {
|
|
689
|
+
return raw;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!existsSync(absPath)) return raw;
|
|
693
|
+
|
|
694
|
+
let stat;
|
|
695
|
+
try {
|
|
696
|
+
stat = statSync(absPath);
|
|
697
|
+
} catch {
|
|
698
|
+
return raw;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!stat.isFile()) return raw;
|
|
702
|
+
|
|
703
|
+
let contents;
|
|
704
|
+
try {
|
|
705
|
+
if (stat.size > cap) {
|
|
706
|
+
const buf = Buffer.alloc(cap);
|
|
707
|
+
const fd = openSync(absPath, 'r');
|
|
708
|
+
try {
|
|
709
|
+
readSync(fd, buf, 0, cap, 0);
|
|
710
|
+
} finally {
|
|
711
|
+
closeSync(fd);
|
|
712
|
+
}
|
|
713
|
+
contents = buf.toString('utf8')
|
|
714
|
+
+ `\n\n[... truncated: file is ${stat.size} bytes, showing first ${cap} ...]`;
|
|
715
|
+
} else {
|
|
716
|
+
contents = readFileSync(absPath, 'utf8');
|
|
717
|
+
}
|
|
718
|
+
} catch {
|
|
719
|
+
return raw;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return `File: ${raw}\n\n${contents}`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function cmdCross({ mode, target, only, confirm, expand }) {
|
|
726
|
+
const VALID_MODES = ['audit', 'research', 'critique'];
|
|
727
|
+
if (!mode || !VALID_MODES.includes(mode)) {
|
|
728
|
+
console.error(`ijfw cross requires a mode: ${VALID_MODES.join(', ')}. Example: ijfw cross audit <file>`);
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
if (!target) {
|
|
732
|
+
console.error('ijfw cross needs a target -- pass a file path, git range, or topic. Example: ijfw cross audit CLAUDE.md');
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Issue #6 fix: substitute file contents for path string when target is a
|
|
737
|
+
// regular file. Keep the raw target for the user-facing echo line.
|
|
738
|
+
const rawTarget = target;
|
|
739
|
+
target = resolveTarget(target);
|
|
740
|
+
|
|
741
|
+
// Polish 6: pre-flight reachability check. If no auditor is wired, give a
|
|
742
|
+
// positive recovery hint instead of bombing through to a runCrossOp error.
|
|
743
|
+
if (!_anyAuditorReachable()) {
|
|
744
|
+
console.log('');
|
|
745
|
+
console.log('Trident is standing by -- no auditors reachable yet.');
|
|
746
|
+
console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
|
|
747
|
+
console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
|
|
748
|
+
process.exit(0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const projectDir = process.cwd();
|
|
752
|
+
const runStamp = new Date().toISOString();
|
|
753
|
+
|
|
754
|
+
console.log(`\nijfw cross ${mode} -- target: ${rawTarget}`);
|
|
755
|
+
console.log('Probing roster...');
|
|
756
|
+
|
|
757
|
+
let result;
|
|
758
|
+
try {
|
|
759
|
+
result = await runCrossOp({ mode, target, projectDir, runStamp, only, confirm, expand });
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.log('');
|
|
762
|
+
console.log(`Run didn't complete: ${err.message}`);
|
|
763
|
+
console.log('Try `ijfw doctor` to see what to wire next.');
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const { merged, picks, note, auditorResults } = result;
|
|
768
|
+
|
|
769
|
+
if (picks.length === 0) {
|
|
770
|
+
console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
|
|
771
|
+
console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
|
|
776
|
+
|
|
777
|
+
if (note) {
|
|
778
|
+
console.log(`\nNote: ${note}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Issue #9-B + 1.2.5: surface silent auditor degradation AND translate the
|
|
782
|
+
// raw error signature into one actionable line. Old behavior dumped the
|
|
783
|
+
// first 80 chars of stderr, which read like a stack trace. New behavior:
|
|
784
|
+
// pattern-match common auth/timeout/safety/no-key signatures and render
|
|
785
|
+
// "this is what's wrong, this is how to fix it" -- so the user knows
|
|
786
|
+
// whether to re-auth, retry, or change combo without reading source.
|
|
787
|
+
if (auditorResults && auditorResults.length === picks.length) {
|
|
788
|
+
const degraded = [];
|
|
789
|
+
for (let i = 0; i < picks.length; i++) {
|
|
790
|
+
const r = auditorResults[i];
|
|
791
|
+
const id = picks[i].id;
|
|
792
|
+
if (r.status === 'failed' || r.status === 'timeout' || (r.status === 'empty' && r.stderr && r.stderr.trim())) {
|
|
793
|
+
degraded.push({ id, reason: translateAuditorError(id, r.status, r.stderr || '', r.exitCode) });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (degraded.length > 0) {
|
|
797
|
+
console.log('');
|
|
798
|
+
console.log('Heads up -- one or more auditors did not contribute this run:');
|
|
799
|
+
for (const d of degraded) {
|
|
800
|
+
console.log(` - ${d.id}: ${d.reason}`);
|
|
801
|
+
}
|
|
802
|
+
console.log('Trident lineage diversity is reduced for this result. Re-run after fixing the auditor, or pass --with <id> to force a different combination.');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
console.log('\nFindings:');
|
|
807
|
+
printFindings(mode, merged);
|
|
808
|
+
|
|
809
|
+
console.log('\nReceipt logged -- run `ijfw status` to see it.');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Portfolio audit -- `ijfw cross project-audit <rule-file>`
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
// Read the registry (same format as server.js: path|hash|iso lines). Lives
|
|
817
|
+
// here as a narrow duplicate so the CLI does not depend on server.js bootstrap.
|
|
818
|
+
function readProjectRegistry() {
|
|
819
|
+
const file = join(homedir(), '.ijfw', 'registry.md');
|
|
820
|
+
if (!existsSync(file)) return [];
|
|
821
|
+
const body = readFileSync(file, 'utf8');
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const line of body.split('\n')) {
|
|
824
|
+
const parts = line.split('|').map(s => s.trim());
|
|
825
|
+
if (parts.length < 3) continue;
|
|
826
|
+
const [path, hash, iso] = parts;
|
|
827
|
+
if (!path || !isAbsolute(path)) continue;
|
|
828
|
+
out.push({ path, hash, iso });
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function cmdCrossProjectAudit({ rule, dryRun }) {
|
|
834
|
+
if (!rule) {
|
|
835
|
+
console.error('Usage: ijfw cross project-audit <rule-file> [--dry-run]');
|
|
836
|
+
process.exit(1);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const resolvedRule = isAbsolute(rule) ? rule : resolve(process.cwd(), rule);
|
|
840
|
+
if (!existsSync(resolvedRule)) {
|
|
841
|
+
console.error(`Rule file not found: ${resolvedRule}`);
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const projects = readProjectRegistry();
|
|
846
|
+
if (projects.length === 0) {
|
|
847
|
+
console.log('No other IJFW projects registered yet.');
|
|
848
|
+
console.log('The registry auto-populates the first time you run any IJFW command in a project:');
|
|
849
|
+
console.log(' cd /path/to/another/project && ijfw status');
|
|
850
|
+
console.log('Then re-run: ijfw cross project-audit ' + rule);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
console.log(`Phase 12 / Wave 12B -- portfolio audit -- ${projects.length} project${projects.length === 1 ? '' : 's'}.`);
|
|
855
|
+
|
|
856
|
+
if (dryRun) {
|
|
857
|
+
for (const p of projects) console.log(` - ${basename(p.path)} (${p.path})`);
|
|
858
|
+
console.log('\n--dry-run: no audits dispatched. Drop the flag to fire.');
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const startedAt = new Date().toISOString();
|
|
863
|
+
const results = [];
|
|
864
|
+
for (const p of projects) {
|
|
865
|
+
const tag = basename(p.path);
|
|
866
|
+
console.log(` [${tag}] running cross audit ...`);
|
|
867
|
+
const r = spawnSync('ijfw', ['cross', 'audit', resolvedRule], {
|
|
868
|
+
cwd: p.path,
|
|
869
|
+
encoding: 'utf8',
|
|
870
|
+
timeout: 5 * 60 * 1000,
|
|
871
|
+
// shell:true on Windows so ijfw.cmd resolves through PATH.
|
|
872
|
+
shell: process.platform === 'win32',
|
|
873
|
+
});
|
|
874
|
+
if (r.error) {
|
|
875
|
+
results.push({ project: tag, path: p.path, status: 'failed', findings: '', error: `spawn-${r.error.code || 'unknown'}: ${r.error.message}` });
|
|
876
|
+
} else if (r.signal) {
|
|
877
|
+
// Without surfacing r.signal, killed children would otherwise report
|
|
878
|
+
// "exit null" -- portfolio aggregator treated that as silently OK.
|
|
879
|
+
results.push({ project: tag, path: p.path, status: 'failed', findings: r.stdout || '', error: `killed by ${r.signal} (timeout or OS signal)` });
|
|
880
|
+
} else if (r.status !== 0) {
|
|
881
|
+
results.push({ project: tag, path: p.path, status: 'failed', findings: r.stdout || '', error: (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}` });
|
|
882
|
+
} else {
|
|
883
|
+
results.push({ project: tag, path: p.path, status: 'ok', findings: r.stdout || '' });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const finishedAt = new Date().toISOString();
|
|
887
|
+
|
|
888
|
+
const body = aggregatePortfolioFindings(results, { rule: basename(resolvedRule), startedAt, finishedAt });
|
|
889
|
+
const outDir = join(process.cwd(), '.ijfw', 'memory');
|
|
890
|
+
mkdirSync(outDir, { recursive: true });
|
|
891
|
+
const outFile = join(outDir, `portfolio-audit-${finishedAt.replace(/[:.]/g, '-')}.md`);
|
|
892
|
+
writeFileSync(outFile, body, 'utf8');
|
|
893
|
+
console.log(`\nPortfolio findings written: ${outFile}`);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Import -- `ijfw import <tool> [--dry-run] [--force] [--path <p>]`
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
async function cmdImport(parsed) {
|
|
901
|
+
const tool = parsed.tool;
|
|
902
|
+
if (!tool) {
|
|
903
|
+
console.error(`Usage: ijfw import <tool> [--all] [--dry-run] [--force] [--path <p>]`);
|
|
904
|
+
console.error(`Tools: ${listImporters().join(', ')}`);
|
|
905
|
+
console.error(` --all Discover all projects and import each to its own .ijfw/memory/`);
|
|
906
|
+
process.exit(1);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (parsed.allMode) {
|
|
910
|
+
return cmdImportAll(parsed);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const result = await runImport({
|
|
914
|
+
tool,
|
|
915
|
+
dryRun: parsed.dryRun,
|
|
916
|
+
force: parsed.force,
|
|
917
|
+
includeMetrics: parsed.includeMetrics,
|
|
918
|
+
path: parsed.customPath,
|
|
919
|
+
});
|
|
920
|
+
if (!result.ok) {
|
|
921
|
+
console.error(result.error);
|
|
922
|
+
process.exit(1);
|
|
923
|
+
}
|
|
924
|
+
console.log(result.summary);
|
|
925
|
+
if (result.dryRun && result.samples && result.samples.length > 0) {
|
|
926
|
+
console.log('\nSample entries (dry-run):');
|
|
927
|
+
for (const s of result.samples) console.log(` - [${s.type}] ${s.summary || '(no title)'}`);
|
|
928
|
+
console.log('\nRe-run without --dry-run to write them to .ijfw/memory/.');
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function cmdImportAll(parsed) {
|
|
933
|
+
// Phase 1: always do a dry-run preview first, regardless of user's --dry-run flag.
|
|
934
|
+
const preview = await runImportAll({
|
|
935
|
+
tool: parsed.tool,
|
|
936
|
+
dryRun: true,
|
|
937
|
+
force: parsed.force,
|
|
938
|
+
path: parsed.customPath,
|
|
939
|
+
});
|
|
940
|
+
if (!preview.ok) {
|
|
941
|
+
console.error(preview.error);
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const plan = preview.plan;
|
|
946
|
+
console.log(`\nDiscovered ${plan.matched.length + plan.ambiguous.length + plan.unmatched.length} projects in ${parsed.tool} (${plan.totalEntries} total entries).\n`);
|
|
947
|
+
|
|
948
|
+
if (plan.matched.length > 0) {
|
|
949
|
+
console.log(`Auto-matched (${plan.matched.length}):`);
|
|
950
|
+
for (const m of plan.matched) {
|
|
951
|
+
console.log(` [${m.entryCount.toString().padStart(5)}] ${m.project.padEnd(30)} -> ${m.path}`);
|
|
952
|
+
}
|
|
953
|
+
console.log('');
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (plan.ambiguous.length > 0) {
|
|
957
|
+
console.log(`Ambiguous (${plan.ambiguous.length}) -- will go to global archive:`);
|
|
958
|
+
for (const a of plan.ambiguous) {
|
|
959
|
+
const paths = a.candidates.slice(0, 3).map((c) => c.path).join(' OR ');
|
|
960
|
+
console.log(` [${a.entryCount.toString().padStart(5)}] ${a.project.padEnd(30)} -> ${paths}`);
|
|
961
|
+
}
|
|
962
|
+
console.log('');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (plan.unmatched.length > 0) {
|
|
966
|
+
console.log(`No local match (${plan.unmatched.length}) -- will go to global archive:`);
|
|
967
|
+
for (const u of plan.unmatched) {
|
|
968
|
+
console.log(` [${u.entryCount.toString().padStart(5)}] ${u.project}`);
|
|
969
|
+
}
|
|
970
|
+
console.log('');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (parsed.dryRun) {
|
|
974
|
+
console.log('Dry run only. Re-run without --dry-run to execute.');
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// --all is destructive (writes to multiple project dirs). Require --yes.
|
|
979
|
+
if (!parsed.yes) {
|
|
980
|
+
console.log('This will import into all matched projects. Re-run with --yes to confirm.');
|
|
981
|
+
console.log(' ijfw import ' + parsed.tool + ' --all --yes');
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Phase 2: execute.
|
|
986
|
+
console.log('Importing...\n');
|
|
987
|
+
const result = await runImportAll({
|
|
988
|
+
tool: parsed.tool,
|
|
989
|
+
dryRun: false,
|
|
990
|
+
force: parsed.force,
|
|
991
|
+
path: parsed.customPath,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
if (!result.ok) {
|
|
995
|
+
console.error('Some imports failed. See per-project results below.');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Stats categories from common.js emptyStats(): decisions, patterns,
|
|
999
|
+
// observations, handoffs, preferences, skipped, failed, total.
|
|
1000
|
+
const WRITTEN_KEYS = ['decisions', 'patterns', 'observations', 'handoffs', 'preferences'];
|
|
1001
|
+
const sumWritten = (s) => s ? WRITTEN_KEYS.reduce((n, k) => n + (s[k] || 0), 0) : 0;
|
|
1002
|
+
|
|
1003
|
+
let totalWritten = 0, totalSkipped = 0, totalFailed = 0;
|
|
1004
|
+
for (const r of result.results) {
|
|
1005
|
+
if (r.stats) {
|
|
1006
|
+
totalWritten += sumWritten(r.stats);
|
|
1007
|
+
totalSkipped += (r.stats.skipped || 0);
|
|
1008
|
+
totalFailed += (r.stats.failed || 0);
|
|
1009
|
+
}
|
|
1010
|
+
const status = r.ok === false ? 'FAILED' : 'OK';
|
|
1011
|
+
const written = sumWritten(r.stats);
|
|
1012
|
+
const skipped = r.stats ? (r.stats.skipped || 0) : 0;
|
|
1013
|
+
console.log(` [${status}] ${r.project.padEnd(30)} -> ${r.path} (${written} written, ${skipped} skipped)`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (result.orphanResult) {
|
|
1017
|
+
console.log(`\nGlobal archive (~/.ijfw/memory/global-archive/):`);
|
|
1018
|
+
for (const p of result.orphanResult.projects) {
|
|
1019
|
+
const written = sumWritten(p.stats);
|
|
1020
|
+
const skipped = p.stats ? (p.stats.skipped || 0) : 0;
|
|
1021
|
+
totalWritten += written;
|
|
1022
|
+
totalSkipped += skipped;
|
|
1023
|
+
console.log(` ${p.ok !== false ? '[OK]' : '[FAILED]'} ${p.project} (${written} written, ${skipped} skipped)`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
console.log(`\nTotal: ${totalWritten} written, ${totalSkipped} skipped (already present), ${totalFailed} failed.`);
|
|
1028
|
+
console.log('Done.');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ---------------------------------------------------------------------------
|
|
1032
|
+
// Update -- `ijfw update` (polish 5)
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
//
|
|
1035
|
+
// Walks up from the launcher to the IJFW source repo, runs git pull, and
|
|
1036
|
+
// reruns scripts/install.sh in merge-safe mode. Designed for users who
|
|
1037
|
+
// installed via git clone (the canonical path). For users on
|
|
1038
|
+
// `npm install -g @ijfw/install`, hints them at the npm command instead.
|
|
1039
|
+
|
|
1040
|
+
// v1.1.6 update CLI -- replaces the v1.1.5 git-pull-only flow.
|
|
1041
|
+
// Subflags:
|
|
1042
|
+
// ijfw update interactive update (provenance + shasum verified)
|
|
1043
|
+
// ijfw update --check non-invasive availability check
|
|
1044
|
+
// ijfw update --yes non-interactive (terminal-only; rejects MCP context)
|
|
1045
|
+
// ijfw update --verify verification dry-run (no install)
|
|
1046
|
+
// ijfw update --changelog full release notes for available version
|
|
1047
|
+
// ijfw update --confirm <token> consume MCP-issued token, run update
|
|
1048
|
+
// ijfw update --auto on|off|ask set/query auto-update preference
|
|
1049
|
+
function cmdUpdate(opts = {}) {
|
|
1050
|
+
if (opts.auto !== undefined) return cmdUpdateAuto(opts.auto);
|
|
1051
|
+
if (opts.check) return cmdUpdateCheck();
|
|
1052
|
+
if (opts.verify) return cmdUpdateVerify();
|
|
1053
|
+
if (opts.changelog) return cmdUpdateChangelog();
|
|
1054
|
+
if (opts.confirm) return cmdUpdateConfirm(opts.confirm);
|
|
1055
|
+
process.exit(cmdUpdateInteractive(opts));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function ijfwHome() {
|
|
1059
|
+
return process.env.IJFW_HOME || join(process.env.HOME || homedir(), '.ijfw');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function readJsonSafe(path) {
|
|
1063
|
+
try {
|
|
1064
|
+
if (!existsSync(path)) return null;
|
|
1065
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1066
|
+
} catch { return null; }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function npmViewVersion(pkg = '@ijfw/install') {
|
|
1070
|
+
// shell:true on Windows so npm.cmd / npm.bat resolve. Without it, Node's
|
|
1071
|
+
// spawnSync can't find npm and returns ENOENT before the command runs.
|
|
1072
|
+
// pkg is hardcoded internally so there is no injection surface.
|
|
1073
|
+
const r = spawnSync('npm', ['view', pkg, 'version', '--json'], {
|
|
1074
|
+
encoding: 'utf8',
|
|
1075
|
+
timeout: 10_000,
|
|
1076
|
+
shell: process.platform === 'win32',
|
|
1077
|
+
});
|
|
1078
|
+
// Distinguish three failure modes so users + bug reports get actionable text
|
|
1079
|
+
// instead of a generic "npm view failed".
|
|
1080
|
+
if (r.error) {
|
|
1081
|
+
const code = r.error.code || 'unknown';
|
|
1082
|
+
return { ok: false, message: `spawn-${code}: ${(r.error.message || '').slice(0, 120)}` };
|
|
1083
|
+
}
|
|
1084
|
+
if (r.signal) {
|
|
1085
|
+
return { ok: false, message: `killed by ${r.signal} (likely network timeout)` };
|
|
1086
|
+
// r.status === null when killed by signal; explicit branch keeps the next
|
|
1087
|
+
// check clean.
|
|
1088
|
+
}
|
|
1089
|
+
if (r.status !== 0) {
|
|
1090
|
+
const stderr = (r.stderr || '').trim();
|
|
1091
|
+
return { ok: false, message: stderr || `npm view exited ${r.status} with no stderr` };
|
|
1092
|
+
}
|
|
1093
|
+
const raw = (r.stdout || '').trim().replace(/^"|"$/g, '');
|
|
1094
|
+
if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(raw)) return { ok: false, message: `malformed: ${raw.slice(0, 80)}` };
|
|
1095
|
+
return { ok: true, version: raw };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function cmpSemver(a, b) {
|
|
1099
|
+
const parse = v => {
|
|
1100
|
+
const [main, pre] = String(v).split('-', 2);
|
|
1101
|
+
const nums = main.split('.').map(n => parseInt(n, 10) || 0);
|
|
1102
|
+
while (nums.length < 3) nums.push(0);
|
|
1103
|
+
return { nums, pre: pre || null };
|
|
1104
|
+
};
|
|
1105
|
+
const A = parse(a); const B = parse(b);
|
|
1106
|
+
for (let i = 0; i < 3; i++) {
|
|
1107
|
+
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
|
|
1108
|
+
}
|
|
1109
|
+
if (A.pre === B.pre) return 0;
|
|
1110
|
+
if (A.pre && !B.pre) return -1;
|
|
1111
|
+
if (!A.pre && B.pre) return 1;
|
|
1112
|
+
return A.pre < B.pre ? -1 : 1;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
|
|
1116
|
+
function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
|
|
1117
|
+
function writeStateFields(updates) {
|
|
1118
|
+
const path = join(ijfwHome(), 'state.json');
|
|
1119
|
+
const state = Object.assign(readState(), updates);
|
|
1120
|
+
try {
|
|
1121
|
+
writeAtomic(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
console.error(`could not persist state.json: ${e.message}`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function cmdUpdateCheck() {
|
|
1128
|
+
const state = readState();
|
|
1129
|
+
const current = state.installed_version || '0.0.0';
|
|
1130
|
+
const r = npmViewVersion('@ijfw/install');
|
|
1131
|
+
if (!r.ok) {
|
|
1132
|
+
console.log(`IJFW: update check failed (${r.message}). Will retry next session.`);
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
const cmp = cmpSemver(current, r.version);
|
|
1136
|
+
if (cmp >= 0) {
|
|
1137
|
+
console.log(`IJFW is up to date (v${current}).`);
|
|
1138
|
+
process.exit(0);
|
|
1139
|
+
}
|
|
1140
|
+
// Exit 0 + machine-readable sentinel so shell scripts can grep rather than
|
|
1141
|
+
// rely on exit codes. Previously exited 3 which broke POSIX if-statements.
|
|
1142
|
+
console.log(`update-available: ${r.version}`);
|
|
1143
|
+
console.log(`Update available: v${current} -> v${r.version}`);
|
|
1144
|
+
console.log(` Release notes: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
|
|
1145
|
+
console.log(` Run: ijfw update`);
|
|
1146
|
+
process.exit(0);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function cmdUpdateVerify() {
|
|
1150
|
+
const state = readState();
|
|
1151
|
+
const current = state.installed_version || '0.0.0';
|
|
1152
|
+
const r = npmViewVersion('@ijfw/install');
|
|
1153
|
+
console.log('IJFW update verification:');
|
|
1154
|
+
console.log(` installed: v${current}`);
|
|
1155
|
+
console.log(` install_method: ${state.install_method || 'unknown'}`);
|
|
1156
|
+
console.log(` last_good_shasum: ${state.last_good_shasum || '(none)'}`);
|
|
1157
|
+
if (!r.ok) {
|
|
1158
|
+
console.log(` registry: UNREACHABLE (${r.message})`);
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
console.log(` registry latest: v${r.version}`);
|
|
1162
|
+
// Provenance check via npm audit signatures
|
|
1163
|
+
const sig = spawnSync('npm', ['audit', 'signatures', `@ijfw/install@${r.version}`], {
|
|
1164
|
+
encoding: 'utf8',
|
|
1165
|
+
timeout: 15_000,
|
|
1166
|
+
shell: process.platform === 'win32',
|
|
1167
|
+
});
|
|
1168
|
+
if (sig.status === 0) {
|
|
1169
|
+
console.log(` provenance: VERIFIED (npm audit signatures)`);
|
|
1170
|
+
} else {
|
|
1171
|
+
console.log(` provenance: NOT VERIFIED (audit signatures exited ${sig.status})`);
|
|
1172
|
+
}
|
|
1173
|
+
console.log('Verification complete.');
|
|
1174
|
+
process.exit(0);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function cmdUpdateChangelog() {
|
|
1178
|
+
const r = npmViewVersion('@ijfw/install');
|
|
1179
|
+
if (!r.ok) {
|
|
1180
|
+
console.error(`could not fetch latest version: ${r.message}`);
|
|
1181
|
+
process.exit(1);
|
|
1182
|
+
}
|
|
1183
|
+
const url = `https://api.github.com/repos/therealseandonahoe/ijfw/releases/tags/v${r.version}`;
|
|
1184
|
+
const fetchRes = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], { encoding: 'utf8', timeout: 10_000 });
|
|
1185
|
+
if (fetchRes.status !== 0) {
|
|
1186
|
+
console.log(`No release notes available for v${r.version}.`);
|
|
1187
|
+
console.log(`Visit: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
|
|
1188
|
+
process.exit(0);
|
|
1189
|
+
}
|
|
1190
|
+
let body = '';
|
|
1191
|
+
try {
|
|
1192
|
+
const data = JSON.parse(fetchRes.stdout || '{}');
|
|
1193
|
+
body = data.body || '(no body)';
|
|
1194
|
+
} catch { body = '(could not parse release JSON)'; }
|
|
1195
|
+
// ANSI strip + cap 4KB. Control-char regex is intentional -- defangs
|
|
1196
|
+
// CHANGELOG bytes fetched over HTTPS so paste into the terminal can't
|
|
1197
|
+
// execute escape sequences. (oxlint flags this as no-control-regex; OK.)
|
|
1198
|
+
// oxlint-disable no-control-regex
|
|
1199
|
+
const stripped = body
|
|
1200
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
1201
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
1202
|
+
.slice(0, 4096);
|
|
1203
|
+
// oxlint-enable no-control-regex
|
|
1204
|
+
console.log(`Changelog for v${r.version}`);
|
|
1205
|
+
console.log('');
|
|
1206
|
+
console.log(stripped);
|
|
1207
|
+
if (body.length > 4096) console.log(`\n... (truncated; full notes at https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version})`);
|
|
1208
|
+
process.exit(0);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function cmdUpdateAuto(value) {
|
|
1212
|
+
const valid = ['on', 'off', 'ask'];
|
|
1213
|
+
if (!valid.includes(value)) {
|
|
1214
|
+
console.error(`--auto must be one of: ${valid.join(', ')}`);
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
}
|
|
1217
|
+
const settingsPath = join(ijfwHome(), 'settings.json');
|
|
1218
|
+
const settings = readSettings();
|
|
1219
|
+
if (!settings.schema_version) settings.schema_version = 1;
|
|
1220
|
+
settings.auto_update = value;
|
|
1221
|
+
try {
|
|
1222
|
+
writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
|
1223
|
+
console.log(`auto_update set to "${value}".`);
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
console.error(`could not persist settings.json: ${e.message}`);
|
|
1226
|
+
process.exit(1);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function cmdUpdateConfirm(token) {
|
|
1231
|
+
// Locate pending sentinel under any session in ~/.ijfw/run/
|
|
1232
|
+
const runRoot = join(ijfwHome(), 'run');
|
|
1233
|
+
if (!existsSync(runRoot)) {
|
|
1234
|
+
console.error('No pending update sentinel found. Run `ijfw_update_apply` via your AI first.');
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
let sentinelPath = null;
|
|
1238
|
+
try {
|
|
1239
|
+
for (const dir of readdirSync(runRoot)) {
|
|
1240
|
+
const candidate = join(runRoot, dir, 'update-pending.json');
|
|
1241
|
+
if (existsSync(candidate)) { sentinelPath = candidate; break; }
|
|
1242
|
+
}
|
|
1243
|
+
} catch { /* */ }
|
|
1244
|
+
if (!sentinelPath) {
|
|
1245
|
+
console.error('No pending update sentinel found. The MCP `ijfw_update_apply` tool issues sentinels.');
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
const pending = readJsonSafe(sentinelPath);
|
|
1249
|
+
if (!pending || pending.token !== token) {
|
|
1250
|
+
console.error('Token mismatch -- run ijfw_update_check + ijfw_update_apply via your AI to issue a fresh token.');
|
|
1251
|
+
process.exit(1);
|
|
1252
|
+
}
|
|
1253
|
+
if (process.env.IJFW_FROM_MCP === '1') {
|
|
1254
|
+
console.error('Refusing: --confirm must be invoked from a terminal, not an MCP-spawned subprocess.');
|
|
1255
|
+
process.exit(1);
|
|
1256
|
+
}
|
|
1257
|
+
console.log(`Confirming update to v${pending.target_version}...`);
|
|
1258
|
+
// Hold sentinel through install; remove synchronously on all paths
|
|
1259
|
+
// (happy + error + signal). Node does NOT run pending `finally` blocks
|
|
1260
|
+
// on SIGINT/SIGTERM by default, so register explicit handlers that
|
|
1261
|
+
// scrub the sentinel before exit.
|
|
1262
|
+
const scrubAndExit = (sig) => {
|
|
1263
|
+
try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
|
|
1264
|
+
// Mimic the shell's default exit code for the signal: 128 + signum.
|
|
1265
|
+
const signum = sig === 'SIGINT' ? 2 : sig === 'SIGTERM' ? 15 : 1;
|
|
1266
|
+
process.exit(128 + signum);
|
|
1267
|
+
};
|
|
1268
|
+
process.on('SIGINT', () => scrubAndExit('SIGINT'));
|
|
1269
|
+
process.on('SIGTERM', () => scrubAndExit('SIGTERM'));
|
|
1270
|
+
let exitCode = 1;
|
|
1271
|
+
try {
|
|
1272
|
+
exitCode = cmdUpdateInteractive({ yes: true, _confirmedFromToken: pending.target_version });
|
|
1273
|
+
} finally {
|
|
1274
|
+
try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
|
|
1275
|
+
}
|
|
1276
|
+
process.exit(exitCode);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function cmdUpdateInteractive(opts = {}) {
|
|
1280
|
+
if (process.env.IJFW_FROM_MCP === '1' && opts.yes) {
|
|
1281
|
+
console.error('Refusing: `ijfw update --yes` from an MCP-spawned context. Run from your terminal.');
|
|
1282
|
+
return 1;
|
|
1283
|
+
}
|
|
1284
|
+
const state = readState();
|
|
1285
|
+
const current = state.installed_version || '0.0.0';
|
|
1286
|
+
const r = npmViewVersion('@ijfw/install');
|
|
1287
|
+
if (!r.ok) {
|
|
1288
|
+
console.error(`Update check failed: ${r.message}`);
|
|
1289
|
+
return 1;
|
|
1290
|
+
}
|
|
1291
|
+
const cmp = cmpSemver(current, r.version);
|
|
1292
|
+
if (cmp >= 0) {
|
|
1293
|
+
console.log(`IJFW is up to date (v${current}). Nothing to do.`);
|
|
1294
|
+
return 0;
|
|
1295
|
+
}
|
|
1296
|
+
console.log(`IJFW update v${current} -> v${r.version}`);
|
|
1297
|
+
console.log('');
|
|
1298
|
+
// Provenance check (best-effort; report but don't block on transient failures)
|
|
1299
|
+
const sig = spawnSync('npm', ['audit', 'signatures', `@ijfw/install@${r.version}`], {
|
|
1300
|
+
encoding: 'utf8',
|
|
1301
|
+
timeout: 15_000,
|
|
1302
|
+
shell: process.platform === 'win32',
|
|
1303
|
+
});
|
|
1304
|
+
if (sig.status === 0) {
|
|
1305
|
+
console.log(' Provenance: verified');
|
|
1306
|
+
} else {
|
|
1307
|
+
console.log(' Provenance: WARNING -- could not verify signatures');
|
|
1308
|
+
if (!opts.yes) {
|
|
1309
|
+
console.log(' Continuing requires --yes (acknowledge unverified provenance).');
|
|
1310
|
+
return 1;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// Method dispatch
|
|
1314
|
+
const method = state.install_method || 'manual';
|
|
1315
|
+
console.log(` install_method: ${method}`);
|
|
1316
|
+
let installRes;
|
|
1317
|
+
if (method === 'npm-global') {
|
|
1318
|
+
console.log(' Running: npm install -g @ijfw/install@latest');
|
|
1319
|
+
installRes = spawnSync('npm', ['install', '-g', `@ijfw/install@${r.version}`], {
|
|
1320
|
+
stdio: 'inherit',
|
|
1321
|
+
shell: process.platform === 'win32',
|
|
1322
|
+
});
|
|
1323
|
+
if (installRes.error) {
|
|
1324
|
+
console.error(`npm install -g could not be spawned (${installRes.error.code}). Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
|
|
1325
|
+
return 1;
|
|
1326
|
+
}
|
|
1327
|
+
if (installRes.signal) {
|
|
1328
|
+
console.error(`npm install -g killed by ${installRes.signal}. Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
|
|
1329
|
+
return 1;
|
|
1330
|
+
}
|
|
1331
|
+
if (installRes.status !== 0) {
|
|
1332
|
+
console.error(`npm install -g did not complete (exit ${installRes.status}). Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
|
|
1333
|
+
return 1;
|
|
1334
|
+
}
|
|
1335
|
+
// npm install -g only refreshes the CLI shim. The mcp-server payload under
|
|
1336
|
+
// ~/.ijfw/mcp-server/ comes from the git tree and is only refreshed when
|
|
1337
|
+
// ijfw-install runs. Without this, `ijfw update` reports "updated" while
|
|
1338
|
+
// the actual MCP tools keep running stale code until the next manual
|
|
1339
|
+
// ijfw-install. Auto-invoke ijfw-install so the upgrade self-completes.
|
|
1340
|
+
console.log(' Refreshing ~/.ijfw/ via ijfw-install...');
|
|
1341
|
+
const refresh = spawnSync('ijfw-install', [], {
|
|
1342
|
+
stdio: 'inherit',
|
|
1343
|
+
shell: process.platform === 'win32',
|
|
1344
|
+
});
|
|
1345
|
+
if (refresh.error) {
|
|
1346
|
+
console.error(`Auto-refresh could not be spawned (${refresh.error.code}). Run \`ijfw-install\` manually to finish the upgrade.`);
|
|
1347
|
+
return 1;
|
|
1348
|
+
}
|
|
1349
|
+
if (refresh.signal) {
|
|
1350
|
+
console.error(`Auto-refresh killed by ${refresh.signal}. Run \`ijfw-install\` manually to finish the upgrade.`);
|
|
1351
|
+
return 1;
|
|
1352
|
+
}
|
|
1353
|
+
if (refresh.status !== 0) {
|
|
1354
|
+
console.error(`Auto-refresh did not complete (exit ${refresh.status}). Run \`ijfw-install\` manually to finish the upgrade.`);
|
|
1355
|
+
return 1;
|
|
1356
|
+
}
|
|
1357
|
+
} else if (method === 'git-clone') {
|
|
1358
|
+
const repoRoot = repoRootFromCli();
|
|
1359
|
+
const installSh = join(repoRoot, 'scripts', 'install.sh');
|
|
1360
|
+
console.log(' Running: git pull + scripts/install.sh');
|
|
1361
|
+
// Capture stderr explicitly so bug reports include the why -- previously a
|
|
1362
|
+
// network/auth/conflict failure surfaced only as "git pull failed".
|
|
1363
|
+
const pull = spawnSync('git', ['-C', repoRoot, 'pull', '--ff-only'], { encoding: 'utf8' });
|
|
1364
|
+
if (pull.status !== 0) {
|
|
1365
|
+
console.error('git pull failed:');
|
|
1366
|
+
if (pull.stderr) console.error(pull.stderr.trim().split('\n').map(l => ' ' + l).join('\n'));
|
|
1367
|
+
console.error(` Run \`git -C ${repoRoot} status\` to inspect.`);
|
|
1368
|
+
return 1;
|
|
1369
|
+
}
|
|
1370
|
+
console.log(' Running: npm install --omit=dev --ignore-scripts');
|
|
1371
|
+
const npmInstall = spawnSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
|
|
1372
|
+
cwd: repoRoot,
|
|
1373
|
+
stdio: 'inherit',
|
|
1374
|
+
shell: process.platform === 'win32',
|
|
1375
|
+
});
|
|
1376
|
+
if (npmInstall.error) {
|
|
1377
|
+
console.error(`npm install could not be spawned (${npmInstall.error.code}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
|
|
1378
|
+
return 1;
|
|
1379
|
+
}
|
|
1380
|
+
if (npmInstall.signal) {
|
|
1381
|
+
console.error(`npm install killed by ${npmInstall.signal}. Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
|
|
1382
|
+
return 1;
|
|
1383
|
+
}
|
|
1384
|
+
if (npmInstall.status !== 0) {
|
|
1385
|
+
console.error(`npm install did not complete (exit ${npmInstall.status}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
|
|
1386
|
+
return 1;
|
|
1387
|
+
}
|
|
1388
|
+
installRes = spawnSync('bash', [installSh], { stdio: 'inherit' });
|
|
1389
|
+
} else {
|
|
1390
|
+
console.log(' Manual install detected -- run: npx @ijfw/install');
|
|
1391
|
+
installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], { stdio: 'inherit' });
|
|
1392
|
+
}
|
|
1393
|
+
if (!installRes) {
|
|
1394
|
+
console.error('Update did not complete (install command could not be spawned)');
|
|
1395
|
+
return 1;
|
|
1396
|
+
}
|
|
1397
|
+
if (installRes.signal) {
|
|
1398
|
+
console.error(`Update did not complete (killed by ${installRes.signal}). State not written.`);
|
|
1399
|
+
return 1;
|
|
1400
|
+
}
|
|
1401
|
+
if (installRes.status !== 0) {
|
|
1402
|
+
console.error(`Update did not complete (exit ${installRes.status}). State not written.`);
|
|
1403
|
+
return 1;
|
|
1404
|
+
}
|
|
1405
|
+
// Persist both fields atomically -- single write avoids concurrent-reader inconsistency
|
|
1406
|
+
writeStateFields({ last_applied_version: r.version, installed_version: r.version });
|
|
1407
|
+
console.log('');
|
|
1408
|
+
console.log(`IJFW updated to v${r.version}. Run \`ijfw status\` to confirm.`);
|
|
1409
|
+
return 0;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// `ijfw --version` (pure) and `ijfw --version --verbose` per v3 �section MEDIUM
|
|
1413
|
+
function cmdVersion(opts = {}) {
|
|
1414
|
+
const root = repoRootFromCli();
|
|
1415
|
+
const pkgPath = join(root, 'installer', 'package.json');
|
|
1416
|
+
let version = 'unknown';
|
|
1417
|
+
try { version = JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown'; } catch { /* */ }
|
|
1418
|
+
|
|
1419
|
+
// `ijfw --version` is a stable shell-script contract -- only switch to
|
|
1420
|
+
// JSON when --json is explicit. status/doctor follow the gh-CLI convention
|
|
1421
|
+
// and auto-JSON on non-TTY, but version stays one-line on pipe.
|
|
1422
|
+
if (opts.json) {
|
|
1423
|
+
if (!opts.verbose) {
|
|
1424
|
+
emitJson({ package: '@ijfw/install', version });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const state = readState();
|
|
1428
|
+
const settings = readSettings();
|
|
1429
|
+
emitJson({
|
|
1430
|
+
package: '@ijfw/install',
|
|
1431
|
+
version,
|
|
1432
|
+
install_method: state.install_method || null,
|
|
1433
|
+
installed_at: state.installed_at ? new Date(state.installed_at * 1000).toISOString() : null,
|
|
1434
|
+
last_applied: state.last_applied_version || null,
|
|
1435
|
+
auto_update: settings.auto_update || 'ask',
|
|
1436
|
+
update_check_interval_hours: (settings.update_check && settings.update_check.interval_hours) || 24,
|
|
1437
|
+
ijfw_home: ijfwHome(),
|
|
1438
|
+
});
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (!opts.verbose) {
|
|
1443
|
+
console.log(`@ijfw/install@${version}`);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const state = readState();
|
|
1447
|
+
const settings = readSettings();
|
|
1448
|
+
console.log(`@ijfw/install@${version}`);
|
|
1449
|
+
console.log(` install_method: ${state.install_method || '(not recorded)'}`);
|
|
1450
|
+
console.log(` installed_at: ${state.installed_at ? new Date(state.installed_at * 1000).toISOString() : '(unknown)'}`);
|
|
1451
|
+
console.log(` last_applied: ${state.last_applied_version || '(none)'}`);
|
|
1452
|
+
console.log(` auto_update: ${settings.auto_update || 'ask'}`);
|
|
1453
|
+
console.log(` bg update check: every ${settings.update_check && settings.update_check.interval_hours || 24}h`);
|
|
1454
|
+
console.log(` kill switches: IJFW_DISABLE_UPDATE_CHECK={1,true,yes,on}`);
|
|
1455
|
+
console.log(` ijfw home: ${ijfwHome()}`);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// 1.1.6b statusline CLI family. Manages the Claude Code statusLine slot
|
|
1459
|
+
// at ~/.claude/settings.json. Compose-mode allowlist + change-detection per v3 sec 8.
|
|
1460
|
+
|
|
1461
|
+
const STATUSLINE_PATH_ALLOWLIST = [
|
|
1462
|
+
// Canonical compose targets -- canonicalized at compare time
|
|
1463
|
+
'/.claude/', '/.gsd/', '/.ijfw/claude/', '/.cursor/',
|
|
1464
|
+
];
|
|
1465
|
+
|
|
1466
|
+
function statuslineScriptPath() {
|
|
1467
|
+
// Where Claude Code reads the statusline command from
|
|
1468
|
+
const root = repoRootFromCli();
|
|
1469
|
+
return join(root, 'claude', 'hooks', 'scripts', 'ijfw-statusline.js');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function readClaudeSettings() {
|
|
1473
|
+
const path = join(homedir(), '.claude', 'settings.json');
|
|
1474
|
+
return { path, data: readJsonSafe(path) || {} };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function writeClaudeSettings(path, data) {
|
|
1478
|
+
try {
|
|
1479
|
+
writeAtomic(path, JSON.stringify(data, null, 2) + '\n');
|
|
1480
|
+
return true;
|
|
1481
|
+
} catch (e) {
|
|
1482
|
+
console.error(`could not write ${path}: ${e.message}`);
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function setIjfwStatuslineSetting(field, value) {
|
|
1488
|
+
const settingsPath = join(ijfwHome(), 'settings.json');
|
|
1489
|
+
const settings = readSettings();
|
|
1490
|
+
if (!settings.schema_version) settings.schema_version = 1;
|
|
1491
|
+
if (!settings.statusline) settings.statusline = {};
|
|
1492
|
+
settings.statusline[field] = value;
|
|
1493
|
+
try {
|
|
1494
|
+
writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
|
1495
|
+
return true;
|
|
1496
|
+
} catch (e) {
|
|
1497
|
+
console.error(`could not persist settings.json: ${e.message}`);
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function isPathInAllowlist(p) {
|
|
1503
|
+
if (!p || typeof p !== 'string') return false;
|
|
1504
|
+
for (const seg of STATUSLINE_PATH_ALLOWLIST) {
|
|
1505
|
+
if (p.includes(seg)) return true;
|
|
1506
|
+
}
|
|
1507
|
+
return false;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function cmdStatusline(sub) {
|
|
1511
|
+
switch (sub) {
|
|
1512
|
+
case 'status': return statuslineStatus();
|
|
1513
|
+
case 'install': return statuslineInstall();
|
|
1514
|
+
case 'compose': return statuslineCompose();
|
|
1515
|
+
case 'disable': return statuslineDisable();
|
|
1516
|
+
case 'recompute': return statuslineRecompute();
|
|
1517
|
+
default:
|
|
1518
|
+
console.log('Usage: ijfw statusline --install|--compose|--disable|--status|--recompute');
|
|
1519
|
+
process.exit(1);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function statuslineStatus() {
|
|
1524
|
+
const settings = readSettings();
|
|
1525
|
+
const claude = readClaudeSettings();
|
|
1526
|
+
const claudeStatusline = claude.data.statusLine;
|
|
1527
|
+
|
|
1528
|
+
console.log('IJFW statusline status:');
|
|
1529
|
+
console.log(` IJFW setting: ${(settings.statusline && settings.statusline.enabled) || 'auto'}`);
|
|
1530
|
+
console.log(` IJFW mode: ${(settings.statusline && settings.statusline.mode) || 'compose'}`);
|
|
1531
|
+
if (claudeStatusline) {
|
|
1532
|
+
const cmd = claudeStatusline.command || '(none)';
|
|
1533
|
+
const owner = cmd.includes('ijfw-statusline.js') ? 'IJFW'
|
|
1534
|
+
: isPathInAllowlist(cmd) ? 'allowlisted (compose-eligible)'
|
|
1535
|
+
: 'unknown (will skip on next install)';
|
|
1536
|
+
console.log(` Claude statusLine: ${cmd}`);
|
|
1537
|
+
console.log(` Owner classification: ${owner}`);
|
|
1538
|
+
} else {
|
|
1539
|
+
console.log(' Claude statusLine: (not set)');
|
|
1540
|
+
}
|
|
1541
|
+
console.log('');
|
|
1542
|
+
console.log('Run ijfw statusline --install to take ownership, --compose to coexist with another tool, --disable to remove.');
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function statuslineInstall() {
|
|
1546
|
+
const ijfwScript = statuslineScriptPath();
|
|
1547
|
+
if (!existsSync(ijfwScript)) {
|
|
1548
|
+
console.error(`statusline script not found at ${ijfwScript}`);
|
|
1549
|
+
process.exit(1);
|
|
1550
|
+
}
|
|
1551
|
+
const claude = readClaudeSettings();
|
|
1552
|
+
const prev = claude.data.statusLine;
|
|
1553
|
+
if (prev && prev.command && !prev.command.includes('ijfw-statusline.js')) {
|
|
1554
|
+
console.log(`Existing statusLine found: ${prev.command}`);
|
|
1555
|
+
console.log(` -> backing up to ~/.claude/settings.json (statusLine field replaced)`);
|
|
1556
|
+
}
|
|
1557
|
+
claude.data.statusLine = { type: 'command', command: `node ${ijfwScript}` };
|
|
1558
|
+
if (writeClaudeSettings(claude.path, claude.data)) {
|
|
1559
|
+
setIjfwStatuslineSetting('enabled', 'on');
|
|
1560
|
+
setIjfwStatuslineSetting('mode', 'own');
|
|
1561
|
+
console.log(`statusline installed -- IJFW now owns the slot.`);
|
|
1562
|
+
console.log(`Open a new Claude Code session to see it.`);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function statuslineCompose() {
|
|
1567
|
+
const claude = readClaudeSettings();
|
|
1568
|
+
const prev = claude.data.statusLine;
|
|
1569
|
+
if (!prev || !prev.command) {
|
|
1570
|
+
console.error('No existing statusLine to compose with. Run --install instead.');
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
if (prev.command.includes('ijfw-statusline.js')) {
|
|
1574
|
+
console.log('IJFW already owns the slot. Nothing to compose with.');
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (!isPathInAllowlist(prev.command)) {
|
|
1578
|
+
console.error(`Existing statusLine command not in allowlist for safe compose: ${prev.command}`);
|
|
1579
|
+
console.error('Refusing for security. Use --install to replace, or contact the existing tool maintainer.');
|
|
1580
|
+
process.exit(1);
|
|
1581
|
+
}
|
|
1582
|
+
// Persist compose target (the IJFW script reads this from settings.json)
|
|
1583
|
+
setIjfwStatuslineSetting('mode', 'compose');
|
|
1584
|
+
setIjfwStatuslineSetting('composed_command', prev.command);
|
|
1585
|
+
setIjfwStatuslineSetting('enabled', 'on');
|
|
1586
|
+
// Wrap the existing command so IJFW renders alongside it
|
|
1587
|
+
const ijfwScript = statuslineScriptPath();
|
|
1588
|
+
claude.data.statusLine = { type: 'command', command: `node ${ijfwScript}` };
|
|
1589
|
+
if (writeClaudeSettings(claude.path, claude.data)) {
|
|
1590
|
+
console.log(`statusline composed -- IJFW renders alongside ${prev.command}.`);
|
|
1591
|
+
console.log('Open a new Claude Code session to see it.');
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function statuslineDisable() {
|
|
1596
|
+
const claude = readClaudeSettings();
|
|
1597
|
+
const prev = claude.data.statusLine;
|
|
1598
|
+
if (prev && prev.command && prev.command.includes('ijfw-statusline.js')) {
|
|
1599
|
+
delete claude.data.statusLine;
|
|
1600
|
+
writeClaudeSettings(claude.path, claude.data);
|
|
1601
|
+
}
|
|
1602
|
+
setIjfwStatuslineSetting('enabled', 'off');
|
|
1603
|
+
console.log('statusline disabled.');
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function statuslineRecompute() {
|
|
1607
|
+
// Force statusline to re-read everything (cache + settings) -- a no-op for
|
|
1608
|
+
// the script itself (it reads on every invocation), but useful as a UX hook
|
|
1609
|
+
// after the user changes settings.json manually.
|
|
1610
|
+
console.log('statusline will re-read cache + settings on the next render.');
|
|
1611
|
+
console.log('Open a new Claude Code session to see the change.');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function cmdConfig(sub) {
|
|
1615
|
+
if (sub === 'audit') {
|
|
1616
|
+
console.log('ijfw config --audit -- this feature is queued for a later release.');
|
|
1617
|
+
console.log('Track progress: https://gitlab.com/therealseandonahoe/ijfw/issues');
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
console.log('Usage: ijfw config --audit');
|
|
1621
|
+
console.log(' --audit Show the active configuration resolution hierarchy (queued for a later release).');
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function cmdInsight(sub) {
|
|
1625
|
+
// Alias for `ijfw dashboard start` -- context-mode parity per v3 CLI table.
|
|
1626
|
+
cmdDashboard(sub === 'start' || !sub ? 'start' : sub);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ---------------------------------------------------------------------------
|
|
1630
|
+
// Receipt -- `ijfw receipt last` (polish 8)
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
//
|
|
1633
|
+
// Prints a redacted, shareable block from the most recent Trident run.
|
|
1634
|
+
// Strips absolute paths + project basenames so the user can paste it in
|
|
1635
|
+
// PR comments / Slack without leaking environment detail.
|
|
1636
|
+
|
|
1637
|
+
const RECEIPT_SUBS = new Set(['last']);
|
|
1638
|
+
|
|
1639
|
+
function cmdReceipt(sub = 'last') {
|
|
1640
|
+
if (!RECEIPT_SUBS.has(sub)) {
|
|
1641
|
+
console.log(`Unknown receipt sub-command: ${sub}`);
|
|
1642
|
+
console.log(`Usage: ijfw receipt <${[...RECEIPT_SUBS].join('|')}>`);
|
|
1643
|
+
process.exit(1);
|
|
1644
|
+
}
|
|
1645
|
+
const receipts = readReceipts(process.cwd());
|
|
1646
|
+
if (receipts.length === 0) {
|
|
1647
|
+
console.log('No Trident runs on record yet. Try `ijfw cross audit <file>` first.');
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
const last = receipts[receipts.length - 1];
|
|
1651
|
+
// Receipts schema: { findings: { items: [...] } }. Earlier draft read
|
|
1652
|
+
// merged.findings -- caught by Trident audit on the polish pass.
|
|
1653
|
+
const findings = Array.isArray(last.findings?.items) ? last.findings.items : [];
|
|
1654
|
+
const auditors = Array.isArray(last.auditors)
|
|
1655
|
+
? last.auditors.map(a => a.id).filter(Boolean)
|
|
1656
|
+
: [];
|
|
1657
|
+
|
|
1658
|
+
const lines = [];
|
|
1659
|
+
lines.push('```');
|
|
1660
|
+
lines.push(`Trident -- ${last.mode || 'audit'} -- ${(last.timestamp || '').slice(0, 10)}`);
|
|
1661
|
+
lines.push(`Auditors: ${auditors.join(', ') || 'n/a'}`);
|
|
1662
|
+
lines.push(`Findings: ${findings.length}`);
|
|
1663
|
+
for (const f of findings.slice(0, 5)) {
|
|
1664
|
+
const sev = f.severity ? `[${String(f.severity).toLowerCase()}] ` : '';
|
|
1665
|
+
const claim = redact(String(f.claim || f.issue || ''));
|
|
1666
|
+
if (claim) lines.push(` ${sev}${claim.slice(0, 140)}`);
|
|
1667
|
+
}
|
|
1668
|
+
if (findings.length > 5) lines.push(` ... ${findings.length - 5} more.`);
|
|
1669
|
+
lines.push(`Receipt: ijfw status`);
|
|
1670
|
+
lines.push('```');
|
|
1671
|
+
console.log(lines.join('\n'));
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Redact absolute paths + git directories so the receipt is safe to paste.
|
|
1675
|
+
function redact(s) {
|
|
1676
|
+
return s
|
|
1677
|
+
.replace(/\/Users\/[^/\s]+/g, '~')
|
|
1678
|
+
.replace(/\/home\/[^/\s]+/g, '~')
|
|
1679
|
+
.replace(/\/var\/folders\/[^/]+\/[^/]+\/T\//g, '/tmp/')
|
|
1680
|
+
.replace(/\/run\/user\/\d+\//g, '/run/user/<uid>/')
|
|
1681
|
+
.replace(/[A-Z]:\\Users\\[^\\\s]+/g, '%USERPROFILE%');
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// ---------------------------------------------------------------------------
|
|
1685
|
+
// ijfw help -- open the full guide (terminal paged or rendered HTML)
|
|
1686
|
+
// ---------------------------------------------------------------------------
|
|
1687
|
+
async function handleGuide(useBrowser) {
|
|
1688
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
1689
|
+
const candidates = [
|
|
1690
|
+
resolve(__dir, '..', '..', 'docs', 'GUIDE.md'),
|
|
1691
|
+
resolve(__dir, '..', '..', '..', 'docs', 'GUIDE.md'),
|
|
1692
|
+
join(homedir(), '.ijfw', 'docs', 'GUIDE.md'),
|
|
1693
|
+
];
|
|
1694
|
+
const guidePath = candidates.find(p => existsSync(p));
|
|
1695
|
+
if (!guidePath) {
|
|
1696
|
+
console.error('[ijfw] Guide not found. Visit https://gitlab.com/therealseandonahoe/ijfw/-/blob/main/docs/GUIDE.md');
|
|
1697
|
+
process.exit(1);
|
|
1698
|
+
}
|
|
1699
|
+
const md = readFileSync(guidePath, 'utf8');
|
|
1700
|
+
|
|
1701
|
+
if (useBrowser) {
|
|
1702
|
+
const outDir = join(homedir(), '.ijfw', 'guide');
|
|
1703
|
+
mkdirSync(outDir, { recursive: true });
|
|
1704
|
+
const outFile = join(outDir, 'index.html');
|
|
1705
|
+
const esc = md.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
1706
|
+
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
|
1707
|
+
<title>IJFW Guide</title>
|
|
1708
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-dark.min.css">
|
|
1709
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
1710
|
+
<style>
|
|
1711
|
+
body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;padding:2rem}
|
|
1712
|
+
.markdown-body{max-width:1000px;margin:0 auto;padding:2.5rem 3rem;background:#161b22;border-radius:12px;border:1px solid #30363d}
|
|
1713
|
+
img{max-width:100%;border-radius:8px}
|
|
1714
|
+
.brand{position:fixed;top:1rem;right:1.25rem;font-size:0.8rem;color:#8b949e;font-family:'SF Mono',Menlo,monospace}
|
|
1715
|
+
</style></head>
|
|
1716
|
+
<body>
|
|
1717
|
+
<div class="brand">ijfw help --browser</div>
|
|
1718
|
+
<article class="markdown-body" id="content"></article>
|
|
1719
|
+
<script>
|
|
1720
|
+
// Trusted source: GUIDE.md is shipped with IJFW; no user input in this path.
|
|
1721
|
+
const md = \`${esc}\`;
|
|
1722
|
+
const host = document.getElementById('content');
|
|
1723
|
+
const range = document.createRange();
|
|
1724
|
+
range.selectNodeContents(host);
|
|
1725
|
+
host.appendChild(range.createContextualFragment(marked.parse(md)));
|
|
1726
|
+
</script></body></html>`;
|
|
1727
|
+
writeFileSync(outFile, html);
|
|
1728
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
1729
|
+
: process.platform === 'win32' ? 'start'
|
|
1730
|
+
: 'xdg-open';
|
|
1731
|
+
spawnSync(opener, [outFile], { stdio: 'ignore', detached: true });
|
|
1732
|
+
console.log(`[ijfw] Guide rendered to ${outFile}`);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Terminal: pipe through less -R when available + interactive, else dump to stdout.
|
|
1737
|
+
// If less exits non-zero (e.g. user hits q, or less is broken), fall back to
|
|
1738
|
+
// plain stdout so the content is never silently swallowed.
|
|
1739
|
+
const lessAvailable = spawnSync('less', ['--version'], { stdio: 'ignore' }).status === 0;
|
|
1740
|
+
if (lessAvailable && process.stdout.isTTY) {
|
|
1741
|
+
const lessRes = spawnSync('less', ['-R'], { input: md, stdio: ['pipe', 'inherit', 'inherit'] });
|
|
1742
|
+
if (lessRes.status !== 0 && lessRes.status !== null) {
|
|
1743
|
+
process.stdout.write(md);
|
|
1744
|
+
}
|
|
1745
|
+
} else {
|
|
1746
|
+
process.stdout.write(md);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// ---------------------------------------------------------------------------
|
|
1751
|
+
// Main
|
|
1752
|
+
// ---------------------------------------------------------------------------
|
|
1753
|
+
// Only dispatch when run directly. When imported as a module (e.g. by tests),
|
|
1754
|
+
// skip the CLI entry so the test runner can use exported helpers.
|
|
1755
|
+
|
|
1756
|
+
// Two-layer check:
|
|
1757
|
+
// 1. pathToFileURL normalizes Windows drive paths (C:\...) and MSYS-style
|
|
1758
|
+
// paths (/c/...) into the same file:///C:/... form that import.meta.url
|
|
1759
|
+
// uses on Git Bash / MINGW64, where literal string interpolation breaks.
|
|
1760
|
+
// 2. Realpath fallback for macOS symlink hops (/tmp -> /private/tmp etc.)
|
|
1761
|
+
// that would otherwise make a string-equality check spuriously false.
|
|
1762
|
+
const isMainModule = (() => {
|
|
1763
|
+
try {
|
|
1764
|
+
if (!process.argv[1]) return false;
|
|
1765
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) return true;
|
|
1766
|
+
let argvPath = process.argv[1];
|
|
1767
|
+
let metaPath = fileURLToPath(import.meta.url);
|
|
1768
|
+
try { argvPath = realpathSync(argvPath); } catch { /* */ }
|
|
1769
|
+
try { metaPath = realpathSync(metaPath); } catch { /* */ }
|
|
1770
|
+
return argvPath === metaPath;
|
|
1771
|
+
} catch {
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
})();
|
|
1775
|
+
|
|
1776
|
+
if (isMainModule) {
|
|
1777
|
+
const parsed = parseArgs(process.argv);
|
|
1778
|
+
|
|
1779
|
+
if (parsed.cmd === 'guide') {
|
|
1780
|
+
await handleGuide(parsed.browser);
|
|
1781
|
+
process.exit(0);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (parsed.cmd === 'help') {
|
|
1785
|
+
printUsage();
|
|
1786
|
+
process.exit(0);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (parsed.cmd === 'status') {
|
|
1790
|
+
cmdStatus(process.cwd(), parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
1791
|
+
} else if (parsed.cmd === 'demo') {
|
|
1792
|
+
cmdDemo().catch(err => { console.error(err.message); process.exit(1); });
|
|
1793
|
+
} else if (parsed.cmd === 'cross') {
|
|
1794
|
+
cmdCross(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
1795
|
+
} else if (parsed.cmd === 'cross-project-audit') {
|
|
1796
|
+
cmdCrossProjectAudit(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
1797
|
+
} else if (parsed.cmd === 'import') {
|
|
1798
|
+
cmdImport(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
1799
|
+
} else if (parsed.cmd === 'doctor') {
|
|
1800
|
+
cmdDoctor(parsed);
|
|
1801
|
+
} else if (parsed.cmd === 'update') {
|
|
1802
|
+
cmdUpdate(parsed);
|
|
1803
|
+
} else if (parsed.cmd === 'version') {
|
|
1804
|
+
cmdVersion(parsed);
|
|
1805
|
+
} else if (parsed.cmd === 'statusline') {
|
|
1806
|
+
cmdStatusline(parsed.sub);
|
|
1807
|
+
} else if (parsed.cmd === 'config') {
|
|
1808
|
+
cmdConfig(parsed.sub);
|
|
1809
|
+
} else if (parsed.cmd === 'insight') {
|
|
1810
|
+
cmdInsight(parsed.sub);
|
|
1811
|
+
} else if (parsed.cmd === 'receipt') {
|
|
1812
|
+
cmdReceipt(parsed.sub);
|
|
1813
|
+
} else if (parsed.cmd === 'purge-receipts') {
|
|
1814
|
+
cmdPurgeReceipts(process.cwd());
|
|
1815
|
+
} else if (parsed.cmd === 'install') {
|
|
1816
|
+
cmdInstall();
|
|
1817
|
+
} else if (parsed.cmd === 'uninstall') {
|
|
1818
|
+
cmdUninstall();
|
|
1819
|
+
} else if (parsed.cmd === 'preflight') {
|
|
1820
|
+
cmdPreflight();
|
|
1821
|
+
} else if (parsed.cmd === 'dashboard') {
|
|
1822
|
+
cmdDashboard(parsed.sub);
|
|
1823
|
+
} else {
|
|
1824
|
+
console.error(`Unknown command: ${parsed.raw}`);
|
|
1825
|
+
printUsage();
|
|
1826
|
+
process.exit(1);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// --- install / uninstall / preflight / dashboard ---
|
|
1831
|
+
// These shell out to the existing scripts/installer modules so there is one
|
|
1832
|
+
// CLI entry point that covers every command named in the README, regardless
|
|
1833
|
+
// of how the user installed (git clone + install.sh OR npm @ijfw/install).
|
|
1834
|
+
// Resolve internal assets across both the repo-clone layout and the
|
|
1835
|
+
// post-install ~/.ijfw layout. Same shape as installer/src/ijfw.js findInTree.
|
|
1836
|
+
// Without this, dispatch paths fail for any user whose CLI shim originates
|
|
1837
|
+
// from npm-global without a sibling scripts/ tree.
|
|
1838
|
+
function repoRootFromCli() {
|
|
1839
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1840
|
+
return join(here, '..', '..');
|
|
1841
|
+
}
|
|
1842
|
+
function findCliAsset(...rel) {
|
|
1843
|
+
const candidates = [
|
|
1844
|
+
join(repoRootFromCli(), ...rel),
|
|
1845
|
+
process.env.IJFW_HOME ? join(process.env.IJFW_HOME, ...rel) : null,
|
|
1846
|
+
join(homedir(), '.ijfw', ...rel),
|
|
1847
|
+
].filter(Boolean);
|
|
1848
|
+
return candidates.find(p => existsSync(p)) || null;
|
|
1849
|
+
}
|
|
1850
|
+
function cmdInstall() {
|
|
1851
|
+
const script = findCliAsset('scripts', 'install.sh');
|
|
1852
|
+
if (!script) {
|
|
1853
|
+
console.error('install.sh not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
|
|
1854
|
+
process.exit(1);
|
|
1855
|
+
}
|
|
1856
|
+
const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
1857
|
+
process.exit(res.status ?? 1);
|
|
1858
|
+
}
|
|
1859
|
+
function cmdUninstall() {
|
|
1860
|
+
const script = findCliAsset('installer', 'src', 'uninstall.js');
|
|
1861
|
+
if (!script) {
|
|
1862
|
+
console.error('uninstall.js not found. Remove ~/.ijfw manually and strip ijfw keys from ~/.claude/settings.json.');
|
|
1863
|
+
process.exit(1);
|
|
1864
|
+
}
|
|
1865
|
+
const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
1866
|
+
process.exit(res.status ?? 1);
|
|
1867
|
+
}
|
|
1868
|
+
function cmdPreflight() {
|
|
1869
|
+
const script = findCliAsset('scripts', 'check-all.sh');
|
|
1870
|
+
if (!script) {
|
|
1871
|
+
console.error('check-all.sh not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|
|
1874
|
+
const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
1875
|
+
process.exit(res.status ?? 1);
|
|
1876
|
+
}
|
|
1877
|
+
function cmdDashboard(sub) {
|
|
1878
|
+
const script = findCliAsset('scripts', 'dashboard', 'bin.js');
|
|
1879
|
+
if (!script) {
|
|
1880
|
+
console.error('dashboard/bin.js not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
|
|
1881
|
+
process.exit(1);
|
|
1882
|
+
}
|
|
1883
|
+
const res = spawnSync(process.execPath, [script, sub], { stdio: 'inherit' });
|
|
1884
|
+
process.exit(res.status ?? 1);
|
|
1885
|
+
}
|