nodepyx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +399 -0
  3. package/binding.gyp +73 -0
  4. package/dist/core/PyCallable.d.ts +65 -0
  5. package/dist/core/PyCallable.d.ts.map +1 -0
  6. package/dist/core/PyCallable.js +109 -0
  7. package/dist/core/PyCallable.js.map +1 -0
  8. package/dist/core/PyContext.d.ts +76 -0
  9. package/dist/core/PyContext.d.ts.map +1 -0
  10. package/dist/core/PyContext.js +228 -0
  11. package/dist/core/PyContext.js.map +1 -0
  12. package/dist/core/PyIterator.d.ts +84 -0
  13. package/dist/core/PyIterator.d.ts.map +1 -0
  14. package/dist/core/PyIterator.js +243 -0
  15. package/dist/core/PyIterator.js.map +1 -0
  16. package/dist/core/PyModule.d.ts +55 -0
  17. package/dist/core/PyModule.d.ts.map +1 -0
  18. package/dist/core/PyModule.js +172 -0
  19. package/dist/core/PyModule.js.map +1 -0
  20. package/dist/core/PyProxy.d.ts +65 -0
  21. package/dist/core/PyProxy.d.ts.map +1 -0
  22. package/dist/core/PyProxy.js +483 -0
  23. package/dist/core/PyProxy.js.map +1 -0
  24. package/dist/core/PyRuntime.d.ts +105 -0
  25. package/dist/core/PyRuntime.d.ts.map +1 -0
  26. package/dist/core/PyRuntime.js +438 -0
  27. package/dist/core/PyRuntime.js.map +1 -0
  28. package/dist/env/CondaManager.d.ts +118 -0
  29. package/dist/env/CondaManager.d.ts.map +1 -0
  30. package/dist/env/CondaManager.js +401 -0
  31. package/dist/env/CondaManager.js.map +1 -0
  32. package/dist/env/PackageInstaller.d.ts +233 -0
  33. package/dist/env/PackageInstaller.d.ts.map +1 -0
  34. package/dist/env/PackageInstaller.js +609 -0
  35. package/dist/env/PackageInstaller.js.map +1 -0
  36. package/dist/env/PythonDetector.d.ts +103 -0
  37. package/dist/env/PythonDetector.d.ts.map +1 -0
  38. package/dist/env/PythonDetector.js +381 -0
  39. package/dist/env/PythonDetector.js.map +1 -0
  40. package/dist/env/VenvManager.d.ts +117 -0
  41. package/dist/env/VenvManager.d.ts.map +1 -0
  42. package/dist/env/VenvManager.js +331 -0
  43. package/dist/env/VenvManager.js.map +1 -0
  44. package/dist/index.d.ts +169 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +393 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/plugins/Plugin.interface.d.ts +41 -0
  49. package/dist/plugins/Plugin.interface.d.ts.map +1 -0
  50. package/dist/plugins/Plugin.interface.js +12 -0
  51. package/dist/plugins/Plugin.interface.js.map +1 -0
  52. package/dist/plugins/PluginManager.d.ts +26 -0
  53. package/dist/plugins/PluginManager.d.ts.map +1 -0
  54. package/dist/plugins/PluginManager.js +174 -0
  55. package/dist/plugins/PluginManager.js.map +1 -0
  56. package/dist/plugins/builtin/NumpyPlugin.d.ts +17 -0
  57. package/dist/plugins/builtin/NumpyPlugin.d.ts.map +1 -0
  58. package/dist/plugins/builtin/NumpyPlugin.js +41 -0
  59. package/dist/plugins/builtin/NumpyPlugin.js.map +1 -0
  60. package/dist/plugins/builtin/PandasPlugin.d.ts +19 -0
  61. package/dist/plugins/builtin/PandasPlugin.d.ts.map +1 -0
  62. package/dist/plugins/builtin/PandasPlugin.js +57 -0
  63. package/dist/plugins/builtin/PandasPlugin.js.map +1 -0
  64. package/dist/plugins/builtin/TorchPlugin.d.ts +23 -0
  65. package/dist/plugins/builtin/TorchPlugin.d.ts.map +1 -0
  66. package/dist/plugins/builtin/TorchPlugin.js +50 -0
  67. package/dist/plugins/builtin/TorchPlugin.js.map +1 -0
  68. package/dist/plugins/index.d.ts +7 -0
  69. package/dist/plugins/index.d.ts.map +1 -0
  70. package/dist/plugins/index.js +12 -0
  71. package/dist/plugins/index.js.map +1 -0
  72. package/dist/serialization/DataFrameBridge.d.ts +141 -0
  73. package/dist/serialization/DataFrameBridge.d.ts.map +1 -0
  74. package/dist/serialization/DataFrameBridge.js +355 -0
  75. package/dist/serialization/DataFrameBridge.js.map +1 -0
  76. package/dist/serialization/MsgPackSerializer.d.ts +45 -0
  77. package/dist/serialization/MsgPackSerializer.d.ts.map +1 -0
  78. package/dist/serialization/MsgPackSerializer.js +242 -0
  79. package/dist/serialization/MsgPackSerializer.js.map +1 -0
  80. package/dist/serialization/NumpyBridge.d.ts +96 -0
  81. package/dist/serialization/NumpyBridge.d.ts.map +1 -0
  82. package/dist/serialization/NumpyBridge.js +323 -0
  83. package/dist/serialization/NumpyBridge.js.map +1 -0
  84. package/dist/serialization/Serializer.d.ts +78 -0
  85. package/dist/serialization/Serializer.d.ts.map +1 -0
  86. package/dist/serialization/Serializer.js +281 -0
  87. package/dist/serialization/Serializer.js.map +1 -0
  88. package/dist/types/PythonTypeMapper.d.ts +87 -0
  89. package/dist/types/PythonTypeMapper.d.ts.map +1 -0
  90. package/dist/types/PythonTypeMapper.js +449 -0
  91. package/dist/types/PythonTypeMapper.js.map +1 -0
  92. package/dist/types/StubCache.d.ts +109 -0
  93. package/dist/types/StubCache.d.ts.map +1 -0
  94. package/dist/types/StubCache.js +333 -0
  95. package/dist/types/StubCache.js.map +1 -0
  96. package/dist/types/TypeGenerator.d.ts +139 -0
  97. package/dist/types/TypeGenerator.d.ts.map +1 -0
  98. package/dist/types/TypeGenerator.js +372 -0
  99. package/dist/types/TypeGenerator.js.map +1 -0
  100. package/dist/types/addon.d.ts +114 -0
  101. package/dist/types/addon.d.ts.map +1 -0
  102. package/dist/types/addon.js +32 -0
  103. package/dist/types/addon.js.map +1 -0
  104. package/dist/types/config.d.ts +175 -0
  105. package/dist/types/config.d.ts.map +1 -0
  106. package/dist/types/config.js +35 -0
  107. package/dist/types/config.js.map +1 -0
  108. package/dist/types/index.d.ts +10 -0
  109. package/dist/types/index.d.ts.map +1 -0
  110. package/dist/types/index.js +12 -0
  111. package/dist/types/index.js.map +1 -0
  112. package/dist/types/python.d.ts +235 -0
  113. package/dist/types/python.d.ts.map +1 -0
  114. package/dist/types/python.js +7 -0
  115. package/dist/types/python.js.map +1 -0
  116. package/dist/utils/ErrorTranslator.d.ts +83 -0
  117. package/dist/utils/ErrorTranslator.d.ts.map +1 -0
  118. package/dist/utils/ErrorTranslator.js +210 -0
  119. package/dist/utils/ErrorTranslator.js.map +1 -0
  120. package/dist/utils/Logger.d.ts +27 -0
  121. package/dist/utils/Logger.d.ts.map +1 -0
  122. package/dist/utils/Logger.js +115 -0
  123. package/dist/utils/Logger.js.map +1 -0
  124. package/dist/utils/MemoryMonitor.d.ts +44 -0
  125. package/dist/utils/MemoryMonitor.d.ts.map +1 -0
  126. package/dist/utils/MemoryMonitor.js +143 -0
  127. package/dist/utils/MemoryMonitor.js.map +1 -0
  128. package/package.json +177 -0
  129. package/python/error_handler.py +433 -0
  130. package/python/nodepyx_runtime.py +575 -0
  131. package/python/serializer.py +379 -0
  132. package/python/type_inspector.py +288 -0
  133. package/scripts/build-native.js +68 -0
  134. package/scripts/download-prebuilds.js +99 -0
  135. package/scripts/generate-stubs.js +405 -0
  136. package/scripts/install.js +260 -0
  137. package/src/core/PyCallable.ts +137 -0
  138. package/src/core/PyContext.ts +296 -0
  139. package/src/core/PyIterator.ts +294 -0
  140. package/src/core/PyModule.ts +194 -0
  141. package/src/core/PyProxy.ts +605 -0
  142. package/src/core/PyRuntime.ts +504 -0
  143. package/src/env/CondaManager.ts +451 -0
  144. package/src/env/PackageInstaller.ts +738 -0
  145. package/src/env/PythonDetector.ts +414 -0
  146. package/src/env/VenvManager.ts +396 -0
  147. package/src/index.ts +425 -0
  148. package/src/native/gil_guard.cpp +26 -0
  149. package/src/native/gil_guard.h +175 -0
  150. package/src/native/nodepyx_addon.cpp +886 -0
  151. package/src/native/python_bridge.cpp +790 -0
  152. package/src/native/python_bridge.h +257 -0
  153. package/src/native/thread_pool.cpp +336 -0
  154. package/src/native/thread_pool.h +175 -0
  155. package/src/native/type_converter.cpp +901 -0
  156. package/src/native/type_converter.h +272 -0
  157. package/src/nextjs/PyProvider.tsx +123 -0
  158. package/src/nextjs/index.ts +21 -0
  159. package/src/nextjs/usePython.ts +106 -0
  160. package/src/nextjs/withnodepyx.ts +88 -0
  161. package/src/plugins/Plugin.interface.ts +51 -0
  162. package/src/plugins/PluginManager.ts +155 -0
  163. package/src/plugins/builtin/NumpyPlugin.ts +36 -0
  164. package/src/plugins/builtin/PandasPlugin.ts +49 -0
  165. package/src/plugins/builtin/TorchPlugin.ts +56 -0
  166. package/src/plugins/index.ts +7 -0
  167. package/src/serialization/DataFrameBridge.ts +398 -0
  168. package/src/serialization/MsgPackSerializer.ts +220 -0
  169. package/src/serialization/NumpyBridge.ts +332 -0
  170. package/src/serialization/Serializer.ts +320 -0
  171. package/src/types/PythonTypeMapper.ts +495 -0
  172. package/src/types/StubCache.ts +340 -0
  173. package/src/types/TypeGenerator.ts +491 -0
  174. package/src/types/addon.ts +170 -0
  175. package/src/types/config.ts +226 -0
  176. package/src/types/index.ts +55 -0
  177. package/src/types/python.ts +309 -0
  178. package/src/types/stubs/numpy.d.ts +441 -0
  179. package/src/types/stubs/pandas.d.ts +575 -0
  180. package/src/types/stubs/sklearn.d.ts +728 -0
  181. package/src/types/stubs/torch.d.ts +694 -0
  182. package/src/utils/ErrorTranslator.ts +220 -0
  183. package/src/utils/Logger.ts +119 -0
  184. package/src/utils/MemoryMonitor.ts +175 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * nodepyx — CondaManager
3
+ * Manages Conda environments for Python package isolation.
4
+ *
5
+ * Supports:
6
+ * - Detecting an existing conda environment by name
7
+ * - Creating new conda environments
8
+ * - Installing packages into a conda environment (conda + pip fallback)
9
+ * - Returning the Python executable path inside a conda env
10
+ *
11
+ * Detection order for conda executable:
12
+ * 1. CONDA_EXE environment variable
13
+ * 2. MAMBA_EXE (uses mamba as a drop-in replacement)
14
+ * 3. condaPath from config
15
+ * 4. 'conda' on PATH
16
+ * 5. Common installation locations
17
+ */
18
+
19
+ import { execSync, spawnSync } from 'child_process';
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as os from 'os';
23
+ import { Logger } from '../utils/Logger';
24
+ import type { CondaConfig } from '../types/config';
25
+
26
+ const logger = new Logger('CondaManager');
27
+
28
+ // ─── Types ─────────────────────────────────────────────────────────────────
29
+
30
+ export interface CondaEnvInfo {
31
+ /** Environment name */
32
+ name: string;
33
+ /** Absolute prefix path of the environment */
34
+ prefix: string;
35
+ /** Python executable inside the env */
36
+ pythonExecutable: string;
37
+ /** Whether this is the currently active environment */
38
+ isActive: boolean;
39
+ }
40
+
41
+ export interface CondaSetupResult {
42
+ /** Absolute path to Python executable */
43
+ pythonExecutable: string;
44
+ /** Whether the env was newly created */
45
+ created: boolean;
46
+ /** Whether packages were installed */
47
+ packagesInstalled: boolean;
48
+ /** Environment info */
49
+ envInfo: CondaEnvInfo;
50
+ }
51
+
52
+ export interface CondaManagerOptions {
53
+ /** Conda config from nodepyxConfig */
54
+ conda: CondaConfig;
55
+ /** Timeout for conda operations (ms). Default: 300000 (5 min) */
56
+ timeout?: number;
57
+ /** Use mamba instead of conda if available. Default: true */
58
+ preferMamba?: boolean;
59
+ }
60
+
61
+ // ─── Common conda installation paths ─────────────────────────────────────────
62
+
63
+ const CONDA_LOCATIONS_LINUX = [
64
+ '/opt/conda/bin/conda',
65
+ `${os.homedir()}/anaconda3/bin/conda`,
66
+ `${os.homedir()}/miniconda3/bin/conda`,
67
+ `${os.homedir()}/miniconda/bin/conda`,
68
+ '/usr/local/bin/conda',
69
+ ];
70
+
71
+ const CONDA_LOCATIONS_DARWIN = [
72
+ `${os.homedir()}/opt/anaconda3/bin/conda`,
73
+ `${os.homedir()}/opt/miniconda3/bin/conda`,
74
+ '/opt/homebrew/bin/conda',
75
+ `/opt/conda/bin/conda`,
76
+ ];
77
+
78
+ const CONDA_LOCATIONS_WIN = [
79
+ `${process.env.USERPROFILE}\\Anaconda3\\Scripts\\conda.exe`,
80
+ `${process.env.USERPROFILE}\\Miniconda3\\Scripts\\conda.exe`,
81
+ `C:\\Anaconda3\\Scripts\\conda.exe`,
82
+ `C:\\Miniconda3\\Scripts\\conda.exe`,
83
+ `${process.env.PROGRAMDATA}\\Anaconda3\\Scripts\\conda.exe`,
84
+ ];
85
+
86
+ /**
87
+ * CondaManager — manages Conda environments.
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * const manager = new CondaManager({
92
+ * conda: {
93
+ * envName: 'my-ml-env',
94
+ * packages: ['numpy', 'pandas', 'scikit-learn'],
95
+ * channels: ['conda-forge', 'defaults'],
96
+ * }
97
+ * });
98
+ *
99
+ * const result = await manager.setup();
100
+ * console.log(result.pythonExecutable); // /opt/conda/envs/my-ml-env/bin/python3
101
+ * ```
102
+ */
103
+ export class CondaManager {
104
+ private readonly _config: CondaConfig;
105
+ private readonly _timeout: number;
106
+ private readonly _preferMamba: boolean;
107
+ private _condaExec: string | null = null;
108
+
109
+ constructor(options: CondaManagerOptions) {
110
+ this._config = options.conda;
111
+ this._timeout = options.timeout ?? 300_000;
112
+ this._preferMamba = options.preferMamba ?? true;
113
+ }
114
+
115
+ // ─── Public API ──────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Set up the Conda environment.
119
+ * Creates the environment if it doesn't exist, then installs packages.
120
+ */
121
+ async setup(): Promise<CondaSetupResult> {
122
+ const condaExec = this._findCondaExecutable();
123
+ if (!condaExec) {
124
+ throw new CondaNotFoundError(
125
+ 'Could not find conda executable. ' +
126
+ 'Install Anaconda/Miniconda or set CONDA_EXE environment variable.',
127
+ );
128
+ }
129
+
130
+ this._condaExec = condaExec;
131
+ logger.info(`CondaManager: using conda at ${condaExec}`);
132
+
133
+ let created = false;
134
+ let packagesInstalled = false;
135
+
136
+ // ── Check if environment exists ──────────────────────────────────────
137
+ const existingEnv = await this._findEnv(this._config.envName);
138
+
139
+ if (!existingEnv) {
140
+ logger.info(`CondaManager: creating environment '${this._config.envName}'`);
141
+ await this._createEnv();
142
+ created = true;
143
+ }
144
+
145
+ const envInfo = await this._getEnvInfo(this._config.envName);
146
+ if (!envInfo) {
147
+ throw new CondaEnvError(`Environment '${this._config.envName}' not found after creation`);
148
+ }
149
+
150
+ // ── Install packages ─────────────────────────────────────────────────
151
+ const packagesToInstall = this._config.packages ?? [];
152
+ if (packagesToInstall.length > 0) {
153
+ logger.info(`CondaManager: installing packages into '${this._config.envName}'`);
154
+ await this._installPackages(envInfo.prefix, packagesToInstall);
155
+ packagesInstalled = true;
156
+ }
157
+
158
+ return {
159
+ pythonExecutable: envInfo.pythonExecutable,
160
+ created,
161
+ packagesInstalled,
162
+ envInfo,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * List all conda environments.
168
+ */
169
+ async listEnvironments(): Promise<CondaEnvInfo[]> {
170
+ const conda = this._findCondaExecutable();
171
+ if (!conda) {return [];}
172
+
173
+ try {
174
+ const result = spawnSync(
175
+ conda,
176
+ ['env', 'list', '--json'],
177
+ { encoding: 'utf8', timeout: 30_000, stdio: 'pipe' },
178
+ );
179
+
180
+ if (result.status !== 0) {return [];}
181
+
182
+ const parsed = JSON.parse(result.stdout) as { envs: string[] };
183
+ const envs: CondaEnvInfo[] = [];
184
+
185
+ for (const prefix of parsed.envs ?? []) {
186
+ const name = path.basename(prefix);
187
+ const pyExec = this._pythonInPrefix(prefix);
188
+ if (fs.existsSync(pyExec)) {
189
+ envs.push({
190
+ name,
191
+ prefix,
192
+ pythonExecutable: pyExec,
193
+ isActive: process.env.CONDA_PREFIX === prefix,
194
+ });
195
+ }
196
+ }
197
+
198
+ return envs;
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Check if a conda environment exists.
206
+ */
207
+ async environmentExists(envName: string): Promise<boolean> {
208
+ return (await this._findEnv(envName)) !== null;
209
+ }
210
+
211
+ /**
212
+ * Get the Python executable for a conda environment.
213
+ */
214
+ async getPythonExecutable(envName: string): Promise<string | null> {
215
+ const info = await this._getEnvInfo(envName);
216
+ return info?.pythonExecutable ?? null;
217
+ }
218
+
219
+ /**
220
+ * Install packages into an existing environment.
221
+ */
222
+ async installPackages(envName: string, packages: string[]): Promise<void> {
223
+ const info = await this._getEnvInfo(envName);
224
+ if (!info) {
225
+ throw new CondaEnvError(`Environment '${envName}' not found`);
226
+ }
227
+ await this._installPackages(info.prefix, packages);
228
+ }
229
+
230
+ /**
231
+ * Remove a conda environment.
232
+ */
233
+ async removeEnvironment(envName: string): Promise<void> {
234
+ const conda = this._ensureCondaExec();
235
+ const result = spawnSync(
236
+ conda,
237
+ ['env', 'remove', '--name', envName, '--yes'],
238
+ { encoding: 'utf8', timeout: 120_000, stdio: 'pipe' },
239
+ );
240
+
241
+ if (result.status !== 0) {
242
+ logger.warn(`CondaManager: failed to remove env '${envName}'`);
243
+ } else {
244
+ logger.info(`CondaManager: removed environment '${envName}'`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Return the conda executable path (or null if not found).
250
+ */
251
+ get condaExecutable(): string | null {
252
+ return this._findCondaExecutable();
253
+ }
254
+
255
+ // ─── Private ─────────────────────────────────────────────────────────────
256
+
257
+ private _findCondaExecutable(): string | null {
258
+ // mamba is a faster drop-in
259
+ if (this._preferMamba) {
260
+ const mamba = process.env.MAMBA_EXE || this._which('mamba');
261
+ if (mamba) {return mamba;}
262
+ }
263
+
264
+ // CONDA_EXE env var
265
+ if (process.env.CONDA_EXE) {return process.env.CONDA_EXE;}
266
+
267
+ // Explicit config path
268
+ if (this._config.condaPath) {return this._config.condaPath;}
269
+
270
+ // PATH
271
+ const fromPath = this._which('conda');
272
+ if (fromPath) {return fromPath;}
273
+
274
+ // Common locations
275
+ const locations =
276
+ process.platform === 'win32' ? CONDA_LOCATIONS_WIN :
277
+ process.platform === 'darwin' ? CONDA_LOCATIONS_DARWIN :
278
+ CONDA_LOCATIONS_LINUX;
279
+
280
+ for (const loc of locations) {
281
+ if (loc && fs.existsSync(loc)) {return loc;}
282
+ }
283
+
284
+ return null;
285
+ }
286
+
287
+ private _ensureCondaExec(): string {
288
+ const exec = this._condaExec ?? this._findCondaExecutable();
289
+ if (!exec) {throw new CondaNotFoundError('conda executable not found');}
290
+ return exec;
291
+ }
292
+
293
+ private async _findEnv(envName: string): Promise<string | null> {
294
+ try {
295
+ const envs = await this.listEnvironments();
296
+ const match = envs.find(e => e.name === envName);
297
+ return match?.prefix ?? null;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ private async _getEnvInfo(envName: string): Promise<CondaEnvInfo | null> {
304
+ try {
305
+ const envs = await this.listEnvironments();
306
+ return envs.find(e => e.name === envName) ?? null;
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ private async _createEnv(): Promise<void> {
313
+ const conda = this._ensureCondaExec();
314
+ const args = [
315
+ 'create',
316
+ '--name', this._config.envName,
317
+ '--yes',
318
+ 'python>=3.8',
319
+ '--quiet',
320
+ ];
321
+
322
+ if (this._config.channels?.length) {
323
+ for (const ch of this._config.channels) {
324
+ args.push('--channel', ch);
325
+ }
326
+ }
327
+
328
+ const result = spawnSync(conda, args, {
329
+ encoding: 'utf8',
330
+ timeout: this._timeout,
331
+ stdio: ['ignore', 'pipe', 'pipe'],
332
+ });
333
+
334
+ if (result.status !== 0) {
335
+ const err = result.stderr?.trim() || 'conda create failed';
336
+ throw new CondaEnvError(`Failed to create conda env '${this._config.envName}': ${err}`);
337
+ }
338
+
339
+ logger.info(`CondaManager: created env '${this._config.envName}'`);
340
+ }
341
+
342
+ private async _installPackages(prefix: string, packages: string[]): Promise<void> {
343
+ if (packages.length === 0) {return;}
344
+
345
+ const conda = this._ensureCondaExec();
346
+
347
+ // Split into conda-installable and pip-only packages
348
+ const pipOnlyMarker = 'pip::';
349
+ const condaPackages = packages.filter(p => !p.startsWith(pipOnlyMarker));
350
+ const pipPackages = packages.filter(p => p.startsWith(pipOnlyMarker)).map(p => p.slice(pipOnlyMarker.length));
351
+
352
+ // conda install
353
+ if (condaPackages.length > 0) {
354
+ const condaArgs = [
355
+ 'install',
356
+ '--prefix', prefix,
357
+ '--yes',
358
+ '--quiet',
359
+ ...condaPackages,
360
+ ];
361
+
362
+ if (this._config.channels?.length) {
363
+ for (const ch of this._config.channels) {
364
+ condaArgs.push('--channel', ch);
365
+ }
366
+ }
367
+
368
+ const result = spawnSync(conda, condaArgs, {
369
+ encoding: 'utf8',
370
+ timeout: this._timeout,
371
+ stdio: ['ignore', 'pipe', 'pipe'],
372
+ });
373
+
374
+ if (result.status !== 0) {
375
+ // Fall back to pip for failed packages
376
+ logger.warn(`CondaManager: conda install failed, falling back to pip`);
377
+ pipPackages.push(...condaPackages);
378
+ } else {
379
+ logger.info(`CondaManager: conda-installed ${condaPackages.join(', ')}`);
380
+ }
381
+ }
382
+
383
+ // pip install (fallback)
384
+ if (pipPackages.length > 0) {
385
+ const pip = path.join(
386
+ prefix,
387
+ process.platform === 'win32' ? 'Scripts\\pip.exe' : 'bin/pip3',
388
+ );
389
+
390
+ if (!fs.existsSync(pip)) {
391
+ logger.warn(`CondaManager: pip not found in env, skipping: ${pipPackages.join(', ')}`);
392
+ return;
393
+ }
394
+
395
+ const result = spawnSync(pip, ['install', '--quiet', ...pipPackages], {
396
+ encoding: 'utf8',
397
+ timeout: this._timeout,
398
+ stdio: ['ignore', 'pipe', 'pipe'],
399
+ });
400
+
401
+ if (result.status !== 0) {
402
+ throw new CondaPackageInstallError(pipPackages, result.stderr?.trim() ?? 'unknown error');
403
+ }
404
+
405
+ logger.info(`CondaManager: pip-installed ${pipPackages.join(', ')}`);
406
+ }
407
+ }
408
+
409
+ private _pythonInPrefix(prefix: string): string {
410
+ return path.join(
411
+ prefix,
412
+ process.platform === 'win32' ? 'python.exe' : 'bin/python3',
413
+ );
414
+ }
415
+
416
+ private _which(name: string): string | null {
417
+ try {
418
+ const cmd = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
419
+ const result = execSync(cmd, { encoding: 'utf8', timeout: 3000, stdio: 'pipe' }).trim();
420
+ return result.split(/\r?\n/)[0]?.trim() || null;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+ }
426
+
427
+ // ─── Error types ─────────────────────────────────────────────────────────────
428
+
429
+ export class CondaNotFoundError extends Error {
430
+ constructor(message: string) {
431
+ super(message);
432
+ this.name = 'CondaNotFoundError';
433
+ }
434
+ }
435
+
436
+ export class CondaEnvError extends Error {
437
+ constructor(message: string) {
438
+ super(message);
439
+ this.name = 'CondaEnvError';
440
+ }
441
+ }
442
+
443
+ export class CondaPackageInstallError extends Error {
444
+ readonly packages: string[];
445
+ constructor(packages: string[], detail: string) {
446
+ super(`Failed to install conda packages [${packages.join(', ')}]: ${detail}`);
447
+ this.name = 'CondaPackageInstallError';
448
+ this.packages = packages;
449
+ }
450
+ }
451
+