From c8690121e31c26bf92dd12d5216be8fd081d2746 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:03:57 +0000 Subject: [PATCH 1/8] fix(candidate): print commands for the launcher the user has (HACK-2251) With the prerelease installed through hack-next, `hack-next upgrade check` printed `hack-v5 ...` commands: the bundle entrypoint overwrote the manager's HACK_CANDIDATE_ENTRYPOINT=hack-next with hack-v5, and hack-v5 is not on PATH. - scripts/hack-v5.sh keeps `hack-next` when the manager set exactly that and sets `hack-v5` otherwise. - src/lib/candidate-launcher.ts resolves the runnable command per install kind: `hack`, ~/.hack-next/bin/hack-next, or the bundle's hack-v5 by path (next to the running hack-cli). - upgrade check/apply, native setup guidance, the Compose-backend refusal and doctor hint, and the self-update refusal use it. Candidate-run detection and the daemon autostart rule are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/hack-v5.sh | 10 +- src/backends/native-runtime-setup.ts | 59 +++++++--- src/commands/update.ts | 20 +++- src/lib/candidate-launcher.ts | 106 ++++++++++++++++++ src/lib/doctor-compose-backend.ts | 6 +- src/lib/native-compose-backend-features.ts | 21 +++- src/lib/upgrade-check.ts | 23 ++-- ...ative-compose-backend-features-cli.test.ts | 8 +- tests/native-project-settings-cli.test.ts | 2 +- tests/update-command.test.ts | 9 +- 10 files changed, 219 insertions(+), 45 deletions(-) create mode 100644 src/lib/candidate-launcher.ts diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index a8ee48eed..6027e6eee 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -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). diff --git a/src/backends/native-runtime-setup.ts b/src/backends/native-runtime-setup.ts index 273c8a095..786db6efc 100644 --- a/src/backends/native-runtime-setup.ts +++ b/src/backends/native-runtime-setup.ts @@ -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"; @@ -46,21 +52,37 @@ const CODES: Readonly> = { unavailable: "native_setup_unavailable", unsupported_host: "unsupported_host", }; -const GUIDANCE: Readonly> = { - 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); @@ -157,9 +179,10 @@ export async function inspectNativeSetup(opts: { } export function nativeSetupGuidance( - inspection: Pick + inspection: Pick, + launcher: CandidateLauncher = currentCandidateLauncher() ): string { - return GUIDANCE[inspection.state]; + return guidance(inspection.state, launcher); } export function nativeSetupCanResume( @@ -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; } diff --git a/src/commands/update.ts b/src/commands/update.ts index 00358f1eb..91c95dd1e 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -5,6 +5,11 @@ import { confirm, isCancel } from "@clack/prompts"; import type { CommandHandlerFor } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optJson } from "../cli/options.ts"; +import { + type CandidateLauncher, + currentCandidateLauncher, + launcherCommand, +} from "../lib/candidate-launcher.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { @@ -64,6 +69,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"])} (${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 = async ({ ctx, args, @@ -79,8 +96,7 @@ const handleUpdate: CommandHandlerFor = 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()), }, }); } diff --git a/src/lib/candidate-launcher.ts b/src/lib/candidate-launcher.ts new file mode 100644 index 000000000..1e15b8c7a --- /dev/null +++ b/src/lib/candidate-launcher.ts @@ -0,0 +1,106 @@ +import { statSync } from "node:fs"; +import { 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 + * `~/.hack-next/bin/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 } + | { 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 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>; + 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" }; + } + if (entry === "hack-next") { + return { kind: "hack-next", command: hackNextLauncherPath(opts.homeDir) }; + } + return { + kind: "bundle", + command: + adjacentBundleLauncher(opts.execPath ?? process.execPath) ?? "hack-v5", + }; +} + +/** 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> = {} +): 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. `` is a placeholder + * the user replaces. + */ +export function freshNativeHomeSetupCommand( + launcher: CandidateLauncher, + execPath: string = process.execPath +): string { + const runner = + launcher.kind === "hack-next" + ? (adjacentBundleLauncher(execPath) ?? "hack-v5") + : launcher.command; + return `HACK_NATIVE_HOME= ${quoteShellWord(runner)} native setup`; +} diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index ce331fd71..7bb1298cc 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,8 +1,8 @@ import { BASIC_BUILD_NEXT_STEP, - COMPOSE_BACKEND_SELECTION, type ComposeBackendFeature, type ComposeBackendFeatureOptions, + composeBackendCommand, describeComposeBackendFeatures, ISOLATED_NETWORKS_SELECTION, } from "./native-compose-backend-features.ts"; @@ -66,7 +66,7 @@ export function composeBackendCheckResult( return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${BASIC_BUILD_NEXT_STEP}, or ${COMPOSE_BACKEND_SELECTION} to run it on the Compose backend.`, + message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${BASIC_BUILD_NEXT_STEP}, or run it on the Compose backend: ${composeBackendCommand(["up"])}.`, composeBackend, }; } @@ -91,7 +91,7 @@ export function composeBackendCheckResult( return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `${uses}, so native up refuses it and the project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} to run it there.${network}${build}`, + message: `${uses}, so native up refuses it and the project runs on the Compose backend. To run it there: ${composeBackendCommand(["up"])}.${network}${build}`, composeBackend, }; } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 1ca0b04ac..d58266d69 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -6,6 +6,11 @@ import { selectNativePlatformModes, } from "../backends/native-project-platform.ts"; import { readNativeSelection } from "../backends/native-project-selection.ts"; +import { + type CandidateLauncher, + currentCandidateLauncher, + launcherCommand, +} from "./candidate-launcher.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; import { HACK_OWNERSHIP_LABEL_NAMESPACES } from "./hack-ownership-labels.ts"; @@ -582,9 +587,17 @@ export function describeComposeBackendFeatures( return `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}`; } -/** The selection that runs this project on the Compose backend. */ -export const COMPOSE_BACKEND_SELECTION = - "Set HACK_RUNTIME_BACKEND=compose (hack-next and hack-v5 accept it too)"; +/** + * The command that runs `args` on the Compose backend, through the launcher + * the user has (`hack`, `~/.hack-next/bin/hack-next` or the bundle's + * `hack-v5`; HACK-2251). + */ +export function composeBackendCommand( + args: readonly string[], + launcher: CandidateLauncher = currentCandidateLauncher() +): string { + return launcherCommand(launcher, args, { HACK_RUNTIME_BACKEND: "compose" }); +} /** * The documented selection that keeps a project with an external network on @@ -615,7 +628,7 @@ export function composeBackendRefusal( : ""; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.${names}`, + message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. To run it there: ${composeBackendCommand([opts.restart ? "restart" : "up"])}. Nothing was started or stopped.${names}`, detail: { backend: "compose", features: [...opts.features] }, }); } diff --git a/src/lib/upgrade-check.ts b/src/lib/upgrade-check.ts index de0d3dbb3..fa89ca8cc 100644 --- a/src/lib/upgrade-check.ts +++ b/src/lib/upgrade-check.ts @@ -1,6 +1,7 @@ -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { isCandidateRun } from "../daemon/client.ts"; +import { resolveCandidateLauncher } from "./candidate-launcher.ts"; import { quoteShellWord } from "./logs.ts"; import { withProcessDeadline } from "./process-deadline.ts"; import { @@ -78,28 +79,28 @@ export type UpgradeCommandPrefix = { /** * During the prerelease, v5 runs through a candidate launcher while stable - * v4 stays `hack` on PATH, so a bare `hack` would run the old CLI. The - * `hack-next` launcher lives at `~/.hack-next/bin/hack-next` (not on PATH); - * a Compose candidate run also needs `HACK_RUNTIME_BACKEND=compose`. + * v4 stays `hack` on PATH, so a bare `hack` would run the old CLI. Printed + * commands use the launcher this run came through (see + * {@link resolveCandidateLauncher}): `~/.hack-next/bin/hack-next`, or the + * bundle's `hack-v5` by path. A Compose candidate run also needs + * `HACK_RUNTIME_BACKEND=compose`. */ export function upgradeCommandPrefix(opts: { readonly env: Readonly>; readonly homeDir: string; + /** The running executable (default `process.execPath`). */ + readonly execPath?: string; }): UpgradeCommandPrefix { - const entry = (opts.env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim(); - if (entry === "") { + const launcher = resolveCandidateLauncher(opts); + if (launcher.kind === "stable") { return { env: {}, argv: ["hack"] }; } - const launcher = - entry === "hack-next" - ? join(opts.homeDir, ".hack-next", "bin", "hack-next") - : entry; return { env: (opts.env.HACK_RUNTIME_BACKEND ?? "").trim() === "compose" ? { HACK_RUNTIME_BACKEND: "compose" } : {}, - argv: [launcher], + argv: [launcher.command], }; } diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index c29073fc7..df27af35e 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -43,8 +43,10 @@ const ADMITTED_START_REQUESTS = [ "runtime check-project-share", "runtime up", ]; -const SELECTION = - "this project runs on the Compose backend. Set HACK_RUNTIME_BACKEND=compose (hack-next and hack-v5 accept it too) and run the command again. Nothing was started or stopped."; +/** The refusal's next step names the refused command through the launcher (`hack` here). */ +const selection = (command = "hack up") => + `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose ${command}. Nothing was started or stopped.`; +const SELECTION = selection(); afterEach(async () => { for (const root of roots.splice(0)) { @@ -219,7 +221,7 @@ async function expectComposeBackendRefusal(opts: { const result = await cli(f, opts.args ?? ["up"], opts.env); expectOneClosedLine( result, - `E_NATIVE_PROJECT_UNSUPPORTED: Native \`${opts.command ?? "hack up"}\` does not support ${opts.phrase}; ${SELECTION}${networkDetail}` + `E_NATIVE_PROJECT_UNSUPPORTED: Native \`${opts.command ?? "hack up"}\` does not support ${opts.phrase}; ${selection(opts.command)}${networkDetail}` ); for (const secret of opts.secrets ?? []) { expect(result.output).not.toContain(secret); diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index e61f884a8..0b61ce702 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -33,7 +33,7 @@ const SPAWN_TIMEOUT_MS = 60_000; const IMAGE = `sha256:${"e".repeat(64)}`; const PROJECT = "settings-cli-fixture"; const SELECTION = - "this project runs on the Compose backend. Set HACK_RUNTIME_BACKEND=compose (hack-next and hack-v5 accept it too) and run the command again. Nothing was started or stopped."; + "this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up. Nothing was started or stopped."; const EXTERNAL_COMPOSE = { services: { web: { image: IMAGE, networks: ["shared-dev"] } }, networks: { "shared-dev": { external: true } }, diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index 56f65decd..629f1b495 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -13,6 +13,9 @@ import { join } from "node:path"; import { runCli } from "../src/cli/run.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; +import { hackNextLauncherPath } from "../src/lib/candidate-launcher.ts"; +import { resolveUserHomeDir } from "../src/lib/home-dir.ts"; +import { quoteShellWord } from "../src/lib/logs.ts"; const ENV_KEYS = [ "HACK_RUNTIME_BACKEND", @@ -170,10 +173,14 @@ test.each([ }); const result = await capturedRun(["update", "--yes", "--json"]); expect(result.exitCode).toBe(1); + // hack-next names its channel manager by path; a bundle run has no command. + const launcher = quoteShellWord(hackNextLauncherPath(resolveUserHomeDir())); expect(JSON.parse(result.stdout)).toEqual({ ok: false, error: - "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", + entrypoint === "hack-next" + ? `Refusing to self-update the opt-in native candidate. Upgrade it through its channel manager: ${launcher} --manager upgrade --version (${launcher} --manager status shows the selected version).` + : "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", }); expect(fetch).not.toHaveBeenCalled(); expect(lookup).not.toHaveBeenCalled(); From 8c192b0f2ba1eecf55ef35337137c37464af4733 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:08:14 +0000 Subject: [PATCH 2/8] test(candidate): cover printed launcher commands per install kind; docs (HACK-2251) - The bundle's resolved hack-v5 path rides on the hack-next launcher, so a fresh-native-home fix needs no second lookup. - The channel manager's custom-home refusal names the selected bundle's hack-v5 by path. - tests: the entrypoint keeps exactly hack-next and marks every other run hack-v5 (native and Compose, autostart still off); resolver, upgrade prefix, native setup guidance, Compose hint and update refusal per kind; the manager refusal names the bundle path. - docs: candidate install gains "Printed commands name your launcher"; native setup fixes and the start page say the printed command names the launcher. Co-Authored-By: Claude Opus 5.5 --- docs/guides/candidate-install.md | 23 +++- docs/guides/native-candidate.md | 4 +- docs/native/try-it.md | 3 +- scripts/installer/channel.ts | 3 +- src/lib/candidate-launcher.ts | 28 ++-- tests/candidate-launcher.test.ts | 166 ++++++++++++++++++++++++ tests/native-candidate-launcher.test.ts | 23 ++++ tests/python-free-installer.test.ts | 4 + 8 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 tests/candidate-launcher.test.ts diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 19ed2cac7..85ce0ae7f 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -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. @@ -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//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 @@ -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` | `$HOME/.hack-next/bin/hack-next` (the full path, since 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: diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 22f3dba64..59fec5e87 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -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`. diff --git a/docs/native/try-it.md b/docs/native/try-it.md index 62fc95cf5..b0d0efb19 100644 --- a/docs/native/try-it.md +++ b/docs/native/try-it.md @@ -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 diff --git a/scripts/installer/channel.ts b/scripts/installer/channel.ts index e4a84b0bd..b5485ec58 100644 --- a/scripts/installer/channel.ts +++ b/scripts/installer/channel.ts @@ -1038,7 +1038,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: ${this.path(`versions/${version}/bundle/hack-v5`)}` ); } const checksum = this.state.installed[version]; diff --git a/src/lib/candidate-launcher.ts b/src/lib/candidate-launcher.ts index 1e15b8c7a..fa8b86ebf 100644 --- a/src/lib/candidate-launcher.ts +++ b/src/lib/candidate-launcher.ts @@ -25,7 +25,12 @@ import { quoteShellWord } from "./logs.ts"; */ export type CandidateLauncher = | { readonly kind: "stable"; readonly command: "hack" } - | { readonly kind: "hack-next"; readonly command: string } + | { + 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`. */ @@ -56,14 +61,16 @@ export function resolveCandidateLauncher(opts: { if (entry === "") { return { kind: "stable", command: "hack" }; } + const bundle = + adjacentBundleLauncher(opts.execPath ?? process.execPath) ?? "hack-v5"; if (entry === "hack-next") { - return { kind: "hack-next", command: hackNextLauncherPath(opts.homeDir) }; + return { + kind: "hack-next", + command: hackNextLauncherPath(opts.homeDir), + bundle, + }; } - return { - kind: "bundle", - command: - adjacentBundleLauncher(opts.execPath ?? process.execPath) ?? "hack-v5", - }; + return { kind: "bundle", command: bundle }; } /** The launcher for this process, from its environment and the user's home. */ @@ -95,12 +102,9 @@ export function launcherCommand( * the user replaces. */ export function freshNativeHomeSetupCommand( - launcher: CandidateLauncher, - execPath: string = process.execPath + launcher: CandidateLauncher ): string { const runner = - launcher.kind === "hack-next" - ? (adjacentBundleLauncher(execPath) ?? "hack-v5") - : launcher.command; + launcher.kind === "hack-next" ? launcher.bundle : launcher.command; return `HACK_NATIVE_HOME= ${quoteShellWord(runner)} native setup`; } diff --git a/tests/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts new file mode 100644 index 000000000..b60421370 --- /dev/null +++ b/tests/candidate-launcher.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativeSetupGuidance } from "../src/backends/native-runtime-setup.ts"; +import { candidateUpdateRefusal } from "../src/commands/update.ts"; +import { + type CandidateLauncher, + freshNativeHomeSetupCommand, + resolveCandidateLauncher, +} from "../src/lib/candidate-launcher.ts"; +import { composeBackendCommand } from "../src/lib/native-compose-backend-features.ts"; +import { + renderUpgradeCommand, + upgradeCommandPrefix, +} from "../src/lib/upgrade-check.ts"; + +/** + * HACK-2251: text that tells the user what to run names the launcher their + * install actually has. A fake bundle directory holds `hack-cli` (standing in + * for the running executable) and `hack-v5`; nothing is executed. + */ +let root: string; +let home: string; +let bundle: string; +let hackCli: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "hack-launcher-"))); + home = join(root, "home"); + bundle = join(root, "versions", "5.0.0-next.18", "bundle"); + hackCli = join(bundle, "hack-cli"); + await Bun.write(hackCli, ""); + await writeFile(join(bundle, "hack-v5"), "#!/bin/sh\n"); + await chmod(join(bundle, "hack-v5"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const resolve = (entry: string | undefined, execPath = hackCli) => + resolveCandidateLauncher({ + env: { HACK_CANDIDATE_ENTRYPOINT: entry }, + homeDir: home, + execPath, + }); + +test("each install kind resolves to a command the user can run", () => { + expect(resolve(undefined)).toEqual({ kind: "stable", command: "hack" }); + expect(resolve(" ")).toEqual({ kind: "stable", command: "hack" }); + expect(resolve("hack-next")).toEqual({ + kind: "hack-next", + command: join(home, ".hack-next", "bin", "hack-next"), + bundle: join(bundle, "hack-v5"), + }); + // A direct bundle run names its own hack-v5 by path, not the bare name. + expect(resolve("hack-v5")).toEqual({ + kind: "bundle", + command: join(bundle, "hack-v5"), + }); + // Without an adjacent hack-v5 (a source run) the name is all there is. + expect(resolve("hack-v5", join(root, "elsewhere", "bun"))).toEqual({ + kind: "bundle", + command: "hack-v5", + }); +}); + +test("upgrade check and apply print the hack-next launcher on either backend", () => { + const launcher = join(home, ".hack-next", "bin", "hack-next"); + for (const [backend, env] of [ + [undefined, ""], + ["native", ""], + ["compose", "HACK_RUNTIME_BACKEND=compose "], + ] as const) { + const prefix = upgradeCommandPrefix({ + env: { + HACK_CANDIDATE_ENTRYPOINT: "hack-next", + HACK_RUNTIME_BACKEND: backend, + }, + homeDir: home, + execPath: hackCli, + }); + expect(renderUpgradeCommand(prefix, ["upgrade", "snapshot"])).toBe( + `${env}${launcher} upgrade snapshot` + ); + } + const direct = upgradeCommandPrefix({ + env: { HACK_CANDIDATE_ENTRYPOINT: "hack-v5" }, + homeDir: home, + execPath: hackCli, + }); + expect(renderUpgradeCommand(direct, ["upgrade", "snapshot"])).toBe( + `${join(bundle, "hack-v5")} upgrade snapshot` + ); +}); + +const kinds = (): readonly CandidateLauncher[] => [ + resolve(undefined), + resolve("hack-next"), + resolve("hack-v5"), +]; + +test("native setup guidance names setup through the user's launcher", () => { + const [stable, next, direct] = kinds(); + expect(nativeSetupGuidance({ state: "not_set_up" }, stable)).toBe( + "Native runtime is not set up; run hack native setup." + ); + expect(nativeSetupGuidance({ state: "not_set_up" }, next)).toBe( + `Native runtime is not set up; run ${join(home, ".hack-next", "bin", "hack-next")} native setup.` + ); + expect(nativeSetupGuidance({ state: "partial_resumable" }, direct)).toBe( + `Native runtime setup is incomplete; run ${join(bundle, "hack-v5")} native setup to resume.` + ); +}); + +test("a fresh native home is set up through the bundle's hack-v5, never hack-next", () => { + // hack-next refuses a custom HACK_NATIVE_HOME, so both candidate kinds name + // the bundle script by path. + const [stable, next, direct] = kinds(); + const bundled = `HACK_NATIVE_HOME= ${join(bundle, "hack-v5")} native setup`; + expect(freshNativeHomeSetupCommand(next)).toBe(bundled); + expect(freshNativeHomeSetupCommand(direct)).toBe(bundled); + expect(freshNativeHomeSetupCommand(stable)).toBe( + "HACK_NATIVE_HOME= hack native setup" + ); + for (const state of ["invalid_preserved", "pin_mismatch"] as const) { + const text = nativeSetupGuidance({ state }, next); + expect(text).toContain(join(bundle, "hack-v5")); + expect(text).not.toContain("the bundle's hack-v5 launcher"); + } +}); + +test("the Compose-backend hint is a command for the user's launcher", () => { + const [stable, next, direct] = kinds(); + expect(composeBackendCommand(["up"], stable)).toBe( + "HACK_RUNTIME_BACKEND=compose hack up" + ); + expect(composeBackendCommand(["restart"], next)).toBe( + `HACK_RUNTIME_BACKEND=compose ${join(home, ".hack-next", "bin", "hack-next")} restart` + ); + expect(composeBackendCommand(["up"], direct)).toBe( + `HACK_RUNTIME_BACKEND=compose ${join(bundle, "hack-v5")} up` + ); +}); + +test("the self-update refusal names the channel manager only for hack-next", () => { + const [, next, direct] = kinds(); + const launcher = join(home, ".hack-next", "bin", "hack-next"); + expect(candidateUpdateRefusal(next)).toBe( + `Refusing to self-update the opt-in native candidate. Upgrade it through its channel manager: ${launcher} --manager upgrade --version (${launcher} --manager status shows the selected version).` + ); + expect(candidateUpdateRefusal(direct)).toBe( + "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it." + ); +}); + +test("paths that need quoting are quoted as single shell words", () => { + const spaced = resolveCandidateLauncher({ + env: { HACK_CANDIDATE_ENTRYPOINT: "hack-next" }, + homeDir: join(root, "a home"), + }); + expect(composeBackendCommand(["up"], spaced)).toBe( + `HACK_RUNTIME_BACKEND=compose '${join(root, "a home", ".hack-next", "bin", "hack-next")}' up` + ); +}); diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 34e64bfd8..5c01f8217 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -89,6 +89,29 @@ test.each([ expect(result.exitCode).toBe(7); }); +test.each([ + ["unset (a direct run)", undefined, "hack-v5"], + ["empty", "", "hack-v5"], + ["the channel manager's hack-next", "hack-next", "hack-next"], + ["hack-next with padding", " hack-next", "hack-v5"], + ["any other value", "custom-launcher", "hack-v5"], + ["hack-v5 itself", "hack-v5", "hack-v5"], +])("entrypoint keeps exactly hack-next and marks every other run hack-v5: %s", async (_label, inherited, expected) => { + // HACK-2251: hack-next runs this script on native; printed commands must + // name ~/.hack-next/bin/hack-next, not the bundle-internal hack-v5. + for (const backend of ["native", "compose"]) { + const result = await launch( + { HACK_RUNTIME_BACKEND: backend, HACK_CANDIDATE_ENTRYPOINT: inherited }, + ["ps"] + ); + expect(result.exitCode).toBe(7); + const printed = result.stdout.split("\n"); + expect(printed[4]).toBe(expected); + // Candidate runs never autostart hackd, whichever launcher is named. + expect(printed[5]).toBe("1"); + } +}); + test("explicit Compose selection is respected with the caller's Hack home and no native selectors", async () => { // An inherited opt-in cannot re-enable hackd autostart for a candidate run. const result = await launch({ diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 38beb77ee..5cadd0445 100644 --- a/tests/python-free-installer.test.ts +++ b/tests/python-free-installer.test.ts @@ -135,6 +135,10 @@ fixtureTest( await expect(f.channel.run(["up"])).rejects.toThrow( "receipt-owned native home" ); + // The refusal names the bundle's launcher by path; it is not on PATH. + await expect(f.channel.run(["up"])).rejects.toThrow( + join(f.channel.root, "versions/5.0.0-next.1/bundle/hack-v5") + ); expect(() => f.channel.requireQuiescent("5.0.0-next.1")).not.toThrow(); expect(f.channel.environment("5.0.0-next.1").HACK_NATIVE_HOME).toBe( join(f.channel.root, "versions/5.0.0-next.1/native-home") From 188fe1e90b218b0a35f5b48ba13861fbbebaacad Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:43:43 +0000 Subject: [PATCH 3/8] fix(candidate): route every upgrade and native hint through the launcher (HACK-2251) Review found suggestions that still sent candidate users to stable `hack`: the upgrade check's "(apply)" line, the upgrade apply notes (`upgrade check lists it`, `--only agent-integrations`, `upgrade rollback needs --overwrite`), the project native-preflight hint, and the basic-build `config import` preview. Each now renders through the report's `cli.command` or the resolved launcher. The manager's custom-home refusal quotes the bundle path as one shell word. Tests: a native hack-next upgrade check prints no bare hack/hack-v5 command in any hint (fails on the old renderer and project hint); apply notes and the overwrite message carry the launcher; a spaced channel root is quoted. Co-Authored-By: Claude Opus 5.5 --- scripts/installer/channel.ts | 3 +- src/lib/doctor-compose-backend.ts | 8 ++-- src/lib/native-compose-backend-features.ts | 12 +++-- src/lib/upgrade-apply.ts | 39 +++++++++------ src/lib/upgrade-check-project.ts | 15 ++++-- src/lib/upgrade-check-render.ts | 3 +- src/lib/upgrade-check.ts | 1 + tests/candidate-launcher.test.ts | 25 +++++++--- tests/native-compose-backend-features.test.ts | 2 +- tests/python-free-installer.test.ts | 16 +++++-- tests/upgrade-apply.test.ts | 18 +++++-- tests/upgrade-check.test.ts | 47 +++++++++++++++++++ 12 files changed, 149 insertions(+), 40 deletions(-) diff --git a/scripts/installer/channel.ts b/scripts/installer/channel.ts index b5485ec58..40ec28516 100644 --- a/scripts/installer/channel.ts +++ b/scripts/installer/channel.ts @@ -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, @@ -1039,7 +1040,7 @@ export class Channel { requireThat( !override || override === this.path(`versions/${version}/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: ${this.path(`versions/${version}/bundle/hack-v5`)}` + `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]; diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index 7bb1298cc..8a88dc9d4 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,5 +1,5 @@ import { - BASIC_BUILD_NEXT_STEP, + basicBuildNextStep, type ComposeBackendFeature, type ComposeBackendFeatureOptions, composeBackendCommand, @@ -59,14 +59,14 @@ export function composeBackendCheckResult( ? ` ${ISOLATED_NETWORKS_SELECTION}.` : ""; const build = basicBuild - ? ` Its basic Compose build is also refused by native image-project up. ${BASIC_BUILD_NEXT_STEP}.` + ? ` Its basic Compose build is also refused by native image-project up. ${basicBuildNextStep()}.` : ""; if (opts.features.length === 0) { if (basicBuild && opts.backend === "native") { return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${BASIC_BUILD_NEXT_STEP}, or run it on the Compose backend: ${composeBackendCommand(["up"])}.`, + message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${basicBuildNextStep()}, or run it on the Compose backend: ${composeBackendCommand(["up"])}.`, composeBackend, }; } @@ -74,7 +74,7 @@ export function composeBackendCheckResult( return { name: COMPOSE_BACKEND_CHECK, status: "ok", - message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it while HACK_NATIVE_BUILDS is not set: ${BASIC_BUILD_NEXT_STEP}.`, + message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it while HACK_NATIVE_BUILDS is not set: ${basicBuildNextStep()}.`, composeBackend, }; } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index ca2212271..373c7e0c2 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -630,9 +630,15 @@ export function composeBackendCommand( export const ISOLATED_NETWORKS_SELECTION = "An external network can stay on native instead: list it by name in `isolatedNetworks` of the `HACK_NATIVE_ADAPTATION` selection, and native start replaces it with an owned isolated network that does not join the external one"; -/** The next step for a basic build, which native image-project `up` refuses without native builds. */ -export const BASIC_BUILD_NEXT_STEP = - "Set HACK_NATIVE_BUILDS=1 to build it in the native runtime, or preview the authored native conversion with `hack config import --dry-run --path `"; +/** + * The next step for a basic build, which native image-project `up` refuses + * without native builds. The preview command names the user's launcher. + */ +export function basicBuildNextStep( + launcher: CandidateLauncher = currentCandidateLauncher() +): string { + return `Set HACK_NATIVE_BUILDS=1 to build it in the native runtime, or preview the authored native conversion with \`${launcherCommand(launcher, ["config", "import", "--dry-run", "--path"])} \``; +} /** * The closed refusal for a project that needs the Compose backend: stable code diff --git a/src/lib/upgrade-apply.ts b/src/lib/upgrade-apply.ts index 830631977..21ef3b9bc 100644 --- a/src/lib/upgrade-apply.ts +++ b/src/lib/upgrade-apply.ts @@ -189,14 +189,18 @@ export type Planned = { }; const ERROR_CODE = /^[A-Z0-9_]{1,40}$/; -const SKIP_BLOCKED = - "Not run: this checkout has a blocker or a check that could not be performed; fix that first (hack upgrade check lists it)."; -const NOTE_HOME_WITH_PROJECT = - "Agent files under HOME serve every project and are left out with --project; run hack upgrade apply --only agent-integrations without --project for them."; +/* + * Messages that name a command take `cli`, the report's `cli.command`: the + * launcher the check ran through, never a bare `hack` (HACK-2251). + */ +const skipBlocked = (cli: string) => + `Not run: this checkout has a blocker or a check that could not be performed; fix that first (${cli} upgrade check lists it).`; +const noteHomeWithProject = (cli: string) => + `Agent files under HOME serve every project and are left out with --project; run ${cli} upgrade apply --only agent-integrations without --project for them.`; const NOT_IN_SNAPSHOT = "Not changed: the file is not in this run's snapshot, so a rollback could not restore it."; -const NOT_RECORDED = - "Changed, but the snapshot could not record what was written; hack upgrade rollback needs --overwrite for this file."; +const notRecorded = (cli: string) => + `Changed, but the snapshot could not record what was written; ${cli} upgrade rollback needs --overwrite for this file.`; /** * Runs (or with `dryRun` lists) the apply steps of `report`. `collect` @@ -485,6 +489,7 @@ async function executeApplyRun(opts: { } else { results.push( await applyTarget({ + cli: input.report.cli.command, target, snapshot: currentSnapshot, inSnapshot, @@ -657,7 +662,7 @@ function planTargets(opts: { skip: null, }); } else { - notes.push(NOTE_HOME_WITH_PROJECT); + notes.push(noteHomeWithProject(opts.report.cli.command)); } } const seen = new Set(); @@ -676,7 +681,7 @@ function planTargets(opts: { project: target.project, root: target.root, projectDir: byRoot.get(root)?.project_dir ?? null, - skip: unsafe.has(root) ? SKIP_BLOCKED : null, + skip: unsafe.has(root) ? skipBlocked(opts.report.cli.command) : null, }); } } @@ -697,6 +702,8 @@ function unsafeRoots(report: UpgradeCheckReport): ReadonlySet { } async function applyTarget(opts: { + /** How printed commands run this CLI (the report's `cli.command`). */ + readonly cli: string; readonly target: Planned; readonly snapshot: UpgradeSnapshot; readonly inSnapshot: ReadonlySet; @@ -765,7 +772,7 @@ async function applyTarget(opts: { : [], })) !== "safe" ) { - return { ...result, status: "failed", message: NOT_RECORDED }; + return { ...result, status: "failed", message: notRecorded(opts.cli) }; } let recorded = false; try { @@ -780,13 +787,14 @@ async function applyTarget(opts: { recorded = false; } if (!recorded) { - return { ...result, status: "failed", message: NOT_RECORDED }; + return { ...result, status: "failed", message: notRecorded(opts.cli) }; } } return result; } async function applyIgnoreRules(opts: { + readonly cli: string; readonly target: Planned; readonly snapshot: UpgradeSnapshot; readonly inSnapshot: ReadonlySet; @@ -855,11 +863,11 @@ async function applyIgnoreRules(opts: { return outcome( target, "failed", - `${closedError(failure)}${recorded ? "" : ` ${NOT_RECORDED}`}` + `${closedError(failure)}${recorded ? "" : ` ${notRecorded(opts.cli)}`}` ); } if (!recorded) { - return outcome(target, "failed", NOT_RECORDED); + return outcome(target, "failed", notRecorded(opts.cli)); } if (result.readOnly) { return outcome( @@ -1044,12 +1052,15 @@ function recoverPlannedTarget(opts: { ); let skip: string | null = null; if (target.project === null) { - skip = normalize(target.root) === normalize(home) ? null : SKIP_BLOCKED; + skip = + normalize(target.root) === normalize(home) + ? null + : skipBlocked(report.cli.command); } else if ( checkout === undefined || unsafeRoots(report).has(normalize(target.root)) ) { - skip = SKIP_BLOCKED; + skip = skipBlocked(report.cli.command); } return { fix: target.fix, diff --git a/src/lib/upgrade-check-project.ts b/src/lib/upgrade-check-project.ts index 3aaea848a..519ba4df4 100644 --- a/src/lib/upgrade-check-project.ts +++ b/src/lib/upgrade-check-project.ts @@ -532,7 +532,8 @@ async function agentFinding(opts: { } function nativeFinding( - probe: NativePreflightProbe | null + probe: NativePreflightProbe | null, + command: (args: readonly string[]) => string ): UpgradeFinding | null { if (probe === null) { return null; @@ -541,7 +542,7 @@ function nativeFinding( ? { id: "native-preflight", status: "ok", - summary: `No native configuration blockers (${probe.warnings} warning(s)); runtime setup and runtime review were not checked. Run hack native setup, then hack doctor --native-preflight.`, + summary: `No native configuration blockers (${probe.warnings} warning(s)); runtime setup and runtime review were not checked. Run ${command(["native", "setup"])}, then ${command(["doctor", "--native-preflight"])}.`, args: null, action: null, } @@ -799,6 +800,11 @@ export async function inspectUpgradeProject(opts: { readonly deadline: number; /** Per-check limit; defaults to 8 s. */ readonly checkTimeoutMs?: number; + /** + * Renders a command line for text that names one, through the launcher the + * check ran through (HACK-2251); defaults to `hack`. + */ + readonly command?: (args: readonly string[]) => string; }): Promise { const root = opts.project.repoRoot; const projectDir = opts.project.projectDir; @@ -835,7 +841,10 @@ export async function inspectUpgradeProject(opts: { projectStoreFinding({ root, projectDir }) ), guarded("native-preflight", limits, async () => - nativeFinding(await opts.nativePreflight({ projectRoot: root })) + nativeFinding( + await opts.nativePreflight({ projectRoot: root }), + opts.command ?? ((args) => ["hack", ...args].join(" ")) + ) ), ]); return results.filter((finding) => finding !== null); diff --git a/src/lib/upgrade-check-render.ts b/src/lib/upgrade-check-render.ts index 53917f94c..f8a525146 100644 --- a/src/lib/upgrade-check-render.ts +++ b/src/lib/upgrade-check-render.ts @@ -241,7 +241,8 @@ function planLines(report: UpgradeCheckReport): string[] { : ["Plan", ...report.plan.flatMap(stepLines)]; if (report.plan.some((step) => step.applies)) { lines.push( - " Steps marked (apply) run for all their checkouts with hack upgrade apply (--dry-run to preview, --project for one project)." + // The launcher this check ran through, never a bare `hack` (HACK-2251). + ` Steps marked (apply) run for all their checkouts with ${report.cli.command} upgrade apply (--dry-run to preview, --project for one project).` ); } lines.push(""); diff --git a/src/lib/upgrade-check.ts b/src/lib/upgrade-check.ts index fa89ca8cc..529e0f7bf 100644 --- a/src/lib/upgrade-check.ts +++ b/src/lib/upgrade-check.ts @@ -251,6 +251,7 @@ export async function runUpgradeCheck(opts: { nativePreflight: opts.probes.nativePreflight, deadline, checkTimeoutMs: opts.checkTimeoutMs, + command: (args) => renderUpgradeCommand(prefix, args), }) ).map(render), }) diff --git a/tests/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts index b60421370..fb3091cb0 100644 --- a/tests/candidate-launcher.test.ts +++ b/tests/candidate-launcher.test.ts @@ -9,7 +9,10 @@ import { freshNativeHomeSetupCommand, resolveCandidateLauncher, } from "../src/lib/candidate-launcher.ts"; -import { composeBackendCommand } from "../src/lib/native-compose-backend-features.ts"; +import { + basicBuildNextStep, + composeBackendCommand, +} from "../src/lib/native-compose-backend-features.ts"; import { renderUpgradeCommand, upgradeCommandPrefix, @@ -95,11 +98,11 @@ test("upgrade check and apply print the hack-next launcher on either backend", ( ); }); -const kinds = (): readonly CandidateLauncher[] => [ - resolve(undefined), - resolve("hack-next"), - resolve("hack-v5"), -]; +const kinds = (): readonly [ + CandidateLauncher, + CandidateLauncher, + CandidateLauncher, +] => [resolve(undefined), resolve("hack-next"), resolve("hack-v5")]; test("native setup guidance names setup through the user's launcher", () => { const [stable, next, direct] = kinds(); @@ -144,6 +147,16 @@ test("the Compose-backend hint is a command for the user's launcher", () => { ); }); +test("the basic-build hint previews config import through the user's launcher", () => { + const [stable, next] = kinds(); + expect(basicBuildNextStep(stable)).toContain( + "`hack config import --dry-run --path `" + ); + expect(basicBuildNextStep(next)).toContain( + `\`${join(home, ".hack-next", "bin", "hack-next")} config import --dry-run --path \`` + ); +}); + test("the self-update refusal names the channel manager only for hack-next", () => { const [, next, direct] = kinds(); const launcher = join(home, ".hack-next", "bin", "hack-next"); diff --git a/tests/native-compose-backend-features.test.ts b/tests/native-compose-backend-features.test.ts index f3206e42b..aa0a4939a 100644 --- a/tests/native-compose-backend-features.test.ts +++ b/tests/native-compose-backend-features.test.ts @@ -291,7 +291,7 @@ test("the refusal is one coded line with closed detail and no values", async () const error = await expectCodedRefusal( refuseNativeComposeBackendFeatures({ composeFile: file, restart: false }), "E_NATIVE_PROJECT_UNSUPPORTED", - "Native `hack up` does not support an external Compose network and a service platform other than linux/arm64; this project runs on the Compose backend. Set HACK_RUNTIME_BACKEND=compose" + "Native `hack up` does not support an external Compose network and a service platform other than linux/arm64; this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up." ); expect(error.message).not.toContain("\n"); expect(error.message).toContain("Foreign external networks: private-net."); diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 5cadd0445..d4f8c5f68 100644 --- a/tests/python-free-installer.test.ts +++ b/tests/python-free-installer.test.ts @@ -105,11 +105,14 @@ function fixtureArchive( expect(tar.exitCode).toBe(0); return { archive, checksum: digest(archive) }; } -function fixture(hooks: ConstructorParameters[2] = {}) { +function fixture( + hooks: ConstructorParameters[2] = {}, + channelName = "channel" +) { const root = scratch(); const source = join(root, "source-manager"); writeFileSync(source, "manager-one"); - const channel = new Channel(join(root, "channel"), source, { + const channel = new Channel(join(root, channelName), source, { signatures: () => {}, quiescence: () => {}, ...hooks, @@ -118,8 +121,8 @@ function fixture(hooks: ConstructorParameters[2] = {}) { channel.validate(); return { root, source, channel }; } -async function installed() { - const f = fixture(); +async function installed(channelName?: string) { + const f = fixture({}, channelName); const input = fixtureArchive(f.root); await f.channel.install("5.0.0-next.1", input.archive, input.checksum); return f; @@ -139,6 +142,11 @@ fixtureTest( await expect(f.channel.run(["up"])).rejects.toThrow( join(f.channel.root, "versions/5.0.0-next.1/bundle/hack-v5") ); + // A root with a space is printed as one shell word (HACK-2251). + const spaced = await installed("a channel"); + await expect(spaced.channel.run(["up"])).rejects.toThrow( + `directly: '${join(spaced.channel.root, "versions/5.0.0-next.1/bundle/hack-v5")}'` + ); expect(() => f.channel.requireQuiescent("5.0.0-next.1")).not.toThrow(); expect(f.channel.environment("5.0.0-next.1").HACK_NATIVE_HOME).toBe( join(f.channel.root, "versions/5.0.0-next.1/native-home") diff --git a/tests/upgrade-apply.test.ts b/tests/upgrade-apply.test.ts index 1b0e73a6b..4a434fcb3 100644 --- a/tests/upgrade-apply.test.ts +++ b/tests/upgrade-apply.test.ts @@ -194,6 +194,12 @@ function healthyProbes(): UpgradeCheckProbes { }; } +/** A launcher path standing in for `~/.hack-next/bin/hack-next`. */ +const LAUNCHER = "/fixture/home/.hack-next/bin/hack-next"; +function withLauncher(report: UpgradeCheckReport): UpgradeCheckReport { + return { ...report, cli: { ...report.cli, command: LAUNCHER } }; +} + async function check(): Promise { return await runUpgradeCheck({ cliVersion: "5.0.0-next.17", @@ -687,7 +693,8 @@ test("--project leaves HOME agent files out without counting them as unfinished" const agentFile = await staleHomeAgentFile(); const a = await project("app-a"); await writeRegistry([a]); - const report = await check(); + // Printed commands use the launcher the check ran through (HACK-2251). + const report = withLauncher(await check()); const scopes: string[] = []; const applied = await runUpgradeApply({ report, @@ -711,13 +718,16 @@ test("--project leaves HOME agent files out without counting them as unfinished" } expect(applied.targets.map((t) => t.scope)).toEqual(["app-a"]); expect(applied.notes[0]).toContain("left out with --project"); + expect(applied.notes[0]).toContain( + `run ${LAUNCHER} upgrade apply --only agent-integrations without --project` + ); expect(await readFile(agentFile, "utf8")).toContain("old standalone"); }, 60_000); test("a write whose after state cannot be recorded is failed, not applied", async () => { const a = await project("app-a"); await writeRegistry([a]); - const report = await check(); + const report = withLauncher(await check()); for (const record of [ async () => ({ status: "refused" as const, paths: [] }), async () => ({ status: "invalid-manifest" as const, message: "x" }), @@ -738,7 +748,9 @@ test("a write whose after state cannot be recorded is failed, not applied", asyn throw new Error(`unexpected status ${applied.status}`); } expect(applied.targets[0]?.status).toBe("failed"); - expect(applied.targets[0]?.message).toContain("needs --overwrite"); + expect(applied.targets[0]?.message).toContain( + `${LAUNCHER} upgrade rollback needs --overwrite` + ); const journal = (await readUpgradeSnapshotJournal({ manifestPath: applied.coverage.manifest_path, })) as { state: string }; diff --git a/tests/upgrade-check.test.ts b/tests/upgrade-check.test.ts index 185f82b18..f3f4af1b6 100644 --- a/tests/upgrade-check.test.ts +++ b/tests/upgrade-check.test.ts @@ -26,7 +26,9 @@ import { type UpgradeCheckProbes, } from "../src/lib/upgrade-check-probes.ts"; import { inspectUpgradeProject } from "../src/lib/upgrade-check-project.ts"; +import { renderUpgradeCheck } from "../src/lib/upgrade-check-render.ts"; import { collectUpgradeSnapshotTargets } from "../src/lib/upgrade-snapshot-targets.ts"; +import { spawnSyncBounded } from "./helpers/bounded-spawn-sync.ts"; import { restoreEnv } from "./helpers/env.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -733,6 +735,51 @@ test("printed commands run the candidate launcher when the check ran through it" } }); +test("a native hack-next check prints only hack-next commands, in every hint (HACK-2251)", async () => { + // The bundle entrypoint keeps the manager's hack-next marker on native, so + // the CLI sees HACK_CANDIDATE_ENTRYPOINT=hack-next with no Compose selection. + const legacy = await liveProject("app-native-next"); + await writeFile(join(legacy.projectDir, "hack.env.json"), "{}\n"); + // A Git checkout without Hack's ignore rules makes an applicable step. + const git = spawnSyncBounded(["git", "-C", legacy.repoRoot, "init", "-q"], { + env: fixtureGitEnv(), + timeout: 10_000, + }); + expect(git.exitCode).toBe(0); + await writeRegistry([legacy]); + const report = await runUpgradeCheck({ + cliVersion: "5.0.0-next.18", + homeDir: home, + probes: healthyProbes({ + nativePreflight: async () => ({ + ready: true, + blockers: 0, + warnings: 0, + consentNeeded: 0, + }), + }), + env: { + HACK_CANDIDATE_ENTRYPOINT: "hack-next", + HACK_RUNTIME_BACKEND: "native", + }, + }); + const launcher = join(home, ".hack-next", "bin", "hack-next"); + expect(report.cli.command).toBe(launcher); + expect(report.next_command?.startsWith(`${launcher} `)).toBe(true); + const native = report.projects + .flatMap((project) => project.findings) + .find((finding) => finding.id === "native-preflight"); + expect(native?.summary).toContain( + `Run ${launcher} native setup, then ${launcher} doctor --native-preflight.` + ); + const text = renderUpgradeCheck(report, { verbose: true }); + expect(report.plan.some((step) => step.applies)).toBe(true); + expect(text).toContain(`with ${launcher} upgrade apply (--dry-run`); + // No printed hint sends a candidate user to stable `hack` or bare hack-v5. + expect(text).not.toMatch(/(^|[\s(`])hack (upgrade|native|doctor) /m); + expect(text).not.toContain("hack-v5"); +}); + /** A global encrypted_file store with one entry, and its key file. */ async function globalStore(opts: { readonly reader: Entry; From 1ea914ebd86875504b250404422effcc9a69f1f0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:49:56 +0000 Subject: [PATCH 4/8] fix(candidate): name the launcher in grouped and exceptional upgrade hints (HACK-2251) Second review pass found bare `hack` suggestions in less common branches: the "and N more" unchecked line, grouped manual plan steps, refused env layer findings, the unknown-project apply refusal and the agent migration outcome. Each renders through the report's launcher; the project checks take a `CommandRenderer` (default `hack`). Hints that sit next to an already-rendered doctor command now refer to it instead of repeating a bare `hack doctor`. Tests cover the env-layer refusal, the "and N more" line, a grouped manual step and the unknown-project refusal with a hack-next launcher. Co-Authored-By: Claude Opus 5.5 --- src/commands/upgrade.ts | 2 +- src/lib/upgrade-apply.ts | 14 +++---- src/lib/upgrade-check-project.ts | 34 ++++++++++----- src/lib/upgrade-check-render.ts | 10 +++-- src/lib/upgrade-check.ts | 6 +-- src/lib/upgrade-plan.ts | 4 +- tests/upgrade-apply.test.ts | 12 ++++++ tests/upgrade-check.test.ts | 71 ++++++++++++++++++++++++++++++++ 8 files changed, 126 insertions(+), 27 deletions(-) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index ccc9bfafc..0838ef4f0 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -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(); for (const target of result.targets) { diff --git a/src/lib/upgrade-apply.ts b/src/lib/upgrade-apply.ts index 21ef3b9bc..21889acf8 100644 --- a/src/lib/upgrade-apply.ts +++ b/src/lib/upgrade-apply.ts @@ -602,8 +602,7 @@ export function validateUpgradeSelection(opts: { return { status: "refused", reason: "unknown-project", - message: - "No live registered project has that name; nothing was changed. hack upgrade check lists them.", + message: `No live registered project has that name; nothing was changed. ${opts.report.cli.command} upgrade check lists them.`, }; } // A registration is identified by its primary checkout, which carries @@ -750,7 +749,7 @@ async function applyTarget(opts: { backup_manifest: kept, }; } - const result = agentOutcome({ target, migrated }); + const result = agentOutcome({ target, migrated, cli: opts.cli }); await opts.checkpoint?.("agent-written"); if ( migrated.status === "applied" || @@ -882,17 +881,18 @@ async function applyIgnoreRules(opts: { function agentOutcome(opts: { readonly target: Planned; readonly migrated: PluginMigrationApplyResult; + readonly cli: string; }): UpgradeApplyTarget { - const { target, migrated } = opts; + const { target, migrated, cli } = opts; const preview = target.projectDir === null - ? "hack setup migrate --global" - : "hack setup migrate --path "; + ? `${cli} setup migrate --global` + : `${cli} setup migrate --path `; if (migrated.status === "nothing-to-migrate") { return outcome( target, "unchanged", - "No standalone Hack agent file to remove here; a stale AGENTS.md or CLAUDE.md section is refreshed by hack setup, not by apply." + `No standalone Hack agent file to remove here; a stale AGENTS.md or CLAUDE.md section is refreshed by ${cli} setup, not by apply.` ); } if (migrated.status === "refused") { diff --git a/src/lib/upgrade-check-project.ts b/src/lib/upgrade-check-project.ts index 519ba4df4..92c2a6673 100644 --- a/src/lib/upgrade-check-project.ts +++ b/src/lib/upgrade-check-project.ts @@ -1,7 +1,6 @@ import { constants } from "node:fs"; import { type FileHandle, open, readdir, stat } from "node:fs/promises"; import { basename, resolve } from "node:path"; - import { inspectDoctorAgentIntegrations } from "../commands/doctor.ts"; import { PROJECT_COMPOSE_FILENAME, @@ -13,6 +12,7 @@ import { PROJECT_ENV_FILENAME, PROJECT_ENV_STATE_FILENAME, } from "../constants.ts"; +import { launcherCommand } from "./candidate-launcher.ts"; import { gitIsLocalKeyIgnored, gitListTrackedKeyPaths, @@ -117,6 +117,12 @@ export type RegisteredCheckout = { readonly projectDirName: string; }; +/** Renders a command line through the launcher the check ran through (HACK-2251). */ +export type CommandRenderer = (args: readonly string[]) => string; + +const stableCommand: CommandRenderer = (args) => + launcherCommand({ kind: "stable", command: "hack" }, args); + /** A registered project's primary checkout and its registered worktrees. */ export function registeredCheckouts( project: RegisteredProject @@ -324,6 +330,7 @@ async function assertReadableEnvLayers(projectDir: string): Promise { function envFormatFinding(opts: { readonly detected: Awaited>; readonly root: string; + readonly command: CommandRenderer; }): UpgradeFinding { if (opts.detected.kind === "conflict") { return refusedEnvLayerFinding({ @@ -331,6 +338,7 @@ function envFormatFinding(opts: { root: opts.root, layer: opts.detected.layer, status: "blocker", + command: opts.command, }); } const { formats } = opts.detected; @@ -533,7 +541,7 @@ async function agentFinding(opts: { function nativeFinding( probe: NativePreflightProbe | null, - command: (args: readonly string[]) => string + command: CommandRenderer ): UpgradeFinding | null { if (probe === null) { return null; @@ -708,6 +716,7 @@ async function guarded( readonly timeoutMs: number; readonly deadline: number; readonly root: string; + readonly command: CommandRenderer; }, run: (timeoutMs: number) => Promise ): Promise { @@ -746,6 +755,7 @@ async function guarded( root: limits.root, layer: error.layer, status: REQUIRED_CHECKS.has(id) ? "incomplete" : "warn", + command: limits.command, }); } const code = upgradeCheckFailureClass(error); @@ -763,14 +773,16 @@ function refusedEnvLayerFinding(input: { readonly root: string; readonly layer: string; readonly status: UpgradeFindingStatus; + readonly command: CommandRenderer; }): UpgradeFinding { + const recheck = input.command(["upgrade", "check"]); const canonicalName = input.layer.toLowerCase(); const isLayer = canonicalName.startsWith(PROJECT_ENV_CONFIG_FILENAME_PREFIX) && canonicalName.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX); const remedy = isLayer - ? "replace the link with a readable file in this checkout or remove the layer, then rerun hack upgrade check." - : "repair the readable directory inside this checkout, then rerun hack upgrade check."; + ? `replace the link with a readable file in this checkout or remove the layer, then rerun ${recheck}.` + : `repair the readable directory inside this checkout, then rerun ${recheck}.`; return { id: input.id, status: input.status, @@ -782,8 +794,8 @@ function refusedEnvLayerFinding(input: { }, args: ["doctor", "--path", input.root], action: isLayer - ? `Make ${input.layer} a readable regular file inside this checkout: replace the link with a real file here, or remove the layer, then rerun hack upgrade check.` - : `Make ${input.layer} a readable directory inside this checkout, then rerun hack upgrade check.`, + ? `Make ${input.layer} a readable regular file inside this checkout: replace the link with a real file here, or remove the layer, then rerun ${recheck}.` + : `Make ${input.layer} a readable directory inside this checkout, then rerun ${recheck}.`, }; } @@ -804,20 +816,23 @@ export async function inspectUpgradeProject(opts: { * Renders a command line for text that names one, through the launcher the * check ran through (HACK-2251); defaults to `hack`. */ - readonly command?: (args: readonly string[]) => string; + readonly command?: CommandRenderer; }): Promise { const root = opts.project.repoRoot; const projectDir = opts.project.projectDir; + const command = opts.command ?? stableCommand; const limits = { timeoutMs: opts.checkTimeoutMs ?? PROJECT_CHECK_TIMEOUT_MS, deadline: opts.deadline, root, + command, }; const results = await Promise.all([ guarded("env-format", limits, async () => envFormatFinding({ detected: await detectEnvFormats({ projectDir }), root, + command, }) ), guarded("env-key", limits, () => envKeyFinding({ root })), @@ -841,10 +856,7 @@ export async function inspectUpgradeProject(opts: { projectStoreFinding({ root, projectDir }) ), guarded("native-preflight", limits, async () => - nativeFinding( - await opts.nativePreflight({ projectRoot: root }), - opts.command ?? ((args) => ["hack", ...args].join(" ")) - ) + nativeFinding(await opts.nativePreflight({ projectRoot: root }), command) ), ]); return results.filter((finding) => finding !== null); diff --git a/src/lib/upgrade-check-render.ts b/src/lib/upgrade-check-render.ts index f8a525146..9b1a6a6b8 100644 --- a/src/lib/upgrade-check-render.ts +++ b/src/lib/upgrade-check-render.ts @@ -246,8 +246,10 @@ function planLines(report: UpgradeCheckReport): string[] { ); } lines.push(""); - lines.push(...namedLines("Blocked by", report.blockers)); - lines.push(...namedLines("Not checked", report.incomplete, true)); + lines.push(...namedLines("Blocked by", report.blockers, report.cli.command)); + lines.push( + ...namedLines("Not checked", report.incomplete, report.cli.command, true) + ); if (report.next_command !== null) { lines.push(`Next: ${report.next_command}`); } else if (report.next_action !== null) { @@ -302,6 +304,8 @@ function machineStepPurpose(step: UpgradePlanStep): string { function namedLines( title: string, items: UpgradeCheckReport["blockers"], + /** The report's `cli.command`: the launcher the check ran through. */ + cli: string, why = false ): string[] { if (items.length === 0) { @@ -318,7 +322,7 @@ function namedLines( ]; if (why && items.length > MAX_NAMED) { lines.push( - ` … and ${items.length - MAX_NAMED} more (hack upgrade check --verbose).` + ` … and ${items.length - MAX_NAMED} more (${cli} upgrade check --verbose).` ); } lines.push(""); diff --git a/src/lib/upgrade-check.ts b/src/lib/upgrade-check.ts index 529e0f7bf..ac68f87b7 100644 --- a/src/lib/upgrade-check.ts +++ b/src/lib/upgrade-check.ts @@ -580,7 +580,7 @@ async function inspectMachine(opts: { id: "linux-dns", status: "info", summary: - "The Linux DNS route is set up by hack global install (listed above).", + "The Linux DNS route is set up by the global install listed above.", args: null, action: null, } @@ -872,7 +872,7 @@ async function inspectGlobalSecretStore(opts: { "The global secret store's key file is missing; the OS keychain may hold its key, but it was not asked (asking can prompt).", args: ["doctor"], action: - "hack doctor asks the OS keychain and reports whether the key is there.", + "Doctor (the command above) asks the OS keychain and reports whether the key is there.", }; } // Each reader's own key decides whether it is stranded: a project may @@ -936,7 +936,7 @@ function misconfiguredReadersFinding(readers: ReaderKeyStates): UpgradeFinding { action: readers.opening.length > 0 ? `Give ${readers.failing.join(", ")} the key that opens the store (the keyPath ${readers.opening.join(", ")} use${readers.opening.length === 1 ? "s" : ""}), or HACK_SECRETS_FILE_KEY.` - : "Run hack doctor to establish every reader's key before retiring or replacing the store.", + : "Run doctor (the command above) to establish every reader's key before retiring or replacing the store.", }; } diff --git a/src/lib/upgrade-plan.ts b/src/lib/upgrade-plan.ts index d88beecc8..007646d16 100644 --- a/src/lib/upgrade-plan.ts +++ b/src/lib/upgrade-plan.ts @@ -346,9 +346,9 @@ function groupAction(opts: { ? " Each checkout's finding says what else to do." : ""; if (shared !== null && first?.args?.includes(first.root)) { - return `Run ${opts.render(shared)} --path ${PATH_PLACEHOLDER} for each of the ${count} checkouts (hack upgrade check --verbose lists them).${advice}`; + return `Run ${opts.render(shared)} --path ${PATH_PLACEHOLDER} for each of the ${count} checkouts (${opts.render(["upgrade", "check", "--verbose"])} lists them).${advice}`; } - return `See the finding in each of the ${count} checkouts (hack upgrade check --verbose lists them).`; + return `See the finding in each of the ${count} checkouts (${opts.render(["upgrade", "check", "--verbose"])} lists them).`; } /** `args` without its `--path ` pair. */ diff --git a/tests/upgrade-apply.test.ts b/tests/upgrade-apply.test.ts index 4a434fcb3..4f92f4f7f 100644 --- a/tests/upgrade-apply.test.ts +++ b/tests/upgrade-apply.test.ts @@ -406,6 +406,18 @@ test("apply --dry-run changes nothing; apply snapshots, journals, refreshes .git collect: collectUpgradeSnapshotTargets, }) ).toMatchObject({ status: "refused", reason: "unknown-project" }); + // The refusal points at the launcher the check ran through (HACK-2251). + expect( + await runUpgradeApply({ + report: withLauncher(report), + dryRun: true, + project: "nope", + home, + collect: collectUpgradeSnapshotTargets, + }) + ).toMatchObject({ + message: expect.stringContaining(`${LAUNCHER} upgrade check lists them.`), + }); expect(await tree(root)).toEqual(before); const applied = await runUpgradeApply({ diff --git a/tests/upgrade-check.test.ts b/tests/upgrade-check.test.ts index f3f4af1b6..bc93363dd 100644 --- a/tests/upgrade-check.test.ts +++ b/tests/upgrade-check.test.ts @@ -27,6 +27,7 @@ import { } from "../src/lib/upgrade-check-probes.ts"; import { inspectUpgradeProject } from "../src/lib/upgrade-check-project.ts"; import { renderUpgradeCheck } from "../src/lib/upgrade-check-render.ts"; +import { buildUpgradePlan } from "../src/lib/upgrade-plan.ts"; import { collectUpgradeSnapshotTargets } from "../src/lib/upgrade-snapshot-targets.ts"; import { spawnSyncBounded } from "./helpers/bounded-spawn-sync.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -780,6 +781,76 @@ test("a native hack-next check prints only hack-next commands, in every hint (HA expect(text).not.toContain("hack-v5"); }); +test("exceptional and grouped hints also name the hack-next launcher (HACK-2251)", async () => { + const env = { + HACK_CANDIDATE_ENTRYPOINT: "hack-next", + HACK_RUNTIME_BACKEND: "native", + }; + const launcher = join(home, ".hack-next", "bin", "hack-next"); + // A refused env layer tells the user to rerun the check through the launcher. + const project = await liveProject("linked-next"); + const target = join(root, "sibling-next", "layer.yaml"); + await mkdir(join(root, "sibling-next")); + await writeFile(target, "version: 1\nvars: {}\n"); + await symlink(target, join(project.projectDir, "hack.env.dev.local.yaml")); + await writeRegistry([project]); + const report = await runUpgradeCheck({ + cliVersion: "5.0.0-next.18", + homeDir: home, + probes: healthyProbes(), + env, + }); + const refused = report.projects[0]?.findings.find( + (item) => item.id === "env-format" + ); + expect(refused?.summary).toContain(`then rerun ${launcher} upgrade check.`); + expect(refused?.action).toContain(`then rerun ${launcher} upgrade check.`); + // More unchecked items than are named point at the launcher's --verbose. + const many = { + ...report, + incomplete: Array.from({ length: 7 }, (_, index) => ({ + scope: `checkout-${index}`, + id: "env-format", + status: "incomplete" as const, + summary: "Not checked.", + args: null, + action: null, + kind: "manual" as const, + command: null, + })), + }; + expect(renderUpgradeCheck(many)).toContain( + `more (${launcher} upgrade check --verbose).` + ); + // A grouped manual step points at the launcher's --verbose listing. + const finding = (checkout: string) => ({ + id: "project-store", + status: "warn" as const, + summary: "Project store.", + args: ["doctor", "--path", checkout], + action: null, + kind: "manual" as const, + command: null, + }); + const plan = buildUpgradePlan({ + machine: [], + projects: ["/repos/a", "/repos/b"].map((checkout) => ({ + name: checkout, + project: checkout, + root: checkout, + project_dir: join(checkout, ".hack"), + state: "live" as const, + reason: null, + findings: [finding(checkout)], + })), + render: (args) => [launcher, ...args].join(" "), + }); + const grouped = plan.find((step) => step.id === "project-store"); + expect(grouped?.action).toContain( + `(${launcher} upgrade check --verbose lists them)` + ); +}); + /** A global encrypted_file store with one entry, and its key file. */ async function globalStore(opts: { readonly reader: Entry; From bd113858910a177cdccfb4861290acc5d038616d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:14:24 +0000 Subject: [PATCH 5/8] fix(candidate): keep the project selector in the Compose fallback command (HACK-2251) Codex review of #863: the printed `HACK_RUNTIME_BACKEND=compose up` dropped the project the refused command selected, so pasting it from another directory found no project or the wrong one. The refusal now prints `--path `, plus `--branch` when one was given, and doctor's Compose and basic-build hints name the checked project's root. Co-Authored-By: Claude Opus 5.5 --- src/commands/doctor.ts | 1 + src/commands/project.ts | 4 ++ src/lib/doctor-compose-backend.ts | 11 +++-- src/lib/native-compose-backend-features.ts | 40 +++++++++++++++++-- tests/candidate-launcher.test.ts | 29 ++++++++++++++ ...ative-compose-backend-features-cli.test.ts | 15 ++++--- tests/native-project-settings-cli.test.ts | 10 +++-- 7 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 92bcbaa7c..a9b1db056 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3003,6 +3003,7 @@ async function checkComposeBackendFeatures(opts: { }); return composeBackendCheckResult({ backend: opts.backend, + projectRoot: ctx.projectRoot, buildsEnabled, features: reading.features, basicBuild: reading.basicBuild, diff --git a/src/commands/project.ts b/src/commands/project.ts index 9df3bb385..806fe804b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6099,6 +6099,10 @@ 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, }); const branch = await resolveEffectiveBranchForCommand({ project, diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index 8a88dc9d4..8c806735b 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -5,6 +5,7 @@ import { composeBackendCommand, describeComposeBackendFeatures, ISOLATED_NETWORKS_SELECTION, + projectSelectorArgs, } from "./native-compose-backend-features.ts"; /** Doctor check id for the Compose-backend feature report. */ @@ -43,6 +44,8 @@ export function composeBackendCheckResult( readonly backend: "native" | "compose"; readonly features: readonly ComposeBackendFeature[]; readonly basicBuild?: boolean; + /** The checked project's root, so printed commands select it. */ + readonly projectRoot?: string; } & ComposeBackendFeatureOptions ): { readonly name: string; @@ -59,14 +62,14 @@ export function composeBackendCheckResult( ? ` ${ISOLATED_NETWORKS_SELECTION}.` : ""; const build = basicBuild - ? ` Its basic Compose build is also refused by native image-project up. ${basicBuildNextStep()}.` + ? ` Its basic Compose build is also refused by native image-project up. ${basicBuildNextStep(undefined, opts.projectRoot)}.` : ""; if (opts.features.length === 0) { if (basicBuild && opts.backend === "native") { return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${basicBuildNextStep()}, or run it on the Compose backend: ${composeBackendCommand(["up"])}.`, + message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build while HACK_NATIVE_BUILDS is not set. ${basicBuildNextStep(undefined, opts.projectRoot)}, or run it on the Compose backend: ${composeBackendCommand(["up", ...projectSelectorArgs(opts)])}.`, composeBackend, }; } @@ -74,7 +77,7 @@ export function composeBackendCheckResult( return { name: COMPOSE_BACKEND_CHECK, status: "ok", - message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it while HACK_NATIVE_BUILDS is not set: ${basicBuildNextStep()}.`, + message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it while HACK_NATIVE_BUILDS is not set: ${basicBuildNextStep(undefined, opts.projectRoot)}.`, composeBackend, }; } @@ -91,7 +94,7 @@ export function composeBackendCheckResult( return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `${uses}, so native up refuses it and the project runs on the Compose backend. To run it there: ${composeBackendCommand(["up"])}.${network}${build}`, + message: `${uses}, so native up refuses it and the project runs on the Compose backend. To run it there: ${composeBackendCommand(["up", ...projectSelectorArgs(opts)])}.${network}${build}`, composeBackend, }; } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 373c7e0c2..f4f7ec9c9 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -635,9 +635,35 @@ export const ISOLATED_NETWORKS_SELECTION = * without native builds. The preview command names the user's launcher. */ export function basicBuildNextStep( - launcher: CandidateLauncher = currentCandidateLauncher() + launcher: CandidateLauncher = currentCandidateLauncher(), + /** The selected project's root; `` when unknown. */ + projectRoot?: string ): string { - return `Set HACK_NATIVE_BUILDS=1 to build it in the native runtime, or preview the authored native conversion with \`${launcherCommand(launcher, ["config", "import", "--dry-run", "--path"])} \``; + const preview = + projectRoot === undefined + ? `${launcherCommand(launcher, ["config", "import", "--dry-run", "--path"])} ` + : launcherCommand(launcher, [ + "config", + "import", + "--dry-run", + "--path", + projectRoot, + ]); + return `Set HACK_NATIVE_BUILDS=1 to build it in the native runtime, or preview the authored native conversion with \`${preview}\``; +} + +/** + * The command words that select the same project (and branch) as the refused + * command, so the printed fallback works from any directory. + */ +export function projectSelectorArgs(opts: { + readonly projectRoot?: string; + readonly branch?: string; +}): string[] { + return [ + ...(opts.projectRoot === undefined ? [] : ["--path", opts.projectRoot]), + ...(opts.branch === undefined ? [] : ["--branch", opts.branch]), + ]; } /** @@ -650,6 +676,10 @@ export function composeBackendRefusal( readonly features: readonly ComposeBackendFeature[]; readonly restart: boolean; readonly foreignNetworks?: readonly string[]; + /** The selected project's root, printed as `--path` in the fallback. */ + readonly projectRoot?: string; + /** The `--branch` the refused command was given, if any. */ + readonly branch?: string; } & ComposeBackendFeatureOptions ): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; @@ -658,7 +688,7 @@ export function composeBackendRefusal( : ""; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. To run it there: ${composeBackendCommand([opts.restart ? "restart" : "up"])}. Nothing was started or stopped.${names}`, + message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. To run it there: ${composeBackendCommand([opts.restart ? "restart" : "up", ...projectSelectorArgs(opts)])}. Nothing was started or stopped.${names}`, detail: { backend: "compose", features: [...opts.features] }, }); } @@ -676,6 +706,10 @@ export async function refuseNativeComposeBackendFeatures( /** Every adaptation native start could apply; see {@link readComposeBackendFeatures}. */ readonly adaptationCandidates?: readonly (string | undefined)[]; readonly restart: boolean; + /** The selected project's root, printed as `--path` in the fallback. */ + readonly projectRoot?: string; + /** The `--branch` the refused command was given, if any. */ + readonly branch?: string; } & ComposeBackendFeatureOptions ): Promise { const features = await readComposeBackendFeatures(opts.composeFile, { diff --git a/tests/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts index fb3091cb0..89ede1a3d 100644 --- a/tests/candidate-launcher.test.ts +++ b/tests/candidate-launcher.test.ts @@ -9,9 +9,11 @@ import { freshNativeHomeSetupCommand, resolveCandidateLauncher, } from "../src/lib/candidate-launcher.ts"; +import { composeBackendCheckResult } from "../src/lib/doctor-compose-backend.ts"; import { basicBuildNextStep, composeBackendCommand, + composeBackendRefusal, } from "../src/lib/native-compose-backend-features.ts"; import { renderUpgradeCommand, @@ -157,6 +159,33 @@ test("the basic-build hint previews config import through the user's launcher", ); }); +test("the Compose fallback selects the same project and branch from any directory", () => { + // Codex review of #863: a refused `up --path /repos/app --branch feature` + // must print a fallback that still selects that project and branch. + const refusal = composeBackendRefusal({ + features: ["external_network"], + restart: true, + projectRoot: join(root, "my app"), + branch: "feature-x", + }); + expect(refusal.message).toContain( + `To run it there: HACK_RUNTIME_BACKEND=compose hack restart --path '${join(root, "my app")}' --branch feature-x.` + ); + // Doctor checks one project; its hints select it too. + const doctor = composeBackendCheckResult({ + backend: "native", + features: ["external_network"], + basicBuild: true, + projectRoot: join(root, "app"), + }); + expect(doctor.message).toContain( + `To run it there: HACK_RUNTIME_BACKEND=compose hack up --path ${join(root, "app")}.` + ); + expect(doctor.message).toContain( + `hack config import --dry-run --path ${join(root, "app")}\`` + ); +}); + test("the self-update refusal names the channel manager only for hack-next", () => { const [, next, direct] = kinds(); const launcher = join(home, ".hack-next", "bin", "hack-next"); diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index df27af35e..90ce52ed7 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -14,6 +14,7 @@ import { join, relative, resolve } from "node:path"; import { nativeBuildsDisabledRefusal } from "../src/backends/native-project-builds.ts"; import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-start.ts"; import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { quoteShellWord } from "../src/lib/logs.ts"; import { foreignComposeNetworkNames } from "../src/lib/native-hack-compose-conventions.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; @@ -43,10 +44,12 @@ const ADMITTED_START_REQUESTS = [ "runtime check-project-share", "runtime up", ]; -/** The refusal's next step names the refused command through the launcher (`hack` here). */ -const selection = (command = "hack up") => - `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose ${command}. Nothing was started or stopped.`; -const SELECTION = selection(); +/** + * The refusal's next step: the refused command through the launcher (`hack` + * here), selecting the same project so it works from any directory. + */ +const selection = (project: string, command = "hack up") => + `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose ${command} --path ${quoteShellWord(project)}. Nothing was started or stopped.`; afterEach(async () => { for (const root of roots.splice(0)) { @@ -221,7 +224,7 @@ async function expectComposeBackendRefusal(opts: { const result = await cli(f, opts.args ?? ["up"], opts.env); expectOneClosedLine( result, - `E_NATIVE_PROJECT_UNSUPPORTED: Native \`${opts.command ?? "hack up"}\` does not support ${opts.phrase}; ${selection(opts.command)}${networkDetail}` + `E_NATIVE_PROJECT_UNSUPPORTED: Native \`${opts.command ?? "hack up"}\` does not support ${opts.phrase}; ${selection(f.project, opts.command)}${networkDetail}` ); for (const secret of opts.secrets ?? []) { expect(result.output).not.toContain(secret); @@ -619,7 +622,7 @@ test( }); expectOneClosedLine( result, - `E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${SELECTION} Foreign external networks: foreign-dev.` + `E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${selection(f.project)} Foreign external networks: foreign-dev.` ); expect(await f.calls()).toEqual([]); expect(await tree(f.root)).toEqual(before); diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index 0b61ce702..69243ab8b 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { NATIVE_SHARED_SOURCE_REFUSAL } from "../src/backends/native-project-start.ts"; +import { quoteShellWord } from "../src/lib/logs.ts"; import { nativeSetupShellReply } from "./helpers/native-setup-inspection.ts"; import { nativeSourceSizeReceipt } from "./helpers/native-source-size.ts"; import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; @@ -32,8 +33,9 @@ const entrypoint = resolve("index.ts"); const SPAWN_TIMEOUT_MS = 60_000; const IMAGE = `sha256:${"e".repeat(64)}`; const PROJECT = "settings-cli-fixture"; -const SELECTION = - "this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up. Nothing was started or stopped."; +/** The refusal's fallback selects the same project, so it works from any directory. */ +const selection = (project: string) => + `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up --path ${quoteShellWord(project)}. Nothing was started or stopped.`; const EXTERNAL_COMPOSE = { services: { web: { image: IMAGE, networks: ["shared-dev"] } }, networks: { "shared-dev": { external: true } }, @@ -198,7 +200,7 @@ test( HACK_NATIVE_ADAPTATION: explicit, }); expect(errorLines(result.output)).toEqual([ - `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${SELECTION} Foreign external networks: shared-dev.`, + `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${selection(f.project)} Foreign external networks: shared-dev.`, ]); expect(await f.calls()).toEqual([]); }, @@ -215,7 +217,7 @@ test( }); const without = await cli(f, ["up"], { HACK_NATIVE_SHARED_SOURCE: "1" }); expect(errorLines(without.output)).toEqual([ - `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${SELECTION} Foreign external networks: shared-dev.`, + `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${selection(f.project)} Foreign external networks: shared-dev.`, ]); expect(await f.calls()).toEqual([]); const withEnv = await cli(f, ["up", "--env", "qa"], { From e2bab57a2d96b24a44e6b5d748da363edbfd598a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:17:24 +0000 Subject: [PATCH 6/8] fix(candidate): keep an explicit --env in the Compose fallback command (HACK-2251) Review: `up --path /repos/app --env staging` printed a fallback without `--env staging`, so pasting it would start the default overlay. The fallback now carries the refused command's explicit --env next to --path and --branch. A CLI regression covers `up --env dev`. Co-Authored-By: Claude Opus 5.5 --- src/commands/project.ts | 1 + src/lib/native-compose-backend-features.ts | 11 +++++++++-- tests/candidate-launcher.test.ts | 5 ++++- tests/native-project-settings-cli.test.ts | 11 +++++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/commands/project.ts b/src/commands/project.ts index 806fe804b..bd9baf6fb 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6103,6 +6103,7 @@ async function handleNativeUp({ 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, diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index f4f7ec9c9..2a1572f49 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -653,16 +653,19 @@ export function basicBuildNextStep( } /** - * The command words that select the same project (and branch) as the refused - * command, so the printed fallback works from any directory. + * The command words that select the same project, branch and env overlay as + * the refused command, so the printed fallback works from any directory and + * starts the same configuration. */ export function projectSelectorArgs(opts: { readonly projectRoot?: string; readonly branch?: string; + readonly env?: string; }): string[] { return [ ...(opts.projectRoot === undefined ? [] : ["--path", opts.projectRoot]), ...(opts.branch === undefined ? [] : ["--branch", opts.branch]), + ...(opts.env === undefined ? [] : ["--env", opts.env]), ]; } @@ -680,6 +683,8 @@ export function composeBackendRefusal( readonly projectRoot?: string; /** The `--branch` the refused command was given, if any. */ readonly branch?: string; + /** The `--env` the refused command was given, if any. */ + readonly env?: string; } & ComposeBackendFeatureOptions ): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; @@ -710,6 +715,8 @@ export async function refuseNativeComposeBackendFeatures( readonly projectRoot?: string; /** The `--branch` the refused command was given, if any. */ readonly branch?: string; + /** The `--env` the refused command was given, if any. */ + readonly env?: string; } & ComposeBackendFeatureOptions ): Promise { const features = await readComposeBackendFeatures(opts.composeFile, { diff --git a/tests/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts index 89ede1a3d..bb5bcd392 100644 --- a/tests/candidate-launcher.test.ts +++ b/tests/candidate-launcher.test.ts @@ -167,9 +167,12 @@ test("the Compose fallback selects the same project and branch from any director restart: true, projectRoot: join(root, "my app"), branch: "feature-x", + env: "staging", }); + // An explicit --env is kept too: without it the paste would start the + // default overlay, a different configuration. expect(refusal.message).toContain( - `To run it there: HACK_RUNTIME_BACKEND=compose hack restart --path '${join(root, "my app")}' --branch feature-x.` + `To run it there: HACK_RUNTIME_BACKEND=compose hack restart --path '${join(root, "my app")}' --branch feature-x --env staging.` ); // Doctor checks one project; its hints select it too. const doctor = composeBackendCheckResult({ diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index 69243ab8b..d9df765ee 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -34,8 +34,8 @@ const SPAWN_TIMEOUT_MS = 60_000; const IMAGE = `sha256:${"e".repeat(64)}`; const PROJECT = "settings-cli-fixture"; /** The refusal's fallback selects the same project, so it works from any directory. */ -const selection = (project: string) => - `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up --path ${quoteShellWord(project)}. Nothing was started or stopped.`; +const selection = (project: string, env?: string) => + `this project runs on the Compose backend. To run it there: HACK_RUNTIME_BACKEND=compose hack up --path ${quoteShellWord(project)}${env === undefined ? "" : ` --env ${env}`}. Nothing was started or stopped.`; const EXTERNAL_COMPOSE = { services: { web: { image: IMAGE, networks: ["shared-dev"] } }, networks: { "shared-dev": { external: true } }, @@ -224,6 +224,13 @@ test( HACK_NATIVE_SHARED_SOURCE: "1", }); expect(withEnv.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + // Another env still refuses, and the fallback keeps that --env (HACK-2251). + const otherEnv = await cli(f, ["up", "--env", "dev"], { + HACK_NATIVE_SHARED_SOURCE: "1", + }); + expect(errorLines(otherEnv.output)).toEqual([ + `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${selection(f.project, "dev")} Foreign external networks: shared-dev.`, + ]); }, SPAWN_TIMEOUT_MS ); From 01bf2537b4381b8a8b2092622d8e59f0f70755ea Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:20:51 +0000 Subject: [PATCH 7/8] test(upgrade): use a neutral launcher stand-in path (privacy check) The privacy check flags a home-shaped path in a test fixture string. Co-Authored-By: Claude Opus 5.5 --- tests/upgrade-apply.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/upgrade-apply.test.ts b/tests/upgrade-apply.test.ts index 4f92f4f7f..478473b28 100644 --- a/tests/upgrade-apply.test.ts +++ b/tests/upgrade-apply.test.ts @@ -194,8 +194,8 @@ function healthyProbes(): UpgradeCheckProbes { }; } -/** A launcher path standing in for `~/.hack-next/bin/hack-next`. */ -const LAUNCHER = "/fixture/home/.hack-next/bin/hack-next"; +/** A launcher path standing in for the hack-next launcher. */ +const LAUNCHER = "/fixture/launcher/hack-next"; function withLauncher(report: UpgradeCheckReport): UpgradeCheckReport { return { ...report, cli: { ...report.cli, command: LAUNCHER } }; } From d50c0798cef8cc453fe68d7dfd2f15e3a7bbecdb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:24:47 +0000 Subject: [PATCH 8/8] fix(candidate): name the hack-next launcher of the actual channel root (HACK-2251) Codex review of #863: a `--root` install lives outside ~/.hack-next, but the resolver always printed $HOME/.hack-next/bin/hack-next. The manager runs /versions//bundle/hack-cli on both backends, so the root now comes from the running executable: its bin/hack-next when that file exists, the default root only otherwise (a source run). Co-Authored-By: Claude Opus 5.5 --- docs/guides/candidate-install.md | 2 +- src/lib/candidate-launcher.ts | 37 +++++++++++++++++++++++++++----- tests/candidate-launcher.test.ts | 26 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 85ce0ae7f..ce5f703e8 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -246,7 +246,7 @@ launcher the command ran through: | You ran | Printed commands start with | | --- | --- | -| `hack-next` | `$HOME/.hack-next/bin/hack-next` (the full path, since it is not on PATH) | +| `hack-next` | your channel's launcher by full path, `$HOME/.hack-next/bin/hack-next` by default or `/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` | diff --git a/src/lib/candidate-launcher.ts b/src/lib/candidate-launcher.ts index fa8b86ebf..1246001b2 100644 --- a/src/lib/candidate-launcher.ts +++ b/src/lib/candidate-launcher.ts @@ -1,5 +1,5 @@ import { statSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { resolveUserHomeDir } from "./home-dir.ts"; import { quoteShellWord } from "./logs.ts"; @@ -13,7 +13,8 @@ import { quoteShellWord } from "./logs.ts"; * * - `stable`: no candidate marker; the command is `hack`. * - `hack-next`: the channel manager's launcher at - * `~/.hack-next/bin/hack-next` (not on PATH). + * `/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 @@ -41,6 +42,29 @@ 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 `/versions//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"); @@ -61,12 +85,15 @@ export function resolveCandidateLauncher(opts: { if (entry === "") { return { kind: "stable", command: "hack" }; } - const bundle = - adjacentBundleLauncher(opts.execPath ?? process.execPath) ?? "hack-v5"; + const execPath = opts.execPath ?? process.execPath; + const bundle = adjacentBundleLauncher(execPath) ?? "hack-v5"; if (entry === "hack-next") { return { kind: "hack-next", - command: hackNextLauncherPath(opts.homeDir), + // 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, }; } diff --git a/tests/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts index bb5bcd392..c2f296678 100644 --- a/tests/candidate-launcher.test.ts +++ b/tests/candidate-launcher.test.ts @@ -71,6 +71,32 @@ test("each install kind resolves to a command the user can run", () => { }); }); +test("hack-next resolves the launcher of the channel root that holds the running bundle", async () => { + // Codex review of #863: `--root` installs live outside ~/.hack-next; the + // printed launcher must be that installation's own. + const channel = join(root, "my channel"); + const custom = join(channel, "versions", "5.0.0-next.18", "bundle"); + await Bun.write(join(custom, "hack-cli"), ""); + await Bun.write(join(custom, "hack-v5"), "#!/bin/sh\n"); + await Bun.write(join(channel, "bin", "hack-next"), "#!/bin/sh\n"); + const launcher = resolve("hack-next", join(custom, "hack-cli")); + expect(launcher).toEqual({ + kind: "hack-next", + command: join(channel, "bin", "hack-next"), + bundle: join(custom, "hack-v5"), + }); + expect(composeBackendCommand(["up"], launcher)).toBe( + `HACK_RUNTIME_BACKEND=compose '${join(channel, "bin", "hack-next")}' up` + ); + expect(candidateUpdateRefusal(launcher)).toContain( + `'${join(channel, "bin", "hack-next")}' --manager upgrade --version` + ); + // A bundle that is not inside a channel root falls back to the default. + expect(resolve("hack-next").command).toBe( + join(home, ".hack-next", "bin", "hack-next") + ); +}); + test("upgrade check and apply print the hack-next launcher on either backend", () => { const launcher = join(home, ".hack-next", "bin", "hack-next"); for (const [backend, env] of [