diff --git a/docs/cli.md b/docs/cli.md
index 1c0a9e484..0eeb6f78f 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -130,8 +130,22 @@ Historical remote guides are excluded from the supported documentation.
- `--json` on `hack up`/`down`/`restart`/`doctor` emits a `{ok, data | error: {code, message}}`
envelope with stable `E_*` error codes. `--json` on `hack up` implies `--detach`.
- A `--path` (or working directory) without a `.hack/` project reports `E_PROJECT_NOT_FOUND`
- from every project command, for example `hack ps -p
--json` and `hack up -p --json`.
- Without `--json` it prints one `E_PROJECT_NOT_FOUND` line and no stack trace.
+ from `up`, `down`, `restart`, `ps`, `logs`, `run`, `exec`, `open`, `tui`, `branch ...`,
+ `config get`/`set`, `env ...`, `host exec`/`shell` and `project owner`/`target`/`task`, for
+ example `hack ps -p --json` and `hack branch list -p `. Under `--json` it is one
+ error envelope; without `--json` it is one `ERROR: E_PROJECT_NOT_FOUND: ...` line and no stack
+ trace. `project target` and `project task` use their own message, and `target` adds a
+ `detail`. These commands report something else:
+ - `tui` without a terminal refuses for that reason first.
+ - `env get` never prints a failure reason, since its output is raw values: it prints one fixed
+ line on stderr, with or without `--json`.
+ - `config validate`, `plan`, `import` and `adopt` read native configuration and keep their own
+ refusals: `E_NATIVE_PROJECT_UNSUPPORTED` for `validate`/`plan`, a refused preview for
+ `import --dry-run`, and `E_CONFIG_INVALID` for `adopt`.
+ - `init`, `doctor`, `crash-capture`, and the `setup`, `mcp` and `agent` commands do not need a
+ project.
+
+ `tests/missing-project-error-code.test.ts` pins each command that takes `--path`.
- Known Hack errors and closed native runtime refusals print their message only, never a stack
trace; set `HACK_DEBUG=1` to add the stack. Unexpected exceptions always print their stack.
- `--no-interactive` (or `HACK_NO_INTERACTIVE=1`) is a global flag: commands never prompt — they
@@ -206,8 +220,16 @@ to JSON. A `--` before the child command is optional and is not passed on; a lat
Hack parses options with the resolved command's own options plus the global ones, so each command
keeps its own short flags and option types. `-p` is `--path` for project commands (`init`, `up`,
-`down`, `ps`, `logs`, `env ...`, `config ...`, `setup ...`) and `--port` for `hack ssh`. An option
-that only another command declares is refused as `Option(s) not valid for ""`.
+`down`, `ps`, `logs`, `env ...`, `config ...`, `setup ...`) and `--port` for `hack ssh`. Both
+refusals below are `E_USAGE`:
+
+- A long option that only another command declares is refused as
+ `Option(s) not valid for "": --`, for example `hack doctor --follow`.
+- A short spelling the command does not declare is unknown to it, even when another command
+ declares it. `hack doctor -f` refuses as `Unknown option '-f'. To specify a positional argument
+ starting with a '-', ...`, the same refusal as a spelling no command declares, such as `-x`.
+ (`-f` is `--follow` for `logs` and `global logs`.) In a group of short flags, such as `-fd`, the refusal
+ names the first unknown letter without its dash (`Unknown option 'f'`).
An option placed before the command name, as in `hack -p up`, is read as that command reads
it. There is one remaining edge: when a spelling placed before the command name is a flag in one
@@ -216,11 +238,23 @@ name. No spelling in the current CLI has this conflict. Put options after the co
avoid it.
Per-command parsing replaced one parse over every command's options. Comparing the old and new
-parsers on edge cases found one difference, the intended one: `-p ` on a project command (for
-example `up -p /x` or `--no-interactive up -p /x`) now sets `--path`. The old parser set `ssh`'s
-`--port`, which the command then refused. These cases parse the same as before: `--help --all`,
-`help --all`, `-- up`, `--json run web tool --json`, `--path /x up`, `logs --tail`, `up --tail`,
-`ssh --path /x` and `x ext cmd -p 1` (where `-p 1` belongs to the extension command).
+parsers on edge cases found two differences:
+
+- The intended one: `-p ` on a project command (for example `up -p /x` or
+ `--no-interactive up -p /x`) now sets `--path`. The old parser set `ssh`'s `--port`, which the
+ command then refused.
+- A short spelling only another command declares is still refused, with different text. The old
+ parser read it as that command's long option and refused it as not valid: `doctor -f` and
+ `doctor -d` gave `Option(s) not valid for "doctor": --follow` and `--detach`, `ps -fd` gave
+ `--follow, --detach`, and `doctor -c --json` (`-c` takes a value for `agent init`) gave an
+ "argument is ambiguous" error. Each now gives `Unknown option ''`, and `ps -fd` gives
+ `Unknown option 'f'`.
+
+These cases parse the same as before: `--help --all`, `help --all`, `-- up`,
+`--json run web tool --json`, `--path /x up`, `logs --tail`, `up --tail`, `ssh --path /x` and
+`x ext cmd -p 1` (where `-p 1` belongs to the extension command).
+`tests/cli-per-command-options.test.ts` pins the short-spelling refusal for every short spelling
+that `doctor` does not declare.
`session exec` takes the workspace command as one argument, so quote a command that has its own
arguments or flags: `hack session exec ws "npm --version"`. Hack options go before it, and an
diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md
index 132704aa5..e031bad66 100644
--- a/docs/guides/native-candidate.md
+++ b/docs/guides/native-candidate.md
@@ -876,7 +876,12 @@ are optional; when given they must equal the selection recorded at startup
recorded selectors, refuses with `E_USAGE` before any runtime request. An empty
value (`--env ""`, `--profile ""`, or one that is only spaces or commas) also
refuses with `E_USAGE`: it does not mean "inherit" or an empty selection. Omit
-the option to inherit. `run` never restarts the project to change its selection.
+the option to inherit. A profile list is empty only when every entry is, so
+`--profile "web,"` asks for `web`. On the native backend the empty check runs
+first, before the project is selected, so an authored `hack.project.json`
+project refuses an empty value with the same `E_USAGE` line rather than its
+`E_NATIVE_PROJECT_UNSUPPORTED` refusal of any `--env` or `--profile`.
+`run` never restarts the project to change its selection.
Services with no managed environment values also support `run`; they keep the
admitted image's environment without creating a private environment mount or
launcher. The one-off request still carries an explicit empty, identity-bound
diff --git a/src/backends/native-project-one-off.ts b/src/backends/native-project-one-off.ts
index cd0bdcc9a..9940fac40 100644
--- a/src/backends/native-project-one-off.ts
+++ b/src/backends/native-project-one-off.ts
@@ -45,6 +45,9 @@ export const NATIVE_RUN_EMPTY_SELECTION_REFUSAL =
* `--profile` means no profiles, but a native run never changes the running
* selection, so an empty value could only silently mean "inherit" or an
* explicit empty selection. Whitespace and comma-only values count as empty.
+ * A profile list is empty only when every entry is: an empty entry beside a
+ * real one is dropped, so `--profile "web,"` asks for `web`. On the native
+ * backend `hack run` calls this before any project dispatch.
*/
export function requireNonEmptyNativeRunOptions(opts: {
readonly env: string | undefined;
diff --git a/src/commands/branch.ts b/src/commands/branch.ts
index 8107ae6f5..771038496 100644
--- a/src/commands/branch.ts
+++ b/src/commands/branch.ts
@@ -8,9 +8,10 @@ import {
} from "../cli/command.ts";
import { optOpenPreference, optPath, optProject } from "../cli/options.ts";
import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
-import { DEFAULT_PROJECT_TLD, HACK_PROJECT_DIR_PRIMARY } from "../constants.ts";
+import { DEFAULT_PROJECT_TLD } from "../constants.ts";
import { readBranchesFile, writeBranchesFile } from "../lib/branches.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import {
parseOpenHostPreference,
resolvePreferredOpenHost,
@@ -369,9 +370,7 @@ async function requireProjectContext(
): Promise {
const ctx = await findProjectContext(startDir);
if (!ctx) {
- throw new Error(
- `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`
- );
+ throw new MissingProjectContextError();
}
return ctx;
}
diff --git a/src/commands/config.ts b/src/commands/config.ts
index c3b153662..cd1d949c3 100644
--- a/src/commands/config.ts
+++ b/src/commands/config.ts
@@ -10,7 +10,6 @@ import { optPath, optProject } from "../cli/options.ts";
import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
import {
GLOBAL_ONLY_EXTENSION_IDS,
- HACK_PROJECT_DIR_PRIMARY,
PROJECT_CONFIG_FILENAME,
PROJECT_CONFIG_LEGACY_FILENAME,
} from "../constants.ts";
@@ -21,6 +20,7 @@ import {
} from "../lib/default-domain.ts";
import { ensureDir, readTextFile, writeTextFileIfChanged } from "../lib/fs.ts";
import { isRecord } from "../lib/guards.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import type { ProjectContext } from "../lib/project.ts";
import { findProjectContext } from "../lib/project.ts";
import { assertLegacyProjectDirectory } from "../lib/project-input-selection.ts";
@@ -286,9 +286,7 @@ async function requireProjectContext(
): Promise {
const ctx = await findProjectContext(startDir);
if (!ctx) {
- throw new Error(
- `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`
- );
+ throw new MissingProjectContextError();
}
return ctx;
}
diff --git a/src/commands/env.ts b/src/commands/env.ts
index b360ff4e2..ed45420dd 100644
--- a/src/commands/env.ts
+++ b/src/commands/env.ts
@@ -63,6 +63,7 @@ import {
requireInteractive,
} from "../lib/interactivity.ts";
import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import type { ProjectContext } from "../lib/project.ts";
import {
defaultProjectSlugFromPath,
@@ -693,7 +694,7 @@ async function resolveProjectForEnv(opts: {
: opts.ctx.cwd;
const project = await findProjectContext(startDir);
if (!project) {
- throw new CliUsageError("No .hack/ found. Run: hack init");
+ throw new MissingProjectContextError();
}
return project;
}
diff --git a/src/commands/project-admin.ts b/src/commands/project-admin.ts
index 8786e42f2..811402809 100644
--- a/src/commands/project-admin.ts
+++ b/src/commands/project-admin.ts
@@ -3,7 +3,7 @@ import type { CommandHandlerFor } from "../cli/command.ts";
import { CliUsageError, defineCommand, withHandler } from "../cli/command.ts";
import { optJson, optPath, optProject } from "../cli/options.ts";
import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
-import { HACK_PROJECT_DIR_PRIMARY } from "../constants.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import {
findProjectContext,
readProjectConfig,
@@ -115,9 +115,7 @@ async function resolveProjectTarget(input: {
: input.cwd;
const project = await findProjectContext(startDir);
if (!project) {
- throw new Error(
- `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`
- );
+ throw new MissingProjectContextError();
}
return project;
}
diff --git a/src/commands/project.ts b/src/commands/project.ts
index e64808cf8..7d56e6af7 100644
--- a/src/commands/project.ts
+++ b/src/commands/project.ts
@@ -226,6 +226,7 @@ import {
resolveUseLoki,
runLokiSnapshotWithFallback,
} from "../lib/logs.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import { refuseNativeComposeBackendFeatures } from "../lib/native-compose-backend-features.ts";
import { tryNativeComposeCommand } from "../lib/native-compose-command.ts";
import { tryNativeComposeOpen } from "../lib/native-compose-open.ts";
@@ -4830,22 +4831,6 @@ async function requireProjectContext(startDir: string) {
return ctx;
}
-/**
- * No project at the selected path. It carries `E_PROJECT_NOT_FOUND`, the code
- * `up`, `down` and `restart` already reported, so every project command that
- * resolves its project here (`ps`, `logs`, `run`, ...) reports the same closed
- * code under `--json` and one closed line without it.
- */
-class MissingProjectContextError extends HackCliError {
- constructor() {
- super({
- code: "E_PROJECT_NOT_FOUND",
- message: `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`,
- });
- this.name = "MissingProjectContextError";
- }
-}
-
type RemoteLifecycleAction = "up" | "down" | "restart";
/**
@@ -7494,8 +7479,9 @@ async function handlePs({
/**
* Compose runs need the Compose operation; a native one-off instead carries its
* `--env`/`--profile` request. Omitted options inherit the running selection;
- * an empty value is refused; given options must match it exactly, which the
- * native backend checks before any runtime request (no implicit restart).
+ * an empty value was already refused by {@link refuseEmptyNativeRunSelection};
+ * given options must match it exactly, which the native backend checks before
+ * any runtime request (no implicit restart).
*/
function runBackendSelection(
native: boolean,
@@ -7505,10 +7491,6 @@ function runBackendSelection(
requireComposeOperationAvailable("run");
return;
}
- requireNonEmptyNativeRunOptions({
- env: args.options.env,
- profile: args.options.profile,
- });
return {
envName: resolveRequestedEnvName({ envOption: args.options.env }),
profiles:
@@ -7518,6 +7500,23 @@ function runBackendSelection(
};
}
+/**
+ * On the native backend an empty `--env` or `--profile` is a usage error before
+ * any dispatch, so authored, adopted and legacy projects all refuse it with the
+ * same E_USAGE line, not the authored path's broader
+ * E_NATIVE_PROJECT_UNSUPPORTED. The Compose backend keeps its meaning: there
+ * an empty `--env` selects the base layers.
+ */
+function refuseEmptyNativeRunSelection(args: RunArgs): void {
+ if (process.env.HACK_RUNTIME_BACKEND !== "native") {
+ return;
+ }
+ requireNonEmptyNativeRunOptions({
+ env: args.options.env,
+ profile: args.options.profile,
+ });
+}
+
async function handleRun({
ctx,
args,
@@ -7525,6 +7524,7 @@ async function handleRun({
readonly ctx: CliContext;
readonly args: RunArgs;
}): Promise {
+ refuseEmptyNativeRunSelection(args);
const authored = await tryNativeComposeCommand({
cwd: ctx.cwd,
path: args.options.path,
diff --git a/src/commands/tui.ts b/src/commands/tui.ts
index a76c44890..17dd942cc 100644
--- a/src/commands/tui.ts
+++ b/src/commands/tui.ts
@@ -3,6 +3,7 @@ import type { CliContext, CommandArgs } from "../cli/command.ts";
import { CliUsageError, defineCommand, withHandler } from "../cli/command.ts";
import { optPath, optProject } from "../cli/options.ts";
import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
+import { MissingProjectContextError } from "../lib/missing-project-error.ts";
import type { ProjectContext } from "../lib/project.ts";
import { findProjectContext } from "../lib/project.ts";
import { upsertProjectRegistration } from "../lib/projects-registry.ts";
@@ -77,7 +78,7 @@ async function requireProjectContext(
): Promise {
const ctx = await findProjectContext(startDir);
if (!ctx) {
- throw new Error("No .hack/ (or legacy .dev/) found. Run: hack init");
+ throw new MissingProjectContextError();
}
return ctx;
}
diff --git a/src/lib/missing-project-error.ts b/src/lib/missing-project-error.ts
new file mode 100644
index 000000000..116f093ba
--- /dev/null
+++ b/src/lib/missing-project-error.ts
@@ -0,0 +1,20 @@
+import { HACK_PROJECT_DIR_PRIMARY } from "../constants.ts";
+import { HackCliError } from "./cli-result.ts";
+
+/** The message every project command reports for a path without a project. */
+export const MISSING_PROJECT_MESSAGE = `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`;
+
+/**
+ * No project at the selected path (`--path` or the working directory). It
+ * carries the closed code `E_PROJECT_NOT_FOUND`, so every project command that
+ * resolves its project through this error reports the same `--json` envelope
+ * and, without `--json`, one `ERROR: E_PROJECT_NOT_FOUND: ...` line and no
+ * stack. `docs/cli.md` lists the commands, and
+ * `tests/missing-project-error-code.test.ts` pins each one.
+ */
+export class MissingProjectContextError extends HackCliError {
+ constructor() {
+ super({ code: "E_PROJECT_NOT_FOUND", message: MISSING_PROJECT_MESSAGE });
+ this.name = "MissingProjectContextError";
+ }
+}
diff --git a/tests/cli-per-command-options.test.ts b/tests/cli-per-command-options.test.ts
index a005dbc8b..633b15fc5 100644
--- a/tests/cli-per-command-options.test.ts
+++ b/tests/cli-per-command-options.test.ts
@@ -4,10 +4,12 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
type AnyCommandSpec,
+ builtinOptions,
defineCli,
defineCommand,
defineOption,
findPassthroughStart,
+ type OptionSpec,
parseCliArgv,
resolveCommand,
} from "../src/cli/command.ts";
@@ -96,6 +98,47 @@ test("an option another command declares is refused as not valid here", () => {
expect(() => parse(["up", "-x"])).toThrow("Unknown option '-x'");
});
+/**
+ * HACK-1371: only a long spelling reaches the "not valid for" refusal. A short
+ * spelling the resolved command does not declare is unknown to its parse even
+ * when another command declares it (`-f` is `--follow` for `logs`, `-d` is
+ * `--detach` for `up`). The parse over every command's options that #370
+ * replaced read it as that long option and refused it as not valid instead.
+ */
+test("a short spelling only another command declares is unknown here", () => {
+ const ownOptions: readonly OptionSpec[] = [
+ ...builtinOptions(),
+ ...CLI_SPEC.globalOptions,
+ ...(CLI_SPEC.commands.find((command) => command.name === "doctor")
+ ?.options ?? []),
+ ];
+ const own = new Set(
+ ownOptions.flatMap((option) => (option.short ? [option.short] : []))
+ );
+ const others = new Set();
+ const walk = (commands: readonly AnyCommandSpec[]) => {
+ for (const command of commands) {
+ for (const option of command.options) {
+ if (option.short && !own.has(option.short)) {
+ others.add(option.short);
+ }
+ }
+ walk(command.subcommands);
+ }
+ };
+ walk(CLI_SPEC.commands);
+ for (const expected of ["-f", "-d", "-c", "-H", "-u", "-t"]) {
+ expect(others).toContain(expected);
+ }
+ for (const short of others) {
+ expect(() => parse(["doctor", short])).toThrow(
+ `Unknown option '${short}'. To specify a positional argument starting with a '-'`
+ );
+ }
+ // The long spelling still parses, so the CLI can name it as not valid.
+ expect(parse(["doctor", "--follow"]).values).toEqual({ follow: true });
+});
+
test("config adopt --rollback stays boolean; setup migrate --rollback-manifest takes a value", () => {
const adopt = parse(["config", "adopt", "--rollback", "--json"]);
expect(adopt.path).toEqual(["config", "adopt"]);
diff --git a/tests/cli-renderer-loading.test.ts b/tests/cli-renderer-loading.test.ts
index 92ebdb72d..87fe9b73f 100644
--- a/tests/cli-renderer-loading.test.ts
+++ b/tests/cli-renderer-loading.test.ts
@@ -158,7 +158,12 @@ const controls = [
{ mode: "remote-help", exit: 0, text: "hack remote", loads: false },
{ mode: "tui-help", exit: 0, text: "hack tui", loads: false },
{ mode: "tui-refused", exit: 1, text: "TUI requires a TTY", loads: false },
- { mode: "tui-missing-project", exit: 1, text: "No .hack/", loads: false },
+ {
+ mode: "tui-missing-project",
+ exit: 1,
+ text: "ERROR: E_PROJECT_NOT_FOUND: No .hack/",
+ loads: false,
+ },
{ mode: "remote-missing-project", exit: 1, text: "No .hack/", loads: false },
{
mode: "tui-selected",
diff --git a/tests/missing-project-error-code.test.ts b/tests/missing-project-error-code.test.ts
index cc03f8684..a8d592c21 100644
--- a/tests/missing-project-error-code.test.ts
+++ b/tests/missing-project-error-code.test.ts
@@ -2,20 +2,226 @@ import { afterEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
+import type { AnyCommandSpec } from "../src/cli/command.ts";
+import { CLI_SPEC } from "../src/cli/spec.ts";
+import { MISSING_PROJECT_MESSAGE } from "../src/lib/missing-project-error.ts";
import { privateCliEnv } from "./helpers/private-cli-env.ts";
/**
- * HACK-1339: every project command reports a path without a project with the
- * same closed code. `up`, `down` and `restart` already reported
- * E_PROJECT_NOT_FOUND; `ps` and `logs` reported E_UNEXPECTED (and, without
- * `--json`, a stack trace).
+ * HACK-1339, HACK-1371: every project command reports a `--path` without a
+ * project with the same closed code, `E_PROJECT_NOT_FOUND`. Under `--json` it
+ * is one envelope; without `--json` it is one `ERROR: E_PROJECT_NOT_FOUND:`
+ * line and no stack. `docs/cli.md` lists the commands and the exceptions
+ * pinned below.
+ *
+ * The table classifies every command that declares `--path`, so a new one
+ * fails the enumeration test until its missing-project behaviour is pinned.
*/
-const MESSAGE = "No .hack/ (or legacy .dev/) found. Run: hack init";
/** A JavaScript stack frame as Bun prints it. */
const STACK_FRAME = /^\s+at /m;
/** A source CLI spawn can take several seconds on a loaded runner. */
const SPAWN_TIMEOUT_MS = 30_000;
+/** The target-inspect contract's own E_PROJECT_NOT_FOUND message. */
+const TARGET_MESSAGE = "No hack project found at the selected location.";
+const NATIVE_VALIDATION_MESSAGE =
+ "Project-aware native validation requires an exact native input family; values omitted.";
+
+/** `DIR` in an argv is replaced with the empty fixture directory. */
+type Argv = readonly string[];
+
+type Expectation =
+ /** `{ok:false, error:{code:"E_PROJECT_NOT_FOUND", message}}` on stdout. */
+ | { readonly kind: "envelope"; readonly argv: Argv }
+ /** Exactly one closed `ERROR: E_PROJECT_NOT_FOUND:` line on stderr. */
+ | { readonly kind: "line"; readonly argv: Argv }
+ /** The target-inspect E_PROJECT_NOT_FOUND envelope, which adds a detail. */
+ | { readonly kind: "target-envelope"; readonly argv: Argv }
+ /** A documented exception: exact stdout and stderr. */
+ | {
+ readonly kind: "exception";
+ readonly argv: Argv;
+ readonly stdout: string;
+ readonly stderr: string;
+ }
+ /** Does not require an existing project, or is outside the supported CLI. */
+ | { readonly kind: "not-a-project-command"; readonly reason: string };
+
+const NOT_REQUIRED = "creates, diagnoses or configures without a project";
+const UNSUPPORTED = "unsupported or internal command";
+
+const COMMANDS: Readonly> = {
+ up: { kind: "envelope", argv: ["up", "-p", "DIR", "--json"] },
+ down: { kind: "envelope", argv: ["down", "-p", "DIR", "--json"] },
+ restart: { kind: "envelope", argv: ["restart", "-p", "DIR", "--json"] },
+ ps: { kind: "envelope", argv: ["ps", "-p", "DIR", "--json"] },
+ logs: { kind: "envelope", argv: ["logs", "-p", "DIR", "--json"] },
+ open: { kind: "envelope", argv: ["open", "-p", "DIR", "--json"] },
+ run: { kind: "line", argv: ["run", "-p", "DIR", "web", "true"] },
+ exec: { kind: "line", argv: ["exec", "-p", "DIR", "web", "true"] },
+ // Without a TTY, tui refuses before it looks for a project; with one it
+ // reports E_PROJECT_NOT_FOUND (tests/cli-renderer-loading.test.ts).
+ tui: {
+ kind: "exception",
+ argv: ["tui", "-p", "DIR"],
+ stdout: "",
+ stderr:
+ "ERROR: TUI requires a TTY. Run this from an interactive terminal.\n",
+ },
+ "branch add": { kind: "line", argv: ["branch", "add", "x", "-p", "DIR"] },
+ "branch list": { kind: "line", argv: ["branch", "list", "-p", "DIR"] },
+ "branch remove": {
+ kind: "line",
+ argv: ["branch", "remove", "x", "-p", "DIR"],
+ },
+ "branch open": { kind: "line", argv: ["branch", "open", "x", "-p", "DIR"] },
+ "config get": { kind: "line", argv: ["config", "get", "name", "-p", "DIR"] },
+ "config set": {
+ kind: "line",
+ argv: ["config", "set", "name", "x", "-p", "DIR"],
+ },
+ "env list": {
+ kind: "envelope",
+ argv: ["env", "list", "-p", "DIR", "--json"],
+ },
+ "env explain": { kind: "line", argv: ["env", "explain", "K", "-p", "DIR"] },
+ "env apply": {
+ kind: "line",
+ argv: ["env", "apply", "--service", "web", "-p", "DIR"],
+ },
+ "env add": { kind: "line", argv: ["env", "add", "K", "V", "-p", "DIR"] },
+ "env set": { kind: "line", argv: ["env", "set", "K", "V", "-p", "DIR"] },
+ "env unset": { kind: "line", argv: ["env", "unset", "K", "-p", "DIR"] },
+ "env hydrate": { kind: "line", argv: ["env", "hydrate", "-p", "DIR"] },
+ "env materialize": {
+ kind: "line",
+ argv: ["env", "materialize", "-p", "DIR"],
+ },
+ "env exec": { kind: "line", argv: ["env", "exec", "-p", "DIR", "true"] },
+ "env shell": { kind: "line", argv: ["env", "shell", "-p", "DIR"] },
+ "host exec": { kind: "line", argv: ["host", "exec", "-p", "DIR", "true"] },
+ "host shell": { kind: "line", argv: ["host", "shell", "-p", "DIR"] },
+ "project owner show": {
+ kind: "envelope",
+ argv: ["project", "owner", "show", "-p", "DIR", "--json"],
+ },
+ // env get prints raw values, so every failure is its fixed stderr text.
+ "env get": {
+ kind: "exception",
+ argv: ["env", "get", "K", "-p", "DIR", "--json"],
+ stdout: "",
+ stderr:
+ "Unable to read env value. Check the key, arguments, project env configuration, and key/backend access. Use hack env get --help for usage.\n",
+ },
+ // The target-inspect contract: same code, its own message and detail.
+ "project target": {
+ kind: "target-envelope",
+ argv: ["project", "target", "-p", "DIR", "--json"],
+ },
+ "project task list": {
+ kind: "exception",
+ argv: ["project", "task", "list", "-p", "DIR"],
+ stdout: "",
+ stderr: `ERROR: E_PROJECT_NOT_FOUND: ${TARGET_MESSAGE}\n`,
+ },
+ "project task inspect": {
+ kind: "exception",
+ argv: ["project", "task", "inspect", "x", "-p", "DIR"],
+ stdout: "",
+ stderr: `ERROR: E_PROJECT_NOT_FOUND: ${TARGET_MESSAGE}\n`,
+ },
+ // Native configuration commands keep their own transport and codes.
+ "config validate": {
+ kind: "exception",
+ argv: ["config", "validate", "-p", "DIR", "--json"],
+ stdout: `${JSON.stringify({
+ transport_version: 1,
+ ok: false,
+ error: {
+ code: "E_NATIVE_PROJECT_UNSUPPORTED",
+ message: NATIVE_VALIDATION_MESSAGE,
+ },
+ })}\n`,
+ stderr: "",
+ },
+ "config plan": {
+ kind: "exception",
+ argv: ["config", "plan", "-p", "DIR"],
+ stdout: "",
+ stderr: `E_NATIVE_PROJECT_UNSUPPORTED: ${NATIVE_VALIDATION_MESSAGE}\n`,
+ },
+ "config import": {
+ kind: "exception",
+ argv: ["config", "import", "--dry-run", "-p", "DIR"],
+ stdout:
+ 'Native import preview refused; no inputs or resources changed.\nconfig "/" (1:1): refused unsafe_changed_or_unavailable_input\n',
+ stderr: "",
+ },
+ "config adopt": {
+ kind: "exception",
+ argv: ["config", "adopt", "-p", "DIR"],
+ stdout: "",
+ stderr:
+ "ERROR: E_CONFIG_INVALID: Legacy adoption generation state is invalid, unsafe or changed; values omitted.\n",
+ },
+ init: { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ doctor: { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "crash-capture": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "mcp install": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "mcp print": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup cursor": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup claude": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup codex": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup agents": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup sync": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup migrate": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "setup mcp": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "agent init": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "agent onboard": { kind: "not-a-project-command", reason: NOT_REQUIRED },
+ "gateway enable": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "gateway setup": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "gateway disable": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "node workspace attach": {
+ kind: "not-a-project-command",
+ reason: UNSUPPORTED,
+ },
+ remote: { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "remote setup": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "remote status": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "remote monitor": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "remote qr": { kind: "not-a-project-command", reason: UNSUPPORTED },
+ "internal extra-hosts set": {
+ kind: "not-a-project-command",
+ reason: UNSUPPORTED,
+ },
+ "internal extra-hosts unset": {
+ kind: "not-a-project-command",
+ reason: UNSUPPORTED,
+ },
+ "internal extra-hosts list": {
+ kind: "not-a-project-command",
+ reason: UNSUPPORTED,
+ },
+};
+
+/** Every command path whose own options include `--path`. */
+function commandsWithPath(): string[] {
+ const out: string[] = [];
+ const walk = (
+ commands: readonly AnyCommandSpec[],
+ parent: readonly string[]
+ ) => {
+ for (const command of commands) {
+ const path = [...parent, command.name];
+ if (command.options.some((option) => option.long === "--path")) {
+ out.push(path.join(" "));
+ }
+ walk(command.subcommands, path);
+ }
+ };
+ walk(CLI_SPEC.commands, []);
+ return out.sort();
+}
const roots: string[] = [];
afterEach(async () => {
@@ -60,17 +266,57 @@ async function cli(opts: {
return { stdout, stderr, code };
}
-for (const command of ["ps", "logs", "up", "down", "restart"]) {
+test("every command that declares --path has a pinned missing-project behaviour", () => {
+ expect(Object.keys(COMMANDS).sort()).toEqual(commandsWithPath());
+});
+
+for (const [name, expectation] of Object.entries(COMMANDS)) {
+ if (expectation.kind === "not-a-project-command") {
+ continue;
+ }
test(
- `${command} -p --json reports E_PROJECT_NOT_FOUND`,
+ `${name} with a path without a project: ${expectation.kind}`,
async () => {
const { root, dir } = await emptyProject();
- const result = await cli({ root, args: [command, "-p", dir, "--json"] });
- expect(result.code).toBe(1);
- expect(JSON.parse(result.stdout)).toEqual({
- ok: false,
- error: { code: "E_PROJECT_NOT_FOUND", message: MESSAGE },
+ const result = await cli({
+ root,
+ args: expectation.argv.map((token) => (token === "DIR" ? dir : token)),
});
+ expect(result.code).toBe(1);
+ expect(result.stderr).not.toMatch(STACK_FRAME);
+ switch (expectation.kind) {
+ case "target-envelope":
+ expect(JSON.parse(result.stdout)).toMatchObject({
+ ok: false,
+ error: { code: "E_PROJECT_NOT_FOUND", message: TARGET_MESSAGE },
+ });
+ expect(result.stderr).toBe("");
+ break;
+ case "envelope":
+ expect(JSON.parse(result.stdout)).toEqual({
+ ok: false,
+ error: {
+ code: "E_PROJECT_NOT_FOUND",
+ message: MISSING_PROJECT_MESSAGE,
+ },
+ });
+ expect(result.stderr).toBe("");
+ break;
+ case "line":
+ expect(result.stdout).toBe("");
+ expect(result.stderr).toBe(
+ `ERROR: E_PROJECT_NOT_FOUND: ${MISSING_PROJECT_MESSAGE}\n`
+ );
+ break;
+ case "exception":
+ expect({ stdout: result.stdout, stderr: result.stderr }).toEqual({
+ stdout: expectation.stdout,
+ stderr: expectation.stderr,
+ });
+ break;
+ default:
+ throw new Error("unreachable");
+ }
},
SPAWN_TIMEOUT_MS
);
@@ -83,8 +329,9 @@ test(
const result = await cli({ root, args: ["ps", "-p", dir] });
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
- expect(result.stderr).toBe(`ERROR: E_PROJECT_NOT_FOUND: ${MESSAGE}\n`);
- expect(result.stderr).not.toMatch(STACK_FRAME);
+ expect(result.stderr).toBe(
+ `ERROR: E_PROJECT_NOT_FOUND: ${MISSING_PROJECT_MESSAGE}\n`
+ );
},
SPAWN_TIMEOUT_MS
);
diff --git a/tests/native-authored-command-dispatch.test.ts b/tests/native-authored-command-dispatch.test.ts
index 30d1a6c63..fdb0ad0d1 100644
--- a/tests/native-authored-command-dispatch.test.ts
+++ b/tests/native-authored-command-dispatch.test.ts
@@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test";
import { chmod, mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
+import { NATIVE_RUN_EMPTY_SELECTION_REFUSAL } from "../src/backends/native-project-one-off.ts";
import { isRecord } from "../src/lib/guards.ts";
import { requireTestConfigCompiler } from "./helpers/test-config-compiler.ts";
@@ -127,6 +128,40 @@ test.each([
]);
});
+/**
+ * HACK-1371: on the native backend an empty `run --env`/`--profile` is the
+ * same closed E_USAGE refusal for an authored project as for a legacy one. It
+ * is checked before authored dispatch, which would otherwise refuse it with
+ * the broader E_NATIVE_PROJECT_UNSUPPORTED.
+ */
+test.each([
+ { args: ["--profile", ""] },
+ { args: ["--profile", " , "] },
+ { args: ["--env", ""] },
+ { args: ["--env= "] },
+])("source CLI native run %j refuses as an empty selection before authored dispatch", async ({
+ args,
+}) => {
+ const selected = await fixture();
+ const value = await invoke({
+ selected,
+ args: ["run", ...args, "web", "--", "true"],
+ backend: "native",
+ logger: "console",
+ });
+ expect(value.code).toBe(1);
+ expect(value.stdout).toBe("");
+ expect(value.stderr).toBe(
+ `ERROR: E_USAGE: ${NATIVE_RUN_EMPTY_SELECTION_REFUSAL}\n`
+ );
+ expect(await Bun.file(join(selected.root, "compiler-called")).exists()).toBe(
+ false
+ );
+ expect(await readdir(join(selected.root, ".hack"))).toEqual([
+ "hack.project.json",
+ ]);
+});
+
test("source CLI native up --detach refuses with one line before compiler or runtime work", async () => {
const selected = await fixture();
const value = await invoke({
diff --git a/tests/native-authored-project-oneoff.test.ts b/tests/native-authored-project-oneoff.test.ts
index 54a63d33c..3a6f584b6 100644
--- a/tests/native-authored-project-oneoff.test.ts
+++ b/tests/native-authored-project-oneoff.test.ts
@@ -21,6 +21,7 @@ import {
saveNativeAuthoredProjectRun,
withNativeAuthoredProjectStatus,
} from "../src/backends/native-authored-project-run.ts";
+import { NATIVE_RUN_EMPTY_SELECTION_REFUSAL } from "../src/backends/native-project-one-off.ts";
import { invokeNativeRuntime } from "../src/backends/native-runtime-client.ts";
import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts";
import type { NativeComposeCommandOptions } from "../src/lib/native-compose-command.ts";
@@ -578,16 +579,23 @@ test(
);
const macTest = process.platform === "darwin" ? test : test.skip;
+// An empty selection is refused as E_USAGE before authored dispatch; a
+// non-empty selection reaches dispatch and is refused there.
+const unsupportedSelection = "E_NATIVE_PROJECT_UNSUPPORTED";
macTest.each([
- { extra: [], code: 3 },
+ { extra: [], code: 3, refusal: undefined },
+ {
+ extra: ["--profile", ""],
+ code: 1,
+ refusal: `ERROR: E_USAGE: ${NATIVE_RUN_EMPTY_SELECTION_REFUSAL}`,
+ },
...[
- ["--profile", ""],
["--profile", "dev"],
["--env", "base"],
- ].map((extra) => ({ extra, code: 1 })),
+ ].map((extra) => ({ extra, code: 1, refusal: unsupportedSelection })),
])(
"source CLI run %j preserves omitted options and exact completion",
- async ({ extra, code }) =>
+ async ({ extra, code, refusal }) =>
owned(async () => {
const f = await fixture(),
attempts = join(f.runtime.home, "run-attempts");
@@ -646,8 +654,8 @@ macTest.each([
expect({ code: result.exitCode, output: result.combined }).toMatchObject({
code,
});
- if (extra.length) {
- expect(result.combined).toContain("E_NATIVE_PROJECT_UNSUPPORTED");
+ if (refusal) {
+ expect(result.combined).toContain(refusal);
expect(await Bun.file(attempts).exists()).toBe(false);
} else {
expect(await Bun.file(attempts).text()).toBe("one-off\n");
diff --git a/tests/native-run-selection-cli.test.ts b/tests/native-run-selection-cli.test.ts
index 362ce4fa3..8a97b0d85 100644
--- a/tests/native-run-selection-cli.test.ts
+++ b/tests/native-run-selection-cli.test.ts
@@ -292,6 +292,44 @@ for (const request of [
}, 30_000);
}
+/**
+ * HACK-1371: a profile list is empty only when every entry is. An empty entry
+ * beside a real profile is dropped, so the list is compared with the running
+ * selection like any other: it runs when the real entries match and refuses
+ * with the mismatch refusal (not the empty one) when they differ.
+ */
+for (const profile of ["toolchain,", ",toolchain", " , toolchain , "]) {
+ test(`run --profile ${JSON.stringify(profile)} keeps its real entry and runs`, async () => {
+ const f = await fixture({ profiles: ["toolchain"] });
+ const result = await cli({
+ fixture: f,
+ args: ["run", "--profile", profile, "toolchain", "bun", "--version"],
+ });
+ expect(result.stderr).toBe("");
+ expect(result.code).toBe(0);
+ expect(result.stdout).toBe(CHILD_STDOUT);
+ expect(operations(await nativeCalls(f.root))).toEqual([
+ "refresh-dependencies",
+ "run-selection",
+ "run-service",
+ ]);
+ }, 30_000);
+}
+
+test('run --profile "web," with a different running profile refuses as a mismatch', async () => {
+ const f = await fixture({ profiles: ["toolchain"] });
+ const result = await cli({
+ fixture: f,
+ args: ["run", "--profile", "web,", "toolchain", "bun", "--version"],
+ });
+ expect(result.code).toBe(1);
+ expect(result.stdout).toBe("");
+ expect(result.stderr).toBe(
+ `ERROR: E_USAGE: ${NATIVE_RUN_SELECTION_REFUSAL}\n`
+ );
+ expect(await nativeCalls(f.root)).toEqual([]);
+}, 30_000);
+
test("run without --env or --profile still inherits the running selection", async () => {
const f = await fixture({ profiles: [], effectiveEnvName: null });
const result = await cli({