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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ 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.
- 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 @@ -199,6 +202,26 @@ rather than printing Hack's version, and a child `--json` does not switch Hack's
to JSON. A `--` before the child command is optional and is not passed on; a later `--` (as in
`hack run web npm test -- --watch`) reaches the child unchanged.

### Option parsing

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>"`.

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
command and takes a value in another, Hack reads it as a flag, so it never consumes the command
name. No spelling in the current CLI has this conflict. Put options after the command name to
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).

`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
unquoted `hack session exec ws npm --version` refuses with a hint to quote the command instead
Expand Down
6 changes: 4 additions & 2 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,8 +868,10 @@ confirmed. There is no automatic command replay or fallback to Compose.
`run` uses the running project's environment and profiles. `--env` and `--profile`
are optional; when given they must equal the selection recorded at startup
(profiles compare as a set). A different selection, or a legacy mapping without
recorded selectors, refuses with `E_USAGE` before any runtime request. `run` never
restarts the project to change its selection.
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.
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
31 changes: 29 additions & 2 deletions src/backends/native-project-one-off.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ function decode(value: unknown): Buffer {
export const NATIVE_RUN_SELECTION_REFUSAL =
"Native `hack run` uses the running project's environment and profiles, and the requested --env or --profile does not match them. Omit --env and --profile, or pass exactly the selection `hack up` started with. No command ran.";

/** The closed `E_USAGE` refusal of an empty native `run --env` or `--profile`. */
export const NATIVE_RUN_EMPTY_SELECTION_REFUSAL =
"Native `hack run` does not accept an empty --env or --profile value. Omit the option to use the running selection, or pass the env or profiles `hack up` started with. No command ran.";

/**
* Refuse an empty native `run --env` or `--profile` value before it is
* resolved. Elsewhere an empty `--env` means base layers and an empty
* `--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.
*/
export function requireNonEmptyNativeRunOptions(opts: {
readonly env: string | undefined;
readonly profile: string | undefined;
}): void {
const emptyEnv = opts.env?.trim() === "";
const emptyProfile =
opts.profile?.split(",").every((part) => part.trim() === "") === true;
if (emptyEnv || emptyProfile) {
throw new HackCliError({
code: "E_USAGE",
message: NATIVE_RUN_EMPTY_SELECTION_REFUSAL,
});
}
}

/**
* The selection a native one-off asks for. An omitted field inherits the
* running selection; a given field must equal it exactly. Profiles compare as
Expand All @@ -56,10 +82,11 @@ export function requireNativeRunSelection(opts: {
readonly selection?: NativeRunSelection;
}): void {
const { run, selection } = opts;
// A legacy mapping without a recorded env has `effectiveEnvName` undefined,
// which never equals a given selection (a string or the base `null`).
const envDiffers =
selection?.envName !== undefined &&
(run.effectiveEnvName === undefined ||
selection.envName !== run.effectiveEnvName);
selection.envName !== run.effectiveEnvName;
const profilesDiffer =
selection?.profiles !== undefined &&
(run.profiles === undefined ||
Expand Down
24 changes: 18 additions & 6 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import {
type NativeRunSelection,
nativeProjectOneOff,
requireNonEmptyNativeRunOptions,
} from "../backends/native-project-one-off.ts";
import {
retireNativeRecoveredPublisher,
Expand Down Expand Up @@ -4817,11 +4818,18 @@ async function requireProjectContext(startDir: string) {
return ctx;
}

class MissingProjectContextError extends Error {
/**
* 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(
`No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`
);
super({
code: "E_PROJECT_NOT_FOUND",
message: `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`,
});
this.name = "MissingProjectContextError";
}
}
Expand Down Expand Up @@ -7472,8 +7480,8 @@ 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;
* given options must match it exactly, which the native backend checks before
* any runtime request (no implicit restart).
* an empty value is refused; given options must match it exactly, which the
* native backend checks before any runtime request (no implicit restart).
*/
function runBackendSelection(
native: boolean,
Expand All @@ -7483,6 +7491,10 @@ function runBackendSelection(
requireComposeOperationAvailable("run");
return;
}
requireNonEmptyNativeRunOptions({
env: args.options.env,
profile: args.options.profile,
});
return {
envName: resolveRequestedEnvName({ envOption: args.options.env }),
profiles:
Expand Down
41 changes: 37 additions & 4 deletions tests/cli-closed-error-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { checkNativeHttpsPort } from "../src/backends/native-https-port.ts";
import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.ts";
import { nativeHttpsVerificationError } from "../src/backends/native-project-https.ts";
import { NativePartialShutdownError } from "../src/backends/native-project-interrupted-cleanup.ts";
import {
NATIVE_RUN_SELECTION_REFUSAL,
requireNativeRunSelection,
} from "../src/backends/native-project-one-off.ts";
import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts";
import { CliUsageError } from "../src/cli/command.ts";
import { handleRunCliError } from "../src/cli/run.ts";
Expand Down Expand Up @@ -167,9 +171,8 @@ test("--json keeps one envelope for a closed refusal", async () => {
});

test("a handler's usage refusal prints its message and one hint line, not the help", async () => {
const error = new CliUsageError(
"Native run uses the started project's environment and profiles; restart with the requested selections first."
);
// The `run` handler's own refusal of a missing service positional.
const error = new CliUsageError("Missing required argument: service");
expect(
await handleRunCliError({
error,
Expand All @@ -182,6 +185,34 @@ test("a handler's usage refusal prints its message and one hint line, not the he
);
});

test("the native run selection refusal prints one coded closed line and no hint", async () => {
// The current refusal (#368) replaced the removed "restart with the
// requested selections first" usage error; it is a coded E_USAGE error.
const error = thrownBy(() =>
requireNativeRunSelection({
run: {
run: "a".repeat(32),
owner: "b".repeat(32),
namespace: "c".repeat(64),
planId: "d".repeat(64),
profiles: ["toolchain"],
effectiveEnvName: null,
aws: null,
},
selection: { profiles: ["web"] },
})
);
expect(error).toBeInstanceOf(HackCliError);
expect(
await handleRunCliError({
error,
jsonRequested: false,
handlerPath: ["run"],
})
).toBe(1);
expect(output).toBe(`ERROR: E_USAGE: ${NATIVE_RUN_SELECTION_REFUSAL}\n`);
});

test("a usage error found while parsing still prints the help", async () => {
const error = new CliUsageError("Unknown option(s): --bogus");
expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1);
Expand All @@ -190,7 +221,9 @@ test("a usage error found while parsing still prints the help", async () => {
});

test("a handler's usage refusal under --json stays one E_USAGE envelope", async () => {
const error = new CliUsageError("Native exec does not support --env.");
const error = new CliUsageError(
"Native exec does not support --env or --profile changes; it uses the running service environment."
);
expect(
await handleRunCliError({
error,
Expand Down
54 changes: 54 additions & 0 deletions tests/cli-per-command-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,60 @@ test("the child-command boundary uses the resolved command's option types", () =
expect(findPassthroughStart(COLLIDING_CLI, ["gamma", "-r", "ls"])).toBe(2);
});

test("a lone - is a positional, so it can start the child command", () => {
// `-` is never an option: here it is gamma's child command, and the `-r`
// after it belongs to the child.
expect(findPassthroughStart(COLLIDING_CLI, ["gamma", "-", "-r"])).toBe(1);
expect(parseCliArgv(COLLIDING_CLI, ["gamma", "-", "-r"]).positionals).toEqual(
["gamma", "-", "-r"]
);
});

/**
* A passthrough command that also has a subcommand, so a subcommand name can
* appear after the command's own positional.
*/
const SUBCOMMAND_CLI = defineCli({
name: "demo",
version: "0.0.0",
summary: "A passthrough command with a subcommand",
globalOptions: [],
commands: [
defineCommand({
name: "delta",
summary: "service then child command",
group: "Project",
options: [optFlag],
positionals: [
{ name: "service" },
{ name: "command", multiple: true, passthrough: true },
],
subcommands: [
defineCommand({
name: "ls",
summary: "a subcommand of delta",
group: "Project",
options: [],
positionals: [],
subcommands: [],
} as const),
],
} as const),
],
} as const);

test("after a command's own positional, a subcommand name is the child command", () => {
// `ls` names a subcommand of delta only before delta's first positional;
// after `web` it is the child command, and its `-r` belongs to the child.
expect(
findPassthroughStart(SUBCOMMAND_CLI, ["delta", "web", "ls", "-r"])
).toBe(2);
// Before any positional, `ls` still resolves as the subcommand.
expect(
findPassthroughStart(SUBCOMMAND_CLI, ["delta", "ls", "-r"])
).toBeNull();
});

const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) {
Expand Down
90 changes: 90 additions & 0 deletions tests/missing-project-error-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 { 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).
*/

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;

const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});

async function emptyProject() {
const root = await realpath(
await mkdtemp(join(tmpdir(), "hack-missing-project-"))
);
roots.push(root);
const dir = join(root, "empty");
await mkdir(dir);
return { root, dir };
}

async function cli(opts: {
readonly root: string;
readonly args: readonly string[];
}) {
const child = Bun.spawn(
[process.execPath, resolve("index.ts"), ...opts.args],
{
cwd: opts.root,
env: privateCliEnv({
root: opts.root,
env: { HACK_NO_INTERACTIVE: "1", HACK_LOGGER: "console" },
}),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
timeout: 20_000,
}
);
const [stdout, stderr, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
return { stdout, stderr, code };
}

for (const command of ["ps", "logs", "up", "down", "restart"]) {
test(
`${command} -p <empty dir> --json reports E_PROJECT_NOT_FOUND`,
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 },
});
},
SPAWN_TIMEOUT_MS
);
}

test(
"ps -p <empty dir> without --json prints one closed line and no stack",
async () => {
const { root, dir } = await emptyProject();
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);
},
SPAWN_TIMEOUT_MS
);
Loading
Loading