specrails-desktop 2.11.1 → 2.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -620,19 +620,42 @@ async function validateCoreContract() {
|
|
|
620
620
|
}
|
|
621
621
|
// ─── SetupManager ─────────────────────────────────────────────────────────────
|
|
622
622
|
const INSTALL_LOG_BUFFER_MAX = 2000;
|
|
623
|
-
|
|
623
|
+
/**
|
|
624
|
+
* Persist the FULL install log to `~/.specrails/logs/` and return the path (or
|
|
625
|
+
* null on failure). The in-error tail is only a window; the full log carries the
|
|
626
|
+
* complete child stack — needed because a Node uncaught error prints the ORIGIN
|
|
627
|
+
* frames ABOVE the entry frames, so an 8-line tail shows only the bottom of the
|
|
628
|
+
* stack + the error object, never where it was thrown.
|
|
629
|
+
*/
|
|
630
|
+
function persistInstallLog(projectId, logBuffer) {
|
|
631
|
+
try {
|
|
632
|
+
const dir = (0, path_1.join)((0, artifact_registry_1.resolveHome)(), '.specrails', 'logs');
|
|
633
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
634
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
635
|
+
const file = (0, path_1.join)(dir, `setup-${projectId}-${stamp}.log`);
|
|
636
|
+
(0, fs_1.writeFileSync)(file, logBuffer.join('\n') + '\n', { mode: 0o600 });
|
|
637
|
+
return file;
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function formatBufferedInstallError(baseMessage, logBuffer, logPath) {
|
|
644
|
+
// Show a generous tail: a Node uncaught-exception dump is ~15-30 lines (header,
|
|
645
|
+
// stack frames, the `{errno,code,syscall,path}` object, version footer). 8 lines
|
|
646
|
+
// truncated to just the entry frames + error object, hiding the throw origin.
|
|
624
647
|
const recentLines = logBuffer
|
|
625
648
|
.map((line) => line.trim())
|
|
626
649
|
.filter(Boolean)
|
|
627
|
-
.slice(-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
'
|
|
634
|
-
|
|
635
|
-
|
|
650
|
+
.slice(-40);
|
|
651
|
+
const parts = [baseMessage];
|
|
652
|
+
if (recentLines.length > 0) {
|
|
653
|
+
parts.push('', 'Recent output:', ...recentLines.map((line) => `- ${line}`));
|
|
654
|
+
}
|
|
655
|
+
if (logPath) {
|
|
656
|
+
parts.push('', `Full log: ${logPath}`);
|
|
657
|
+
}
|
|
658
|
+
return parts.join('\n');
|
|
636
659
|
}
|
|
637
660
|
class SetupManager {
|
|
638
661
|
_broadcast;
|
|
@@ -788,12 +811,20 @@ class SetupManager {
|
|
|
788
811
|
const initArgs = hasConfig
|
|
789
812
|
? ['--yes', '--from-config', spawnConfigPath ?? configPath]
|
|
790
813
|
: ['--yes', '--root-dir', projectPath];
|
|
814
|
+
// Seed the install log with a diagnostic header capturing the EXACT spawn
|
|
815
|
+
// (node interpreter, cli entry, cwd) + relevant env. This lands in the
|
|
816
|
+
// failure report so a Windows/packaged path issue (e.g. an EISDIR on the
|
|
817
|
+
// entry realpath) is diagnosable without server-console access.
|
|
818
|
+
const diagHeader = [];
|
|
819
|
+
if (useBundledCore) {
|
|
820
|
+
diagHeader.push(`[diag] node=${(0, path_resolver_1.resolveBundledNodeExe)() ?? process.execPath}`, `[diag] cli=${(0, bundled_core_1.getBundledCoreCli)() ?? '<none>'}`, `[diag] cwd=${projectPath}`, `[diag] args=${initArgs.join(' ')}`, `[diag] SPECRAILS_BUNDLED_RUNTIMES_PATH=${process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '<unset>'}`, `[diag] SPECRAILS_BUNDLED_CORE_PATH=${process.env.SPECRAILS_BUNDLED_CORE_PATH ?? '<unset>'}`);
|
|
821
|
+
}
|
|
791
822
|
// Bundled core (offline, node <cli> init) when available, else legacy npx.
|
|
792
823
|
const child = useBundledCore
|
|
793
824
|
? spawnBundledCoreInit(initArgs, projectPath)
|
|
794
825
|
: spawnCoreInit(initArgs, projectPath);
|
|
795
826
|
this._installProcesses.set(projectId, child);
|
|
796
|
-
this._installLogBuffer.set(projectId,
|
|
827
|
+
this._installLogBuffer.set(projectId, diagHeader);
|
|
797
828
|
// spawnCoreInit uses shell:false on POSIX, so a spawn failure emits 'error'
|
|
798
829
|
// (and NOT 'close') — without this handler the temp config file leaks and
|
|
799
830
|
// the unhandled 'error' event would crash the app.
|
|
@@ -867,10 +898,11 @@ class SetupManager {
|
|
|
867
898
|
}
|
|
868
899
|
else {
|
|
869
900
|
const logBuffer = this._installLogBuffer.get(projectId) ?? [];
|
|
901
|
+
const logPath = persistInstallLog(projectId, logBuffer);
|
|
870
902
|
this._broadcast({
|
|
871
903
|
type: 'setup_error',
|
|
872
904
|
projectId,
|
|
873
|
-
error: formatBufferedInstallError(`${useBundledCore ? 'bundled specrails-core' : 'npx specrails-core'} exited with code ${code ?? 'unknown'}`, logBuffer),
|
|
905
|
+
error: formatBufferedInstallError(`${useBundledCore ? 'bundled specrails-core' : 'npx specrails-core'} exited with code ${code ?? 'unknown'}`, logBuffer, logPath),
|
|
874
906
|
});
|
|
875
907
|
}
|
|
876
908
|
});
|
|
@@ -15,6 +15,8 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
15
15
|
const path_1 = __importDefault(require("path"));
|
|
16
16
|
const providers_1 = require("./providers");
|
|
17
17
|
const win_spawn_1 = require("./util/win-spawn");
|
|
18
|
+
const bundled_core_1 = require("./bundled-core");
|
|
19
|
+
const bundled_openspec_1 = require("./bundled-openspec");
|
|
18
20
|
const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
|
|
19
21
|
/**
|
|
20
22
|
* Run `<cmd> <args>` synchronously. On Windows use cross-spawn (NOT `shell:true`):
|
|
@@ -49,6 +51,22 @@ function locateCommand(command) {
|
|
|
49
51
|
const resolvedPath = `${result.stdout ?? ''}`.trim().split(/\r?\n/)[0]?.trim() || undefined;
|
|
50
52
|
return { found: true, resolvedPath };
|
|
51
53
|
}
|
|
54
|
+
/** Run `<bin> <args>` and interpret the result as a `--version` probe. */
|
|
55
|
+
function runProbe(bin, args) {
|
|
56
|
+
const result = runVersionSpawn(bin, args);
|
|
57
|
+
if (result.error) {
|
|
58
|
+
const err = result.error;
|
|
59
|
+
return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
|
|
60
|
+
}
|
|
61
|
+
if ((result.status ?? 1) !== 0) {
|
|
62
|
+
const stderr = `${result.stderr ?? ''}`.trim().slice(0, 400);
|
|
63
|
+
const signal = result.signal ? ` signal=${result.signal}` : '';
|
|
64
|
+
return { executed: false, error: `exit=${result.status ?? '?'}${signal}${stderr ? ` stderr=${stderr}` : ''}` };
|
|
65
|
+
}
|
|
66
|
+
const output = `${result.stdout ?? result.stderr ?? ''}`.trim();
|
|
67
|
+
const version = output.split(/\r?\n/)[0]?.trim() || undefined;
|
|
68
|
+
return { executed: true, version };
|
|
69
|
+
}
|
|
52
70
|
function probeVersion(command, resolvedPath) {
|
|
53
71
|
// IMPORTANT: prefer the absolute path returned by `which`. When the server is
|
|
54
72
|
// bundled with `pkg`, calling spawn with the bare command name `'node'` is
|
|
@@ -64,19 +82,7 @@ function probeVersion(command, resolvedPath) {
|
|
|
64
82
|
// cross-spawn with correct escaping. The env carries SystemRoot so cmd.exe can
|
|
65
83
|
// start. This replaces the old `shell:true` path that made every bundled probe
|
|
66
84
|
// fail with a bogus "corrupted-bundle" when the sidecar env lacked SystemRoot.
|
|
67
|
-
|
|
68
|
-
if (result.error) {
|
|
69
|
-
const err = result.error;
|
|
70
|
-
return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
|
|
71
|
-
}
|
|
72
|
-
if ((result.status ?? 1) !== 0) {
|
|
73
|
-
const stderr = `${result.stderr ?? ''}`.trim().slice(0, 400);
|
|
74
|
-
const signal = result.signal ? ` signal=${result.signal}` : '';
|
|
75
|
-
return { executed: false, error: `exit=${result.status ?? '?'}${signal}${stderr ? ` stderr=${stderr}` : ''}` };
|
|
76
|
-
}
|
|
77
|
-
const output = `${result.stdout ?? result.stderr ?? ''}`.trim();
|
|
78
|
-
const version = output.split(/\r?\n/)[0]?.trim() || undefined;
|
|
79
|
-
return { executed: true, version };
|
|
85
|
+
return runProbe(target, ['--version']);
|
|
80
86
|
}
|
|
81
87
|
/** Extracts the first `major.minor.patch` triple from a version string.
|
|
82
88
|
* Tolerates prefixes like `v`, `git version `, `node `. */
|
|
@@ -144,6 +150,41 @@ function fileExists(p) {
|
|
|
144
150
|
return false;
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
/** The bundled REAL node executable inside the runtimes tree, or null. */
|
|
154
|
+
function bundledNodeExe(runtimesBase) {
|
|
155
|
+
return getBundledToolCandidates(runtimesBase, 'node').find(fileExists) ?? null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Absolute path to the bundled npm/npx CLI JS (`npm-cli.js` / `npx-cli.js`), or
|
|
159
|
+
* null. The npm package ships INSIDE the node distribution: at
|
|
160
|
+
* `node/node_modules/npm/bin/` on Windows and `node/lib/node_modules/npm/bin/`
|
|
161
|
+
* on POSIX. Both `npm` and `npx` are served by the npm package (npx-cli.js lives
|
|
162
|
+
* there too).
|
|
163
|
+
*/
|
|
164
|
+
function bundledNpmCliJs(runtimesBase, tool) {
|
|
165
|
+
const file = tool === 'npx' ? 'npx-cli.js' : 'npm-cli.js';
|
|
166
|
+
const candidates = process.platform === 'win32'
|
|
167
|
+
? [path_1.default.join(runtimesBase, 'node', 'node_modules', 'npm', 'bin', file)]
|
|
168
|
+
: [path_1.default.join(runtimesBase, 'node', 'lib', 'node_modules', 'npm', 'bin', file)];
|
|
169
|
+
return candidates.find(fileExists) ?? null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Probe a bundled npm/npx by running the bundled node DIRECTLY against the npm
|
|
173
|
+
* package's CLI JS, instead of executing the `.cmd`/wrapper shim. The shims route
|
|
174
|
+
* through `cmd.exe` (Windows) / a wrapper script and proved fragile in the
|
|
175
|
+
* packaged app (a stripped sidecar env / cmd.exe quirks made `npm.cmd --version`
|
|
176
|
+
* fail with a bogus "corrupted-bundle" even though node.exe itself runs fine).
|
|
177
|
+
* Running `node npm-cli.js --version` is deterministic, shell-free and pkg-safe.
|
|
178
|
+
* Returns null when the bundled node or the CLI JS isn't present (caller falls
|
|
179
|
+
* back to probing the shim, then to the system tool).
|
|
180
|
+
*/
|
|
181
|
+
function probeBundledNpmLike(runtimesBase, tool) {
|
|
182
|
+
const nodeExe = bundledNodeExe(runtimesBase);
|
|
183
|
+
const cliJs = bundledNpmCliJs(runtimesBase, tool);
|
|
184
|
+
if (!nodeExe || !cliJs)
|
|
185
|
+
return null;
|
|
186
|
+
return { probe: runProbe(nodeExe, [cliJs, '--version']), resolvedPath: cliJs };
|
|
187
|
+
}
|
|
147
188
|
// Short-lived memo: probing involves up to ~12 synchronous spawnSync calls that
|
|
148
189
|
// block the single Express event loop. The endpoint only fires from low-freq
|
|
149
190
|
// setup flows, so a 30s TTL eliminates repeated probe storms (cold-cache
|
|
@@ -167,6 +208,13 @@ function __resetSetupPrerequisitesCacheForTest() {
|
|
|
167
208
|
function computeSetupPrerequisitesStatus(options = {}) {
|
|
168
209
|
const isDesktop = process.env.SPECRAILS_IS_DESKTOP === '1';
|
|
169
210
|
const runtimesBase = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '';
|
|
211
|
+
// When the app ships a bundled core AND bundled openspec, project-add is fully
|
|
212
|
+
// OFFLINE: it runs `node cli.js` / `node openspec.js` directly and NEVER spawns
|
|
213
|
+
// npm or npx. So npm/npx are advisory (not required) in that mode — a broken or
|
|
214
|
+
// unprobeable npm/npx must NOT dead-end Add Project for tools the install flow
|
|
215
|
+
// never invokes. Outside the bundled-offline path (dev / no bundle) npx IS used
|
|
216
|
+
// (npx specrails-core), so npm/npx stay required there.
|
|
217
|
+
const bundledOfflineSetup = isDesktop && (0, bundled_core_1.getBundledCoreCli)() !== null && (0, bundled_openspec_1.getBundledOpenspecCli)() !== null;
|
|
170
218
|
const platform = process.platform === 'darwin'
|
|
171
219
|
? 'darwin'
|
|
172
220
|
: process.platform === 'win32'
|
|
@@ -190,7 +238,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
|
|
|
190
238
|
kind: 'tool',
|
|
191
239
|
label: 'npm',
|
|
192
240
|
command: 'npm',
|
|
193
|
-
required:
|
|
241
|
+
required: !bundledOfflineSetup,
|
|
194
242
|
minVersion: exports.MIN_VERSIONS.npm,
|
|
195
243
|
installUrl: 'https://nodejs.org/en/download',
|
|
196
244
|
installHint: 'npm ships with Node.js LTS.',
|
|
@@ -200,7 +248,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
|
|
|
200
248
|
kind: 'tool',
|
|
201
249
|
label: 'npx',
|
|
202
250
|
command: 'npx',
|
|
203
|
-
required:
|
|
251
|
+
required: !bundledOfflineSetup,
|
|
204
252
|
installUrl: 'https://nodejs.org/en/download',
|
|
205
253
|
installHint: 'npx ships with npm and is required to run specrails-core.',
|
|
206
254
|
},
|
|
@@ -254,17 +302,27 @@ function computeSetupPrerequisitesStatus(options = {}) {
|
|
|
254
302
|
// Desktop mode: probe bundled absolute paths for node/npm/npx/git.
|
|
255
303
|
// Provider CLIs (claude, codex) are always probed via system PATH regardless of mode.
|
|
256
304
|
if (isDesktop && definition.kind === 'tool' && isBundledTool(definition.key)) {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
305
|
+
const tool = definition.key;
|
|
306
|
+
// npm/npx: probe by running the bundled node DIRECTLY against the npm
|
|
307
|
+
// package's CLI JS — robust where the `.cmd`/wrapper shim proved fragile in
|
|
308
|
+
// the packaged app (cmd.exe / stripped-env quirks made `npm.cmd --version`
|
|
309
|
+
// fail with a bogus "corrupted-bundle" even though node.exe runs fine).
|
|
310
|
+
let effective = tool === 'npm' || tool === 'npx' ? probeBundledNpmLike(runtimesBase, tool) : null;
|
|
311
|
+
// Otherwise (node/git, or npm/npx whose CLI JS wasn't found) probe the
|
|
312
|
+
// bundled binary/shim directly. Only treat as bundled when the FILE exists;
|
|
313
|
+
// a missing file means this build shipped no runtimes for this platform/arch
|
|
262
314
|
// — fall through to the system probe so a system-installed tool still
|
|
263
|
-
// satisfies the requirement
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
if (
|
|
267
|
-
const
|
|
315
|
+
// satisfies the requirement instead of dead-ending Add Project with a futile
|
|
316
|
+
// "reinstall the app". 'corrupted-bundle' is reserved for file-EXISTS-but-
|
|
317
|
+
// fails-its-probe.
|
|
318
|
+
if (!effective) {
|
|
319
|
+
const bundledPath = getBundledToolCandidates(runtimesBase, tool).find(fileExists);
|
|
320
|
+
if (bundledPath) {
|
|
321
|
+
effective = { probe: probeVersion(tool, bundledPath), resolvedPath: bundledPath };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (effective) {
|
|
325
|
+
const { probe, resolvedPath } = effective;
|
|
268
326
|
if (!probe.executed) {
|
|
269
327
|
return {
|
|
270
328
|
...definition,
|
|
@@ -272,7 +330,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
|
|
|
272
330
|
executable: false,
|
|
273
331
|
bundled: true,
|
|
274
332
|
error: 'corrupted-bundle',
|
|
275
|
-
resolvedPath
|
|
333
|
+
resolvedPath,
|
|
276
334
|
executionError: probe.error,
|
|
277
335
|
meetsMinimum: false,
|
|
278
336
|
installHint: 'Bundle corrupted — reinstall the Specrails app.',
|
|
@@ -284,12 +342,12 @@ function computeSetupPrerequisitesStatus(options = {}) {
|
|
|
284
342
|
executable: true,
|
|
285
343
|
bundled: true,
|
|
286
344
|
version: probe.version,
|
|
287
|
-
resolvedPath
|
|
345
|
+
resolvedPath,
|
|
288
346
|
meetsMinimum: meetsMinimumVersion(probe.version, definition.minVersion),
|
|
289
347
|
installHint: '',
|
|
290
348
|
};
|
|
291
349
|
}
|
|
292
|
-
//
|
|
350
|
+
// No bundled binary/CLI found → fall through to the system probe below.
|
|
293
351
|
}
|
|
294
352
|
// Non-desktop (or provider CLI in any mode, or desktop with bundle absent): system probe.
|
|
295
353
|
const lookup = locateCommand(definition.command);
|
|
@@ -54,10 +54,29 @@ function windowsSpawnEnv(base = process.env) {
|
|
|
54
54
|
if (process.platform !== 'win32')
|
|
55
55
|
return base;
|
|
56
56
|
const env = { ...base };
|
|
57
|
-
const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows';
|
|
58
|
-
env.SystemRoot = systemRoot;
|
|
57
|
+
const systemRoot = (env.SystemRoot || env.windir || 'C:\\Windows').replace(/[\\/]$/, '');
|
|
58
|
+
env.SystemRoot = env.SystemRoot || systemRoot;
|
|
59
59
|
env.windir = env.windir || systemRoot;
|
|
60
|
-
env.ComSpec = env.ComSpec || `${systemRoot
|
|
60
|
+
env.ComSpec = env.ComSpec || `${systemRoot}\\System32\\cmd.exe`;
|
|
61
|
+
// npm/npx load @npmcli/config on startup (even `npm --version` and the inner
|
|
62
|
+
// npm-prefix.js resolver), which reads the user profile via USERPROFILE /
|
|
63
|
+
// APPDATA / HOMEDRIVE+HOMEPATH / TEMP. A GUI-launched, env-stripped sidecar can
|
|
64
|
+
// lack these → `node npm-cli.js --version` fails the same way the .cmd shim did.
|
|
65
|
+
// Backfill canonical Windows defaults when absent (node.exe itself needs none
|
|
66
|
+
// of these, which is why node/git probed fine while npm/npx did not).
|
|
67
|
+
const userProfile = env.USERPROFILE ||
|
|
68
|
+
(env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : 'C:\\Users\\Default');
|
|
69
|
+
env.USERPROFILE = userProfile;
|
|
70
|
+
const drive = /^([A-Za-z]:)(.*)$/.exec(userProfile);
|
|
71
|
+
if (drive) {
|
|
72
|
+
env.HOMEDRIVE = env.HOMEDRIVE || drive[1];
|
|
73
|
+
env.HOMEPATH = env.HOMEPATH || (drive[2] || '\\');
|
|
74
|
+
}
|
|
75
|
+
env.APPDATA = env.APPDATA || `${userProfile}\\AppData\\Roaming`;
|
|
76
|
+
env.LOCALAPPDATA = env.LOCALAPPDATA || `${userProfile}\\AppData\\Local`;
|
|
77
|
+
const temp = env.TEMP || env.TMP || `${env.LOCALAPPDATA}\\Temp`;
|
|
78
|
+
env.TEMP = env.TEMP || temp;
|
|
79
|
+
env.TMP = env.TMP || temp;
|
|
61
80
|
return env;
|
|
62
81
|
}
|
|
63
82
|
// Back-compat for callsites that only need the resolved binary
|