hacksh 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # hacksh
2
2
 
3
3
  Define a type-safe command-line interface with a Zod 4 object schema. The same schema parses and
4
- validates arguments, infers the value passed to `run`, and generates help.
4
+ validates arguments, infers the value passed to `run`, and generates help. Run external commands
5
+ with the context’s `exec`, and use its `logger` for progress and interactive prompts.
5
6
 
6
7
  The name comes from 拍手 (_hakushu_), the Japanese word for "clap", as a nod to
7
8
  [clap](https://github.com/clap-rs/clap), the command-line argument parser for Rust. It is spelled
@@ -30,21 +31,22 @@ import { z } from "zod";
30
31
  import { defineCommand, runMain } from "hacksh";
31
32
 
32
33
  const main = defineCommand({
33
- name: "gh-pr-commits",
34
+ name: "git-summary",
34
35
  version: "0.1.0",
35
- description: "List commits in a GitHub pull request",
36
+ description: "Show a commit summary",
36
37
  args: z.object({
37
- _: z.tuple([
38
- z.coerce.number().int().optional().describe("Pull request number").meta({ title: "PR" }),
39
- ]),
40
- repo: z.string().optional().describe("Repository in OWNER/REPO format"),
41
- verbose: z.boolean().default(false).describe("Show details"),
38
+ _: z.tuple([z.string().default("HEAD").describe("Commit to inspect").meta({ title: "REF" })]),
39
+ repo: z.string().optional().describe("Repository directory"),
42
40
  }),
43
- argOptions: { repo: { alias: "R" }, verbose: { alias: ["v", "V"] } },
44
- async run({ args, rawArgs, extraArgs, passthrough, logger }) {
45
- // args is { _: [number?]; repo?: string; verbose: boolean }
46
- logger.info("Loading commits for {repo}", { repo: args.repo ?? "the current repository" });
47
- logger.debug("Invocation arguments", { rawArgs, extraArgs, passthrough });
41
+ argOptions: { repo: { alias: "C" } },
42
+ async run({ args, exec }) {
43
+ const [ref] = args._;
44
+ const { stdout } = await exec(
45
+ "git",
46
+ ["show", "--no-patch", "--format=%h %s", "--end-of-options", ref],
47
+ { cwd: args.repo },
48
+ );
49
+ console.log(stdout);
48
50
  },
49
51
  });
50
52
 
@@ -52,11 +54,22 @@ await runMain(main);
52
54
  ```
53
55
 
54
56
  ```sh
55
- gh-pr-commits 42 -R owner/repo --verbose
56
- gh-pr-commits --help
57
- gh-pr-commits --version
57
+ git-summary
58
+ git-summary HEAD~1 -C ./my-repo
59
+ git-summary --help
60
+ git-summary --version
58
61
  ```
59
62
 
63
+ Use an `async run` handler and its `exec` for external commands. It captures output, removes one
64
+ final newline, throws on failure, and observes invocation cancellation. Pass arguments as an array;
65
+ there is no command string to quote or split. You do not need a `node:child_process` import or a
66
+ custom process wrapper for these behaviors. See [External commands](#external-commands) for expected
67
+ nonzero exits, stdin, and terminal access.
68
+
69
+ `console.log` above writes the command’s data to stdout. Direct stdout writes are not converted
70
+ to JSON by hacksh’s output mode. Logger events go through the printer; their stream depends on
71
+ the level, so informational logs also share stdout by default.
72
+
60
73
  `--version` is a root-only builtin. It prints the root command's `version`. When the root has
61
74
  none, `runMain` resolves the executable (`process.argv[1]`) through symlinks and prints the
62
75
  `version` of the nearest ancestor `package.json` that declares one, so a published CLI reports its
@@ -67,8 +80,8 @@ are accepted (`--dryRun` and `--dry-run`). Every boolean flag also accepts a `--
67
80
  that sets it to `false`, such as `--no-verbose`, which is how a default-on flag is disabled.
68
81
  Boolean flags also accept the explicit forms `--verbose=true` and
69
82
  `--verbose=false`; other inline values and values on a negated flag (such as
70
- `--no-verbose=false`) are validation errors. Value options accept `--repo owner/repo` and
71
- `--repo=owner/repo`; values such as `false` remain strings for non-boolean fields.
83
+ `--no-verbose=false`) are validation errors. Value options accept `--repo ./my-repo` and
84
+ `--repo=./my-repo`; values such as `false` remain strings for non-boolean fields.
72
85
 
73
86
  ## Positional arguments
74
87
 
@@ -98,8 +111,58 @@ copy out a.txt b.txt # { _: ["out", "a.txt", "b.txt"] }
98
111
  Help and validation errors label each item with its `.meta({ title })`, such as `<DESTINATION>` and
99
112
  `[FILES...]`. Untitled items are labeled by position (`ARG1`, `ARG2`, ...) and an untitled rest is
100
113
  labeled `ARGS`. Use `z.array` instead of a tuple to accept only variadic values. Without a rest,
101
- additional positional arguments are kept in `extraArgs`. Unknown options are also kept in
102
- `extraArgs`; arguments after `--` are kept in `passthrough`.
114
+ additional positional arguments are rejected by default, as are unknown options.
115
+
116
+ ### Unknown arguments and forwarding
117
+
118
+ A command rejects unknown options and excess positional arguments before its `run` handler or
119
+ schema transforms execute. For ordinary commands, declare the accepted arguments in `args`;
120
+ there is no need to inspect `extraArgs` manually. Errors are `HackshValidationError` instances,
121
+ with each rejected token in `issues[].arg`.
122
+
123
+ Everything after `--` is always collected unchanged in `passthrough`, independently of this policy.
124
+ Use that explicit boundary when forwarding arguments to another executable:
125
+
126
+ ```ts
127
+ const forward = defineCommand({
128
+ name: "git-forward",
129
+ async run({ exec, passthrough }) {
130
+ const { stdout } = await exec("git", passthrough);
131
+ console.log(stdout);
132
+ },
133
+ });
134
+ // git-forward -- status --short
135
+ ```
136
+
137
+ For a wrapper that also accepts undeclared arguments before `--`, set `allowUnknownArgs: true`:
138
+
139
+ ```ts
140
+ const wrapper = defineCommand({
141
+ allowUnknownArgs: true,
142
+ async run({ extraArgs, passthrough, exec }) {
143
+ await exec("some-tool", [...extraArgs, ...passthrough]);
144
+ },
145
+ });
146
+ ```
147
+
148
+ `extraArgs` preserves the order of unknown option tokens and excess positional values. Unknown
149
+ options do not consume a following value: if the schema has an unfilled positional slot, that
150
+ value fills it. Known options are still parsed and validated. Use `--` when a child's arguments
151
+ must not be interpreted by this command. A handler that does not use `passthrough` ignores those
152
+ arguments; commands that require rejecting them can check `passthrough.length` explicitly.
153
+
154
+ `allowUnknownArgs` defaults to `false`, inherits down the selected subcommand route, and can be
155
+ overridden by each child. A child's setting does not change validation of its parent's arguments.
156
+ Definitions are not mutated, so reusing a child under another root does not retain inherited settings.
157
+ Standalone `parseArgs` uses the supplied command's setting and does not resolve a parent route.
158
+
159
+ Once routing succeeds, help and root version output take precedence over argument validation,
160
+ including unknown arguments. Routing errors such as an unknown subcommand still fail. A child's
161
+ undeclared `--version` is an unknown argument, since the version builtin belongs to the root.
162
+
163
+ **Migrating from 0.11.0:** unknown input was previously accepted by default. Add
164
+ `allowUnknownArgs: true` to commands that intentionally use `extraArgs`, or to their shared parent.
165
+ No migration is needed for `--` forwarding.
103
166
 
104
167
  ## Argument options
105
168
 
@@ -304,10 +367,12 @@ multiselect prompt.
304
367
  Prompts are allowed only when the resolved mode is interactive: pretty output outside CI with TTY
305
368
  stdin and stdout. JSON, CI, and non-TTY invocations throw `HackshPromptError` immediately even if a
306
369
  default is available. Its `reason` and `prompt` fields identify why input failed and the prompt's
307
- `kind`, `message`, and select `choices`. Reasons distinguish `non-interactive`, `cancelled`, `eof`,
308
- `overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and Escape cancel;
309
- `runMain` reports cancellation and exits with status 130. Only one prompt may be active in an
310
- invocation. Input listeners and raw terminal mode are restored after every outcome.
370
+ `kind`, `message`, and select `choices`. Reasons distinguish `non-interactive`, `cancelled`,
371
+ `aborted`, `eof`, `overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and
372
+ Escape cancel; `runMain` reports cancellation and exits with status 130. A prompt that starts or is
373
+ waiting after the invocation's `signal` aborts fails with `aborted` and the signal's reason as its
374
+ `cause`. Only one prompt may be active in an invocation. Input listeners and raw terminal mode are
375
+ restored after every outcome.
311
376
  Failed prompts emit a non-visible `closed` prompt state through the same printer before rejecting,
312
377
  so callers that catch cancellation, EOF, input, or printer errors do not leave stale terminal UI.
313
378
 
@@ -323,21 +388,27 @@ positive `columns` value, the renderer conservatively counts explicit lines only
323
388
  Every `run` context has an `exec` function that starts an executable with an argument array. It
324
389
  never uses a shell, so spaces, quotes, `$`, `;`, `|`, and `*` in arguments reach the child
325
390
  unchanged. By default it resolves with the captured output and rejects with `HackshExecError`
326
- when the command fails. Pass `reject: false` to receive failures as results instead.
391
+ when the command fails. Pass `reject: false` to receive failures as results instead. Prefer this
392
+ context API to `spawnSync` or a custom subprocess wrapper inside a command handler so execution
393
+ uses the invocation’s environment, cancellation, and printer.
327
394
 
328
395
  ```ts
329
- const sync = defineCommand({
330
- name: "sync",
396
+ const check = defineCommand({
397
+ name: "check-head",
331
398
  async run({ exec, logger }) {
332
- const { stdout: branch } = await exec("git", ["branch", "--show-current"]);
333
- const upstream = await exec("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {
399
+ const head = await exec("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
334
400
  reject: false,
335
401
  });
336
- if (upstream.status === "failed") {
337
- logger.warn("{branch} has no upstream", { branch });
338
- return;
402
+ if (head.status === "failed") {
403
+ // Only this specific exit means that the revision was not found.
404
+ if (head.reason === "exit-code" && head.exitCode === 1) {
405
+ logger.warn("The repository has no HEAD commit");
406
+ return;
407
+ }
408
+ // Missing executables, cancellation, and other failures must still fail the command.
409
+ throw new Error("Could not inspect HEAD", { cause: head });
339
410
  }
340
- await exec("git", ["pull", "--ff-only"], { cwd: "/path/to/repo", timeout: 60_000 });
411
+ logger.info("HEAD is {commit}", { commit: head.stdout });
341
412
  },
342
413
  });
343
414
  ```
@@ -350,9 +421,24 @@ UTF-8 `stdout` and `stderr` with one final newline removed. A failure's `reason`
350
421
  gives `ExecSuccess`, while `false` or a `boolean` variable gives `ExecResult`, which must be
351
422
  narrowed before reading `exitCode`.
352
423
 
424
+ ### Working with captured output
425
+
426
+ Use `stdout` directly for a single value such as a commit ID or repository path. Do not call
427
+ `.trim()` on paths: surrounding whitespace can be part of the name. `exec` already removes one
428
+ final LF or CRLF; it preserves other whitespace and NUL separators.
429
+
430
+ Interpret structured output according to the child command’s format. If it offers JSON, request
431
+ that format and parse it with `JSON.parse` (and a Zod schema when validation is needed). For Git’s
432
+ `--porcelain -z` output, keep the NUL delimiters and parse the records in a named, application-specific
433
+ helper. Splitting that format on newlines would break paths containing newlines. hacksh does not
434
+ parse Git records or infer whether stdout is text, JSON, or a list.
435
+
436
+ ### Execution options
437
+
353
438
  Options are `cwd`, `env`, `input`, `reject`, `timeout` (milliseconds), `signal` (an
354
439
  `AbortSignal`), `maxBuffer` (bytes per stream, default 100,000,000), and `forceKillAfterDelay`
355
- (milliseconds, default 5,000). `input` is written to stdin and closed; otherwise stdin is empty.
440
+ (milliseconds, default 5,000). Every call also stops with `aborted` when the invocation's `signal`
441
+ aborts. `input` is written to stdin and closed; otherwise stdin is empty.
356
442
  Output beyond `maxBuffer` is truncated to that many bytes. On timeout, abort, or output limit,
357
443
  hacksh sends `SIGTERM` to the child it started and `SIGKILL` after `forceKillAfterDelay`; it does
358
444
  not stop grandchildren, and it does not wait for them to close inherited pipes. Invalid arguments
@@ -571,7 +657,36 @@ Usage failures are `HackshUsageError` instances. Argument failures are
571
657
  `HackshValidationError` instances whose `issues` identify CLI names such as `--repo` or `<PR>`.
572
658
  Prompt failures are `HackshPromptError` instances with structured `reason` and `prompt` fields.
573
659
 
574
- ## Exit status
660
+ ## Cancellation and exit status
661
+
662
+ Every `run` context has a `signal`, an `AbortSignal` for that invocation. `exec` calls and prompts
663
+ already follow it; pass it to `fetch` and other APIs that accept one. Cancellation is cooperative:
664
+ hacksh does not stop code that ignores the signal.
665
+
666
+ ```ts
667
+ import { defineCommand, HackshExitError, runCommand } from "hacksh";
668
+
669
+ const check = defineCommand({
670
+ name: "check",
671
+ async run({ signal }) {
672
+ const response = await fetch("https://example.com/health", { signal });
673
+ if (!response.ok) throw new HackshExitError(2, `Health check returned ${response.status}`);
674
+ },
675
+ });
676
+
677
+ const controller = new AbortController();
678
+ await runCommand(check, [], { signal: controller.signal });
679
+ ```
680
+
681
+ `runCommand` accepts a `signal` option and never listens for process signals or exits. If that
682
+ signal is already aborted, `runCommand` rejects with its reason without calling `run`; aborting it
683
+ later aborts the context's `signal`.
684
+
685
+ `runMain` listens for `SIGINT` and `SIGTERM` while it runs and removes those listeners before it
686
+ returns or exits. The first signal aborts the invocation with an `AbortError` named after the
687
+ signal; once `run` settles, output is drained and the process exits with 130 for `SIGINT` or 143 for
688
+ `SIGTERM`, even if `run` threw. A second signal while `run` is still pending exits immediately with
689
+ that signal's status.
575
690
 
576
691
  Throw `HackshExitError(exitCode, message?)` to choose the exit status, an integer from 0 to 255.
577
692
  `runMain` prints a non-empty message as an ordinary error and prints nothing when the message is
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ declare class HackshValidationError extends HackshUsageError {
14
14
  constructor(command: AnyCommandDef, issues: ReadonlyArray<HackshIssue>);
15
15
  }
16
16
  type PromptKind = "select" | "confirm" | "text";
17
- type HackshPromptErrorReason = "non-interactive" | "cancelled" | "eof" | "overlapping" | "invalid-options" | "input-error" | "printer-error";
17
+ type HackshPromptErrorReason = "non-interactive" | "cancelled" | "aborted" | "eof" | "overlapping" | "invalid-options" | "input-error" | "printer-error";
18
18
  interface PromptErrorDetails {
19
19
  kind: PromptKind;
20
20
  message: string;
@@ -372,12 +372,15 @@ interface CommandContext<S extends ArgsSchema> {
372
372
  extraArgs: string[];
373
373
  logger: Logger;
374
374
  exec: Exec;
375
+ signal: AbortSignal;
375
376
  }
376
377
  interface CommandDef<S extends ArgsSchema = EmptyArgs> {
377
378
  name?: string;
378
379
  version?: string;
379
380
  description?: string;
380
381
  hidden?: boolean;
382
+ /** Keep unknown options and excess positionals in extraArgs. Inherits from the parent; defaults to false. */
383
+ allowUnknownArgs?: boolean;
381
384
  args?: S;
382
385
  argOptions?: ArgOptionsMap<S>;
383
386
  subCommands?: Record<string, CommandDef<ArgsSchema>>;
@@ -408,6 +411,7 @@ declare function createPrettyPrinter(options: PrettyPrinterOptions): Printer;
408
411
  //#region src/run.d.ts
409
412
  interface RunCommandOptions extends OutputOptions {
410
413
  printer?: Printer;
414
+ signal?: AbortSignal;
411
415
  showUsage?: ShowUsageCallback;
412
416
  }
413
417
  declare function runCommand<S extends ArgsSchema>(root: CommandDef<S>, argv: string[], options?: RunCommandOptions): Promise<unknown>;
package/dist/index.js CHANGED
@@ -108,6 +108,7 @@ function schemaShape(schema) {
108
108
  //#endregion
109
109
  //#region src/command.ts
110
110
  function defineCommand(def) {
111
+ if (def.allowUnknownArgs !== void 0 && typeof def.allowUnknownArgs !== "boolean") throw new TypeError("allowUnknownArgs must be a boolean");
111
112
  const shape = def.args ? schemaShape(def.args) : {};
112
113
  const positionals = readPositionals(shape);
113
114
  if (positionals) {
@@ -611,6 +612,10 @@ async function parseCommandArgs(command, rawArgs, tokenized = tokenizeCommandArg
611
612
  else if (token.kind === "unknown-option") extraArgs.push(token.value);
612
613
  else if (positionals && (positionals.rest || positionalValues.length < positionals.items.length)) positionalValues.push(token.value);
613
614
  else extraArgs.push(token.value);
615
+ if (!command.allowUnknownArgs && extraArgs.length > 0) throw new HackshValidationError(command, extraArgs.map((arg) => ({
616
+ arg,
617
+ message: "unexpected argument"
618
+ })));
614
619
  const envSources = /* @__PURE__ */ new Map();
615
620
  if (env && command.args) {
616
621
  const shape = schemaShape(command.args);
@@ -767,7 +772,7 @@ function redactError(error, secrets) {
767
772
  function observedError(plan, error) {
768
773
  return plan.redact.length === 0 ? error : redactError(error, plan.redact);
769
774
  }
770
- function parsePlan(file, args, options, baseEnv) {
775
+ function parsePlan(file, args, options, context) {
771
776
  if (typeof file !== "string" || file === "") throw new TypeError("Exec file must be a non-empty string");
772
777
  assertNoNul(file, "Exec file");
773
778
  const argv = args === void 0 ? [] : args;
@@ -797,11 +802,11 @@ function parsePlan(file, args, options, baseEnv) {
797
802
  return {
798
803
  command,
799
804
  reported: redactCommand(command, redact),
800
- env: mergeEnvironment(baseEnv, raw.env),
805
+ env: mergeEnvironment(context.env, raw.env),
801
806
  input,
802
807
  reject: reject ?? true,
803
808
  timeout: timeout === void 0 ? void 0 : readInteger(timeout, "Exec option `timeout`", 1, MAX_DELAY),
804
- signal,
809
+ signal: signal === void 0 ? context.signal : AbortSignal.any([context.signal, signal]),
805
810
  maxBuffer: maxBuffer === void 0 ? 1e8 : readInteger(maxBuffer, "Exec option `maxBuffer`", 1, Number.MAX_SAFE_INTEGER),
806
811
  forceKillAfterDelay: forceKillAfterDelay === void 0 ? 5e3 : readInteger(forceKillAfterDelay, "Exec option `forceKillAfterDelay`", 0, MAX_DELAY),
807
812
  stdin,
@@ -912,7 +917,7 @@ function runPlan(plan) {
912
917
  spawnError: void 0,
913
918
  exit: void 0
914
919
  };
915
- if (plan.signal?.aborted) {
920
+ if (plan.signal.aborted) {
916
921
  outcome.cause = "aborted";
917
922
  return Promise.resolve(toResult(plan.reported, outcome, "", ""));
918
923
  }
@@ -931,7 +936,7 @@ function runPlan(plan) {
931
936
  settled = true;
932
937
  clearTimeout(timeoutTimer);
933
938
  clearTimeout(killTimer);
934
- plan.signal?.removeEventListener("abort", onAbort);
939
+ plan.signal.removeEventListener("abort", onAbort);
935
940
  resolve(toResult(plan.reported, outcome, readStdout(), readStderr()));
936
941
  };
937
942
  const release = () => {
@@ -953,7 +958,7 @@ function runPlan(plan) {
953
958
  const readStdout = stdout ? collect(stdout, plan.maxBuffer, () => terminate("output-limit")) : () => "";
954
959
  const readStderr = stderr ? collect(stderr, plan.maxBuffer, () => terminate("output-limit")) : () => "";
955
960
  const timeoutTimer = plan.timeout === void 0 ? void 0 : setTimeout(() => terminate("timeout"), plan.timeout);
956
- plan.signal?.addEventListener("abort", onAbort, { once: true });
961
+ plan.signal.addEventListener("abort", onAbort, { once: true });
957
962
  child.on("error", (error) => {
958
963
  if (child.pid !== void 0) return;
959
964
  outcome.spawnError = observedError(plan, error);
@@ -1013,8 +1018,8 @@ function blockedReason(plan, context, stdio) {
1013
1018
  if (plan.interactive && !context.mode.interactive) return "non-interactive";
1014
1019
  }
1015
1020
  async function execute(file, args, options, context) {
1016
- const plan = parsePlan(file, args, options, context.env);
1017
- if (plan.signal?.aborted) return complete(plan, emptyFailure(plan, "aborted"));
1021
+ const plan = parsePlan(file, args, options, context);
1022
+ if (plan.signal.aborted) return complete(plan, emptyFailure(plan, "aborted"));
1018
1023
  const stdio = commandStdio(plan);
1019
1024
  const blocked = blockedReason(plan, context, stdio);
1020
1025
  if (blocked) return complete(plan, emptyFailure(plan, blocked));
@@ -1503,11 +1508,13 @@ var PromptController = class {
1503
1508
  #printEvent;
1504
1509
  #bufferedKeys = [];
1505
1510
  #terminal;
1511
+ #signal;
1506
1512
  constructor(options) {
1507
1513
  this.#interactive = options.interactive;
1508
1514
  this.#inputValue = options.input;
1509
1515
  this.#printEvent = (event) => options.print(event);
1510
1516
  this.#terminal = options.terminal;
1517
+ this.#signal = options.signal;
1511
1518
  }
1512
1519
  async #print(state, prompt) {
1513
1520
  try {
@@ -1521,6 +1528,7 @@ var PromptController = class {
1521
1528
  }
1522
1529
  async #run(prompt, initial, accept, update, submitted, closed) {
1523
1530
  if (!this.#interactive) throw promptError("non-interactive", prompt, `Cannot request ${prompt.kind} input in non-interactive mode: ${prompt.message}`);
1531
+ if (this.#signal.aborted) throw promptError("aborted", prompt, "Prompt aborted", this.#signal.reason);
1524
1532
  const input = asInput(this.#inputValue, prompt);
1525
1533
  if (!this.#terminal.acquire("prompt")) throw promptError("overlapping", prompt, this.#terminal.owner === "exec" ? "A command is using the terminal" : "Another prompt is already active");
1526
1534
  let originallyFlowing = false;
@@ -1607,6 +1615,7 @@ var PromptController = class {
1607
1615
  };
1608
1616
  const onEnd = () => fail("eof", "Prompt input ended before a value was submitted");
1609
1617
  const onError = (cause) => fail("input-error", "Prompt input failed", cause);
1618
+ const onAbort = () => fail("aborted", "Prompt aborted", this.#signal.reason);
1610
1619
  const onData = (chunk) => {
1611
1620
  if (typeof chunk === "string" || chunk instanceof Uint8Array) keypressInput.write(chunk);
1612
1621
  else fail("input-error", "Prompt input produced a non-byte value");
@@ -1618,12 +1627,15 @@ var PromptController = class {
1618
1627
  input.removeListener("end", onEnd);
1619
1628
  input.removeListener("close", onEnd);
1620
1629
  input.removeListener("error", onError);
1630
+ this.#signal.removeEventListener("abort", onAbort);
1621
1631
  };
1622
1632
  keypressInput.on("keypress", onKeypress);
1623
1633
  input.on("data", onData);
1624
1634
  input.on("end", onEnd);
1625
1635
  input.on("close", onEnd);
1626
1636
  input.on("error", onError);
1637
+ this.#signal.addEventListener("abort", onAbort, { once: true });
1638
+ if (this.#signal.aborted) onAbort();
1627
1639
  input.resume();
1628
1640
  drainKeys().catch((cause) => fail("input-error", "Prompt input could not be processed", cause));
1629
1641
  })
@@ -1808,7 +1820,8 @@ function createLogger(printer, category, options) {
1808
1820
  interactive: options.interactive,
1809
1821
  input: options.input,
1810
1822
  print: (event) => options.printPrompt(event),
1811
- terminal: options.terminal
1823
+ terminal: options.terminal,
1824
+ signal: options.signal
1812
1825
  });
1813
1826
  return createBoundLogger(printer, [...category], {}, prompts);
1814
1827
  }
@@ -2059,15 +2072,17 @@ function resolveCommand(root, rawArgs) {
2059
2072
  const parentPath = commandEnvPath(command);
2060
2073
  command = {
2061
2074
  ...child,
2062
- name: child.name ?? key
2075
+ name: child.name ?? key,
2076
+ allowUnknownArgs: child.allowUnknownArgs ?? command.allowUnknownArgs
2063
2077
  };
2064
2078
  registerCommandPath(command, parentPath === void 0 ? void 0 : [...parentPath, key]);
2065
2079
  remaining = remaining.slice(index + 1);
2066
2080
  }
2067
2081
  }
2068
- async function executeCommand(root, argv, displayUsage, printer, loggerOptions, execContext, fallbackVersion = async () => void 0) {
2082
+ async function executeCommand(root, argv, displayUsage, output, env, signal, fallbackVersion = async () => void 0) {
2069
2083
  if (root.args && "version" in schemaShape(root.args)) throw new TypeError("Root option `version` conflicts with the builtin --version");
2070
- const env = execContext.env ?? process.env;
2084
+ const { printer } = output;
2085
+ const argEnv = env ?? process.env;
2071
2086
  const resolved = resolveCommand(root, argv);
2072
2087
  if (resolved.segments[0]?.tokenized.version) {
2073
2088
  const version = root.version ?? await fallbackVersion() ?? "";
@@ -2081,16 +2096,33 @@ async function executeCommand(root, argv, displayUsage, printer, loggerOptions,
2081
2096
  await printUsage(displayUsage, resolved.command, printer, "stdout");
2082
2097
  return;
2083
2098
  }
2084
- for (const parent of resolved.parents) await parseCommandArgs(parent.command, parent.rawArgs, parent.tokenized, env, commandEnvPath(parent.command));
2085
- const parsed = await parseCommandArgs(resolved.command, resolved.rawArgs, resolved.tokenized, env, commandEnvPath(resolved.command));
2099
+ for (const parent of resolved.parents) await parseCommandArgs(parent.command, parent.rawArgs, parent.tokenized, argEnv, commandEnvPath(parent.command));
2100
+ const parsed = await parseCommandArgs(resolved.command, resolved.rawArgs, resolved.tokenized, argEnv, commandEnvPath(resolved.command));
2086
2101
  if (!resolved.command.run) throw new HackshUsageError("No command specified", resolved.command);
2102
+ signal.throwIfAborted();
2087
2103
  const category = resolved.segments.flatMap(({ command }) => command.name === void 0 ? [] : [command.name]);
2088
2104
  return resolved.command.run({
2089
2105
  ...parsed,
2090
2106
  cmd: resolved.command,
2091
2107
  rawArgs: resolved.rawArgs,
2092
- logger: createLogger(printer, category, loggerOptions),
2093
- exec: createExec(execContext)
2108
+ logger: createLogger(printer, category, {
2109
+ interactive: output.interactive,
2110
+ input: output.input,
2111
+ printPrompt: (event) => output.printChecked(event),
2112
+ terminal: output.terminal,
2113
+ signal
2114
+ }),
2115
+ exec: createExec({
2116
+ env,
2117
+ mode: {
2118
+ format: output.format,
2119
+ interactive: output.interactive
2120
+ },
2121
+ print: (event) => printer.print(event),
2122
+ terminal: output.terminal,
2123
+ signal
2124
+ }),
2125
+ signal
2094
2126
  });
2095
2127
  }
2096
2128
  function printUsage(displayUsage, command, printer, stream) {
@@ -2117,26 +2149,10 @@ function prepareOutput(options, env) {
2117
2149
  terminal: new TerminalLock()
2118
2150
  };
2119
2151
  }
2120
- function invocationExec(output, env) {
2121
- return {
2122
- env,
2123
- mode: {
2124
- format: output.format,
2125
- interactive: output.interactive
2126
- },
2127
- print: (event) => output.printer.print(event),
2128
- terminal: output.terminal
2129
- };
2130
- }
2131
2152
  async function runCommand(root, argv, options = {}) {
2132
2153
  const output = prepareOutput(options, options.env ?? process.env);
2133
2154
  try {
2134
- return await executeCommand(root, argv, options.showUsage ?? showUsage, output.printer, {
2135
- interactive: output.interactive,
2136
- input: output.input,
2137
- printPrompt: (event) => output.printChecked(event),
2138
- terminal: output.terminal
2139
- }, invocationExec(output, options.env));
2155
+ return await executeCommand(root, argv, options.showUsage ?? showUsage, output, options.env, options.signal ?? new AbortController().signal);
2140
2156
  } finally {
2141
2157
  await output.drain();
2142
2158
  }
@@ -2152,6 +2168,30 @@ function commandErrorExitCode(error) {
2152
2168
  if (error instanceof HackshExitError) return error.exitCode;
2153
2169
  return error instanceof HackshPromptError && error.reason === "cancelled" ? 130 : 1;
2154
2170
  }
2171
+ const SIGNAL_EXIT_CODES = {
2172
+ SIGINT: 130,
2173
+ SIGTERM: 143
2174
+ };
2175
+ function listenForExitSignals() {
2176
+ const controller = new AbortController();
2177
+ let exitCode;
2178
+ const listeners = Object.entries(SIGNAL_EXIT_CODES).map(([signal, code]) => {
2179
+ const listener = () => {
2180
+ if (exitCode !== void 0) process.exit(code);
2181
+ exitCode = code;
2182
+ controller.abort(new DOMException(`Received ${signal}`, "AbortError"));
2183
+ };
2184
+ process.on(signal, listener);
2185
+ return [signal, listener];
2186
+ });
2187
+ return {
2188
+ signal: controller.signal,
2189
+ exitCode: () => exitCode,
2190
+ release() {
2191
+ for (const [signal, listener] of listeners) process.removeListener(signal, listener);
2192
+ }
2193
+ };
2194
+ }
2155
2195
  async function reportOutputSetupError(error, options) {
2156
2196
  if (options.onError) {
2157
2197
  await options.onError(error);
@@ -2183,24 +2223,28 @@ async function runMain(command, options = {}) {
2183
2223
  } catch (error) {
2184
2224
  return reportOutputSetupError(error, options);
2185
2225
  }
2226
+ const signals = listenForExitSignals();
2227
+ const exit = (code) => {
2228
+ signals.release();
2229
+ return drainAndExit(output, signals.exitCode() ?? code);
2230
+ };
2186
2231
  try {
2187
- await executeCommand(command, options.argv ?? process.argv.slice(2), displayUsage, output.printer, {
2188
- interactive: output.interactive,
2189
- input: output.input,
2190
- printPrompt: (event) => output.printChecked(event),
2191
- terminal: output.terminal
2192
- }, invocationExec(output, options.env), () => findPackageVersion(process.argv[1]));
2232
+ await executeCommand(command, options.argv ?? process.argv.slice(2), displayUsage, output, options.env, signals.signal, () => findPackageVersion(process.argv[1]));
2193
2233
  } catch (error) {
2194
2234
  if (options.onError) {
2195
2235
  await options.onError(error);
2196
- return drainAndExit(output, commandErrorExitCode(error));
2236
+ return exit(commandErrorExitCode(error));
2197
2237
  }
2198
2238
  try {
2199
2239
  await reportCommandError(error, output, displayUsage);
2200
2240
  } finally {
2201
- await drainAndExit(output, commandErrorExitCode(error));
2241
+ await exit(commandErrorExitCode(error));
2202
2242
  }
2243
+ } finally {
2244
+ signals.release();
2203
2245
  }
2246
+ const signalExitCode = signals.exitCode();
2247
+ if (signalExitCode !== void 0) return drainAndExit(output, signalExitCode);
2204
2248
  await output.drain();
2205
2249
  }
2206
2250
  function createMain(command, options = {}) {