premanmcp 1.1.11 → 1.1.13

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/bin/desktop.js CHANGED
@@ -31,25 +31,33 @@
31
31
  * has to do.
32
32
  */
33
33
 
34
- import { spawn, spawnSync } from "node:child_process";
34
+ import { spawnSync } from "node:child_process";
35
35
  import { createHash } from "node:crypto";
36
+ import { once } from "node:events";
36
37
  import {
38
+ accessSync,
37
39
  chmodSync,
40
+ constants,
41
+ createWriteStream,
38
42
  existsSync,
39
43
  mkdirSync,
40
44
  mkdtempSync,
41
45
  readFileSync,
42
46
  readdirSync,
47
+ renameSync,
43
48
  rmSync,
49
+ statSync,
44
50
  writeFileSync,
45
51
  } from "node:fs";
46
52
  import os from "node:os";
47
53
  import path from "node:path";
54
+ import { finished } from "node:stream/promises";
48
55
 
49
56
  import { CREDENTIALS_DIR, makeArgs } from "./shared.js";
50
57
 
51
58
  export const DESKTOP_HELP = `
52
59
  Install-desktop options:
60
+ --allow-unverified Install even when no published checksum can be read
53
61
  --arch <arm64|x64> Override architecture detection
54
62
  --dest <dir> Install directory. Defaults to /Applications
55
63
  --force Reinstall even when the latest release is already installed
@@ -60,7 +68,43 @@ Install-desktop options:
60
68
  const RELEASES_BASE = "https://github.com/PreMan-Inc/PreMan-Desktop/releases";
61
69
  const RELEASE_API = "https://api.github.com/repos/PreMan-Inc/PreMan-Desktop/releases/latest";
62
70
  export const APP_NAME = "PreMan.app";
63
- const DOWNLOAD_TIMEOUT_MS = 300_000;
71
+ /**
72
+ * How long the download may go without receiving a byte.
73
+ *
74
+ * This was a budget for the whole transfer, which is not the same thing: five
75
+ * minutes is a 150 MB image at 500 KB/s, so a slow but perfectly healthy
76
+ * connection was aborted near the end of it, the partial file thrown away and
77
+ * nothing retried. A download making steady progress has nothing wrong with it
78
+ * however long it takes. What is worth giving up on is silence.
79
+ */
80
+ const DOWNLOAD_STALL_TIMEOUT_MS = 60_000;
81
+
82
+ /** A connection that drops is worth asking again; a status code is an answer. */
83
+ const DOWNLOAD_ATTEMPTS = 3;
84
+ const RETRY_BACKOFF_MS = 1_000;
85
+
86
+ /**
87
+ * The cap for tag resolution and the manifest.
88
+ *
89
+ * These are small requests that run before the progress line exists, so a hung
90
+ * connection to the release host stalled the whole walk with nothing on screen
91
+ * to say what it was waiting for. Unlike the image, they are bounded by total
92
+ * time: there is no such thing as one of them legitimately taking a minute.
93
+ */
94
+ const METADATA_TIMEOUT_MS = 15_000;
95
+
96
+ /**
97
+ * The oldest macOS the app will start on.
98
+ *
99
+ * This is the `LSMinimumSystemVersion` of the shipped bundle, which comes from
100
+ * Electron and not from the desktop repo's electron-builder.yml -- that sets no
101
+ * `minimumSystemVersion`, so the floor moves on its own the next time the app
102
+ * takes an Electron major. It cannot be read out of a build that has not been
103
+ * downloaded yet, so it is duplicated here and has to be changed with it.
104
+ * Verified against release/mac-arm64/PreMan.app and node_modules/electron/dist
105
+ * on Electron 38, both of which say 12.0.
106
+ */
107
+ const MIN_MACOS_VERSION = "12.0";
64
108
 
65
109
  /**
66
110
  * Where the desktop app looks for a session the CLI just established.
@@ -260,7 +304,13 @@ export async function openDesktopSignedIn(
260
304
  return { state: "opened-already-running", handoff: handoff.state };
261
305
  }
262
306
 
263
- launch(destination);
307
+ // An injected launch returns whatever the test wants it to, so only an
308
+ // explicit failure counts as one.
309
+ const launched = launch(destination);
310
+ if (launched?.ok === false) {
311
+ reportLaunchFailure(destination, launched.detail);
312
+ return { state: "launch-failed", detail: launched.detail, handoff: handoff.state };
313
+ }
264
314
  if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
265
315
 
266
316
  if (wasRunning && !restarted) {
@@ -285,11 +335,40 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
285
335
 
286
336
  // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
287
337
  // which may well pick a different copy than the one just installed.
288
- const defaultLaunch = (destination) =>
289
- spawn("open", ["-a", installedAppPath(destination)], {
290
- stdio: "ignore",
291
- detached: true,
292
- }).unref();
338
+ //
339
+ // Waited on, and its exit status read. A detached spawn with its stdio ignored
340
+ // reported a Gatekeeper refusal, a damaged bundle and a successful launch
341
+ // identically, so the walk's closing line told people to sign in to a window
342
+ // that was never going to appear. `open` returns once LaunchServices has
343
+ // accepted the launch rather than once the app is up, so this does not wait for
344
+ // a cold Electron start.
345
+ const defaultLaunch = (destination) => {
346
+ const opened = spawnSync("open", ["-a", installedAppPath(destination)], { encoding: "utf8" });
347
+ if (opened.status === 0) return { ok: true };
348
+ return {
349
+ ok: false,
350
+ detail:
351
+ String(opened.stderr || opened.error?.message || `open exited ${opened.status}`).trim() ||
352
+ "open reported no reason",
353
+ };
354
+ };
355
+
356
+ /**
357
+ * Say that a launch failed, here rather than in the caller.
358
+ *
359
+ * Every other outcome is phrased by whoever asked for it, which is the right
360
+ * split for results a caller can choose between. A launch that did not happen
361
+ * is not one of those: the failure is invisible unless it is reported where it
362
+ * was found, and what to do about it belongs to Gatekeeper rather than to the
363
+ * flow that happened to be running.
364
+ */
365
+ function reportLaunchFailure(destination, detail) {
366
+ process.stdout.write(
367
+ `Could not open ${installedAppPath(destination)}: ${detail}\n` +
368
+ ` macOS may be refusing the bundle. Open it once from Finder -- right-click PreMan,\n` +
369
+ ` then Open -- or reinstall it with: preman install-desktop --force\n`
370
+ );
371
+ }
293
372
 
294
373
  /**
295
374
  * Open a Playground session URL in PreMan.app on macOS when it is installed.
@@ -299,11 +378,15 @@ export function openPlayground(url) {
299
378
  const target = String(url || "").trim();
300
379
  if (!target) return "none";
301
380
  const app = path.join("/Applications", APP_NAME);
302
- if (process.platform === "darwin" && existsSync(app)) {
303
- spawn("open", ["-a", "PreMan", target], { stdio: "ignore", detached: true }).unref();
304
- return "desktop";
305
- }
306
- return "web";
381
+ if (process.platform !== "darwin" || !existsSync(app)) return "web";
382
+ // The bundle path, and waited on: the app being present says nothing about it
383
+ // being openable, and a detached spawn whose stdio was ignored could not tell
384
+ // a quarantined or damaged copy from a successful handoff. A failed spawn also
385
+ // arrives as an asynchronous 'error' event, which with no listener on it takes
386
+ // the whole process down.
387
+ const opened = spawnSync("open", ["-a", app, target], { encoding: "utf8" });
388
+ if (opened.status === 0) return "desktop";
389
+ return "app-failed";
307
390
  }
308
391
 
309
392
  export function printPlayground(url) {
@@ -313,6 +396,13 @@ export function printPlayground(url) {
313
396
  process.stdout.write(`Watch it: ${target}\n`);
314
397
  if (where === "desktop") {
315
398
  process.stdout.write("Opened in PreMan desktop\n");
399
+ } else if (where === "app-failed") {
400
+ // Handing the URL to the browser is the fallback, and it was a silent one:
401
+ // nothing said the app had been tried and refused, so a link that opened
402
+ // nowhere read as the CLI having printed a URL for no reason.
403
+ process.stdout.write(
404
+ "PreMan desktop would not open it, so nothing was opened. Use the link above in a browser.\n"
405
+ );
316
406
  }
317
407
  }
318
408
 
@@ -339,21 +429,86 @@ export function versionFromTag(tag) {
339
429
  return String(tag || "").replace(/^v/i, "");
340
430
  }
341
431
 
342
- export function detectArch() {
343
- const machine = os.machine ? os.machine() : os.arch();
344
- if (machine === "arm64" || machine === "aarch64") return "arm64";
345
- if (machine === "x86_64" || machine === "x64") return "x64";
346
- // Rosetta reports x86_64 for the Node binary itself, so ask sysctl whether the
347
- // hardware is actually Apple silicon before handing over an Intel build.
348
- const probe = spawnSync("sysctl", ["-n", "hw.optional.arm64"], { encoding: "utf8" });
349
- if (probe.status === 0 && probe.stdout.trim() === "1") return "arm64";
350
- return "x64";
432
+ /**
433
+ * Whether a sysctl this machine may not have answers yes.
434
+ *
435
+ * `stdout` is null when the binary could not be spawned at all, which is what
436
+ * made reading it a crash rather than the fallback it was written to be.
437
+ */
438
+ function sysctlIsOne(key) {
439
+ const probe = spawnSync("sysctl", ["-n", key], { encoding: "utf8" });
440
+ return probe.status === 0 && String(probe.stdout || "").trim() === "1";
441
+ }
442
+
443
+ /**
444
+ * Which build this machine should be given.
445
+ *
446
+ * The reported architecture cannot have the last word on macOS. Rosetta runs
447
+ * the Node binary translated, so `os.machine()` says `x86_64` on an Apple
448
+ * silicon Mac whenever the CLI is started from an Intel node or an Intel
449
+ * terminal -- and that is exactly the machine that must not be handed the Intel
450
+ * app, because it would run it translated and then keep it: electron-updater
451
+ * follows the architecture the app was installed as, so the wrong one is not
452
+ * something a later update corrects. Returning on the reported value first
453
+ * meant the probe written for that case could never run.
454
+ *
455
+ * `sysctl.proc_translated` is the process being translated and
456
+ * `hw.optional.arm64` the hardware under it; asking both covers a translated
457
+ * process whichever one of them Rosetta chooses to answer. Neither exists on an
458
+ * Intel Mac and `sysctl` itself does not exist elsewhere, so a probe that
459
+ * errors or prints something unexpected leaves the reported value standing.
460
+ */
461
+ export function detectArch({
462
+ machine = os.machine ? os.machine() : os.arch(),
463
+ platform = process.platform,
464
+ // Injected so the translated case can be exercised on a machine that is not
465
+ // being translated, which is every machine these tests run on.
466
+ sysctl = sysctlIsOne,
467
+ } = {}) {
468
+ const reported = machine === "arm64" || machine === "aarch64" ? "arm64" : "x64";
469
+ if (reported === "arm64" || platform !== "darwin") return reported;
470
+ if (sysctl("sysctl.proc_translated") || sysctl("hw.optional.arm64")) return "arm64";
471
+ return reported;
472
+ }
473
+
474
+ /**
475
+ * The running macOS version when it is below what the app needs, else "".
476
+ *
477
+ * `sw_vers` rather than `os.release()`, which reports the Darwin kernel version
478
+ * and would need a mapping table kept up to date for no benefit. A probe that
479
+ * cannot answer is not a reason to refuse an install, so an unreadable version
480
+ * goes ahead and lets the app speak for itself.
481
+ */
482
+ export function macosProductVersion() {
483
+ const probe = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf8" });
484
+ if (probe.status !== 0) return "";
485
+ return String(probe.stdout || "").trim();
486
+ }
487
+
488
+ export function macosBelowMinimum(probe = macosProductVersion) {
489
+ const running = probe();
490
+ if (!running) return "";
491
+ return versionBelow(running, MIN_MACOS_VERSION) ? running : "";
492
+ }
493
+
494
+ /** Field by field and numeric: "11.7.10" is below "12.0", "26.5" is not. */
495
+ export function versionBelow(version, minimum) {
496
+ const fields = (value) => String(value).split(".").map((part) => Number.parseInt(part, 10) || 0);
497
+ const left = fields(version);
498
+ const right = fields(minimum);
499
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
500
+ const a = left[i] ?? 0;
501
+ const b = right[i] ?? 0;
502
+ if (a !== b) return a < b;
503
+ }
504
+ return false;
351
505
  }
352
506
 
353
507
  async function fetchLatestRelease() {
354
508
  try {
355
509
  const resp = await fetch(RELEASE_API, {
356
510
  headers: { Accept: "application/vnd.github+json", "User-Agent": "premanmcp-cli" },
511
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
357
512
  });
358
513
  if (!resp.ok) return null;
359
514
  return await resp.json();
@@ -384,6 +539,7 @@ async function resolveReleaseTag(arch) {
384
539
  method: "HEAD",
385
540
  redirect: "manual",
386
541
  headers: { "User-Agent": "premanmcp-cli" },
542
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
387
543
  });
388
544
  return tagFromRedirect(resp.headers.get("location"));
389
545
  } catch {
@@ -423,15 +579,26 @@ export function parseMacManifest(text, arch) {
423
579
  }
424
580
 
425
581
  async function expectedDigest(arch, tag = null) {
582
+ let resp;
426
583
  try {
427
- const resp = await fetch(manifestUrl(tag), {
584
+ resp = await fetch(manifestUrl(tag), {
428
585
  headers: { "User-Agent": "premanmcp-cli" },
586
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
429
587
  });
430
- if (!resp.ok) return null;
431
- return parseMacManifest(await resp.text(), arch);
432
- } catch {
433
- return null;
588
+ } catch (err) {
589
+ return { state: "unreachable", detail: err?.message || "the request failed" };
434
590
  }
591
+ if (!resp.ok) return { state: "unreachable", detail: `HTTP ${resp.status}` };
592
+ let digest;
593
+ try {
594
+ digest = parseMacManifest(await resp.text(), arch);
595
+ } catch (err) {
596
+ return { state: "unreachable", detail: err?.message || "the manifest could not be read" };
597
+ }
598
+ // A manifest that reads fine but carries no entry for this architecture is
599
+ // the release's own omission rather than anything to do with the transport,
600
+ // and the two are not answered the same way.
601
+ return digest ? { state: "ok", digest } : { state: "unpublished" };
435
602
  }
436
603
 
437
604
  const BYTES_PER_MB = 1024 * 1024;
@@ -470,33 +637,111 @@ export function progressLine() {
470
637
  return { tick, clear };
471
638
  }
472
639
 
473
- export async function download(url, destination, onProgress) {
640
+ /** How much of a partial download survived the connection dropping. */
641
+ function partialSize(filePath) {
642
+ try {
643
+ return statSync(filePath).size;
644
+ } catch {
645
+ return 0;
646
+ }
647
+ }
648
+
649
+ /**
650
+ * How large the file is altogether, which on a resumed request `content-length`
651
+ * does not say: that describes the remainder being sent.
652
+ */
653
+ function totalBytes(resp, from) {
654
+ const whole = resp.headers.get("content-range")?.match(/\/(\d+)\s*$/)?.[1];
655
+ if (whole) return Number(whole);
656
+ const length = Number(resp.headers.get("content-length")) || 0;
657
+ return length ? length + from : 0;
658
+ }
659
+
660
+ /**
661
+ * One attempt at the image, written to disk as it arrives.
662
+ *
663
+ * The body used to be collected into an array of chunks and concatenated, which
664
+ * asks a small machine for the whole 150 MB twice over at the moment it
665
+ * finishes. Streaming it also means an interrupted transfer leaves the bytes it
666
+ * did get where the next attempt can continue from them.
667
+ *
668
+ * A resume is only trusted on a 206. A server that ignores the range header
669
+ * answers 200 with the whole file, and appending that to what is already on
670
+ * disk builds an image nothing but the checksum would catch.
671
+ */
672
+ async function downloadOnce(url, destination, onProgress, offset) {
474
673
  const controller = new AbortController();
475
- const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
674
+ let timer = null;
675
+ const stall = () => {
676
+ clearTimeout(timer);
677
+ timer = setTimeout(
678
+ () => controller.abort(new Error(`no data for ${DOWNLOAD_STALL_TIMEOUT_MS / 1000}s`)),
679
+ DOWNLOAD_STALL_TIMEOUT_MS
680
+ );
681
+ };
682
+ const headers = { "User-Agent": "premanmcp-cli" };
683
+ if (offset > 0) headers.Range = `bytes=${offset}-`;
684
+ // Started before the fetch, so a connection that never answers is bounded by
685
+ // the same rule as one that goes quiet halfway.
686
+ stall();
476
687
  try {
477
- const resp = await fetch(url, { signal: controller.signal, redirect: "follow" });
478
- if (!resp.ok) throw new Error(`download failed: ${resp.status} ${url}`);
479
- // Read the body in chunks rather than as one arrayBuffer: the buffering is
480
- // the same either way, but only this version can say how far along it is.
481
- const total = Number(resp.headers.get("content-length")) || 0;
482
- const reader = resp.body.getReader();
483
- const chunks = [];
484
- let received = 0;
485
- for (;;) {
486
- const { done, value } = await reader.read();
487
- if (done) break;
488
- chunks.push(value);
489
- received += value.length;
490
- onProgress?.(received, total);
688
+ const resp = await fetch(url, { signal: controller.signal, redirect: "follow", headers });
689
+ if (!resp.ok) {
690
+ const error = new Error(`download failed: ${resp.status} ${url}`);
691
+ // A status is an answer rather than a dropped connection, so asking again
692
+ // gets the same one -- and 404 in particular is what the caller reads to
693
+ // fall back from a pinned asset to the version-less alias.
694
+ error.fatal = true;
695
+ throw error;
491
696
  }
492
- const buffer = Buffer.concat(chunks);
493
- writeFileSync(destination, buffer);
494
- return buffer.length;
697
+ const resumed = offset > 0 && resp.status === 206;
698
+ const file = createWriteStream(destination, { flags: resumed ? "a" : "w" });
699
+ let received = resumed ? offset : 0;
700
+ const total = totalBytes(resp, received);
701
+ try {
702
+ for await (const chunk of resp.body) {
703
+ if (!file.write(chunk)) await once(file, "drain");
704
+ received += chunk.length;
705
+ stall();
706
+ onProgress?.(received, total);
707
+ }
708
+ file.end();
709
+ await finished(file);
710
+ } catch (err) {
711
+ file.destroy();
712
+ throw err;
713
+ }
714
+ return received;
495
715
  } finally {
496
716
  clearTimeout(timer);
497
717
  }
498
718
  }
499
719
 
720
+ /**
721
+ * Fetch `url` to `destination`, retrying a connection that drops.
722
+ *
723
+ * A transfer that died three quarters of the way through a 150 MB image used to
724
+ * end the install, which is the most expensive possible moment to give up: the
725
+ * bytes were already on disk and the release had already been resolved. So the
726
+ * attempt is repeated, resuming from the partial file where the server allows
727
+ * it, and the retry is announced because the progress line otherwise appears to
728
+ * jump backwards on its own.
729
+ */
730
+ export async function download(url, destination, onProgress) {
731
+ for (let attempt = 1; ; attempt += 1) {
732
+ try {
733
+ return await downloadOnce(url, destination, onProgress, attempt === 1 ? 0 : partialSize(destination));
734
+ } catch (err) {
735
+ if (err?.fatal || attempt >= DOWNLOAD_ATTEMPTS) throw err;
736
+ process.stdout.write(
737
+ `\n download interrupted (${err?.message || "connection lost"}); ` +
738
+ `attempt ${attempt + 1} of ${DOWNLOAD_ATTEMPTS}\n`
739
+ );
740
+ await defaultSleep(RETRY_BACKOFF_MS * attempt);
741
+ }
742
+ }
743
+ }
744
+
500
745
  function sha512Base64(filePath) {
501
746
  return createHash("sha512").update(readFileSync(filePath)).digest("base64");
502
747
  }
@@ -543,13 +788,211 @@ function appInsideVolume(volume) {
543
788
  return bundle ? path.join(volume, bundle) : null;
544
789
  }
545
790
 
546
- export async function installDesktopCommand(commandArgs = [], { onInstalled } = {}) {
791
+ /**
792
+ * The copy already on this machine, wherever it ended up.
793
+ *
794
+ * Two places rather than one, because an install lands in ~/Applications when
795
+ * /Applications cannot be written to, and a later run that only looked in
796
+ * /Applications would not find it: it would fetch the same release again every
797
+ * time it ran, and install a second copy beside the first.
798
+ */
799
+ function existingInstall(requested) {
800
+ const candidates = requested
801
+ ? [requested]
802
+ : ["/Applications", path.join(os.homedir(), "Applications")];
803
+ for (const dir of candidates) {
804
+ const version = installedDesktopVersion(dir);
805
+ if (version) return { destination: dir, version };
806
+ }
807
+ return { destination: requested || "/Applications", version: "" };
808
+ }
809
+
810
+ function writableDirectory(dir) {
811
+ try {
812
+ accessSync(dir, constants.W_OK);
813
+ return true;
814
+ } catch {
815
+ return false;
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Where this install can actually write.
821
+ *
822
+ * /Applications belongs to root:admin, so an unprivileged account -- which is
823
+ * every account on plenty of MDM-enrolled Macs -- cannot write to it, and there
824
+ * was no fallback: the install resolved a release, downloaded 150 MB, attached
825
+ * the image and only then failed, on the copy. ~/Applications is a real
826
+ * LaunchServices location that needs no password, so it is the second choice,
827
+ * and the caller says where the app went because nobody thinks to look there.
828
+ *
829
+ * An explicit --dest is never second-guessed. The suite passes one to keep
830
+ * installs off a developer's own /Applications, and quietly installing
831
+ * somewhere else instead would defeat the point of it.
832
+ */
833
+ function installDestination(requested, existing) {
834
+ if (requested) {
835
+ try {
836
+ mkdirSync(requested, { recursive: true });
837
+ } catch {
838
+ // Reported by the check below, which says the same thing better.
839
+ }
840
+ if (!writableDirectory(requested)) {
841
+ throw new Error(`cannot write to ${requested}. Nothing was downloaded.`);
842
+ }
843
+ return { destination: requested, fallbackFrom: null };
844
+ }
845
+ // An update goes back to wherever the app already is, or a copy installed in
846
+ // the fallback directory gets shadowed by a second one in /Applications.
847
+ if (existing.version && writableDirectory(existing.destination)) {
848
+ return { destination: existing.destination, fallbackFrom: null };
849
+ }
850
+ if (writableDirectory("/Applications")) {
851
+ return { destination: "/Applications", fallbackFrom: null };
852
+ }
853
+ const home = path.join(os.homedir(), "Applications");
854
+ try {
855
+ mkdirSync(home, { recursive: true });
856
+ } catch {
857
+ // Same as above: the writability check is the one worth reporting.
858
+ }
859
+ if (!writableDirectory(home)) {
860
+ throw new Error(
861
+ `neither /Applications nor ${home} can be written to by this account. ` +
862
+ `Nothing was downloaded. Run this from an account that can install ` +
863
+ `applications, or pass --dest with a directory you own.`
864
+ );
865
+ }
866
+ return { destination: home, fallbackFrom: "/Applications" };
867
+ }
868
+
869
+ /**
870
+ * Refuse an image that cannot be shown to be the one that was published.
871
+ *
872
+ * A mismatch has always been fatal and stays that way: the bytes are not the
873
+ * release, and nothing about that improves by installing them. What changed is
874
+ * the missing case, which used to print one line and carry on. The manifest is
875
+ * the only integrity signal there is without our own signing infrastructure, so
876
+ * failing open means a substituted image, or a truncated one from a proxy that
877
+ * served its error page with a 200, installs exactly as quietly as a good
878
+ * download. Every release publishes latest-mac.yml beside the image, and an
879
+ * install pinned to a tag reads both out of the same release -- so a checksum
880
+ * that is missing there is a broken release rather than a normal condition.
881
+ * `--allow-unverified` is how someone says otherwise, and it is loud about it.
882
+ *
883
+ * The two cases are worded apart deliberately. "No checksum was published" and
884
+ * "the checksum did not match" are a release problem and a compromised download
885
+ * respectively, and they are not the same conversation to have with anyone.
886
+ */
887
+ function verifyDownload(dmgPath, bytes, expected, { arch, allowUnverified }) {
888
+ if (expected?.state === "ok") {
889
+ const digest = expected.digest;
890
+ if (digest.size && digest.size !== bytes) {
891
+ throw new Error(
892
+ `size mismatch: downloaded ${bytes} bytes but the release manifest ` +
893
+ `records ${digest.size} for ${digest.asset}. Nothing was installed.`
894
+ );
895
+ }
896
+ if (sha512Base64(dmgPath) !== digest.sha512) {
897
+ throw new Error(
898
+ "digest mismatch: the downloaded disk image does not match the checksum " +
899
+ "published with the release. Nothing was installed."
900
+ );
901
+ }
902
+ process.stdout.write(` checksum verified against ${digest.asset}\n`);
903
+ return digest;
904
+ }
905
+ const why =
906
+ expected?.state === "unreachable"
907
+ ? `the checksum published with this release could not be read (${expected.detail})`
908
+ : `this release publishes no checksum for the ${arch} disk image`;
909
+ if (!allowUnverified) {
910
+ throw new Error(
911
+ `unverified download: ${why}. The image was not installed, because an ` +
912
+ `image that cannot be checked against the release is not known to be ` +
913
+ `the release. Try again, or pass --allow-unverified to install it anyway.`
914
+ );
915
+ }
916
+ process.stdout.write(
917
+ ` WARNING: ${why}.\n` +
918
+ ` --allow-unverified was passed, so PreMan is being installed from an image that\n` +
919
+ ` was never checked against a published checksum.\n`
920
+ );
921
+ return null;
922
+ }
923
+
924
+ /**
925
+ * Does this look like something that will launch?
926
+ *
927
+ * A bundle is a directory, so a copy that ran out of disk or was interrupted
928
+ * leaves one behind that exists, satisfies every check this file used to make,
929
+ * and cannot start. Contents/MacOS is what LaunchServices runs, which makes it
930
+ * the one thing whose absence means the bundle certainly will not open.
931
+ *
932
+ * Info.plist is deliberately not required as well. Its only reader here is the
933
+ * installed-version check, which already falls back to the manifest when it
934
+ * cannot parse one, so insisting on it would turn something this command
935
+ * recovers from into a refusal to install.
936
+ */
937
+ function looksLikeAppBundle(bundle) {
938
+ return existsSync(path.join(bundle, "Contents", "MacOS"));
939
+ }
940
+
941
+ /**
942
+ * Put the new bundle in place without taking the old one out first.
943
+ *
944
+ * `rmSync` and then `cp -R` deleted the working copy before the replacement
945
+ * existed, and the copy is the step most likely not to finish: a full disk, a
946
+ * Ctrl-C somewhere in the ninety seconds it takes, a directory that turns out
947
+ * to want a password. What that left behind was no PreMan at all, which is
948
+ * worse than the version the customer was updating away from. So the copy lands
949
+ * beside the target under a name of its own, is checked, and is only moved into
950
+ * place once it is whole -- and the bundle it replaces is not removed until
951
+ * after that move has succeeded.
952
+ */
953
+ function installBundle(source, target) {
954
+ // Hidden, and outside the .app extension: a half-copied PreMan.app in
955
+ // /Applications is something Finder offers to open and LaunchServices
956
+ // registers, and it exists for as long as the copy takes.
957
+ const staging = path.join(path.dirname(target), `.${APP_NAME}.installing`);
958
+ const replaced = path.join(path.dirname(target), `.${APP_NAME}.replaced`);
959
+ rmSync(staging, { recursive: true, force: true });
960
+ rmSync(replaced, { recursive: true, force: true });
961
+ try {
962
+ run("cp", ["-R", source, staging]);
963
+ chmodSync(staging, 0o755);
964
+ if (!looksLikeAppBundle(staging)) {
965
+ throw new Error(
966
+ `the copy of ${path.basename(source)} has no Contents/MacOS in it, so it ` +
967
+ `would not launch. Nothing was installed.`
968
+ );
969
+ }
970
+ if (existsSync(target)) renameSync(target, replaced);
971
+ try {
972
+ renameSync(staging, target);
973
+ } catch (err) {
974
+ // Both names are in the same directory, so this is not a cross-device
975
+ // move; whatever it is, the copy that was working goes back.
976
+ if (existsSync(replaced) && !existsSync(target)) renameSync(replaced, target);
977
+ throw err;
978
+ }
979
+ } finally {
980
+ rmSync(staging, { recursive: true, force: true });
981
+ rmSync(replaced, { recursive: true, force: true });
982
+ }
983
+ }
984
+
985
+ export async function installDesktopCommand(
986
+ commandArgs = [],
987
+ { onInstalled, macosVersion = macosProductVersion } = {}
988
+ ) {
547
989
  const args = makeArgs(commandArgs);
548
990
 
549
991
  if (process.platform !== "darwin" && !args.has("--print-url")) {
550
992
  process.stdout.write(
551
993
  `The PreMan desktop app is macOS-only right now.\n` +
552
- `Downloads for other platforms: ${RELEASES_BASE}/latest\n`
994
+ `Downloads for other platforms: ${RELEASES_BASE}/latest\n` +
995
+ `Nothing was downloaded here: that page is the whole of the install on this platform.\n`
553
996
  );
554
997
  return { state: "unsupported", platform: process.platform };
555
998
  }
@@ -566,24 +1009,75 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
566
1009
  return { state: "printed", url: alias, arch };
567
1010
  }
568
1011
 
1012
+ const tooOld = macosBelowMinimum(macosVersion);
1013
+ if (tooOld) {
1014
+ // Before the download rather than after it. An older Mac used to fetch the
1015
+ // whole image, install it, and then be told by LaunchServices that the
1016
+ // application could not be opened -- a message about the OS, from the OS,
1017
+ // with nothing in it to say that the version floor was the reason or that
1018
+ // the 150 MB had been spent on something that was never going to run.
1019
+ process.stdout.write(
1020
+ `The PreMan desktop app requires macOS ${MIN_MACOS_VERSION} or later.\n` +
1021
+ `This Mac is running macOS ${tooOld}, so nothing was downloaded.\n` +
1022
+ `Update macOS and run preman install-desktop again.\n`
1023
+ );
1024
+ return { state: "unsupported-macos", macos: tooOld, required: MIN_MACOS_VERSION, arch };
1025
+ }
1026
+
1027
+ // Read before anything is resolved, so whether there is work to do does not
1028
+ // depend on the release host being reachable.
1029
+ const existing = existingInstall(args.value("--dest", ""));
1030
+ const installed = existing.version;
1031
+
569
1032
  // Resolved once. Everything below is pinned to this tag, so the version
570
1033
  // reported, the image downloaded and the checksum verified against it all
571
1034
  // describe the same release even if another ships while this runs.
572
1035
  const tag = await resolveReleaseTag(arch);
573
1036
  const url = dmgUrl(arch, tag);
574
1037
  const version = tag ? versionFromTag(tag) : "latest";
575
- const destination = args.value("--dest", "/Applications");
576
1038
 
577
- const installed = installedDesktopVersion(destination);
578
- if (tag && installed && installed === version && !args.has("--force")) {
579
- // Re-fetching 100 MB to arrive at the bytes already on disk is the most
580
- // literal form of the "it downloads the update again" complaint, and it is
581
- // what this command did every time it was run.
1039
+ if (installed && !args.has("--force")) {
1040
+ if (tag && installed === version) {
1041
+ // Re-fetching 100 MB to arrive at the bytes already on disk is the most
1042
+ // literal form of the "it downloads the update again" complaint, and it is
1043
+ // what this command did every time it was run.
1044
+ process.stdout.write(
1045
+ `PreMan ${installed} is already installed and is the latest release.\n` +
1046
+ `Nothing to do. Use --force to reinstall.\n`
1047
+ );
1048
+ return {
1049
+ state: "current",
1050
+ version: installed,
1051
+ arch,
1052
+ path: installedAppPath(existing.destination),
1053
+ };
1054
+ }
1055
+ if (!tag) {
1056
+ // This whole branch used to be gated on having a tag, so a release host
1057
+ // that could not be reached turned "you already have it" into an error --
1058
+ // on the one question here whose answer is entirely on the local disk.
1059
+ process.stdout.write(
1060
+ `PreMan ${installed} is installed at ${installedAppPath(existing.destination)}.\n` +
1061
+ `The release server could not be reached, so whether a newer build exists is\n` +
1062
+ `unknown and nothing was changed. Use --force to reinstall.\n`
1063
+ );
1064
+ return {
1065
+ state: "current",
1066
+ version: installed,
1067
+ arch,
1068
+ path: installedAppPath(existing.destination),
1069
+ latestKnown: false,
1070
+ };
1071
+ }
1072
+ }
1073
+
1074
+ const { destination, fallbackFrom } = installDestination(args.value("--dest", ""), existing);
1075
+ if (fallbackFrom) {
582
1076
  process.stdout.write(
583
- `PreMan ${installed} is already installed and is the latest release.\n` +
584
- `Nothing to do. Use --force to reinstall.\n`
1077
+ `${fallbackFrom} cannot be written to by this account, which is normal on a Mac managed\n` +
1078
+ `by an organisation. PreMan is being installed to ${destination} instead --\n` +
1079
+ `look for it there rather than in ${fallbackFrom}. It launches the same way.\n`
585
1080
  );
586
- return { state: "current", version: installed, arch, path: installedAppPath(destination) };
587
1081
  }
588
1082
 
589
1083
  if (installed === version) {
@@ -624,24 +1118,10 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
624
1118
 
625
1119
  // Read from the same place the image came from, pinned or not, so the two
626
1120
  // can never describe different releases.
627
- const expected = await expectedDigest(arch, pinned);
628
- if (expected) {
629
- if (expected.size && expected.size !== bytes) {
630
- throw new Error(
631
- `size mismatch: downloaded ${bytes} bytes but the release manifest ` +
632
- `records ${expected.size} for ${expected.asset}. Nothing was installed.`
633
- );
634
- }
635
- if (sha512Base64(dmgPath) !== expected.sha512) {
636
- throw new Error(
637
- "digest mismatch: the downloaded disk image does not match the checksum " +
638
- "published with the release. Nothing was installed."
639
- );
640
- }
641
- process.stdout.write(` checksum verified against ${expected.asset}\n`);
642
- } else {
643
- process.stdout.write(" checksum unavailable for this release; skipping verification\n");
644
- }
1121
+ const verified = verifyDownload(dmgPath, bytes, await expectedDigest(arch, pinned), {
1122
+ arch,
1123
+ allowUnverified: args.has("--allow-unverified"),
1124
+ });
645
1125
 
646
1126
  mounted = attachDiskImage(dmgPath, path.join(workDir, "mnt"));
647
1127
 
@@ -650,14 +1130,12 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
650
1130
  throw new Error(`no application bundle found inside the disk image at ${mounted}`);
651
1131
  }
652
1132
  const target = path.join(destination, APP_NAME);
653
- if (existsSync(target)) rmSync(target, { recursive: true, force: true });
654
- run("cp", ["-R", source, target]);
655
- chmodSync(target, 0o755);
1133
+ installBundle(source, target);
656
1134
 
657
1135
  // The manifest's version describes bytes whose size and digest were just
658
1136
  // checked against it, which makes it a better answer than the tag resolved
659
1137
  // before any of this ran -- and the only honest one on the alias fallback.
660
- const installedVersion = expected?.version || installedDesktopVersion(destination) || version;
1138
+ const installedVersion = verified?.version || installedDesktopVersion(destination) || version;
661
1139
  const result = { state: "installed", version: installedVersion, arch, path: target };
662
1140
  process.stdout.write(`\nInstalled PreMan ${installedVersion} to ${target}\n\n`);
663
1141
  onInstalled?.(result);