specrails-desktop 2.11.2 → 2.11.4
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/cli/dist/specrails-desktop.js +18 -5
- package/cli/dist/win-spawn.js +40 -0
- package/package.json +1 -1
- package/server/dist/artifact-registry.js +25 -1
- package/server/dist/binary-probe.js +5 -1
- package/server/dist/bundled-core.js +4 -1
- package/server/dist/bundled-openspec.js +4 -1
- package/server/dist/config.js +5 -2
- package/server/dist/desktop-router.js +17 -2
- package/server/dist/file-summary-manager.js +5 -1
- package/server/dist/framework-migration.js +5 -5
- package/server/dist/index.js +4 -0
- package/server/dist/path-resolver.js +44 -4
- package/server/dist/plugins/codex-mcp.js +19 -19
- package/server/dist/plugins/prereq-installer.js +3 -1
- package/server/dist/plugins/serena/verify.js +4 -0
- package/server/dist/setup-manager.js +44 -12
- package/server/dist/terminal-manager.js +11 -2
- package/server/dist/util/win-spawn.js +20 -0
|
@@ -27,12 +27,11 @@ exports.formatTokens = formatTokens;
|
|
|
27
27
|
exports.printSummary = printSummary;
|
|
28
28
|
const http_1 = __importDefault(require("http"));
|
|
29
29
|
const net_1 = __importDefault(require("net"));
|
|
30
|
-
const child_process_1 = require("child_process");
|
|
31
|
-
const child_process_2 = require("child_process");
|
|
32
30
|
const readline_1 = require("readline");
|
|
33
31
|
const ws_1 = __importDefault(require("ws"));
|
|
34
32
|
const path_1 = __importDefault(require("path"));
|
|
35
33
|
const os_1 = __importDefault(require("os"));
|
|
34
|
+
const win_spawn_1 = require("./win-spawn");
|
|
36
35
|
const fs_1 = __importDefault(require("fs"));
|
|
37
36
|
// ---------------------------------------------------------------------------
|
|
38
37
|
// Constants
|
|
@@ -324,7 +323,15 @@ async function resolveProjectFromCwd(baseUrl, projectOverride) {
|
|
|
324
323
|
try {
|
|
325
324
|
// --project flag: resolve by path (absolute/relative) or by name
|
|
326
325
|
if (projectOverride) {
|
|
327
|
-
|
|
326
|
+
// Path vs name: POSIX absolute (`/…`), relative (`./…`), OR a Windows
|
|
327
|
+
// absolute (`C:\…` / `C:/…`) or any path containing a separator. Without
|
|
328
|
+
// the drive-letter/backslash cases `--project C:\repo` was mis-read as a
|
|
329
|
+
// project NAME on Windows and never matched.
|
|
330
|
+
const isPathLike = path_1.default.isAbsolute(projectOverride) ||
|
|
331
|
+
projectOverride.startsWith('.') ||
|
|
332
|
+
/^[A-Za-z]:[\\/]/.test(projectOverride) ||
|
|
333
|
+
projectOverride.includes('/') ||
|
|
334
|
+
projectOverride.includes('\\');
|
|
328
335
|
if (isPathLike) {
|
|
329
336
|
const res = await httpGet(`${baseUrl}/api/resolve?path=${encodeURIComponent(projectOverride)}`);
|
|
330
337
|
if (res.status === 200) {
|
|
@@ -531,7 +538,10 @@ async function runDirect(command) {
|
|
|
531
538
|
];
|
|
532
539
|
let child;
|
|
533
540
|
try {
|
|
534
|
-
|
|
541
|
+
// Windows: `claude` is `claude.cmd`; spawnCli routes through cross-spawn so
|
|
542
|
+
// the shim resolves and multi-line args survive (raw `spawn(shell:false)`
|
|
543
|
+
// threw ENOENT/EINVAL on Windows even with claude installed).
|
|
544
|
+
child = (0, win_spawn_1.spawnCli)('claude', args, {
|
|
535
545
|
env: process.env,
|
|
536
546
|
shell: false,
|
|
537
547
|
});
|
|
@@ -812,7 +822,10 @@ async function desktopStart(port) {
|
|
|
812
822
|
logFd ?? 'ignore',
|
|
813
823
|
logFd ?? 'ignore',
|
|
814
824
|
];
|
|
815
|
-
|
|
825
|
+
// spawnCli so a dev `tsx`(.cmd) launcher resolves on Windows and the env is
|
|
826
|
+
// SystemRoot-backfilled; for the packaged `node` path cross-spawn spawns the
|
|
827
|
+
// .exe directly (no cmd.exe wrapper), keeping `detached` clean.
|
|
828
|
+
const child = (0, win_spawn_1.spawnCli)(args[0], args.slice(1), {
|
|
816
829
|
detached: true,
|
|
817
830
|
stdio,
|
|
818
831
|
env: { ...process.env },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Windows-safe spawn for the CLI bridge. Mirrors server/util/win-spawn.ts but is
|
|
3
|
+
// self-contained because the CLI compiles with its own tsconfig (rootDir: cli).
|
|
4
|
+
//
|
|
5
|
+
// Two Windows problems this solves:
|
|
6
|
+
// 1. `claude`/`tsx` etc. ship as `.cmd` shims. `spawn('claude', …, { shell:false })`
|
|
7
|
+
// fails with ENOENT (no extension expansion) and, since Node 20.12 /
|
|
8
|
+
// CVE-2024-27980, `spawn('claude.cmd', …, { shell:false })` fails with EINVAL.
|
|
9
|
+
// cross-spawn launches cmd.exe with quoted args so `.cmd` resolves and
|
|
10
|
+
// multi-line `--system-prompt`/`-p` values survive intact.
|
|
11
|
+
// 2. A GUI-launched packaged app can inherit a stripped env missing SystemRoot/
|
|
12
|
+
// ComSpec, which cmd.exe needs to start — windowsSpawnEnv backfills them.
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.windowsSpawnEnv = windowsSpawnEnv;
|
|
18
|
+
exports.spawnCli = spawnCli;
|
|
19
|
+
const child_process_1 = require("child_process");
|
|
20
|
+
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
21
|
+
/** Reconstruct the Windows shell-critical env when a stripped sidecar lacks it. */
|
|
22
|
+
function windowsSpawnEnv(base = process.env) {
|
|
23
|
+
if (process.platform !== 'win32')
|
|
24
|
+
return base;
|
|
25
|
+
const env = { ...base };
|
|
26
|
+
const systemRoot = (env.SystemRoot || env.windir || 'C:\\Windows').replace(/[\\/]$/, '');
|
|
27
|
+
env.SystemRoot = env.SystemRoot || systemRoot;
|
|
28
|
+
env.windir = env.windir || systemRoot;
|
|
29
|
+
env.ComSpec = env.ComSpec || `${systemRoot}\\System32\\cmd.exe`;
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
/** Spawn `binary` Windows-safely: cross-spawn on win32 (resolves `.cmd`, quotes
|
|
33
|
+
* args), plain spawn on POSIX. Backfills the Windows base env unless the caller
|
|
34
|
+
* already supplied one. */
|
|
35
|
+
function spawnCli(binary, args, options = {}) {
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return (0, cross_spawn_1.default)(binary, args, { ...options, env: windowsSpawnEnv(options.env) });
|
|
38
|
+
}
|
|
39
|
+
return (0, child_process_1.spawn)(binary, args, options);
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -189,7 +189,31 @@ function atomicWrite(filePath, data) {
|
|
|
189
189
|
finally {
|
|
190
190
|
(0, fs_1.closeSync)(fd);
|
|
191
191
|
}
|
|
192
|
-
|
|
192
|
+
// Windows: renaming over an EXISTING destination held open by a reader (the
|
|
193
|
+
// bundled core reads registry.json WITHOUT the lock by contract), an AV
|
|
194
|
+
// scanner, or a concurrent reader fails transiently with EPERM/EACCES/EBUSY.
|
|
195
|
+
// POSIX rename-over is atomic and never hits this. Bounded retry on Windows
|
|
196
|
+
// only; rethrow the last error so the caller's try/catch still surfaces it.
|
|
197
|
+
let lastErr;
|
|
198
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
199
|
+
try {
|
|
200
|
+
(0, fs_1.renameSync)(tmp, filePath);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
lastErr = err;
|
|
205
|
+
const code = err.code;
|
|
206
|
+
const retryable = process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY');
|
|
207
|
+
if (!retryable)
|
|
208
|
+
throw err;
|
|
209
|
+
syncSleep(20 * (attempt + 1));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
(0, fs_1.unlinkSync)(tmp);
|
|
214
|
+
}
|
|
215
|
+
catch { /* best-effort temp cleanup */ }
|
|
216
|
+
throw lastErr;
|
|
193
217
|
}
|
|
194
218
|
/** Synchronous sleep without busy-spinning the CPU. */
|
|
195
219
|
function syncSleep(ms) {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.binaryOnPath = binaryOnPath;
|
|
4
4
|
exports.__resetBinaryProbeCacheForTest = __resetBinaryProbeCacheForTest;
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
|
+
const win_spawn_1 = require("./util/win-spawn");
|
|
6
7
|
// Windows has no `which`; probe via `where` instead. Both exit non-zero
|
|
7
8
|
// when the command is missing, which the try/catch relies on.
|
|
8
9
|
const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
|
|
@@ -20,7 +21,10 @@ function binaryOnPath(binary) {
|
|
|
20
21
|
return hit.onPath;
|
|
21
22
|
let onPath;
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
+
// `where` runs via cmd.exe; pass a SystemRoot-backfilled env so the probe
|
|
25
|
+
// (a HARD gate before job/chat spawns) can't be falsely cached as missing
|
|
26
|
+
// when a packaged sidecar inherited a stripped env.
|
|
27
|
+
(0, child_process_1.execSync)(`${WHICH_CMD} ${binary}`, { stdio: 'ignore', env: (0, win_spawn_1.windowsSpawnEnv)() });
|
|
24
28
|
onPath = true;
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
@@ -9,6 +9,7 @@ exports.getBundledCoreVersion = getBundledCoreVersion;
|
|
|
9
9
|
exports.hasBundledCore = hasBundledCore;
|
|
10
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const win_spawn_1 = require("./util/win-spawn");
|
|
12
13
|
/**
|
|
13
14
|
* Bundled specrails-core resolution.
|
|
14
15
|
*
|
|
@@ -24,7 +25,9 @@ const path_1 = __importDefault(require("path"));
|
|
|
24
25
|
*/
|
|
25
26
|
/** The bundled core package root (`<resource_dir>/core`) or null when absent. */
|
|
26
27
|
function getBundledCoreRoot() {
|
|
27
|
-
|
|
28
|
+
// Strip any `\\?\` verbatim prefix (Tauri resource_dir) — Node's module loader
|
|
29
|
+
// realpathSync chokes on it when running cli.js (EISDIR lstat 'C:').
|
|
30
|
+
const p = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_CORE_PATH ?? '');
|
|
28
31
|
if (!p || p.length === 0)
|
|
29
32
|
return null;
|
|
30
33
|
// Existence-gate (defence-in-depth — lib.rs already gates, but a stale env
|
|
@@ -7,6 +7,7 @@ exports.getBundledOpenspecCli = getBundledOpenspecCli;
|
|
|
7
7
|
exports.hasBundledOpenspec = hasBundledOpenspec;
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const win_spawn_1 = require("./util/win-spawn");
|
|
10
11
|
/**
|
|
11
12
|
* Bundled `@fission-ai/openspec` resolution.
|
|
12
13
|
*
|
|
@@ -29,7 +30,9 @@ const path_1 = __importDefault(require("path"));
|
|
|
29
30
|
const OPENSPEC_CLI_REL = path_1.default.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js');
|
|
30
31
|
/** The bundled openspec root (`<resource_dir>/openspec`) or null when absent. */
|
|
31
32
|
function getBundledOpenspecRoot() {
|
|
32
|
-
|
|
33
|
+
// Strip any `\\?\` verbatim prefix (Tauri resource_dir) so `node openspec.js`
|
|
34
|
+
// resolves its entry without the realpathSync EISDIR-on-'C:' crash.
|
|
35
|
+
const p = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_OPENSPEC_PATH ?? '');
|
|
33
36
|
if (!p || p.length === 0)
|
|
34
37
|
return null;
|
|
35
38
|
// Existence-gate (defence-in-depth — lib.rs already gates, but a stale env
|
package/server/dist/config.js
CHANGED
|
@@ -9,6 +9,9 @@ exports.fetchIssues = fetchIssues;
|
|
|
9
9
|
const fs_1 = __importDefault(require("fs"));
|
|
10
10
|
const path_1 = __importDefault(require("path"));
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
|
+
// Windows has no `which`; it's `where`. The lone holdout that hardcoded `which`
|
|
13
|
+
// here made gh/jira tracker detection always report unavailable on Windows.
|
|
14
|
+
const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
|
|
12
15
|
function runCommand(cmd, cwd) {
|
|
13
16
|
try {
|
|
14
17
|
return (0, child_process_1.execSync)(cmd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, cwd }).toString().trim();
|
|
@@ -37,7 +40,7 @@ function runCommandArgs(file, args, cwd) {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
function detectGithub() {
|
|
40
|
-
const ghPath = runCommand(
|
|
43
|
+
const ghPath = runCommand(`${WHICH_CMD} gh`);
|
|
41
44
|
if (!ghPath)
|
|
42
45
|
return { available: false, authenticated: false };
|
|
43
46
|
const authOutput = runCommand('gh auth status');
|
|
@@ -45,7 +48,7 @@ function detectGithub() {
|
|
|
45
48
|
return { available: true, authenticated };
|
|
46
49
|
}
|
|
47
50
|
function detectJira() {
|
|
48
|
-
const jiraPath = runCommand(
|
|
51
|
+
const jiraPath = runCommand(`${WHICH_CMD} jira`);
|
|
49
52
|
if (!jiraPath)
|
|
50
53
|
return { available: false, authenticated: false };
|
|
51
54
|
// jira CLI availability means it is configured (auth is implicit via jira config)
|
|
@@ -65,13 +65,28 @@ const THEME_ID_ALLOWLIST = new Set(['dracula', 'aurora-light', 'obsidian-dark',
|
|
|
65
65
|
// Language allow-list. Mirror of LANGUAGE_IDS in `client/src/lib/i18n.ts` —
|
|
66
66
|
// kept duplicated to avoid pulling client code into the server bundle.
|
|
67
67
|
const LANGUAGE_ID_ALLOWLIST = new Set(['en', 'es', 'fr', 'de', 'pt', 'it', 'zh', 'ja']);
|
|
68
|
-
// LOW-04: Deny registration of system-critical directory paths.
|
|
68
|
+
// LOW-04: Deny registration of system-critical directory paths. The POSIX list
|
|
69
|
+
// is matched against forward-slash-normalized, lowercased paths; on Windows it
|
|
70
|
+
// was a complete no-op (no path matches `/etc`), so add a Windows deny-list and
|
|
71
|
+
// fold case (Windows FS is case-insensitive).
|
|
69
72
|
const DENIED_PATH_PREFIXES = [
|
|
70
73
|
'/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
|
|
71
74
|
'/sys', '/proc', '/dev', '/boot', '/run',
|
|
72
75
|
];
|
|
76
|
+
// Windows: deny the Windows dir, Program Files variants, and any bare drive root.
|
|
77
|
+
const DENIED_WINDOWS_PREFIXES = [
|
|
78
|
+
'c:/windows', 'c:/program files', 'c:/program files (x86)', 'c:/programdata',
|
|
79
|
+
];
|
|
73
80
|
function isPathSafe(resolvedPath) {
|
|
74
|
-
const
|
|
81
|
+
const slashed = resolvedPath.replace(/\\/g, '/');
|
|
82
|
+
const normalized = slashed.endsWith('/') ? slashed : slashed + '/';
|
|
83
|
+
if (process.platform === 'win32') {
|
|
84
|
+
const lower = normalized.toLowerCase();
|
|
85
|
+
// Bare drive root (C:/) is never a valid project location.
|
|
86
|
+
if (/^[a-z]:\/$/.test(lower))
|
|
87
|
+
return false;
|
|
88
|
+
return !DENIED_WINDOWS_PREFIXES.some((prefix) => lower.startsWith(prefix + '/') || lower === prefix + '/');
|
|
89
|
+
}
|
|
75
90
|
return !DENIED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix + '/') || normalized === prefix + '/');
|
|
76
91
|
}
|
|
77
92
|
function deriveProjectName(projectPath) {
|
|
@@ -592,7 +592,11 @@ class FileSummaryManager {
|
|
|
592
592
|
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
|
|
593
593
|
});
|
|
594
594
|
watcher.on('change', (changed) => {
|
|
595
|
-
|
|
595
|
+
// The summary store keys off POSIX forward-slash relpaths everywhere
|
|
596
|
+
// (the REST normalizeRel, pathHash, knownSummaries seeding). On Windows
|
|
597
|
+
// path.relative yields backslashes, so normalize before lookup/markStale —
|
|
598
|
+
// otherwise `known.has(rel)` always misses and edits never mark stale.
|
|
599
|
+
const rel = path.relative(projectPath, changed).split(path.sep).join('/');
|
|
596
600
|
if (!rel || rel.startsWith('..'))
|
|
597
601
|
return;
|
|
598
602
|
// Skip the readSummary disk hit when this file provably has no summary.
|
|
@@ -180,7 +180,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
|
|
|
180
180
|
try {
|
|
181
181
|
// Clear a stale backup from a prior aborted run.
|
|
182
182
|
if (fs_1.default.existsSync(bak))
|
|
183
|
-
fs_1.default.rmSync(bak, { recursive: true, force: true });
|
|
183
|
+
fs_1.default.rmSync(bak, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
184
184
|
fs_1.default.renameSync(live, bak);
|
|
185
185
|
backedUp.push({ live, bak });
|
|
186
186
|
}
|
|
@@ -216,7 +216,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
|
|
|
216
216
|
for (const { live } of backedUp) {
|
|
217
217
|
try {
|
|
218
218
|
if (fs_1.default.existsSync(live) || isSymlink(live))
|
|
219
|
-
fs_1.default.rmSync(live, { recursive: true, force: true });
|
|
219
|
+
fs_1.default.rmSync(live, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
220
220
|
}
|
|
221
221
|
catch {
|
|
222
222
|
/* best-effort */
|
|
@@ -242,7 +242,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
|
|
|
242
242
|
const preserved = live + '.custom.preserved';
|
|
243
243
|
try {
|
|
244
244
|
if (fs_1.default.existsSync(preserved))
|
|
245
|
-
fs_1.default.rmSync(preserved, { recursive: true, force: true });
|
|
245
|
+
fs_1.default.rmSync(preserved, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
246
246
|
fs_1.default.renameSync(bak, preserved);
|
|
247
247
|
preservedCustom.push(...customs.map((c) => path_1.default.basename(c)));
|
|
248
248
|
}
|
|
@@ -252,7 +252,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
|
|
|
252
252
|
}
|
|
253
253
|
else {
|
|
254
254
|
try {
|
|
255
|
-
fs_1.default.rmSync(bak, { recursive: true, force: true });
|
|
255
|
+
fs_1.default.rmSync(bak, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
256
256
|
}
|
|
257
257
|
catch {
|
|
258
258
|
/* leaving a stale .bak is harmless; never fail the migration on cleanup */
|
|
@@ -272,7 +272,7 @@ function restoreBackups(backedUp) {
|
|
|
272
272
|
if (!fs_1.default.existsSync(bak))
|
|
273
273
|
continue;
|
|
274
274
|
if (fs_1.default.existsSync(live) || isSymlink(live))
|
|
275
|
-
fs_1.default.rmSync(live, { recursive: true, force: true });
|
|
275
|
+
fs_1.default.rmSync(live, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
276
276
|
fs_1.default.renameSync(bak, live);
|
|
277
277
|
}
|
|
278
278
|
catch {
|
package/server/dist/index.js
CHANGED
|
@@ -34,6 +34,10 @@ const path_resolver_1 = require("./path-resolver");
|
|
|
34
34
|
// openspec/changes/add-multi-provider-support/specs/multi-provider-architecture/spec.md.
|
|
35
35
|
require("./providers");
|
|
36
36
|
const inheritedPathBeforeResolve = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length;
|
|
37
|
+
// Backfill SystemRoot/ComSpec/etc into process.env BEFORE anything spawns, so a
|
|
38
|
+
// stripped GUI-launch sidecar env never breaks cmd.exe-mediated spawns (PTY,
|
|
39
|
+
// where-probes, .cmd shims). No-op on POSIX. Must precede resolveStartupPath().
|
|
40
|
+
(0, path_resolver_1.ensureWindowsBaseEnv)();
|
|
37
41
|
(0, path_resolver_1.resolveStartupPath)();
|
|
38
42
|
const TERMINAL_PANEL_ENABLED = process.env.SPECRAILS_TERMINAL_PANEL !== 'false';
|
|
39
43
|
const BROWSER_CAPTURE_ENABLED = (0, feature_flags_1.isBrowserCaptureEnabled)();
|
|
@@ -3,6 +3,7 @@ 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.ensureWindowsBaseEnv = ensureWindowsBaseEnv;
|
|
6
7
|
exports.resolveBundledRuntimePath = resolveBundledRuntimePath;
|
|
7
8
|
exports.resolveBundledNodeExe = resolveBundledNodeExe;
|
|
8
9
|
exports.resolveStartupPath = resolveStartupPath;
|
|
@@ -14,6 +15,42 @@ const child_process_1 = require("child_process");
|
|
|
14
15
|
const fs_1 = __importDefault(require("fs"));
|
|
15
16
|
const os_1 = __importDefault(require("os"));
|
|
16
17
|
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const win_spawn_1 = require("./util/win-spawn");
|
|
19
|
+
/** Bundled-path env vars set by the Tauri host — normalized at startup. */
|
|
20
|
+
const BUNDLED_PATH_ENV_VARS = [
|
|
21
|
+
'SPECRAILS_BUNDLED_RUNTIMES_PATH',
|
|
22
|
+
'SPECRAILS_BUNDLED_CORE_PATH',
|
|
23
|
+
'SPECRAILS_BUNDLED_OPENSPEC_PATH',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Backfill the Windows shell-critical environment into `process.env` ONCE at
|
|
27
|
+
* startup. The desktop server runs as a pkg sidecar launched by the Tauri host,
|
|
28
|
+
* which can deliver a STRIPPED env missing `SystemRoot`/`windir`/`ComSpec`
|
|
29
|
+
* (and the npm-config family). Without `SystemRoot`, every `cmd.exe`-mediated
|
|
30
|
+
* spawn — PTY/PowerShell, `execSync('where …')`, `.cmd` shims — fails to start.
|
|
31
|
+
* Doing this on `process.env` directly means EVERY downstream consumer that
|
|
32
|
+
* copies `process.env` (terminal-manager, binary-probe, plugin spawns, …) is
|
|
33
|
+
* protected at the source, in addition to the per-callsite `windowsSpawnEnv()`.
|
|
34
|
+
*
|
|
35
|
+
* ALSO strips the Windows verbatim prefix (`\\?\`) from the bundled-path env
|
|
36
|
+
* vars. Tauri's `resource_dir()` returns `\\?\C:\…` paths; Node's module loader
|
|
37
|
+
* `realpathSync` mishandles that prefix when resolving the main entry script,
|
|
38
|
+
* crashing the bundled-core child with `EISDIR: lstat 'C:'`. Normalizing here
|
|
39
|
+
* (before resolveStartupPath + before any spawn) means every reader
|
|
40
|
+
* (getBundledCoreCli, resolveBundledNodeExe, chromium/docs/setup-prerequisites,
|
|
41
|
+
* the PATH prepend) gets a plain `C:\…` path. No-op on POSIX / already-present /
|
|
42
|
+
* unprefixed. Idempotent.
|
|
43
|
+
*/
|
|
44
|
+
function ensureWindowsBaseEnv() {
|
|
45
|
+
if (process.platform !== 'win32')
|
|
46
|
+
return;
|
|
47
|
+
Object.assign(process.env, (0, win_spawn_1.windowsSpawnEnv)(process.env));
|
|
48
|
+
for (const key of BUNDLED_PATH_ENV_VARS) {
|
|
49
|
+
const v = process.env[key];
|
|
50
|
+
if (v)
|
|
51
|
+
process.env[key] = (0, win_spawn_1.stripWindowsVerbatimPrefix)(v);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
17
54
|
const PATH_BEGIN = '__SRH_PATH_BEGIN__';
|
|
18
55
|
const PATH_END = '__SRH_PATH_END__';
|
|
19
56
|
const LOGIN_SHELL_TIMEOUT_MS = 1500;
|
|
@@ -89,11 +126,12 @@ function fastPathDirectories() {
|
|
|
89
126
|
* Throws if the env var is missing.
|
|
90
127
|
*/
|
|
91
128
|
function resolveBundledRuntimePath() {
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
129
|
+
const raw = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
|
|
130
|
+
if (!raw) {
|
|
94
131
|
throw new Error('[path-resolver] resolveBundledRuntimePath() called but SPECRAILS_BUNDLED_RUNTIMES_PATH is not set');
|
|
95
132
|
}
|
|
96
|
-
|
|
133
|
+
// Strip the `\\?\` verbatim prefix (Tauri resource_dir) — see ensureWindowsBaseEnv.
|
|
134
|
+
return (0, win_spawn_1.stripWindowsVerbatimPrefix)(raw);
|
|
97
135
|
}
|
|
98
136
|
/**
|
|
99
137
|
* Absolute path to the bundled REAL Node executable (`runtimes/node/bin/node` on
|
|
@@ -107,7 +145,9 @@ function resolveBundledRuntimePath() {
|
|
|
107
145
|
* Existence-gated so a stale/partial bundle degrades to the PATH `node` instead.
|
|
108
146
|
*/
|
|
109
147
|
function resolveBundledNodeExe() {
|
|
110
|
-
|
|
148
|
+
// Strip the `\\?\` verbatim prefix so the resulting node.exe path doesn't
|
|
149
|
+
// crash Node's module loader when it runs cli.js (EISDIR lstat 'C:').
|
|
150
|
+
const runtimesPath = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '');
|
|
111
151
|
if (!runtimesPath || runtimesPath.length === 0)
|
|
112
152
|
return null;
|
|
113
153
|
const exe = process.platform === 'win32'
|
|
@@ -18,9 +18,25 @@ exports.codexMcpAdd = codexMcpAdd;
|
|
|
18
18
|
exports.codexMcpRemove = codexMcpRemove;
|
|
19
19
|
exports.codexMcpList = codexMcpList;
|
|
20
20
|
const child_process_1 = require("child_process");
|
|
21
|
+
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
21
22
|
const fs_1 = __importDefault(require("fs"));
|
|
22
23
|
const path_1 = __importDefault(require("path"));
|
|
23
24
|
const os_1 = __importDefault(require("os"));
|
|
25
|
+
const win_spawn_1 = require("../util/win-spawn");
|
|
26
|
+
/**
|
|
27
|
+
* Spawn `codex` synchronously, Windows-safe. On Windows the codex CLI is a
|
|
28
|
+
* `.cmd` shim; Node 20.12+ (CVE-2024-27980) refuses to spawn `.cmd` without a
|
|
29
|
+
* shell, and the packaged sidecar may lack `SystemRoot`/`ComSpec` — so route
|
|
30
|
+
* through cross-spawn (resolves the shim, runs cmd.exe with correct quoting)
|
|
31
|
+
* under `windowsSpawnEnv()`. POSIX is a plain `spawnSync`.
|
|
32
|
+
*/
|
|
33
|
+
function codexSpawn(argv, extraEnv) {
|
|
34
|
+
const env = (0, win_spawn_1.windowsSpawnEnv)({ ...process.env, ...extraEnv });
|
|
35
|
+
const opts = { env, encoding: 'utf-8', timeout: 10_000 };
|
|
36
|
+
return process.platform === 'win32'
|
|
37
|
+
? cross_spawn_1.default.sync('codex', argv, opts)
|
|
38
|
+
: (0, child_process_1.spawnSync)('codex', argv, opts);
|
|
39
|
+
}
|
|
24
40
|
/** Per-project CODEX_HOME root, sibling of the existing
|
|
25
41
|
* `~/.specrails/projects/<slug>/{telemetry,jobs,explore-cwd,...}` tree. */
|
|
26
42
|
function codexHomeFor(slug) {
|
|
@@ -39,15 +55,7 @@ function ensureCodexHome(slug) {
|
|
|
39
55
|
function codexMcpAdd(slug, name, entry) {
|
|
40
56
|
const home = ensureCodexHome(slug);
|
|
41
57
|
const argv = ['mcp', 'add', name, '--', entry.command, ...entry.args];
|
|
42
|
-
const result = (
|
|
43
|
-
env: {
|
|
44
|
-
...process.env,
|
|
45
|
-
...(entry.env ?? {}),
|
|
46
|
-
CODEX_HOME: home,
|
|
47
|
-
},
|
|
48
|
-
encoding: 'utf-8',
|
|
49
|
-
timeout: 10_000,
|
|
50
|
-
});
|
|
58
|
+
const result = codexSpawn(argv, { ...(entry.env ?? {}), CODEX_HOME: home });
|
|
51
59
|
if (result.error) {
|
|
52
60
|
return { ok: false, stdout: '', stderr: `${result.error.message}` };
|
|
53
61
|
}
|
|
@@ -60,11 +68,7 @@ function codexMcpAdd(slug, name, entry) {
|
|
|
60
68
|
/** Run `codex mcp remove <name>` against the per-project CODEX_HOME. */
|
|
61
69
|
function codexMcpRemove(slug, name) {
|
|
62
70
|
const home = ensureCodexHome(slug);
|
|
63
|
-
const result = (
|
|
64
|
-
env: { ...process.env, CODEX_HOME: home },
|
|
65
|
-
encoding: 'utf-8',
|
|
66
|
-
timeout: 10_000,
|
|
67
|
-
});
|
|
71
|
+
const result = codexSpawn(['mcp', 'remove', name], { CODEX_HOME: home });
|
|
68
72
|
if (result.error) {
|
|
69
73
|
return { ok: false, stdout: '', stderr: `${result.error.message}` };
|
|
70
74
|
}
|
|
@@ -80,11 +84,7 @@ function codexMcpRemove(slug, name) {
|
|
|
80
84
|
* for this subcommand. */
|
|
81
85
|
function codexMcpList(slug) {
|
|
82
86
|
const home = ensureCodexHome(slug);
|
|
83
|
-
const result = (
|
|
84
|
-
env: { ...process.env, CODEX_HOME: home },
|
|
85
|
-
encoding: 'utf-8',
|
|
86
|
-
timeout: 10_000,
|
|
87
|
-
});
|
|
87
|
+
const result = codexSpawn(['mcp', 'list'], { CODEX_HOME: home });
|
|
88
88
|
if (result.error || (result.status ?? 1) !== 0) {
|
|
89
89
|
return { ok: false, servers: [], raw: `${result.stderr ?? result.stdout ?? ''}` };
|
|
90
90
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.installPrerequisite = installPrerequisite;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const win_spawn_1 = require("../util/win-spawn");
|
|
5
6
|
/**
|
|
6
7
|
* Returns the official installer command for `name` on the current platform.
|
|
7
8
|
* Returns `null` for unsupported (name, platform) pairs — caller should treat
|
|
@@ -60,7 +61,8 @@ async function installPrerequisite(name, projectId, broadcast) {
|
|
|
60
61
|
const child = (0, child_process_1.spawn)(cmd.shell, [], {
|
|
61
62
|
shell: isWin ? 'powershell.exe' : true,
|
|
62
63
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
-
env
|
|
64
|
+
// PowerShell needs SystemRoot/windir to start; backfill for a stripped env.
|
|
65
|
+
env: (0, win_spawn_1.windowsSpawnEnv)(),
|
|
64
66
|
});
|
|
65
67
|
let settled = false;
|
|
66
68
|
const finish = (result) => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.verifySerena = verifySerena;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const win_spawn_1 = require("../../util/win-spawn");
|
|
5
6
|
const TIMEOUT_MS = 1800;
|
|
6
7
|
/**
|
|
7
8
|
* Verify Serena availability. We only probe `uv --version` here — proxy for
|
|
@@ -29,6 +30,9 @@ async function verifySerena() {
|
|
|
29
30
|
child = (0, child_process_1.spawn)('uv', ['--version'], {
|
|
30
31
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
32
|
shell: isWin,
|
|
33
|
+
// SystemRoot/ComSpec so cmd.exe (shell:true) can start under a stripped
|
|
34
|
+
// packaged-sidecar env; else uv is wrongly reported not-on-path.
|
|
35
|
+
env: (0, win_spawn_1.windowsSpawnEnv)(),
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
catch {
|
|
@@ -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
|
});
|
|
@@ -14,6 +14,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
14
14
|
const path_1 = __importDefault(require("path"));
|
|
15
15
|
const node_pty_1 = require("node-pty");
|
|
16
16
|
const ids_1 = require("./ids");
|
|
17
|
+
const win_spawn_1 = require("./util/win-spawn");
|
|
17
18
|
const terminal_osc_parser_1 = require("./terminal-osc-parser");
|
|
18
19
|
const terminal_marks_store_1 = require("./terminal-marks-store");
|
|
19
20
|
const terminal_shell_integration_1 = require("./terminal-shell-integration");
|
|
@@ -69,8 +70,11 @@ function resolveShell() {
|
|
|
69
70
|
* default and has no shell-integration shim.
|
|
70
71
|
*/
|
|
71
72
|
function resolveShellFor(platform, env, exists) {
|
|
73
|
+
// Honor $SHELL only on POSIX. On Windows $SHELL is not native but is commonly
|
|
74
|
+
// exported by Git Bash/MSYS2 as a Unix path (e.g. /usr/bin/bash) that ConPTY
|
|
75
|
+
// cannot spawn — so on win32 ignore it and fall through to pwsh→powershell→cmd.
|
|
72
76
|
const envShell = env.SHELL;
|
|
73
|
-
if (envShell && envShell.trim().length > 0)
|
|
77
|
+
if (platform !== 'win32' && envShell && envShell.trim().length > 0)
|
|
74
78
|
return envShell.trim();
|
|
75
79
|
if (platform === 'win32') {
|
|
76
80
|
for (const dir of (env.PATH ?? '').split(';')) {
|
|
@@ -215,8 +219,13 @@ class TerminalManager {
|
|
|
215
219
|
const baseArgs = shellArgs(shell);
|
|
216
220
|
const cols = clampDim(opts.cols ?? exports.TERMINAL_DEFAULT_COLS, 2, 1000);
|
|
217
221
|
const rows = clampDim(opts.rows ?? exports.TERMINAL_DEFAULT_ROWS, 2, 1000);
|
|
222
|
+
// Build the PTY env from a SystemRoot-backfilled base so PowerShell/cmd can
|
|
223
|
+
// start even when the packaged sidecar inherited a stripped env (no
|
|
224
|
+
// SystemRoot/ComSpec → ConPTY spawn fails and the panel never opens). No-op
|
|
225
|
+
// on POSIX. (process.env is also backfilled globally at startup; this is the
|
|
226
|
+
// explicit guard at the PTY boundary.)
|
|
218
227
|
const env = {};
|
|
219
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
228
|
+
for (const [k, v] of Object.entries((0, win_spawn_1.windowsSpawnEnv)(process.env))) {
|
|
220
229
|
if (typeof v === 'string')
|
|
221
230
|
env[k] = v;
|
|
222
231
|
}
|
|
@@ -26,6 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
exports.spawnCli = spawnCli;
|
|
28
28
|
exports.windowsSpawnEnv = windowsSpawnEnv;
|
|
29
|
+
exports.stripWindowsVerbatimPrefix = stripWindowsVerbatimPrefix;
|
|
29
30
|
exports.resolveWindowsBinary = resolveWindowsBinary;
|
|
30
31
|
const child_process_1 = require("child_process");
|
|
31
32
|
const cross_spawn_1 = __importDefault(require("cross-spawn"));
|
|
@@ -79,6 +80,25 @@ function windowsSpawnEnv(base = process.env) {
|
|
|
79
80
|
env.TMP = env.TMP || temp;
|
|
80
81
|
return env;
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Strip the Windows extended-length / "verbatim" path prefix (`\\?\`) from a
|
|
85
|
+
* path. Tauri's `resource_dir()` returns canonicalized paths like
|
|
86
|
+
* `\\?\C:\Users\…\core`, and Node's MODULE LOADER `realpathSync` (used to
|
|
87
|
+
* resolve the main entry script) does NOT handle the `\\?\` prefix — it parses
|
|
88
|
+
* the root as a bare `C:` and throws `EISDIR: lstat 'C:'`. So a bundled `node`
|
|
89
|
+
* interpreter or `cli.js` entry carrying this prefix crashes the child at
|
|
90
|
+
* startup. Normalizing the bundled path env vars to plain `C:\…` form fixes it.
|
|
91
|
+
* `\\?\UNC\server\share` → `\\server\share`. No-op on POSIX / unprefixed paths.
|
|
92
|
+
*/
|
|
93
|
+
function stripWindowsVerbatimPrefix(p) {
|
|
94
|
+
if (typeof p !== 'string' || p.length === 0)
|
|
95
|
+
return p;
|
|
96
|
+
if (p.startsWith('\\\\?\\UNC\\'))
|
|
97
|
+
return '\\\\' + p.slice(8);
|
|
98
|
+
if (p.startsWith('\\\\?\\'))
|
|
99
|
+
return p.slice(4);
|
|
100
|
+
return p;
|
|
101
|
+
}
|
|
82
102
|
// Back-compat for callsites that only need the resolved binary
|
|
83
103
|
// (e.g. logging). Kept as a no-op identity on POSIX; on Windows
|
|
84
104
|
// `where`-based resolution lives inside cross-spawn now.
|