nebula-notebook 0.1.0

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 (97) hide show
  1. package/README.md +222 -0
  2. package/bin/nebula-notebook.js +33 -0
  3. package/dist/assets/index-C1h_sArD.css +32 -0
  4. package/dist/assets/index-CDSTBon8.js +658 -0
  5. package/dist/favicon.svg +11 -0
  6. package/dist/index.html +73 -0
  7. package/node-server/dist/app.d.ts +5 -0
  8. package/node-server/dist/app.js +38 -0
  9. package/node-server/dist/auth/auth-middleware.d.ts +24 -0
  10. package/node-server/dist/auth/auth-middleware.js +276 -0
  11. package/node-server/dist/auth/auth-service.d.ts +84 -0
  12. package/node-server/dist/auth/auth-service.js +265 -0
  13. package/node-server/dist/auth/index.d.ts +3 -0
  14. package/node-server/dist/auth/index.js +8 -0
  15. package/node-server/dist/cluster/client-registration.d.ts +43 -0
  16. package/node-server/dist/cluster/client-registration.js +217 -0
  17. package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
  18. package/node-server/dist/cluster/cluster-secret.js +90 -0
  19. package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
  20. package/node-server/dist/cluster/kernel-proxy.js +361 -0
  21. package/node-server/dist/cluster/server-registry.d.ts +109 -0
  22. package/node-server/dist/cluster/server-registry.js +217 -0
  23. package/node-server/dist/config/output-limits.d.ts +7 -0
  24. package/node-server/dist/config/output-limits.js +10 -0
  25. package/node-server/dist/discovery/discovery-service.d.ts +198 -0
  26. package/node-server/dist/discovery/discovery-service.js +811 -0
  27. package/node-server/dist/discovery/index.d.ts +5 -0
  28. package/node-server/dist/discovery/index.js +21 -0
  29. package/node-server/dist/discovery/types.d.ts +48 -0
  30. package/node-server/dist/discovery/types.js +24 -0
  31. package/node-server/dist/fs/fs-service.d.ts +218 -0
  32. package/node-server/dist/fs/fs-service.js +1422 -0
  33. package/node-server/dist/fs/index.d.ts +5 -0
  34. package/node-server/dist/fs/index.js +21 -0
  35. package/node-server/dist/fs/types.d.ts +132 -0
  36. package/node-server/dist/fs/types.js +5 -0
  37. package/node-server/dist/index.d.ts +13 -0
  38. package/node-server/dist/index.js +556 -0
  39. package/node-server/dist/kernel/default-kernel.d.ts +5 -0
  40. package/node-server/dist/kernel/default-kernel.js +138 -0
  41. package/node-server/dist/kernel/index.d.ts +7 -0
  42. package/node-server/dist/kernel/index.js +23 -0
  43. package/node-server/dist/kernel/kernel-service.d.ts +290 -0
  44. package/node-server/dist/kernel/kernel-service.js +1714 -0
  45. package/node-server/dist/kernel/kernelspec.d.ts +29 -0
  46. package/node-server/dist/kernel/kernelspec.js +210 -0
  47. package/node-server/dist/kernel/session-store.d.ts +87 -0
  48. package/node-server/dist/kernel/session-store.js +303 -0
  49. package/node-server/dist/kernel/types.d.ts +143 -0
  50. package/node-server/dist/kernel/types.js +17 -0
  51. package/node-server/dist/llm/index.d.ts +5 -0
  52. package/node-server/dist/llm/index.js +21 -0
  53. package/node-server/dist/llm/llm-service.d.ts +77 -0
  54. package/node-server/dist/llm/llm-service.js +454 -0
  55. package/node-server/dist/llm/types.d.ts +40 -0
  56. package/node-server/dist/llm/types.js +15 -0
  57. package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
  58. package/node-server/dist/notebook/cell-metadata.js +76 -0
  59. package/node-server/dist/notebook/headless-handler.d.ts +127 -0
  60. package/node-server/dist/notebook/headless-handler.js +1530 -0
  61. package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
  62. package/node-server/dist/notebook/notebook-websocket.js +103 -0
  63. package/node-server/dist/notebook/operation-router.d.ts +115 -0
  64. package/node-server/dist/notebook/operation-router.js +641 -0
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
  66. package/node-server/dist/notebook/undoRedoManager.js +558 -0
  67. package/node-server/dist/output/display-data.d.ts +14 -0
  68. package/node-server/dist/output/display-data.js +134 -0
  69. package/node-server/dist/resources/resource-service.d.ts +69 -0
  70. package/node-server/dist/resources/resource-service.js +363 -0
  71. package/node-server/dist/routes/auth.d.ts +5 -0
  72. package/node-server/dist/routes/auth.js +61 -0
  73. package/node-server/dist/routes/cluster.d.ts +7 -0
  74. package/node-server/dist/routes/cluster.js +94 -0
  75. package/node-server/dist/routes/fs.d.ts +7 -0
  76. package/node-server/dist/routes/fs.js +392 -0
  77. package/node-server/dist/routes/kernel.d.ts +13 -0
  78. package/node-server/dist/routes/kernel.js +637 -0
  79. package/node-server/dist/routes/llm.d.ts +8 -0
  80. package/node-server/dist/routes/llm.js +105 -0
  81. package/node-server/dist/routes/notebook.d.ts +10 -0
  82. package/node-server/dist/routes/notebook.js +335 -0
  83. package/node-server/dist/routes/python.d.ts +8 -0
  84. package/node-server/dist/routes/python.js +187 -0
  85. package/node-server/dist/routes/resources.d.ts +7 -0
  86. package/node-server/dist/routes/resources.js +77 -0
  87. package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
  88. package/node-server/dist/scripts/show-auth-qr.js +81 -0
  89. package/node-server/dist/terminal/pty-manager.d.ts +100 -0
  90. package/node-server/dist/terminal/pty-manager.js +246 -0
  91. package/node-server/dist/terminal/server.d.ts +19 -0
  92. package/node-server/dist/terminal/server.js +254 -0
  93. package/node-server/dist/terminal/types.d.ts +50 -0
  94. package/node-server/dist/terminal/types.js +9 -0
  95. package/node-server/package.json +45 -0
  96. package/package.json +99 -0
  97. package/scripts/postinstall.cjs +26 -0
@@ -0,0 +1,811 @@
1
+ "use strict";
2
+ /**
3
+ * Python Discovery Service
4
+ *
5
+ * Discovers Python environments on the system (conda, pyenv, venv, system).
6
+ * Node.js port of the Python PythonDiscoveryService.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.pythonDiscovery = exports.PythonDiscoveryService = void 0;
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const os = __importStar(require("os"));
46
+ const child_process_1 = require("child_process");
47
+ const types_1 = require("./types");
48
+ const DEFAULT_CACHE_FILE = path.join(os.homedir(), '.nebula-notebook', 'python-cache.json');
49
+ class PythonDiscoveryService {
50
+ cache = {};
51
+ cacheTimestamp = 0;
52
+ cacheFile;
53
+ cacheTtlHours;
54
+ versionCheckTimeoutMs;
55
+ ipykernelCheckTimeoutMs;
56
+ condaListTimeoutMs;
57
+ kernelInstallTimeoutMs;
58
+ registrationTimeoutMs;
59
+ backgroundRefreshInProgress = false;
60
+ constructor(options = {}) {
61
+ this.cacheFile = options.cacheFile || DEFAULT_CACHE_FILE;
62
+ this.cacheTtlHours = options.cacheTtlHours ?? types_1.DEFAULT_CACHE_TTL_HOURS;
63
+ this.versionCheckTimeoutMs = options.versionCheckTimeoutMs ?? types_1.DEFAULT_VERSION_CHECK_TIMEOUT_MS;
64
+ this.ipykernelCheckTimeoutMs = options.ipykernelCheckTimeoutMs ?? types_1.DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS;
65
+ this.condaListTimeoutMs = options.condaListTimeoutMs ?? types_1.DEFAULT_CONDA_LIST_TIMEOUT_MS;
66
+ this.kernelInstallTimeoutMs = options.kernelInstallTimeoutMs ?? types_1.DEFAULT_KERNEL_INSTALL_TIMEOUT_MS;
67
+ this.registrationTimeoutMs = options.registrationTimeoutMs ?? types_1.DEFAULT_REGISTRATION_TIMEOUT_MS;
68
+ this.loadCache();
69
+ }
70
+ /**
71
+ * Load cache from disk
72
+ */
73
+ loadCache() {
74
+ try {
75
+ if (fs.existsSync(this.cacheFile)) {
76
+ const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8'));
77
+ this.cache = data.environments || {};
78
+ this.cacheTimestamp = data.timestamp || 0;
79
+ }
80
+ }
81
+ catch (e) {
82
+ console.warn('Failed to load Python cache:', e);
83
+ this.cache = {};
84
+ this.cacheTimestamp = 0;
85
+ }
86
+ }
87
+ /**
88
+ * Save environments to cache
89
+ */
90
+ saveToCache(environments) {
91
+ try {
92
+ const dir = path.dirname(this.cacheFile);
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+ const data = {
97
+ environments,
98
+ timestamp: Date.now(),
99
+ };
100
+ fs.writeFileSync(this.cacheFile, JSON.stringify(data, null, 2), 'utf-8');
101
+ this.cache = environments;
102
+ this.cacheTimestamp = data.timestamp;
103
+ }
104
+ catch (e) {
105
+ console.warn('Failed to save Python cache:', e);
106
+ }
107
+ }
108
+ /**
109
+ * Get environments from cache (returns null if empty)
110
+ */
111
+ getFromCache() {
112
+ if (Object.keys(this.cache).length === 0) {
113
+ return null;
114
+ }
115
+ return this.cache;
116
+ }
117
+ /**
118
+ * Check if cache is still valid
119
+ */
120
+ isCacheValid() {
121
+ if (Object.keys(this.cache).length === 0) {
122
+ return false;
123
+ }
124
+ const ageHours = (Date.now() - this.cacheTimestamp) / (1000 * 60 * 60);
125
+ return ageHours < this.cacheTtlHours;
126
+ }
127
+ /**
128
+ * Get cache info
129
+ */
130
+ getCacheInfo() {
131
+ return {
132
+ cachedCount: Object.keys(this.cache).length,
133
+ cacheAgeHours: this.cacheTimestamp > 0 ? (Date.now() - this.cacheTimestamp) / (1000 * 60 * 60) : null,
134
+ cacheValid: this.isCacheValid(),
135
+ cacheFile: this.cacheFile,
136
+ refreshing: this.backgroundRefreshInProgress,
137
+ };
138
+ }
139
+ /**
140
+ * Run a command asynchronously with timeout
141
+ */
142
+ runCommand(command, args, timeoutMs) {
143
+ return new Promise((resolve, reject) => {
144
+ const child = (0, child_process_1.spawn)(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
145
+ let stdout = '';
146
+ let stderr = '';
147
+ let finished = false;
148
+ const timer = timeoutMs > 0 ? setTimeout(() => {
149
+ if (finished)
150
+ return;
151
+ finished = true;
152
+ child.kill('SIGKILL');
153
+ reject(new Error(`Command timed out after ${timeoutMs}ms`));
154
+ }, timeoutMs) : null;
155
+ child.stdout?.on('data', (data) => {
156
+ stdout += data.toString();
157
+ });
158
+ child.stderr?.on('data', (data) => {
159
+ stderr += data.toString();
160
+ });
161
+ child.on('error', (err) => {
162
+ if (finished)
163
+ return;
164
+ finished = true;
165
+ if (timer)
166
+ clearTimeout(timer);
167
+ reject(err);
168
+ });
169
+ child.on('close', (code) => {
170
+ if (finished)
171
+ return;
172
+ finished = true;
173
+ if (timer)
174
+ clearTimeout(timer);
175
+ if (code === 0) {
176
+ resolve({ stdout, stderr });
177
+ }
178
+ else {
179
+ reject(new Error(`Command failed with code ${code}: ${stderr || stdout}`));
180
+ }
181
+ });
182
+ });
183
+ }
184
+ /**
185
+ * Generate display name for an environment
186
+ */
187
+ generateDisplayName(version, envType, envName) {
188
+ if (envType === 'conda' && envName) {
189
+ return `Python ${version} (conda: ${envName})`;
190
+ }
191
+ else if (envType === 'pyenv' && envName) {
192
+ return `Python ${version} (pyenv: ${envName})`;
193
+ }
194
+ else if (envType === 'venv' && envName) {
195
+ return `Python ${version} (venv: ${envName})`;
196
+ }
197
+ else if (envType === 'pixi') {
198
+ return `Python ${version} (pixi${envName ? `: ${envName}` : ''})`;
199
+ }
200
+ else if (envType === 'uv') {
201
+ return `Python ${version} (uv-managed)`;
202
+ }
203
+ else if (envType === 'homebrew') {
204
+ return `Python ${version} (Homebrew)`;
205
+ }
206
+ return `Python ${version} (System)`;
207
+ }
208
+ /**
209
+ * Sort environments by type priority and name
210
+ */
211
+ sortEnvironments(envs) {
212
+ const typeOrder = {
213
+ conda: 0,
214
+ pyenv: 1,
215
+ venv: 2,
216
+ pixi: 3,
217
+ uv: 4,
218
+ homebrew: 5,
219
+ system: 6,
220
+ };
221
+ return [...envs].sort((a, b) => {
222
+ const typeA = typeOrder[a.envType] ?? 99;
223
+ const typeB = typeOrder[b.envType] ?? 99;
224
+ if (typeA !== typeB) {
225
+ return typeA - typeB;
226
+ }
227
+ return a.displayName.localeCompare(b.displayName);
228
+ });
229
+ }
230
+ /**
231
+ * Check if a python path exists
232
+ */
233
+ pythonExists(pythonPath) {
234
+ return fs.existsSync(pythonPath);
235
+ }
236
+ /**
237
+ * Get common conda base paths to search
238
+ */
239
+ getCondaBasePaths() {
240
+ const home = os.homedir();
241
+ return [
242
+ path.join(home, 'anaconda3'),
243
+ path.join(home, 'miniconda3'),
244
+ path.join(home, 'miniforge3'),
245
+ path.join(home, 'mambaforge'),
246
+ '/opt/anaconda3',
247
+ '/opt/miniconda3',
248
+ '/opt/homebrew/anaconda3',
249
+ '/usr/local/anaconda3',
250
+ '/usr/local/miniconda3',
251
+ ];
252
+ }
253
+ /**
254
+ * Get common system python paths
255
+ */
256
+ getSystemPythonPaths() {
257
+ const paths = [
258
+ '/usr/bin/python3',
259
+ '/usr/local/bin/python3',
260
+ '/opt/homebrew/bin/python3', // Apple Silicon Homebrew
261
+ '/usr/local/opt/python/libexec/bin/python', // Intel Homebrew
262
+ ];
263
+ // Add paths from PATH environment
264
+ const envPath = process.env.PATH || '';
265
+ for (const dir of envPath.split(':')) {
266
+ for (const name of ['python3', 'python']) {
267
+ const candidate = path.join(dir, name);
268
+ if (!paths.includes(candidate)) {
269
+ paths.push(candidate);
270
+ }
271
+ }
272
+ }
273
+ return paths;
274
+ }
275
+ /**
276
+ * Get pyenv versions path
277
+ */
278
+ getPyenvVersionsPath() {
279
+ return path.join(os.homedir(), '.pyenv', 'versions');
280
+ }
281
+ /**
282
+ * Get common virtualenv paths
283
+ */
284
+ getVirtualenvPaths() {
285
+ const home = os.homedir();
286
+ return [
287
+ path.join(home, '.virtualenvs'),
288
+ path.join(home, 'venvs'),
289
+ path.join(home, '.venvs'),
290
+ ];
291
+ }
292
+ /**
293
+ * Generate a unique kernel name from path
294
+ */
295
+ generateKernelName(pythonPath, version) {
296
+ const versionShort = version.split('.').slice(0, 2).join('.');
297
+ const pathHash = Math.abs(this.hashString(pythonPath)) % 10000;
298
+ return `python${versionShort}_${pathHash}`;
299
+ }
300
+ /**
301
+ * Simple string hash function
302
+ */
303
+ hashString(str) {
304
+ let hash = 0;
305
+ for (let i = 0; i < str.length; i++) {
306
+ const char = str.charCodeAt(i);
307
+ hash = ((hash << 5) - hash) + char;
308
+ hash = hash & hash; // Convert to 32bit integer
309
+ }
310
+ return hash;
311
+ }
312
+ /**
313
+ * Parse Python version from version string
314
+ */
315
+ parseVersionString(versionOutput) {
316
+ const match = versionOutput.match(/Python (\d+\.\d+\.\d+)/);
317
+ if (match) {
318
+ return match[1];
319
+ }
320
+ // Try without patch version
321
+ const match2 = versionOutput.match(/Python (\d+\.\d+)/);
322
+ if (match2) {
323
+ return match2[1];
324
+ }
325
+ return versionOutput.replace('Python ', '').trim();
326
+ }
327
+ /**
328
+ * Get Python version from executable
329
+ */
330
+ async getPythonVersion(pythonPath) {
331
+ try {
332
+ const { stdout, stderr } = await this.runCommand(pythonPath, ['--version'], this.versionCheckTimeoutMs);
333
+ const output = (stdout || stderr).trim();
334
+ return this.parseVersionString(output);
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ }
340
+ /**
341
+ * Check if ipykernel is installed
342
+ */
343
+ async checkIpykernel(pythonPath) {
344
+ return (await this.probeEnvironment(pythonPath)).hasIpykernel;
345
+ }
346
+ /**
347
+ * Probe an interpreter for the two facts that drive kernel provisioning, in a
348
+ * single Python invocation (keeps discovery cheap — one spawn per env):
349
+ * - whether `ipykernel` is importable
350
+ * - whether the interpreter is PEP 668 "externally managed" (an
351
+ * `EXTERNALLY-MANAGED` marker beside the stdlib), which blocks pip installs.
352
+ *
353
+ * Detecting the marker file is capability-based and stable: we ask Python
354
+ * itself rather than fingerprinting tool names by path, so it keeps working as
355
+ * new package managers (uv, pixi, …) appear.
356
+ */
357
+ async probeEnvironment(pythonPath) {
358
+ const script = [
359
+ 'import json, os, sys, sysconfig',
360
+ 'from importlib.util import find_spec',
361
+ 'stdlib = sysconfig.get_path("stdlib") or ""',
362
+ 'marker = bool(stdlib) and os.path.exists(os.path.join(stdlib, "EXTERNALLY-MANAGED"))',
363
+ // A virtual environment always allows pip installs — PEP 668 enforcement is
364
+ // skipped inside venvs (pip checks sys.prefix != sys.base_prefix), even when
365
+ // the venv's base interpreter carries the EXTERNALLY-MANAGED marker.
366
+ 'in_venv = sys.prefix != sys.base_prefix',
367
+ 'em = marker and not in_venv',
368
+ 'ik = find_spec("ipykernel") is not None',
369
+ 'print(json.dumps({"ipykernel": ik, "externally_managed": em, "venv": in_venv}))',
370
+ ].join('; ');
371
+ try {
372
+ const { stdout } = await this.runCommand(pythonPath, ['-c', script], this.ipykernelCheckTimeoutMs);
373
+ const parsed = JSON.parse(stdout.trim().split('\n').pop() || '{}');
374
+ return {
375
+ hasIpykernel: !!parsed.ipykernel,
376
+ externallyManaged: !!parsed.externally_managed,
377
+ isVenv: !!parsed.venv,
378
+ };
379
+ }
380
+ catch {
381
+ return { hasIpykernel: false, externallyManaged: false, isVenv: false };
382
+ }
383
+ }
384
+ /**
385
+ * Refine an environment's type using stable path markers that the coarse
386
+ * discovery scans miss. Only used to pick a better label / guidance hint —
387
+ * the safe-vs-unsafe install decision relies on `externallyManaged`, not this.
388
+ *
389
+ * A venv is left as-is: its `bin/python` symlinks to (and `sysconfig` resolves
390
+ * to) its base interpreter, so following the symlink to e.g. a uv-managed build
391
+ * must NOT relabel the venv itself as "uv".
392
+ */
393
+ classifyEnvType(originalPath, realPath, current, isVenv) {
394
+ const o = originalPath.replace(/\\/g, '/');
395
+ const r = realPath.replace(/\\/g, '/');
396
+ if (o.includes('/.pixi/envs/') || r.includes('/.pixi/envs/'))
397
+ return 'pixi';
398
+ // uv-managed standalone interpreters live under .../uv/python/cpython-* . Only
399
+ // a bare interpreter qualifies — a venv built on one stays a venv.
400
+ if (!isVenv && (r.includes('/uv/python/') || r.includes('/.local/share/uv/')))
401
+ return 'uv';
402
+ return current;
403
+ }
404
+ /**
405
+ * Build a copy-pasteable command that makes `ipykernel` available for an env,
406
+ * tailored to the detected ecosystem. After the user runs it (and clicks
407
+ * Refresh), the env shows up with ipykernel and Nebula offers one-click
408
+ * Register — Nebula never installs packages itself.
409
+ *
410
+ * Returns null when ipykernel is already present (nothing to do but Register).
411
+ */
412
+ buildInstallHint(pythonPath, envType, envName, hasIpykernel, externallyManaged) {
413
+ if (hasIpykernel)
414
+ return null;
415
+ const py = pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath;
416
+ // Installable in place: not externally managed → use the env's own manager.
417
+ if (!externallyManaged) {
418
+ if (envType === 'conda') {
419
+ return `conda install -n ${envName || 'base'} ipykernel`;
420
+ }
421
+ if (envType === 'pixi') {
422
+ return `pixi add ipykernel`;
423
+ }
424
+ // venv / pyenv / plain: pip into the interpreter directly.
425
+ return `${py} -m pip install ipykernel`;
426
+ }
427
+ // Externally managed (PEP 668): can't install in place — create an isolated
428
+ // env Nebula will then discover under ~/.venvs.
429
+ if (envType === 'uv') {
430
+ return `uv venv ~/.venvs/nebula && uv pip install --python ~/.venvs/nebula/bin/python ipykernel`;
431
+ }
432
+ return `${py} -m venv ~/.venvs/nebula && ~/.venvs/nebula/bin/python -m pip install ipykernel`;
433
+ }
434
+ /**
435
+ * Find conda environments
436
+ */
437
+ async findCondaEnvs() {
438
+ const envs = [];
439
+ const seenPaths = new Set();
440
+ // Possible conda executable paths
441
+ const condaPaths = [
442
+ '/opt/anaconda3/bin/conda',
443
+ '/opt/miniconda3/bin/conda',
444
+ '/opt/homebrew/bin/conda',
445
+ '/opt/homebrew/anaconda3/bin/conda',
446
+ path.join(os.homedir(), 'anaconda3', 'bin', 'conda'),
447
+ path.join(os.homedir(), 'miniconda3', 'bin', 'conda'),
448
+ path.join(os.homedir(), 'miniforge3', 'bin', 'conda'),
449
+ path.join(os.homedir(), 'mambaforge', 'bin', 'conda'),
450
+ '/usr/local/anaconda3/bin/conda',
451
+ '/usr/local/miniconda3/bin/conda',
452
+ ];
453
+ // Try to get envs from conda command
454
+ for (const condaPath of condaPaths) {
455
+ if (!fs.existsSync(condaPath))
456
+ continue;
457
+ try {
458
+ const { stdout } = await this.runCommand(condaPath, ['env', 'list', '--json'], this.condaListTimeoutMs);
459
+ const data = JSON.parse(stdout);
460
+ for (const envPath of data.envs || []) {
461
+ const pythonPath = path.join(envPath, 'bin', 'python');
462
+ if (fs.existsSync(pythonPath) && !seenPaths.has(pythonPath)) {
463
+ seenPaths.add(pythonPath);
464
+ const envName = path.basename(envPath);
465
+ const isBase = envPath === data.root_prefix || !envPath.includes('envs');
466
+ envs.push({
467
+ path: pythonPath,
468
+ envType: 'conda',
469
+ envName: isBase ? 'base' : envName,
470
+ base: envPath,
471
+ });
472
+ }
473
+ }
474
+ }
475
+ catch (e) {
476
+ console.warn(`Error running conda env list: ${e}`);
477
+ }
478
+ }
479
+ // Also scan common conda base directories
480
+ for (const base of this.getCondaBasePaths()) {
481
+ if (!fs.existsSync(base))
482
+ continue;
483
+ // Base environment
484
+ const basePython = path.join(base, 'bin', 'python');
485
+ if (fs.existsSync(basePython) && !seenPaths.has(basePython)) {
486
+ seenPaths.add(basePython);
487
+ envs.push({
488
+ path: basePython,
489
+ envType: 'conda',
490
+ envName: 'base',
491
+ base,
492
+ });
493
+ }
494
+ // Sub-environments
495
+ const envsDir = path.join(base, 'envs');
496
+ if (fs.existsSync(envsDir)) {
497
+ try {
498
+ for (const envDir of fs.readdirSync(envsDir)) {
499
+ const envPath = path.join(envsDir, envDir);
500
+ if (!fs.statSync(envPath).isDirectory())
501
+ continue;
502
+ const pythonPath = path.join(envPath, 'bin', 'python');
503
+ if (fs.existsSync(pythonPath) && !seenPaths.has(pythonPath)) {
504
+ seenPaths.add(pythonPath);
505
+ envs.push({
506
+ path: pythonPath,
507
+ envType: 'conda',
508
+ envName: envDir,
509
+ base,
510
+ });
511
+ }
512
+ }
513
+ }
514
+ catch (e) {
515
+ console.warn(`Error scanning conda envs: ${e}`);
516
+ }
517
+ }
518
+ }
519
+ return envs;
520
+ }
521
+ /**
522
+ * Find pyenv Python versions
523
+ */
524
+ findPyenvVersions() {
525
+ const envs = [];
526
+ const pyenvRoot = this.getPyenvVersionsPath();
527
+ if (!fs.existsSync(pyenvRoot)) {
528
+ return envs;
529
+ }
530
+ try {
531
+ for (const versionDir of fs.readdirSync(pyenvRoot)) {
532
+ const versionPath = path.join(pyenvRoot, versionDir);
533
+ if (!fs.statSync(versionPath).isDirectory())
534
+ continue;
535
+ const pythonPath = path.join(versionPath, 'bin', 'python');
536
+ if (fs.existsSync(pythonPath)) {
537
+ envs.push({
538
+ path: pythonPath,
539
+ envType: 'pyenv',
540
+ envName: versionDir,
541
+ });
542
+ }
543
+ }
544
+ }
545
+ catch (e) {
546
+ console.warn(`Error scanning pyenv versions: ${e}`);
547
+ }
548
+ return envs;
549
+ }
550
+ /**
551
+ * Find virtualenvs in common locations
552
+ */
553
+ findVirtualenvs() {
554
+ const envs = [];
555
+ for (const venvDir of this.getVirtualenvPaths()) {
556
+ if (!fs.existsSync(venvDir))
557
+ continue;
558
+ try {
559
+ for (const envName of fs.readdirSync(venvDir)) {
560
+ const envPath = path.join(venvDir, envName);
561
+ if (!fs.statSync(envPath).isDirectory())
562
+ continue;
563
+ const pythonPath = path.join(envPath, 'bin', 'python');
564
+ if (fs.existsSync(pythonPath)) {
565
+ envs.push({
566
+ path: pythonPath,
567
+ envType: 'venv',
568
+ envName,
569
+ });
570
+ }
571
+ }
572
+ }
573
+ catch (e) {
574
+ console.warn(`Error scanning virtualenvs: ${e}`);
575
+ }
576
+ }
577
+ return envs;
578
+ }
579
+ /**
580
+ * Find system Python installations
581
+ */
582
+ findSystemPythons() {
583
+ const envs = [];
584
+ const seen = new Set();
585
+ for (const pythonPath of this.getSystemPythonPaths()) {
586
+ if (!fs.existsSync(pythonPath))
587
+ continue;
588
+ // Resolve symlinks to avoid duplicates
589
+ try {
590
+ const realPath = fs.realpathSync(pythonPath);
591
+ if (seen.has(realPath))
592
+ continue;
593
+ seen.add(realPath);
594
+ }
595
+ catch {
596
+ continue;
597
+ }
598
+ const envType = pythonPath.toLowerCase().includes('homebrew') ||
599
+ pythonPath.includes('/opt/homebrew') ? 'homebrew' : 'system';
600
+ envs.push({
601
+ path: pythonPath,
602
+ envType,
603
+ envName: null,
604
+ });
605
+ }
606
+ return envs;
607
+ }
608
+ /**
609
+ * Enrich a candidate with version and ipykernel info
610
+ */
611
+ async enrichEnvironment(candidate) {
612
+ const version = await this.getPythonVersion(candidate.path);
613
+ if (!version) {
614
+ return null;
615
+ }
616
+ const { hasIpykernel, externallyManaged, isVenv } = await this.probeEnvironment(candidate.path);
617
+ // Refine the type via stable path markers (uv/pixi) that the coarse scans
618
+ // can't distinguish; resolve symlinks first (e.g. ~/.local/bin/python3 → uv).
619
+ let realPath = candidate.path;
620
+ try {
621
+ realPath = fs.realpathSync(candidate.path);
622
+ }
623
+ catch { /* keep original */ }
624
+ const envType = this.classifyEnvType(candidate.path, realPath, candidate.envType, isVenv);
625
+ const displayName = this.generateDisplayName(version, envType, candidate.envName);
626
+ const installHint = this.buildInstallHint(candidate.path, envType, candidate.envName, hasIpykernel, externallyManaged);
627
+ return {
628
+ path: candidate.path,
629
+ version,
630
+ displayName,
631
+ envType,
632
+ envName: candidate.envName,
633
+ hasIpykernel,
634
+ kernelName: null,
635
+ externallyManaged,
636
+ installHint,
637
+ };
638
+ }
639
+ /**
640
+ * Perform the actual discovery (internal, always runs full scan)
641
+ */
642
+ async performDiscovery() {
643
+ console.log('Discovering Python environments...');
644
+ // Collect all candidates
645
+ const candidates = [];
646
+ candidates.push(...(await this.findCondaEnvs()));
647
+ candidates.push(...this.findPyenvVersions());
648
+ candidates.push(...this.findVirtualenvs());
649
+ candidates.push(...this.findSystemPythons());
650
+ // Enrich in parallel
651
+ const enrichPromises = candidates.map(c => this.enrichEnvironment(c));
652
+ const results = await Promise.all(enrichPromises);
653
+ const environments = results.filter((e) => e !== null);
654
+ // Sort and cache
655
+ const sorted = this.sortEnvironments(environments);
656
+ // Save to cache
657
+ const cacheMap = {};
658
+ for (const env of sorted) {
659
+ cacheMap[env.path] = env;
660
+ }
661
+ this.saveToCache(cacheMap);
662
+ console.log(`Discovered ${sorted.length} Python environments`);
663
+ return sorted;
664
+ }
665
+ /**
666
+ * Trigger background refresh (non-blocking)
667
+ */
668
+ triggerBackgroundRefresh() {
669
+ if (this.backgroundRefreshInProgress) {
670
+ return; // Already refreshing
671
+ }
672
+ this.backgroundRefreshInProgress = true;
673
+ console.log('Starting background Python environment refresh...');
674
+ // Run discovery in background, don't await
675
+ this.performDiscovery()
676
+ .then(() => {
677
+ console.log('Background Python environment refresh complete');
678
+ })
679
+ .catch((e) => {
680
+ console.warn('Background Python environment refresh failed:', e);
681
+ })
682
+ .finally(() => {
683
+ this.backgroundRefreshInProgress = false;
684
+ });
685
+ }
686
+ /**
687
+ * Discover all Python environments
688
+ *
689
+ * Uses stale-while-revalidate pattern:
690
+ * - If cache exists (even stale): return immediately, refresh in background if stale
691
+ * - If no cache: block and perform full discovery
692
+ * - If forceRefresh: block and perform full discovery
693
+ */
694
+ async discover(options = {}) {
695
+ const { forceRefresh = false } = options;
696
+ // Force refresh: always block and run full discovery
697
+ if (forceRefresh) {
698
+ return this.performDiscovery();
699
+ }
700
+ // Cache valid: return immediately
701
+ if (this.isCacheValid()) {
702
+ return this.sortEnvironments(Object.values(this.cache));
703
+ }
704
+ // Cache exists but stale: return stale cache, refresh in background
705
+ const cachedEnvs = this.getFromCache();
706
+ if (cachedEnvs) {
707
+ this.triggerBackgroundRefresh();
708
+ return this.sortEnvironments(Object.values(cachedEnvs));
709
+ }
710
+ // No cache at all: must block and discover
711
+ return this.performDiscovery();
712
+ }
713
+ /**
714
+ * Check if background refresh is in progress
715
+ */
716
+ isRefreshing() {
717
+ return this.backgroundRefreshInProgress;
718
+ }
719
+ /**
720
+ * Register a Python environment as a Jupyter kernel.
721
+ *
722
+ * Policy (capability-based, see ADR in the kernel onboarding flow):
723
+ * - If `ipykernel` is already importable → just register the kernelspec.
724
+ * This is the safe, universal path and works for every ecosystem.
725
+ * - If `ipykernel` is missing and the interpreter is PEP 668 externally
726
+ * managed → refuse, with a `needs_ipykernel`/`externally_managed` code so
727
+ * the UI shows guidance instead of a raw pip traceback. Nebula does not
728
+ * install into managed interpreters.
729
+ * - If `ipykernel` is missing but the interpreter is writable (a plain venv,
730
+ * conda env, …) → install it, then register. This keeps the API usable for
731
+ * deliberate callers (MCP, scripts); the UI funnels everything through the
732
+ * register-only path.
733
+ */
734
+ async installKernel(pythonPath, kernelName) {
735
+ if (!fs.existsSync(pythonPath)) {
736
+ throw new types_1.KernelProvisionError(`Python not found: ${pythonPath}`, 'python_not_found');
737
+ }
738
+ const { hasIpykernel, externallyManaged } = await this.probeEnvironment(pythonPath);
739
+ if (!hasIpykernel) {
740
+ if (externallyManaged) {
741
+ const version = await this.getPythonVersion(pythonPath);
742
+ const hint = this.buildInstallHint(pythonPath, 'system', null, false, true);
743
+ throw new types_1.KernelProvisionError(`This Python (${version || pythonPath}) is externally managed (PEP 668) and ipykernel is not installed, ` +
744
+ `so Nebula can't install it here. Set up ipykernel in an isolated environment, then Register it.`, 'externally_managed', hint || undefined);
745
+ }
746
+ // Writable env without ipykernel: install it (safe — not externally managed).
747
+ console.log(`Installing ipykernel for ${pythonPath}...`);
748
+ try {
749
+ (0, child_process_1.execSync)(`"${pythonPath}" -m pip install ipykernel -q`, {
750
+ timeout: this.kernelInstallTimeoutMs,
751
+ stdio: ['pipe', 'pipe', 'pipe'],
752
+ });
753
+ }
754
+ catch (e) {
755
+ const detail = this.errorOutput(e);
756
+ // A late-detected PEP 668 (marker absent but pip still refused): treat as managed.
757
+ if (this.isExternallyManagedError(detail)) {
758
+ const hint = this.buildInstallHint(pythonPath, 'system', null, false, true);
759
+ throw new types_1.KernelProvisionError(`This Python blocks package installation (PEP 668). Set up ipykernel in an isolated environment, then Register it.`, 'externally_managed', hint || undefined);
760
+ }
761
+ throw new types_1.KernelProvisionError(`Failed to install ipykernel: ${detail || e}`, 'install_failed');
762
+ }
763
+ }
764
+ // Generate kernel name if not provided
765
+ if (!kernelName) {
766
+ const version = await this.getPythonVersion(pythonPath) || '3';
767
+ kernelName = this.generateKernelName(pythonPath, version);
768
+ }
769
+ // Register kernel
770
+ console.log(`Registering kernel as ${kernelName}...`);
771
+ try {
772
+ (0, child_process_1.execSync)(`"${pythonPath}" -m ipykernel install --user --name "${kernelName}"`, {
773
+ timeout: this.registrationTimeoutMs,
774
+ stdio: ['pipe', 'pipe', 'pipe'],
775
+ });
776
+ }
777
+ catch (e) {
778
+ throw new types_1.KernelProvisionError(`Failed to register kernel: ${this.errorOutput(e) || e}`, 'register_failed');
779
+ }
780
+ // Update cache entry
781
+ if (this.cache[pythonPath]) {
782
+ this.cache[pythonPath].hasIpykernel = true;
783
+ this.cache[pythonPath].kernelName = kernelName;
784
+ this.saveToCache(this.cache);
785
+ }
786
+ return {
787
+ kernelName,
788
+ pythonPath,
789
+ message: `Successfully registered kernel '${kernelName}'`,
790
+ };
791
+ }
792
+ /** Extract combined stdout+stderr text from a child_process error. */
793
+ errorOutput(e) {
794
+ const err = e;
795
+ const parts = [err?.stderr, err?.stdout, err?.message]
796
+ .map(p => (p == null ? '' : p.toString()))
797
+ .filter(Boolean);
798
+ return parts.join('\n');
799
+ }
800
+ /** Detect PEP 668 "externally managed" refusals generically (not tool-specific). */
801
+ isExternallyManagedError(msg) {
802
+ const m = (msg || '').toLowerCase();
803
+ return m.includes('externally-managed')
804
+ || m.includes('externally managed')
805
+ || m.includes('break-system-packages')
806
+ || m.includes('pep 668');
807
+ }
808
+ }
809
+ exports.PythonDiscoveryService = PythonDiscoveryService;
810
+ // Global instance with default configuration
811
+ exports.pythonDiscovery = new PythonDiscoveryService();