@mypolis.eu/command 0.1.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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +196 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MyPolis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @mypolis.eu/command
|
|
2
|
+
|
|
3
|
+
Lazy, terminal-first commands without a shell. ESM JavaScript with TypeScript declarations, for Node.js 24+. No runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @mypolis.eu/command
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import {$, CommandError} from '@mypolis.eu/command';
|
|
11
|
+
|
|
12
|
+
// Inherit the terminal, including interactive stdin. Resolves to undefined.
|
|
13
|
+
await $`git status`;
|
|
14
|
+
|
|
15
|
+
// Optional timeout, configured before execution; no timeout by default.
|
|
16
|
+
await $`git fetch`.timeout(30_000);
|
|
17
|
+
|
|
18
|
+
// Capture raw UTF-8 stdout without trimming whitespace or trailing newlines.
|
|
19
|
+
const branch = await $`git branch --show-current`.text();
|
|
20
|
+
const {stdout, stderr} = await $`git status --short`.output();
|
|
21
|
+
|
|
22
|
+
// Each interpolation is a whole argument; arrays expand into arguments.
|
|
23
|
+
const paths = ['notes with spaces.md', 'draft.md'];
|
|
24
|
+
await $`git diff -- ${paths}`;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Commands inherit the current working directory and environment. Awaiting a command streams stdout and stderr to the terminal. `.text()` and `.output()` instead capture **both** streams; stdin remains inherited. `.text()` returns only stdout, while `.output()` returns both streams.
|
|
28
|
+
|
|
29
|
+
## Arguments, not Bash
|
|
30
|
+
|
|
31
|
+
Literal text is split on whitespace. Interpolate strings or numbers for whole arguments; interpolate arrays of strings or numbers for multiple arguments. Empty strings are preserved and empty arrays add no arguments. Separate interpolations from adjacent arguments with whitespace. To combine a prefix and value, interpolate the complete string:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const count = 5;
|
|
35
|
+
await $`git log ${`--max-count=${count}`}`;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Quoting, pipes, redirects, globs, environment expansion and other shell syntax in literal text are rejected. Characters in interpolated values are passed literally, not evaluated by a shell. This prevents shell expansion, not interpretation of options by the executable: use `--` where supported when passing untrusted filenames. Choose the executable and its arguments appropriately.
|
|
39
|
+
|
|
40
|
+
## Lazy execution and reuse
|
|
41
|
+
|
|
42
|
+
Construction does not spawn a process. Awaiting or calling `.then()`, `.text()` or `.output()` starts one shared execution. Repeated use returns the original result or failure, not a new process. `.text()` and `.output()` can share the same capture execution. Switching between inherited and captured stdio after starting is rejected. `.timeout(ms)` must be called before starting;
|
|
43
|
+
|
|
44
|
+
## Failures and limits
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
try {
|
|
48
|
+
await $`git status`.timeout(5_000).output();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!(error instanceof CommandError)) throw error;
|
|
51
|
+
console.error(error.reason, error.exitCode, error.signal);
|
|
52
|
+
console.error(error.stdout, error.stderr);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT © MyPolis
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
type Argument = string | number;
|
|
2
|
+
type Values = (Argument | readonly Argument[])[];
|
|
3
|
+
type Output = {
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
};
|
|
7
|
+
type FailureReason = "spawn" | "exit" | "signal" | "timeout" | "overflow";
|
|
8
|
+
export declare class CommandError extends Error {
|
|
9
|
+
readonly reason: FailureReason;
|
|
10
|
+
readonly exitCode: number | null;
|
|
11
|
+
readonly signal: NodeJS.Signals | null;
|
|
12
|
+
readonly code: string | undefined;
|
|
13
|
+
readonly stdout: string | undefined;
|
|
14
|
+
readonly stderr: string | undefined;
|
|
15
|
+
constructor(message: string, details: {
|
|
16
|
+
reason: FailureReason;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
signal: NodeJS.Signals | null;
|
|
19
|
+
code?: string;
|
|
20
|
+
output?: Output;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
type Command = PromiseLike<undefined> & {
|
|
24
|
+
timeout(ms: number): Command;
|
|
25
|
+
text(): Promise<string>;
|
|
26
|
+
output(): Promise<Output>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Lazy, shell-free command; awaiting inherits cwd, environment and stdio.
|
|
30
|
+
* Interpolations occupy whole arguments; arrays expand into multiple arguments.
|
|
31
|
+
* .text()/.output() capture raw UTF-8 (1 MiB per stream); .timeout(ms) configures before execution.
|
|
32
|
+
* Execution is shared; capture mode cannot change and timeout is frozen after starting.
|
|
33
|
+
* Timeout/overflow sends SIGTERM, then SIGKILL after 500 ms; settlement always waits for close.
|
|
34
|
+
*/
|
|
35
|
+
export declare function $(strings: TemplateStringsArray, ...values: Values): Command;
|
|
36
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const CAPTURE_LIMIT = 1024 * 1024; // Bytes per stream; overflow terminates rather than silently truncating.
|
|
3
|
+
const TERMINATION_GRACE_MS = 500;
|
|
4
|
+
export class CommandError extends Error {
|
|
5
|
+
constructor(message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CommandError";
|
|
8
|
+
this.reason = details.reason;
|
|
9
|
+
this.exitCode = details.exitCode;
|
|
10
|
+
this.signal = details.signal;
|
|
11
|
+
this.code = details.code;
|
|
12
|
+
this.stdout = details.output?.stdout;
|
|
13
|
+
this.stderr = details.output?.stderr;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Lazy, shell-free command; awaiting inherits cwd, environment and stdio.
|
|
18
|
+
* Interpolations occupy whole arguments; arrays expand into multiple arguments.
|
|
19
|
+
* .text()/.output() capture raw UTF-8 (1 MiB per stream); .timeout(ms) configures before execution.
|
|
20
|
+
* Execution is shared; capture mode cannot change and timeout is frozen after starting.
|
|
21
|
+
* Timeout/overflow sends SIGTERM, then SIGKILL after 500 ms; settlement always waits for close.
|
|
22
|
+
*/
|
|
23
|
+
export function $(strings, ...values) {
|
|
24
|
+
let execution;
|
|
25
|
+
const options = {};
|
|
26
|
+
const start = (capture) => {
|
|
27
|
+
if (execution) {
|
|
28
|
+
if (options.capture !== capture)
|
|
29
|
+
throw new Error("Command capture mode cannot change after execution starts.");
|
|
30
|
+
return execution;
|
|
31
|
+
}
|
|
32
|
+
options.capture = capture;
|
|
33
|
+
execution = execute(options, strings, values);
|
|
34
|
+
return execution;
|
|
35
|
+
};
|
|
36
|
+
const command = {
|
|
37
|
+
// biome-ignore lint/suspicious/noThenProperty: Awaiting this lazy command intentionally starts shared execution.
|
|
38
|
+
then(onfulfilled, onrejected) {
|
|
39
|
+
return start(false)
|
|
40
|
+
.then(() => undefined)
|
|
41
|
+
.then(onfulfilled, onrejected);
|
|
42
|
+
},
|
|
43
|
+
timeout(ms) {
|
|
44
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) {
|
|
45
|
+
throw new TypeError("Timeout must be finite, positive and at most 2147483647 ms.");
|
|
46
|
+
}
|
|
47
|
+
if (execution)
|
|
48
|
+
throw new Error("Command timeout cannot change after execution starts.");
|
|
49
|
+
options.timeoutMs = ms;
|
|
50
|
+
return command;
|
|
51
|
+
},
|
|
52
|
+
async text() {
|
|
53
|
+
const { stdout } = await start(true);
|
|
54
|
+
return stdout;
|
|
55
|
+
},
|
|
56
|
+
output() {
|
|
57
|
+
return start(true);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return command;
|
|
61
|
+
}
|
|
62
|
+
async function execute(options, strings, values) {
|
|
63
|
+
const args = [];
|
|
64
|
+
for (const [index, text] of strings.entries()) {
|
|
65
|
+
if (/["'`\\$|&;<>*?()[\]{}~#\0]/.test(text)) {
|
|
66
|
+
throw new Error("Shell syntax is not supported. Interpolate argument values instead.");
|
|
67
|
+
}
|
|
68
|
+
if ((index > 0 && text !== "" && !/^\s/.test(text)) ||
|
|
69
|
+
(index < values.length && text !== "" && !/\s$/.test(text)) ||
|
|
70
|
+
(index > 0 && index < values.length && text === "")) {
|
|
71
|
+
throw new Error("Interpolations must be separated from other arguments by whitespace.");
|
|
72
|
+
}
|
|
73
|
+
args.push(...text.split(/\s+/).filter(Boolean));
|
|
74
|
+
if (index < values.length) {
|
|
75
|
+
const value = values[index];
|
|
76
|
+
const items = Array.isArray(value) ? value : [value];
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
if (typeof item !== "string" && typeof item !== "number") {
|
|
79
|
+
throw new TypeError("Arguments must be strings or numbers.");
|
|
80
|
+
}
|
|
81
|
+
if (String(item).includes("\0"))
|
|
82
|
+
throw new TypeError("Arguments must not contain null bytes.");
|
|
83
|
+
args.push(String(item));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const command = args.shift();
|
|
88
|
+
if (!command)
|
|
89
|
+
throw new Error("A command is required.");
|
|
90
|
+
let child;
|
|
91
|
+
try {
|
|
92
|
+
child = spawn(command, args, {
|
|
93
|
+
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
94
|
+
shell: false
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const { code } = error;
|
|
99
|
+
throw new CommandError(`Command could not be spawned (${code ?? "process error"}).`, {
|
|
100
|
+
reason: "spawn",
|
|
101
|
+
exitCode: null,
|
|
102
|
+
signal: null,
|
|
103
|
+
code,
|
|
104
|
+
output: options.capture ? { stdout: "", stderr: "" } : undefined
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
let interrupted;
|
|
109
|
+
let failure;
|
|
110
|
+
let stopped;
|
|
111
|
+
let overflowStream;
|
|
112
|
+
let timeout;
|
|
113
|
+
let escalation;
|
|
114
|
+
const chunks = { stdout: [], stderr: [] };
|
|
115
|
+
const bytes = { stdout: 0, stderr: 0 };
|
|
116
|
+
const terminate = (reason) => {
|
|
117
|
+
if (stopped)
|
|
118
|
+
return;
|
|
119
|
+
stopped = reason;
|
|
120
|
+
child.kill("SIGTERM");
|
|
121
|
+
escalation = setTimeout(() => child.kill("SIGKILL"), TERMINATION_GRACE_MS);
|
|
122
|
+
};
|
|
123
|
+
const collect = (stream, chunk) => {
|
|
124
|
+
const remaining = CAPTURE_LIMIT - bytes[stream];
|
|
125
|
+
if (remaining > 0) {
|
|
126
|
+
const retained = Buffer.from(chunk.subarray(0, remaining));
|
|
127
|
+
chunks[stream].push(retained);
|
|
128
|
+
bytes[stream] += retained.length;
|
|
129
|
+
}
|
|
130
|
+
if (chunk.length > remaining) {
|
|
131
|
+
overflowStream ??= stream;
|
|
132
|
+
terminate("overflow");
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const onStdout = (chunk) => collect("stdout", chunk);
|
|
136
|
+
const onStderr = (chunk) => collect("stderr", chunk);
|
|
137
|
+
const onError = (error) => {
|
|
138
|
+
failure ??= error;
|
|
139
|
+
};
|
|
140
|
+
const forwardSignal = (signal) => {
|
|
141
|
+
interrupted ??= signal;
|
|
142
|
+
child.kill(signal);
|
|
143
|
+
};
|
|
144
|
+
const onInterrupt = () => forwardSignal("SIGINT");
|
|
145
|
+
const onTerminate = () => forwardSignal("SIGTERM");
|
|
146
|
+
process.on("SIGINT", onInterrupt);
|
|
147
|
+
process.on("SIGTERM", onTerminate);
|
|
148
|
+
child.on("error", onError);
|
|
149
|
+
child.stdout?.on("data", onStdout);
|
|
150
|
+
child.stderr?.on("data", onStderr);
|
|
151
|
+
if (options.timeoutMs !== undefined)
|
|
152
|
+
timeout = setTimeout(() => terminate("timeout"), options.timeoutMs);
|
|
153
|
+
child.once("close", (exitCode, signal) => {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
clearTimeout(escalation);
|
|
156
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
157
|
+
process.removeListener("SIGTERM", onTerminate);
|
|
158
|
+
child.removeListener("error", onError);
|
|
159
|
+
child.stdout?.removeListener("data", onStdout);
|
|
160
|
+
child.stderr?.removeListener("data", onStderr);
|
|
161
|
+
let output;
|
|
162
|
+
if (options.capture)
|
|
163
|
+
output = {
|
|
164
|
+
stdout: Buffer.concat(chunks.stdout).toString("utf8"),
|
|
165
|
+
stderr: Buffer.concat(chunks.stderr).toString("utf8")
|
|
166
|
+
};
|
|
167
|
+
let reason;
|
|
168
|
+
let message;
|
|
169
|
+
if (stopped === "timeout") {
|
|
170
|
+
reason = stopped;
|
|
171
|
+
message = `Command timed out after ${options.timeoutMs} ms.`;
|
|
172
|
+
}
|
|
173
|
+
else if (stopped === "overflow") {
|
|
174
|
+
reason = stopped;
|
|
175
|
+
message = `Command ${overflowStream} exceeded the ${CAPTURE_LIMIT}-byte capture limit.`;
|
|
176
|
+
}
|
|
177
|
+
else if (failure) {
|
|
178
|
+
reason = "spawn";
|
|
179
|
+
message = `Command failed (${failure.code ?? "process error"}).`;
|
|
180
|
+
}
|
|
181
|
+
else if (interrupted || signal) {
|
|
182
|
+
reason = "signal";
|
|
183
|
+
message = `Command failed (${interrupted ?? signal}).`;
|
|
184
|
+
}
|
|
185
|
+
else if (exitCode !== 0) {
|
|
186
|
+
reason = "exit";
|
|
187
|
+
message = `Command failed (exit ${exitCode}).`;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
resolve(output ?? { stdout: "", stderr: "" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
reject(new CommandError(message, { reason, exitCode, signal: interrupted ?? signal, code: failure?.code, output }));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mypolis.eu/command",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lazy, terminal-first, shell-free commands for Node.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "MyPolis",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=24"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org/"
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist/index.js", "dist/index.d.ts", "README.md", "LICENSE"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
|
+
"prepack": "npm run build",
|
|
19
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "24.13.2",
|
|
29
|
+
"typescript": "5.9.3"
|
|
30
|
+
}
|
|
31
|
+
}
|