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:
can1357
2026-08-11 15:49:50 +02:00
parent 54ce9e47fc
commit 87a18e3880
3 changed files with 285 additions and 19 deletions
+198 -16
View File
@@ -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}`));