diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 19ed2cac7..ce5f703e8 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` | 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` | + +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/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/scripts/installer/channel.ts b/scripts/installer/channel.ts index e4a84b0bd..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, @@ -1038,7 +1039,8 @@ export class Channel { const override = process.env.HACK_NATIVE_HOME?.trim(); requireThat( !override || override === this.path(`versions/${version}/native-home`), - "hack-next uses its receipt-owned native home for safe upgrades; use the bundle's hack-v5 launcher for a custom HACK_NATIVE_HOME." + // Name the selected bundle's script by path: it is not on PATH (HACK-2251). + `hack-next uses its receipt-owned native home for safe upgrades; for a custom HACK_NATIVE_HOME run the bundle's launcher directly: ${quoteShellWord(this.path(`versions/${version}/bundle/hack-v5`))}` ); } const checksum = this.state.installed[version]; 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/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..bd9baf6fb 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6099,6 +6099,11 @@ async function handleNativeUp({ }), restart, buildsEnabled: nativeBuildsEnabled(), + // The printed Compose fallback selects the same project and branch. + projectRoot: project.projectRoot, + branch: + typeof args.options.branch === "string" ? args.options.branch : undefined, + env: typeof args.options.env === "string" ? args.options.env : undefined, }); const branch = await resolveEffectiveBranchForCommand({ project, diff --git a/src/commands/update.ts b/src/commands/update.ts index 9056010e3..8cccbc9b3 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -10,6 +10,11 @@ import { withHandler, } from "../cli/command.ts"; import { optJson } from "../cli/options.ts"; +import { + type CandidateLauncher, + currentCandidateLauncher, + launcherCommand, +} from "../lib/candidate-launcher.ts"; import { emitCliResult } from "../lib/cli-result.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { canPrompt } from "../lib/interactivity.ts"; @@ -81,6 +86,18 @@ const spec = defineCommand({ type Spec = typeof spec; +/** + * The refusal names how this install kind is updated (HACK-2251): `hack-next` + * through its channel manager; a bundle run directly, by installing a newer + * reviewed bundle (there is no command for that). + */ +export function candidateUpdateRefusal(launcher: CandidateLauncher): string { + if (launcher.kind === "hack-next") { + return `Refusing to self-update the opt-in native candidate. Upgrade it through its channel manager: ${launcherCommand(launcher, ["--manager", "upgrade", "--version"])} (${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, @@ -99,8 +116,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/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/candidate-launcher.ts b/src/lib/candidate-launcher.ts new file mode 100644 index 000000000..1246001b2 --- /dev/null +++ b/src/lib/candidate-launcher.ts @@ -0,0 +1,137 @@ +import { statSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { resolveUserHomeDir } from "./home-dir.ts"; +import { quoteShellWord } from "./logs.ts"; + +/** + * Which command a user runs to reach this CLI again, by install kind + * (HACK-2251). + * + * During the prerelease, v5 runs through a candidate launcher while stable v4 + * stays `hack` on PATH, so text that tells the user what to run must name the + * launcher they actually have: + * + * - `stable`: no candidate marker; the command is `hack`. + * - `hack-next`: the channel manager's launcher at + * `/bin/hack-next` of the channel root holding the running bundle + * (default root `~/.hack-next`; not on PATH). + * - `bundle`: the bundle's own `hack-v5` script, run by path. It sits next to + * the bundle's `hack-cli`, which is the running executable. When that file + * cannot be found (a source run with the marker set by hand), the name + * `hack-v5` is the best available answer. + * + * This only names commands. Whether a run is a candidate run, and so whether + * hackd may autostart, is `isCandidateRun` in `src/daemon/client.ts`: any + * non-empty marker counts, whatever this function makes of it. + */ +export type CandidateLauncher = + | { readonly kind: "stable"; readonly command: "hack" } + | { + readonly kind: "hack-next"; + readonly command: string; + /** The selected bundle's `hack-v5`, for what `hack-next` refuses. */ + readonly bundle: string; + } + | { readonly kind: "bundle"; readonly command: string }; + +/** The marker the launchers set; see `scripts/hack-v5.sh`. */ +const CANDIDATE_ENTRYPOINT_ENV = "HACK_CANDIDATE_ENTRYPOINT"; + +/** The channel manager's launcher under a home directory. */ +export function hackNextLauncherPath(homeDir: string): string { + return join(homeDir, ".hack-next", "bin", "hack-next"); +} + +/** + * The `hack-next` launcher of the channel root that holds the running + * bundle, if there is one. The manager runs `/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"); + 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" }; + } + const execPath = opts.execPath ?? process.execPath; + const bundle = adjacentBundleLauncher(execPath) ?? "hack-v5"; + if (entry === "hack-next") { + return { + kind: "hack-next", + // The installation's own launcher; the default root only when the + // running bundle is not inside a channel root (a source run). + command: + bundleChannelLauncher(execPath) ?? hackNextLauncherPath(opts.homeDir), + bundle, + }; + } + return { kind: "bundle", command: bundle }; +} + +/** The launcher for this process, from its environment and the user's home. */ +export function currentCandidateLauncher(): CandidateLauncher { + return resolveCandidateLauncher({ + env: process.env, + homeDir: resolveUserHomeDir(), + }); +} + +/** A command line for `launcher`: optional env assignments, then each word quoted. */ +export function launcherCommand( + launcher: CandidateLauncher, + args: readonly string[], + env: Readonly> = {} +): 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 +): string { + const runner = + launcher.kind === "hack-next" ? launcher.bundle : 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..8c806735b 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,10 +1,11 @@ import { - BASIC_BUILD_NEXT_STEP, - COMPOSE_BACKEND_SELECTION, + basicBuildNextStep, type ComposeBackendFeature, type ComposeBackendFeatureOptions, + 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. ${BASIC_BUILD_NEXT_STEP}.` + ? ` 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. ${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. ${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: ${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(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. ${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", ...projectSelectorArgs(opts)])}.${network}${build}`, composeBackend, }; } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 9f765c099..2a1572f49 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"; @@ -606,9 +611,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 @@ -617,9 +630,44 @@ export const COMPOSE_BACKEND_SELECTION = 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(), + /** The selected project's root; `` when unknown. */ + projectRoot?: string +): string { + 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, 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]), + ]; +} /** * The closed refusal for a project that needs the Compose backend: stable code @@ -631,6 +679,12 @@ 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; + /** The `--env` the refused command was given, if any. */ + readonly env?: string; } & ComposeBackendFeatureOptions ): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; @@ -639,7 +693,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", ...projectSelectorArgs(opts)])}. Nothing was started or stopped.${names}`, detail: { backend: "compose", features: [...opts.features] }, }); } @@ -657,6 +711,12 @@ 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; + /** The `--env` the refused command was given, if any. */ + readonly env?: string; } & ComposeBackendFeatureOptions ): Promise { const features = await readComposeBackendFeatures(opts.composeFile, { diff --git a/src/lib/upgrade-apply.ts b/src/lib/upgrade-apply.ts index 830631977..21889acf8 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, @@ -597,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 @@ -657,7 +661,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 +680,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 +701,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; @@ -743,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" || @@ -765,7 +771,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 +786,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 +862,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( @@ -874,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") { @@ -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..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; @@ -532,7 +540,8 @@ async function agentFinding(opts: { } function nativeFinding( - probe: NativePreflightProbe | null + probe: NativePreflightProbe | null, + command: CommandRenderer ): UpgradeFinding | null { if (probe === null) { return null; @@ -541,7 +550,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, } @@ -707,6 +716,7 @@ async function guarded( readonly timeoutMs: number; readonly deadline: number; readonly root: string; + readonly command: CommandRenderer; }, run: (timeoutMs: number) => Promise ): Promise { @@ -745,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); @@ -762,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, @@ -781,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}.`, }; } @@ -799,19 +812,27 @@ 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?: 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 })), @@ -835,7 +856,7 @@ 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 }), command) ), ]); return results.filter((finding) => finding !== null); diff --git a/src/lib/upgrade-check-render.ts b/src/lib/upgrade-check-render.ts index 53917f94c..9b1a6a6b8 100644 --- a/src/lib/upgrade-check-render.ts +++ b/src/lib/upgrade-check-render.ts @@ -241,12 +241,15 @@ 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(""); - 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) { @@ -301,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) { @@ -317,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 de0d3dbb3..ac68f87b7 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], }; } @@ -250,6 +251,7 @@ export async function runUpgradeCheck(opts: { nativePreflight: opts.probes.nativePreflight, deadline, checkTimeoutMs: opts.checkTimeoutMs, + command: (args) => renderUpgradeCommand(prefix, args), }) ).map(render), }) @@ -578,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, } @@ -870,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 @@ -934,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/candidate-launcher.test.ts b/tests/candidate-launcher.test.ts new file mode 100644 index 000000000..c2f296678 --- /dev/null +++ b/tests/candidate-launcher.test.ts @@ -0,0 +1,237 @@ +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 { composeBackendCheckResult } from "../src/lib/doctor-compose-backend.ts"; +import { + basicBuildNextStep, + composeBackendCommand, + composeBackendRefusal, +} 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("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 [ + [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, + 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(); + 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 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 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", + 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 --env staging.` + ); + // 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"); + 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/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index c29073fc7..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,8 +44,12 @@ 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: 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)) { @@ -219,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}${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); @@ -617,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-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/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index e61f884a8..d9df765ee 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. 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 fallback selects the same project, so it works from any directory. */ +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 } }, @@ -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,13 +217,20 @@ 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"], { 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 ); diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 38beb77ee..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; @@ -135,6 +138,15 @@ 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") + ); + // 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/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(); diff --git a/tests/upgrade-apply.test.ts b/tests/upgrade-apply.test.ts index 1b0e73a6b..478473b28 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 the hack-next launcher. */ +const LAUNCHER = "/fixture/launcher/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", @@ -400,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({ @@ -687,7 +705,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 +730,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 +760,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..bc93363dd 100644 --- a/tests/upgrade-check.test.ts +++ b/tests/upgrade-check.test.ts @@ -26,7 +26,10 @@ 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 { 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"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -733,6 +736,121 @@ 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"); +}); + +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;