Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/v5-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -1947,6 +1948,7 @@ Notes:
| `--check` | Check for updates (do not install) |
| `--yes` | Apply update without prompting |
| `--tag <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 |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/machine-readable-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
<!-- json-inventory:end -->
62 changes: 62 additions & 0 deletions docs/reference/update-check.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions docs/versions/v5/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
5 changes: 5 additions & 0 deletions src/cli/json-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
50 changes: 48 additions & 2 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +27,7 @@ import {
resolveUpdateTarget,
selectCliTarballAsset,
} from "../lib/self-update.ts";
import { observeUpdate } from "../lib/update-observe.ts";

const optCheck = defineOption({
name: "check",
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -68,6 +85,9 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({
ctx,
args,
}): Promise<number> => {
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.
Expand Down Expand Up @@ -267,6 +287,32 @@ const handleUpdate: CommandHandlerFor<Spec> = 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<Spec> = 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;
Expand Down
1 change: 1 addition & 0 deletions src/lib/machine-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends object>(opts: {
Expand Down
Loading
Loading