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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> --json` and `hack up -p <dir> --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 <dir> --json` and `hack branch list -p <dir>`. 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
Expand Down Expand Up @@ -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 "<command>"`.
`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 "<command>": --<option>`, 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 <dir> 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
Expand All @@ -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 <dir>` 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 <dir>` 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 '<spelling>'`, 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
Expand Down
7 changes: 6 additions & 1 deletion docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/backends/native-project-one-off.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 3 additions & 4 deletions src/commands/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -369,9 +370,7 @@ async function requireProjectContext(
): Promise<ProjectContext> {
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;
}
Expand Down
6 changes: 2 additions & 4 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -286,9 +286,7 @@ async function requireProjectContext(
): Promise<ProjectContext> {
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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 2 additions & 4 deletions src/commands/project-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
44 changes: 22 additions & 22 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -7518,13 +7500,31 @@ 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,
}: {
readonly ctx: CliContext;
readonly args: RunArgs;
}): Promise<number> {
refuseEmptyNativeRunSelection(args);
const authored = await tryNativeComposeCommand({
cwd: ctx.cwd,
path: args.options.path,
Expand Down
3 changes: 2 additions & 1 deletion src/commands/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,7 +78,7 @@ async function requireProjectContext(
): Promise<ProjectContext> {
const ctx = await findProjectContext(startDir);
if (!ctx) {
throw new Error("No .hack/ (or legacy .dev/) found. Run: hack init");
throw new MissingProjectContextError();
}
return ctx;
}
Expand Down
20 changes: 20 additions & 0 deletions src/lib/missing-project-error.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
43 changes: 43 additions & 0 deletions tests/cli-per-command-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
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"]);
Expand Down
7 changes: 6 additions & 1 deletion tests/cli-renderer-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading