amicus 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +11 -4
- package/electron/load-failsafe.js +11 -4
- package/electron/main.js +12 -9
- package/electron/opencode-theme.js +130 -0
- package/electron/preload.js +1 -1
- package/electron/setup-ui-styles.js +4 -2
- package/electron/setup-ui.js +2 -2
- package/electron/toolbar.js +6 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +59 -35
- package/src/cli-handlers-doctor.js +90 -14
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +3 -0
- package/src/mcp-server.js +26 -6
- package/src/mcp-tools.js +17 -1
- package/src/sidecar/electron-cache.js +42 -0
- package/src/sidecar/electron-ensure.js +92 -0
- package/src/sidecar/electron-install.js +244 -0
- package/src/sidecar/fanout.js +3 -0
- package/src/sidecar/interactive.js +22 -10
- package/src/sidecar/setup-window.js +12 -7
- package/src/sidecar/setup.js +2 -0
- package/src/utils/project-root-sanity.js +66 -0
- package/src/utils/remediation-hints.js +51 -0
- package/src/utils/result-schema.js +16 -3
- package/src/utils/version-info.js +49 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// src/cli-handlers-doctor.js
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const HINTS = require('./utils/remediation-hints');
|
|
5
|
+
|
|
4
6
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
5
7
|
|
|
8
|
+
/** #56: keep `doctor --fix`'s electron self-heal from ever hanging on a slow disk/network. */
|
|
9
|
+
const FIX_TIMEOUT_MS = 90 * 1000;
|
|
10
|
+
|
|
6
11
|
/** Default real helpers; tests override via deps. */
|
|
7
12
|
function realDeps() {
|
|
8
13
|
const fs = require('fs');
|
|
@@ -11,6 +16,13 @@ function realDeps() {
|
|
|
11
16
|
return {
|
|
12
17
|
nodeVersion: process.version,
|
|
13
18
|
readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
|
|
19
|
+
readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
|
|
20
|
+
checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
|
|
21
|
+
getCwd: () => process.cwd(),
|
|
22
|
+
readProjectMarkers: (dir) => {
|
|
23
|
+
const exists = (name) => { try { return fs.existsSync(path.join(dir, name)); } catch (_e) { return false; } };
|
|
24
|
+
return { hasGit: exists('.git'), hasPackageJson: exists('package.json'), hasClaude: exists('.claude') };
|
|
25
|
+
},
|
|
14
26
|
getConfigDir: () => require('./utils/config').getConfigDir(),
|
|
15
27
|
resolveModel: () => require('./utils/config').resolveModel(),
|
|
16
28
|
readCache: () => require('./utils/model-catalog').readCache(),
|
|
@@ -27,6 +39,10 @@ function realDeps() {
|
|
|
27
39
|
return candidates.some(p => fs.existsSync(p));
|
|
28
40
|
},
|
|
29
41
|
getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
|
|
42
|
+
// #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
|
|
43
|
+
// stays separate; repair only runs when fix is requested.
|
|
44
|
+
repairElectron: (opts) => require('./sidecar/electron-install').repairElectron(opts),
|
|
45
|
+
fix: false,
|
|
30
46
|
discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
|
|
31
47
|
discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
|
|
32
48
|
skillInstalled: () => {
|
|
@@ -43,12 +59,19 @@ function guard(id, name, fn) {
|
|
|
43
59
|
catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
|
|
44
60
|
}
|
|
45
61
|
|
|
62
|
+
/** Async variant of guard; a thrown/rejected fn becomes an error line. */
|
|
63
|
+
async function guardAsync(id, name, fn) {
|
|
64
|
+
try { return await fn(); }
|
|
65
|
+
catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
/**
|
|
47
|
-
* Compose the health checks. Never throws
|
|
69
|
+
* Compose the health checks. Never throws (async; awaits non-blocking
|
|
70
|
+
* network checks such as the OpenRouter credit probe).
|
|
48
71
|
* @param {object} [depsOverride]
|
|
49
|
-
* @returns {Array<{id,name,status,message,hint}
|
|
72
|
+
* @returns {Promise<Array<{id,name,status,message,hint}>>}
|
|
50
73
|
*/
|
|
51
|
-
function runDoctorChecks(depsOverride = {}) {
|
|
74
|
+
async function runDoctorChecks(depsOverride = {}) {
|
|
52
75
|
const d = { ...realDeps(), ...depsOverride };
|
|
53
76
|
const checks = [];
|
|
54
77
|
|
|
@@ -105,19 +128,43 @@ function runDoctorChecks(depsOverride = {}) {
|
|
|
105
128
|
checks.push(guard('opencode-bin', 'OpenCode binary', () => (
|
|
106
129
|
d.hasOpencodeBinary()
|
|
107
130
|
? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
|
|
108
|
-
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint:
|
|
131
|
+
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngine }
|
|
109
132
|
)));
|
|
110
133
|
|
|
111
|
-
checks.push(
|
|
112
|
-
d.getElectronPath()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
|
|
135
|
+
if (d.getElectronPath()) {
|
|
136
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
|
|
137
|
+
}
|
|
138
|
+
// Broken (missing / quarantined). With --fix, self-heal in place (#56):
|
|
139
|
+
// repairElectron provisions the binary; {deferred} (no cache, no network)
|
|
140
|
+
// maps to WARN — a deferred download is not a failure. Without --fix, just
|
|
141
|
+
// point the user at `amicus doctor --fix`.
|
|
142
|
+
if (d.fix) {
|
|
143
|
+
let res;
|
|
144
|
+
try {
|
|
145
|
+
res = await d.repairElectron({ timeoutMs: FIX_TIMEOUT_MS });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
|
|
148
|
+
}
|
|
149
|
+
res = res || {};
|
|
150
|
+
if (res.repaired || res.usable) {
|
|
151
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
|
|
152
|
+
}
|
|
153
|
+
const why = res.reason ? ` — ${res.reason}` : '';
|
|
154
|
+
const detail = res.deferred
|
|
155
|
+
? `deferred${why}`
|
|
156
|
+
: res.contended
|
|
157
|
+
? `repair already in progress${why}`
|
|
158
|
+
: `not provisioned${why}`;
|
|
159
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
|
|
160
|
+
}
|
|
161
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
|
|
162
|
+
}));
|
|
116
163
|
|
|
117
164
|
checks.push(guard('skills', 'Skills installed', () => (
|
|
118
165
|
d.skillInstalled()
|
|
119
166
|
? { id: 'skills', name: 'Skills installed', status: 'ok', message: '~/.claude/skills/{sidecar,second-opinion}', hint: null }
|
|
120
|
-
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint:
|
|
167
|
+
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: `${HINTS.reinstall} (re-runs the skill install)` }
|
|
121
168
|
)));
|
|
122
169
|
|
|
123
170
|
checks.push(guard('mcp', 'MCP registration', () => {
|
|
@@ -127,12 +174,37 @@ function runDoctorChecks(depsOverride = {}) {
|
|
|
127
174
|
const inCowork = !!(cowork && cowork.amicus);
|
|
128
175
|
// Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
|
|
129
176
|
if (!inCode) {
|
|
130
|
-
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint:
|
|
177
|
+
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
|
|
131
178
|
}
|
|
132
179
|
const extra = inCowork ? ', Cowork/Desktop' : '';
|
|
133
180
|
return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
|
|
134
181
|
}));
|
|
135
182
|
|
|
183
|
+
// #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
|
|
184
|
+
checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
|
|
185
|
+
const values = d.readApiKeyValues() || {};
|
|
186
|
+
const key = values.openrouter;
|
|
187
|
+
if (!key) {
|
|
188
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
|
|
189
|
+
}
|
|
190
|
+
// Reuses the #38 non-blocking probe; resolves warning:null on any failure.
|
|
191
|
+
const res = (await d.checkOpenRouterCredit(key)) || {};
|
|
192
|
+
if (res.warning) {
|
|
193
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
|
|
194
|
+
}
|
|
195
|
+
const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
|
|
196
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
// #43: project-root sanity — warns when cwd looks like an app/install dir or lacks project markers.
|
|
200
|
+
checks.push(guard('project-root', 'Project root', () => {
|
|
201
|
+
const dir = d.getCwd();
|
|
202
|
+
const markers = d.readProjectMarkers(dir);
|
|
203
|
+
const { assessProjectRoot } = require('./utils/project-root-sanity');
|
|
204
|
+
const r = assessProjectRoot(dir, markers);
|
|
205
|
+
return { id: 'project-root', name: 'Project root', status: r.status, message: r.message, hint: r.hint };
|
|
206
|
+
}));
|
|
207
|
+
|
|
136
208
|
return checks;
|
|
137
209
|
}
|
|
138
210
|
|
|
@@ -151,14 +223,18 @@ function renderHuman(checks) {
|
|
|
151
223
|
}
|
|
152
224
|
|
|
153
225
|
/**
|
|
154
|
-
* `amicus doctor [--json]`. Injectable `runChecks` for tests.
|
|
155
|
-
*
|
|
226
|
+
* `amicus doctor [--json] [--fix]`. Injectable `runChecks` for tests.
|
|
227
|
+
* `--fix` (#56) self-heals fixable checks in place (electron via repairElectron).
|
|
228
|
+
* @param {{_:string[], json?:boolean, fix?:boolean}} args
|
|
156
229
|
* @param {(deps?:object)=>Array} [runChecks]
|
|
157
230
|
* @returns {Promise<number>} exit code
|
|
158
231
|
*/
|
|
159
232
|
async function handleDoctor(args, runChecks = runDoctorChecks) {
|
|
160
233
|
const useJson = !!args.json;
|
|
161
|
-
|
|
234
|
+
// #56: --fix flows into runDoctorChecks as a dep so fixable checks (electron)
|
|
235
|
+
// self-heal in place. Omitted (not false) when absent so the injected
|
|
236
|
+
// test-double sees a clean "no fix" call.
|
|
237
|
+
const checks = await runChecks(args.fix ? { fix: true } : undefined);
|
|
162
238
|
if (useJson) {
|
|
163
239
|
const { buildDoctorDoc } = require('./utils/result-schema');
|
|
164
240
|
const VERSION = require('../package.json').version;
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -173,6 +173,10 @@ async function handleFanout(args) {
|
|
|
173
173
|
contextTurns: args['context-turns'],
|
|
174
174
|
contextSince: args['context-since'],
|
|
175
175
|
contextMaxTokens: args['context-max-tokens'],
|
|
176
|
+
// #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
|
|
177
|
+
// session (mirrors handleStart's coworkProcess plumbing). Without this the
|
|
178
|
+
// spawned `--cowork-process` flag is dropped and buildContext gets null.
|
|
179
|
+
coworkProcess: args['cowork-process'],
|
|
176
180
|
mcp: args.mcp,
|
|
177
181
|
mcpConfig: args['mcp-config'],
|
|
178
182
|
noMcp: args['no-mcp'],
|
package/src/cli.js
CHANGED
|
@@ -116,6 +116,7 @@ function isBooleanFlag(key) {
|
|
|
116
116
|
'no-ledger', // council tally: compute the record without appending to the reliability ledger
|
|
117
117
|
'html', // council report: emit a self-contained HTML page
|
|
118
118
|
'md', // council report: emit Markdown (default)
|
|
119
|
+
'fix', // doctor: self-heal fixable checks in place (#56)
|
|
119
120
|
];
|
|
120
121
|
return booleanFlags.includes(key);
|
|
121
122
|
}
|
|
@@ -438,6 +439,8 @@ Subcommands for 'council':
|
|
|
438
439
|
doctor: `
|
|
439
440
|
Options for 'doctor':
|
|
440
441
|
--json Machine-readable output
|
|
442
|
+
--fix Self-heal fixable checks in place (provisions the
|
|
443
|
+
Electron GUI binary; no global reinstall)
|
|
441
444
|
`,
|
|
442
445
|
setup: `
|
|
443
446
|
Options for 'setup':
|
package/src/mcp-server.js
CHANGED
|
@@ -14,6 +14,7 @@ const { durationBetween } = require('./utils/result-schema');
|
|
|
14
14
|
const { canonicalProjectPath } = require('./utils/project-path');
|
|
15
15
|
const { recordSession } = require('./utils/session-index');
|
|
16
16
|
const { fileURLToPath } = require('url');
|
|
17
|
+
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Elapsed run duration: time between createdAt and the run's end, bounding the
|
|
@@ -142,6 +143,17 @@ function textResult(text, isError) {
|
|
|
142
143
|
return result;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Append a stale-version warning content block (#33) when the on-disk
|
|
148
|
+
* package.json has been upgraded under the running process. No-op when in
|
|
149
|
+
* sync or unreadable (versionWarning() returns null). Mutates `content`.
|
|
150
|
+
*/
|
|
151
|
+
function appendVersionWarning(content) {
|
|
152
|
+
const warn = versionWarning();
|
|
153
|
+
if (warn) { content.push({ type: 'text', text: warn }); }
|
|
154
|
+
return content;
|
|
155
|
+
}
|
|
156
|
+
|
|
145
157
|
/**
|
|
146
158
|
* Compute next poll hint for headless sessions.
|
|
147
159
|
* @returns {{ hint: string }}
|
|
@@ -429,15 +441,17 @@ const handlers = {
|
|
|
429
441
|
taskId: metadata.taskId, type: 'wave', status: metadata.status,
|
|
430
442
|
legsComplete: done, legsTotal: legs.length, legs,
|
|
431
443
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
444
|
+
version: RUNNING_VERSION,
|
|
432
445
|
};
|
|
433
446
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
434
447
|
response.reason = metadata.reason || 'Unknown error';
|
|
435
448
|
}
|
|
436
|
-
const
|
|
449
|
+
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
450
|
+
appendVersionWarning(content);
|
|
437
451
|
if (metadata.status === 'running') {
|
|
438
|
-
|
|
452
|
+
content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
|
|
439
453
|
}
|
|
440
|
-
return
|
|
454
|
+
return { content };
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
if (metadata.status === 'running' && metadata.pid) {
|
|
@@ -455,6 +469,7 @@ const handlers = {
|
|
|
455
469
|
const response = {
|
|
456
470
|
taskId: metadata.taskId, status: metadata.status,
|
|
457
471
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
472
|
+
version: RUNNING_VERSION,
|
|
458
473
|
};
|
|
459
474
|
if (metadata.model) { response.model = metadata.model; }
|
|
460
475
|
|
|
@@ -479,11 +494,12 @@ const handlers = {
|
|
|
479
494
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
480
495
|
response.reason = metadata.reason || 'Unknown error';
|
|
481
496
|
}
|
|
482
|
-
const
|
|
497
|
+
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
498
|
+
appendVersionWarning(content);
|
|
483
499
|
if (metadata.status === 'running' && metadata.headless) {
|
|
484
|
-
|
|
500
|
+
content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
|
|
485
501
|
}
|
|
486
|
-
return
|
|
502
|
+
return { content };
|
|
487
503
|
},
|
|
488
504
|
|
|
489
505
|
async amicus_read(input, project) {
|
|
@@ -729,6 +745,10 @@ const handlers = {
|
|
|
729
745
|
if (input.timeout) { args.push('--timeout', String(input.timeout)); }
|
|
730
746
|
if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
|
|
731
747
|
if (input.includeContext === false) { args.push('--no-context'); }
|
|
748
|
+
// #10: forward cowork session pinning to the legs (parity with amicus_start),
|
|
749
|
+
// so context-inheriting fanout launched from Cowork resolves the right parent.
|
|
750
|
+
if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
|
|
751
|
+
if (input.parentSession) { args.push('--session-id', input.parentSession); }
|
|
732
752
|
|
|
733
753
|
try { spawnSidecarProcess(args, waveDir); } catch (err) {
|
|
734
754
|
// Best-effort: never leave a pid-less wave record claiming 'running'
|
package/src/mcp-tools.js
CHANGED
|
@@ -115,7 +115,7 @@ function getTools() {
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
name: 'amicus_status',
|
|
118
|
-
annotations: { readOnlyHint:
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
119
119
|
description:
|
|
120
120
|
'Check the status of a running Amicus session. Returns status ' +
|
|
121
121
|
'(running/complete), elapsed time, and progress info. Primarily ' +
|
|
@@ -279,6 +279,14 @@ function getTools() {
|
|
|
279
279
|
includeContext: z.boolean().optional().default(true).describe(
|
|
280
280
|
'Include parent conversation context (built once, shared by all legs). Set false for self-contained briefings.'
|
|
281
281
|
),
|
|
282
|
+
coworkProcess: z.string().optional().describe(
|
|
283
|
+
'Cowork VM process name (e.g., "modest-laughing-goodall"). ' +
|
|
284
|
+
'Extract from CWD: /sessions/<name>/. Required for parent context loading from Cowork.'
|
|
285
|
+
),
|
|
286
|
+
parentSession: z.string().optional().describe(
|
|
287
|
+
'Claude Code session UUID for exact context matching. ' +
|
|
288
|
+
'Prevents ambiguity when multiple sessions are active in the same project.'
|
|
289
|
+
),
|
|
282
290
|
project: z.string().optional().describe(
|
|
283
291
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
284
292
|
),
|
|
@@ -360,13 +368,21 @@ function getTools() {
|
|
|
360
368
|
*/
|
|
361
369
|
function getGuideText() {
|
|
362
370
|
const { getEffectiveAliases } = require('./utils/config');
|
|
371
|
+
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
363
372
|
const aliases = getEffectiveAliases();
|
|
364
373
|
const aliasRows = Object.entries(aliases)
|
|
365
374
|
.map(([name, model]) => `| ${name} | ${model} |`)
|
|
366
375
|
.join('\n');
|
|
376
|
+
// #33: surface the running version (and a call-time staleness warning) so a
|
|
377
|
+
// post-upgrade agent session can tell it's running old code.
|
|
378
|
+
const warn = versionWarning();
|
|
379
|
+
const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
|
|
380
|
+
+ (warn ? `\n\n> ⚠️ ${warn}` : '');
|
|
367
381
|
|
|
368
382
|
return `# Amicus Usage Guide
|
|
369
383
|
|
|
384
|
+
${versionLine}
|
|
385
|
+
|
|
370
386
|
## What Is Amicus?
|
|
371
387
|
Amicus spawns parallel conversations with different LLMs and folds results back into your context.
|
|
372
388
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron download-cache root resolution (#53 helper).
|
|
3
|
+
*
|
|
4
|
+
* Split out of electron-install.js to keep that module under the 300-line
|
|
5
|
+
* size gate. Mirrors @electron/get's cache-root precedence:
|
|
6
|
+
* - electron_config_cache (npm config / .npmrc)
|
|
7
|
+
* - ELECTRON_CACHE (env override)
|
|
8
|
+
* - the platform default from env-paths('electron').cache
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
/** Platform default cache dir, mirroring env-paths('electron',{suffix:''}).cache. */
|
|
17
|
+
function defaultCacheRoot(env = process.env) {
|
|
18
|
+
const home = env.HOME || os.homedir();
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
21
|
+
return path.join(localAppData, 'electron', 'Cache');
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === 'darwin') {
|
|
24
|
+
return path.join(home, 'Library', 'Caches', 'electron');
|
|
25
|
+
}
|
|
26
|
+
const xdg = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
27
|
+
return path.join(xdg, 'electron');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ordered, de-duplicated list of cache roots to probe for a cached zip.
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
function resolveCacheRoots(env = process.env) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
if (env.electron_config_cache) { roots.push(env.electron_config_cache); }
|
|
37
|
+
if (env.ELECTRON_CACHE) { roots.push(env.ELECTRON_CACHE); }
|
|
38
|
+
roots.push(defaultCacheRoot(env));
|
|
39
|
+
return [...new Set(roots.filter(Boolean))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { resolveCacheRoots, defaultCacheRoot };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensureElectron() — lazy first-GUI provisioning (#55).
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY entry point allowed to PROVISION (download/extract) electron,
|
|
5
|
+
* and only on FIRST GUI use. getElectronPath()/checkElectronAvailable() stay
|
|
6
|
+
* PURE PROBES: the doctor check (cli-handlers-doctor.js) and MCP amicus_setup
|
|
7
|
+
* (mcp-server.js) depend on that purity — making a probe provision would silently
|
|
8
|
+
* fetch ~170MB. Kept in its own module so src/sidecar/electron-install.js stays
|
|
9
|
+
* under the 300-line size gate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
isElectronUsable: defaultIsUsable,
|
|
16
|
+
resolveElectronBinary: defaultResolve,
|
|
17
|
+
repairElectron: defaultRepair,
|
|
18
|
+
} = require('./electron-install');
|
|
19
|
+
const HINTS = require('../utils/remediation-hints');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Module-level single-flight guard. Holds the in-flight (or last SUCCESSFUL)
|
|
23
|
+
* provision promise so repeated GUI launches in one process never re-download.
|
|
24
|
+
* A FAILED provision is cleared so a later launch may retry.
|
|
25
|
+
*/
|
|
26
|
+
let _ensurePromise = null;
|
|
27
|
+
|
|
28
|
+
/** Test-only: clear the once-guard so each test starts single-flight-clean. */
|
|
29
|
+
function _resetEnsureElectron() {
|
|
30
|
+
_ensurePromise = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lazily PROVISION electron on FIRST GUI use.
|
|
35
|
+
*
|
|
36
|
+
* Flow: if isElectronUsable() return the resolved path (NO repair). Otherwise
|
|
37
|
+
* call repairElectron({cacheOnly:false}) — network ALLOWED here, this is an
|
|
38
|
+
* explicit first-use provision — with progress messaging, then re-check.
|
|
39
|
+
*
|
|
40
|
+
* Single-flight: the in-flight / last-successful promise is memoized so
|
|
41
|
+
* concurrent and repeated launches share ONE provision. A failed attempt is
|
|
42
|
+
* NOT cached (the guard is cleared) so a later launch can retry.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {object} [opts.deps] injected
|
|
46
|
+
* { isElectronUsable, resolveElectronBinary, repairElectron, logProgress }.
|
|
47
|
+
* @param {object} [opts.repairOptions] forwarded to repairElectron (electronDir, etc.).
|
|
48
|
+
* @returns {Promise<{ok:boolean, path?:string, reason?:string}>}
|
|
49
|
+
*/
|
|
50
|
+
function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
|
|
51
|
+
const usable = deps.isElectronUsable || defaultIsUsable;
|
|
52
|
+
const resolve = deps.resolveElectronBinary || defaultResolve;
|
|
53
|
+
const repair = deps.repairElectron || defaultRepair;
|
|
54
|
+
const logProgress = deps.logProgress
|
|
55
|
+
|| ((msg) => { try { process.stderr.write(`${msg}\n`); } catch { /* ignore */ } });
|
|
56
|
+
|
|
57
|
+
// Fast path: already provisioned. Cheap stat — safe to run every launch.
|
|
58
|
+
if (usable()) {
|
|
59
|
+
return Promise.resolve({ ok: true, path: resolve() });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Single-flight: reuse an in-flight (or already-succeeded) provision.
|
|
63
|
+
if (_ensurePromise) { return _ensurePromise; }
|
|
64
|
+
|
|
65
|
+
_ensurePromise = (async () => {
|
|
66
|
+
logProgress('[amicus] Provisioning the Electron GUI binary (first GUI use, ~170MB). This runs once...');
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await repair({ cacheOnly: false, ...repairOptions });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { ok: false, reason: `Electron provisioning failed: ${err && err.message}` };
|
|
72
|
+
}
|
|
73
|
+
if (usable()) {
|
|
74
|
+
logProgress('[amicus] Electron GUI ready.');
|
|
75
|
+
return { ok: true, path: resolve() };
|
|
76
|
+
}
|
|
77
|
+
const reason = (result && result.reason)
|
|
78
|
+
|| `Electron could not be provisioned; the GUI is unavailable. ${HINTS.doctorFix} (or use --no-ui).`;
|
|
79
|
+
return { ok: false, reason };
|
|
80
|
+
})().then((r) => {
|
|
81
|
+
// Only memoize SUCCESS; a failure clears the guard so a later launch retries.
|
|
82
|
+
if (!r.ok) { _ensurePromise = null; }
|
|
83
|
+
return r;
|
|
84
|
+
}, (err) => {
|
|
85
|
+
_ensurePromise = null;
|
|
86
|
+
throw err;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return _ensurePromise;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { ensureElectron, _resetEnsureElectron };
|