@chikhamx/voidx 3.2.1 → 3.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 (3) hide show
  1. package/bin/postinstall.js +431 -431
  2. package/bin/voidx.js +393 -393
  3. package/package.json +33 -33
@@ -1,431 +1,431 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- // Post-install: download a standalone Python, create venv, pip install voidx.
5
- // Uses only the bundled Python — never falls back to the system Python.
6
-
7
- const { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } = require("fs");
8
- const { spawnSync } = require("child_process");
9
- const { join, dirname } = require("path");
10
- const os = require("os");
11
- const fs = require("fs");
12
- const path = require("path");
13
-
14
- const pkg = require("../package.json");
15
-
16
- // ── Python build metadata ──────────────────────────────────────────────────
17
- // Pin to a specific python-build-standalone release tag and CPython version.
18
- // Update these when upgrading the bundled Python.
19
- const PBS_TAG = "20260602";
20
- const PBS_CPYTHON = "3.12.13";
21
- const PBS_RELEASE_BASE = `https://github.com/astral-sh/python-build-standalone/releases/download`;
22
-
23
- // ── Platform mapping ───────────────────────────────────────────────────────
24
- function getPlatformInfo() {
25
- const platform = os.platform();
26
- const arch = os.arch();
27
-
28
- const map = {
29
- "darwin-arm64": { target: "aarch64-apple-darwin" },
30
- "darwin-x64": { target: "x86_64-apple-darwin" },
31
- "linux-arm64": { target: "aarch64-unknown-linux-gnu" },
32
- "linux-x64": { target: "x86_64-unknown-linux-gnu" },
33
- "win32-arm64": { target: "aarch64-pc-windows-msvc" },
34
- "win32-x64": { target: "x86_64-pc-windows-msvc" },
35
- };
36
-
37
- const key = `${platform}-${arch}`;
38
- const entry = map[key];
39
- if (!entry) {
40
- return null;
41
- }
42
- return { platform, arch, target: entry.target };
43
- }
44
-
45
- function getPbsFilename(target) {
46
- return `cpython-${PBS_CPYTHON}+${PBS_TAG}-${target}-install_only_stripped.tar.gz`;
47
- }
48
-
49
- // ── Paths ──────────────────────────────────────────────────────────────────
50
- function resolveDataHome(env) {
51
- if (env.VOIDX_NPM_HOME) return path.resolve(env.VOIDX_NPM_HOME);
52
- if (process.platform === "win32") {
53
- return env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
54
- }
55
- return env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
56
- }
57
-
58
- function resolvePythonDir(env) {
59
- return path.join(resolveDataHome(env), "voidx", "python");
60
- }
61
-
62
- function resolveVenvDir(env) {
63
- if (env.VOIDX_NPM_VENV) return path.resolve(env.VOIDX_NPM_VENV);
64
- // Use the same venv directory as install.sh — single environment, no duplicates
65
- return path.join(resolveDataHome(env), "voidx", "venv");
66
- }
67
-
68
- function resolveVenvPython(venvDir) {
69
- return process.platform === "win32"
70
- ? path.join(venvDir, "Scripts", "python.exe")
71
- : path.join(venvDir, "bin", "python");
72
- }
73
-
74
- function resolveVoidxExecutable(venvDir) {
75
- return process.platform === "win32"
76
- ? path.join(venvDir, "Scripts", "voidx.exe")
77
- : path.join(venvDir, "bin", "voidx");
78
- }
79
-
80
- function resolveBundledPython(pythonDir, platform) {
81
- // install_only_stripped layout:
82
- // unix: python/python/bin/python3
83
- // win32: python/python/python.exe
84
- const installDir = path.join(pythonDir, "python");
85
- if (platform === "win32") {
86
- return path.join(installDir, "python.exe");
87
- }
88
- return path.join(installDir, "bin", "python3");
89
- }
90
-
91
- // ── Legacy cleanup ──────────────────────────────────────────────────────────
92
- // Remove voidx installed via system Python (pip/pipx) from v1.x era,
93
- // and old npm-venv directory from v2.x early releases.
94
- function cleanupLegacy(env) {
95
- const isWin = process.platform === "win32";
96
-
97
- // pip
98
- for (const cmd of isWin ? ["pip"] : ["pip3", "pip"]) {
99
- try {
100
- const result = spawnSync(cmd, ["show", "voidx"], {
101
- encoding: "utf8",
102
- windowsHide: true,
103
- });
104
- if (!result.error && result.status === 0 && (result.stdout || "").includes("Name: voidx")) {
105
- const versionMatch = (result.stdout || "").match(/Version:\s*(\S+)/);
106
- const version = versionMatch ? versionMatch[1] : "unknown";
107
- console.error(` ⚠️ Found pip-installed voidx ${version} (${cmd}), uninstalling…`);
108
- const uninstallResult = spawnSync(cmd, ["uninstall", "voidx", "-y"], {
109
- encoding: "utf8",
110
- windowsHide: true,
111
- stdio: "inherit",
112
- });
113
- if (uninstallResult.status === 0) {
114
- console.error(" ✅ Uninstalled pip-installed voidx");
115
- } else {
116
- console.error(` ⚠️ Failed to uninstall voidx via ${cmd}. Run manually: ${cmd} uninstall voidx`);
117
- }
118
- }
119
- } catch (err) {
120
- // spawnSync sets result.error for ENOENT (handled by status check above);
121
- // this catch only handles truly unexpected synchronous throws.
122
- console.error(` ⚠️ Failed to check ${cmd}: ${err.message}`);
123
- }
124
- }
125
-
126
- // pipx
127
- try {
128
- const result = spawnSync("pipx", ["list"], { encoding: "utf8", windowsHide: true });
129
- if (!result.error && result.status === 0 && /^voidx\b/m.test(result.stdout || "")) {
130
- console.error(" ⚠️ Found pipx-installed voidx, uninstalling…");
131
- const uninstallResult = spawnSync("pipx", ["uninstall", "voidx"], {
132
- encoding: "utf8",
133
- windowsHide: true,
134
- stdio: "inherit",
135
- });
136
- if (uninstallResult.status === 0) {
137
- console.error(" ✅ Uninstalled pipx-installed voidx");
138
- } else {
139
- console.error(" ⚠️ Failed to uninstall voidx via pipx. Run manually: pipx uninstall voidx");
140
- }
141
- }
142
- } catch (err) {
143
- // spawnSync sets result.error for ENOENT (handled by status check above);
144
- // this catch only handles truly unexpected synchronous throws.
145
- console.error(` ⚠️ Failed to check pipx: ${err.message}`);
146
- }
147
-
148
- // Old npm-venv directory
149
- const dataHome = resolveDataHome(env);
150
- const oldNpmVenv = path.join(dataHome, "voidx", "npm-venv");
151
- if (existsSync(oldNpmVenv)) {
152
- console.error(" ⚠️ Found old npm-venv directory, removing…");
153
- try {
154
- rmSync(oldNpmVenv, { recursive: true, force: true });
155
- console.error(" ✅ Removed old npm-venv directory");
156
- } catch (err) {
157
- console.error(` Failed to remove old npm-venv: ${err.message}`);
158
- }
159
- }
160
- }
161
-
162
- // ── Download with retry ────────────────────────────────────────────────────
163
- const MAX_DOWNLOAD_RETRIES = 3;
164
-
165
- function sleep(ms) {
166
- return new Promise((resolve) => setTimeout(resolve, ms));
167
- }
168
-
169
- async function downloadFileWithRetry(url, dest, retries = MAX_DOWNLOAD_RETRIES) {
170
- for (let attempt = 1; attempt <= retries; attempt++) {
171
- try {
172
- return await downloadFile(url, dest);
173
- } catch (err) {
174
- // Clean up partial download
175
- try { unlinkSync(dest); } catch {}
176
- if (attempt < retries) {
177
- const delay = Math.pow(2, attempt) * 1000;
178
- console.error(` Download attempt ${attempt}/${retries} failed: ${err.message}`);
179
- console.error(` Retrying in ${delay / 1000}s…`);
180
- await sleep(delay);
181
- } else {
182
- throw err;
183
- }
184
- }
185
- }
186
- }
187
-
188
- function downloadFile(url, dest) {
189
- return new Promise((resolve, reject) => {
190
- const doRequest = (currentUrl, redirects = 0) => {
191
- if (redirects > 5) {
192
- return reject(new Error(`Too many redirects downloading ${url}`));
193
- }
194
- const mod = currentUrl.startsWith("https") ? require("https") : require("http");
195
- mod.get(currentUrl, { timeout: 30000 }, (res) => {
196
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
197
- return doRequest(res.headers.location, redirects + 1);
198
- }
199
- if (res.statusCode !== 200) {
200
- return reject(new Error(`HTTP ${res.statusCode} downloading ${currentUrl}`));
201
- }
202
- const total = parseInt(res.headers["content-length"] || "0", 10);
203
- let downloaded = 0;
204
- let lastPercent = -1;
205
-
206
- res.on("data", (chunk) => {
207
- downloaded += chunk.length;
208
- if (total > 0) {
209
- const pct = Math.floor((downloaded / total) * 100);
210
- if (pct !== lastPercent && pct % 10 === 0) {
211
- lastPercent = pct;
212
- process.stderr.write(` ${pct}% (${Math.round(downloaded / 1024 / 1024)}/${Math.round(total / 1024 / 1024)} MB)\n`);
213
- }
214
- }
215
- });
216
-
217
- const stream = createWriteStream(dest);
218
- res.pipe(stream);
219
- stream.on("finish", () => {
220
- stream.close();
221
- resolve(dest);
222
- });
223
- stream.on("error", reject);
224
- res.on("error", reject);
225
- }).on("error", reject);
226
- };
227
- doRequest(url);
228
- });
229
- }
230
-
231
- // ── Extract ────────────────────────────────────────────────────────────────
232
- function extractTarGz(archive, destDir) {
233
- // Use the system tar command — available on macOS, Linux, and modern Windows (10+)
234
- const result = spawnSync("tar", ["-xzf", archive, "-C", destDir], {
235
- encoding: "utf8",
236
- windowsHide: true,
237
- });
238
- if (result.error) {
239
- throw new Error(`Failed to extract Python: ${result.error.message}`);
240
- }
241
- if (result.status !== 0) {
242
- throw new Error(`Failed to extract Python (tar exited ${result.status}).`);
243
- }
244
- }
245
-
246
- // ── Version marker ─────────────────────────────────────────────────────────
247
- function readMarker(markerPath) {
248
- try { return readFileSync(markerPath, "utf8"); } catch { return ""; }
249
- }
250
-
251
- function writeMarker(markerPath, content) {
252
- mkdirSync(dirname(markerPath), { recursive: true });
253
- writeFileSync(markerPath, content);
254
- }
255
-
256
- // ── pip install ────────────────────────────────────────────────────────────
257
- function pipInstall(venvPython, packageSpec, env) {
258
- // Upgrade pip first to avoid resolver bugs in old versions
259
- const pipUpgradeEnv = Object.assign({}, env, {
260
- PIP_NO_INPUT: "1",
261
- PIP_DISABLE_PIP_VERSION_CHECK: "1",
262
- PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
263
- });
264
- const pipUpgradeResult = spawnSync(
265
- venvPython,
266
- ["-m", "pip", "install", "--upgrade", "pip", "--no-cache-dir"],
267
- { encoding: "utf8", stdio: "inherit", windowsHide: true, env: pipUpgradeEnv }
268
- );
269
- if (pipUpgradeResult.error || pipUpgradeResult.status !== 0) {
270
- console.error(" ⚠️ Failed to upgrade pip, continuing with current version…");
271
- }
272
-
273
- const pipEnv = Object.assign({}, env, {
274
- PIP_NO_INPUT: "1",
275
- PIP_DISABLE_PIP_VERSION_CHECK: "1",
276
- PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
277
- });
278
-
279
- const pipArgs = ["-m", "pip", "install", "--upgrade", "--no-cache-dir", "--progress-bar", "on"];
280
-
281
- // Support custom PyPI index for users behind firewalls or in regions with slow PyPI access
282
- const pipIndex = env.VOIDX_NPM_PIP_INDEX;
283
- if (pipIndex) {
284
- pipArgs.push("-i", pipIndex);
285
- // Extract host for --trusted-host when using a custom index
286
- try {
287
- const indexUrl = new URL(pipIndex);
288
- pipArgs.push("--trusted-host", indexUrl.hostname);
289
- } catch {}
290
- }
291
-
292
- pipArgs.push(packageSpec);
293
-
294
- const result = spawnSync(
295
- venvPython,
296
- pipArgs,
297
- { encoding: "utf8", stdio: "inherit", windowsHide: true, env: pipEnv }
298
- );
299
- if (result.error) {
300
- throw new Error(`pip install failed: ${result.error.message}`);
301
- }
302
- if (result.status !== 0) {
303
- throw new Error("pip install failed. See errors above.");
304
- }
305
- }
306
-
307
- // ── Main ───────────────────────────────────────────────────────────────────
308
- async function main() {
309
- const env = process.env;
310
- const venvDir = resolveVenvDir(env);
311
- const venvPython = resolveVenvPython(venvDir);
312
- const executable = resolveVoidxExecutable(venvDir);
313
- const packageSpec = env.VOIDX_NPM_PACKAGE_SPEC || `voidx==${pkg.version}`;
314
- const markerPath = path.join(venvDir, ".voidx-install-version");
315
- const marker = `${pkg.version}\n${PBS_TAG}\n${PBS_CPYTHON}\n`;
316
-
317
- // ── Legacy cleanup ────────────────────────────────────────────────────────
318
- // Remove voidx installed via system Python (pip/pipx) from v1.x era.
319
- // Also remove old npm-venv directory from v2.x early releases.
320
- // Runs before marker check so legacy cleanup happens even when already installed.
321
- cleanupLegacy(env);
322
-
323
- // Already set up and up-to-date?
324
- if (existsSync(executable) && readMarker(markerPath) === marker) {
325
- console.error(`\n✅ voidx ${pkg.version} ready (cached)\n`);
326
- return;
327
- }
328
-
329
- // Step 1: Download bundled Python (required — no system Python fallback)
330
- const platformInfo = getPlatformInfo();
331
- if (!platformInfo) {
332
- console.error(`\n ❌ Unsupported platform: ${os.platform()}-${os.arch()}\n`);
333
- console.error(" voidx npm package supports: macOS (x64/arm64), Linux (x64/arm64), Windows (x64/arm64)\n");
334
- process.exit(1);
335
- }
336
-
337
- console.error(`\n🐍 Setting up voidx ${pkg.version}…\n`);
338
- console.error(" [1/3] Downloading Python runtime…");
339
-
340
- const pythonDir = resolvePythonDir(env);
341
- const pbsFilename = getPbsFilename(platformInfo.target);
342
- const pbsUrlBase = env.VOIDX_NPM_PYTHON_MIRROR || PBS_RELEASE_BASE;
343
- const pbsUrl = `${pbsUrlBase}/${PBS_TAG}/${pbsFilename}`;
344
- const archivePath = path.join(pythonDir, pbsFilename);
345
- const bundledPython = resolveBundledPython(pythonDir, platformInfo.platform);
346
-
347
- if (!existsSync(bundledPython)) {
348
- try {
349
- mkdirSync(pythonDir, { recursive: true });
350
-
351
- if (!existsSync(archivePath)) {
352
- console.error(` Downloading ${pbsFilename}…`);
353
- await downloadFileWithRetry(pbsUrl, archivePath);
354
- console.error(" Download complete.");
355
- }
356
-
357
- console.error(" Extracting Python runtime…");
358
- extractTarGz(archivePath, pythonDir);
359
- console.error(" Extraction complete.");
360
-
361
- // Clean up archive to save disk
362
- try { fs.unlinkSync(archivePath); } catch {}
363
- } catch (err) {
364
- // Clean up partial archive on failure
365
- try { unlinkSync(archivePath); } catch {}
366
- console.error(`\n ❌ Failed to download Python runtime: ${err.message}\n`);
367
- console.error(" This is usually a network issue. Try:");
368
- console.error(" 1. Use a mirror: VOIDX_NPM_PYTHON_MIRROR=https://npmmirror.com/mirrors/python-standalone");
369
- console.error(" 2. Retry: npm install -g @chikhamx/voidx");
370
- console.error(" 3. Debug: VOIDX_NPM_DEBUG=1 npm install -g @chikhamx/voidx\n");
371
- process.exit(1);
372
- }
373
- } else {
374
- console.error(" Using cached Python runtime.");
375
- }
376
-
377
- // Step 2: Create venv (rebuild if corrupted)
378
- console.error(" [2/3] Creating virtual environment…");
379
-
380
- // If venv exists but is corrupted (python binary missing), nuke and rebuild
381
- if (existsSync(venvDir) && !existsSync(venvPython)) {
382
- console.error(" Existing venv is corrupted, rebuilding…");
383
- try {
384
- rmSync(venvDir, { recursive: true, force: true });
385
- } catch (err) {
386
- console.error(` Failed to remove corrupted venv: ${err.message}`);
387
- }
388
- }
389
-
390
- if (!existsSync(venvPython)) {
391
- const venvResult = spawnSync(
392
- bundledPython,
393
- ["-m", "venv", venvDir],
394
- { encoding: "utf8", stdio: "inherit", windowsHide: true }
395
- );
396
- if (venvResult.error) {
397
- console.error(`\n ❌ Failed to create venv: ${venvResult.error.message}\n`);
398
- process.exit(1);
399
- }
400
- if (venvResult.status !== 0) {
401
- console.error("\n ❌ Failed to create venv. See errors above.\n");
402
- process.exit(1);
403
- }
404
- }
405
-
406
- // Step 3: pip install
407
- console.error(" [3/3] Installing voidx and dependencies…");
408
- pipInstall(venvPython, packageSpec, env);
409
-
410
- // Done
411
- writeMarker(markerPath, marker);
412
- console.error(`\n✅ voidx ${pkg.version} installed! Run: voidx\n`);
413
- }
414
-
415
- // ── URL builder (for mirror support) ──────────────────────────────────────
416
-
417
- function buildPythonDownloadUrl(mirrorBase, tag, filename) {
418
- return `${mirrorBase}/${tag}/${filename}`;
419
- }
420
-
421
- if (require.main === module) {
422
- main().catch((err) => {
423
- console.error(`\n❌ Setup failed: ${err.message}\n`);
424
- process.exit(1);
425
- });
426
- }
427
-
428
- module.exports = {
429
- buildPythonDownloadUrl,
430
- downloadFileWithRetry,
431
- };
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Post-install: download a standalone Python, create venv, pip install voidx.
5
+ // Uses only the bundled Python — never falls back to the system Python.
6
+
7
+ const { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } = require("fs");
8
+ const { spawnSync } = require("child_process");
9
+ const { join, dirname } = require("path");
10
+ const os = require("os");
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ const pkg = require("../package.json");
15
+
16
+ // ── Python build metadata ──────────────────────────────────────────────────
17
+ // Pin to a specific python-build-standalone release tag and CPython version.
18
+ // Update these when upgrading the bundled Python.
19
+ const PBS_TAG = "20260602";
20
+ const PBS_CPYTHON = "3.12.13";
21
+ const PBS_RELEASE_BASE = `https://github.com/astral-sh/python-build-standalone/releases/download`;
22
+
23
+ // ── Platform mapping ───────────────────────────────────────────────────────
24
+ function getPlatformInfo() {
25
+ const platform = os.platform();
26
+ const arch = os.arch();
27
+
28
+ const map = {
29
+ "darwin-arm64": { target: "aarch64-apple-darwin" },
30
+ "darwin-x64": { target: "x86_64-apple-darwin" },
31
+ "linux-arm64": { target: "aarch64-unknown-linux-gnu" },
32
+ "linux-x64": { target: "x86_64-unknown-linux-gnu" },
33
+ "win32-arm64": { target: "aarch64-pc-windows-msvc" },
34
+ "win32-x64": { target: "x86_64-pc-windows-msvc" },
35
+ };
36
+
37
+ const key = `${platform}-${arch}`;
38
+ const entry = map[key];
39
+ if (!entry) {
40
+ return null;
41
+ }
42
+ return { platform, arch, target: entry.target };
43
+ }
44
+
45
+ function getPbsFilename(target) {
46
+ return `cpython-${PBS_CPYTHON}+${PBS_TAG}-${target}-install_only_stripped.tar.gz`;
47
+ }
48
+
49
+ // ── Paths ──────────────────────────────────────────────────────────────────
50
+ function resolveDataHome(env) {
51
+ if (env.VOIDX_NPM_HOME) return path.resolve(env.VOIDX_NPM_HOME);
52
+ if (process.platform === "win32") {
53
+ return env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
54
+ }
55
+ return env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
56
+ }
57
+
58
+ function resolvePythonDir(env) {
59
+ return path.join(resolveDataHome(env), "voidx", "python");
60
+ }
61
+
62
+ function resolveVenvDir(env) {
63
+ if (env.VOIDX_NPM_VENV) return path.resolve(env.VOIDX_NPM_VENV);
64
+ // Use the same venv directory as install.sh — single environment, no duplicates
65
+ return path.join(resolveDataHome(env), "voidx", "venv");
66
+ }
67
+
68
+ function resolveVenvPython(venvDir) {
69
+ return process.platform === "win32"
70
+ ? path.join(venvDir, "Scripts", "python.exe")
71
+ : path.join(venvDir, "bin", "python");
72
+ }
73
+
74
+ function resolveVoidxExecutable(venvDir) {
75
+ return process.platform === "win32"
76
+ ? path.join(venvDir, "Scripts", "voidx.exe")
77
+ : path.join(venvDir, "bin", "voidx");
78
+ }
79
+
80
+ function resolveBundledPython(pythonDir, platform) {
81
+ // install_only_stripped layout:
82
+ // unix: python/python/bin/python3
83
+ // win32: python/python/python.exe
84
+ const installDir = path.join(pythonDir, "python");
85
+ if (platform === "win32") {
86
+ return path.join(installDir, "python.exe");
87
+ }
88
+ return path.join(installDir, "bin", "python3");
89
+ }
90
+
91
+ // ── Legacy cleanup ──────────────────────────────────────────────────────────
92
+ // Remove voidx installed via system Python (pip/pipx) from v1.x era,
93
+ // and old npm-venv directory from v2.x early releases.
94
+ function cleanupLegacy(env) {
95
+ const isWin = process.platform === "win32";
96
+
97
+ // pip
98
+ for (const cmd of isWin ? ["pip"] : ["pip3", "pip"]) {
99
+ try {
100
+ const result = spawnSync(cmd, ["show", "voidx"], {
101
+ encoding: "utf8",
102
+ windowsHide: true,
103
+ });
104
+ if (!result.error && result.status === 0 && (result.stdout || "").includes("Name: voidx")) {
105
+ const versionMatch = (result.stdout || "").match(/Version:\s*(\S+)/);
106
+ const version = versionMatch ? versionMatch[1] : "unknown";
107
+ console.error(` ⚠️ Found pip-installed voidx ${version} (${cmd}), uninstalling…`);
108
+ const uninstallResult = spawnSync(cmd, ["uninstall", "voidx", "-y"], {
109
+ encoding: "utf8",
110
+ windowsHide: true,
111
+ stdio: "inherit",
112
+ });
113
+ if (uninstallResult.status === 0) {
114
+ console.error(" ✅ Uninstalled pip-installed voidx");
115
+ } else {
116
+ console.error(` ⚠️ Failed to uninstall voidx via ${cmd}. Run manually: ${cmd} uninstall voidx`);
117
+ }
118
+ }
119
+ } catch (err) {
120
+ // spawnSync sets result.error for ENOENT (handled by status check above);
121
+ // this catch only handles truly unexpected synchronous throws.
122
+ console.error(` ⚠️ Failed to check ${cmd}: ${err.message}`);
123
+ }
124
+ }
125
+
126
+ // pipx
127
+ try {
128
+ const result = spawnSync("pipx", ["list"], { encoding: "utf8", windowsHide: true });
129
+ if (!result.error && result.status === 0 && /^voidx\b/m.test(result.stdout || "")) {
130
+ console.error(" ⚠️ Found pipx-installed voidx, uninstalling…");
131
+ const uninstallResult = spawnSync("pipx", ["uninstall", "voidx"], {
132
+ encoding: "utf8",
133
+ windowsHide: true,
134
+ stdio: "inherit",
135
+ });
136
+ if (uninstallResult.status === 0) {
137
+ console.error(" ✅ Uninstalled pipx-installed voidx");
138
+ } else {
139
+ console.error(" ⚠️ Failed to uninstall voidx via pipx. Run manually: pipx uninstall voidx");
140
+ }
141
+ }
142
+ } catch (err) {
143
+ // spawnSync sets result.error for ENOENT (handled by status check above);
144
+ // this catch only handles truly unexpected synchronous throws.
145
+ console.error(` ⚠️ Failed to check pipx: ${err.message}`);
146
+ }
147
+
148
+ // Old npm-venv directory
149
+ const dataHome = resolveDataHome(env);
150
+ const oldNpmVenv = path.join(dataHome, "voidx", "npm-venv");
151
+ if (existsSync(oldNpmVenv)) {
152
+ console.error(" ⚠️ Found old npm-venv directory, removing…");
153
+ try {
154
+ rmSync(oldNpmVenv, { recursive: true, force: true });
155
+ console.error(" ✅ Removed old npm-venv directory");
156
+ } catch (err) {
157
+ console.error(` Failed to remove old npm-venv: ${err.message}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ // ── Download with retry ────────────────────────────────────────────────────
163
+ const MAX_DOWNLOAD_RETRIES = 3;
164
+
165
+ function sleep(ms) {
166
+ return new Promise((resolve) => setTimeout(resolve, ms));
167
+ }
168
+
169
+ async function downloadFileWithRetry(url, dest, retries = MAX_DOWNLOAD_RETRIES) {
170
+ for (let attempt = 1; attempt <= retries; attempt++) {
171
+ try {
172
+ return await downloadFile(url, dest);
173
+ } catch (err) {
174
+ // Clean up partial download
175
+ try { unlinkSync(dest); } catch {}
176
+ if (attempt < retries) {
177
+ const delay = Math.pow(2, attempt) * 1000;
178
+ console.error(` Download attempt ${attempt}/${retries} failed: ${err.message}`);
179
+ console.error(` Retrying in ${delay / 1000}s…`);
180
+ await sleep(delay);
181
+ } else {
182
+ throw err;
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ function downloadFile(url, dest) {
189
+ return new Promise((resolve, reject) => {
190
+ const doRequest = (currentUrl, redirects = 0) => {
191
+ if (redirects > 5) {
192
+ return reject(new Error(`Too many redirects downloading ${url}`));
193
+ }
194
+ const mod = currentUrl.startsWith("https") ? require("https") : require("http");
195
+ mod.get(currentUrl, { timeout: 30000 }, (res) => {
196
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
197
+ return doRequest(res.headers.location, redirects + 1);
198
+ }
199
+ if (res.statusCode !== 200) {
200
+ return reject(new Error(`HTTP ${res.statusCode} downloading ${currentUrl}`));
201
+ }
202
+ const total = parseInt(res.headers["content-length"] || "0", 10);
203
+ let downloaded = 0;
204
+ let lastPercent = -1;
205
+
206
+ res.on("data", (chunk) => {
207
+ downloaded += chunk.length;
208
+ if (total > 0) {
209
+ const pct = Math.floor((downloaded / total) * 100);
210
+ if (pct !== lastPercent && pct % 10 === 0) {
211
+ lastPercent = pct;
212
+ process.stderr.write(` ${pct}% (${Math.round(downloaded / 1024 / 1024)}/${Math.round(total / 1024 / 1024)} MB)\n`);
213
+ }
214
+ }
215
+ });
216
+
217
+ const stream = createWriteStream(dest);
218
+ res.pipe(stream);
219
+ stream.on("finish", () => {
220
+ stream.close();
221
+ resolve(dest);
222
+ });
223
+ stream.on("error", reject);
224
+ res.on("error", reject);
225
+ }).on("error", reject);
226
+ };
227
+ doRequest(url);
228
+ });
229
+ }
230
+
231
+ // ── Extract ────────────────────────────────────────────────────────────────
232
+ function extractTarGz(archive, destDir) {
233
+ // Use the system tar command — available on macOS, Linux, and modern Windows (10+)
234
+ const result = spawnSync("tar", ["-xzf", archive, "-C", destDir], {
235
+ encoding: "utf8",
236
+ windowsHide: true,
237
+ });
238
+ if (result.error) {
239
+ throw new Error(`Failed to extract Python: ${result.error.message}`);
240
+ }
241
+ if (result.status !== 0) {
242
+ throw new Error(`Failed to extract Python (tar exited ${result.status}).`);
243
+ }
244
+ }
245
+
246
+ // ── Version marker ─────────────────────────────────────────────────────────
247
+ function readMarker(markerPath) {
248
+ try { return readFileSync(markerPath, "utf8"); } catch { return ""; }
249
+ }
250
+
251
+ function writeMarker(markerPath, content) {
252
+ mkdirSync(dirname(markerPath), { recursive: true });
253
+ writeFileSync(markerPath, content);
254
+ }
255
+
256
+ // ── pip install ────────────────────────────────────────────────────────────
257
+ function pipInstall(venvPython, packageSpec, env) {
258
+ // Upgrade pip first to avoid resolver bugs in old versions
259
+ const pipUpgradeEnv = Object.assign({}, env, {
260
+ PIP_NO_INPUT: "1",
261
+ PIP_DISABLE_PIP_VERSION_CHECK: "1",
262
+ PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
263
+ });
264
+ const pipUpgradeResult = spawnSync(
265
+ venvPython,
266
+ ["-m", "pip", "install", "--upgrade", "pip", "--no-cache-dir"],
267
+ { encoding: "utf8", stdio: "inherit", windowsHide: true, env: pipUpgradeEnv }
268
+ );
269
+ if (pipUpgradeResult.error || pipUpgradeResult.status !== 0) {
270
+ console.error(" ⚠️ Failed to upgrade pip, continuing with current version…");
271
+ }
272
+
273
+ const pipEnv = Object.assign({}, env, {
274
+ PIP_NO_INPUT: "1",
275
+ PIP_DISABLE_PIP_VERSION_CHECK: "1",
276
+ PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
277
+ });
278
+
279
+ const pipArgs = ["-m", "pip", "install", "--upgrade", "--no-cache-dir", "--progress-bar", "on"];
280
+
281
+ // Support custom PyPI index for users behind firewalls or in regions with slow PyPI access
282
+ const pipIndex = env.VOIDX_NPM_PIP_INDEX;
283
+ if (pipIndex) {
284
+ pipArgs.push("-i", pipIndex);
285
+ // Extract host for --trusted-host when using a custom index
286
+ try {
287
+ const indexUrl = new URL(pipIndex);
288
+ pipArgs.push("--trusted-host", indexUrl.hostname);
289
+ } catch {}
290
+ }
291
+
292
+ pipArgs.push(packageSpec);
293
+
294
+ const result = spawnSync(
295
+ venvPython,
296
+ pipArgs,
297
+ { encoding: "utf8", stdio: "inherit", windowsHide: true, env: pipEnv }
298
+ );
299
+ if (result.error) {
300
+ throw new Error(`pip install failed: ${result.error.message}`);
301
+ }
302
+ if (result.status !== 0) {
303
+ throw new Error("pip install failed. See errors above.");
304
+ }
305
+ }
306
+
307
+ // ── Main ───────────────────────────────────────────────────────────────────
308
+ async function main() {
309
+ const env = process.env;
310
+ const venvDir = resolveVenvDir(env);
311
+ const venvPython = resolveVenvPython(venvDir);
312
+ const executable = resolveVoidxExecutable(venvDir);
313
+ const packageSpec = env.VOIDX_NPM_PACKAGE_SPEC || `voidx==${pkg.version}`;
314
+ const markerPath = path.join(venvDir, ".voidx-install-version");
315
+ const marker = `${pkg.version}\n${PBS_TAG}\n${PBS_CPYTHON}\n`;
316
+
317
+ // ── Legacy cleanup ────────────────────────────────────────────────────────
318
+ // Remove voidx installed via system Python (pip/pipx) from v1.x era.
319
+ // Also remove old npm-venv directory from v2.x early releases.
320
+ // Runs before marker check so legacy cleanup happens even when already installed.
321
+ cleanupLegacy(env);
322
+
323
+ // Already set up and up-to-date?
324
+ if (existsSync(executable) && readMarker(markerPath) === marker) {
325
+ console.error(`\n✅ voidx ${pkg.version} ready (cached)\n`);
326
+ return;
327
+ }
328
+
329
+ // Step 1: Download bundled Python (required — no system Python fallback)
330
+ const platformInfo = getPlatformInfo();
331
+ if (!platformInfo) {
332
+ console.error(`\n ❌ Unsupported platform: ${os.platform()}-${os.arch()}\n`);
333
+ console.error(" voidx npm package supports: macOS (x64/arm64), Linux (x64/arm64), Windows (x64/arm64)\n");
334
+ process.exit(1);
335
+ }
336
+
337
+ console.error(`\n🐍 Setting up voidx ${pkg.version}…\n`);
338
+ console.error(" [1/3] Downloading Python runtime…");
339
+
340
+ const pythonDir = resolvePythonDir(env);
341
+ const pbsFilename = getPbsFilename(platformInfo.target);
342
+ const pbsUrlBase = env.VOIDX_NPM_PYTHON_MIRROR || PBS_RELEASE_BASE;
343
+ const pbsUrl = `${pbsUrlBase}/${PBS_TAG}/${pbsFilename}`;
344
+ const archivePath = path.join(pythonDir, pbsFilename);
345
+ const bundledPython = resolveBundledPython(pythonDir, platformInfo.platform);
346
+
347
+ if (!existsSync(bundledPython)) {
348
+ try {
349
+ mkdirSync(pythonDir, { recursive: true });
350
+
351
+ if (!existsSync(archivePath)) {
352
+ console.error(` Downloading ${pbsFilename}…`);
353
+ await downloadFileWithRetry(pbsUrl, archivePath);
354
+ console.error(" Download complete.");
355
+ }
356
+
357
+ console.error(" Extracting Python runtime…");
358
+ extractTarGz(archivePath, pythonDir);
359
+ console.error(" Extraction complete.");
360
+
361
+ // Clean up archive to save disk
362
+ try { fs.unlinkSync(archivePath); } catch {}
363
+ } catch (err) {
364
+ // Clean up partial archive on failure
365
+ try { unlinkSync(archivePath); } catch {}
366
+ console.error(`\n ❌ Failed to download Python runtime: ${err.message}\n`);
367
+ console.error(" This is usually a network issue. Try:");
368
+ console.error(" 1. Use a mirror: VOIDX_NPM_PYTHON_MIRROR=https://npmmirror.com/mirrors/python-standalone");
369
+ console.error(" 2. Retry: npm install -g @chikhamx/voidx");
370
+ console.error(" 3. Debug: VOIDX_NPM_DEBUG=1 npm install -g @chikhamx/voidx\n");
371
+ process.exit(1);
372
+ }
373
+ } else {
374
+ console.error(" Using cached Python runtime.");
375
+ }
376
+
377
+ // Step 2: Create venv (rebuild if corrupted)
378
+ console.error(" [2/3] Creating virtual environment…");
379
+
380
+ // If venv exists but is corrupted (python binary missing), nuke and rebuild
381
+ if (existsSync(venvDir) && !existsSync(venvPython)) {
382
+ console.error(" Existing venv is corrupted, rebuilding…");
383
+ try {
384
+ rmSync(venvDir, { recursive: true, force: true });
385
+ } catch (err) {
386
+ console.error(` Failed to remove corrupted venv: ${err.message}`);
387
+ }
388
+ }
389
+
390
+ if (!existsSync(venvPython)) {
391
+ const venvResult = spawnSync(
392
+ bundledPython,
393
+ ["-m", "venv", venvDir],
394
+ { encoding: "utf8", stdio: "inherit", windowsHide: true }
395
+ );
396
+ if (venvResult.error) {
397
+ console.error(`\n ❌ Failed to create venv: ${venvResult.error.message}\n`);
398
+ process.exit(1);
399
+ }
400
+ if (venvResult.status !== 0) {
401
+ console.error("\n ❌ Failed to create venv. See errors above.\n");
402
+ process.exit(1);
403
+ }
404
+ }
405
+
406
+ // Step 3: pip install
407
+ console.error(" [3/3] Installing voidx and dependencies…");
408
+ pipInstall(venvPython, packageSpec, env);
409
+
410
+ // Done
411
+ writeMarker(markerPath, marker);
412
+ console.error(`\n✅ voidx ${pkg.version} installed! Run: voidx\n`);
413
+ }
414
+
415
+ // ── URL builder (for mirror support) ──────────────────────────────────────
416
+
417
+ function buildPythonDownloadUrl(mirrorBase, tag, filename) {
418
+ return `${mirrorBase}/${tag}/${filename}`;
419
+ }
420
+
421
+ if (require.main === module) {
422
+ main().catch((err) => {
423
+ console.error(`\n❌ Setup failed: ${err.message}\n`);
424
+ process.exit(1);
425
+ });
426
+ }
427
+
428
+ module.exports = {
429
+ buildPythonDownloadUrl,
430
+ downloadFileWithRetry,
431
+ };