nebula-notebook 0.2.0 → 0.2.2
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/README.md +35 -20
- package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
- package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
- package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +35 -2
- package/node-server/dist/cluster/kernel-proxy.js +19 -3
- package/node-server/dist/cluster/server-registry.d.ts +9 -0
- package/node-server/dist/cluster/server-registry.js +26 -0
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +31 -1
- package/node-server/dist/fs/fs-service.js +160 -28
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/index.js +71 -4
- package/node-server/dist/kernel/kernel-service.d.ts +17 -0
- package/node-server/dist/kernel/kernel-service.js +72 -4
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +10 -0
- package/node-server/dist/notebook/headless-handler.js +55 -5
- package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
- package/node-server/dist/notebook/undoRedoManager.js +39 -16
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -0
- package/node-server/dist/routes/fs.js +21 -4
- package/node-server/dist/routes/kernel.js +45 -2
- package/node-server/dist/routes/notebook.js +8 -2
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
- package/node-server/dist/scheduler/allocation-service.js +124 -8
- package/node-server/dist/scheduler/mock-scheduler.js +7 -3
- package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
- package/node-server/dist/scheduler/types.d.ts +3 -2
- package/node-server/dist/terminal/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +130 -19
- package/node-server/dist/terminal/server.js +112 -21
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/package.json +1 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +17 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-7-YBurka.js +0 -716
- package/dist/assets/index-CFBUnxSZ.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -41,10 +41,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.pythonDiscovery = exports.PythonDiscoveryService = void 0;
|
|
43
43
|
const fs = __importStar(require("fs"));
|
|
44
|
+
const fs_1 = require("fs");
|
|
44
45
|
const path = __importStar(require("path"));
|
|
45
46
|
const os = __importStar(require("os"));
|
|
46
47
|
const child_process_1 = require("child_process");
|
|
47
48
|
const types_1 = require("./types");
|
|
49
|
+
const conda_locations_1 = require("./conda-locations");
|
|
48
50
|
const DEFAULT_CACHE_FILE = path.join(os.homedir(), '.nebula-notebook', 'python-cache.json');
|
|
49
51
|
class PythonDiscoveryService {
|
|
50
52
|
cache = {};
|
|
@@ -53,20 +55,31 @@ class PythonDiscoveryService {
|
|
|
53
55
|
cacheTtlHours;
|
|
54
56
|
versionCheckTimeoutMs;
|
|
55
57
|
ipykernelCheckTimeoutMs;
|
|
56
|
-
condaListTimeoutMs;
|
|
57
58
|
kernelInstallTimeoutMs;
|
|
58
59
|
registrationTimeoutMs;
|
|
60
|
+
condaLocatorOverrides;
|
|
61
|
+
condaBinaryFinder;
|
|
59
62
|
backgroundRefreshInProgress = false;
|
|
60
63
|
constructor(options = {}) {
|
|
61
64
|
this.cacheFile = options.cacheFile || DEFAULT_CACHE_FILE;
|
|
62
65
|
this.cacheTtlHours = options.cacheTtlHours ?? types_1.DEFAULT_CACHE_TTL_HOURS;
|
|
63
66
|
this.versionCheckTimeoutMs = options.versionCheckTimeoutMs ?? types_1.DEFAULT_VERSION_CHECK_TIMEOUT_MS;
|
|
64
67
|
this.ipykernelCheckTimeoutMs = options.ipykernelCheckTimeoutMs ?? types_1.DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS;
|
|
65
|
-
this.condaListTimeoutMs = options.condaListTimeoutMs ?? types_1.DEFAULT_CONDA_LIST_TIMEOUT_MS;
|
|
66
68
|
this.kernelInstallTimeoutMs = options.kernelInstallTimeoutMs ?? types_1.DEFAULT_KERNEL_INSTALL_TIMEOUT_MS;
|
|
67
69
|
this.registrationTimeoutMs = options.registrationTimeoutMs ?? types_1.DEFAULT_REGISTRATION_TIMEOUT_MS;
|
|
70
|
+
this.condaLocatorOverrides = options.condaLocator ?? {};
|
|
71
|
+
this.condaBinaryFinder = options.condaBinaryFinder ?? conda_locations_1.findCondaLikeBinaries;
|
|
68
72
|
this.loadCache();
|
|
69
73
|
}
|
|
74
|
+
/** Context for the filesystem conda locator (test seam applied). */
|
|
75
|
+
condaContext() {
|
|
76
|
+
const ctx = (0, conda_locations_1.defaultCondaContext)();
|
|
77
|
+
if (this.condaLocatorOverrides.home)
|
|
78
|
+
ctx.home = this.condaLocatorOverrides.home;
|
|
79
|
+
if (this.condaLocatorOverrides.env)
|
|
80
|
+
ctx.env = this.condaLocatorOverrides.env;
|
|
81
|
+
return ctx;
|
|
82
|
+
}
|
|
70
83
|
/**
|
|
71
84
|
* Load cache from disk
|
|
72
85
|
*/
|
|
@@ -137,9 +150,11 @@ class PythonDiscoveryService {
|
|
|
137
150
|
};
|
|
138
151
|
}
|
|
139
152
|
/**
|
|
140
|
-
* Run a command asynchronously with timeout
|
|
153
|
+
* Run a command asynchronously with timeout. `onData` receives every
|
|
154
|
+
* stdout/stderr chunk as it arrives (interleaved), for callers that
|
|
155
|
+
* surface live progress (the ipykernel install modal).
|
|
141
156
|
*/
|
|
142
|
-
runCommand(command, args, timeoutMs) {
|
|
157
|
+
runCommand(command, args, timeoutMs, onData) {
|
|
143
158
|
return new Promise((resolve, reject) => {
|
|
144
159
|
const child = (0, child_process_1.spawn)(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
145
160
|
let stdout = '';
|
|
@@ -154,9 +169,11 @@ class PythonDiscoveryService {
|
|
|
154
169
|
}, timeoutMs) : null;
|
|
155
170
|
child.stdout?.on('data', (data) => {
|
|
156
171
|
stdout += data.toString();
|
|
172
|
+
onData?.(data.toString());
|
|
157
173
|
});
|
|
158
174
|
child.stderr?.on('data', (data) => {
|
|
159
175
|
stderr += data.toString();
|
|
176
|
+
onData?.(data.toString());
|
|
160
177
|
});
|
|
161
178
|
child.on('error', (err) => {
|
|
162
179
|
if (finished)
|
|
@@ -233,6 +250,21 @@ class PythonDiscoveryService {
|
|
|
233
250
|
pythonExists(pythonPath) {
|
|
234
251
|
return fs.existsSync(pythonPath);
|
|
235
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Async existence check. The discovery scan MUST use this (not existsSync):
|
|
255
|
+
* env dirs often live on network filesystems (GPFS) where each sync stat can
|
|
256
|
+
* take 10-500ms, and the scan runs dozens — enough to freeze the entire
|
|
257
|
+
* event loop for seconds (observed as "terminal dead during autosave").
|
|
258
|
+
*/
|
|
259
|
+
async fsExists(p) {
|
|
260
|
+
try {
|
|
261
|
+
await fs_1.promises.access(p);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
236
268
|
/**
|
|
237
269
|
* Get common conda base paths to search
|
|
238
270
|
*/
|
|
@@ -416,7 +448,13 @@ class PythonDiscoveryService {
|
|
|
416
448
|
// Installable in place: not externally managed → use the env's own manager.
|
|
417
449
|
if (!externallyManaged) {
|
|
418
450
|
if (envType === 'conda') {
|
|
419
|
-
|
|
451
|
+
// -p <prefix> works for named, base AND path-based (-p created) envs;
|
|
452
|
+
// -n would silently target the wrong env for the path-based ones.
|
|
453
|
+
const prefix = path.basename(path.dirname(pythonPath)) === 'bin'
|
|
454
|
+
? path.dirname(path.dirname(pythonPath))
|
|
455
|
+
: path.dirname(pythonPath);
|
|
456
|
+
const p = prefix.includes(' ') ? `"${prefix}"` : prefix;
|
|
457
|
+
return `conda install -p ${p} ipykernel -y`;
|
|
420
458
|
}
|
|
421
459
|
if (envType === 'pixi') {
|
|
422
460
|
return `pixi add ipykernel`;
|
|
@@ -432,146 +470,95 @@ class PythonDiscoveryService {
|
|
|
432
470
|
return `${py} -m venv ~/.venvs/nebula && ~/.venvs/nebula/bin/python -m pip install ipykernel`;
|
|
433
471
|
}
|
|
434
472
|
/**
|
|
435
|
-
*
|
|
473
|
+
* Public probe for kernel provisioning: does this interpreter have ipykernel,
|
|
474
|
+
* can we install into it, and if not — what should the user run instead?
|
|
475
|
+
* Uses the discovery cache for env-type context (better hints) but always
|
|
476
|
+
* probes the interpreter live, so a just-installed ipykernel is seen
|
|
477
|
+
* immediately even with a stale cache.
|
|
478
|
+
*/
|
|
479
|
+
async probeForKernel(pythonPath) {
|
|
480
|
+
const { hasIpykernel, externallyManaged } = await this.probeEnvironment(pythonPath);
|
|
481
|
+
const cached = this.cache[pythonPath];
|
|
482
|
+
const installHint = this.buildInstallHint(pythonPath, cached?.envType ?? 'system', cached?.envName ?? null, hasIpykernel, externallyManaged);
|
|
483
|
+
return { hasIpykernel, externallyManaged, installHint };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Find conda environments — pure filesystem forensics (see conda-locations.ts).
|
|
487
|
+
* conda/mamba are never executed: envs come from ~/.conda/environments.txt,
|
|
488
|
+
* .condarc envs_dirs, well-known roots, the CONDA_ and MAMBA_ env vars, and
|
|
489
|
+
* roots derived from conda-like binaries found on PATH.
|
|
436
490
|
*/
|
|
437
491
|
async findCondaEnvs() {
|
|
492
|
+
const ctx = this.condaContext();
|
|
438
493
|
const envs = [];
|
|
439
494
|
const seenPaths = new Set();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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))
|
|
495
|
+
for (const loc of await (0, conda_locations_1.collectCondaEnvs)(ctx)) {
|
|
496
|
+
const pythonPath = (0, conda_locations_1.pythonExeForPrefix)(loc.prefix, ctx.platform);
|
|
497
|
+
if (seenPaths.has(pythonPath))
|
|
456
498
|
continue;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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))
|
|
499
|
+
// An env can legitimately lack python (e.g. `conda create` without it) —
|
|
500
|
+
// nothing to offer as a kernel, skip.
|
|
501
|
+
if (!(await this.fsExists(pythonPath)))
|
|
482
502
|
continue;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
}
|
|
503
|
+
seenPaths.add(pythonPath);
|
|
504
|
+
envs.push({
|
|
505
|
+
path: pythonPath,
|
|
506
|
+
envType: 'conda',
|
|
507
|
+
envName: loc.envName,
|
|
508
|
+
base: loc.base ?? loc.prefix,
|
|
509
|
+
});
|
|
518
510
|
}
|
|
519
511
|
return envs;
|
|
520
512
|
}
|
|
521
513
|
/**
|
|
522
514
|
* Find pyenv Python versions
|
|
523
515
|
*/
|
|
524
|
-
findPyenvVersions() {
|
|
516
|
+
async findPyenvVersions() {
|
|
525
517
|
const envs = [];
|
|
526
518
|
const pyenvRoot = this.getPyenvVersionsPath();
|
|
527
|
-
if (!fs.existsSync(pyenvRoot)) {
|
|
528
|
-
return envs;
|
|
529
|
-
}
|
|
530
519
|
try {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (!
|
|
520
|
+
const entries = await fs_1.promises.readdir(pyenvRoot, { withFileTypes: true });
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
534
523
|
continue;
|
|
535
|
-
const pythonPath = path.join(
|
|
536
|
-
if (
|
|
524
|
+
const pythonPath = path.join(pyenvRoot, entry.name, 'bin', 'python');
|
|
525
|
+
if (await this.fsExists(pythonPath)) {
|
|
537
526
|
envs.push({
|
|
538
527
|
path: pythonPath,
|
|
539
528
|
envType: 'pyenv',
|
|
540
|
-
envName:
|
|
529
|
+
envName: entry.name,
|
|
541
530
|
});
|
|
542
531
|
}
|
|
543
532
|
}
|
|
544
533
|
}
|
|
545
|
-
catch
|
|
546
|
-
|
|
534
|
+
catch {
|
|
535
|
+
// pyenv root missing/unreadable — nothing to scan
|
|
547
536
|
}
|
|
548
537
|
return envs;
|
|
549
538
|
}
|
|
550
539
|
/**
|
|
551
540
|
* Find virtualenvs in common locations
|
|
552
541
|
*/
|
|
553
|
-
findVirtualenvs() {
|
|
542
|
+
async findVirtualenvs() {
|
|
554
543
|
const envs = [];
|
|
555
544
|
for (const venvDir of this.getVirtualenvPaths()) {
|
|
556
|
-
if (!fs.existsSync(venvDir))
|
|
557
|
-
continue;
|
|
558
545
|
try {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (!
|
|
546
|
+
const entries = await fs_1.promises.readdir(venvDir, { withFileTypes: true });
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
562
549
|
continue;
|
|
563
|
-
const pythonPath = path.join(
|
|
564
|
-
if (
|
|
550
|
+
const pythonPath = path.join(venvDir, entry.name, 'bin', 'python');
|
|
551
|
+
if (await this.fsExists(pythonPath)) {
|
|
565
552
|
envs.push({
|
|
566
553
|
path: pythonPath,
|
|
567
554
|
envType: 'venv',
|
|
568
|
-
envName,
|
|
555
|
+
envName: entry.name,
|
|
569
556
|
});
|
|
570
557
|
}
|
|
571
558
|
}
|
|
572
559
|
}
|
|
573
|
-
catch
|
|
574
|
-
|
|
560
|
+
catch {
|
|
561
|
+
// venv dir missing/unreadable — nothing to scan
|
|
575
562
|
}
|
|
576
563
|
}
|
|
577
564
|
return envs;
|
|
@@ -579,22 +566,21 @@ class PythonDiscoveryService {
|
|
|
579
566
|
/**
|
|
580
567
|
* Find system Python installations
|
|
581
568
|
*/
|
|
582
|
-
findSystemPythons() {
|
|
569
|
+
async findSystemPythons() {
|
|
583
570
|
const envs = [];
|
|
584
571
|
const seen = new Set();
|
|
585
572
|
for (const pythonPath of this.getSystemPythonPaths()) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
// Resolve symlinks to avoid duplicates
|
|
573
|
+
// Resolve symlinks to avoid duplicates (also serves as the existence check)
|
|
574
|
+
let realPath;
|
|
589
575
|
try {
|
|
590
|
-
|
|
591
|
-
if (seen.has(realPath))
|
|
592
|
-
continue;
|
|
593
|
-
seen.add(realPath);
|
|
576
|
+
realPath = await fs_1.promises.realpath(pythonPath);
|
|
594
577
|
}
|
|
595
578
|
catch {
|
|
596
579
|
continue;
|
|
597
580
|
}
|
|
581
|
+
if (seen.has(realPath))
|
|
582
|
+
continue;
|
|
583
|
+
seen.add(realPath);
|
|
598
584
|
const envType = pythonPath.toLowerCase().includes('homebrew') ||
|
|
599
585
|
pythonPath.includes('/opt/homebrew') ? 'homebrew' : 'system';
|
|
600
586
|
envs.push({
|
|
@@ -618,7 +604,7 @@ class PythonDiscoveryService {
|
|
|
618
604
|
// can't distinguish; resolve symlinks first (e.g. ~/.local/bin/python3 → uv).
|
|
619
605
|
let realPath = candidate.path;
|
|
620
606
|
try {
|
|
621
|
-
realPath =
|
|
607
|
+
realPath = await fs_1.promises.realpath(candidate.path);
|
|
622
608
|
}
|
|
623
609
|
catch { /* keep original */ }
|
|
624
610
|
const envType = this.classifyEnvType(candidate.path, realPath, candidate.envType, isVenv);
|
|
@@ -644,9 +630,9 @@ class PythonDiscoveryService {
|
|
|
644
630
|
// Collect all candidates
|
|
645
631
|
const candidates = [];
|
|
646
632
|
candidates.push(...(await this.findCondaEnvs()));
|
|
647
|
-
candidates.push(...this.findPyenvVersions());
|
|
648
|
-
candidates.push(...this.findVirtualenvs());
|
|
649
|
-
candidates.push(...this.findSystemPythons());
|
|
633
|
+
candidates.push(...(await this.findPyenvVersions()));
|
|
634
|
+
candidates.push(...(await this.findVirtualenvs()));
|
|
635
|
+
candidates.push(...(await this.findSystemPythons()));
|
|
650
636
|
// Enrich in parallel
|
|
651
637
|
const enrichPromises = candidates.map(c => this.enrichEnvironment(c));
|
|
652
638
|
const results = await Promise.all(enrichPromises);
|
|
@@ -732,7 +718,7 @@ class PythonDiscoveryService {
|
|
|
732
718
|
* register-only path.
|
|
733
719
|
*/
|
|
734
720
|
async installKernel(pythonPath, kernelName) {
|
|
735
|
-
if (!
|
|
721
|
+
if (!(await this.fsExists(pythonPath))) {
|
|
736
722
|
throw new types_1.KernelProvisionError(`Python not found: ${pythonPath}`, 'python_not_found');
|
|
737
723
|
}
|
|
738
724
|
const { hasIpykernel, externallyManaged } = await this.probeEnvironment(pythonPath);
|
|
@@ -744,12 +730,11 @@ class PythonDiscoveryService {
|
|
|
744
730
|
`so Nebula can't install it here. Set up ipykernel in an isolated environment, then Register it.`, 'externally_managed', hint || undefined);
|
|
745
731
|
}
|
|
746
732
|
// Writable env without ipykernel: install it (safe — not externally managed).
|
|
733
|
+
// Async spawn (NOT execSync): a pip install can run 30s+, and execSync
|
|
734
|
+
// would freeze the whole server for that long.
|
|
747
735
|
console.log(`Installing ipykernel for ${pythonPath}...`);
|
|
748
736
|
try {
|
|
749
|
-
|
|
750
|
-
timeout: this.kernelInstallTimeoutMs,
|
|
751
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
752
|
-
});
|
|
737
|
+
await this.runCommand(pythonPath, ['-m', 'pip', 'install', 'ipykernel', '-q'], this.kernelInstallTimeoutMs);
|
|
753
738
|
}
|
|
754
739
|
catch (e) {
|
|
755
740
|
const detail = this.errorOutput(e);
|
|
@@ -766,13 +751,10 @@ class PythonDiscoveryService {
|
|
|
766
751
|
const version = await this.getPythonVersion(pythonPath) || '3';
|
|
767
752
|
kernelName = this.generateKernelName(pythonPath, version);
|
|
768
753
|
}
|
|
769
|
-
// Register kernel
|
|
754
|
+
// Register kernel (async spawn — see pip install above)
|
|
770
755
|
console.log(`Registering kernel as ${kernelName}...`);
|
|
771
756
|
try {
|
|
772
|
-
|
|
773
|
-
timeout: this.registrationTimeoutMs,
|
|
774
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
775
|
-
});
|
|
757
|
+
await this.runCommand(pythonPath, ['-m', 'ipykernel', 'install', '--user', '--name', kernelName], this.registrationTimeoutMs);
|
|
776
758
|
}
|
|
777
759
|
catch (e) {
|
|
778
760
|
throw new types_1.KernelProvisionError(`Failed to register kernel: ${this.errorOutput(e) || e}`, 'register_failed');
|
|
@@ -789,6 +771,108 @@ class PythonDiscoveryService {
|
|
|
789
771
|
message: `Successfully registered kernel '${kernelName}'`,
|
|
790
772
|
};
|
|
791
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* Probe a manually-entered interpreter path, classify it (conda-meta →
|
|
776
|
+
* conda, pyvenv.cfg → venv, else system + the usual uv/pixi refinement),
|
|
777
|
+
* and persist it into the discovery cache so it shows up in the picker from
|
|
778
|
+
* now on. VSCode's "Enter interpreter path…" equivalent.
|
|
779
|
+
*/
|
|
780
|
+
async probeAndRemember(pythonPath) {
|
|
781
|
+
if (!(await this.fsExists(pythonPath))) {
|
|
782
|
+
throw new types_1.KernelProvisionError(`Python not found: ${pythonPath}`, 'python_not_found');
|
|
783
|
+
}
|
|
784
|
+
const prefix = (0, conda_locations_1.prefixForPythonExe)(pythonPath);
|
|
785
|
+
let envType = 'system';
|
|
786
|
+
let envName = null;
|
|
787
|
+
if (await (0, conda_locations_1.isCondaEnv)(prefix)) {
|
|
788
|
+
envType = 'conda';
|
|
789
|
+
envName = path.basename(prefix);
|
|
790
|
+
}
|
|
791
|
+
else if (await this.fsExists(path.join(prefix, 'pyvenv.cfg'))) {
|
|
792
|
+
envType = 'venv';
|
|
793
|
+
envName = path.basename(prefix);
|
|
794
|
+
}
|
|
795
|
+
const env = await this.enrichEnvironment({ path: pythonPath, envType, envName });
|
|
796
|
+
if (!env) {
|
|
797
|
+
throw new types_1.KernelProvisionError(`${pythonPath} did not respond to --version — not a working Python interpreter`, 'python_not_found');
|
|
798
|
+
}
|
|
799
|
+
this.cache[pythonPath] = env;
|
|
800
|
+
this.saveToCache(this.cache);
|
|
801
|
+
return env;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Pick ONE installer for ipykernel, up front (VSCode's shape — no fallback
|
|
805
|
+
* chain at run time, so failures are attributable and honest):
|
|
806
|
+
* 1. conda env → a conda-like binary (conda/mamba/micromamba; PATH first,
|
|
807
|
+
* then known roots) with `-p <prefix>` — correct for named, base and
|
|
808
|
+
* path-based envs alike.
|
|
809
|
+
* 2. uv on PATH → `uv pip install --python <exe>` — works on ANY env,
|
|
810
|
+
* including conda envs without a conda binary and envs without pip.
|
|
811
|
+
* 3. the env's own pip.
|
|
812
|
+
*/
|
|
813
|
+
async planIpykernelInstall(pythonPath) {
|
|
814
|
+
const ctx = this.condaContext();
|
|
815
|
+
const prefix = (0, conda_locations_1.prefixForPythonExe)(pythonPath);
|
|
816
|
+
if (await (0, conda_locations_1.isCondaEnv)(prefix)) {
|
|
817
|
+
const bins = await this.condaBinaryFinder(ctx);
|
|
818
|
+
if (bins.length > 0) {
|
|
819
|
+
return { kind: 'conda', argv: [bins[0], 'install', '-p', prefix, 'ipykernel', '-y'] };
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const uv = await (0, conda_locations_1.findExecutableOnPath)('uv', ctx);
|
|
823
|
+
if (uv) {
|
|
824
|
+
return { kind: 'uv', argv: [uv, 'pip', 'install', '--python', pythonPath, 'ipykernel'] };
|
|
825
|
+
}
|
|
826
|
+
return { kind: 'pip', argv: [pythonPath, '-m', 'pip', 'install', 'ipykernel'] };
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Install ipykernel into an environment with the planned installer, then
|
|
830
|
+
* VERIFY it actually became importable. Refuses PEP 668 externally-managed
|
|
831
|
+
* interpreters up front (with guidance). Failure carries the installer's
|
|
832
|
+
* output — no silent fallback to a different installer.
|
|
833
|
+
*/
|
|
834
|
+
async installIpykernel(pythonPath, onOutput) {
|
|
835
|
+
if (!(await this.fsExists(pythonPath))) {
|
|
836
|
+
throw new types_1.KernelProvisionError(`Python not found: ${pythonPath}`, 'python_not_found');
|
|
837
|
+
}
|
|
838
|
+
const before = await this.probeEnvironment(pythonPath);
|
|
839
|
+
if (before.hasIpykernel) {
|
|
840
|
+
return { installer: 'none', message: 'ipykernel is already installed' };
|
|
841
|
+
}
|
|
842
|
+
const cached = this.cache[pythonPath];
|
|
843
|
+
const hint = this.buildInstallHint(pythonPath, cached?.envType ?? 'system', cached?.envName ?? null, false, before.externallyManaged);
|
|
844
|
+
if (before.externallyManaged) {
|
|
845
|
+
throw new types_1.KernelProvisionError(`This Python is externally managed (PEP 668) — Nebula won't install into it. ` +
|
|
846
|
+
`Set up ipykernel in an isolated environment instead.`, 'externally_managed', hint || undefined);
|
|
847
|
+
}
|
|
848
|
+
const plan = await this.planIpykernelInstall(pythonPath);
|
|
849
|
+
console.log(`Installing ipykernel via ${plan.kind}: ${plan.argv.join(' ')}`);
|
|
850
|
+
// Echo the exact command first so the user sees WHAT ran, then live output.
|
|
851
|
+
onOutput?.(`$ ${plan.argv.join(' ')}\n`);
|
|
852
|
+
try {
|
|
853
|
+
await this.runCommand(plan.argv[0], plan.argv.slice(1), this.kernelInstallTimeoutMs, onOutput);
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
const detail = this.errorOutput(e);
|
|
857
|
+
if (this.isExternallyManagedError(detail)) {
|
|
858
|
+
throw new types_1.KernelProvisionError('This Python blocks package installation (PEP 668). Set up ipykernel in an isolated environment instead.', 'externally_managed', hint || undefined);
|
|
859
|
+
}
|
|
860
|
+
// Honest failure: name the installer and surface the tail of its output.
|
|
861
|
+
const tail = detail.trim().split('\n').slice(-8).join('\n').slice(-600);
|
|
862
|
+
throw new types_1.KernelProvisionError(`${plan.kind} failed to install ipykernel${tail ? `:\n${tail}` : ''}`, 'install_failed', hint || undefined);
|
|
863
|
+
}
|
|
864
|
+
const after = await this.probeEnvironment(pythonPath);
|
|
865
|
+
if (!after.hasIpykernel) {
|
|
866
|
+
throw new types_1.KernelProvisionError(`${plan.kind} reported success but ipykernel is still not importable in ${pythonPath}`, 'install_failed', hint || undefined);
|
|
867
|
+
}
|
|
868
|
+
// Reflect reality in the cache so the picker updates without a rescan.
|
|
869
|
+
if (this.cache[pythonPath]) {
|
|
870
|
+
this.cache[pythonPath].hasIpykernel = true;
|
|
871
|
+
this.cache[pythonPath].installHint = null;
|
|
872
|
+
this.saveToCache(this.cache);
|
|
873
|
+
}
|
|
874
|
+
return { installer: plan.kind, message: `Installed ipykernel via ${plan.kind}` };
|
|
875
|
+
}
|
|
792
876
|
/** Extract combined stdout+stderr text from a child_process error. */
|
|
793
877
|
errorOutput(e) {
|
|
794
878
|
const err = e;
|
|
@@ -43,6 +43,5 @@ export interface CacheInfo {
|
|
|
43
43
|
export declare const DEFAULT_CACHE_TTL_HOURS = 24;
|
|
44
44
|
export declare const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 5000;
|
|
45
45
|
export declare const DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS = 10000;
|
|
46
|
-
export declare const DEFAULT_CONDA_LIST_TIMEOUT_MS = 30000;
|
|
47
46
|
export declare const DEFAULT_KERNEL_INSTALL_TIMEOUT_MS = 120000;
|
|
48
47
|
export declare const DEFAULT_REGISTRATION_TIMEOUT_MS = 60000;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Python Discovery Service Types
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.DEFAULT_REGISTRATION_TIMEOUT_MS = exports.DEFAULT_KERNEL_INSTALL_TIMEOUT_MS = exports.
|
|
6
|
+
exports.DEFAULT_REGISTRATION_TIMEOUT_MS = exports.DEFAULT_KERNEL_INSTALL_TIMEOUT_MS = exports.DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS = exports.DEFAULT_VERSION_CHECK_TIMEOUT_MS = exports.DEFAULT_CACHE_TTL_HOURS = exports.KernelProvisionError = void 0;
|
|
7
7
|
class KernelProvisionError extends Error {
|
|
8
8
|
code;
|
|
9
9
|
installHint;
|
|
@@ -19,6 +19,5 @@ exports.KernelProvisionError = KernelProvisionError;
|
|
|
19
19
|
exports.DEFAULT_CACHE_TTL_HOURS = 24;
|
|
20
20
|
exports.DEFAULT_VERSION_CHECK_TIMEOUT_MS = 5000;
|
|
21
21
|
exports.DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS = 10000;
|
|
22
|
-
exports.DEFAULT_CONDA_LIST_TIMEOUT_MS = 30000;
|
|
23
22
|
exports.DEFAULT_KERNEL_INSTALL_TIMEOUT_MS = 120000;
|
|
24
23
|
exports.DEFAULT_REGISTRATION_TIMEOUT_MS = 60000;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type EnvironmentKind = 'local' | 'cluster' | 'server';
|
|
2
|
+
export interface EnvironmentInfo {
|
|
3
|
+
kind: EnvironmentKind;
|
|
4
|
+
/** 'high' = trust it; 'ambiguous' = the UI should ask the user once. */
|
|
5
|
+
confidence: 'high' | 'ambiguous';
|
|
6
|
+
/** Human-readable one-liner for the settings UI ("SLURM detected", …). */
|
|
7
|
+
reason: string;
|
|
8
|
+
hostname: string;
|
|
9
|
+
platform: NodeJS.Platform;
|
|
10
|
+
scheduler: string | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function getEnvironment(): EnvironmentInfo;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getEnvironment = getEnvironment;
|
|
37
|
+
/**
|
|
38
|
+
* Environment awareness — where is this Nebula server running relative to the user?
|
|
39
|
+
*
|
|
40
|
+
* The "run the agent / autocomplete on this server vs. my machine" choice only
|
|
41
|
+
* makes sense when the server ISN'T the user's own machine. This detects the
|
|
42
|
+
* server's context so the UI can collapse that choice when it's meaningless
|
|
43
|
+
* (a local install) and surface it when it matters (a cluster/remote server).
|
|
44
|
+
*
|
|
45
|
+
* kind = 'cluster' → a scheduler (SLURM) is present: shared HPC login/compute node
|
|
46
|
+
* kind = 'local' → a personal machine (desktop OS / desktop session)
|
|
47
|
+
* kind = 'server' → some other remote host (headless, reached over ssh)
|
|
48
|
+
*
|
|
49
|
+
* Signals, most→least decisive:
|
|
50
|
+
* NEBULA_ENV override → whatever it says (confidence: high)
|
|
51
|
+
* scheduler present → cluster (high)
|
|
52
|
+
* darwin → local (high)
|
|
53
|
+
* started inside ssh → server (high — the user reaches this box remotely)
|
|
54
|
+
* desktop session → local (high — DISPLAY/Wayland ⇒ someone sits here)
|
|
55
|
+
* headless linux, none of the above → local (safe default) but confidence:
|
|
56
|
+
* 'ambiguous' — the UI asks the user ONCE and stores their answer.
|
|
57
|
+
*
|
|
58
|
+
* Override with NEBULA_ENV=local|cluster|server, or per-user in the client
|
|
59
|
+
* settings (environmentOverride) — the client override always wins in the UI.
|
|
60
|
+
*/
|
|
61
|
+
const os = __importStar(require("os"));
|
|
62
|
+
const allocation_service_1 = require("./scheduler/allocation-service");
|
|
63
|
+
function detect(scheduler, platform) {
|
|
64
|
+
const override = (process.env.NEBULA_ENV || '').toLowerCase();
|
|
65
|
+
if (override === 'local' || override === 'cluster' || override === 'server') {
|
|
66
|
+
return { kind: override, confidence: 'high', reason: `NEBULA_ENV=${override}` };
|
|
67
|
+
}
|
|
68
|
+
if (scheduler) {
|
|
69
|
+
return { kind: 'cluster', confidence: 'high', reason: `${scheduler} scheduler detected` };
|
|
70
|
+
}
|
|
71
|
+
if (platform === 'darwin') {
|
|
72
|
+
return { kind: 'local', confidence: 'high', reason: 'macOS — a personal machine' };
|
|
73
|
+
}
|
|
74
|
+
// Started from inside an ssh session → the user reaches this box remotely.
|
|
75
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
|
|
76
|
+
return { kind: 'server', confidence: 'high', reason: 'server was started over SSH' };
|
|
77
|
+
}
|
|
78
|
+
// A desktop session on Linux → someone sits at this machine.
|
|
79
|
+
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'x11' || process.env.XDG_SESSION_TYPE === 'wayland') {
|
|
80
|
+
return { kind: 'local', confidence: 'high', reason: 'Linux desktop session detected' };
|
|
81
|
+
}
|
|
82
|
+
// Headless Linux, no scheduler, not launched over ssh (e.g. systemd unit,
|
|
83
|
+
// container, tmux that outlived its ssh). Could be a workstation or a remote
|
|
84
|
+
// box — default local (never nag a single-user install) but flag it so the
|
|
85
|
+
// UI can ask the user once instead of silently guessing.
|
|
86
|
+
return {
|
|
87
|
+
kind: 'local',
|
|
88
|
+
confidence: 'ambiguous',
|
|
89
|
+
reason: 'headless Linux with no scheduler — could be your workstation or a remote box',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function getEnvironment() {
|
|
93
|
+
// NO caching: scheduler detection completes asynchronously after boot, and
|
|
94
|
+
// the first /api/health frequently arrives before it (startup health polls,
|
|
95
|
+
// the browser tab reconnecting) — caching that first answer froze
|
|
96
|
+
// kind='local' onto SLURM login nodes for the server's whole lifetime.
|
|
97
|
+
// Recomputing is two method calls + os lookups; the answer self-corrects
|
|
98
|
+
// the moment scheduler detection lands.
|
|
99
|
+
const scheduler = allocation_service_1.allocationService.isEnabled()
|
|
100
|
+
? (allocation_service_1.allocationService.getScheduler()?.name ?? null)
|
|
101
|
+
: null;
|
|
102
|
+
const platform = os.platform();
|
|
103
|
+
return { ...detect(scheduler, platform), hostname: os.hostname(), platform, scheduler };
|
|
104
|
+
}
|