specrails-desktop 2.11.0 → 2.11.2

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.0",
3
+ "version": "2.11.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,7 @@ const os_1 = __importDefault(require("os"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const framework_manager_1 = require("./framework-manager");
12
12
  const semver_lite_1 = require("./semver-lite");
13
+ const win_spawn_1 = require("./util/win-spawn");
13
14
  /**
14
15
  * CoreUpdateManager — the voluntary, app-global specrails-core update channel.
15
16
  *
@@ -215,5 +216,8 @@ function defaultNpmInstall(spec, cwd) {
215
216
  stdio: ['ignore', 'inherit', 'inherit'],
216
217
  timeout: INSTALL_TIMEOUT_MS,
217
218
  shell: process.platform === 'win32',
219
+ // SystemRoot/ComSpec so npm.cmd's cmd.exe can start even if the packaged
220
+ // sidecar inherited a stripped Windows environment.
221
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
218
222
  });
219
223
  }
@@ -13,6 +13,19 @@ const bundled_core_1 = require("./bundled-core");
13
13
  const artifact_registry_1 = require("./artifact-registry");
14
14
  Object.defineProperty(exports, "atomicWrite", { enumerable: true, get: function () { return artifact_registry_1.atomicWrite; } });
15
15
  const semver_lite_1 = require("./semver-lite");
16
+ const path_resolver_1 = require("./path-resolver");
17
+ /**
18
+ * The node interpreter to run the core CLI with. In the PACKAGED app
19
+ * `process.execPath` is the `specrails-server` pkg binary — NOT node — so
20
+ * `spawnSync(process.execPath, [cli, …])` would re-launch the server instead of
21
+ * running `cli.js`, silently breaking every framework materialize/swap/assemble.
22
+ * Use the bundled real node when present (it always is when a bundled core is —
23
+ * they ship together), and fall back to `process.execPath` only in dev/tests
24
+ * where `process.execPath` IS node.
25
+ */
26
+ function nodeInterpreter() {
27
+ return (0, path_resolver_1.resolveBundledNodeExe)() ?? process.execPath;
28
+ }
16
29
  /** `~/.specrails/framework` — same home as the registry. */
17
30
  function frameworkRoot(home) {
18
31
  return path_1.default.join((0, artifact_registry_1.resolveHome)(home), '.specrails', 'framework');
@@ -114,7 +127,7 @@ class FrameworkManager {
114
127
  const errors = [];
115
128
  const done = [];
116
129
  for (const provider of dedupe(providers)) {
117
- const res = (0, child_process_1.spawnSync)(process.execPath, [
130
+ const res = (0, child_process_1.spawnSync)(nodeInterpreter(), [
118
131
  cli,
119
132
  'install-framework',
120
133
  '--framework-dir',
@@ -153,7 +166,7 @@ class FrameworkManager {
153
166
  if (readCurrentFrameworkVersion(this.home) === version)
154
167
  return true;
155
168
  const fwDir = frameworkRoot(this.home);
156
- const res = (0, child_process_1.spawnSync)(process.execPath, [cli, 'swap-current', '--framework-dir', fwDir, '--version', version], { env: this.childEnv(), encoding: 'utf-8', timeout: 120_000 });
169
+ const res = (0, child_process_1.spawnSync)(nodeInterpreter(), [cli, 'swap-current', '--framework-dir', fwDir, '--version', version], { env: this.childEnv(), encoding: 'utf-8', timeout: 120_000 });
157
170
  if (res.error || (res.status ?? 1) !== 0)
158
171
  return false;
159
172
  return readCurrentFrameworkVersion(this.home) === version;
@@ -245,7 +258,7 @@ class FrameworkManager {
245
258
  if (!ver)
246
259
  return { ran: false };
247
260
  const fwDir = frameworkRoot(this.home);
248
- const res = (0, child_process_1.spawnSync)(process.execPath, [
261
+ const res = (0, child_process_1.spawnSync)(nodeInterpreter(), [
249
262
  cli,
250
263
  'assemble',
251
264
  '--workspace',
@@ -12,6 +12,7 @@ const fs_1 = __importDefault(require("fs"));
12
12
  const os_1 = __importDefault(require("os"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const bundled_openspec_1 = require("./bundled-openspec");
15
+ const path_resolver_1 = require("./path-resolver");
15
16
  /**
16
17
  * openspec PATH shim — the robustness backstop for relocated Claude rails.
17
18
  *
@@ -54,11 +55,15 @@ function openspecShimDir(slug, jobId, home = os_1.default.homedir()) {
54
55
  function realOpenspecInvocation() {
55
56
  const cli = (0, bundled_openspec_1.getBundledOpenspecCli)();
56
57
  if (cli) {
57
- // node "<cli>" — invoke the bundled openspec as a node script (Tauri strips
58
- // exec bits from bundled resources, so it can't be run as a binary).
58
+ // <node> "<cli>" — invoke the bundled openspec as a node script (Tauri strips
59
+ // exec bits from bundled resources, so it can't be run as a binary). Anchor to
60
+ // the absolute bundled node when present rather than a bare `node` (which is
61
+ // PATH-dependent and may be absent / a mismatched system node in a partial
62
+ // bundle); fall back to bare `node` only when no bundled runtimes ship.
63
+ const node = (0, path_resolver_1.resolveBundledNodeExe)();
59
64
  return {
60
- posix: `node ${shq(cli)}`,
61
- windows: `node ${winq(cli)}`,
65
+ posix: `${node ? shq(node) : 'node'} ${shq(cli)}`,
66
+ windows: `${node ? winq(node) : 'node'} ${winq(cli)}`,
62
67
  };
63
68
  }
64
69
  // Legacy fallback: npx resolves + caches openspec. `-y` skips the install
@@ -44,7 +44,7 @@ function resolveCoreBinary(bin) {
44
44
  if (bin.includes('/') || bin.includes('\\'))
45
45
  return (0, path_1.resolve)(bin);
46
46
  const result = (0, child_process_1.spawnSync)(WHICH_CMD, [bin], {
47
- env: process.env,
47
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
48
48
  shell: process.platform === 'win32',
49
49
  encoding: 'utf-8',
50
50
  timeout: 5_000,
@@ -82,7 +82,7 @@ function spawnCoreInit(args, cwd) {
82
82
  // repo). The desktop manages the cwd (spawns rails with cwd=workspace), so
83
83
  // it MUST relocate to keep the user's imported repo pristine — independent of
84
84
  // whether the registry mirror already ran. See specrails-core init's gate.
85
- env: { ...process.env, SPECRAILS_RELOCATE: '1' },
85
+ env: { ...(0, win_spawn_1.windowsSpawnEnv)(), SPECRAILS_RELOCATE: '1' },
86
86
  stdio: ['ignore', 'pipe', 'pipe'],
87
87
  });
88
88
  }
@@ -105,7 +105,7 @@ function spawnBundledCoreInit(args, cwd) {
105
105
  const fullArgs = [cli, 'init', ...args];
106
106
  // Force RELOCATION (core installs in-repo by default; the desktop keeps the
107
107
  // user's repo pristine + manages the spawn cwd). See spawnCoreInit.
108
- const env = { ...process.env, SPECRAILS_CORE_SCRIPT_DIR: coreRoot, SPECRAILS_RELOCATE: '1' };
108
+ const env = { ...(0, win_spawn_1.windowsSpawnEnv)(), SPECRAILS_CORE_SCRIPT_DIR: coreRoot, SPECRAILS_RELOCATE: '1' };
109
109
  // Bundled openspec (offline) — the LAST network step of project-add. When the
110
110
  // app ships @fission-ai/openspec, point specrails-core's `installOpenSpecProject`
111
111
  // at the bundled CLI and the SAME node we run the bundled core with. Tauri
@@ -125,9 +125,14 @@ function spawnBundledCoreInit(args, cwd) {
125
125
  // bundled node bin in desktop mode, so PATH `node` is the bundled node too).
126
126
  env.SPECRAILS_OPENSPEC_NODE = (0, path_resolver_1.resolveBundledNodeExe)() ?? 'node';
127
127
  }
128
- console.log(`[SetupManager] spawning BUNDLED core: ${process.execPath} ${fullArgs.join(' ')} (cwd=${cwd})` +
128
+ // Run the core CLI with the REAL bundled node — NOT process.execPath, which in
129
+ // the packaged app is the `specrails-server` pkg binary and would re-launch the
130
+ // server instead of running cli.js (same trap as SPECRAILS_OPENSPEC_NODE above).
131
+ // Fall back to process.execPath only in dev/tests where it IS node.
132
+ const nodeBin = (0, path_resolver_1.resolveBundledNodeExe)() ?? process.execPath;
133
+ console.log(`[SetupManager] spawning BUNDLED core: ${nodeBin} ${fullArgs.join(' ')} (cwd=${cwd})` +
129
134
  (openspecCli ? ' [bundled openspec: offline]' : ' [openspec: npx fallback]'));
130
- return (0, win_spawn_1.spawnCli)(process.execPath, fullArgs, {
135
+ return (0, win_spawn_1.spawnCli)(nodeBin, fullArgs, {
131
136
  cwd,
132
137
  env,
133
138
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -144,7 +149,7 @@ function probeCoreRuntimeVersion(cwd) {
144
149
  const { bin, fullArgs } = buildCoreArgs(['version']);
145
150
  const result = (0, child_process_1.spawnSync)(bin, fullArgs, {
146
151
  cwd,
147
- env: process.env,
152
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
148
153
  shell: process.platform === 'win32',
149
154
  encoding: 'utf-8',
150
155
  timeout: CORE_PROBE_TIMEOUT_MS,
@@ -10,10 +10,31 @@ exports.getSetupPrerequisitesStatus = getSetupPrerequisitesStatus;
10
10
  exports.__resetSetupPrerequisitesCacheForTest = __resetSetupPrerequisitesCacheForTest;
11
11
  exports.formatMissingSetupPrerequisites = formatMissingSetupPrerequisites;
12
12
  const child_process_1 = require("child_process");
13
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
13
14
  const fs_1 = __importDefault(require("fs"));
14
15
  const path_1 = __importDefault(require("path"));
15
16
  const providers_1 = require("./providers");
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");
16
20
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
21
+ /**
22
+ * Run `<cmd> <args>` synchronously. On Windows use cross-spawn (NOT `shell:true`):
23
+ * it spawns a real `.exe` directly (no `cmd.exe` interposition — pkg-safe and
24
+ * immune to the GUI-launch env stripping), and runs `.cmd`/`.bat` shims through
25
+ * `cmd.exe` with correct quoting (Node refuses to spawn `.cmd` without a shell
26
+ * since CVE-2024-27980). On POSIX it is a plain `spawnSync`. `windowsSpawnEnv()`
27
+ * guarantees `cmd.exe` can start (SystemRoot/ComSpec present even if the sidecar
28
+ * inherited a stripped env) — without it every bundled probe failed with a bogus
29
+ * "bundle corrupted — reinstall the app".
30
+ */
31
+ function runVersionSpawn(cmd, args) {
32
+ const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: 5_000 };
33
+ if (process.platform === 'win32') {
34
+ return cross_spawn_1.default.sync(cmd, args, opts);
35
+ }
36
+ return (0, child_process_1.spawnSync)(cmd, args, opts);
37
+ }
17
38
  exports.MIN_VERSIONS = {
18
39
  node: '18.0.0',
19
40
  npm: '9.0.0',
@@ -21,37 +42,18 @@ exports.MIN_VERSIONS = {
21
42
  uv: '0.1.0',
22
43
  };
23
44
  function locateCommand(command) {
24
- const result = (0, child_process_1.spawnSync)(WHICH_CMD, [command], {
25
- env: process.env,
26
- shell: process.platform === 'win32',
27
- encoding: 'utf-8',
28
- });
45
+ // cross-spawn (via runVersionSpawn) resolves the `where`/`which` executable
46
+ // and PATHEXT itself — no `shell:true` needed — and runs under an env that
47
+ // includes SystemRoot so `where` can actually start in the packaged sidecar.
48
+ const result = runVersionSpawn(WHICH_CMD, [command]);
29
49
  if (result.error || (result.status ?? 1) !== 0)
30
50
  return { found: false };
31
51
  const resolvedPath = `${result.stdout ?? ''}`.trim().split(/\r?\n/)[0]?.trim() || undefined;
32
52
  return { found: true, resolvedPath };
33
53
  }
34
- function probeVersion(command, resolvedPath) {
35
- // IMPORTANT: prefer the absolute path returned by `which`. When the server is
36
- // bundled with `pkg`, calling spawn with the bare command name `'node'` is
37
- // intercepted by pkg's child_process patch and redirected to `process.execPath`
38
- // (the server binary itself) — the child then crashes with
39
- // `Cannot find module '/--version'` from pkg/prelude/bootstrap.js. Passing
40
- // an absolute path bypasses this interception.
41
- const target = resolvedPath || command;
42
- // On Windows we must use `shell: true` to execute `.cmd` shims (npm, npx) — Node
43
- // refuses to spawn them directly since CVE-2024-27980. But cmd.exe splits the
44
- // command line on whitespace, so an absolute path like
45
- // `C:\Program Files\Git\cmd\git.exe` becomes `C:\Program` + arg `Files\Git\...`.
46
- // Wrap the target in double quotes when it contains a space.
47
- const isWin = process.platform === 'win32';
48
- const quotedTarget = isWin && /\s/.test(target) ? `"${target}"` : target;
49
- const result = (0, child_process_1.spawnSync)(quotedTarget, ['--version'], {
50
- env: process.env,
51
- shell: isWin,
52
- encoding: 'utf-8',
53
- timeout: 5_000,
54
- });
54
+ /** Run `<bin> <args>` and interpret the result as a `--version` probe. */
55
+ function runProbe(bin, args) {
56
+ const result = runVersionSpawn(bin, args);
55
57
  if (result.error) {
56
58
  const err = result.error;
57
59
  return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
@@ -65,6 +67,23 @@ function probeVersion(command, resolvedPath) {
65
67
  const version = output.split(/\r?\n/)[0]?.trim() || undefined;
66
68
  return { executed: true, version };
67
69
  }
70
+ function probeVersion(command, resolvedPath) {
71
+ // IMPORTANT: prefer the absolute path returned by `which`. When the server is
72
+ // bundled with `pkg`, calling spawn with the bare command name `'node'` is
73
+ // intercepted by pkg's child_process patch and redirected to `process.execPath`
74
+ // (the server binary itself) — the child then crashes with
75
+ // `Cannot find module '/--version'` from pkg/prelude/bootstrap.js. Passing
76
+ // an absolute path bypasses this interception.
77
+ const target = resolvedPath || command;
78
+ // Spawn via cross-spawn on Windows (NOT shell:true). A bundled `.exe`
79
+ // (node.exe, git.exe) is launched DIRECTLY — no cmd.exe interposition — so a
80
+ // path with spaces needs no manual quoting and pkg's bare-name redirect never
81
+ // applies. A `.cmd` shim (npm.cmd, npx.cmd) is run through cmd.exe by
82
+ // cross-spawn with correct escaping. The env carries SystemRoot so cmd.exe can
83
+ // start. This replaces the old `shell:true` path that made every bundled probe
84
+ // fail with a bogus "corrupted-bundle" when the sidecar env lacked SystemRoot.
85
+ return runProbe(target, ['--version']);
86
+ }
68
87
  /** Extracts the first `major.minor.patch` triple from a version string.
69
88
  * Tolerates prefixes like `v`, `git version `, `node `. */
70
89
  function parseSemver(raw) {
@@ -131,6 +150,41 @@ function fileExists(p) {
131
150
  return false;
132
151
  }
133
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
+ }
134
188
  // Short-lived memo: probing involves up to ~12 synchronous spawnSync calls that
135
189
  // block the single Express event loop. The endpoint only fires from low-freq
136
190
  // setup flows, so a 30s TTL eliminates repeated probe storms (cold-cache
@@ -154,6 +208,13 @@ function __resetSetupPrerequisitesCacheForTest() {
154
208
  function computeSetupPrerequisitesStatus(options = {}) {
155
209
  const isDesktop = process.env.SPECRAILS_IS_DESKTOP === '1';
156
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;
157
218
  const platform = process.platform === 'darwin'
158
219
  ? 'darwin'
159
220
  : process.platform === 'win32'
@@ -177,7 +238,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
177
238
  kind: 'tool',
178
239
  label: 'npm',
179
240
  command: 'npm',
180
- required: true,
241
+ required: !bundledOfflineSetup,
181
242
  minVersion: exports.MIN_VERSIONS.npm,
182
243
  installUrl: 'https://nodejs.org/en/download',
183
244
  installHint: 'npm ships with Node.js LTS.',
@@ -187,7 +248,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
187
248
  kind: 'tool',
188
249
  label: 'npx',
189
250
  command: 'npx',
190
- required: true,
251
+ required: !bundledOfflineSetup,
191
252
  installUrl: 'https://nodejs.org/en/download',
192
253
  installHint: 'npx ships with npm and is required to run specrails-core.',
193
254
  },
@@ -241,17 +302,27 @@ function computeSetupPrerequisitesStatus(options = {}) {
241
302
  // Desktop mode: probe bundled absolute paths for node/npm/npx/git.
242
303
  // Provider CLIs (claude, codex) are always probed via system PATH regardless of mode.
243
304
  if (isDesktop && definition.kind === 'tool' && isBundledTool(definition.key)) {
244
- const candidates = getBundledToolCandidates(runtimesBase, definition.key);
245
- const bundledPath = candidates.find(fileExists);
246
- // Only treat as bundled when the binary FILE actually exists. A missing
247
- // file means this build never shipped runtimes for this platform/arch
248
- // (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
249
314
  // — fall through to the system probe so a system-installed tool still
250
- // satisfies the requirement, instead of dead-ending Add Project with a
251
- // futile "reinstall the app" message. 'corrupted-bundle' is reserved for
252
- // the case where the file EXISTS but fails its --version probe.
253
- if (bundledPath) {
254
- 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;
255
326
  if (!probe.executed) {
256
327
  return {
257
328
  ...definition,
@@ -259,7 +330,7 @@ function computeSetupPrerequisitesStatus(options = {}) {
259
330
  executable: false,
260
331
  bundled: true,
261
332
  error: 'corrupted-bundle',
262
- resolvedPath: bundledPath,
333
+ resolvedPath,
263
334
  executionError: probe.error,
264
335
  meetsMinimum: false,
265
336
  installHint: 'Bundle corrupted — reinstall the Specrails app.',
@@ -271,12 +342,12 @@ function computeSetupPrerequisitesStatus(options = {}) {
271
342
  executable: true,
272
343
  bundled: true,
273
344
  version: probe.version,
274
- resolvedPath: bundledPath,
345
+ resolvedPath,
275
346
  meetsMinimum: meetsMinimumVersion(probe.version, definition.minVersion),
276
347
  installHint: '',
277
348
  };
278
349
  }
279
- // bundledPath not found → fall through to the system probe below.
350
+ // No bundled binary/CLI found → fall through to the system probe below.
280
351
  }
281
352
  // Non-desktop (or provider CLI in any mode, or desktop with bundle absent): system probe.
282
353
  const lookup = locateCommand(definition.command);
@@ -25,16 +25,60 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  };
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.spawnCli = spawnCli;
28
+ exports.windowsSpawnEnv = windowsSpawnEnv;
28
29
  exports.resolveWindowsBinary = resolveWindowsBinary;
29
30
  const child_process_1 = require("child_process");
30
31
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
31
32
  function spawnCli(binary, args, options = {}) {
32
- /* c8 ignore next 3 -- Windows-only branch; coverage runs on Linux/macOS */
33
+ /* c8 ignore next 5 -- Windows-only branch; coverage runs on Linux/macOS */
33
34
  if (process.platform === 'win32') {
34
- return (0, cross_spawn_1.default)(binary, args, options);
35
+ // Guarantee SystemRoot/ComSpec so cmd.exe (which cross-spawn uses to run
36
+ // `.cmd` shims like claude.cmd/npm.cmd) can start even when the packaged
37
+ // sidecar inherited a stripped environment. Chokepoint for EVERY Windows
38
+ // spawn — protects rails, chat, setup and probes uniformly.
39
+ return (0, cross_spawn_1.default)(binary, args, { ...options, env: windowsSpawnEnv(options.env) });
35
40
  }
36
41
  return (0, child_process_1.spawn)(binary, args, options);
37
42
  }
43
+ /**
44
+ * Return an environment safe for spawning children on Windows. The desktop
45
+ * server runs as a pkg-packaged sidecar launched by the Tauri host, and that
46
+ * spawn can deliver a STRIPPED environment missing `SystemRoot` / `windir` /
47
+ * `ComSpec`. Without `SystemRoot`, `cmd.exe` (used to run `.cmd` shims like
48
+ * `npm.cmd`/`npx.cmd`, and by cross-spawn) fails to initialise — which silently
49
+ * broke every bundled-tool `--version` probe and any npx/npm spawn during setup.
50
+ * Reconstructs the canonical values when absent. No-op (returns `base`
51
+ * unchanged) on POSIX. Pass a base env to layer extra vars on top.
52
+ */
53
+ function windowsSpawnEnv(base = process.env) {
54
+ if (process.platform !== 'win32')
55
+ return base;
56
+ const env = { ...base };
57
+ const systemRoot = (env.SystemRoot || env.windir || 'C:\\Windows').replace(/[\\/]$/, '');
58
+ env.SystemRoot = env.SystemRoot || systemRoot;
59
+ env.windir = env.windir || 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;
80
+ return env;
81
+ }
38
82
  // Back-compat for callsites that only need the resolved binary
39
83
  // (e.g. logging). Kept as a no-op identity on POSIX; on Windows
40
84
  // `where`-based resolution lives inside cross-spawn now.