specrails-desktop 2.10.1 → 2.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.10.1",
3
+ "version": "2.11.1",
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,
@@ -77,7 +77,12 @@ function spawnCoreInit(args, cwd) {
77
77
  // .cmd shim AND quotes each arg, so spaces (and newlines) survive intact.
78
78
  return (0, win_spawn_1.spawnCli)(bin, fullArgs, {
79
79
  cwd,
80
- env: process.env,
80
+ // Force RELOCATION: specrails-core installs in-repo by DEFAULT now (so a
81
+ // standalone `npx specrails-core init` user's CLI finds the agents in their
82
+ // repo). The desktop manages the cwd (spawns rails with cwd=workspace), so
83
+ // it MUST relocate to keep the user's imported repo pristine — independent of
84
+ // whether the registry mirror already ran. See specrails-core init's gate.
85
+ env: { ...(0, win_spawn_1.windowsSpawnEnv)(), SPECRAILS_RELOCATE: '1' },
81
86
  stdio: ['ignore', 'pipe', 'pipe'],
82
87
  });
83
88
  }
@@ -98,7 +103,9 @@ function spawnBundledCoreInit(args, cwd) {
98
103
  if (!cli || !coreRoot)
99
104
  return null;
100
105
  const fullArgs = [cli, 'init', ...args];
101
- const env = { ...process.env, SPECRAILS_CORE_SCRIPT_DIR: coreRoot };
106
+ // Force RELOCATION (core installs in-repo by default; the desktop keeps the
107
+ // user's repo pristine + manages the spawn cwd). See spawnCoreInit.
108
+ const env = { ...(0, win_spawn_1.windowsSpawnEnv)(), SPECRAILS_CORE_SCRIPT_DIR: coreRoot, SPECRAILS_RELOCATE: '1' };
102
109
  // Bundled openspec (offline) — the LAST network step of project-add. When the
103
110
  // app ships @fission-ai/openspec, point specrails-core's `installOpenSpecProject`
104
111
  // at the bundled CLI and the SAME node we run the bundled core with. Tauri
@@ -118,9 +125,14 @@ function spawnBundledCoreInit(args, cwd) {
118
125
  // bundled node bin in desktop mode, so PATH `node` is the bundled node too).
119
126
  env.SPECRAILS_OPENSPEC_NODE = (0, path_resolver_1.resolveBundledNodeExe)() ?? 'node';
120
127
  }
121
- 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})` +
122
134
  (openspecCli ? ' [bundled openspec: offline]' : ' [openspec: npx fallback]'));
123
- return (0, win_spawn_1.spawnCli)(process.execPath, fullArgs, {
135
+ return (0, win_spawn_1.spawnCli)(nodeBin, fullArgs, {
124
136
  cwd,
125
137
  env,
126
138
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -137,7 +149,7 @@ function probeCoreRuntimeVersion(cwd) {
137
149
  const { bin, fullArgs } = buildCoreArgs(['version']);
138
150
  const result = (0, child_process_1.spawnSync)(bin, fullArgs, {
139
151
  cwd,
140
- env: process.env,
152
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
141
153
  shell: process.platform === 'win32',
142
154
  encoding: 'utf-8',
143
155
  timeout: CORE_PROBE_TIMEOUT_MS,
@@ -10,10 +10,29 @@ 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");
16
18
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
19
+ /**
20
+ * Run `<cmd> <args>` synchronously. On Windows use cross-spawn (NOT `shell:true`):
21
+ * it spawns a real `.exe` directly (no `cmd.exe` interposition — pkg-safe and
22
+ * immune to the GUI-launch env stripping), and runs `.cmd`/`.bat` shims through
23
+ * `cmd.exe` with correct quoting (Node refuses to spawn `.cmd` without a shell
24
+ * since CVE-2024-27980). On POSIX it is a plain `spawnSync`. `windowsSpawnEnv()`
25
+ * guarantees `cmd.exe` can start (SystemRoot/ComSpec present even if the sidecar
26
+ * inherited a stripped env) — without it every bundled probe failed with a bogus
27
+ * "bundle corrupted — reinstall the app".
28
+ */
29
+ function runVersionSpawn(cmd, args) {
30
+ const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: 5_000 };
31
+ if (process.platform === 'win32') {
32
+ return cross_spawn_1.default.sync(cmd, args, opts);
33
+ }
34
+ return (0, child_process_1.spawnSync)(cmd, args, opts);
35
+ }
17
36
  exports.MIN_VERSIONS = {
18
37
  node: '18.0.0',
19
38
  npm: '9.0.0',
@@ -21,11 +40,10 @@ exports.MIN_VERSIONS = {
21
40
  uv: '0.1.0',
22
41
  };
23
42
  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
- });
43
+ // cross-spawn (via runVersionSpawn) resolves the `where`/`which` executable
44
+ // and PATHEXT itself — no `shell:true` needed — and runs under an env that
45
+ // includes SystemRoot so `where` can actually start in the packaged sidecar.
46
+ const result = runVersionSpawn(WHICH_CMD, [command]);
29
47
  if (result.error || (result.status ?? 1) !== 0)
30
48
  return { found: false };
31
49
  const resolvedPath = `${result.stdout ?? ''}`.trim().split(/\r?\n/)[0]?.trim() || undefined;
@@ -39,19 +57,14 @@ function probeVersion(command, resolvedPath) {
39
57
  // `Cannot find module '/--version'` from pkg/prelude/bootstrap.js. Passing
40
58
  // an absolute path bypasses this interception.
41
59
  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
- });
60
+ // Spawn via cross-spawn on Windows (NOT shell:true). A bundled `.exe`
61
+ // (node.exe, git.exe) is launched DIRECTLY no cmd.exe interposition — so a
62
+ // path with spaces needs no manual quoting and pkg's bare-name redirect never
63
+ // applies. A `.cmd` shim (npm.cmd, npx.cmd) is run through cmd.exe by
64
+ // cross-spawn with correct escaping. The env carries SystemRoot so cmd.exe can
65
+ // start. This replaces the old `shell:true` path that made every bundled probe
66
+ // fail with a bogus "corrupted-bundle" when the sidecar env lacked SystemRoot.
67
+ const result = runVersionSpawn(target, ['--version']);
55
68
  if (result.error) {
56
69
  const err = result.error;
57
70
  return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
@@ -25,16 +25,41 @@ 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';
58
+ env.SystemRoot = systemRoot;
59
+ env.windir = env.windir || systemRoot;
60
+ env.ComSpec = env.ComSpec || `${systemRoot.replace(/[\\/]$/, '')}\\System32\\cmd.exe`;
61
+ return env;
62
+ }
38
63
  // Back-compat for callsites that only need the resolved binary
39
64
  // (e.g. logging). Kept as a no-op identity on POSIX; on Windows
40
65
  // `where`-based resolution lives inside cross-spawn now.