Skip to content
Closed
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
29 changes: 22 additions & 7 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,26 @@ How it is maintained:
initialized), `hack doctor` reports the "hack gitignore" check as a warning,
and `hack doctor --fix` creates the file, or restores its managed block,
as its own step. Commit the result so fresh clones and worktrees get the
rules. `hack init` does not repair an existing project. Hack never writes
rules. `hack init` does not repair an existing project unless you confirm
overwriting its scaffold files. Hack never writes
through a symlinked `.hack/.gitignore` or `.hack/` (including a `.hack/`
that links outside the repository); doctor reports that case and leaves it
alone.
- a read-only `.hack/.gitignore` (for example mode 0444) is never replaced,
the same policy as for `info/exclude`. When it lacks the managed block,
alone. Doctor asks git about the project root (the directory that holds
`.hack/`), not about `.hack/` itself. So when the project root is not a git
checkout, the check reports "Skipped (not a git checkout)" even if `.hack/`
links into another repository; Hack still refuses to write through that
link.
- a read-only `.hack/.gitignore` is never replaced, the same policy as for
`info/exclude`. A file with no write permission bit (for example mode 0444)
is read-only for every user, root included; otherwise Hack asks the
operating system whether you may write it. When it lacks the managed block,
`hack doctor` warns and names the fix (make it writable, then run
`hack doctor --fix`), and `--fix` leaves it unchanged. `hack env set --local`
and `hack env hydrate` still write the local layer, print the same warning
on stderr (so `--json` output on stdout stays valid JSON), and leave the
file unchanged. A read-only file that already has the block is fine.
file unchanged. Interactive `hack init` that overwrites an existing scaffold
prints the same warning and leaves the file unchanged. A read-only file that
already has the block is fine.
- runtime commands (`hack up`, `down`, `restart`, `run`, `exec`) never write
into your working tree to set up ignores, so they leave `git status`
unchanged. Before they write `.hack/.internal/` or `.hack/.branch/` files,
Expand Down Expand Up @@ -249,8 +258,14 @@ A value that a service gets from the global scope uses one `HACK_ENV_*` referenc
by every such service. A key that a service scope sets keeps a reference of its own for
that service. The Compose client environment therefore grows with the distinct values,
not with services times keys: twenty services that share one 64 KiB global value add
about 64 KiB, not 1.25 MiB. The choice follows which scope sets each key, never the
values, so the override file still holds no value. Compose also receives each global
about 64 KiB, not 1.25 MiB. The choice follows which scope sets each key, so the
override file still holds no value. One safety check does compare values: if a key that
the resolved scopes call global has a value for a service that differs from the global
value, that service keeps a reference of its own for the key, so the container still gets
its own value. In that case the file shows that the service's value differs from the
global value, but never the value itself or its length. With a normal env config the
scopes and values come from the same resolution and always agree, so this check never
changes the file. Compose also receives each global
value under its own name for `${KEY}` interpolation in your Compose file, so a global
value reaches the `docker compose` process twice.

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
66 changes: 42 additions & 24 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 @@ -256,6 +257,7 @@ import {
import {
discoverComposeServiceNames,
ensureHackDirGitignore,
HACK_DIR_GITIGNORE_READ_ONLY_WARNING,
migrateLegacyProjectEnv,
projectEnvConfigExists,
resolveProjectEnvConfig,
Expand Down Expand Up @@ -3038,6 +3040,23 @@ async function promptInitUseDiscovery(opts: {
return useDiscovery;
}

/**
* `hack init` step for the committed `.hack/.gitignore`. Interactive init
* reaches an existing file when the user confirms overwriting the scaffold;
* a read-only one that lacks the managed block is left unchanged (see
* `ensureHackDirGitignore`) and reported with the same fixed warning that
* `hack doctor` and the machine-local env writes print, instead of silently.
* Init still succeeds: the scaffold does not depend on the file.
*/
async function ensureInitHackDirGitignore(opts: {
readonly hackDir: string;
}): Promise<void> {
const result = await ensureHackDirGitignore({ projectDir: opts.hackDir });
if (result.readOnly) {
logger.warn({ message: HACK_DIR_GITIGNORE_READ_ONLY_WARNING });
}
}

async function ensureInitHackDir(opts: {
readonly io: InitInteractiveIo;
readonly hackDir: string;
Expand Down Expand Up @@ -3186,7 +3205,7 @@ export async function handleInit({
// Committed, hack-owned ignore file for machine-local generated files
// (.internal/, .branch/, .env, env state, env-local overrides).
await assertLegacyProjectInputFamily({ projectRoot: repoRoot });
await ensureHackDirGitignore({ projectDir: hackDir });
await ensureInitHackDirGitignore({ hackDir });

await writeInitScaffoldFile({
repoRoot,
Expand Down Expand Up @@ -3359,7 +3378,7 @@ async function handleInitAuto({
// Committed, hack-owned ignore file for machine-local generated files
// (.internal/, .branch/, .env, env state, env-local overrides).
await assertLegacyProjectInputFamily({ projectRoot: repoRoot });
await ensureHackDirGitignore({ projectDir: hackDir });
await ensureInitHackDirGitignore({ hackDir });

await writeInitScaffoldFile({
repoRoot,
Expand Down Expand Up @@ -4830,22 +4849,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 +7497,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 +7509,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 +7518,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
Loading
Loading