specrails-desktop 2.28.0 → 2.28.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.
@@ -38,6 +38,7 @@ const fs_1 = __importDefault(require("fs"));
38
38
  // ---------------------------------------------------------------------------
39
39
  const DEFAULT_PORT = 4200;
40
40
  const DETECTION_TIMEOUT_MS = 500;
41
+ const HTTP_REQUEST_TIMEOUT_MS = 15_000;
41
42
  exports.KNOWN_VERBS = new Set([
42
43
  'implement',
43
44
  'batch-implement',
@@ -242,7 +243,10 @@ function loadDesktopToken() {
242
243
  }
243
244
  return null;
244
245
  }
245
- function httpGet(url) {
246
+ function requestTimeoutError(url, timeoutMs) {
247
+ return new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
248
+ }
249
+ function httpGet(url, timeoutMs = HTTP_REQUEST_TIMEOUT_MS) {
246
250
  return new Promise((resolve, reject) => {
247
251
  const token = loadDesktopToken();
248
252
  const parsed = new URL(url);
@@ -260,10 +264,13 @@ function httpGet(url) {
260
264
  res.on('data', (chunk) => { body += chunk; });
261
265
  res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
262
266
  });
267
+ req.setTimeout(timeoutMs, () => {
268
+ req.destroy(requestTimeoutError(url, timeoutMs));
269
+ });
263
270
  req.on('error', reject);
264
271
  });
265
272
  }
266
- function httpPost(url, payload) {
273
+ function httpPost(url, payload, timeoutMs = HTTP_REQUEST_TIMEOUT_MS) {
267
274
  return new Promise((resolve, reject) => {
268
275
  const data = JSON.stringify(payload);
269
276
  const urlObj = new URL(url);
@@ -286,6 +293,9 @@ function httpPost(url, payload) {
286
293
  res.on('data', (chunk) => { body += chunk; });
287
294
  res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
288
295
  });
296
+ req.setTimeout(timeoutMs, () => {
297
+ req.destroy(requestTimeoutError(url, timeoutMs));
298
+ });
289
299
  req.on('error', reject);
290
300
  req.write(data);
291
301
  req.end();
@@ -1138,5 +1148,5 @@ exports._internal = {
1138
1148
  httpGet, httpPost, formatJobDuration, formatJobStarted, printVersion, printHelp,
1139
1149
  handleStatus, handleJobs, handleDesktop, desktopStart, desktopStop, desktopStatus, desktopAdd, desktopRemove, desktopList, desktopServerPath,
1140
1150
  resolveProjectFromCwd, runViaWebManager, runDirect, isPortInUse, readPid, isProcessRunning, main,
1141
- isTTY, DESKTOP_PID_FILE, DESKTOP_LOG_FILE, EXIT_PATTERN, DEFAULT_PORT, DETECTION_TIMEOUT_MS,
1151
+ isTTY, DESKTOP_PID_FILE, DESKTOP_LOG_FILE, EXIT_PATTERN, DEFAULT_PORT, DETECTION_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS,
1142
1152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.28.0",
3
+ "version": "2.28.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,7 +37,7 @@
37
37
  "check-core-compat": "tsx scripts/check-core-compat.ts",
38
38
  "test": "vitest run && tsx scripts/check-core-compat.ts",
39
39
  "test:watch": "vitest",
40
- "test:coverage": "vitest run --coverage",
40
+ "test:coverage": "vitest run --coverage --maxWorkers=1 --reporter=dot --bail=1",
41
41
  "test:client": "cd client && npx vitest run",
42
42
  "test:all": "vitest run && cd client && npx vitest run",
43
43
  "ci": "npm run typecheck && npm run check-core-compat && npm run test:coverage && cd client && npm run test:coverage"
@@ -163,17 +163,35 @@ function readRefineChildOutput(child, timeoutMs, kill = tree_kill_1.default) {
163
163
  resolve({ fullText, resultEvent, code: -1, timedOut: false });
164
164
  return;
165
165
  }
166
+ const stdout = child.stdout;
166
167
  let stderrBuf = '';
167
- if (child.stderr) {
168
- child.stderr.on('data', (chunk) => {
169
- const s = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
170
- stderrBuf += s;
171
- if (stderrBuf.length > 8192)
172
- stderrBuf = stderrBuf.slice(-8192);
173
- });
174
- }
168
+ const onStderrData = (chunk) => {
169
+ const s = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
170
+ stderrBuf += s;
171
+ if (stderrBuf.length > 8192)
172
+ stderrBuf = stderrBuf.slice(-8192);
173
+ };
174
+ if (child.stderr)
175
+ child.stderr.on('data', onStderrData);
175
176
  let killEscalation = null;
176
- const reader = (0, node_readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
177
+ const reader = (0, node_readline_1.createInterface)({ input: stdout, crlfDelay: Infinity });
178
+ let outputClosed = false;
179
+ const closeOutputReaders = () => {
180
+ if (outputClosed)
181
+ return;
182
+ outputClosed = true;
183
+ reader.removeAllListeners('line');
184
+ try {
185
+ reader.close();
186
+ }
187
+ catch { /* already closed */ }
188
+ if (child.stderr)
189
+ child.stderr.off('data', onStderrData);
190
+ if (!stdout.destroyed)
191
+ stdout.destroy();
192
+ if (child.stderr && !child.stderr.destroyed)
193
+ child.stderr.destroy();
194
+ };
177
195
  reader.on('line', (line) => {
178
196
  let parsed = null;
179
197
  try {
@@ -202,6 +220,7 @@ function readRefineChildOutput(child, timeoutMs, kill = tree_kill_1.default) {
202
220
  // BUG-PARSER-01: treeKill the whole subtree (SIGTERM) and SIGKILL-escalate
203
221
  // after a grace window so a signal-swallowing CLI tree is force-killed.
204
222
  killEscalation = escalateKill(child.pid, kill);
223
+ closeOutputReaders();
205
224
  if (!settled) {
206
225
  settled = true;
207
226
  resolve({ fullText, resultEvent, code: null, timedOut: true });
@@ -215,6 +234,7 @@ function readRefineChildOutput(child, timeoutMs, kill = tree_kill_1.default) {
215
234
  clearTimeout(killEscalation);
216
235
  killEscalation = null;
217
236
  }
237
+ closeOutputReaders();
218
238
  if (settled)
219
239
  return;
220
240
  settled = true;
@@ -229,6 +249,7 @@ function readRefineChildOutput(child, timeoutMs, kill = tree_kill_1.default) {
229
249
  clearTimeout(killEscalation);
230
250
  killEscalation = null;
231
251
  }
252
+ closeOutputReaders();
232
253
  console.log(`[contract-refine-runner] child error: ${err.message}; stderr=${JSON.stringify(stderrBuf.slice(-2000))}`);
233
254
  if (settled)
234
255
  return;
@@ -20,7 +20,7 @@ const providers_1 = require("./providers");
20
20
  * provider behaviour (instructions file is adapter-driven: CLAUDE.md for
21
21
  * claude, AGENTS.md for codex).
22
22
  *
23
- * Layout under `~/.specrails/projects/<slug>/explore-cwd/`:
23
+ * Layout under `<specrails-home>/.specrails/projects/<slug>/explore-cwd/`:
24
24
  * ├── <adapter.instructionsFilename> — embedded mini-prompt, app-owned
25
25
  * ├── project — symlink/junction → <project.path>
26
26
  * └── project-path.txt — fallback when symlink creation fails
@@ -87,12 +87,15 @@ function renderExploreInstructions(projectName) {
87
87
  function renderExploreClaudeMd(projectName) {
88
88
  return renderExploreInstructions(projectName);
89
89
  }
90
+ function homeDir() {
91
+ return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
92
+ }
90
93
  /**
91
94
  * Compute the explore-cwd path for a project without touching the filesystem.
92
95
  * Used by the legacy-cwd env-var short-circuit and by tests.
93
96
  */
94
97
  function exploreCwdPathFor(slug, baseDir) {
95
- const base = baseDir ?? path_1.default.join(os_1.default.homedir(), '.specrails', 'projects');
98
+ const base = baseDir ?? path_1.default.join(homeDir(), '.specrails', 'projects');
96
99
  return path_1.default.join(base, slug, 'explore-cwd');
97
100
  }
98
101
  /**
@@ -102,7 +105,7 @@ function exploreCwdPathFor(slug, baseDir) {
102
105
  * When SPECRAILS_EXPLORE_LEGACY_CWD=1 is set, returns `projectPath` directly
103
106
  * and performs no filesystem IO — used as the rollback escape hatch.
104
107
  *
105
- * Pass `baseDir` for tests to redirect away from the user's home directory.
108
+ * Pass `baseDir` for tests that need a specific projects directory.
106
109
  */
107
110
  function ensureExploreCwd(input, baseDir) {
108
111
  if (process.env.SPECRAILS_EXPLORE_LEGACY_CWD === '1') {
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HeadroomManager = void 0;
6
+ exports.HeadroomManager = exports.HEADROOM_MANAGED_PYTHON_VERSION = void 0;
7
+ exports.getHeadroomManagedInstallPlan = getHeadroomManagedInstallPlan;
7
8
  const child_process_1 = require("child_process");
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const os_1 = __importDefault(require("os"));
@@ -12,11 +13,15 @@ const desktop_db_1 = require("./desktop-db");
12
13
  const headroom_routing_1 = require("./headroom-routing");
13
14
  const STATE_KEY = 'plugins.headroom.state';
14
15
  const DEFAULT_PORT = 8787;
16
+ exports.HEADROOM_MANAGED_PYTHON_VERSION = '3.12';
15
17
  function nowIso() {
16
18
  return new Date().toISOString();
17
19
  }
20
+ function specrailsHome() {
21
+ return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
22
+ }
18
23
  function toolRoot() {
19
- return path_1.default.join(os_1.default.homedir(), '.specrails', 'tools');
24
+ return path_1.default.join(specrailsHome(), '.specrails', 'tools');
20
25
  }
21
26
  function uvToolDir() {
22
27
  return path_1.default.join(toolRoot(), 'uv', 'tools');
@@ -24,6 +29,15 @@ function uvToolDir() {
24
29
  function uvCacheDir() {
25
30
  return path_1.default.join(toolRoot(), 'uv', 'cache');
26
31
  }
32
+ function uvPythonInstallDir() {
33
+ return path_1.default.join(toolRoot(), 'uv', 'python');
34
+ }
35
+ function uvPythonCacheDir() {
36
+ return path_1.default.join(toolRoot(), 'uv', 'python-cache');
37
+ }
38
+ function uvPythonBinDir() {
39
+ return path_1.default.join(toolRoot(), 'uv', 'python-bin');
40
+ }
27
41
  function uvBinDir() {
28
42
  return path_1.default.join(toolRoot(), 'bin');
29
43
  }
@@ -152,6 +166,41 @@ function classifyInstallFailure(output, command) {
152
166
  }
153
167
  return issue('unknown', output, command);
154
168
  }
169
+ function getHeadroomManagedInstallPlan() {
170
+ return {
171
+ pythonVersion: exports.HEADROOM_MANAGED_PYTHON_VERSION,
172
+ pythonInstallArgs: [
173
+ 'python',
174
+ 'install',
175
+ exports.HEADROOM_MANAGED_PYTHON_VERSION,
176
+ '--managed-python',
177
+ ],
178
+ toolInstallArgs: [
179
+ 'tool',
180
+ 'install',
181
+ '--python',
182
+ exports.HEADROOM_MANAGED_PYTHON_VERSION,
183
+ '--managed-python',
184
+ '--force',
185
+ 'headroom-ai[all]',
186
+ ],
187
+ env: {
188
+ UV_TOOL_DIR: uvToolDir(),
189
+ UV_TOOL_BIN_DIR: uvBinDir(),
190
+ UV_CACHE_DIR: uvCacheDir(),
191
+ UV_PYTHON: exports.HEADROOM_MANAGED_PYTHON_VERSION,
192
+ UV_MANAGED_PYTHON: 'true',
193
+ UV_PYTHON_DOWNLOADS: 'automatic',
194
+ UV_PYTHON_PREFERENCE: 'only-managed',
195
+ UV_PYTHON_INSTALL_DIR: uvPythonInstallDir(),
196
+ UV_PYTHON_CACHE_DIR: uvPythonCacheDir(),
197
+ UV_PYTHON_BIN_DIR: uvPythonBinDir(),
198
+ UV_PYTHON_NO_REGISTRY: 'true',
199
+ UV_PYTHON_INSTALL_REGISTRY: 'false',
200
+ UV_NO_PROGRESS: '1',
201
+ },
202
+ };
203
+ }
155
204
  function classifyProxyFailure(output, command) {
156
205
  const lower = output.toLowerCase();
157
206
  if (lower.includes('address already in use') ||
@@ -334,18 +383,32 @@ class HeadroomManager {
334
383
  fs_1.default.mkdirSync(uvToolDir(), { recursive: true });
335
384
  fs_1.default.mkdirSync(uvCacheDir(), { recursive: true });
336
385
  fs_1.default.mkdirSync(uvBinDir(), { recursive: true });
337
- const args = ['tool', 'install', 'headroom-ai[all]', '--force'];
338
- const command = `${uv} ${args.join(' ')}`;
339
- const logs = [`Running ${command}`];
386
+ fs_1.default.mkdirSync(uvPythonInstallDir(), { recursive: true });
387
+ fs_1.default.mkdirSync(uvPythonCacheDir(), { recursive: true });
388
+ fs_1.default.mkdirSync(uvPythonBinDir(), { recursive: true });
389
+ const plan = getHeadroomManagedInstallPlan();
390
+ const pythonCommand = `${uv} ${plan.pythonInstallArgs.join(' ')}`;
391
+ const installCommand = `${uv} ${plan.toolInstallArgs.join(' ')}`;
392
+ const logs = [
393
+ `Ensuring Headroom Python runtime (CPython ${plan.pythonVersion})`,
394
+ `Running ${pythonCommand}`,
395
+ ];
340
396
  this.emit('installing', logs[0]);
341
- const result = await this.runCommand(uv, args, {
342
- UV_TOOL_DIR: uvToolDir(),
343
- UV_TOOL_BIN_DIR: uvBinDir(),
344
- UV_CACHE_DIR: uvCacheDir(),
345
- UV_NO_PROGRESS: '1',
346
- }, logs);
397
+ this.emit('installing', logs[1]);
398
+ const pythonResult = await this.runCommand(uv, plan.pythonInstallArgs, plan.env, logs);
399
+ if (pythonResult.code !== 0) {
400
+ const failure = classifyInstallFailure(logs.join('\n'), pythonCommand);
401
+ this.updatePersisted({ lastIssue: failure });
402
+ this.emit('failed', failure.title);
403
+ return { ok: false, state: this.getState(), issue: failure, logs };
404
+ }
405
+ logs.push(`Installing Headroom with managed CPython ${plan.pythonVersion}`);
406
+ logs.push(`Running ${installCommand}`);
407
+ this.emit('installing', logs[logs.length - 2]);
408
+ this.emit('installing', logs[logs.length - 1]);
409
+ const result = await this.runCommand(uv, plan.toolInstallArgs, plan.env, logs);
347
410
  if (result.code !== 0) {
348
- const failure = classifyInstallFailure(logs.join('\n'), command);
411
+ const failure = classifyInstallFailure(logs.join('\n'), installCommand);
349
412
  this.updatePersisted({ lastIssue: failure });
350
413
  this.emit('failed', failure.title);
351
414
  return { ok: false, state: this.getState(), issue: failure, logs };
@@ -353,7 +416,7 @@ class HeadroomManager {
353
416
  const exe = this.resolveHeadroomInstall(null, false)?.path ?? null;
354
417
  const version = exe ? this.readHeadroomVersion(exe) : null;
355
418
  if (!exe || !version) {
356
- const failure = issue('headroom_not_found_after_install', logs.join('\n'), command);
419
+ const failure = issue('headroom_not_found_after_install', logs.join('\n'), installCommand);
357
420
  this.updatePersisted({ lastIssue: failure });
358
421
  this.emit('failed', failure.title);
359
422
  return { ok: false, state: this.getState(), issue: failure, logs };
@@ -494,6 +557,9 @@ class HeadroomManager {
494
557
  toolDir: uvToolDir(),
495
558
  binDir: uvBinDir(),
496
559
  cacheDir: uvCacheDir(),
560
+ pythonInstallDir: uvPythonInstallDir(),
561
+ pythonBinDir: uvPythonBinDir(),
562
+ pythonCacheDir: uvPythonCacheDir(),
497
563
  proxyTail: this.proxyTail.slice(-4000),
498
564
  installSource: state.installSource,
499
565
  detectedRoutes: state.detectedRoutes,
@@ -62,8 +62,11 @@ function warnShimMissingOnce(name) {
62
62
  _warnedShims.add(name);
63
63
  console.warn(`[terminal-shell-integration] bundled shim not found: ${name} — shell integration disabled for this shell`);
64
64
  }
65
+ function specrailsHome() {
66
+ return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
67
+ }
65
68
  function projectsRoot() {
66
- return path_1.default.join(os_1.default.homedir(), '.specrails', 'projects');
69
+ return path_1.default.join(specrailsHome(), '.specrails', 'projects');
67
70
  }
68
71
  function shimDirFor(projectSlug, sessionId) {
69
72
  return path_1.default.join(projectsRoot(), projectSlug, 'terminals', sessionId);