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 +150 -35
- package/dist/index.d.ts +5 -1
- package/dist/index.js +85 -41
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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: "
|
|
34
|
+
name: "git-summary",
|
|
34
35
|
version: "0.1.0",
|
|
35
|
-
description: "
|
|
36
|
+
description: "Show a commit summary",
|
|
36
37
|
args: z.object({
|
|
37
|
-
_: z.tuple([
|
|
38
|
-
|
|
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: "
|
|
44
|
-
async run({ args,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
71
|
-
`--repo
|
|
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
|
|
102
|
-
|
|
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`,
|
|
308
|
-
`overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and
|
|
309
|
-
`runMain` reports cancellation and exits with status 130.
|
|
310
|
-
invocation
|
|
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
|
|
330
|
-
name: "
|
|
396
|
+
const check = defineCommand({
|
|
397
|
+
name: "check-head",
|
|
331
398
|
async run({ exec, logger }) {
|
|
332
|
-
const
|
|
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 (
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
##
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1017
|
-
if (plan.signal
|
|
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,
|
|
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
|
|
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,
|
|
2085
|
-
const parsed = await parseCommandArgs(resolved.command, resolved.rawArgs, resolved.tokenized,
|
|
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,
|
|
2093
|
-
|
|
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.
|
|
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.
|
|
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
|
|
2236
|
+
return exit(commandErrorExitCode(error));
|
|
2197
2237
|
}
|
|
2198
2238
|
try {
|
|
2199
2239
|
await reportCommandError(error, output, displayUsage);
|
|
2200
2240
|
} finally {
|
|
2201
|
-
await
|
|
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 = {}) {
|