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.
- package/README.md +222 -0
- package/bin/nebula-notebook.js +33 -0
- package/dist/assets/index-C1h_sArD.css +32 -0
- package/dist/assets/index-CDSTBon8.js +658 -0
- package/dist/favicon.svg +11 -0
- package/dist/index.html +73 -0
- package/node-server/dist/app.d.ts +5 -0
- package/node-server/dist/app.js +38 -0
- package/node-server/dist/auth/auth-middleware.d.ts +24 -0
- package/node-server/dist/auth/auth-middleware.js +276 -0
- package/node-server/dist/auth/auth-service.d.ts +84 -0
- package/node-server/dist/auth/auth-service.js +265 -0
- package/node-server/dist/auth/index.d.ts +3 -0
- package/node-server/dist/auth/index.js +8 -0
- package/node-server/dist/cluster/client-registration.d.ts +43 -0
- package/node-server/dist/cluster/client-registration.js +217 -0
- package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
- package/node-server/dist/cluster/cluster-secret.js +90 -0
- package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
- package/node-server/dist/cluster/kernel-proxy.js +361 -0
- package/node-server/dist/cluster/server-registry.d.ts +109 -0
- package/node-server/dist/cluster/server-registry.js +217 -0
- package/node-server/dist/config/output-limits.d.ts +7 -0
- package/node-server/dist/config/output-limits.js +10 -0
- package/node-server/dist/discovery/discovery-service.d.ts +198 -0
- package/node-server/dist/discovery/discovery-service.js +811 -0
- package/node-server/dist/discovery/index.d.ts +5 -0
- package/node-server/dist/discovery/index.js +21 -0
- package/node-server/dist/discovery/types.d.ts +48 -0
- package/node-server/dist/discovery/types.js +24 -0
- package/node-server/dist/fs/fs-service.d.ts +218 -0
- package/node-server/dist/fs/fs-service.js +1422 -0
- package/node-server/dist/fs/index.d.ts +5 -0
- package/node-server/dist/fs/index.js +21 -0
- package/node-server/dist/fs/types.d.ts +132 -0
- package/node-server/dist/fs/types.js +5 -0
- package/node-server/dist/index.d.ts +13 -0
- package/node-server/dist/index.js +556 -0
- package/node-server/dist/kernel/default-kernel.d.ts +5 -0
- package/node-server/dist/kernel/default-kernel.js +138 -0
- package/node-server/dist/kernel/index.d.ts +7 -0
- package/node-server/dist/kernel/index.js +23 -0
- package/node-server/dist/kernel/kernel-service.d.ts +290 -0
- package/node-server/dist/kernel/kernel-service.js +1714 -0
- package/node-server/dist/kernel/kernelspec.d.ts +29 -0
- package/node-server/dist/kernel/kernelspec.js +210 -0
- package/node-server/dist/kernel/session-store.d.ts +87 -0
- package/node-server/dist/kernel/session-store.js +303 -0
- package/node-server/dist/kernel/types.d.ts +143 -0
- package/node-server/dist/kernel/types.js +17 -0
- package/node-server/dist/llm/index.d.ts +5 -0
- package/node-server/dist/llm/index.js +21 -0
- package/node-server/dist/llm/llm-service.d.ts +77 -0
- package/node-server/dist/llm/llm-service.js +454 -0
- package/node-server/dist/llm/types.d.ts +40 -0
- package/node-server/dist/llm/types.js +15 -0
- package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
- package/node-server/dist/notebook/cell-metadata.js +76 -0
- package/node-server/dist/notebook/headless-handler.d.ts +127 -0
- package/node-server/dist/notebook/headless-handler.js +1530 -0
- package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
- package/node-server/dist/notebook/notebook-websocket.js +103 -0
- package/node-server/dist/notebook/operation-router.d.ts +115 -0
- package/node-server/dist/notebook/operation-router.js +641 -0
- package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
- package/node-server/dist/notebook/undoRedoManager.js +558 -0
- package/node-server/dist/output/display-data.d.ts +14 -0
- package/node-server/dist/output/display-data.js +134 -0
- package/node-server/dist/resources/resource-service.d.ts +69 -0
- package/node-server/dist/resources/resource-service.js +363 -0
- package/node-server/dist/routes/auth.d.ts +5 -0
- package/node-server/dist/routes/auth.js +61 -0
- package/node-server/dist/routes/cluster.d.ts +7 -0
- package/node-server/dist/routes/cluster.js +94 -0
- package/node-server/dist/routes/fs.d.ts +7 -0
- package/node-server/dist/routes/fs.js +392 -0
- package/node-server/dist/routes/kernel.d.ts +13 -0
- package/node-server/dist/routes/kernel.js +637 -0
- package/node-server/dist/routes/llm.d.ts +8 -0
- package/node-server/dist/routes/llm.js +105 -0
- package/node-server/dist/routes/notebook.d.ts +10 -0
- package/node-server/dist/routes/notebook.js +335 -0
- package/node-server/dist/routes/python.d.ts +8 -0
- package/node-server/dist/routes/python.js +187 -0
- package/node-server/dist/routes/resources.d.ts +7 -0
- package/node-server/dist/routes/resources.js +77 -0
- package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
- package/node-server/dist/scripts/show-auth-qr.js +81 -0
- package/node-server/dist/terminal/pty-manager.d.ts +100 -0
- package/node-server/dist/terminal/pty-manager.js +246 -0
- package/node-server/dist/terminal/server.d.ts +19 -0
- package/node-server/dist/terminal/server.js +254 -0
- package/node-server/dist/terminal/types.d.ts +50 -0
- package/node-server/dist/terminal/types.js +9 -0
- package/node-server/package.json +45 -0
- package/package.json +99 -0
- 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();
|