@asher-1/acloudviewer 0.0.0-stage → 3.9.5

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 CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # @asher-1/acloudviewer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cross-platform **installer bootstrap** for [ACloudViewer](https://asher-1.github.io/ACloudViewer/) — an open-source 3D point cloud and mesh processing application.
4
+
5
+ The npm package itself is tiny (~KB): it detects your platform, downloads the matching official installer (`.exe` / `.dmg` / `.run`) **or** Python wheel from [GitHub Releases](https://github.com/Asher-1/ACloudViewer/releases), and launches it. The binary distribution always comes from GitHub Releases — npm never ships the large binaries.
6
+
7
+ ## Install
8
+
9
+ ### Channel 1: npmjs.org (no authentication, recommended)
10
+
11
+ ```bash
12
+ npm install -g @asher-1/acloudviewer
13
+ acloudviewer install # downloads + launches the installer for your platform
14
+ ```
15
+
16
+ ### Channel 2: GitHub Packages (mirror)
17
+
18
+ GitHub Packages requires a token to install **any** package, including public ones. One-time setup:
19
+
20
+ ```bash
21
+ # 1. Create a PAT (classic) with read:packages at
22
+ # https://github.com/settings/tokens/new?scopes=read:packages
23
+ # 2. Configure npm once:
24
+ npm config set @asher-1:registry https://npm.pkg.github.com/
25
+ npm config set //npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
26
+ # 3. Install:
27
+ npm install -g @asher-1/acloudviewer
28
+ acloudviewer install
29
+ ```
30
+
31
+ No Node.js on the target machine? Any user can also download installers directly at https://github.com/Asher-1/ACloudViewer/releases.
32
+
33
+ ## Usage
34
+
35
+ ```text
36
+ acloudviewer install [--cuda|--cpu] [--silent] [--prefix <dir>] [--parallel <n>] [--dry-run] [--release <tag>]
37
+ acloudviewer install --wheel [--cpu-only] [--python <path>] [--release <tag>]
38
+ acloudviewer list [--release <tag>]
39
+ acloudviewer uninstall [--prefix <dir>] [--yes]
40
+ acloudviewer version | help
41
+ ```
42
+
43
+ | Option | Meaning |
44
+ |--------|---------|
45
+ | `--cuda` / `--cpu` | Force build flavor; default auto-detects an NVIDIA GPU (CUDA build falls back to CPU automatically). |
46
+ | `--silent` | Unattended install (QtIFW console mode; the app lands in `--prefix`, default `~/.local/share/ACloudViewer` — no sudo). |
47
+ | `--prefix <dir>` | Install root for `--silent` / `--from`, and the target for `uninstall`. |
48
+ | `--parallel <n>` | Concurrent download segments, 1–16 (see *Download behavior*). |
49
+ | `--wheel` | Install the Python `cloudViewer` wheel via pip instead of the desktop app (requires Python 3.10+). |
50
+ | `--dry-run` | Print the matched asset and download URL, then exit (no download). |
51
+ | `--release <tag>` | GitHub release tag; defaults to the rolling `main-devel` release. |
52
+
53
+ Examples:
54
+
55
+ ```bash
56
+ acloudviewer list # show all assets in the current release
57
+ acloudviewer install --silent # unattended desktop install
58
+ acloudviewer install --wheel # pip-install the cloudViewer Python package
59
+ acloudviewer uninstall --yes # remove the installation via maintenancetool
60
+ ```
61
+
62
+ ## Download behavior
63
+
64
+ Large assets (>= 64 MB) are fetched as **4 parallel ranged segments** written
65
+ into one preallocated file — release assets are served by a CDN that
66
+ throttles **per connection**, so segments download in parallel and aggregate
67
+ throughput roughly 2–3x (measured on the release CDN; override with
68
+ `--parallel 1..16`). Any stream that receives no bytes for 60s is aborted and
69
+ retried, and every retry **resumes from the bytes already on disk** (HTTP
70
+ `Range`) instead of restarting the whole file. If the server does not honor
71
+ range requests (a `200` reply to a ranged request), the downloader
72
+ automatically falls back to a single stream. Downloads land in a temp dir and
73
+ are removed after install.
74
+
75
+ ## Uninstall
76
+
77
+ `uninstall` runs the QtIFW **maintenancetool** of the installation directory
78
+ (`purge --confirm-command`) — the same uninstaller the install wizard
79
+ provides, verified end-to-end. Without `--yes` it asks for confirmation on a
80
+ terminal; in a non-interactive shell (scripts, agents, CI) it **refuses** to
81
+ run unless `--yes` is given, so automation can never be surprised by it.
82
+
83
+ ```bash
84
+ acloudviewer uninstall # interactive confirmation
85
+ acloudviewer uninstall --yes # unattended
86
+ acloudviewer uninstall --prefix ~/opt/ACloudViewer --yes # non-default location
87
+ ```
88
+
89
+ ## What it does per platform
90
+
91
+ | Platform | Default | `--silent` |
92
+ |----------|---------|-----------|
93
+ | Windows | Runs the QtIFW `.exe` wizard | QtIFW headless install to `--prefix` |
94
+ | macOS | Opens the `.dmg` in Finder (drag to Applications) | Runs the bundled installer headless (`hdiutil` attach → install → detach) |
95
+ | Linux | Runs the QtIFW `.run` wizard | QtIFW headless install to `--prefix` |
96
+
97
+ Silent installs mirror the pip harness's production-verified QtIFW console
98
+ invocations (`--root <dir> --accept-licenses --confirm-command install`), so
99
+ the same uninstaller/maintenance tooling applies. The GUI default keeps the
100
+ exact flow of downloading from the releases page.
101
+
102
+ ## Troubleshooting: `acloudviewer: command not found`
103
+
104
+ `npm install -g` places the binary in npm's global `bin` directory
105
+ (`npm prefix -g`/`bin`). If that directory is not on your `PATH` (common with
106
+ custom npm prefixes), the command exists but the shell cannot find it:
107
+
108
+ ```bash
109
+ export PATH="$(npm prefix -g)/bin:$PATH" # add to ~/.bashrc to persist
110
+ ```
111
+
112
+ ## License
113
+
114
+ GPL-2.0-only — same as [ACloudViewer](https://github.com/Asher-1/ACloudViewer).
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @asher-1/acloudviewer — cross-platform installer bootstrap.
4
+ *
5
+ * Fetches the matching official ACloudViewer installer (.exe/.dmg/.run) or
6
+ * Python wheel from the GitHub release and installs it. Zero dependencies:
7
+ * Node >= 18 built-ins only (fetch, fs, child_process).
8
+ *
9
+ * Asset-matching rules mirror cli_anything/acloudviewer/utils/installer.py;
10
+ * keep the two in sync when release asset names change.
11
+ */
12
+
13
+ "use strict";
14
+
15
+ const fs = require("fs");
16
+ const os = require("os");
17
+ const path = require("path");
18
+ const { spawn, spawnSync } = require("child_process");
19
+ const { Readable } = require("stream");
20
+
21
+ const GITHUB_REPO = "Asher-1/ACloudViewer";
22
+ const RELEASE_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/tags/`;
23
+ const DEFAULT_RELEASE = "main-devel";
24
+ const USER_AGENT = "acloudviewer-npm-bootstrap";
25
+ // Same default install root as installer.py DEFAULT_APP_DIR: user-writable,
26
+ // no sudo needed for --silent installs.
27
+ const DEFAULT_TARGET = path.join(os.homedir(), ".local", "share", "ACloudViewer");
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────────────────
30
+
31
+ function die(msg) {
32
+ console.error(`error: ${msg}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ function isWindows() { return process.platform === "win32"; }
37
+ function isMacOS() { return process.platform === "darwin"; }
38
+ function isLinux() { return process.platform === "linux"; }
39
+
40
+ function archTag() {
41
+ // Mirror installer.py PlatformInfo.arch_tag.
42
+ return process.arch === "arm64" ? "ARM64" : "amd64";
43
+ }
44
+
45
+ function macArchTag() {
46
+ // Mirror installer.py _match_macos_wheel tag preference.
47
+ return process.arch === "arm64" ? "arm64" : "x86_64";
48
+ }
49
+
50
+ function detectNvidiaGpu() {
51
+ const r = spawnSync("nvidia-smi", ["-L"], { timeout: 5000 });
52
+ return r.status === 0;
53
+ }
54
+
55
+ function detectGlibcVersion() {
56
+ if (!isLinux()) return "";
57
+ const r = spawnSync("ldd", ["--version"], { encoding: "utf8" });
58
+ if (r.status !== 0 || !r.stdout) return "";
59
+ const m = r.stdout.split("\n")[0].match(/(\d+\.\d+)/);
60
+ return m ? m[1] : "";
61
+ }
62
+
63
+ function detectLinuxDistro() {
64
+ if (!isLinux()) return { id: "unknown", version: "" };
65
+ try {
66
+ const text = fs.readFileSync("/etc/os-release", "utf8");
67
+ const info = {};
68
+ for (const line of text.split("\n")) {
69
+ const eq = line.indexOf("=");
70
+ if (eq > 0) info[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"|"$/g, "");
71
+ }
72
+ return { id: info.ID || "unknown", version: info.VERSION_ID || "" };
73
+ } catch {
74
+ return { id: "unknown", version: "" };
75
+ }
76
+ }
77
+
78
+ function detectPythonInterpreter(explicit) {
79
+ // Windows: try the py launcher first (always on PATH when the python.org
80
+ // installer was used), then bare interpreters.
81
+ const candidates = explicit ? [explicit]
82
+ : (isWindows() ? ["py", "python", "python3"] : ["python3", "python"]);
83
+ for (const exe of candidates) {
84
+ const r = spawnSync(exe, ["--version"], { encoding: "utf8" });
85
+ const out = `${r.stdout || ""}${r.stderr || ""}`;
86
+ const m = out.match(/Python\s+(\d+)\.(\d+)/);
87
+ if (r.status === 0 && m) {
88
+ return { exe, tag: `cp${m[1]}${m[2]}`, version: `${m[1]}.${m[2]}` };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function closestUbuntuTag(glibcVersion) {
95
+ // Mirror installer.py _closest_ubuntu_tag.
96
+ const map = { "2.27": "ubuntu18.04", "2.31": "ubuntu20.04", "2.35": "ubuntu22.04", "2.39": "ubuntu24.04" };
97
+ if (map[glibcVersion]) return map[glibcVersion];
98
+ const ver = parseFloat(glibcVersion);
99
+ if (!Number.isFinite(ver)) return "ubuntu20.04";
100
+ if (ver < 2.31) return "ubuntu18.04";
101
+ if (ver < 2.35) return "ubuntu20.04";
102
+ if (ver < 2.39) return "ubuntu22.04";
103
+ return "ubuntu24.04";
104
+ }
105
+
106
+ // ── Release assets ───────────────────────────────────────────────────────
107
+
108
+ async function fetchReleaseAssets(tag) {
109
+ // 30s hard timeout: a hung api.github.com call must not wedge the CLI.
110
+ let res;
111
+ try {
112
+ res = await fetch(RELEASE_API + tag, {
113
+ headers: { "User-Agent": USER_AGENT, Accept: "application/vnd.github+json" },
114
+ signal: AbortSignal.timeout(30000),
115
+ });
116
+ } catch (e) {
117
+ throw new Error(`cannot reach GitHub API (${e && e.message ? e.message : e}). Check your network and try again.`);
118
+ }
119
+ if (res.status === 403) {
120
+ throw new Error(`GitHub API refused the request for release "${tag}" (HTTP 403). ` +
121
+ "Anonymous API calls are limited to 60/hour per IP — wait and retry, or check that the release exists at " +
122
+ `https://github.com/${GITHUB_REPO}/releases`);
123
+ }
124
+ if (!res.ok) {
125
+ throw new Error(`cannot read GitHub release "${tag}" (HTTP ${res.status}). Check https://github.com/${GITHUB_REPO}/releases`);
126
+ }
127
+ let release;
128
+ try {
129
+ release = await res.json();
130
+ } catch {
131
+ throw new Error(`unexpected non-JSON response from the GitHub API for "${tag}".`);
132
+ }
133
+ return release.assets || [];
134
+ }
135
+
136
+ function pickFlavor(args, hasNvidia) {
137
+ // installer.py defaults to CUDA and falls back to CPU; --cpu/--cuda override.
138
+ if (args.has("cpu")) return "cpu";
139
+ if (args.has("cuda")) return "cuda";
140
+ return hasNvidia ? "cuda" : "cpu";
141
+ }
142
+
143
+ function findInstallerApp(assets, plat, flavor) {
144
+ // Mirror installer.py find_matching_app (desktop installers only).
145
+ const isCpu = (n) => n.includes("-cpu-");
146
+ const isCuda = (n) => n.includes("-cuda-");
147
+
148
+ if (isLinux()) {
149
+ const ubuntuTag = plat.distro.id === "ubuntu" && plat.distro.version
150
+ ? `ubuntu${plat.distro.version}`
151
+ : closestUbuntuTag(plat.glibc);
152
+ let candidates = assets.filter((a) =>
153
+ a.name.startsWith("ACloudViewer-") && a.name.endsWith(".run") &&
154
+ a.name.includes(ubuntuTag) &&
155
+ (flavor === "cpu" ? isCpu(a.name) : isCuda(a.name)));
156
+ if (!candidates.length && flavor !== "cpu") {
157
+ // CUDA missing -> CPU fallback (same as installer.py).
158
+ candidates = assets.filter((a) =>
159
+ a.name.startsWith("ACloudViewer-") && a.name.endsWith(".run") &&
160
+ a.name.includes(ubuntuTag) && isCpu(a.name));
161
+ }
162
+ return candidates[0] || null;
163
+ }
164
+ if (isMacOS()) {
165
+ // macOS ships CPU-only builds; the arch tag is the discriminator.
166
+ return assets.find((a) =>
167
+ a.name.startsWith("ACloudViewer-") && a.name.endsWith(".dmg") &&
168
+ a.name.includes(plat.macArch)) || null;
169
+ }
170
+ if (isWindows()) {
171
+ let candidates = assets.filter((a) =>
172
+ a.name.startsWith("ACloudViewer-") && a.name.endsWith(".exe") &&
173
+ (flavor === "cpu" ? isCpu(a.name) : isCuda(a.name)));
174
+ if (!candidates.length && flavor !== "cpu") {
175
+ candidates = assets.filter((a) =>
176
+ a.name.startsWith("ACloudViewer-") && a.name.endsWith(".exe") && isCpu(a.name));
177
+ }
178
+ return candidates[0] || null;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function findWheel(assets, plat, py, cpuOnly) {
184
+ // Mirror installer.py find_matching_wheel (+ _match_linux/_macos/_windows).
185
+ const prefix = cpuOnly ? "cloudviewer_cpu-" : "cloudviewer-";
186
+ const hasPyTag = (n) => n.includes(`-${py.tag}-${py.tag}-`);
187
+
188
+ if (isLinux()) {
189
+ const manylinux = plat.glibc
190
+ ? `manylinux_${plat.glibc.replace(".", "_")}_${plat.macArch === "ARM64" ? "aarch64" : "x86_64"}`
191
+ : "";
192
+ let exact = assets.filter((a) =>
193
+ a.name.startsWith(prefix) && hasPyTag(a.name) && manylinux && a.name.includes(manylinux));
194
+ if (exact.length) return exact[0];
195
+ const compatible = assets
196
+ .filter((a) => a.name.startsWith(prefix) && hasPyTag(a.name) &&
197
+ a.name.includes("manylinux") && a.name.includes("x86_64"))
198
+ .sort((a, b) => a.name.localeCompare(b.name));
199
+ return compatible[0] || null;
200
+ }
201
+ if (isMacOS()) {
202
+ const matches = assets.filter((a) =>
203
+ a.name.startsWith("cloudviewer-") && !a.name.startsWith("cloudviewer_cpu-") &&
204
+ hasPyTag(a.name) && a.name.includes("macosx") && a.name.includes(plat.macArch));
205
+ if (matches.length) return matches[0];
206
+ const universal = assets.filter((a) =>
207
+ a.name.startsWith("cloudviewer-") && hasPyTag(a.name) && a.name.includes("macosx"));
208
+ return universal[0] || null;
209
+ }
210
+ if (isWindows()) {
211
+ const matches = assets.filter((a) =>
212
+ a.name.startsWith(prefix) && hasPyTag(a.name) && a.name.includes("win_amd64"));
213
+ return matches[0] || null;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // ── Download ─────────────────────────────────────────────────────────────
219
+
220
+ function progressBar(current, total, speed) {
221
+ const width = 30;
222
+ const mb = (n) => (n / 1048576).toFixed(0);
223
+ if (!total) return ` ${(current / 1048576).toFixed(1)} MB downloaded...`;
224
+ const pct = Math.min(Math.floor((current * 100) / total), 100);
225
+ const filled = Math.floor((width * current) / total);
226
+ const bar = "█".repeat(filled) + "░".repeat(width - filled);
227
+ const rate = speed !== undefined ? ` ${speed.toFixed(1)} MB/s` : "";
228
+ return ` [${bar}] ${pct}% ${mb(current)}/${mb(total)} MB${rate}`;
229
+ }
230
+
231
+ // Aggregated progress over one or many concurrent byte streams. TTY: redraw
232
+ // a \r line at most every 200ms (with speed). Non-TTY (CI logs, pipes): print
233
+ // one line per ~10% (or 10 MB) — \r spam would flood the log.
234
+ function makeProgress(total) {
235
+ const isTTY = process.stdout.isTTY;
236
+ let got = 0, lastPrint = 0, lastGot = 0, lastTime = Date.now();
237
+ const print = (now) => {
238
+ const dt = (now - lastTime) / 1000;
239
+ const speed = dt > 0 ? (got - lastGot) / dt : undefined;
240
+ lastGot = got;
241
+ lastTime = now;
242
+ process.stdout.write(`\r${progressBar(got, total, speed)}`);
243
+ };
244
+ return {
245
+ add(n) {
246
+ got += n;
247
+ const now = Date.now();
248
+ if (isTTY) {
249
+ if (now - lastPrint >= 200) { lastPrint = now; print(now); }
250
+ } else {
251
+ const step = total ? total / 10 : 10 * 1048576;
252
+ if (got - lastGot >= step) {
253
+ lastGot = got;
254
+ lastPrint = now;
255
+ console.log(progressBar(got, total));
256
+ }
257
+ }
258
+ },
259
+ finish() {
260
+ if (isTTY) process.stdout.write("\n");
261
+ else console.log(progressBar(got, total));
262
+ },
263
+ };
264
+ }
265
+
266
+ // ── Download engine ──────────────────────────────────────────────────────
267
+ //
268
+ // Zero-dependency downloader with three layers, cheapest first:
269
+ // 1. probe — a Range: bytes=0-0 request discovers size + range support
270
+ // 2. segmented— N parallel Range requests written at absolute offsets
271
+ // (helps when the bottleneck is per-connection throttling;
272
+ // measured 2.4x aggregate on release CDN assets)
273
+ // 3. single — one stream with idle-stall abort + Range resume on retry
274
+ // Every response that proves ranges do not work (200 to a Range request,
275
+ // content-encoding, garbage content-range) degrades to the single stream.
276
+
277
+ const SEGMENT_THRESHOLD = 64 * 1048576; // only segment files >= 64 MB
278
+ const MAX_PARALLEL = 16;
279
+ const STALL_TIMEOUT_MS = 60000; // abort after 60s without any bytes
280
+ const SEGMENT_RETRIES = 8; // with 60s backoff cap: survives a
281
+ // ~4 min network outage mid-download
282
+
283
+ class RangesUnsupportedError extends Error {
284
+ constructor() { super("server does not honor range requests"); }
285
+ }
286
+
287
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
288
+
289
+ // Human-readable failure reason: fetch wraps low-level errors as
290
+ // "fetch failed" and keeps the real cause (ECONNRESET, getaddrinfo ...)
291
+ // in err.cause — surface it, a bare "fetch failed" is undebuggable.
292
+ function errorReason(e) {
293
+ if (e && e.name === "AbortError") return `stalled (${STALL_TIMEOUT_MS / 1000}s without data)`;
294
+ const cause = e && e.cause && (e.cause.code || e.cause.message)
295
+ ? ` (${e.cause.code || e.cause.message})` : "";
296
+ return `${(e && e.message) || String(e)}${cause}`;
297
+ }
298
+
299
+ // Range probe: 206 + content-range => size + ranges OK; 200 => whole-file
300
+ // reply, ranges unsupported. The body is always cancelled: in the 200 case
301
+ // it IS the entire file, and letting it stream would duplicate the transfer.
302
+ async function probeRange(url) {
303
+ const res = await fetch(url, {
304
+ headers: { "User-Agent": USER_AGENT, Range: "bytes=0-0" },
305
+ redirect: "follow",
306
+ signal: AbortSignal.timeout(30000),
307
+ });
308
+ if (!res.ok) throw new Error(`HTTP ${res.status} while probing ${url}`);
309
+ try {
310
+ if (res.status !== 206) {
311
+ return { ranges: false, total: Number(res.headers.get("content-length") || 0) };
312
+ }
313
+ if (res.headers.get("content-encoding")) return { ranges: false, total: 0 };
314
+ const total = Number(((res.headers.get("content-range") || "").split("/")[1] || "").trim());
315
+ return Number.isFinite(total) && total > 0
316
+ ? { ranges: true, total }
317
+ : { ranges: false, total: 0 };
318
+ } finally {
319
+ if (res.body) await res.body.cancel().catch(() => {});
320
+ }
321
+ }
322
+
323
+ // N parallel Range requests into one preallocated file. Each worker writes
324
+ // its own byte range at absolute offsets via positional fs.writeSync on the
325
+ // shared fd — writeSync is atomic within the event loop and workers never
326
+ // overlap offsets, so no locking is needed. Memory: MAX_PARALLEL workers x
327
+ // stream chunk (~64KB) per segment, i.e. a few MB total, never the file size.
328
+ // why not O(file): total work is O(bytes) but wall time drops ~N-fold under
329
+ // per-connection throttling (measured 2.4x with N=4 on release CDN assets).
330
+ async function downloadSegmented(url, dest, total, segments, progress) {
331
+ const fd = fs.openSync(dest, "w");
332
+ fs.ftruncateSync(fd, total);
333
+ const segSize = Math.ceil(total / segments);
334
+ const segs = [];
335
+ for (let i = 0; i < segments; i++) {
336
+ const start = i * segSize;
337
+ segs.push({ start, end: Math.min(start + segSize, total) - 1, done: 0, ok: false });
338
+ }
339
+ const state = { cancelled: false };
340
+ const results = await Promise.allSettled(segs.map(async (seg, idx) => {
341
+ for (let attempt = 1; attempt <= SEGMENT_RETRIES && !seg.ok; attempt++) {
342
+ const ctrl = new AbortController();
343
+ let timer = setTimeout(() => ctrl.abort(), STALL_TIMEOUT_MS);
344
+ try {
345
+ const res = await fetch(url, {
346
+ headers: { "User-Agent": USER_AGENT, Range: `bytes=${seg.start + seg.done}-${seg.end}` },
347
+ redirect: "follow",
348
+ signal: ctrl.signal,
349
+ });
350
+ if (res.status === 200) throw new RangesUnsupportedError();
351
+ if (res.status !== 206) throw new Error(`HTTP ${res.status} for segment range`);
352
+ if (res.headers.get("content-encoding")) throw new RangesUnsupportedError();
353
+ for await (const chunk of Readable.fromWeb(res.body)) {
354
+ if (state.cancelled) throw new Error("cancelled");
355
+ fs.writeSync(fd, chunk, 0, chunk.length, seg.start + seg.done);
356
+ seg.done += chunk.length;
357
+ progress.add(chunk.length);
358
+ timer.refresh(); // idle timer resets on every received chunk
359
+ }
360
+ clearTimeout(timer);
361
+ seg.ok = true;
362
+ } catch (e) {
363
+ clearTimeout(timer);
364
+ if (e instanceof RangesUnsupportedError) { state.cancelled = true; throw e; }
365
+ if (attempt < SEGMENT_RETRIES) {
366
+ const wait = Math.min(2 * 2 ** (attempt - 1), 60);
367
+ console.log(` segment ${idx + 1}/${segments}: retry ${attempt}/${SEGMENT_RETRIES - 1} in ${wait}s (${errorReason(e)})`);
368
+ await sleep(wait * 1000);
369
+ } else {
370
+ throw new Error(`segment ${idx + 1}/${segments} (bytes ${seg.start}-${seg.end}) failed after ${SEGMENT_RETRIES} attempts: ${errorReason(e)}`);
371
+ }
372
+ }
373
+ }
374
+ }));
375
+ fs.closeSync(fd);
376
+ const unsupported = results.find((r) => r.status === "rejected" && r.reason instanceof RangesUnsupportedError);
377
+ if (unsupported) throw unsupported.reason;
378
+ const failed = results.filter((r) => r.status === "rejected").map((r) => r.reason && r.reason.message);
379
+ if (failed.length) throw new Error(`${failed.length}/${segments} download segment(s) failed:\n - ${failed.join("\n - ")}`);
380
+ const size = fs.statSync(dest).size;
381
+ if (size !== total) throw new Error(`downloaded size mismatch: ${size}/${total} bytes`);
382
+ }
383
+
384
+ // Single stream with idle-stall abort and Range resume: on retry the next
385
+ // attempt asks for bytes=<written>- instead of restarting from zero, which
386
+ // matters at low bandwidth (a 278 MB transfer that drops at 90% must not
387
+ // restart). A 200 reply to a resume request means the server ignored the
388
+ // Range header -> truncate and restart cleanly.
389
+ async function downloadSingleStream(url, dest, info, progress, retries = 8) {
390
+ let written = fs.existsSync(dest) ? fs.statSync(dest).size : 0;
391
+ if (written > info.total) written = 0; // stale/larger file: start over
392
+ for (let attempt = 1; attempt <= retries; attempt++) {
393
+ let fd = null;
394
+ const ctrl = new AbortController();
395
+ let timer = setTimeout(() => ctrl.abort(), STALL_TIMEOUT_MS);
396
+ try {
397
+ const headers = { "User-Agent": USER_AGENT };
398
+ if (written > 0 && info.ranges) headers.Range = `bytes=${written}-`;
399
+ const res = await fetch(url, { headers, redirect: "follow", signal: ctrl.signal });
400
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
401
+ if (res.headers.get("content-encoding")) {
402
+ throw new Error("unexpected content-encoding on asset download; refusing to write a corrupt file");
403
+ }
404
+ if (res.status === 200 && written > 0) {
405
+ written = 0; // server ignored the resume range
406
+ }
407
+ if (info.total && written === info.total) return; // already complete
408
+ fd = fs.openSync(dest, written > 0 ? "r+" : "w");
409
+ if (written > 0) fs.ftruncateSync(fd, written);
410
+ for await (const chunk of Readable.fromWeb(res.body)) {
411
+ fs.writeSync(fd, chunk, 0, chunk.length, written);
412
+ written += chunk.length;
413
+ progress.add(chunk.length);
414
+ timer.refresh();
415
+ }
416
+ clearTimeout(timer);
417
+ if (info.total && written < info.total) {
418
+ throw new Error(`connection closed early: ${written}/${info.total} bytes`);
419
+ }
420
+ return;
421
+ } catch (e) {
422
+ clearTimeout(timer);
423
+ if (attempt < retries) {
424
+ const wait = Math.min(5 * 2 ** (attempt - 1), 60);
425
+ console.log(` retry ${attempt}/${retries - 1} in ${wait}s (${errorReason(e)})`);
426
+ await sleep(wait * 1000);
427
+ } else {
428
+ throw new Error(`download failed after ${retries} attempts: ${errorReason(e)}`);
429
+ }
430
+ } finally {
431
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
432
+ }
433
+ }
434
+ }
435
+
436
+ async function downloadWithProgress(url, dest, label, opts = {}) {
437
+ const probe = await probeRange(url);
438
+ const total = probe.total || 0;
439
+ // Segments: explicit --parallel N wins; otherwise auto (4) for large files
440
+ // on range-capable servers. Everything unsupported degrades to 1.
441
+ let segments = opts.parallel || (probe.ranges && total >= SEGMENT_THRESHOLD ? 4 : 1);
442
+ segments = Math.max(1, Math.min(MAX_PARALLEL, Math.floor(segments) || 1));
443
+ if (segments > 1 && (!probe.ranges || (total && total < segments))) {
444
+ console.log(" note: server does not support range requests; single-stream download.");
445
+ segments = 1;
446
+ }
447
+ const progress = makeProgress(total);
448
+ console.log(`Downloading ${label}${total ? ` (${(total / 1048576).toFixed(0)} MB${segments > 1 ? `, ${segments} parallel segments` : ""})` : ""} ...`);
449
+ if (segments > 1) {
450
+ try {
451
+ await downloadSegmented(url, dest, total, segments, progress);
452
+ progress.finish();
453
+ return dest;
454
+ } catch (e) {
455
+ if (e instanceof RangesUnsupportedError) {
456
+ console.log(" note: server did not honor range requests; falling back to a single stream.");
457
+ if (fs.existsSync(dest)) fs.unlinkSync(dest);
458
+ } else {
459
+ throw e;
460
+ }
461
+ }
462
+ }
463
+ await downloadSingleStream(url, dest, { total, ranges: probe.ranges }, progress);
464
+ progress.finish();
465
+ return dest;
466
+ }
467
+
468
+ // ── Install actions ──────────────────────────────────────────────────────
469
+
470
+ function findBinaryInDir(target) {
471
+ // Real QtIFW layout (verified end-to-end on Linux): the binary and its
472
+ // .sh/.bat launcher sit at the target root, no bin/ subdir.
473
+ const name = isWindows() ? "ACloudViewer.exe" : "ACloudViewer";
474
+ for (const p of [path.join(target, name), path.join(target, "bin", name)]) {
475
+ if (fs.existsSync(p)) return p;
476
+ }
477
+ return null;
478
+ }
479
+
480
+ function runInstaller(asset, silent, target) {
481
+ if (isMacOS() && asset.name.endsWith(".dmg")) {
482
+ if (silent) return installDmgSilent(asset, target);
483
+ // Attach synchronously BEFORE returning: the tmp dir holding the dmg is
484
+ // removed right after this call, so the volume must exist by then.
485
+ console.log("Mounting disk image ...");
486
+ const r = spawnSync("hdiutil", ["attach", "-nobrowse", "-readonly", asset.path], { encoding: "utf8" });
487
+ if (r.status !== 0) throw new Error(`hdiutil attach failed: ${(r.stderr || "").slice(0, 300)}`);
488
+ let volume = null;
489
+ for (const line of (r.stdout || "").trim().split("\n")) {
490
+ const parts = line.split("\t");
491
+ if (parts.length >= 3) volume = parts[parts.length - 1].trim();
492
+ }
493
+ if (!volume || !fs.existsSync(volume)) throw new Error("DMG mounted but volume not found.");
494
+ spawn("open", [volume], { detached: true, stdio: "ignore" }).unref();
495
+ console.log(`\nMounted ${volume} — drag ACloudViewer.app to Applications in the Finder window.`);
496
+ return;
497
+ }
498
+ if (silent) {
499
+ // Mirror installer.py's production-verified QtIFW console invocations:
500
+ // Linux .run : in --accept-licenses --confirm-command --root <dir>
501
+ // Win/mac : --accept-licenses --accept-messages --confirm-command install --root <dir>
502
+ // All land in --root (user-writable by default) instead of /opt.
503
+ const args = isWindows()
504
+ ? ["--root", target, "--accept-licenses", "--accept-messages", "--confirm-command", "install"]
505
+ : ["in", "--accept-licenses", "--confirm-command", "--root", target];
506
+ console.log(`Running silent install (QtIFW console mode) -> ${target}`);
507
+ const env = { ...process.env };
508
+ if (!isWindows()) env.QT_QPA_PLATFORM = env.QT_QPA_PLATFORM || "minimal";
509
+ // QtIFW refuses to install over an existing target (TargetDirectoryInUse).
510
+ // Mirror installer.py's upgrade semantics: remove the previous
511
+ // installation through its own maintenance tool (never a raw rm).
512
+ const mt = path.join(target, isWindows() ? "maintenancetool.exe" : "maintenancetool");
513
+ if (fs.existsSync(mt)) {
514
+ console.log("Existing installation found; uninstalling via maintenancetool purge ...");
515
+ const p = spawnSync(mt, ["purge", "--confirm-command"], { stdio: "inherit", env });
516
+ if (p.status !== 0) {
517
+ throw new Error(`maintenancetool purge exited with code ${p.status}; remove ${target} manually or choose another --prefix.`);
518
+ }
519
+ }
520
+ fs.mkdirSync(target, { recursive: true });
521
+ const r = spawnSync(asset.path, args, { stdio: "inherit", env });
522
+ if (r.status !== 0) {
523
+ throw new Error(`installer exited with code ${r.status}; retry without --silent for the GUI wizard, or with --prefix <dir> for a different install root.`);
524
+ }
525
+ const binary = findBinaryInDir(target);
526
+ if (binary) console.log(`\nInstalled. Binary: ${binary}`);
527
+ else console.log(`\nInstalled to ${target} (binary not auto-detected; check contents).`);
528
+ return;
529
+ }
530
+ if (isLinux() && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
531
+ console.error("warning: no graphical session detected; the QtIFW wizard may not open. Use --silent for a headless install.");
532
+ }
533
+ const child = spawn(asset.path, [], { detached: true, stdio: "ignore" });
534
+ child.on("error", (e) =>
535
+ console.error(`warning: installer launch failed (${e.message}); download again with 'acloudviewer install' and run it manually.`));
536
+ child.unref();
537
+ console.log(`\nInstaller window opened: ${asset.name}`);
538
+ console.log("Follow the wizard to finish. (Use --silent for unattended install.)");
539
+ }
540
+
541
+ function installDmgSilent(asset, target) {
542
+ // Mirror installer.py _install_macos_dmg: attach -> run the bundled QtIFW
543
+ // installer headlessly with --root -> detach. Throws (never process.exit)
544
+ // so the tmp-dir cleanup in cmdInstall's finally still runs.
545
+ console.log("Attaching disk image ...");
546
+ const r = spawnSync("hdiutil", ["attach", "-nobrowse", "-readonly", asset.path], { encoding: "utf8" });
547
+ if (r.status !== 0) throw new Error(`hdiutil attach failed: ${(r.stderr || "").slice(0, 300)}`);
548
+ let volume = null;
549
+ for (const line of (r.stdout || "").trim().split("\n")) {
550
+ const parts = line.split("\t");
551
+ if (parts.length >= 3) volume = parts[parts.length - 1].trim();
552
+ }
553
+ if (!volume || !fs.existsSync(volume)) throw new Error("DMG mounted but volume not found.");
554
+ try {
555
+ const apps = fs.readdirSync(volume).filter((n) => n.endsWith(".app"));
556
+ if (!apps.length) throw new Error(`no .app bundle inside ${volume}.`);
557
+ const macosDir = path.join(volume, apps[0], "Contents", "MacOS");
558
+ const installerBin = fs.readdirSync(macosDir).find((n) => {
559
+ try { fs.accessSync(path.join(macosDir, n), fs.constants.X_OK); return true; } catch { return false; }
560
+ });
561
+ if (!installerBin) throw new Error(`no executable installer inside ${apps[0]}.`);
562
+ fs.mkdirSync(target, { recursive: true });
563
+ console.log(`Installing to ${target} (QtIFW headless) ...`);
564
+ const env = { ...process.env, QT_QPA_PLATFORM: process.env.QT_QPA_PLATFORM || "minimal" };
565
+ const run = spawnSync(path.join(macosDir, installerBin),
566
+ ["--root", target, "--accept-licenses", "--accept-messages", "--confirm-command", "install"],
567
+ { stdio: "inherit", env });
568
+ if (run.status !== 0) throw new Error(`installer exited with code ${run.status}. Try opening ${asset.name} manually.`);
569
+ } finally {
570
+ spawnSync("hdiutil", ["detach", volume, "-quiet"]);
571
+ }
572
+ console.log(`Installed. Launch from ${target}.`);
573
+ }
574
+
575
+ function installWheelFile(asset, pythonExe) {
576
+ console.log("Installing wheel with pip ...");
577
+ const r = spawnSync(pythonExe, ["-m", "pip", "install", asset.path], { stdio: "inherit" });
578
+ if (r.status !== 0) throw new Error(`pip install exited with code ${r.status}.`);
579
+ console.log("Installed. Try: python -c \"import cloudViewer; print(cloudViewer.__version__)\"");
580
+ }
581
+
582
+ // ── Commands ─────────────────────────────────────────────────────────────
583
+
584
+ function parseParallel(args) {
585
+ const v = args.get("parallel");
586
+ if (v === undefined) return undefined;
587
+ if (typeof v !== "string" || !/^\d+$/.test(v)) {
588
+ throw new Error("--parallel requires a number between 1 and 16 (e.g. --parallel 4).");
589
+ }
590
+ const n = Number(v);
591
+ if (n < 1 || n > MAX_PARALLEL) {
592
+ throw new Error(`--parallel requires a number between 1 and ${MAX_PARALLEL}.`);
593
+ }
594
+ return n;
595
+ }
596
+
597
+ async function cmdInstall(args) {
598
+ const tag = args.get("release") || DEFAULT_RELEASE;
599
+ const silent = args.has("silent");
600
+ const wheel = args.has("wheel");
601
+ const cpuOnly = args.has("cpu") || args.has("cpu-only");
602
+ const parallel = parseParallel(args);
603
+
604
+ // Local-file install (mirrors installer.py install_app_from_file): skips
605
+ // the release query and download entirely. Useful for offline installs
606
+ // and for testing the silent path with an already-downloaded installer.
607
+ const from = args.get("from");
608
+ if (from !== undefined && wheel) {
609
+ throw new Error("--from and --wheel cannot be combined.");
610
+ }
611
+ if (from !== undefined) {
612
+ if (typeof from !== "string" || !from) throw new Error("--from requires a file path argument.");
613
+ if (!fs.existsSync(from)) throw new Error(`file not found: ${from}`);
614
+ const prefixArg = args.get("prefix");
615
+ if (prefixArg && typeof prefixArg !== "string") throw new Error("--prefix requires a directory argument.");
616
+ const asset = { name: path.basename(from), path: from, size: fs.statSync(from).size };
617
+ const target = prefixArg || DEFAULT_TARGET;
618
+ if (args.has("dry-run")) {
619
+ // Zero side effects: no chmod, no installer execution (verified gap:
620
+ // --from previously ignored --dry-run and really launched the file).
621
+ console.log(`would install from local file: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB) -> ${target}` +
622
+ (silent ? " (silent QtIFW console mode)" : " (GUI wizard)"));
623
+ console.log("dry-run: skipping install.");
624
+ return;
625
+ }
626
+ console.log(`Installing from local file: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
627
+ if (isLinux()) fs.chmodSync(from, 0o755);
628
+ runInstaller(asset, silent, target);
629
+ return;
630
+ }
631
+
632
+ const assets = await fetchReleaseAssets(tag);
633
+
634
+ if (wheel) {
635
+ const py = detectPythonInterpreter(args.get("python"));
636
+ if (!py) die("no Python interpreter found; install Python 3.10+ or pass --python <path>.");
637
+ console.log(`Platform: ${process.platform}/${process.arch} Python: ${py.version} (${py.exe})`);
638
+ const asset = findWheel(assets, platformInfo(args), py, cpuOnly);
639
+ if (!asset) die(`no matching wheel for ${py.version} on ${process.platform}/${process.arch}. Run 'acloudviewer list' to see available assets.`);
640
+ console.log(`Matched wheel: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
641
+ if (args.has("dry-run")) {
642
+ console.log(`Download URL: ${asset.browser_download_url}`);
643
+ console.log("dry-run: skipping download and pip install.");
644
+ return;
645
+ }
646
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "acv-wheel-"));
647
+ try {
648
+ asset.path = await downloadWithProgress(asset.browser_download_url, path.join(tmp, asset.name), asset.name, { parallel });
649
+ installWheelFile(asset, py.exe);
650
+ } finally {
651
+ try {
652
+ fs.rmSync(tmp, { recursive: true, force: true });
653
+ } catch { /* best effort */ }
654
+ }
655
+ return;
656
+ }
657
+
658
+ const plat = platformInfo(args);
659
+ console.log(`Platform: ${process.platform}/${process.arch}` +
660
+ (isLinux() ? ` distro: ${plat.distro.id} ${plat.distro.version} glibc: ${plat.glibc}` : "") +
661
+ (plat.hasNvidia ? " GPU: NVIDIA detected" : ""));
662
+ const flavor = pickFlavor(args, plat.hasNvidia);
663
+ if (isMacOS() && flavor === "cuda") {
664
+ console.log("note: macOS has no CUDA build; using the CPU installer.");
665
+ }
666
+ const asset = findInstallerApp(assets, plat, isMacOS() ? "cpu" : flavor);
667
+ if (!asset) die(`no matching installer on ${process.platform}/${process.arch} (flavor: ${flavor}). Run 'acloudviewer list' to see available assets.`);
668
+ console.log(`Matched installer: ${asset.name} (${(asset.size / 1048576).toFixed(0)} MB)`);
669
+ if (args.has("dry-run")) {
670
+ console.log(`Download URL: ${asset.browser_download_url}`);
671
+ console.log("dry-run: skipping download and install.");
672
+ return;
673
+ }
674
+
675
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "acv-install-"));
676
+ try {
677
+ asset.path = await downloadWithProgress(asset.browser_download_url, path.join(tmp, asset.name), asset.name, { parallel });
678
+ if (isLinux()) fs.chmodSync(asset.path, 0o755);
679
+ const prefixArg = args.get("prefix");
680
+ if (prefixArg && typeof prefixArg !== "string") {
681
+ throw new Error("--prefix requires a directory argument.");
682
+ }
683
+ runInstaller(asset, silent, prefixArg || DEFAULT_TARGET);
684
+ } finally {
685
+ // Best-effort cleanup: on Windows a GUI-launched .exe locks its file, so
686
+ // removal may fail — keep the dir and tell the user instead of crashing.
687
+ try {
688
+ fs.rmSync(tmp, { recursive: true, force: true });
689
+ } catch (e) {
690
+ console.log(`note: could not fully clean temp dir ${tmp} (${e.message}); it can be deleted manually.`);
691
+ }
692
+ }
693
+ }
694
+
695
+ function platformInfo(args) {
696
+ const distro = detectLinuxDistro();
697
+ const glibc = detectGlibcVersion();
698
+ return {
699
+ arch: archTag(),
700
+ macArch: macArchTag(),
701
+ glibc,
702
+ distro,
703
+ hasNvidia: args.has("cuda") ? true : (args.has("cpu") ? false : detectNvidiaGpu()),
704
+ };
705
+ }
706
+
707
+ function mb(bytes) { return (bytes / 1048576).toFixed(0); }
708
+
709
+ async function cmdList(args) {
710
+ const tag = args.get("release") || DEFAULT_RELEASE;
711
+ const assets = await fetchReleaseAssets(tag);
712
+ console.log(`Release "${tag}" on ${GITHUB_REPO}:`);
713
+ const apps = assets.filter((a) => a.name.startsWith("ACloudViewer-"));
714
+ const wheels = assets.filter((a) => a.name.endsWith(".whl"));
715
+ if (apps.length) {
716
+ console.log("\nDesktop installers:");
717
+ for (const a of apps) console.log(` ${a.name} ${mb(a.size)} MB`);
718
+ }
719
+ if (wheels.length) {
720
+ console.log("\nPython wheels:");
721
+ for (const a of wheels) console.log(` ${a.name} ${mb(a.size)} MB`);
722
+ }
723
+ }
724
+
725
+ async function cmdUninstall(args) {
726
+ // NOT a mirror of installer.py (which has no uninstall command): this
727
+ // wraps the QtIFW maintenance tool's own purge, whose semantics were
728
+ // verified end-to-end (removes the install dir contents, exit 0).
729
+ const prefixArg = args.get("prefix");
730
+ if (prefixArg && typeof prefixArg !== "string") throw new Error("--prefix requires a directory argument.");
731
+ const target = prefixArg || DEFAULT_TARGET;
732
+ const mtName = isWindows() ? "maintenancetool.exe" : "maintenancetool";
733
+ // Verified real QtIFW layout: maintenancetool sits at the target root;
734
+ // check bin/ too in case a future repack moves it.
735
+ const mt = [path.join(target, mtName), path.join(target, "bin", mtName)]
736
+ .find((p) => fs.existsSync(p));
737
+ if (!mt) {
738
+ throw new Error(
739
+ `no QtIFW maintenance tool found under ${target}. ` +
740
+ "If ACloudViewer is installed in another location, pass --prefix <dir>; " +
741
+ "if it is not installed, there is nothing to uninstall.");
742
+ }
743
+ console.log(`ACloudViewer installation found at: ${target}`);
744
+ console.log(`Uninstaller: ${mt} (purge removes ALL files the installer placed in that directory)`);
745
+ if (!args.has("yes")) {
746
+ // Safety gate: purge is destructive, so a non-interactive shell (agents,
747
+ // CI, piped input) must opt in explicitly instead of being surprised.
748
+ if (!process.stdin.isTTY) {
749
+ throw new Error("refusing to uninstall without confirmation in a non-interactive shell; re-run with --yes.");
750
+ }
751
+ const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout });
752
+ const answer = (await new Promise((resolve) => rl.question("Proceed with uninstall? [y/N] ", resolve))).trim().toLowerCase();
753
+ rl.close();
754
+ if (answer !== "y" && answer !== "yes") {
755
+ console.log("aborted.");
756
+ return;
757
+ }
758
+ }
759
+ const env = { ...process.env };
760
+ if (!isWindows()) env.QT_QPA_PLATFORM = env.QT_QPA_PLATFORM || "minimal";
761
+ const r = spawnSync(mt, ["purge", "--confirm-command"], { stdio: "inherit", env });
762
+ if (r.status !== 0) {
763
+ throw new Error(`maintenancetool purge exited with code ${r.status}.`);
764
+ }
765
+ const leftover = fs.existsSync(target) ? fs.readdirSync(target).length : 0;
766
+ console.log(leftover === 0
767
+ ? `\nUninstalled. ${target} is gone.`
768
+ : `\nUninstall finished; ${leftover} item(s) remain under ${target} (installer logs or shortcuts the purger does not own).`);
769
+ }
770
+
771
+ function usage() {
772
+ console.log(`ACloudViewer installer bootstrap (npm package @asher-1/acloudviewer)
773
+
774
+ Usage:
775
+ acloudviewer install [--cuda|--cpu] [--silent] [--prefix <dir>] [--parallel <n>] [--release <tag>]
776
+ acloudviewer install --from <installer-file> [--silent] [--prefix <dir>]
777
+ acloudviewer install --wheel [--cpu-only] [--python <path>] [--release <tag>]
778
+ acloudviewer list [--release <tag>]
779
+ acloudviewer uninstall [--prefix <dir>] [--yes]
780
+ acloudviewer version | help
781
+
782
+ Options:
783
+ --cuda / --cpu Force a build flavor (default: auto-detect NVIDIA GPU).
784
+ --silent Unattended install (QtIFW console mode / dmg headless).
785
+ --prefix <dir> Install root for --silent (default:
786
+ ~/.local/share/ACloudViewer, same as the pip harness).
787
+ --parallel <n> Concurrent download segments, 1-${MAX_PARALLEL} (default: 4 for
788
+ files >= 64 MB when the server supports ranges; falls
789
+ back to a single stream automatically).
790
+ --wheel Install the Python wheel instead of the desktop app
791
+ (requires Python 3.10+).
792
+ --from <file> Install from a local installer file (.exe/.dmg/.run)
793
+ instead of downloading from GitHub Releases.
794
+ --yes For uninstall: skip the confirmation prompt.
795
+ --dry-run Print the matched asset and URL, then exit.
796
+ --release <tag> GitHub release tag (default: ${DEFAULT_RELEASE}).
797
+
798
+ Downloads land in a temp dir and are removed after install. Segmented
799
+ downloads write into one preallocated file and abort any stream that stalls
800
+ for ${STALL_TIMEOUT_MS / 1000}s; failed attempts resume from the bytes already
801
+ received instead of restarting. Uninstall runs the QtIFW maintenancetool
802
+ ("purge") of the installation directory — the same tool the install wizard
803
+ provides.`);
804
+ }
805
+
806
+ // ── Main ─────────────────────────────────────────────────────────────────
807
+
808
+ async function main() {
809
+ const argv = process.argv.slice(2);
810
+ const cmd = argv[0] || "help";
811
+ const args = new Map();
812
+ for (let i = 1; i < argv.length; i++) {
813
+ if (argv[i].startsWith("--")) {
814
+ const next = argv[i + 1];
815
+ if (next && !next.startsWith("--")) { args.set(argv[i].slice(2), next); i++; }
816
+ else args.set(argv[i].slice(2), true);
817
+ }
818
+ }
819
+ switch (cmd) {
820
+ case "install": await cmdInstall(args); break;
821
+ case "list": await cmdList(args); break;
822
+ case "uninstall": await cmdUninstall(args); break;
823
+ case "version": console.log(require("../package.json").version); break;
824
+ case "help": usage(); break;
825
+ default: console.error(`unknown command: ${cmd}\n`); usage(); process.exit(1);
826
+ }
827
+ }
828
+
829
+ // Expose the download engine for tests when require()d as a module; run the
830
+ // CLI only when executed directly.
831
+ if (require.main === module) {
832
+ module.exports = { downloadWithProgress };
833
+ main().catch((err) => die(err && err.message ? err.message : String(err)));
834
+ } else {
835
+ module.exports = { downloadWithProgress };
836
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@asher-1/acloudviewer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.9.5",
4
+ "description": "Cross-platform installer bootstrap for ACloudViewer — fetches and launches the official Windows/macOS/Linux installer (or Python wheel) from GitHub Releases.",
5
+ "license": "GPL-2.0-only",
6
+ "bin": {
7
+ "acloudviewer": "bin/acloudviewer.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Asher-1/ACloudViewer.git"
19
+ },
20
+ "homepage": "https://asher-1.github.io/ACloudViewer/",
21
+ "bugs": {
22
+ "url": "https://github.com/Asher-1/ACloudViewer/issues"
23
+ },
24
+ "keywords": [
25
+ "acloudviewer",
26
+ "cloudviewer",
27
+ "point-cloud",
28
+ "3d",
29
+ "installer",
30
+ "cli"
31
+ ]
32
+ }