|
| 1 | +/** |
| 2 | + * Post-install verification. |
| 3 | + * |
| 4 | + * An installer exit code of 0 only says the installer believed it finished. |
| 5 | + * It does not say the bytes that will run next launch are the target version: |
| 6 | + * a Windows report had `install.ps1` exit 0 repeatedly while the executable on |
| 7 | + * disk stayed on the old version, so the footer advertised |
| 8 | + * "restart to apply" forever and the recorded outcome was a lie. |
| 9 | + * |
| 10 | + * This module answers the only question that matters after an install — does |
| 11 | + * the thing that runs next report the version we installed? — and it answers |
| 12 | + * it from the same artifact the source updates: |
| 13 | + * |
| 14 | + * - native: the packaged binary at `process.execPath`, probed with |
| 15 | + * `--version` (Commander prints and exits before any preflight runs). |
| 16 | + * - npm/pnpm/yarn/bun: the host `package.json`, re-read from disk. |
| 17 | + * - homebrew: nothing — its update lands through the prepare-on-restart |
| 18 | + * lifecycle, not through this install path. |
| 19 | + * |
| 20 | + * It fails **open**: an unreadable package, a probe that times out or a |
| 21 | + * version string it cannot parse all report `ok`. A slow antivirus scan must |
| 22 | + * never turn a good install into a recorded failure. Only a version it read |
| 23 | + * successfully *and* that disagrees with the target is reported as a mismatch. |
| 24 | + */ |
| 25 | + |
| 26 | +import { execFile } from 'node:child_process'; |
| 27 | +import { readFile } from 'node:fs/promises'; |
| 28 | + |
| 29 | +import { valid } from 'semver'; |
| 30 | + |
| 31 | +import { findHostPackageJsonPath } from '#/cli/version'; |
| 32 | + |
| 33 | +import type { InstallSource } from './types'; |
| 34 | + |
| 35 | +/** Bound on the `--version` probe: a native binary starts in well under this. */ |
| 36 | +const VERSION_PROBE_TIMEOUT_MS = 20_000; |
| 37 | + |
| 38 | +export type InstallVerification = |
| 39 | + | { readonly ok: true } |
| 40 | + | { readonly ok: false; readonly reason: string }; |
| 41 | + |
| 42 | +export interface VerifyInstalledVersionDeps { |
| 43 | + /** Path of the packaged binary to probe (native sources only). */ |
| 44 | + readonly execPath: string; |
| 45 | + /** Runs `<exe> --version` and resolves its stdout. */ |
| 46 | + readonly probeExecutableVersion: (execPath: string) => Promise<string>; |
| 47 | + /** Reads the installed host `package.json`, or null when there is none. */ |
| 48 | + readonly readPackageVersion: () => Promise<string | null>; |
| 49 | +} |
| 50 | + |
| 51 | +const OK: InstallVerification = { ok: true }; |
| 52 | + |
| 53 | +/** |
| 54 | + * Extract the first `x.y.z` from a `--version` output. Commander prints the |
| 55 | + * bare version, but a wrapper is free to add a banner around it. |
| 56 | + */ |
| 57 | +export function parseVersionOutput(output: string): string | null { |
| 58 | + // No leading `\b`: a `v` prefix is a word character, so `v1.2.3` would not |
| 59 | + // match. A digit or dot before the first number still disqualifies it. |
| 60 | + const match = /(?<![\d.])\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/u.exec(output); |
| 61 | + return match?.[0] ?? null; |
| 62 | +} |
| 63 | + |
| 64 | +function sameVersion(found: string, expected: string): boolean { |
| 65 | + const normalize = (value: string): string => value.replace(/^v/u, '').trim(); |
| 66 | + return normalize(found) === normalize(expected); |
| 67 | +} |
| 68 | + |
| 69 | +async function defaultProbeExecutableVersion(execPath: string): Promise<string> { |
| 70 | + return new Promise<string>((resolve, reject) => { |
| 71 | + execFile( |
| 72 | + execPath, |
| 73 | + ['--version'], |
| 74 | + { |
| 75 | + timeout: VERSION_PROBE_TIMEOUT_MS, |
| 76 | + windowsHide: true, |
| 77 | + encoding: 'utf-8', |
| 78 | + // The probe must not check for updates, install anything, or touch the |
| 79 | + // install state this verification is about to write. |
| 80 | + env: { ...process.env, PYTHINKER_CODE_NO_AUTO_UPDATE: '1' }, |
| 81 | + }, |
| 82 | + (error, stdout) => { |
| 83 | + if (error) { |
| 84 | + reject(error); |
| 85 | + return; |
| 86 | + } |
| 87 | + resolve(stdout); |
| 88 | + }, |
| 89 | + ); |
| 90 | + }); |
| 91 | +} |
| 92 | + |
| 93 | +async function defaultReadPackageVersion(): Promise<string | null> { |
| 94 | + const path = findHostPackageJsonPath(); |
| 95 | + if (path === null) return null; |
| 96 | + const parsed = JSON.parse(await readFile(path, 'utf-8')) as { version?: unknown }; |
| 97 | + return typeof parsed.version === 'string' ? parsed.version : null; |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Verify that `expectedVersion` is what an install of `source` actually left |
| 102 | + * behind. See the module comment for the fail-open rule. |
| 103 | + */ |
| 104 | +export async function verifyInstalledVersion( |
| 105 | + source: InstallSource, |
| 106 | + expectedVersion: string, |
| 107 | + overrides: Partial<VerifyInstalledVersionDeps> = {}, |
| 108 | +): Promise<InstallVerification> { |
| 109 | + if (valid(expectedVersion) === null) return OK; |
| 110 | + |
| 111 | + const deps: VerifyInstalledVersionDeps = { |
| 112 | + execPath: overrides.execPath ?? process.execPath, |
| 113 | + probeExecutableVersion: overrides.probeExecutableVersion ?? defaultProbeExecutableVersion, |
| 114 | + readPackageVersion: overrides.readPackageVersion ?? defaultReadPackageVersion, |
| 115 | + }; |
| 116 | + |
| 117 | + switch (source) { |
| 118 | + case 'native': { |
| 119 | + let output: string; |
| 120 | + try { |
| 121 | + output = await deps.probeExecutableVersion(deps.execPath); |
| 122 | + } catch { |
| 123 | + return OK; |
| 124 | + } |
| 125 | + const found = parseVersionOutput(output); |
| 126 | + if (found === null || sameVersion(found, expectedVersion)) return OK; |
| 127 | + return { |
| 128 | + ok: false, |
| 129 | + reason: |
| 130 | + `the installer reported success but ${deps.execPath} still reports ` + |
| 131 | + `${found} (expected ${expectedVersion})`, |
| 132 | + }; |
| 133 | + } |
| 134 | + case 'npm-global': |
| 135 | + case 'pnpm-global': |
| 136 | + case 'yarn-global': |
| 137 | + case 'bun-global': { |
| 138 | + let found: string | null; |
| 139 | + try { |
| 140 | + found = await deps.readPackageVersion(); |
| 141 | + } catch { |
| 142 | + return OK; |
| 143 | + } |
| 144 | + if (found === null || sameVersion(found, expectedVersion)) return OK; |
| 145 | + return { |
| 146 | + ok: false, |
| 147 | + reason: |
| 148 | + `the installer reported success but the installed package is still ` + |
| 149 | + `${found} (expected ${expectedVersion})`, |
| 150 | + }; |
| 151 | + } |
| 152 | + case 'homebrew': |
| 153 | + case 'unsupported': |
| 154 | + return OK; |
| 155 | + } |
| 156 | +} |
0 commit comments