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.
Files changed (100) hide show
  1. package/README.md +35 -20
  2. package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
  3. package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
  4. package/dist/assets/index-DK4nS74r.js +829 -0
  5. package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
  6. package/dist/assets/index-DzjpmUak.css +32 -0
  7. package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
  8. package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/node-server/dist/auth/auth-middleware.js +22 -4
  11. package/node-server/dist/auth/auth-service.js +37 -7
  12. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  13. package/node-server/dist/cluster/client-registration.js +35 -2
  14. package/node-server/dist/cluster/kernel-proxy.js +19 -3
  15. package/node-server/dist/cluster/server-registry.d.ts +9 -0
  16. package/node-server/dist/cluster/server-registry.js +26 -0
  17. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  18. package/node-server/dist/discovery/conda-locations.js +427 -0
  19. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  20. package/node-server/dist/discovery/discovery-service.js +211 -127
  21. package/node-server/dist/discovery/types.d.ts +0 -1
  22. package/node-server/dist/discovery/types.js +1 -2
  23. package/node-server/dist/environment.d.ts +12 -0
  24. package/node-server/dist/environment.js +104 -0
  25. package/node-server/dist/fs/fs-service.d.ts +31 -1
  26. package/node-server/dist/fs/fs-service.js +160 -28
  27. package/node-server/dist/fs/types.d.ts +9 -0
  28. package/node-server/dist/fs/types.js +8 -0
  29. package/node-server/dist/index.js +71 -4
  30. package/node-server/dist/kernel/kernel-service.d.ts +17 -0
  31. package/node-server/dist/kernel/kernel-service.js +72 -4
  32. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  33. package/node-server/dist/kernel/kernelspec.js +75 -4
  34. package/node-server/dist/notebook/headless-handler.d.ts +10 -0
  35. package/node-server/dist/notebook/headless-handler.js +55 -5
  36. package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
  37. package/node-server/dist/notebook/undoRedoManager.js +39 -16
  38. package/node-server/dist/private-tmp.d.ts +14 -0
  39. package/node-server/dist/private-tmp.js +70 -0
  40. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  41. package/node-server/dist/routes/autocomplete.js +294 -0
  42. package/node-server/dist/routes/fs.js +21 -4
  43. package/node-server/dist/routes/kernel.js +45 -2
  44. package/node-server/dist/routes/notebook.js +8 -2
  45. package/node-server/dist/routes/python.d.ts +1 -2
  46. package/node-server/dist/routes/python.js +139 -3
  47. package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
  48. package/node-server/dist/scheduler/allocation-service.js +124 -8
  49. package/node-server/dist/scheduler/mock-scheduler.js +7 -3
  50. package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
  51. package/node-server/dist/scheduler/types.d.ts +3 -2
  52. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  53. package/node-server/dist/terminal/agent-registry.js +246 -0
  54. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  55. package/node-server/dist/terminal/binding-store.js +0 -0
  56. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  57. package/node-server/dist/terminal/pty-manager.js +130 -19
  58. package/node-server/dist/terminal/server.js +112 -21
  59. package/node-server/dist/terminal/types.d.ts +2 -0
  60. package/node-server/package.json +1 -0
  61. package/node_modules/nebula-autocomplete/README.md +145 -0
  62. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  63. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  64. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  65. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  66. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  67. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  68. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  69. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  70. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  71. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  72. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  73. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  74. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  75. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  76. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  77. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  78. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  79. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  80. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  81. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  82. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  83. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  84. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  85. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  86. package/node_modules/nebula-autocomplete/package.json +58 -0
  87. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  88. package/node_modules/nebula-autocomplete/server.js +3 -0
  89. package/package.json +17 -5
  90. package/scripts/postinstall.cjs +25 -5
  91. package/dist/assets/index-7-YBurka.js +0 -716
  92. package/dist/assets/index-CFBUnxSZ.css +0 -32
  93. package/node-server/dist/llm/index.d.ts +0 -5
  94. package/node-server/dist/llm/index.js +0 -21
  95. package/node-server/dist/llm/llm-service.d.ts +0 -77
  96. package/node-server/dist/llm/llm-service.js +0 -454
  97. package/node-server/dist/llm/types.d.ts +0 -40
  98. package/node-server/dist/llm/types.js +0 -15
  99. package/node-server/dist/routes/llm.d.ts +0 -8
  100. 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
- return `conda install -n ${envName || 'base'} ipykernel`;
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
- * Find conda environments
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
- // 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))
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
- 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))
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
- // 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
- }
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
- for (const versionDir of fs.readdirSync(pyenvRoot)) {
532
- const versionPath = path.join(pyenvRoot, versionDir);
533
- if (!fs.statSync(versionPath).isDirectory())
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(versionPath, 'bin', 'python');
536
- if (fs.existsSync(pythonPath)) {
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: versionDir,
529
+ envName: entry.name,
541
530
  });
542
531
  }
543
532
  }
544
533
  }
545
- catch (e) {
546
- console.warn(`Error scanning pyenv versions: ${e}`);
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
- for (const envName of fs.readdirSync(venvDir)) {
560
- const envPath = path.join(venvDir, envName);
561
- if (!fs.statSync(envPath).isDirectory())
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(envPath, 'bin', 'python');
564
- if (fs.existsSync(pythonPath)) {
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 (e) {
574
- console.warn(`Error scanning virtualenvs: ${e}`);
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
- if (!fs.existsSync(pythonPath))
587
- continue;
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
- const realPath = fs.realpathSync(pythonPath);
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 = fs.realpathSync(candidate.path);
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 (!fs.existsSync(pythonPath)) {
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
- (0, child_process_1.execSync)(`"${pythonPath}" -m pip install ipykernel -q`, {
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
- (0, child_process_1.execSync)(`"${pythonPath}" -m ipykernel install --user --name "${kernelName}"`, {
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.DEFAULT_CONDA_LIST_TIMEOUT_MS = exports.DEFAULT_IPYKERNEL_CHECK_TIMEOUT_MS = exports.DEFAULT_VERSION_CHECK_TIMEOUT_MS = exports.DEFAULT_CACHE_TTL_HOURS = exports.KernelProvisionError = void 0;
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
+ }