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..afd762c5a --- /dev/null +++ b/docs/reference/update-check.md @@ -0,0 +1,62 @@ +# 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/user/.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 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: 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 +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 +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..9056010e3 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -3,10 +3,17 @@ 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 +27,7 @@ import { resolveUpdateTarget, selectCliTarballAsset, } from "../lib/self-update.ts"; +import { observeUpdate } from "../lib/update-observe.ts"; const optCheck = defineOption({ name: "check", @@ -43,7 +51,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 +72,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 +85,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 +287,32 @@ 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, + }); + 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..b5563bc4d --- /dev/null +++ b/src/lib/update-observe.ts @@ -0,0 +1,324 @@ +/** + * 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 { lstat } from "node:fs/promises"; +import { isAbsolute, join, sep } from "node:path"; +import { isRecord } from "./guards.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; +/** 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/; +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; + /** + * 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; + } + | { 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 + * on the first page of the release list. + */ +async function resolveLatest(opts: { + readonly fetch: typeof fetch; + readonly channel: "stable" | "next"; + readonly current: ParsedVersion; +}): 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, settled: true } + : { 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; + let includesCurrent = false; + 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.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, + settled: read.json.length < NEXT_LIST_PAGE_SIZE || includesCurrent, + }; +} + +/** + * 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`. */ +async function upgradeCommand(opts: { + readonly installKind: UpdateCheckInstallKind; + readonly channel: VersionReport["channel"]; + readonly current: string; + readonly latest: string; + readonly binaryPath: string; + readonly env: Readonly>; +}): Promise { + if (opts.installKind === "candidate") { + const launcher = await candidateLauncher({ + env: opts.env, + current: opts.current, + }); + return launcher === null + ? null + : [launcher, "--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 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, + current, + }); + if (!latest.ok) { + return unknown(latest.reason); + } + 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, + latest: latest.version, + available, + install_kind: installKind, + upgrade_command: + available === true + ? await upgradeCommand({ + installKind, + channel: identity.channel, + current: identity.version, + latest: latest.version, + binaryPath: identity.binary_path, + env: opts.env, + }) + : 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..75c868115 --- /dev/null +++ b/tests/update-observe.test.ts @@ -0,0 +1,400 @@ +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: {} } 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", + ]); +}); + +/** 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: [ + { 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", + ], + }); + // 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: candidateEnv(channel, "5.0.0-next.17"), + 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: [ + launcher, + "--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=100", + ]); +}); + +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(); +}); + +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" }, + 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, + }); +}); + +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", + ]); +});