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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.11.1",
3
+ "version": "2.11.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -620,19 +620,42 @@ async function validateCoreContract() {
620
620
  }
621
621
  // ─── SetupManager ─────────────────────────────────────────────────────────────
622
622
  const INSTALL_LOG_BUFFER_MAX = 2000;
623
- function formatBufferedInstallError(baseMessage, logBuffer) {
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(-8);
628
- if (recentLines.length === 0)
629
- return baseMessage;
630
- return [
631
- baseMessage,
632
- '',
633
- 'Recent output:',
634
- ...recentLines.map((line) => `- ${line}`),
635
- ].join('\n');
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
- const result = runVersionSpawn(target, ['--version']);
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: true,
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: true,
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 candidates = getBundledToolCandidates(runtimesBase, definition.key);
258
- const bundledPath = candidates.find(fileExists);
259
- // Only treat as bundled when the binary FILE actually exists. A missing
260
- // file means this build never shipped runtimes for this platform/arch
261
- // (e.g. a runtimes-less Windows ARM64 build, or a partial CI extraction)
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, instead of dead-ending Add Project with a
264
- // futile "reinstall the app" message. 'corrupted-bundle' is reserved for
265
- // the case where the file EXISTS but fails its --version probe.
266
- if (bundledPath) {
267
- const probe = probeVersion(definition.key, bundledPath);
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: bundledPath,
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: bundledPath,
345
+ resolvedPath,
288
346
  meetsMinimum: meetsMinimumVersion(probe.version, definition.minVersion),
289
347
  installHint: '',
290
348
  };
291
349
  }
292
- // bundledPath not found → fall through to the system probe below.
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.replace(/[\\/]$/, '')}\\System32\\cmd.exe`;
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