From f0ea54be048c34ab4702747367525371efc840e1 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:36:56 +0000 Subject: [PATCH 1/5] chore: start R14 read-only update check Co-Authored-By: Claude Opus 5.5 From 268d98775ad72d103755c8adfbaabb94d2a91759 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:44:42 +0000 Subject: [PATCH 2/5] feat(update): report available updates read-only for any install kind `hack update --check --json --observe` returns current, channel, latest, available, install_kind, upgrade_command and reason in the schema envelope (hack.update-check.v1). It makes one bounded GitHub releases read and never downloads, installs, prompts, writes, or refuses a Homebrew or candidate install. Next-channel installs compare against the newest same-major -next.N release; unknown evidence is null with a closed reason. Plain `hack update` is unchanged. HACK-2218 R14. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/DESIGN.md | 2 +- docs/guides/v5-release-notes.md | 6 + docs/reference.md | 1 + docs/reference/cli.md | 2 + docs/reference/machine-readable-output.md | 1 + docs/reference/update-check.md | 50 ++++ docs/versions/v5/install.md | 3 + src/cli/json-contracts.ts | 5 + src/commands/update.ts | 52 +++- src/lib/machine-output.ts | 1 + src/lib/update-observe.ts | 273 ++++++++++++++++++++ tests/update-observe.test.ts | 293 ++++++++++++++++++++++ 12 files changed, 686 insertions(+), 3 deletions(-) create mode 100644 docs/reference/update-check.md create mode 100644 src/lib/update-observe.ts create mode 100644 tests/update-observe.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 7feae9d77..019be7501 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -390,7 +390,7 @@ Each is small enough for one PR. 17. **R12b** Always-on fields in project payloads and `autostart_global`. 18. **R12c** Login run: hackd starts always-on projects at service start, retries up to `max_failures`, and honours pause. 19. **R13** Observe-only `hack global status --json --observe` that never starts Docker; reports `host_dns_ok` on both platforms. -20. **R14** Read-only update check for any install kind: `hack update --check --json --observe` → `{current, latest, available, install_kind, upgrade_command}`. +20. **R14 — exists** Read-only update check for any install kind: `hack update --check --json --observe` → `{current, channel, latest, available, install_kind, upgrade_command, reason}` in the envelope with `schema: "hack.update-check.v1"` ([update check](../../docs/reference/update-check.md)). One bounded GitHub read; unknown is `null` with a closed `reason`; `install_kind` adds `candidate`; `upgrade_command` is argv or `null`. 21. **R15** `--json` for `hack daemon start|stop|restart|install`. 22. **R16** Trust status: a doctor check that reports whether the system trust store (and NSS on Linux) trusts the local CA, or `unknown`. 23. **R17 — exists** `kind: safe | admin | manual` on upgrade report findings and plan steps (additive version-2 fields), independent of severity. Only explicitly allowed diagnostic/snapshot commands and fully eligible guarded apply groups are safe; host routing/login-service changes are admin; decisions and unknown actions are manual. The supplied per-checkout command can remain manual while its guarded grouped apply step is safe. Consumers must treat absent or unrecognized kinds as manual and still require an executable command before offering Run. App adoption is separate. diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 039a6f04c..81ba5e12f 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -35,6 +35,12 @@ no longer removes legacy inputs in the same step. The full upgrade steps are in [Upgrade to v5](upgrade-to-v5.md#preview-resume-and-undo-env-migration). +### Read-only update check for any install + +| Change | What to do | +| --- | --- | +| **`hack update --check --json --observe` reports whether a newer release exists, for any install kind.** It returns `current`, `channel`, `latest`, `available`, `install_kind` and `upgrade_command` with one bounded GitHub read, and never installs, prompts or refuses a Homebrew or candidate install. Unknown is `null` with a `reason`. | Use it to poll for updates (for example from the desktop app), then run the reported `upgrade_command` yourself. See [the update check](../reference/update-check.md). Plain `hack update --check` is unchanged. | + ### Breaking change: next.17 machine output moves into the JSON envelope Five `--json` outputs that first shipped in next.17 now use the documented diff --git a/docs/reference.md b/docs/reference.md index bbfdcb582..c5f64ad76 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -12,6 +12,7 @@ - [Project state](./reference/project-state.md) — shared backend-aware CLI and daemon project/service observations - [Version identity](./reference/version.md) — `hack version --json` describes the running CLI without starting services +- [Read-only update check](./reference/update-check.md) — `hack update --check --json --observe`: newer release, install kind and upgrade command, for any install - [Machine-readable output](./reference/machine-readable-output.md) — envelope rule, schema identifiers and the complete JSON command inventory - [Environment model](./env.md) - [Lifecycle & startup](./lifecycle.md) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7cbcfebae..ade17b5f2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1939,6 +1939,7 @@ Downloads and installs the latest stable hack release from GitHub. Notes: - Refuses to self-update when running from a Homebrew install, local dev wrapper, or symlink install. - Updates the current hack binary and refreshes assets. +- `--check --json --observe` only reports: current, latest, availability, install kind and the upgrade command for this install. ### Options @@ -1947,6 +1948,7 @@ Notes: | `--check` | Check for updates (do not install) | | `--yes` | Apply update without prompting | | `--tag ` | Update to a specific release tag (e.g. v1.4.0) | +| `--observe` | With --check --json, report updates for any install kind without installing or refusing (one release read) | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 560859956..0a5a2df7b 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -136,4 +136,5 @@ success behavior stays covered by command-specific suites. | `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | | `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | | `hack usage --json --by-service` | Envelope; `data.schema: hack.usage.by-service.v1` | +| `hack update --json --check --observe` | Envelope; `data.schema: hack.update-check.v1` | diff --git a/docs/reference/update-check.md b/docs/reference/update-check.md new file mode 100644 index 000000000..b9cd5dfd1 --- /dev/null +++ b/docs/reference/update-check.md @@ -0,0 +1,50 @@ +# Read-only update check + +`hack update --check --json --observe` says whether a newer Hack release exists +for the running CLI and how this install would upgrade. It works for every +install kind, including Homebrew and the `hack-next` candidate launcher, which +`hack update` itself refuses to modify. + +It makes one bounded read of the public GitHub releases API (10 seconds). It +never downloads, installs, prompts, writes files, looks Hack up on PATH, or +contacts hackd, Docker or a VM. + +```json +{ + "ok": true, + "data": { + "current": "5.0.0-next.17", + "channel": "next", + "latest": "5.0.0-next.18", + "available": true, + "install_kind": "candidate", + "upgrade_command": [ + "/example/home/.hack-next/bin/hack-next", + "--manager", + "upgrade", + "--version", + "5.0.0-next.18" + ], + "reason": null, + "schema": "hack.update-check.v1" + } +} +``` + +| Field | Meaning | +| --- | --- | +| `current`, `channel` | The running CLI, as in [`hack version --json`](./version.md). | +| `latest` | Stable channel: GitHub's latest release. Next channel: the newest non-draft `-next.N` release with the same major version. `null` when unknown. | +| `available` | `true` when `latest` is newer than `current`, `false` when not, `null` when unknown. | +| `install_kind` | `candidate` under a candidate launcher; otherwise the [version identity](./version.md) kind (`homebrew`, `standalone`, `source`, `symlink`, `dev-wrapper`, `unknown`). | +| `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: candidate → its manager's `upgrade --version`; stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`. | +| `reason` | `null` when `latest` and `available` are known; else `network-unavailable`, `release-unavailable` (error response, no matching release, or an unexpected tag) or `version-unrecognized` (a version other than `X.Y.Z` or `X.Y.Z-next.N`). | + +Unknown is not "up to date": an offline machine reports `available: null`, still +with exit 0, because the observation itself succeeded. `upgrade_command` is +advice, not permission: review it and run it yourself. `hack update` (without +`--observe`) keeps its existing output and refusals. + +`--observe` requires `--check` and `--json`, and cannot be combined with +`--yes` or `--tag`; those return `E_USAGE` before any network read. See +[Machine-readable output](./machine-readable-output.md). diff --git a/docs/versions/v5/install.md b/docs/versions/v5/install.md index 89d3b10af..7013df83f 100644 --- a/docs/versions/v5/install.md +++ b/docs/versions/v5/install.md @@ -105,5 +105,8 @@ Stop its projects with `hack down` first. For a login daemon, use that install's before removing `~/.hack-next`; that removes all its version-private homes too. Removing an executable does not undo host DNS/trust, project files or volumes. +Intended for next.18: to see whether a newer release exists without changing +anything, run `hack update --check --json --observe` through the same launcher; +it prints the upgrade command for this install. For a candidate rollback or return to stable, use [the full procedure](../guides/candidate-install.md#upgrade-roll-back-and-return-to-stable). diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 1bd5047de..4204e5fa9 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -112,4 +112,9 @@ export const SCHEMA_JSON_VARIANTS = [ flags: ["--by-service"], schema: "hack.usage.by-service.v1", }, + { + command: "update", + flags: ["--check", "--observe"], + schema: "hack.update-check.v1", + }, ] as const; diff --git a/src/commands/update.ts b/src/commands/update.ts index 00358f1eb..c228f3009 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,12 +1,20 @@ import { access, rm } from "node:fs/promises"; +import { homedir } from "node:os"; import { basename, resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { CommandHandlerFor } from "../cli/command.ts"; -import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; import { optJson } from "../cli/options.ts"; +import { emitCliResult } from "../lib/cli-result.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { canPrompt } from "../lib/interactivity.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -20,6 +28,7 @@ import { resolveUpdateTarget, selectCliTarballAsset, } from "../lib/self-update.ts"; +import { observeUpdate } from "../lib/update-observe.ts"; const optCheck = defineOption({ name: "check", @@ -43,7 +52,15 @@ const optTag = defineOption({ description: "Update to a specific release tag (e.g. v1.4.0)", } as const); -const options = [optCheck, optYes, optTag, optJson] as const; +const optObserve = defineOption({ + name: "observe", + type: "boolean", + long: "--observe", + description: + "With --check --json, report updates for any install kind without installing or refusing (one release read)", +} as const); + +const options = [optCheck, optYes, optTag, optObserve, optJson] as const; const positionals = [] as const; const spec = defineCommand({ @@ -56,6 +73,7 @@ const spec = defineCommand({ "Notes:", "- Refuses to self-update when running from a Homebrew install, local dev wrapper, or symlink install.", "- Updates the current hack binary and refreshes assets.", + "- `--check --json --observe` only reports: current, latest, availability, install kind and the upgrade command for this install.", ].join("\n"), options, positionals, @@ -68,6 +86,9 @@ const handleUpdate: CommandHandlerFor = async ({ ctx, args, }): Promise => { + if (args.options.observe === true) { + return await handleObserve({ ctx, args }); + } // Candidate launchers run hack-cli; PATH fallback can select the stable install. // `hack-next` and `hack-v5` mark their children on both backends, because an // explicit Compose selection no longer carries the native backend marker. @@ -267,6 +288,33 @@ const handleUpdate: CommandHandlerFor = async ({ }); }; +/** + * Read-only report for every install kind, including Homebrew and candidate + * launchers that `hack update` refuses to modify (HACK-2218 R14). + */ +const handleObserve: CommandHandlerFor = async ({ ctx, args }) => { + if (!(args.options.check === true && args.options.json === true)) { + throw new CliUsageError("--observe requires --check and --json."); + } + if (args.options.yes === true || typeof args.options.tag === "string") { + throw new CliUsageError("--observe cannot use --yes or --tag."); + } + const report = await observeUpdate({ + version: ctx.cli.version, + executable: process.execPath, + compiled: Bun.main.startsWith("/$bunfs/"), + env: process.env, + homeDir: homedir(), + }); + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.updateCheck, + data: report, + }), + }); + return 0; +}; + async function confirmInstall(opts: { readonly yes: boolean; readonly isTty: boolean; diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index 1b8561c5c..e02871b8a 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -8,6 +8,7 @@ export const MACHINE_SCHEMAS = { globalAdminPlan: "hack.global-admin-plan.v1", globalStatusObserve: "hack.global-status.observe.v1", usageByService: "hack.usage.by-service.v1", + updateCheck: "hack.update-check.v1", } as const; export function machineSuccess(opts: { diff --git a/src/lib/update-observe.ts b/src/lib/update-observe.ts new file mode 100644 index 000000000..57b846925 --- /dev/null +++ b/src/lib/update-observe.ts @@ -0,0 +1,273 @@ +/** + * Read-only update check for any install kind (HACK-2218 R14). + * + * One bounded GitHub releases read; no download, prompt, install, PATH lookup, + * registry write, daemon or engine contact. Missing evidence is reported as + * `null` with a closed `reason`, never as "up to date". + */ +import { isRecord } from "./guards.ts"; +import { upgradeCommandPrefix } from "./upgrade-check.ts"; +import { buildVersionReport } from "./version-report.ts"; + +const RELEASES_API = "https://api.github.com/repos/hack-dance/hack/releases"; +const FETCH_TIMEOUT_MS = 10_000; +const NEXT_LIST_PAGE_SIZE = 50; +const HOMEBREW_UPGRADE = ["brew", "upgrade", "hack-dance/tap/hack"] as const; + +const LEADING_V = /^v/; +const VERSION_PATTERN = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-next\.(0|[1-9]\d*))?$/; + +type VersionReport = Awaited>; + +export type UpdateCheckInstallKind = + | VersionReport["install_kind"] + | "candidate"; + +/** Why `latest` or `available` is unknown; `null` when both are known. */ +export type UpdateCheckReason = + | "network-unavailable" + | "release-unavailable" + | "version-unrecognized"; + +export type UpdateCheckReport = { + readonly current: string; + readonly channel: VersionReport["channel"]; + readonly latest: string | null; + readonly available: boolean | null; + readonly install_kind: UpdateCheckInstallKind; + /** argv to run in a terminal; `null` when there is no update or no known path. */ + readonly upgrade_command: readonly string[] | null; + readonly reason: UpdateCheckReason | null; +}; + +type ParsedVersion = { + readonly core: readonly [number, number, number]; + /** `null` for a release; the `-next.N` number for a candidate. */ + readonly next: number | null; +}; + +/** Stable `X.Y.Z` and candidate `X.Y.Z-next.N` only; anything else is unrecognized. */ +export function parseUpdateVersion(raw: string): ParsedVersion | null { + const match = VERSION_PATTERN.exec(raw.trim()); + if (!match) { + return null; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + next: match[4] === undefined ? null : Number(match[4]), + }; +} + +/** Semver order for these two forms: `X.Y.Z-next.N` sorts before `X.Y.Z`. */ +export function compareUpdateVersions( + a: ParsedVersion, + b: ParsedVersion +): number { + for (let index = 0; index < 3; index++) { + const delta = (a.core[index] ?? 0) - (b.core[index] ?? 0); + if (delta !== 0) { + return Math.sign(delta); + } + } + if (a.next === b.next) { + return 0; + } + if (a.next === null) { + return 1; + } + if (b.next === null) { + return -1; + } + return Math.sign(a.next - b.next); +} + +type ReadFailure = "network-unavailable" | "release-unavailable"; + +type LatestOutcome = + | { readonly ok: true; readonly version: string } + | { readonly ok: false; readonly reason: ReadFailure }; + +async function readJson(opts: { + readonly fetch: typeof fetch; + readonly url: string; +}): Promise< + | { readonly ok: true; readonly json: unknown } + | { readonly ok: false; readonly reason: ReadFailure } +> { + let response: Response; + try { + response = await opts.fetch(opts.url, { + headers: { + accept: "application/vnd.github+json", + "user-agent": "hack-cli", + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch { + return { ok: false, reason: "network-unavailable" }; + } + if (!response.ok) { + return { ok: false, reason: "release-unavailable" }; + } + const json: unknown = await response.json().catch(() => undefined); + return json === undefined + ? { ok: false, reason: "release-unavailable" } + : { ok: true, json }; +} + +function releaseVersion(entry: unknown): string | null { + if (!isRecord(entry) || entry.draft === true) { + return null; + } + const tag = entry.tag_name; + if (typeof tag !== "string" || parseUpdateVersion(tag) === null) { + return null; + } + return tag.trim().replace(LEADING_V, ""); +} + +/** + * Stable: GitHub's latest release, which excludes prereleases and drafts. + * Next: the newest `-next.N` release with the running CLI's major version. + */ +async function resolveLatest(opts: { + readonly fetch: typeof fetch; + readonly channel: "stable" | "next"; + readonly major: number; +}): Promise { + if (opts.channel === "stable") { + const read = await readJson({ + fetch: opts.fetch, + url: `${RELEASES_API}/latest`, + }); + if (!read.ok) { + return read; + } + const version = releaseVersion(read.json); + return version !== null && parseUpdateVersion(version)?.next === null + ? { ok: true, version } + : { ok: false, reason: "release-unavailable" }; + } + const read = await readJson({ + fetch: opts.fetch, + url: `${RELEASES_API}?per_page=${NEXT_LIST_PAGE_SIZE}`, + }); + if (!read.ok) { + return read; + } + if (!Array.isArray(read.json)) { + return { ok: false, reason: "release-unavailable" }; + } + let best: { version: string; parsed: ParsedVersion } | null = null; + for (const entry of read.json) { + const version = releaseVersion(entry); + const parsed = version === null ? null : parseUpdateVersion(version); + if ( + version === null || + parsed === null || + parsed.next === null || + parsed.core[0] !== opts.major + ) { + continue; + } + if (best === null || compareUpdateVersions(parsed, best.parsed) > 0) { + best = { version, parsed }; + } + } + return best === null + ? { ok: false, reason: "release-unavailable" } + : { ok: true, version: best.version }; +} + +/** A supported, self-contained upgrade path for this install, or `null`. */ +function upgradeCommand(opts: { + readonly installKind: UpdateCheckInstallKind; + readonly channel: VersionReport["channel"]; + readonly latest: string; + readonly binaryPath: string; + readonly env: Readonly>; + readonly homeDir: string; +}): readonly string[] | null { + if (opts.installKind === "candidate") { + const prefix = upgradeCommandPrefix({ + env: opts.env, + homeDir: opts.homeDir, + }); + return [...prefix.argv, "--manager", "upgrade", "--version", opts.latest]; + } + if (opts.channel !== "stable") { + // `hack update` and Homebrew install the latest stable release only. + return null; + } + if (opts.installKind === "homebrew") { + return [...HOMEBREW_UPGRADE]; + } + if (opts.installKind === "standalone") { + return [opts.binaryPath, "update"]; + } + return null; +} + +export async function observeUpdate(opts: { + readonly version: string; + readonly executable: string; + readonly compiled: boolean; + readonly env: Readonly>; + readonly homeDir: string; + readonly fetch?: typeof fetch; +}): Promise { + const identity = await buildVersionReport({ + version: opts.version, + executable: opts.executable, + compiled: opts.compiled, + }); + const installKind: UpdateCheckInstallKind = + (opts.env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim() === "" + ? identity.install_kind + : "candidate"; + const unknown = (reason: UpdateCheckReason): UpdateCheckReport => ({ + current: identity.version, + channel: identity.channel, + latest: null, + available: null, + install_kind: installKind, + upgrade_command: null, + reason, + }); + // Only stable and `-next.N` versions have a release feed to compare with. + const current = parseUpdateVersion(identity.version); + if (current === null || identity.channel === "prerelease") { + return unknown("version-unrecognized"); + } + const latest = await resolveLatest({ + fetch: opts.fetch ?? fetch, + channel: identity.channel, + major: current.core[0], + }); + if (!latest.ok) { + return unknown(latest.reason); + } + const target = parseUpdateVersion(latest.version); + const available = + target === null ? null : compareUpdateVersions(current, target) < 0; + return { + current: identity.version, + channel: identity.channel, + latest: latest.version, + available, + install_kind: installKind, + upgrade_command: + available === true + ? upgradeCommand({ + installKind, + channel: identity.channel, + latest: latest.version, + binaryPath: identity.binary_path, + env: opts.env, + homeDir: opts.homeDir, + }) + : null, + reason: available === null ? "version-unrecognized" : null, + }; +} diff --git a/tests/update-observe.test.ts b/tests/update-observe.test.ts new file mode 100644 index 000000000..ddd768c2a --- /dev/null +++ b/tests/update-observe.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli/run.ts"; +import { + compareUpdateVersions, + observeUpdate, + parseUpdateVersion, +} from "../src/lib/update-observe.ts"; +import { machineError } from "./helpers/machine-output.ts"; + +/** No real network: every case supplies the releases response it expects. */ +function releases(opts: { + readonly latest?: unknown; + readonly list?: unknown; + readonly status?: number; + readonly fail?: boolean; +}) { + const urls: string[] = []; + const fake = (async (input: string | URL | Request) => { + const url = String(input); + urls.push(url); + if (opts.fail) { + throw new TypeError("network down"); + } + const body = url.endsWith("/latest") ? opts.latest : opts.list; + return new Response(JSON.stringify(body ?? null), { + status: opts.status ?? 200, + }); + }) as typeof fetch; + return { fetch: fake, urls }; +} + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-update-observe-")); +}); +afterEach(async () => { + mock.restore(); + await rm(root, { recursive: true, force: true }); +}); + +async function executable(relative: string): Promise { + const path = join(root, relative); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, "\x7fELF placeholder", { mode: 0o755 }); + return path; +} + +const base = { env: {}, homeDir: "/example/home" } as const; + +test("candidate versions sort before their release and by next number", () => { + const parse = (raw: string) => { + const parsed = parseUpdateVersion(raw); + if (!parsed) { + throw new Error(raw); + } + return parsed; + }; + expect( + compareUpdateVersions(parse("5.0.0-next.17"), parse("5.0.0-next.18")) + ).toBe(-1); + expect(compareUpdateVersions(parse("5.0.0-next.18"), parse("5.0.0"))).toBe( + -1 + ); + expect(compareUpdateVersions(parse("v5.0.1"), parse("5.0.0"))).toBe(1); + expect(compareUpdateVersions(parse("5.0.0"), parse("v5.0.0"))).toBe(0); + expect(parseUpdateVersion("5.0.0-rc.1")).toBeNull(); + expect(parseUpdateVersion("not a version")).toBeNull(); +}); + +test("a stable standalone install reads the latest release and offers its own update", async () => { + const binary = await executable("bin/hack"); + const read = releases({ latest: { tag_name: "v4.4.0", draft: false } }); + const report = await observeUpdate({ + ...base, + version: "4.3.0", + executable: binary, + compiled: true, + fetch: read.fetch, + }); + expect(report).toEqual({ + current: "4.3.0", + channel: "stable", + latest: "4.4.0", + available: true, + install_kind: "standalone", + upgrade_command: [binary, "update"], + reason: null, + }); + expect(read.urls).toEqual([ + "https://api.github.com/repos/hack-dance/hack/releases/latest", + ]); +}); + +test("a Homebrew install is reported, not refused, with the brew upgrade", async () => { + const binary = await executable("opt/homebrew/Cellar/hack/4.3.0/bin/hack"); + const report = await observeUpdate({ + ...base, + version: "4.3.0", + executable: binary, + compiled: true, + fetch: releases({ latest: { tag_name: "v4.4.0" } }).fetch, + }); + expect(report.install_kind).toBe("homebrew"); + expect(report.available).toBe(true); + expect(report.upgrade_command).toEqual([ + "brew", + "upgrade", + "hack-dance/tap/hack", + ]); +}); + +test("a candidate picks the newest same-major next release and its manager upgrade", async () => { + const read = releases({ + list: [ + { tag_name: "v5.0.0-next.18", draft: false, prerelease: true }, + { tag_name: "v5.0.0-next.20", draft: true, prerelease: true }, + { tag_name: "v5.0.0-next.19", draft: false, prerelease: true }, + { tag_name: "v6.0.0-next.1", draft: false, prerelease: true }, + { tag_name: "v4.4.0", draft: false, prerelease: false }, + { tag_name: "nightly", draft: false }, + "not a release", + ], + }); + const report = await observeUpdate({ + env: { HACK_CANDIDATE_ENTRYPOINT: "hack-next" }, + homeDir: "/example/home", + version: "5.0.0-next.17", + executable: "/example/bun", + compiled: false, + fetch: read.fetch, + }); + expect(report).toEqual({ + current: "5.0.0-next.17", + channel: "next", + latest: "5.0.0-next.19", + available: true, + install_kind: "candidate", + upgrade_command: [ + "/example/home/.hack-next/bin/hack-next", + "--manager", + "upgrade", + "--version", + "5.0.0-next.19", + ], + reason: null, + }); + expect(read.urls).toEqual([ + "https://api.github.com/repos/hack-dance/hack/releases?per_page=50", + ]); +}); + +test("no supported path or no update gives no command", async () => { + const source = await observeUpdate({ + ...base, + version: "4.3.0", + executable: "/example/bun", + compiled: false, + fetch: releases({ latest: { tag_name: "v4.4.0" } }).fetch, + }); + expect(source.install_kind).toBe("source"); + expect(source.available).toBe(true); + expect(source.upgrade_command).toBeNull(); + + const current = await observeUpdate({ + ...base, + version: "4.4.0", + executable: await executable("bin/hack"), + compiled: true, + fetch: releases({ latest: { tag_name: "v4.4.0" } }).fetch, + }); + expect(current.available).toBe(false); + expect(current.upgrade_command).toBeNull(); + expect(current.reason).toBeNull(); +}); + +test("missing evidence is unknown with a closed reason, never up to date", async () => { + const offline = await observeUpdate({ + ...base, + version: "4.3.0", + executable: "/example/bun", + compiled: false, + fetch: releases({ fail: true }).fetch, + }); + expect(offline).toMatchObject({ + latest: null, + available: null, + upgrade_command: null, + reason: "network-unavailable", + }); + for (const response of [ + { status: 404, latest: { message: "Not Found" } }, + { latest: { tag_name: "v4.4.0-rc.1" } }, + { latest: { tag_name: "v4.4.0", draft: true } }, + ]) { + const report = await observeUpdate({ + ...base, + version: "4.3.0", + executable: "/example/bun", + compiled: false, + fetch: releases(response).fetch, + }); + expect(report.reason).toBe("release-unavailable"); + expect(report.available).toBeNull(); + } + const noCandidates = await observeUpdate({ + ...base, + version: "5.0.0-next.17", + executable: "/example/bun", + compiled: false, + fetch: releases({ list: [{ tag_name: "v4.4.0" }] }).fetch, + }); + expect(noCandidates.reason).toBe("release-unavailable"); +}); + +test("an unsupported prerelease channel is unknown without any network read", async () => { + const read = releases({ latest: { tag_name: "v5.0.0" } }); + const report = await observeUpdate({ + ...base, + version: "5.0.0-rc.1", + executable: "/example/bun", + compiled: false, + fetch: read.fetch, + }); + expect(report).toMatchObject({ + channel: "prerelease", + available: null, + reason: "version-unrecognized", + }); + expect(read.urls).toEqual([]); +}); + +async function cli(args: readonly string[]) { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = spyOn(Bun, "spawn"); + const spawnSync = spyOn(Bun, "spawnSync"); + const code = await runCli([...args]); + return { code, stdout, spawn, spawnSync }; +} + +test("the CLI prints the schema envelope, refusing nothing and starting nothing", async () => { + const read = releases({ latest: { tag_name: "v99.0.0" } }); + spyOn(globalThis, "fetch").mockImplementation(read.fetch as never); + const saved = process.env.HACK_CANDIDATE_ENTRYPOINT; + Reflect.deleteProperty(process.env, "HACK_CANDIDATE_ENTRYPOINT"); + try { + const run = await cli(["update", "--check", "--json", "--observe"]); + expect(run.code).toBe(0); + const result = JSON.parse(run.stdout); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data.schema).toBe("hack.update-check.v1"); + expect(Object.keys(result.data).sort()).toEqual([ + "available", + "channel", + "current", + "install_kind", + "latest", + "reason", + "schema", + "upgrade_command", + ]); + expect(result.data.latest).toBe("99.0.0"); + expect(read.urls).toHaveLength(1); + expect(run.spawn).not.toHaveBeenCalled(); + expect(run.spawnSync).not.toHaveBeenCalled(); + } finally { + if (saved !== undefined) { + process.env.HACK_CANDIDATE_ENTRYPOINT = saved; + } + } +}); + +test.each([ + [["update", "--observe", "--json"]], + [["update", "--check", "--observe", "--json", "--yes"]], + [["update", "--check", "--observe", "--json", "--tag", "v1.0.0"]], +])("%p is a usage error before any network read", async (args) => { + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((() => { + throw new Error("unexpected network read"); + }) as never); + const run = await cli(args); + expect(run.code).not.toBe(0); + machineError(run.stdout, "E_USAGE"); + expect(fetchSpy).not.toHaveBeenCalled(); +}); From 8bf7861c5b4f659b8dbd11cc05cc3345bce30329 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:50:16 +0000 Subject: [PATCH 3/5] fix(update): only trust a complete page and the hack-next manager Review findings: the release list is ordered by creation, so one page proves "nothing newer" only when it is not full or holds the running release; otherwise the check now reports unknown. Read 100 releases. Only the hack-next launcher has `--manager upgrade`; other candidate launchers get no upgrade command. Placeholder paths avoid a home-path pattern. Co-Authored-By: Claude Opus 5.5 --- docs/reference/update-check.md | 13 ++++--- src/lib/update-observe.ts | 43 +++++++++++++++++----- tests/update-observe.test.ts | 66 +++++++++++++++++++++++++++++++--- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/docs/reference/update-check.md b/docs/reference/update-check.md index b9cd5dfd1..f14a3b657 100644 --- a/docs/reference/update-check.md +++ b/docs/reference/update-check.md @@ -19,7 +19,7 @@ contacts hackd, Docker or a VM. "available": true, "install_kind": "candidate", "upgrade_command": [ - "/example/home/.hack-next/bin/hack-next", + "/example/user/.hack-next/bin/hack-next", "--manager", "upgrade", "--version", @@ -34,11 +34,16 @@ contacts hackd, Docker or a VM. | Field | Meaning | | --- | --- | | `current`, `channel` | The running CLI, as in [`hack version --json`](./version.md). | -| `latest` | Stable channel: GitHub's latest release. Next channel: the newest non-draft `-next.N` release with the same major version. `null` when unknown. | +| `latest` | Stable channel: GitHub's latest release. Next channel: the newest non-draft `-next.N` release with the same major version on the first page of the release list (up to 100, newest created first). `null` when unknown. | | `available` | `true` when `latest` is newer than `current`, `false` when not, `null` when unknown. | | `install_kind` | `candidate` under a candidate launcher; otherwise the [version identity](./version.md) kind (`homebrew`, `standalone`, `source`, `symlink`, `dev-wrapper`, `unknown`). | -| `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: candidate → its manager's `upgrade --version`; stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`. | -| `reason` | `null` when `latest` and `available` are known; else `network-unavailable`, `release-unavailable` (error response, no matching release, or an unexpected tag) or `version-unrecognized` (a version other than `X.Y.Z` or `X.Y.Z-next.N`). | +| `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: the `hack-next` launcher → its manager's `upgrade --version`; stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`, including other candidate launchers (such as a bundle's `hack-v5`) and next-channel Homebrew or standalone installs, whose update paths install stable. | +| `reason` | `null` when `latest` and `available` are known; else `network-unavailable`, `release-unavailable` (error response, no matching release, an unexpected tag, or a full next-channel page that cannot rule out a newer release) or `version-unrecognized` (a version other than `X.Y.Z` or `X.Y.Z-next.N`). | + +The release list is ordered by creation, not version, and the check reads one +page. A newer candidate on that page is reported as available. "Not available" +is reported only when the page is not full or contains the running version's own +release, so every release created after it is on the page. Unknown is not "up to date": an offline machine reports `available: null`, still with exit 0, because the observation itself succeeded. `upgrade_command` is diff --git a/src/lib/update-observe.ts b/src/lib/update-observe.ts index 57b846925..d955030b7 100644 --- a/src/lib/update-observe.ts +++ b/src/lib/update-observe.ts @@ -11,7 +11,8 @@ import { buildVersionReport } from "./version-report.ts"; const RELEASES_API = "https://api.github.com/repos/hack-dance/hack/releases"; const FETCH_TIMEOUT_MS = 10_000; -const NEXT_LIST_PAGE_SIZE = 50; +/** GitHub's maximum page size; the check never reads a second page. */ +const NEXT_LIST_PAGE_SIZE = 100; const HOMEBREW_UPGRADE = ["brew", "upgrade", "hack-dance/tap/hack"] as const; const LEADING_V = /^v/; @@ -85,7 +86,17 @@ export function compareUpdateVersions( type ReadFailure = "network-unavailable" | "release-unavailable"; type LatestOutcome = - | { readonly ok: true; readonly version: string } + | { + readonly ok: true; + readonly version: string; + /** + * Whether "nothing newer" can be trusted. The list is ordered by creation, + * not version, so one page proves it only when the page is not full or + * contains the running version's own release (everything created later + * is then on the page). + */ + readonly settled: boolean; + } | { readonly ok: false; readonly reason: ReadFailure }; async function readJson(opts: { @@ -129,12 +140,13 @@ function releaseVersion(entry: unknown): string | null { /** * Stable: GitHub's latest release, which excludes prereleases and drafts. - * Next: the newest `-next.N` release with the running CLI's major version. + * Next: the newest `-next.N` release with the running CLI's major version + * on the first page of the release list. */ async function resolveLatest(opts: { readonly fetch: typeof fetch; readonly channel: "stable" | "next"; - readonly major: number; + readonly current: ParsedVersion; }): Promise { if (opts.channel === "stable") { const read = await readJson({ @@ -146,7 +158,7 @@ async function resolveLatest(opts: { } const version = releaseVersion(read.json); return version !== null && parseUpdateVersion(version)?.next === null - ? { ok: true, version } + ? { ok: true, version, settled: true } : { ok: false, reason: "release-unavailable" }; } const read = await readJson({ @@ -160,6 +172,7 @@ async function resolveLatest(opts: { return { ok: false, reason: "release-unavailable" }; } let best: { version: string; parsed: ParsedVersion } | null = null; + let includesCurrent = false; for (const entry of read.json) { const version = releaseVersion(entry); const parsed = version === null ? null : parseUpdateVersion(version); @@ -167,17 +180,22 @@ async function resolveLatest(opts: { version === null || parsed === null || parsed.next === null || - parsed.core[0] !== opts.major + parsed.core[0] !== opts.current.core[0] ) { continue; } + includesCurrent ||= compareUpdateVersions(parsed, opts.current) === 0; if (best === null || compareUpdateVersions(parsed, best.parsed) > 0) { best = { version, parsed }; } } return best === null ? { ok: false, reason: "release-unavailable" } - : { ok: true, version: best.version }; + : { + ok: true, + version: best.version, + settled: read.json.length < NEXT_LIST_PAGE_SIZE || includesCurrent, + }; } /** A supported, self-contained upgrade path for this install, or `null`. */ @@ -190,6 +208,11 @@ function upgradeCommand(opts: { readonly homeDir: string; }): readonly string[] | null { if (opts.installKind === "candidate") { + // Only the `hack-next` launcher has the standalone manager; other + // candidate launchers (such as a bundle's `hack-v5`) forward every argument. + if ((opts.env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim() !== "hack-next") { + return null; + } const prefix = upgradeCommandPrefix({ env: opts.env, homeDir: opts.homeDir, @@ -243,7 +266,7 @@ export async function observeUpdate(opts: { const latest = await resolveLatest({ fetch: opts.fetch ?? fetch, channel: identity.channel, - major: current.core[0], + current, }); if (!latest.ok) { return unknown(latest.reason); @@ -251,6 +274,10 @@ export async function observeUpdate(opts: { const target = parseUpdateVersion(latest.version); const available = target === null ? null : compareUpdateVersions(current, target) < 0; + if (available === false && !latest.settled) { + // A newer release may sit beyond the one page read: unknown, not current. + return unknown("release-unavailable"); + } return { current: identity.version, channel: identity.channel, diff --git a/tests/update-observe.test.ts b/tests/update-observe.test.ts index ddd768c2a..21ef89001 100644 --- a/tests/update-observe.test.ts +++ b/tests/update-observe.test.ts @@ -48,7 +48,7 @@ async function executable(relative: string): Promise { return path; } -const base = { env: {}, homeDir: "/example/home" } as const; +const base = { env: {}, homeDir: "/example/user" } as const; test("candidate versions sort before their release and by next number", () => { const parse = (raw: string) => { @@ -126,7 +126,7 @@ test("a candidate picks the newest same-major next release and its manager upgra }); const report = await observeUpdate({ env: { HACK_CANDIDATE_ENTRYPOINT: "hack-next" }, - homeDir: "/example/home", + homeDir: "/example/user", version: "5.0.0-next.17", executable: "/example/bun", compiled: false, @@ -139,7 +139,7 @@ test("a candidate picks the newest same-major next release and its manager upgra available: true, install_kind: "candidate", upgrade_command: [ - "/example/home/.hack-next/bin/hack-next", + "/example/user/.hack-next/bin/hack-next", "--manager", "upgrade", "--version", @@ -148,7 +148,7 @@ test("a candidate picks the newest same-major next release and its manager upgra reason: null, }); expect(read.urls).toEqual([ - "https://api.github.com/repos/hack-dance/hack/releases?per_page=50", + "https://api.github.com/repos/hack-dance/hack/releases?per_page=100", ]); }); @@ -291,3 +291,61 @@ test.each([ machineError(run.stdout, "E_USAGE"); expect(fetchSpy).not.toHaveBeenCalled(); }); + +test("a candidate launcher without the manager gets no upgrade command", async () => { + const report = await observeUpdate({ + env: { HACK_CANDIDATE_ENTRYPOINT: "/example/bundle/bin/hack-v5" }, + homeDir: "/example/user", + version: "5.0.0-next.17", + executable: "/example/bun", + compiled: false, + fetch: releases({ list: [{ tag_name: "v5.0.0-next.18" }] }).fetch, + }); + expect(report).toMatchObject({ + install_kind: "candidate", + available: true, + upgrade_command: null, + reason: null, + }); +}); + +/** A full first page: older stable releases fill it unless `extra` takes slots. */ +function fullPage(extra: readonly string[]): unknown[] { + const tags = [...extra]; + for (let index = 0; tags.length < 100; index++) { + tags.push(`v4.0.${index}`); + } + return tags.map((tag_name) => ({ tag_name, draft: false })); +} + +test("a full page proves 'up to date' only when it holds the running release", async () => { + const observe = (list: unknown[]) => + observeUpdate({ + ...base, + version: "5.0.0-next.17", + executable: "/example/bun", + compiled: false, + fetch: releases({ list }).fetch, + }); + // Older candidates only, and not the running one: a newer one may be on page two. + expect(await observe(fullPage(["v5.0.0-next.16"]))).toMatchObject({ + latest: null, + available: null, + reason: "release-unavailable", + }); + // The running release is listed, so everything created after it is too. + expect( + await observe(fullPage(["v5.0.0-next.17", "v5.0.0-next.16"])) + ).toMatchObject({ latest: "5.0.0-next.17", available: false, reason: null }); + // A newer candidate on the page is positive evidence either way. + expect(await observe(fullPage(["v5.0.0-next.18"]))).toMatchObject({ + latest: "5.0.0-next.18", + available: true, + }); + // A short page is the whole list. + expect(await observe([{ tag_name: "v5.0.0-next.16" }])).toMatchObject({ + latest: "5.0.0-next.16", + available: false, + reason: null, + }); +}); From 9e14a19cc5780b6bc2abdb2f425451a0ab096360 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:51:17 +0000 Subject: [PATCH 4/5] docs(update): state the accepted false negative of the one-page check Co-Authored-By: Claude Opus 5.5 --- docs/reference/update-check.md | 15 +++++++++++---- src/lib/update-observe.ts | 9 +++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/reference/update-check.md b/docs/reference/update-check.md index f14a3b657..bd23cbb06 100644 --- a/docs/reference/update-check.md +++ b/docs/reference/update-check.md @@ -40,10 +40,17 @@ contacts hackd, Docker or a VM. | `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: the `hack-next` launcher → its manager's `upgrade --version`; stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`, including other candidate launchers (such as a bundle's `hack-v5`) and next-channel Homebrew or standalone installs, whose update paths install stable. | | `reason` | `null` when `latest` and `available` are known; else `network-unavailable`, `release-unavailable` (error response, no matching release, an unexpected tag, or a full next-channel page that cannot rule out a newer release) or `version-unrecognized` (a version other than `X.Y.Z` or `X.Y.Z-next.N`). | -The release list is ordered by creation, not version, and the check reads one -page. A newer candidate on that page is reported as available. "Not available" -is reported only when the page is not full or contains the running version's own -release, so every release created after it is on the page. +The release list is ordered by creation time, not version, and the check reads +one page. A newer candidate on that page is reported as available. "Not +available" is reported only when the page is not full or contains the running +version's own release, so every release created after it is on the page. + +Known false negative: GitHub's creation time is when the release (or its draft) +was created. A newer candidate whose release was created *before* the running +version's release, and that a full page has pushed onto page two, is not seen; +the check then reports `available: false`. Hack's release workflow creates +releases in version order, so this needs a release drafted out of order. Treat +`available: false` as "no newer release in the current listing", not proof. Unknown is not "up to date": an offline machine reports `available: null`, still with exit 0, because the observation itself succeeded. `upgrade_command` is diff --git a/src/lib/update-observe.ts b/src/lib/update-observe.ts index d955030b7..0ca69ea50 100644 --- a/src/lib/update-observe.ts +++ b/src/lib/update-observe.ts @@ -90,10 +90,11 @@ type LatestOutcome = readonly ok: true; readonly version: string; /** - * Whether "nothing newer" can be trusted. The list is ordered by creation, - * not version, so one page proves it only when the page is not full or - * contains the running version's own release (everything created later - * is then on the page). + * Whether "nothing newer" can be reported. The list is ordered by + * creation, not version. A page that is not full is the whole list; a + * full page holding the running version's own release holds everything + * created after it. Accepted false negative (documented): a newer release + * created before the running one and pushed off a full page is missed. */ readonly settled: boolean; } From f96f219720e01950ee503f585cf64f83a0bba41d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:58:54 +0000 Subject: [PATCH 5/5] fix(update): name the running channel root's hack-next launcher The launcher exports the same marker for every root, so the command pointed at the default ~/.hack-next even for a custom --root install. The root now comes from the native executor the launcher pinned (/versions//bundle/hack-native) and the launcher file must exist; otherwise, including Compose-backend launches, the command is null. Addresses the Codex review on this PR. Co-Authored-By: Claude Opus 5.5 --- docs/reference/update-check.md | 2 +- src/commands/update.ts | 2 -- src/lib/update-observe.ts | 53 +++++++++++++++++++++--------- tests/update-observe.test.ts | 59 +++++++++++++++++++++++++++++++--- 4 files changed, 93 insertions(+), 23 deletions(-) diff --git a/docs/reference/update-check.md b/docs/reference/update-check.md index bd23cbb06..afd762c5a 100644 --- a/docs/reference/update-check.md +++ b/docs/reference/update-check.md @@ -37,7 +37,7 @@ contacts hackd, Docker or a VM. | `latest` | Stable channel: GitHub's latest release. Next channel: the newest non-draft `-next.N` release with the same major version on the first page of the release list (up to 100, newest created first). `null` when unknown. | | `available` | `true` when `latest` is newer than `current`, `false` when not, `null` when unknown. | | `install_kind` | `candidate` under a candidate launcher; otherwise the [version identity](./version.md) kind (`homebrew`, `standalone`, `source`, `symlink`, `dev-wrapper`, `unknown`). | -| `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: the `hack-next` launcher → its manager's `upgrade --version`; stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`, including other candidate launchers (such as a bundle's `hack-v5`) and next-channel Homebrew or standalone installs, whose update paths install stable. | +| `upgrade_command` | The argv to run in a terminal, only when `available` is `true` and this install has a supported path: a native `hack-next` launch → that channel root's own `bin/hack-next --manager upgrade --version` (the root comes from the executor the launcher pinned, and the launcher file must exist); stable Homebrew → `brew upgrade hack-dance/tap/hack`; stable standalone → that binary's `update`. Otherwise `null`, including a Compose-backend `hack-next` launch (no root is exported), other candidate launchers (such as a bundle's `hack-v5`) and next-channel Homebrew or standalone installs, whose update paths install stable. | | `reason` | `null` when `latest` and `available` are known; else `network-unavailable`, `release-unavailable` (error response, no matching release, an unexpected tag, or a full next-channel page that cannot rule out a newer release) or `version-unrecognized` (a version other than `X.Y.Z` or `X.Y.Z-next.N`). | The release list is ordered by creation time, not version, and the check reads diff --git a/src/commands/update.ts b/src/commands/update.ts index c228f3009..9056010e3 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,5 +1,4 @@ import { access, rm } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; @@ -304,7 +303,6 @@ const handleObserve: CommandHandlerFor = async ({ ctx, args }) => { executable: process.execPath, compiled: Bun.main.startsWith("/$bunfs/"), env: process.env, - homeDir: homedir(), }); emitCliResult({ result: machineSuccess({ diff --git a/src/lib/update-observe.ts b/src/lib/update-observe.ts index 0ca69ea50..b5563bc4d 100644 --- a/src/lib/update-observe.ts +++ b/src/lib/update-observe.ts @@ -5,8 +5,9 @@ * registry write, daemon or engine contact. Missing evidence is reported as * `null` with a closed `reason`, never as "up to date". */ +import { lstat } from "node:fs/promises"; +import { isAbsolute, join, sep } from "node:path"; import { isRecord } from "./guards.ts"; -import { upgradeCommandPrefix } from "./upgrade-check.ts"; import { buildVersionReport } from "./version-report.ts"; const RELEASES_API = "https://api.github.com/repos/hack-dance/hack/releases"; @@ -199,26 +200,49 @@ async function resolveLatest(opts: { }; } +/** + * The `hack-next` launcher of the channel root running this CLI, or `null`. + * The launcher exports the same marker for every root, so the root comes from + * the native executor it pinned: `/versions//bundle/hack-native`. + * A Compose-backend launch carries no root and gets no command. + */ +async function candidateLauncher(opts: { + readonly env: Readonly>; + readonly current: string; +}): Promise { + // Only `hack-next` has the standalone manager; other candidate launchers + // (such as a bundle's `hack-v5`) forward every argument to the CLI. + if ((opts.env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim() !== "hack-next") { + return null; + } + const executor = (opts.env.HACK_NATIVE_BINARY ?? "").trim(); + const suffix = join("versions", opts.current, "bundle", "hack-native"); + if (!(isAbsolute(executor) && executor.endsWith(`${sep}${suffix}`))) { + return null; + } + const root = executor.slice(0, -suffix.length - 1); + const launcher = join(root, "bin", "hack-next"); + const info = await lstat(launcher).catch(() => null); + return info?.isFile() ? launcher : null; +} + /** A supported, self-contained upgrade path for this install, or `null`. */ -function upgradeCommand(opts: { +async function upgradeCommand(opts: { readonly installKind: UpdateCheckInstallKind; readonly channel: VersionReport["channel"]; + readonly current: string; readonly latest: string; readonly binaryPath: string; readonly env: Readonly>; - readonly homeDir: string; -}): readonly string[] | null { +}): Promise { if (opts.installKind === "candidate") { - // Only the `hack-next` launcher has the standalone manager; other - // candidate launchers (such as a bundle's `hack-v5`) forward every argument. - if ((opts.env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim() !== "hack-next") { - return null; - } - const prefix = upgradeCommandPrefix({ + const launcher = await candidateLauncher({ env: opts.env, - homeDir: opts.homeDir, + current: opts.current, }); - return [...prefix.argv, "--manager", "upgrade", "--version", opts.latest]; + return launcher === null + ? null + : [launcher, "--manager", "upgrade", "--version", opts.latest]; } if (opts.channel !== "stable") { // `hack update` and Homebrew install the latest stable release only. @@ -238,7 +262,6 @@ export async function observeUpdate(opts: { readonly executable: string; readonly compiled: boolean; readonly env: Readonly>; - readonly homeDir: string; readonly fetch?: typeof fetch; }): Promise { const identity = await buildVersionReport({ @@ -287,13 +310,13 @@ export async function observeUpdate(opts: { install_kind: installKind, upgrade_command: available === true - ? upgradeCommand({ + ? await upgradeCommand({ installKind, channel: identity.channel, + current: identity.version, latest: latest.version, binaryPath: identity.binary_path, env: opts.env, - homeDir: opts.homeDir, }) : null, reason: available === null ? "version-unrecognized" : null, diff --git a/tests/update-observe.test.ts b/tests/update-observe.test.ts index 21ef89001..75c868115 100644 --- a/tests/update-observe.test.ts +++ b/tests/update-observe.test.ts @@ -48,7 +48,7 @@ async function executable(relative: string): Promise { return path; } -const base = { env: {}, homeDir: "/example/user" } as const; +const base = { env: {} } as const; test("candidate versions sort before their release and by next number", () => { const parse = (raw: string) => { @@ -112,6 +112,20 @@ test("a Homebrew install is reported, not refused, with the brew upgrade", async ]); }); +/** The environment a native `hack-next` launch exports for one channel root. */ +function candidateEnv(channel: string, version: string) { + return { + HACK_CANDIDATE_ENTRYPOINT: "hack-next", + HACK_NATIVE_BINARY: join( + channel, + "versions", + version, + "bundle", + "hack-native" + ), + }; +} + test("a candidate picks the newest same-major next release and its manager upgrade", async () => { const read = releases({ list: [ @@ -124,9 +138,11 @@ test("a candidate picks the newest same-major next release and its manager upgra "not a release", ], }); + // A custom channel root: the command must name this root's launcher. + const channel = join(root, "custom-channel"); + const launcher = await executable("custom-channel/bin/hack-next"); const report = await observeUpdate({ - env: { HACK_CANDIDATE_ENTRYPOINT: "hack-next" }, - homeDir: "/example/user", + env: candidateEnv(channel, "5.0.0-next.17"), version: "5.0.0-next.17", executable: "/example/bun", compiled: false, @@ -139,7 +155,7 @@ test("a candidate picks the newest same-major next release and its manager upgra available: true, install_kind: "candidate", upgrade_command: [ - "/example/user/.hack-next/bin/hack-next", + launcher, "--manager", "upgrade", "--version", @@ -295,7 +311,6 @@ test.each([ test("a candidate launcher without the manager gets no upgrade command", async () => { const report = await observeUpdate({ env: { HACK_CANDIDATE_ENTRYPOINT: "/example/bundle/bin/hack-v5" }, - homeDir: "/example/user", version: "5.0.0-next.17", executable: "/example/bun", compiled: false, @@ -349,3 +364,37 @@ test("a full page proves 'up to date' only when it holds the running release", a reason: null, }); }); + +test("a hack-next command needs the running root's own launcher", async () => { + const channel = join(root, "channel"); + const observe = (env: Record) => + observeUpdate({ + env, + version: "5.0.0-next.17", + executable: "/example/bun", + compiled: false, + fetch: releases({ list: [{ tag_name: "v5.0.0-next.18" }] }).fetch, + }); + // No launcher file at /bin/hack-next. + expect( + (await observe(candidateEnv(channel, "5.0.0-next.17"))).upgrade_command + ).toBeNull(); + await executable("channel/bin/hack-next"); + // A Compose launch exports no executor, so the root is unknown. + expect( + (await observe({ HACK_CANDIDATE_ENTRYPOINT: "hack-next" })).upgrade_command + ).toBeNull(); + // An executor for another version does not identify this CLI's root. + expect( + (await observe(candidateEnv(channel, "5.0.0-next.16"))).upgrade_command + ).toBeNull(); + expect( + (await observe(candidateEnv(channel, "5.0.0-next.17"))).upgrade_command + ).toEqual([ + join(channel, "bin", "hack-next"), + "--manager", + "upgrade", + "--version", + "5.0.0-next.18", + ]); +});