de-shell 0.2.0__py3-none-any.whl

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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. de_shell-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * pythonEnv.test.ts — node:test unit tests for uv resolution.
3
+ *
4
+ * The dev-mode backend command used to be a bare 'uv', resolved by the OS at
5
+ * spawn time — so a uv that is installed but not on the app's PATH (the winget
6
+ * per-user install under an npm-run environment, the recurring case) failed as
7
+ * an opaque `spawn uv ENOENT`. `findUv` resolves uv to an absolute path up
8
+ * front: the PATH itself first, then the standard per-user install locations.
9
+ *
10
+ * Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via
11
+ * the `test:unit` npm script.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { tmpdir } from 'os'
18
+ import { configureShell } from './config.ts'
19
+ import { findUv, resolvePythonEnv } from './pythonEnv.ts'
20
+
21
+ configureShell({
22
+ appId: 'testapp',
23
+ appName: 'Test App',
24
+ pythonModule: 'testapp',
25
+ })
26
+
27
+ /** A directory containing a uv stub under both spellings, so these tests do not
28
+ * fork on the host platform. */
29
+ function dirWithUv(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), 'uv-stub-'))
31
+ writeFileSync(join(dir, 'uv'), '')
32
+ writeFileSync(join(dir, 'uv.exe'), '')
33
+ return dir
34
+ }
35
+
36
+ function emptyDir(): string {
37
+ return mkdtempSync(join(tmpdir(), 'uv-none-'))
38
+ }
39
+
40
+ /** An env whose PATH and every fallback root point somewhere we control. */
41
+ function envWith(overrides: Record<string, string>): Record<string, string> {
42
+ const nowhere = emptyDir()
43
+ return {
44
+ PATH: '',
45
+ HOME: nowhere,
46
+ USERPROFILE: nowhere,
47
+ LOCALAPPDATA: nowhere,
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ test('findUv resolves uv from a PATH entry', () => {
53
+ const dir = dirWithUv()
54
+ const found = findUv(envWith({ PATH: dir }))
55
+ assert.ok(found, 'uv not found on PATH')
56
+ assert.ok(found.startsWith(dir), `${found} is not under ${dir}`)
57
+ })
58
+
59
+ test('findUv falls back to the standard per-user install dirs off PATH', () => {
60
+ const home = emptyDir()
61
+ mkdirSync(join(home, '.local', 'bin'), { recursive: true })
62
+ writeFileSync(join(home, '.local', 'bin', 'uv'), '')
63
+ writeFileSync(join(home, '.local', 'bin', 'uv.exe'), '')
64
+ const found = findUv(envWith({ HOME: home, USERPROFILE: home }))
65
+ assert.ok(found, 'uv not found in ~/.local/bin')
66
+ assert.ok(found.startsWith(join(home, '.local', 'bin')))
67
+ })
68
+
69
+ test('findUv finds a winget-linked uv via LOCALAPPDATA', () => {
70
+ const localAppData = emptyDir()
71
+ const links = join(localAppData, 'Microsoft', 'WinGet', 'Links')
72
+ mkdirSync(links, { recursive: true })
73
+ writeFileSync(join(links, 'uv'), '')
74
+ writeFileSync(join(links, 'uv.exe'), '')
75
+ const found = findUv(envWith({ LOCALAPPDATA: localAppData }))
76
+ assert.ok(found, 'uv not found in the winget Links dir')
77
+ assert.ok(found.startsWith(links))
78
+ })
79
+
80
+ test('findUv returns null when uv is nowhere', () => {
81
+ assert.equal(findUv(envWith({})), null)
82
+ })
83
+
84
+ test('dev-mode resolvePythonEnv spawns the resolved absolute uv', async () => {
85
+ const dir = dirWithUv()
86
+ const saved = { PATH: process.env.PATH, HOME: process.env.HOME,
87
+ USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }
88
+ try {
89
+ Object.assign(process.env, envWith({ PATH: dir }))
90
+ const resolved = await resolvePythonEnv({
91
+ isPackaged: false,
92
+ resourcesPath: emptyDir(),
93
+ projectRoot: emptyDir(),
94
+ userData: emptyDir(),
95
+ })
96
+ assert.ok(resolved.cmd[0].startsWith(dir),
97
+ `expected an absolute uv under ${dir}, got ${resolved.cmd[0]}`)
98
+ assert.deepEqual(resolved.cmd.slice(1), ['run', 'python', '-m', 'testapp'])
99
+ } finally {
100
+ for (const [k, v] of Object.entries(saved)) {
101
+ if (v === undefined) delete process.env[k]
102
+ else process.env[k] = v
103
+ }
104
+ }
105
+ })
106
+
107
+ test('dev-mode resolvePythonEnv leaves a bare uv when none is found (the spawn trap reports it)', async () => {
108
+ const saved = { PATH: process.env.PATH, HOME: process.env.HOME,
109
+ USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }
110
+ try {
111
+ Object.assign(process.env, envWith({}))
112
+ const resolved = await resolvePythonEnv({
113
+ isPackaged: false,
114
+ resourcesPath: emptyDir(),
115
+ projectRoot: emptyDir(),
116
+ userData: emptyDir(),
117
+ })
118
+ assert.equal(resolved.cmd[0], 'uv')
119
+ } finally {
120
+ for (const [k, v] of Object.entries(saved)) {
121
+ if (v === undefined) delete process.env[k]
122
+ else process.env[k] = v
123
+ }
124
+ }
125
+ })
@@ -0,0 +1,442 @@
1
+ /**
2
+ * pythonEnv.ts — resolve (and on first run, create) the Python sidecar env.
3
+ *
4
+ * The uv-managed distribution model: the installer ships a tiny
5
+ * payload — the bundled `uv`, the project (`pyproject.toml` + `uv.lock`) and the
6
+ * app's Python source — under <resources>/python. On first launch we run
7
+ * `uv sync` into a venv in the user's WRITABLE data dir (the app bundle is
8
+ * read-only / code-signed), so the GPU-correct torch wheel is fetched per
9
+ * machine and updates are a cheap incremental `uv sync`.
10
+ *
11
+ * torch is resolved PER MACHINE (win32/linux): the lock pins torch to the cu124
12
+ * index (the dev box's backend), which would force the cu124 wheel onto every
13
+ * user. Instead the first-run install is two-step:
14
+ * 1. `uv sync --frozen --no-dev --no-install-package torch` (lock-exact for
15
+ * everything EXCEPT torch)
16
+ * 2. `uv pip install torch==<locked release> --torch-backend=auto
17
+ * --python <env python>` (uv probes the machine's driver and picks the
18
+ * matching CUDA / CPU wheel — verified: `--python` is required, uv pip
19
+ * IGNORES UV_PROJECT_ENVIRONMENT; needs uv >= 0.5, the staged uv is 0.10.x)
20
+ * Any step-2 failure falls back to the plain full `uv sync` (the old cu124
21
+ * path), so the working install path can never regress. macOS keeps the plain
22
+ * sync (no CUDA pin there; PyPI torch already carries MPS).
23
+ *
24
+ * In development (no bundled payload) we fall back to `uv run` from the repo
25
+ * root — exactly the previous behaviour.
26
+ */
27
+ import { spawn } from 'child_process'
28
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
29
+ import { createHash } from 'crypto'
30
+ import { join } from 'path'
31
+ // Extension spelled out so node:test can load this module without a bundler
32
+ // (native type-stripping resolves relative imports literally).
33
+ import { shellConfig } from './config.ts'
34
+
35
+ export interface ResolvedPython {
36
+ cmd: string[] // argv to spawn, e.g. [pythonExe, '-m', 'spyde'] (module from ShellConfig)
37
+ cwd: string // working directory for the spawn
38
+ }
39
+
40
+ export interface EnsureOptions {
41
+ isPackaged: boolean
42
+ resourcesPath: string // process.resourcesPath (packaged) — holds <…>/python
43
+ projectRoot: string // repo root (dev) — holds pyproject.toml
44
+ userData: string // app.getPath('userData') — writable venv lives here
45
+ onProgress?: (line: string) => void
46
+ }
47
+
48
+ const isWin = process.platform === 'win32'
49
+
50
+ export function venvPython(envDir: string): string {
51
+ return isWin ? join(envDir, 'Scripts', 'python.exe') : join(envDir, 'bin', 'python')
52
+ }
53
+
54
+ function uvBinaryName(): string {
55
+ return isWin ? 'uv.exe' : 'uv'
56
+ }
57
+
58
+ /**
59
+ * Resolve uv to an ABSOLUTE path: each PATH entry first, then the standard
60
+ * per-user install locations that a spawned app's PATH routinely misses —
61
+ * winget's Links dir (win32), `~/.local/bin` (uv's own installer) and
62
+ * `~/.cargo/bin` (cargo installs). Returns null when uv is nowhere; the caller
63
+ * keeps the bare 'uv' so the spawn-error trap reports the absence readably
64
+ * instead of this module deciding startup policy.
65
+ *
66
+ * `env` is injectable for tests; both binary spellings are tried on every
67
+ * platform so the resolution logic itself stays platform-agnostic.
68
+ */
69
+ export function findUv(env: NodeJS.ProcessEnv = process.env): string | null {
70
+ const names = ['uv.exe', 'uv']
71
+ const pathVar = env.PATH ?? env.Path ?? ''
72
+ const home = env.HOME ?? env.USERPROFILE ?? ''
73
+ const candidates = [
74
+ ...pathVar.split(isWin ? ';' : ':').filter(Boolean),
75
+ ...(env.LOCALAPPDATA ? [join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')] : []),
76
+ ...(home ? [join(home, '.local', 'bin'), join(home, '.cargo', 'bin')] : []),
77
+ ]
78
+ for (const dir of candidates) {
79
+ for (const name of names) {
80
+ const p = join(dir, name)
81
+ if (existsSync(p)) return p
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** Paths of the bundled project + managed env, and whether each exists — the
88
+ * single "is there a managed environment?" answer, shared by the first-run
89
+ * setup and the GPU-triage Fix handler (index.ts). In dev there is no bundled
90
+ * payload, so `bundled` is false and the triage UI goes report-only. */
91
+ export function managedEnvPaths(resourcesPath: string, userData: string): {
92
+ projectDir: string
93
+ envDir: string
94
+ pythonExe: string
95
+ bundled: boolean
96
+ envExists: boolean
97
+ } {
98
+ const projectDir = join(resourcesPath, 'python')
99
+ const envDir = join(userData, 'python-env')
100
+ const pythonExe = venvPython(envDir)
101
+ return {
102
+ projectDir,
103
+ envDir,
104
+ pythonExe,
105
+ bundled: existsSync(join(projectDir, 'uv.lock')),
106
+ envExists: existsSync(pythonExe),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The locked torch release for THIS platform, parsed from `<projectDir>/uv.lock`.
112
+ *
113
+ * The lock carries TWO torch entries (platform-conditional sources): the
114
+ * `download.pytorch.org/whl/cu124` one (win32 per pyproject's source marker,
115
+ * e.g. "2.6.0+cu124") and the PyPI one (everything else, e.g. "2.13.0"). Pick
116
+ * the entry whose source matches this platform and strip any `+cuXXX` local
117
+ * tag — `--torch-backend=auto` owns the backend variant; we only pin the
118
+ * release so the resolved wheel stays lock-adjacent. torch is the ONLY
119
+ * torch-family package in the lock (no torchvision/torchaudio), so one
120
+ * `--no-install-package torch` + one pip install covers the family. Returns
121
+ * null when the lock is missing/unparseable (caller uses the plain full sync).
122
+ */
123
+ export function readLockedTorchVersion(projectDir: string): string | null {
124
+ const lock = readSafe(join(projectDir, 'uv.lock'))
125
+ if (!lock) return null
126
+ // Each entry: [[package]] \n name = "torch" \n version = "…" \n source = { registry = "…" }
127
+ const re = /\[\[package\]\]\s*\r?\nname = "torch"\s*\r?\nversion = "([^"]+)"\s*\r?\nsource = \{ registry = "([^"]+)" \}/g
128
+ const entries: Array<{ version: string; registry: string }> = []
129
+ for (let m = re.exec(lock); m; m = re.exec(lock)) {
130
+ entries.push({ version: m[1], registry: m[2] })
131
+ }
132
+ if (!entries.length) return null
133
+ const preferPytorchIndex = isWin // pyproject pins the cu124 index for win32 only
134
+ const pick =
135
+ entries.find((e) => preferPytorchIndex === e.registry.includes('download.pytorch.org')) ??
136
+ entries[0]
137
+ return pick.version.split('+')[0] // "2.6.0+cu124" → "2.6.0"
138
+ }
139
+
140
+ /** Resolve the command to launch the app's backend, creating the venv if needed. */
141
+ export async function resolvePythonEnv(opts: EnsureOptions): Promise<ResolvedPython> {
142
+ const cfg = shellConfig()
143
+ const bundledProject = join(opts.resourcesPath, 'python')
144
+ const bundledLock = join(bundledProject, 'uv.lock')
145
+
146
+ // Development (or any build without the staged payload): resolve uv up front
147
+ // — PATH, then the standard per-user install dirs — so a uv that is installed
148
+ // but off this process's PATH still works. When it is truly nowhere, keep the
149
+ // bare 'uv': the spawn-error trap turns that into a readable report.
150
+ if (!opts.isPackaged || !existsSync(bundledLock)) {
151
+ const uv = findUv() ?? 'uv'
152
+ return { cmd: [uv, 'run', 'python', '-m', cfg.pythonModule], cwd: opts.projectRoot }
153
+ }
154
+
155
+ const envDir = join(opts.userData, 'python-env')
156
+ const pythonExe = venvPython(envDir)
157
+ const stampFile = join(envDir, `.${cfg.appId}-lock-hash`)
158
+ const lockHash = createHash('sha256').update(readFileSync(bundledLock)).digest('hex')
159
+
160
+ // Skip the sync if the venv already matches the shipped lock.
161
+ const upToDate =
162
+ existsSync(pythonExe) &&
163
+ existsSync(stampFile) &&
164
+ readSafe(stampFile) === lockHash
165
+
166
+ if (!upToDate) {
167
+ await setupEnv(bundledProject, envDir, readAppVersion(bundledProject), opts.onProgress)
168
+ try {
169
+ mkdirSync(envDir, { recursive: true })
170
+ writeFileSync(stampFile, lockHash, 'utf8')
171
+ } catch { /* a missing stamp just forces a re-sync next launch */ }
172
+ }
173
+
174
+ return { cmd: [pythonExe, '-m', cfg.pythonModule], cwd: bundledProject }
175
+ }
176
+
177
+ function readSafe(p: string): string {
178
+ try { return readFileSync(p, 'utf8').trim() } catch { return '' }
179
+ }
180
+
181
+ /**
182
+ * The version bundle-python.mjs baked into `<projectDir>/.<appId>-version`
183
+ * (X.Y.Z, resolved at CI bundle time when git history exists). Empty string in
184
+ * dev (no marker) — the dev repo has `.git`, so setuptools_scm works normally.
185
+ */
186
+ function readAppVersion(projectDir: string): string {
187
+ return readSafe(join(projectDir, `.${shellConfig().appId}-version`))
188
+ }
189
+
190
+ /**
191
+ * Build the env for a uv invocation against the managed env.
192
+ *
193
+ * `appVersion` (from the .<appId>-version marker) is exported as
194
+ * SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<DIST> so building the editable app package
195
+ * succeeds even though the staged tree has NO `.git` — without it
196
+ * setuptools_scm raises `LookupError: unable to detect version` and `uv sync`
197
+ * exits 1 (the first-launch crash). Absent in dev (marker not present) → the
198
+ * env is unchanged and setuptools_scm resolves from the repo's git history.
199
+ */
200
+ function uvEnv(envDir: string, appVersion: string, projectDir?: string): NodeJS.ProcessEnv {
201
+ // Pretend-version env for setuptools_scm. The dist-name–scoped var is the
202
+ // precise one; the plain SETUPTOOLS_SCM_PRETEND_VERSION is a harmless
203
+ // belt-and-braces fallback in case the dist name ever changes.
204
+ //
205
+ // setuptools_scm normalises the dist name to the env-var suffix by
206
+ // uppercasing and replacing runs of non-alphanumerics with `_`
207
+ // ("de-shell" → "DE_SHELL", "spyde" → "SPYDE").
208
+ const scmSuffix = String(shellConfig().pythonDist)
209
+ .toUpperCase().replace(/[^A-Z0-9]+/g, '_')
210
+ const scmEnv: Record<string, string> = appVersion
211
+ ? {
212
+ [`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${scmSuffix}`]: appVersion,
213
+ SETUPTOOLS_SCM_PRETEND_VERSION: appVersion,
214
+ }
215
+ : {}
216
+ // Put the vendored portable git (bundle-python.mjs staged it at
217
+ // <projectDir>/git) on PATH so `uv sync` can resolve `git+https://…` deps
218
+ // (the hyperspy fork) on machines with no system git. MinGit's launcher is in
219
+ // git/cmd on Windows; git/bin holds it on posix. Prepend so ours wins; fall
220
+ // through to any system git if the vendored one is absent (dev builds).
221
+ const gitDir = projectDir ? join(projectDir, 'git') : ''
222
+ const gitBins = gitDir
223
+ ? [join(gitDir, 'cmd'), join(gitDir, 'bin')].filter((d) => existsSync(d))
224
+ : []
225
+ const pathKey = isWin ? 'Path' : 'PATH'
226
+ const basePath = process.env[pathKey] ?? process.env.PATH ?? ''
227
+ const mergedPath = gitBins.length
228
+ ? [...gitBins, basePath].join(isWin ? ';' : ':')
229
+ : basePath
230
+
231
+ return {
232
+ ...process.env,
233
+ ...scmEnv,
234
+ [pathKey]: mergedPath,
235
+ // UV_PROJECT_ENVIRONMENT redirects `uv sync`'s venv out of the read-only
236
+ // resources into the writable user dir. (Ignored by `uv pip install`,
237
+ // which targets via --python — verified; harmless to set for both.)
238
+ UV_PROJECT_ENVIRONMENT: envDir,
239
+ // Keep uv's own cache/tools next to the env so an air-gapped re-run is
240
+ // self-contained and we never write into the read-only bundle.
241
+ UV_CACHE_DIR: join(envDir, '..', 'uv-cache'),
242
+ }
243
+ }
244
+
245
+ /** Spawn the bundled (or PATH) uv with `args`, streaming output to onProgress. */
246
+ function runUv(
247
+ projectDir: string,
248
+ envDir: string,
249
+ args: string[],
250
+ appVersion: string,
251
+ onProgress?: (line: string) => void,
252
+ ): Promise<void> {
253
+ const uv = join(projectDir, uvBinaryName())
254
+ // Fall back to a resolved PATH/per-user uv if not bundled (dev builds).
255
+ const uvCmd = existsSync(uv) ? uv : (findUv() ?? 'uv')
256
+ return new Promise((resolve, reject) => {
257
+ const proc = spawn(uvCmd, args, {
258
+ cwd: projectDir,
259
+ env: uvEnv(envDir, appVersion, projectDir),
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ })
262
+ const relay = (b: Buffer) => onProgress?.(b.toString())
263
+ proc.stdout?.on('data', relay)
264
+ proc.stderr?.on('data', relay) // uv prints progress to stderr
265
+ proc.on('error', reject)
266
+ proc.on('close', (code) =>
267
+ code === 0 ? resolve() : reject(new Error(`uv ${args[0]} exited with code ${code}`)),
268
+ )
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Install the locked torch release into the managed env with the backend
274
+ * resolved for THIS machine (`--torch-backend=auto`: uv probes the NVIDIA
275
+ * driver and picks the matching cuXXX wheel, or the much smaller CPU wheel when
276
+ * no GPU is present). Shared by first-run setup (step 2) and the GPU-triage
277
+ * "Fix PyTorch install" handler. Throws on failure (caller decides fallback).
278
+ */
279
+ export function installTorchPerMachine(
280
+ projectDir: string,
281
+ envDir: string,
282
+ onProgress?: (line: string) => void,
283
+ ): Promise<void> {
284
+ const torchVersion = readLockedTorchVersion(projectDir)
285
+ if (!torchVersion) {
286
+ return Promise.reject(new Error('could not read the locked torch version from uv.lock'))
287
+ }
288
+ onProgress?.(`[env-setup] installing torch==${torchVersion} with --torch-backend=auto\n`)
289
+ return runUv(
290
+ projectDir, envDir,
291
+ ['pip', 'install', `torch==${torchVersion}`, '--torch-backend=auto',
292
+ '--python', venvPython(envDir)],
293
+ '', // no scm pretend-version needed for a plain pip install
294
+ onProgress,
295
+ )
296
+ }
297
+
298
+ /** The pre-built app wheel staged by bundle-python.mjs at <projectDir>/wheels/
299
+ * (a single py3-none-any .whl). Null in dev (no staged wheel) → the caller
300
+ * installs the project from source the old way (dev tree is writable).
301
+ *
302
+ * Matched by the DIST name with its separators normalised: a wheel filename
303
+ * uses `_` where the dist name may use `-` ("de-shell" → "de_shell-0.1.0-…"). */
304
+ function stagedAppWheel(projectDir: string): string | null {
305
+ const dir = join(projectDir, 'wheels')
306
+ if (!existsSync(dir)) return null
307
+ const prefix = String(shellConfig().pythonDist).replace(/-/g, '_').toLowerCase()
308
+ try {
309
+ const whl = readdirSync(dir).find(
310
+ (f) => f.toLowerCase().replace(/-/g, '_').startsWith(prefix) && f.endsWith('.whl'),
311
+ )
312
+ return whl ? join(dir, whl) : null
313
+ } catch { return null }
314
+ }
315
+
316
+ /**
317
+ * Every wheel staged beside the app's own — the workspace MEMBERS.
318
+ *
319
+ * The app is one package in a uv workspace, and its siblings are path
320
+ * dependencies. They cannot be built on the user's machine for the same reason
321
+ * the app cannot (setuptools' egg_info writes into a read-only tree), so
322
+ * `bundle-python.mjs` builds them all with `uv build --all-packages` and they
323
+ * are installed from wheels here.
324
+ */
325
+ function stagedMemberWheels(projectDir: string, appWheel: string | null): string[] {
326
+ const dir = join(projectDir, 'wheels')
327
+ if (!existsSync(dir)) return []
328
+ try {
329
+ return readdirSync(dir)
330
+ .filter((f) => f.endsWith('.whl'))
331
+ .map((f) => join(dir, f))
332
+ .filter((f) => f !== appWheel)
333
+ } catch { return []; }
334
+ }
335
+
336
+ /**
337
+ * The distribution name a wheel file installs, from its filename.
338
+ *
339
+ * Wheel names are `{name}-{version}-…`, and the name has `-` normalised to `_`.
340
+ * uv accepts either spelling for `--no-install-package`.
341
+ */
342
+ function wheelPackageName(wheel: string): string {
343
+ return (wheel.split(/[\\/]/).pop() || '').split('-')[0]
344
+ }
345
+
346
+ /**
347
+ * Install the PRE-BUILT app wheel into the managed env with no dependency
348
+ * resolution (`--no-deps` — the sync already installed every dependency). This
349
+ * is the whole reason the wheel exists: it avoids building the app from the
350
+ * read-only shipped source tree (setuptools' egg_info write → "Access is
351
+ * denied", the rc.2/rc.3 first-launch crash). Throws on failure.
352
+ */
353
+ function installAppWheel(
354
+ projectDir: string,
355
+ envDir: string,
356
+ wheel: string,
357
+ onProgress?: (line: string) => void,
358
+ ): Promise<void> {
359
+ onProgress?.(
360
+ `[env-setup] installing ${shellConfig().pythonDist} from wheel ` +
361
+ `${wheel.split(/[\\/]/).pop()}\n`,
362
+ )
363
+ return runUv(
364
+ projectDir, envDir,
365
+ ['pip', 'install', '--no-deps', '--python', venvPython(envDir), wheel],
366
+ '', onProgress,
367
+ )
368
+ }
369
+
370
+ /**
371
+ * Create/refresh the managed env. On win32/linux (the platforms where the lock
372
+ * pins CUDA torch) this is the two-step per-machine install; on failure — or on
373
+ * macOS, or when the lock has no readable torch pin — the plain full
374
+ * `uv sync --frozen --no-dev` (the original, known-good path) runs instead.
375
+ *
376
+ * When a pre-built app wheel is staged (the packaged app), the syncs use
377
+ * `--no-install-project` (resolve + install DEPS only, never build the app) and
378
+ * the app is installed from the wheel afterwards — so NOTHING is built from the
379
+ * read-only source tree. In dev (no staged wheel) the project installs from
380
+ * source as before. Logs which path ran to the progress stream.
381
+ */
382
+ async function setupEnv(
383
+ projectDir: string,
384
+ envDir: string,
385
+ appVersion: string,
386
+ onProgress?: (line: string) => void,
387
+ ): Promise<void> {
388
+ const wheel = stagedAppWheel(projectDir)
389
+ const memberWheels = stagedMemberWheels(projectDir, wheel)
390
+ // With a staged wheel, tell uv sync NOT to touch the project (the app builds
391
+ // from the read-only tree otherwise); we install the wheel separately.
392
+ //
393
+ // `--no-install-project` covers the ROOT project only, so every workspace
394
+ // MEMBER needs excluding by name as well. Without that, uv tries to build
395
+ // them from the payload — which carries their pyproject.toml for workspace
396
+ // discovery and nothing else — and first launch dies before installing
397
+ // anything ("Distribution not found at: file:///…/packages/de-shell").
398
+ const projectArgs = wheel ? ['--no-install-project'] : ['--no-editable']
399
+ for (const memberWheel of memberWheels) {
400
+ projectArgs.push('--no-install-package', wheelPackageName(memberWheel))
401
+ }
402
+
403
+ const twoStep =
404
+ (process.platform === 'win32' || process.platform === 'linux') &&
405
+ readLockedTorchVersion(projectDir) !== null
406
+
407
+ if (twoStep) {
408
+ try {
409
+ onProgress?.('[env-setup] two-step install: uv sync (lock-exact, torch deferred) '
410
+ + 'then torch via --torch-backend=auto\n')
411
+ // Step 1: every DEPENDENCY except torch, exactly as locked. torch is the
412
+ // only torch-family package in the lock, so one exclusion covers it.
413
+ await runUv(
414
+ projectDir, envDir,
415
+ ['sync', '--frozen', '--no-dev', ...projectArgs, '--no-install-package', 'torch'],
416
+ appVersion, onProgress,
417
+ )
418
+ // Step 2: torch resolved for this machine.
419
+ await installTorchPerMachine(projectDir, envDir, onProgress)
420
+ // Step 3: the workspace members, then the app — all from pre-built
421
+ // wheels (packaged only). Members first: the app wheel depends on them
422
+ // and `--no-deps` means nothing else will pull them in.
423
+ for (const memberWheel of memberWheels) {
424
+ await installAppWheel(projectDir, envDir, memberWheel, onProgress)
425
+ }
426
+ if (wheel) await installAppWheel(projectDir, envDir, wheel, onProgress)
427
+ onProgress?.('[env-setup] per-machine torch install complete\n')
428
+ return
429
+ } catch (err) {
430
+ onProgress?.(`[env-setup] per-machine torch install failed (${(err as Error)?.message ?? err}); `
431
+ + 'falling back to the full locked sync\n')
432
+ // fall through to the plain sync
433
+ }
434
+ }
435
+
436
+ onProgress?.('[env-setup] running full locked uv sync\n')
437
+ await runUv(projectDir, envDir, ['sync', '--frozen', '--no-dev', ...projectArgs], appVersion, onProgress)
438
+ for (const memberWheel of memberWheels) {
439
+ await installAppWheel(projectDir, envDir, memberWheel, onProgress)
440
+ }
441
+ if (wheel) await installAppWheel(projectDir, envDir, wheel, onProgress)
442
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * sentryEnvelope.test.ts — node:test unit tests for the problem-report wire
3
+ * format.
4
+ *
5
+ * This file is hand-written against a protocol, so the tests stand in for the
6
+ * SDK that would otherwise be guaranteeing it. They pin the two things a
7
+ * malformed report would fail on silently — the envelope's byte-length framing
8
+ * and the auth header — and the DSN parse, whose whole job is to fail softly so
9
+ * a build with no DSN configured still writes reports to disk.
10
+ *
11
+ * Run: `node --test src/sentryEnvelope.test.ts`, or via the `test:unit` script.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import {
16
+ buildEnvelope, formatEventId, parseSentryDsn, sentryAuthHeader,
17
+ } from './sentryEnvelope.ts'
18
+
19
+ test('parses a modern DSN into endpoint, key and project', () => {
20
+ const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/1234567')
21
+ assert.equal(target?.endpoint, 'https://o44.ingest.sentry.io/api/1234567/envelope/')
22
+ assert.equal(target?.publicKey, 'abc123')
23
+ assert.equal(target?.dsn, 'https://abc123@o44.ingest.sentry.io/1234567')
24
+ })
25
+
26
+ test('drops the secret from a legacy key:secret DSN', () => {
27
+ const target = parseSentryDsn('https://pub:secret@o44.ingest.sentry.io/9')
28
+ assert.equal(target?.publicKey, 'pub')
29
+ assert.ok(!target?.dsn.includes('secret'), 'the secret must not be echoed back')
30
+ })
31
+
32
+ test('a self-hosted DSN keeps its port and host', () => {
33
+ const target = parseSentryDsn('https://key@sentry.example.org:8443/42')
34
+ assert.equal(target?.endpoint, 'https://sentry.example.org:8443/api/42/envelope/')
35
+ })
36
+
37
+ test('an absent or unusable DSN yields null rather than throwing', () => {
38
+ // Each of these must leave the app in the "write to disk only" mode.
39
+ for (const dsn of [
40
+ undefined, null, '', ' ',
41
+ 'not-a-url',
42
+ 'https://o44.ingest.sentry.io/1234567', // no public key
43
+ 'https://abc123@o44.ingest.sentry.io/', // no project id
44
+ 'https://abc123@o44.ingest.sentry.io/notanumber',
45
+ 'ftp://abc123@o44.ingest.sentry.io/1', // not HTTP
46
+ ]) {
47
+ assert.equal(parseSentryDsn(dsn as string | undefined), null, `should reject: ${String(dsn)}`)
48
+ }
49
+ })
50
+
51
+ test('auth header carries version, client and key', () => {
52
+ const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/1')!
53
+ const header = sentryAuthHeader(target, 'spyde-shell/1.0')
54
+ assert.match(header, /^Sentry sentry_version=7/)
55
+ assert.match(header, /sentry_client=spyde-shell\/1\.0/)
56
+ assert.match(header, /sentry_key=abc123/)
57
+ })
58
+
59
+ test('event ids are 32 lowercase hex characters', () => {
60
+ const id = formatEventId(Uint8Array.from({ length: 16 }, (_, i) => i))
61
+ assert.match(id, /^[0-9a-f]{32}$/)
62
+ assert.equal(id, '000102030405060708090a0b0c0d0e0f')
63
+ })
64
+
65
+ test('a short byte array is padded rather than producing a short id', () => {
66
+ assert.match(formatEventId(new Uint8Array([1, 2])), /^[0-9a-f]{32}$/)
67
+ })
68
+
69
+ test('envelope framing is three newline-delimited JSON lines', () => {
70
+ const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/7')!
71
+ const event = { event_id: 'a'.repeat(32), message: 'boom' }
72
+ const lines = buildEnvelope(target, event, '2026-08-28T00:00:00.000Z').split('\n')
73
+
74
+ const envelopeHeader = JSON.parse(lines[0])
75
+ assert.equal(envelopeHeader.event_id, 'a'.repeat(32))
76
+ assert.equal(envelopeHeader.dsn, target.dsn)
77
+
78
+ const itemHeader = JSON.parse(lines[1])
79
+ assert.equal(itemHeader.type, 'event')
80
+
81
+ assert.deepEqual(JSON.parse(lines[2]), event)
82
+ assert.equal(lines[3], '', 'the envelope ends with a newline')
83
+ })
84
+
85
+ test('the item length is BYTES, not characters', () => {
86
+ // A report naming a file with an accent, or a stack trace with a smart quote,
87
+ // is rejected outright if this counts characters — hence the explicit test.
88
+ const target = parseSentryDsn('https://abc123@o44.ingest.sentry.io/7')!
89
+ const event = { event_id: 'b'.repeat(32), message: 'Ångström — µm' }
90
+ const lines = buildEnvelope(target, event, '2026-08-28T00:00:00.000Z').split('\n')
91
+ const declared = JSON.parse(lines[1]).length
92
+ assert.equal(declared, Buffer.byteLength(lines[2], 'utf8'))
93
+ assert.ok(declared > lines[2].length, 'multi-byte content must declare more bytes than characters')
94
+ })