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/account.js +48 -0
- package/bin/cli.js +73 -14
- package/bin/connect/configs.js +20 -6
- package/bin/desktop.js +557 -79
- package/bin/eval.js +328 -50
- package/bin/eval_entry.js +9 -2
- package/bin/eval_generation_contract.py +72 -0
- package/bin/eval_generation_debug.py +279 -0
- package/bin/eval_generation_status.py +284 -0
- package/bin/eval_harness.py +198 -4
- package/bin/eval_start.js +203 -0
- package/bin/eval_target.js +20 -1
- package/bin/eval_templates.js +447 -21
- package/bin/hook.js +21 -1
- package/bin/integrations.js +20 -4
- package/bin/probe.js +323 -0
- package/bin/runner.js +23 -0
- package/bin/shared.js +331 -14
- package/package.json +2 -2
package/bin/desktop.js
CHANGED
|
@@ -31,25 +31,33 @@
|
|
|
31
31
|
* has to do.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return "
|
|
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
|
-
|
|
584
|
+
resp = await fetch(manifestUrl(tag), {
|
|
428
585
|
headers: { "User-Agent": "premanmcp-cli" },
|
|
586
|
+
signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
|
|
429
587
|
});
|
|
430
|
-
|
|
431
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
`
|
|
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
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|