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
23 changes: 21 additions & 2 deletions docs/guides/candidate-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ also available; see [provider setup](native-candidate.md).

Preflight reports runtime installation separately from project review, in text
and in the JSON `runtime_setup` item. A fresh or interrupted home blocks startup
with `hack native setup` as its fix. Noninteractive `up` refuses before starting
with `native setup` as its fix, printed for the launcher you ran (see
[Printed commands name your launcher](#printed-commands-name-your-launcher)). Noninteractive `up` refuses before starting
an owner; interactive `up` offers the same pinned setup and continues only after
successful setup and a fresh readiness check. Record readiness is a quick check;
startup still verifies prepared content before executing it.
Expand All @@ -143,7 +144,8 @@ Diagram [source](native-first-use.mmd); rendered with Pele 1.9.0.
Managed `hack-next` keeps its version's receipt-owned `native-home` so upgrades
can find and stop its runtime. It refuses a differing `HACK_NATIVE_HOME` rather
than silently ignoring it. Use that version's bundle `hack-v5` launcher for a
custom home. To move a manual bundle's home, stop its projects and runtime using
custom home; the refusal prints its path,
`$HOME/.hack-next/versions/<version>/bundle/hack-v5`. To move a manual bundle's home, stop its projects and runtime using
the old home and matching executor, preserve the old home for retained data, then
select a fresh absolute `HACK_NATIVE_HOME` and run `native setup` again. Setup
does not migrate live receipts, disks or project data. The machine home is
Expand Down Expand Up @@ -235,6 +237,23 @@ tracked `.hack/.internal/` paths. `hack doctor --fix` removes them from the Git
index with `git rm --cached` and keeps the files on disk; commit that removal.
See [ignore rules](../env.md#ignore-rules-committed-hackgitignore).

### Printed commands name your launcher

Stable v4 stays `hack` on your PATH, so a prerelease never tells you to run a
bare `hack`. Commands it prints, such as the next steps of `upgrade check` and
`upgrade apply`, the `native setup` fix and the Compose-backend hint, name the
launcher the command ran through:

| You ran | Printed commands start with |
| --- | --- |
| `hack-next` | your channel's launcher by full path, `$HOME/.hack-next/bin/hack-next` by default or `<root>/bin/hack-next` for a `--root` install (it is not on PATH) |
| a bundle's `hack-v5` | that bundle's `hack-v5`, by its full path |
| stable `hack` | `hack` |

A Compose run also prints `HACK_RUNTIME_BACKEND=compose` in front. A custom
`HACK_NATIVE_HOME` is the one exception: `hack-next` refuses it, so that fix
names the selected bundle's `hack-v5` instead.

## Upgrade, roll back and return to stable

The standalone manager runs through the launcher:
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ The runtime review half needs a native runtime, so run the preflight from the
`hack-next` launcher for the full list. Without native selection only the CLI's
checks run and the review is reported as `skipped`. The review needs no running
VM. A selected runtime also receives a read-only setup inspection. Incomplete or
unavailable setup is a `native_runtime_setup` blocker; run `hack native setup`
before retrying. Without native selection, setup is reported as unavailable.
unavailable setup is a `native_runtime_setup` blocker; run the `native setup`
command its fix prints (it names the launcher you used) before retrying. Without native selection, setup is reported as unavailable.

Use a matching updated CLI and runtime. A runtime without start-admission review
is reported as `unavailable` with an update hint and cannot produce `ready: true`.
Expand Down
3 changes: 2 additions & 1 deletion docs/native/try-it.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ Ask Hack whether the project can run on native before you start it:

`hack-next` selects the native backend by default. If the project uses a Compose
feature that native does not support, `doctor` names it. Starting the project would
refuse with `E_NATIVE_PROJECT_UNSUPPORTED` and change nothing. See
refuse with `E_NATIVE_PROJECT_UNSUPPORTED` and change nothing. Both print the
command that runs it on Compose instead, with the full `hack-next` path. See
[Compose features that stay on Compose](limits.md#compose-features-that-stay-on-compose).

## 4. Run it
Expand Down
10 changes: 8 additions & 2 deletions scripts/hack-v5.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,14 @@ if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then
echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2
exit 69
fi
# Candidate CLIs refuse self-update on either backend.
export HACK_CANDIDATE_ENTRYPOINT=hack-v5
# Candidate CLIs refuse self-update on either backend. The marker also names
# the launcher printed commands should use: the channel manager runs this
# script as `hack-next` (~/.hack-next/bin/hack-next) and that value is kept;
# every other run is this script, `hack-v5`. No other value reaches the CLI.
if [ "${HACK_CANDIDATE_ENTRYPOINT:-}" != hack-next ]; then
HACK_CANDIDATE_ENTRYPOINT=hack-v5
fi
export HACK_CANDIDATE_ENTRYPOINT
# Candidate runs never start hackd implicitly: autostart would start the
# stable hackd (launchd plist or the `hack` on PATH), on Compose beside the
# candidate on the same Hack home (HACK-1368).
Expand Down
4 changes: 3 additions & 1 deletion scripts/installer/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "node:fs";
import { constants as osConstants } from "node:os";
import { basename, dirname, join } from "node:path";
import { quoteShellWord } from "../../src/lib/logs.ts";
import {
extractArchive,
sameJson,
Expand Down Expand Up @@ -1038,7 +1039,8 @@ export class Channel {
const override = process.env.HACK_NATIVE_HOME?.trim();
requireThat(
!override || override === this.path(`versions/${version}/native-home`),
"hack-next uses its receipt-owned native home for safe upgrades; use the bundle's hack-v5 launcher for a custom HACK_NATIVE_HOME."
// Name the selected bundle's script by path: it is not on PATH (HACK-2251).
`hack-next uses its receipt-owned native home for safe upgrades; for a custom HACK_NATIVE_HOME run the bundle's launcher directly: ${quoteShellWord(this.path(`versions/${version}/bundle/hack-v5`))}`
);
}
const checksum = this.state.installed[version];
Expand Down
59 changes: 41 additions & 18 deletions src/backends/native-runtime-setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { constants as osConstants } from "node:os";
import { basename, join } from "node:path";
import {
type CandidateLauncher,
currentCandidateLauncher,
freshNativeHomeSetupCommand,
launcherCommand,
} from "../lib/candidate-launcher.ts";
import { childEnvironment } from "../lib/child-environment.ts";
import { HackCliError } from "../lib/cli-result.ts";
import { isRecord } from "../lib/guards.ts";
Expand Down Expand Up @@ -46,21 +52,37 @@ const CODES: Readonly<Record<NativeSetupState, string>> = {
unavailable: "native_setup_unavailable",
unsupported_host: "unsupported_host",
};
const GUIDANCE: Readonly<Record<NativeSetupState, string>> = {
not_set_up: "Native runtime is not set up; run hack native setup.",
partial_resumable:
"Native runtime setup is incomplete; run hack native setup to resume.",
ready: "Native runtime is set up.",
invalid_preserved:
"Native runtime files are incomplete or invalid; preserve this home and select a fresh private HACK_NATIVE_HOME with the bundle's hack-v5 launcher before running hack native setup.",
pin_mismatch:
"Native runtime pins differ from this release; preserve this home for its matching release and select a fresh private HACK_NATIVE_HOME with the bundle's hack-v5 launcher.",
unsafe_home:
"Native runtime home is unsafe; select an owned mode-0700 home without symlink components using HACK_NATIVE_HOME.",
unavailable:
"Native runtime installation could not be inspected; check the selected home's access and retry.",
unsupported_host: "Native runtime requires Apple Silicon macOS.",
};
/**
* What to do next, naming the command for this install kind: `hack`,
* `~/.hack-next/bin/hack-next`, or the bundle's `hack-v5` by path (HACK-2251).
*/
function guidance(
state: NativeSetupState,
launcher: CandidateLauncher
): string {
const setup = launcherCommand(launcher, ["native", "setup"]);
const fresh = freshNativeHomeSetupCommand(launcher);
switch (state) {
case "not_set_up":
return `Native runtime is not set up; run ${setup}.`;
case "partial_resumable":
return `Native runtime setup is incomplete; run ${setup} to resume.`;
case "ready":
return "Native runtime is set up.";
case "invalid_preserved":
return `Native runtime files are incomplete or invalid; preserve this home and set up a fresh private one with the bundle's launcher: ${fresh}.`;
case "pin_mismatch":
return `Native runtime pins differ from this release; preserve this home for its matching release and set up a fresh private one with the bundle's launcher: ${fresh}.`;
case "unsafe_home":
return "Native runtime home is unsafe; select an owned mode-0700 home without symlink components using HACK_NATIVE_HOME.";
case "unavailable":
return "Native runtime installation could not be inspected; check the selected home's access and retry.";
case "unsupported_host":
return "Native runtime requires Apple Silicon macOS.";
default:
return state satisfies never;
}
}

function state(value: unknown): NativeSetupState | undefined {
return STATES.find((candidate) => candidate === value);
Expand Down Expand Up @@ -157,9 +179,10 @@ export async function inspectNativeSetup(opts: {
}

export function nativeSetupGuidance(
inspection: Pick<NativeSetupInspection, "state">
inspection: Pick<NativeSetupInspection, "state">,
launcher: CandidateLauncher = currentCandidateLauncher()
): string {
return GUIDANCE[inspection.state];
return guidance(inspection.state, launcher);
}

export function nativeSetupCanResume(
Expand Down Expand Up @@ -302,7 +325,7 @@ export async function requireNativeSetup(opts: {
});
if (exit !== 0) {
process.stderr.write(
"Native runtime setup did not complete; run hack native setup to resume.\n"
`Native runtime setup did not complete; run ${launcherCommand(currentCandidateLauncher(), ["native", "setup"])} to resume.\n`
);
return exit;
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3003,6 +3003,7 @@ async function checkComposeBackendFeatures(opts: {
});
return composeBackendCheckResult({
backend: opts.backend,
projectRoot: ctx.projectRoot,
buildsEnabled,
features: reading.features,
basicBuild: reading.basicBuild,
Expand Down
5 changes: 5 additions & 0 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6099,6 +6099,11 @@ async function handleNativeUp({
}),
restart,
buildsEnabled: nativeBuildsEnabled(),
// The printed Compose fallback selects the same project and branch.
projectRoot: project.projectRoot,
branch:
typeof args.options.branch === "string" ? args.options.branch : undefined,
env: typeof args.options.env === "string" ? args.options.env : undefined,
});
const branch = await resolveEffectiveBranchForCommand({
project,
Expand Down
20 changes: 18 additions & 2 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
withHandler,
} from "../cli/command.ts";
import { optJson } from "../cli/options.ts";
import {
type CandidateLauncher,
currentCandidateLauncher,
launcherCommand,
} from "../lib/candidate-launcher.ts";
import { emitCliResult } from "../lib/cli-result.ts";
import { resolveGlobalHackDir } from "../lib/config-paths.ts";
import { canPrompt } from "../lib/interactivity.ts";
Expand Down Expand Up @@ -81,6 +86,18 @@ const spec = defineCommand({

type Spec = typeof spec;

/**
* The refusal names how this install kind is updated (HACK-2251): `hack-next`
* through its channel manager; a bundle run directly, by installing a newer
* reviewed bundle (there is no command for that).
*/
export function candidateUpdateRefusal(launcher: CandidateLauncher): string {
if (launcher.kind === "hack-next") {
return `Refusing to self-update the opt-in native candidate. Upgrade it through its channel manager: ${launcherCommand(launcher, ["--manager", "upgrade", "--version"])} <newer prerelease> (${launcherCommand(launcher, ["--manager", "status"])} shows the selected version).`;
}
return "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.";
}

const handleUpdate: CommandHandlerFor<Spec> = async ({
ctx,
args,
Expand All @@ -99,8 +116,7 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({
json: args.options.json === true,
result: {
ok: false,
error:
"Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.",
error: candidateUpdateRefusal(currentCandidateLauncher()),
},
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ function outcomeLines(
): string[] {
const notes = result.notes.map((note) => `Note: ${note}`);
if (result.targets.length === 0) {
return ["Nothing for hack upgrade apply to do.", ...notes];
return ["Nothing for upgrade apply to do.", ...notes];
}
const groups = new Map<string, (typeof result.targets)[number][]>();
for (const target of result.targets) {
Expand Down
137 changes: 137 additions & 0 deletions src/lib/candidate-launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { statSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { resolveUserHomeDir } from "./home-dir.ts";
import { quoteShellWord } from "./logs.ts";

/**
* Which command a user runs to reach this CLI again, by install kind
* (HACK-2251).
*
* During the prerelease, v5 runs through a candidate launcher while stable v4
* stays `hack` on PATH, so text that tells the user what to run must name the
* launcher they actually have:
*
* - `stable`: no candidate marker; the command is `hack`.
* - `hack-next`: the channel manager's launcher at
* `<root>/bin/hack-next` of the channel root holding the running bundle
* (default root `~/.hack-next`; not on PATH).
* - `bundle`: the bundle's own `hack-v5` script, run by path. It sits next to
* the bundle's `hack-cli`, which is the running executable. When that file
* cannot be found (a source run with the marker set by hand), the name
* `hack-v5` is the best available answer.
*
* This only names commands. Whether a run is a candidate run, and so whether
* hackd may autostart, is `isCandidateRun` in `src/daemon/client.ts`: any
* non-empty marker counts, whatever this function makes of it.
*/
export type CandidateLauncher =
| { readonly kind: "stable"; readonly command: "hack" }
| {
readonly kind: "hack-next";
readonly command: string;
/** The selected bundle's `hack-v5`, for what `hack-next` refuses. */
readonly bundle: string;
}
| { readonly kind: "bundle"; readonly command: string };

/** The marker the launchers set; see `scripts/hack-v5.sh`. */
const CANDIDATE_ENTRYPOINT_ENV = "HACK_CANDIDATE_ENTRYPOINT";

/** The channel manager's launcher under a home directory. */
export function hackNextLauncherPath(homeDir: string): string {
return join(homeDir, ".hack-next", "bin", "hack-next");
}

/**
* The `hack-next` launcher of the channel root that holds the running
* bundle, if there is one. The manager runs `<root>/versions/<v>/bundle/
* hack-cli` on both backends, and `--root` installs live anywhere, so the
* root comes from the running executable, not from HOME.
*/
export function bundleChannelLauncher(execPath: string): string | null {
const bundle = dirname(execPath);
if (basename(bundle) !== "bundle") {
return null;
}
const versions = dirname(dirname(bundle));
if (basename(versions) !== "versions") {
return null;
}
const path = join(dirname(versions), "bin", "hack-next");
try {
return statSync(path).isFile() ? path : null;
} catch {
return null;
}
}

/** The bundle's `hack-v5` next to the running `hack-cli`, if it is a file. */
export function adjacentBundleLauncher(execPath: string): string | null {
const path = join(dirname(execPath), "hack-v5");
try {
return statSync(path).isFile() ? path : null;
} catch {
return null;
}
}

export function resolveCandidateLauncher(opts: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly homeDir: string;
/** The running executable (default `process.execPath`). */
readonly execPath?: string;
}): CandidateLauncher {
const entry = (opts.env[CANDIDATE_ENTRYPOINT_ENV] ?? "").trim();
if (entry === "") {
return { kind: "stable", command: "hack" };
}
const execPath = opts.execPath ?? process.execPath;
const bundle = adjacentBundleLauncher(execPath) ?? "hack-v5";
if (entry === "hack-next") {
return {
kind: "hack-next",
// The installation's own launcher; the default root only when the
// running bundle is not inside a channel root (a source run).
command:
bundleChannelLauncher(execPath) ?? hackNextLauncherPath(opts.homeDir),
bundle,
};
}
return { kind: "bundle", command: bundle };
}

/** The launcher for this process, from its environment and the user's home. */
export function currentCandidateLauncher(): CandidateLauncher {
return resolveCandidateLauncher({
env: process.env,
homeDir: resolveUserHomeDir(),
});
}

/** A command line for `launcher`: optional env assignments, then each word quoted. */
export function launcherCommand(
launcher: CandidateLauncher,
args: readonly string[],
env: Readonly<Record<string, string>> = {}
): string {
return [
...Object.entries(env).map(
([key, value]) => `${key}=${quoteShellWord(value)}`
),
...[launcher.command, ...args].map((word) => quoteShellWord(word)),
].join(" ");
}

/**
* The command that sets up a fresh native home. A custom `HACK_NATIVE_HOME`
* needs the bundle's `hack-v5`: `hack-next` refuses one, because it keeps its
* receipt-owned home for safe upgrades. `<new private home>` is a placeholder
* the user replaces.
*/
export function freshNativeHomeSetupCommand(
launcher: CandidateLauncher
): string {
const runner =
launcher.kind === "hack-next" ? launcher.bundle : launcher.command;
return `HACK_NATIVE_HOME=<new private home> ${quoteShellWord(runner)} native setup`;
}
Loading
Loading