feat(cli): enabled binary updates and script-shim takeover on windows
- Added `resolveReleaseDist` and `shouldForceBinaryUpdate` to parse package manifests and gate major updates to binary releases. - Implemented `updateViaShimTakeover` in `update-cli.ts` to seamlessly replace Windows script launchers with standalone binaries. - Added comprehensive unit tests covering release distribution parsing, binary force updates, and script-shim takeover behavior.
This commit is contained in:
@@ -63,9 +63,14 @@ function currentNativeTag(): string {
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
/** Distribution channel advertised by a release's published npm manifest. */
|
||||
export type ReleaseDist = "npm" | "binary";
|
||||
|
||||
interface ReleaseInfo {
|
||||
tag: string;
|
||||
version: string;
|
||||
/** Parsed `omp.dist` from the registry manifest; undefined when absent. */
|
||||
dist?: ReleaseDist;
|
||||
}
|
||||
|
||||
export interface ReleaseBinaryAsset {
|
||||
@@ -80,6 +85,47 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `omp.dist` field from a published package manifest.
|
||||
*
|
||||
* Forward-compatibility contract with future releases: a release that is not
|
||||
* installable as an npm package (e.g. a native rewrite) publishes
|
||||
* `"omp": { "dist": "binary" }` in its package.json. Any value other than
|
||||
* "npm" — including values this updater does not know yet — maps to "binary"
|
||||
* so already-deployed updaters never run a package-manager install against a
|
||||
* release that no longer supports it.
|
||||
*/
|
||||
export function resolveReleaseDist(manifest: unknown): ReleaseDist | undefined {
|
||||
if (!isRecord(manifest) || !isRecord(manifest.omp)) return undefined;
|
||||
const dist = manifest.omp.dist;
|
||||
if (dist === undefined) return undefined;
|
||||
return dist === "npm" ? "npm" : "binary";
|
||||
}
|
||||
|
||||
function majorVersion(version: string): number {
|
||||
const major = Number.parseInt(version, 10);
|
||||
return Number.isNaN(major) ? 0 : major;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the update must bypass bun/npm and install the release binary.
|
||||
*
|
||||
* An explicit `omp.dist` wins in both directions. Without one, a release with
|
||||
* a higher major than the running build is assumed not npm-installable: the
|
||||
* runtime may have changed out from under the package layout, and the pinned
|
||||
* `@oh-my-pi/pi-natives*` companions ({@link buildBunInstallArgs}) may not
|
||||
* exist at that version, which would strand bun/npm-managed installs behind a
|
||||
* hard install failure. Homebrew and mise installs are unaffected — both
|
||||
* already pull GitHub release binaries.
|
||||
*/
|
||||
export function shouldForceBinaryUpdate(
|
||||
release: { version: string; dist?: ReleaseDist },
|
||||
currentVersion: string = VERSION,
|
||||
): boolean {
|
||||
if (release.dist !== undefined) return release.dist === "binary";
|
||||
return majorVersion(release.version) > majorVersion(currentVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and validate the binary asset from GitHub release metadata.
|
||||
*/
|
||||
@@ -394,9 +440,9 @@ interface UpdateMethodResolutionOptions {
|
||||
type UpdateTarget =
|
||||
| { method: "brew" }
|
||||
| { method: "mise" }
|
||||
| { method: "bun" }
|
||||
| { method: "npm" }
|
||||
| { method: "binary"; path: string };
|
||||
| { method: "bun"; path?: string }
|
||||
| { method: "npm"; path?: string }
|
||||
| { method: "binary"; path: string; replacesSymlink: boolean };
|
||||
|
||||
function resolveUpdateMethod(
|
||||
ompPath: string,
|
||||
@@ -433,9 +479,18 @@ export function resolveUpdateMethodForTest(
|
||||
): UpdateMethod {
|
||||
return resolveUpdateMethod(ompPath, bunBinDir, options);
|
||||
}
|
||||
async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
||||
const bunBinDir = await getBunGlobalBinDir();
|
||||
const npmBinDir = await getNpmGlobalBinDir();
|
||||
/**
|
||||
* Resolve how the running install should be updated.
|
||||
*
|
||||
* `allowPackageManagers: false` skips the `bun pm bin -g` / `npm prefix -g`
|
||||
* probes entirely — used for binary-only releases, where routing through a
|
||||
* package manager is never valid and the probes would be wasted subprocesses.
|
||||
* Homebrew/mise detection always runs: both managers install GitHub release
|
||||
* binaries and stay valid regardless of how the release is distributed.
|
||||
*/
|
||||
async function resolveUpdateTarget(options: { allowPackageManagers: boolean }): Promise<UpdateTarget> {
|
||||
const bunBinDir = options.allowPackageManagers ? await getBunGlobalBinDir() : undefined;
|
||||
const npmBinDir = options.allowPackageManagers ? await getNpmGlobalBinDir() : undefined;
|
||||
const homebrewPrefix = await getHomebrewFormulaPrefix();
|
||||
const miseAvailable = $which("mise") !== undefined;
|
||||
const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
|
||||
@@ -448,9 +503,11 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
||||
// overlaps the installer's default (~/.local/bin), that file type — not
|
||||
// directory containment — distinguishes a binary install from npm/bun.
|
||||
let ompIsRegularFile = false;
|
||||
let ompIsSymlink = false;
|
||||
try {
|
||||
const stat = fs.lstatSync(ompPath);
|
||||
ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
|
||||
ompIsSymlink = stat.isSymbolicLink();
|
||||
} catch {}
|
||||
const method = resolveUpdateMethod(ompPath, bunBinDir, {
|
||||
homebrewPrefix,
|
||||
@@ -459,7 +516,8 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
|
||||
npmBinDir,
|
||||
ompIsRegularFile,
|
||||
});
|
||||
if (method === "binary") return { method, path: ompPath };
|
||||
if (method === "binary") return { method, path: ompPath, replacesSymlink: ompIsSymlink };
|
||||
if (method === "bun" || method === "npm") return { method, path: ompPath };
|
||||
return { method };
|
||||
}
|
||||
|
||||
@@ -488,13 +546,16 @@ async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
throw new Error(`Failed to fetch release info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { version: string };
|
||||
const data: unknown = await response.json();
|
||||
if (!isRecord(data) || typeof data.version !== "string") {
|
||||
throw new Error("Malformed npm registry response: missing version");
|
||||
}
|
||||
const version = data.version;
|
||||
const tag = `v${version}`;
|
||||
|
||||
return {
|
||||
tag,
|
||||
tag: `v${version}`,
|
||||
version,
|
||||
dist: resolveReleaseDist(data),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1108,6 +1169,99 @@ export async function updateViaBinaryAt(
|
||||
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a Windows script-launcher install for a binary-only release.
|
||||
*
|
||||
* npm-managed Windows installs are launched through script shims
|
||||
* (`omp`/`omp.cmd`/`omp.ps1`) that cannot be overwritten with a native
|
||||
* executable. The release binary is installed as `omp.exe` beside them and
|
||||
* the shims are then renamed aside: cmd.exe would already prefer `.exe` via
|
||||
* PATHEXT, but PowerShell resolves `.ps1` first, so the takeover only sticks
|
||||
* once the shims are out of the way. A working launcher exists at every
|
||||
* step — the exe lands before any shim moves, a shim that refuses to move
|
||||
* (a running `.cmd` can be renamed but may be held open some other way) is
|
||||
* skipped, and a failed version verification moves everything back.
|
||||
*/
|
||||
export async function updateViaShimTakeover(
|
||||
shimPath: string,
|
||||
expectedVersion: string,
|
||||
options: {
|
||||
binaryName?: string;
|
||||
fetchImpl?: Fetch;
|
||||
githubToken?: string;
|
||||
verifyInstalledVersion?: typeof verifyInstalledVersion;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const binaryName = options.binaryName ?? getBinaryName();
|
||||
const launcherDir = path.dirname(shimPath);
|
||||
const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
|
||||
const tempPath = `${exePath}.new`;
|
||||
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
|
||||
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
||||
await downloadVerifiedBinary({
|
||||
url: asset.url,
|
||||
targetPath: tempPath,
|
||||
expectedSize: asset.size,
|
||||
expectedDigest: asset.digest,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
console.log(chalk.dim(`Verified ${asset.digest}`));
|
||||
|
||||
console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
|
||||
await fs.promises.rename(tempPath, exePath);
|
||||
// Retire the shims so PATH resolution lands on the new exe. Renamed, not
|
||||
// deleted: restorable on verification failure, and Windows permits
|
||||
// renaming a batch file that is still executing.
|
||||
const backupSuffix = `${Date.now()}.${process.pid}.bak`;
|
||||
const retired: Array<{ launcher: string; backup: string }> = [];
|
||||
for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
|
||||
const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
|
||||
const backup = `${launcher}.${backupSuffix}`;
|
||||
try {
|
||||
await fs.promises.rename(launcher, backup);
|
||||
retired.push({ launcher, backup });
|
||||
} catch {
|
||||
// Shim absent or immovable; .exe still outranks .cmd/.bat in PATHEXT.
|
||||
}
|
||||
}
|
||||
|
||||
const verify = options.verifyInstalledVersion ?? verifyInstalledVersion;
|
||||
const verification = await verify(expectedVersion);
|
||||
if (!verification.ok) {
|
||||
for (const { launcher, backup } of retired) {
|
||||
try {
|
||||
await fs.promises.rename(backup, launcher);
|
||||
} catch {}
|
||||
}
|
||||
await unlinkIfExists(exePath);
|
||||
throw new Error(
|
||||
`${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
|
||||
);
|
||||
}
|
||||
for (const { backup } of retired) {
|
||||
await removeBackupBestEffort(backup);
|
||||
}
|
||||
// Reclaim exe backups and retired-shim leftovers from earlier attempts.
|
||||
for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
|
||||
await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`));
|
||||
}
|
||||
printVerifiedVersion(expectedVersion);
|
||||
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-appropriate installer one-liner for recovery instructions.
|
||||
*
|
||||
* Forces the installer's binary mode (`--binary` / `-Binary`): the default
|
||||
* mode prefers a bun-based install whenever bun is present, which would send
|
||||
* a user recovering from a binary-only release straight back through bun.
|
||||
*/
|
||||
function installerHint(): string {
|
||||
return process.platform === "win32"
|
||||
? "& ([scriptblock]::Create((irm https://omp.sh/install.ps1))) -Binary"
|
||||
: "curl -fsSL https://omp.sh/install | sh -s -- --binary";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the update command.
|
||||
*/
|
||||
@@ -1141,19 +1295,47 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose update method based on the prioritized omp binary in PATH
|
||||
// Choose update method based on the prioritized omp binary in PATH. For
|
||||
// binary-only releases the package managers are never consulted: a bun/npm
|
||||
// symlink resolves to method "binary" and is replaced in place, keeping the
|
||||
// same PATH entry live.
|
||||
try {
|
||||
const target = await resolveUpdateTarget();
|
||||
const forceBinary = shouldForceBinaryUpdate(release);
|
||||
const target = await resolveUpdateTarget({ allowPackageManagers: !forceBinary });
|
||||
if (target.method === "brew") {
|
||||
await updateViaHomebrew(release.version, opts.force);
|
||||
} else if (target.method === "mise") {
|
||||
await updateViaMise(release.version, opts.force);
|
||||
} else if (target.method === "bun") {
|
||||
await updateViaBun(release.version);
|
||||
} else if (target.method === "npm") {
|
||||
await updateViaNpm(release.version);
|
||||
} else if (target.method === "bun" || target.method === "npm") {
|
||||
if (forceBinary) {
|
||||
// Reachable in forced mode only through a Windows script
|
||||
// launcher resolved from PATH (the bun/npm bin-dir probes are
|
||||
// skipped), so the launcher path is always known.
|
||||
if (!target.path) throw new Error(`Could not resolve ${APP_NAME} launcher path in PATH`);
|
||||
console.log(chalk.dim("This release ships as a standalone binary; replacing the script launcher."));
|
||||
await updateViaShimTakeover(target.path, release.version);
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`This install is no longer managed by ${target.method}. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`,
|
||||
),
|
||||
);
|
||||
} else if (target.method === "bun") {
|
||||
await updateViaBun(release.version);
|
||||
} else {
|
||||
await updateViaNpm(release.version);
|
||||
}
|
||||
} else {
|
||||
if (forceBinary && target.replacesSymlink) {
|
||||
console.log(chalk.dim("Replacing the package-manager launcher with the standalone binary."));
|
||||
}
|
||||
await updateViaBinaryAt(target.path, release.version);
|
||||
if (forceBinary && target.replacesSymlink) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`This install is no longer managed by bun/npm. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Update failed: ${err}`));
|
||||
|
||||
Reference in New Issue
Block a user