mrmd-server 0.2.6 → 0.2.7

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Python environment utilities for mrmd-electron
3
+ *
4
+ * Shared functions for Python/venv management used by main process and services.
5
+ */
6
+
7
+ import { spawn, execSync } from 'child_process';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { fileURLToPath } from 'url';
12
+ import { PYTHON_DEPS, getPythonInstallArgs } from '../config.js';
13
+ import { findUv, ensureUv } from './uv-installer.js';
14
+ import {
15
+ getVenvPython,
16
+ getVenvExecutable,
17
+ getVenvBinDir,
18
+ getPythonCommand,
19
+ } from './platform.js';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+
24
+ function resolveLocalMrmdPythonSource() {
25
+ const explicit = process.env.MRMD_PYTHON_DEV;
26
+ if (explicit && fs.existsSync(path.join(explicit, 'pyproject.toml'))) {
27
+ return { path: explicit, editable: true, source: 'env' };
28
+ }
29
+
30
+ try {
31
+ const sibling = path.resolve(__dirname, '../../../mrmd-python');
32
+ if (fs.existsSync(path.join(sibling, 'pyproject.toml'))) {
33
+ return { path: sibling, editable: true, source: 'sibling' };
34
+ }
35
+ } catch {
36
+ // ignore
37
+ }
38
+
39
+ try {
40
+ if (process.resourcesPath) {
41
+ const bundled = path.join(process.resourcesPath, 'mrmd-python');
42
+ if (fs.existsSync(path.join(bundled, 'pyproject.toml'))) {
43
+ return { path: bundled, editable: false, source: 'bundled' };
44
+ }
45
+ }
46
+ } catch {
47
+ // ignore
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ // Re-export for backwards compatibility
54
+ export { findUv, ensureUv };
55
+
56
+ /**
57
+ * Find uv binary (sync version for use in constructors)
58
+ * @deprecated Use findUv from uv-installer.js instead
59
+ * @returns {string|null} Path to uv or null if not found
60
+ */
61
+ export function findUvSync() {
62
+ return findUv();
63
+ }
64
+
65
+ /**
66
+ * Install all required Python packages in a virtual environment
67
+ *
68
+ * Uses uv (auto-installed if missing). Installs packages according to
69
+ * the version compatibility matrix in config.js.
70
+ *
71
+ * @param {string} venvPath - Path to the virtual environment
72
+ * @param {object} options - Options
73
+ * @param {string} options.localDev - Local development path (from MRMD_PYTHON_DEV env)
74
+ * @param {boolean} options.fullInstall - Install all packages including optional (default: true)
75
+ * @param {function} options.onProgress - Progress callback (stage, detail)
76
+ * @returns {Promise<{ success: boolean, packages: string[] }>}
77
+ */
78
+ export async function installMrmdPython(venvPath, options = {}) {
79
+ const {
80
+ localDev = process.env.MRMD_PYTHON_DEV,
81
+ fullInstall = true,
82
+ onProgress
83
+ } = options;
84
+
85
+ const localSource = localDev
86
+ ? { path: localDev, editable: true, source: 'explicit' }
87
+ : resolveLocalMrmdPythonSource();
88
+
89
+ const report = (stage, detail) => {
90
+ console.log(`[python] ${stage}: ${detail}`);
91
+ if (onProgress) onProgress(stage, detail);
92
+ };
93
+
94
+ const pythonPath = getVenvPython(venvPath);
95
+
96
+ // Validate venv exists
97
+ if (!fs.existsSync(pythonPath)) {
98
+ throw new Error(`Python not found at ${pythonPath}. Is this a valid venv?`);
99
+ }
100
+
101
+ // Ensure uv is installed (auto-install if missing)
102
+ report('checking', 'uv installation');
103
+ const uvPath = await ensureUv({
104
+ onProgress: (stage, detail) => report(`uv-${stage}`, detail)
105
+ });
106
+
107
+ // Build package list from version matrix
108
+ const packages = [];
109
+
110
+ if (localSource?.path) {
111
+ // Prefer bundled/local mrmd-python source so packaged apps don't depend on
112
+ // PyPI for MRMD's own runtime package. Keep third-party deps from PyPI.
113
+ const installArgs = getPythonInstallArgs().filter((spec) => !spec.startsWith('mrmd-python'));
114
+ packages.push(...installArgs);
115
+ if (localSource.editable) {
116
+ packages.push('-e', localSource.path);
117
+ } else {
118
+ packages.push(localSource.path);
119
+ }
120
+ report('mode', `${localSource.source} mrmd-python + PyPI deps (${localSource.path})`);
121
+ } else {
122
+ // Production: install from PyPI with version constraints
123
+ const installArgs = getPythonInstallArgs();
124
+ packages.push(...installArgs);
125
+ report('mode', `PyPI (${installArgs.length} packages)`);
126
+ }
127
+
128
+ // Run uv pip install
129
+ const args = ['pip', 'install', '--python', pythonPath, ...packages];
130
+
131
+ report('installing', packages.filter(p => !p.startsWith('-')).join(', '));
132
+
133
+ return new Promise((resolve, reject) => {
134
+ console.log(`[python] Running: ${uvPath} ${args.join(' ')}`);
135
+
136
+ const proc = spawn(uvPath, args, {
137
+ cwd: path.dirname(venvPath),
138
+ stdio: ['pipe', 'pipe', 'pipe'],
139
+ env: { ...process.env, VIRTUAL_ENV: venvPath },
140
+ });
141
+
142
+ let stdout = '';
143
+ let stderr = '';
144
+
145
+ proc.stdout.on('data', (d) => {
146
+ stdout += d.toString();
147
+ const line = d.toString().trim();
148
+ if (line) console.log('[uv]', line);
149
+ });
150
+
151
+ proc.stderr.on('data', (d) => {
152
+ stderr += d.toString();
153
+ const line = d.toString().trim();
154
+ if (line) console.error('[uv]', line);
155
+ });
156
+
157
+ proc.on('error', (e) => {
158
+ reject(new Error(`Failed to run uv: ${e.message}`));
159
+ });
160
+
161
+ proc.on('close', (code) => {
162
+ if (code === 0) {
163
+ report('complete', 'all packages installed');
164
+ resolve({
165
+ success: true,
166
+ packages: Object.keys(PYTHON_DEPS)
167
+ });
168
+ } else {
169
+ reject(new Error(`uv pip install failed (code ${code}): ${(stderr || stdout).slice(-500)}`));
170
+ }
171
+ });
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Install mrmd-python only (legacy function for backwards compatibility)
177
+ * @deprecated Use installMrmdPython with fullInstall option
178
+ */
179
+ export async function installMrmdPythonOnly(venvPath, options = {}) {
180
+ return installMrmdPython(venvPath, { ...options, fullInstall: false });
181
+ }
182
+
183
+ /**
184
+ * Create a Python virtual environment
185
+ *
186
+ * Tries uv first (faster), falls back to python -m venv.
187
+ *
188
+ * @param {string} venvPath - Path for the new venv
189
+ * @returns {Promise<void>}
190
+ */
191
+ export function createVenv(venvPath) {
192
+ return new Promise((resolve, reject) => {
193
+ const uvPath = findUvSync();
194
+
195
+ if (uvPath) {
196
+ // Try uv first (faster)
197
+ const proc = spawn(uvPath, ['venv', venvPath], {
198
+ stdio: ['pipe', 'pipe', 'pipe'],
199
+ });
200
+
201
+ let stderr = '';
202
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
203
+
204
+ proc.on('close', (code) => {
205
+ if (code === 0) {
206
+ resolve();
207
+ } else {
208
+ reject(new Error(`uv venv failed: ${stderr}`));
209
+ }
210
+ });
211
+
212
+ proc.on('error', () => {
213
+ // uv failed, fall back to python -m venv
214
+ createVenvWithPython(venvPath).then(resolve).catch(reject);
215
+ });
216
+ } else {
217
+ // No uv, use python -m venv
218
+ createVenvWithPython(venvPath).then(resolve).catch(reject);
219
+ }
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Create venv using python -m venv
225
+ */
226
+ function createVenvWithPython(venvPath) {
227
+ return new Promise((resolve, reject) => {
228
+ const pythonCmd = getPythonCommand();
229
+ const proc = spawn(pythonCmd, ['-m', 'venv', venvPath], {
230
+ stdio: ['pipe', 'pipe', 'pipe'],
231
+ });
232
+
233
+ let stderr = '';
234
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
235
+
236
+ proc.on('error', (e) => {
237
+ reject(new Error(`Failed to create venv: ${e.message}`));
238
+ });
239
+
240
+ proc.on('close', (code) => {
241
+ if (code === 0) {
242
+ resolve();
243
+ } else {
244
+ reject(new Error(`python -m venv failed (code ${code}): ${stderr}`));
245
+ }
246
+ });
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Get information about a Python environment
252
+ *
253
+ * @param {string} envPath - Path to the environment
254
+ * @param {string} envType - Type of environment ('system', 'venv', 'conda', 'pyenv')
255
+ * @returns {object} Environment info
256
+ */
257
+ export function getEnvInfo(envPath, envType) {
258
+ let pythonVersion = null;
259
+ let hasMrmdPython = false;
260
+ let hasPython = false;
261
+ let projectName = '';
262
+ let name = '';
263
+
264
+ try {
265
+ hasPython = fs.existsSync(getVenvPython(envPath));
266
+
267
+ // Get Python version from pyvenv.cfg
268
+ const pyvenvCfg = path.join(envPath, 'pyvenv.cfg');
269
+ if (fs.existsSync(pyvenvCfg)) {
270
+ const content = fs.readFileSync(pyvenvCfg, 'utf8');
271
+ const match = content.match(/version\s*=\s*(\d+\.\d+)/);
272
+ if (match) pythonVersion = match[1];
273
+ }
274
+
275
+ // Check if mrmd-python is installed
276
+ hasMrmdPython = fs.existsSync(getVenvExecutable(envPath, 'mrmd-python'));
277
+
278
+ // Set name based on environment type
279
+ switch (envType) {
280
+ case 'system':
281
+ name = 'System Python';
282
+ projectName = 'system';
283
+ break;
284
+ case 'conda':
285
+ name = path.basename(envPath);
286
+ projectName = 'conda';
287
+ break;
288
+ case 'pyenv':
289
+ name = path.basename(envPath);
290
+ projectName = 'pyenv';
291
+ break;
292
+ default: // venv
293
+ name = path.basename(envPath);
294
+ projectName = path.basename(path.dirname(envPath));
295
+ }
296
+ } catch (e) {
297
+ console.warn(`[python] Error getting env info for ${envPath}:`, e.message);
298
+ }
299
+
300
+ return {
301
+ path: envPath,
302
+ pythonVersion,
303
+ hasMrmdPython,
304
+ hasPython,
305
+ projectName,
306
+ name,
307
+ envType,
308
+ };
309
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * UV Auto-Installer for mrmd-electron
3
+ *
4
+ * Automatically downloads and installs uv if not present.
5
+ * uv is required for fast Python package management.
6
+ */
7
+
8
+ import { spawn, execSync } from 'child_process';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import os from 'os';
12
+ import https from 'https';
13
+ import { createWriteStream, createReadStream } from 'fs';
14
+ import { pipeline } from 'stream/promises';
15
+ import { createGunzip } from 'zlib';
16
+
17
+ import {
18
+ UV_PATHS,
19
+ UV_DOWNLOAD_URLS,
20
+ UV_INSTALL_DIR,
21
+ UV_INSTALL_PATH,
22
+ } from '../config.js';
23
+
24
+ /**
25
+ * Find existing uv installation
26
+ * @returns {string|null} Path to uv binary or null
27
+ */
28
+ export function findUv() {
29
+ // Check configured paths
30
+ for (const loc of UV_PATHS) {
31
+ if (fs.existsSync(loc)) {
32
+ return loc;
33
+ }
34
+ }
35
+
36
+ // Check our install location
37
+ if (fs.existsSync(UV_INSTALL_PATH)) {
38
+ return UV_INSTALL_PATH;
39
+ }
40
+
41
+ // Try PATH via 'which' (Unix) or 'where' (Windows)
42
+ try {
43
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
44
+ const result = execSync(`${cmd} uv`, {
45
+ encoding: 'utf8',
46
+ stdio: ['pipe', 'pipe', 'ignore']
47
+ }).trim().split('\n')[0];
48
+
49
+ if (result && fs.existsSync(result)) {
50
+ return result;
51
+ }
52
+ } catch {
53
+ // uv not in PATH
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Get the download URL for current platform
61
+ * @returns {string|null} Download URL or null if unsupported
62
+ */
63
+ function getDownloadUrl() {
64
+ const platform = process.platform;
65
+ const arch = process.arch;
66
+
67
+ const key = `${platform}-${arch}`;
68
+ return UV_DOWNLOAD_URLS[key] || null;
69
+ }
70
+
71
+ /**
72
+ * Download a file to a local path
73
+ * @param {string} url - URL to download
74
+ * @param {string} dest - Destination path
75
+ * @param {function} onProgress - Progress callback (received, total)
76
+ * @returns {Promise<void>}
77
+ */
78
+ function downloadFile(url, dest, onProgress) {
79
+ return new Promise((resolve, reject) => {
80
+ const file = createWriteStream(dest);
81
+
82
+ const request = (url) => {
83
+ https.get(url, (response) => {
84
+ // Handle redirects
85
+ if (response.statusCode === 301 || response.statusCode === 302) {
86
+ request(response.headers.location);
87
+ return;
88
+ }
89
+
90
+ if (response.statusCode !== 200) {
91
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
92
+ return;
93
+ }
94
+
95
+ const total = parseInt(response.headers['content-length'], 10);
96
+ let received = 0;
97
+
98
+ response.on('data', (chunk) => {
99
+ received += chunk.length;
100
+ if (onProgress) onProgress(received, total);
101
+ });
102
+
103
+ response.pipe(file);
104
+
105
+ file.on('finish', () => {
106
+ file.close();
107
+ resolve();
108
+ });
109
+ }).on('error', (err) => {
110
+ fs.unlink(dest, () => {});
111
+ reject(err);
112
+ });
113
+ };
114
+
115
+ request(url);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Extract tar.gz archive
121
+ * @param {string} archive - Path to archive
122
+ * @param {string} dest - Destination directory
123
+ * @returns {Promise<void>}
124
+ */
125
+ async function extractTarGz(archive, dest) {
126
+ // Use tar command (available on Unix and modern Windows)
127
+ return new Promise((resolve, reject) => {
128
+ const proc = spawn('tar', ['-xzf', archive, '-C', dest], {
129
+ stdio: ['pipe', 'pipe', 'pipe']
130
+ });
131
+
132
+ let stderr = '';
133
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
134
+
135
+ proc.on('close', (code) => {
136
+ if (code === 0) resolve();
137
+ else reject(new Error(`tar extract failed: ${stderr}`));
138
+ });
139
+
140
+ proc.on('error', reject);
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Extract zip archive (Windows)
146
+ * @param {string} archive - Path to archive
147
+ * @param {string} dest - Destination directory
148
+ * @returns {Promise<void>}
149
+ */
150
+ async function extractZip(archive, dest) {
151
+ // Use PowerShell on Windows
152
+ return new Promise((resolve, reject) => {
153
+ const proc = spawn('powershell', [
154
+ '-Command',
155
+ `Expand-Archive -Path '${archive}' -DestinationPath '${dest}' -Force`
156
+ ], {
157
+ stdio: ['pipe', 'pipe', 'pipe']
158
+ });
159
+
160
+ let stderr = '';
161
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
162
+
163
+ proc.on('close', (code) => {
164
+ if (code === 0) resolve();
165
+ else reject(new Error(`zip extract failed: ${stderr}`));
166
+ });
167
+
168
+ proc.on('error', reject);
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Install uv automatically
174
+ * @param {object} options - Options
175
+ * @param {function} options.onProgress - Progress callback (stage, detail)
176
+ * @returns {Promise<string>} Path to installed uv binary
177
+ */
178
+ export async function installUv(options = {}) {
179
+ const { onProgress } = options;
180
+
181
+ const report = (stage, detail) => {
182
+ console.log(`[uv-install] ${stage}: ${detail}`);
183
+ if (onProgress) onProgress(stage, detail);
184
+ };
185
+
186
+ // Check if already installed
187
+ const existing = findUv();
188
+ if (existing) {
189
+ report('found', existing);
190
+ return existing;
191
+ }
192
+
193
+ // Get download URL
194
+ const url = getDownloadUrl();
195
+ if (!url) {
196
+ throw new Error(`Unsupported platform: ${process.platform}-${process.arch}`);
197
+ }
198
+
199
+ report('downloading', url);
200
+
201
+ // Create temp directory
202
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uv-install-'));
203
+ const isZip = url.endsWith('.zip');
204
+ const archivePath = path.join(tmpDir, isZip ? 'uv.zip' : 'uv.tar.gz');
205
+
206
+ try {
207
+ // Download
208
+ await downloadFile(url, archivePath, (received, total) => {
209
+ const pct = total ? Math.round((received / total) * 100) : 0;
210
+ report('downloading', `${pct}%`);
211
+ });
212
+
213
+ report('extracting', archivePath);
214
+
215
+ // Extract
216
+ if (isZip) {
217
+ await extractZip(archivePath, tmpDir);
218
+ } else {
219
+ await extractTarGz(archivePath, tmpDir);
220
+ }
221
+
222
+ // Find the uv binary in extracted files
223
+ const uvBinary = process.platform === 'win32' ? 'uv.exe' : 'uv';
224
+ let extractedUv = null;
225
+
226
+ // Search for uv in tmpDir (might be in a subdirectory)
227
+ const searchDir = (dir) => {
228
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
229
+ const fullPath = path.join(dir, entry.name);
230
+ if (entry.isDirectory()) {
231
+ const found = searchDir(fullPath);
232
+ if (found) return found;
233
+ } else if (entry.name === uvBinary) {
234
+ return fullPath;
235
+ }
236
+ }
237
+ return null;
238
+ };
239
+
240
+ extractedUv = searchDir(tmpDir);
241
+
242
+ if (!extractedUv) {
243
+ throw new Error('uv binary not found in archive');
244
+ }
245
+
246
+ report('installing', UV_INSTALL_PATH);
247
+
248
+ // Ensure install directory exists
249
+ fs.mkdirSync(UV_INSTALL_DIR, { recursive: true });
250
+
251
+ // Move uv to install location
252
+ fs.copyFileSync(extractedUv, UV_INSTALL_PATH);
253
+
254
+ // Make executable (Unix)
255
+ if (process.platform !== 'win32') {
256
+ fs.chmodSync(UV_INSTALL_PATH, 0o755);
257
+ }
258
+
259
+ report('complete', UV_INSTALL_PATH);
260
+
261
+ return UV_INSTALL_PATH;
262
+
263
+ } finally {
264
+ // Cleanup temp directory
265
+ fs.rmSync(tmpDir, { recursive: true, force: true });
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Ensure uv is installed, installing if necessary
271
+ * @param {object} options - Options passed to installUv
272
+ * @returns {Promise<string>} Path to uv binary
273
+ */
274
+ export async function ensureUv(options = {}) {
275
+ const existing = findUv();
276
+ if (existing) {
277
+ return existing;
278
+ }
279
+
280
+ return installUv(options);
281
+ }
282
+
283
+ /**
284
+ * Get uv version
285
+ * @param {string} uvPath - Path to uv binary
286
+ * @returns {string|null} Version string or null
287
+ */
288
+ export function getUvVersion(uvPath) {
289
+ try {
290
+ const result = execSync(`"${uvPath}" --version`, {
291
+ encoding: 'utf8',
292
+ stdio: ['pipe', 'pipe', 'ignore']
293
+ }).trim();
294
+ // Output is like "uv 0.5.14"
295
+ return result.split(' ')[1] || result;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }