@mnaimfaizy/agent-kit 1.0.0-alpha.12 → 1.0.0-beta.1

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,19 +1,22 @@
1
1
  # @mnaimfaizy/agent-kit
2
2
 
3
- The command-line companion to [Agent-kit](https://github.com/mnaimfaizy/agent-kit): checks a Consumer repository against the Agent-kit tag its Callers pin.
3
+ The command-line companion to [Agent-kit](https://github.com/mnaimfaizy/agent-kit): adopts Agent-kit in a Consumer repository, checks it against the Agent-kit tag its Callers pin, and upgrades it to another tag.
4
4
 
5
5
  ```bash
6
- npx @mnaimfaizy/agent-kit doctor # local run with your own gh login
7
- npx @mnaimfaizy/agent-kit doctor --ci # Kit-owned paths and pins only
8
- npx @mnaimfaizy/agent-kit doctor --strict # warnings fail too
6
+ npx @mnaimfaizy/agent-kit init --tag <tag> # first adoption (asks first)
7
+ npx @mnaimfaizy/agent-kit doctor # local run with your own gh login
8
+ npx @mnaimfaizy/agent-kit doctor --ci # Kit-owned paths and pins only
9
+ npx @mnaimfaizy/agent-kit doctor --strict # warnings fail too
10
+ npx @mnaimfaizy/agent-kit upgrade --tag <new-tag> # move to another tag (asks first)
9
11
  ```
10
12
 
11
13
  - The package holds only code. Kit files are read from the git tag your `uses:` pins name, never bundled.
12
14
  - `doctor` is read-only. It runs git in your checkout without hooks or configured filter commands, never changes files or GitHub settings, and for anything to fix prints a POSIX-shell `gh` command for you to run.
13
15
  - No runtime dependencies. Needs Node.js 22 or later, `git`, and (outside `--ci`) `gh` logged in with admin on the repository.
14
16
  - Tags before `v1.0.0-alpha.11` have no `kit-owned-paths.txt`, and the CLI refuses them: adopt those by hand with that tag's `docs/adopt.md`.
15
- - `init` and `upgrade` are not in this version yet.
17
+ - `upgrade --tag <vY>` reads the current tag from your Agent-kit `uses:` pins (they must agree), replaces each Kit-owned path vY lists (delete, then copy), deletes paths only the old tag listed, rewrites only the `@<tag>` of those pins, prints the `examples/` diff between the two tags for you to apply by hand, and then runs the `doctor --ci` checks. Every refusal is checked before anything is written. It refuses a path with uncommitted changes unless `--force`, refuses symbolic links in or above a Kit-owned path, and copies only regular and executable files from the tag. Ignored files inside a replaced path are deleted with it, and listed first. After a Dependabot pin bump, when the pins already name vY, add `--from <vX>` to also delete paths only vX listed and see the `examples/` diff. It asks before writing; pass `--yes` when there is no terminal. It writes only local files and commits nothing; where `core.fileMode` is off (Windows), it stages each copied file with the tag's mode (`git add --chmod=+x` or `-x`), so the executable bit is neither lost nor kept by mistake.
18
+ - `init --tag <vX>` copies the Kit-owned paths, writes `.github/workflows/agent-pipeline.yml` and/or `security-audit.yml` from the tag's `examples/` with your answers filled in (`--flows`, `--actors`, `--default-branch`, `--verify`, `--preset`), writes the Threat pack skeleton to `.security/` (or `--threat-pack-dir`) and `.github/dependabot.yml` if there is none, and prints the GitHub-side steps as `gh` commands. It refuses to overwrite an existing Caller or Kit-owned path without `--force`, never overwrites an existing Threat pack file, and leaves Kit-owned paths that already match the tag alone (so `init --flows audit` later adds that flow); use `upgrade` to change tags. `--yes` asks nothing.
16
19
 
17
- The package version matches the Agent-kit Release line: `@mnaimfaizy/agent-kit@1.0.0-alpha.12` is published from tag `v1.0.0-alpha.12`, with npm provenance.
20
+ The package version matches the Agent-kit Release line: `@mnaimfaizy/agent-kit@1.0.0-beta.1` is published from tag `v1.0.0-beta.1`, with npm provenance.
18
21
 
19
22
  Full documentation: [Getting started](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/getting-started.md) and [Adopt](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/adopt.md#check-an-adoption-with-doctor).
package/bin/agent-kit.mjs CHANGED
@@ -3,9 +3,11 @@
3
3
  // repository. It writes only local files and never changes GitHub settings.
4
4
 
5
5
  import { main } from "../src/cli.mjs";
6
+ import { terminalPrompt } from "../src/prompt.mjs";
6
7
 
7
- process.exitCode = main(process.argv.slice(2), {
8
+ process.exitCode = await main(process.argv.slice(2), {
8
9
  cwd: process.cwd(),
9
10
  stdout: (s) => process.stdout.write(`${s}\n`),
10
11
  stderr: (s) => process.stderr.write(`${s}\n`),
12
+ prompt: terminalPrompt({ input: process.stdin, output: process.stdout }),
11
13
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnaimfaizy/agent-kit",
3
- "version": "1.0.0-alpha.12",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "Adopt, upgrade and check Agent-kit in a Consumer repository. Reads kit files from the pinned git tag.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/callers.mjs CHANGED
@@ -58,6 +58,44 @@ export function findKitPins(text) {
58
58
  return pins;
59
59
  }
60
60
 
61
+ export class PinRewriteError extends Error {}
62
+
63
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+
65
+ /**
66
+ * Point every Agent-kit `uses:` pin in `text` at `newRef`. Only the `@ref`
67
+ * of each pin line changes: every other byte of the file, the line endings
68
+ * and a byte-order mark included, stays as it was. Throws PinRewriteError
69
+ * when a pin line cannot be rewritten unambiguously.
70
+ *
71
+ * @returns {{ text: string, count: number }}
72
+ */
73
+ export function rewriteKitPins(text, newRef) {
74
+ const pins = findKitPins(text);
75
+ if (pins.length === 0) return { text, count: 0 };
76
+ // Split the raw text the way findKitPins numbers lines (\r\n, \r or \n),
77
+ // keeping each separator so the file is rebuilt byte for byte.
78
+ const parts = String(text).split(/(\r\n|\r|\n)/);
79
+ let count = 0;
80
+ for (const pin of pins) {
81
+ const idx = (pin.line - 1) * 2;
82
+ const line = parts[idx];
83
+ const re = new RegExp(
84
+ `(/${escapeRegExp(pin.file)}@)${escapeRegExp(pin.ref)}(?=$|[\\s"'#])`,
85
+ "g",
86
+ );
87
+ const matches = [...line.matchAll(re)];
88
+ if (matches.length !== 1) {
89
+ throw new PinRewriteError(
90
+ `line ${pin.line}: cannot find exactly one ${pin.file}@${pin.ref} to rewrite`,
91
+ );
92
+ }
93
+ parts[idx] = line.replace(re, (_, prefix) => `${prefix}${newRef}`);
94
+ if (parts[idx] !== line) count++;
95
+ }
96
+ return { text: parts.join(""), count };
97
+ }
98
+
61
99
  function lines(text) {
62
100
  return stripBom(String(text))
63
101
  .replace(/\r\n?/g, "\n")
@@ -64,8 +64,8 @@ export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
64
64
  ...howCompared,
65
65
  `Kit-owned paths are replaced whole from the tag your pins name, never edited.`,
66
66
  `If a pin bump (for example from Dependabot) moved the pins to ${tag}, re-copy them on the same branch.`,
67
- `The command below needs \`upgrade\`, which ships in a later release; until then, ` +
68
- `delete and re-copy each path by hand as ${manualCopyUrl(repo, tag, serverUrl)} describes.`,
67
+ `Run the command below, or delete and re-copy each path by hand as ` +
68
+ `${manualCopyUrl(repo, tag, serverUrl)} describes.`,
69
69
  ],
70
70
  fix: [upgradeCommand(tag)],
71
71
  },
@@ -47,8 +47,14 @@ export function byHand(what, value) {
47
47
  return `fix by hand: ${what} ${JSON.stringify(String(value))} (not printed as a command)`;
48
48
  }
49
49
 
50
- // C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
51
- const CONTROL = new RegExp("[\u0000-\u001f\u007f-\u009f\u2028\u2029]", "g");
50
+ // C0 and C1 controls, DEL, the Unicode line and paragraph separators, and the
51
+ // invisible and bidirectional formatting characters that can make printed
52
+ // text read differently from what it is (zero-width, LRM/RLM, embeddings,
53
+ // overrides and isolates).
54
+ const CONTROL = new RegExp(
55
+ "[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]",
56
+ "g",
57
+ );
52
58
 
53
59
  /**
54
60
  * Make one output line safe for a terminal and for a CI log: escape control
package/src/cli.mjs CHANGED
@@ -1,21 +1,30 @@
1
1
  // Argument parsing and dispatch. Exit codes: 0 ok, 1 a check failed (or a
2
- // warning under --strict, or the tag was refused), 2 usage error.
2
+ // warning under --strict, or the tag or the change was refused), 2 usage
3
+ // error (including a missing answer when there is no terminal to ask on).
3
4
 
4
- import { readFileSync } from "node:fs";
5
5
  import { parseArgs } from "node:util";
6
6
  import { formatReport, runDoctor } from "./doctor.mjs";
7
7
  import { REPO_PATTERN, sanitizeLine } from "./checks/result.mjs";
8
8
  import { systemRunner } from "./exec.mjs";
9
+ import { askYesNo } from "./prompt.mjs";
10
+ import { CLI_VERSION, isReleaseTag } from "./tags.mjs";
11
+ import { runInit } from "./init.mjs";
12
+ import { runUpgrade } from "./upgrade.mjs";
9
13
 
10
- export const VERSION = JSON.parse(
11
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
12
- ).version;
14
+ export const VERSION = CLI_VERSION;
13
15
 
14
16
  const USAGE = `Usage: agent-kit <command> [options]
15
17
 
16
18
  Commands:
19
+ init Adopt Agent-kit from a tag: copy the Kit-owned paths, write a
20
+ Caller per flow with your answers filled in, the Threat pack
21
+ skeleton and .github/dependabot.yml, then print the GitHub-side
22
+ steps as gh commands. Local files only.
17
23
  doctor Read-only checks of this repository against the Agent-kit tag
18
24
  its Callers pin. Never changes anything.
25
+ upgrade Move this repository to another Agent-kit tag: replace the
26
+ Kit-owned paths, rewrite the uses: pins, print the examples/
27
+ diff, then run doctor's file and pin checks. Local files only.
19
28
 
20
29
  Options for doctor:
21
30
  --ci Check only Kit-owned paths and uses: pins (no gh needed).
@@ -24,19 +33,64 @@ Options for doctor:
24
33
  --repo <o/r> The GitHub repository to check (default: gh's choice for
25
34
  the current directory).
26
35
 
36
+ Options for init (each one is asked for when missing, on a terminal):
37
+ --tag <vX> The Agent-kit tag to adopt.
38
+ --flows <list> pipeline, audit, or both (comma-separated).
39
+ --actors <logins> GitHub logins allowed to trigger runs.
40
+ --default-branch <b> Default branch (default: origin's HEAD, else main).
41
+ --verify <commands> verify_commands for implement (pipeline). In a
42
+ prompt, \\n separates commands.
43
+ --preset <name|none> Inline examples/setup-presets/<name>.sh as
44
+ setup_commands (pipeline; default none).
45
+ --threat-pack-dir <d> Where the Threat pack goes (audit; default .security).
46
+ --yes Ask nothing; every answer comes from flags.
47
+ --force Overwrite existing Callers, Threat pack files and
48
+ Kit-owned paths (use upgrade to change tags).
49
+
50
+ Options for upgrade:
51
+ --tag <vY> The Agent-kit tag to move to (asked for when missing).
52
+ --from <vX> The tag you are leaving, when the pins already name <vY>
53
+ (after a Dependabot bump): deletes paths only <vX> listed
54
+ and prints the examples/ diff.
55
+ --yes Do not ask before changing files.
56
+ --force Replace Kit-owned paths even when they have uncommitted
57
+ changes (those changes are lost).
58
+
27
59
  -h, --help Show this help.
28
- -v, --version Show the version.
60
+ -v, --version Show the version.`;
29
61
 
30
- init and upgrade are not available yet.`;
62
+ const COMMAND_OPTIONS = {
63
+ doctor: ["ci", "strict", "repo"],
64
+ init: [
65
+ "tag",
66
+ "flows",
67
+ "actors",
68
+ "default-branch",
69
+ "verify",
70
+ "preset",
71
+ "threat-pack-dir",
72
+ "yes",
73
+ "force",
74
+ ],
75
+ upgrade: ["tag", "from", "yes", "force"],
76
+ };
31
77
 
32
78
  /**
33
79
  * @param {string[]} argv
34
- * @param {{ cwd: string, stdout: (s: string) => void, stderr: (s: string) => void, runner?: import("./exec.mjs").Runner, openTag?: Function }} io
35
- * @returns {number} exit code
80
+ * @param {{
81
+ * cwd: string,
82
+ * stdout: (s: string) => void,
83
+ * stderr: (s: string) => void,
84
+ * runner?: import("./exec.mjs").Runner,
85
+ * openTag?: Function,
86
+ * openRepo?: Function,
87
+ * prompt?: ((question: string) => Promise<string>) | null,
88
+ * }} io `prompt` is null (or absent) when there is no terminal to ask on.
89
+ * @returns {Promise<number>} exit code
36
90
  */
37
- export function main(argv, io) {
38
- const { cwd, runner = systemRunner, openTag } = io;
39
- // Anything doctor prints can echo its arguments or file contents: sanitize every line.
91
+ export async function main(argv, io) {
92
+ const { cwd, runner = systemRunner, openTag, openRepo, prompt = null } = io;
93
+ // Anything printed can echo arguments or file contents: sanitize every line.
40
94
  const clean = (text) => String(text).split("\n").map(sanitizeLine).join("\n");
41
95
  const stdout = (text) => io.stdout(clean(text));
42
96
  const stderr = (text) => io.stderr(clean(text));
@@ -50,6 +104,16 @@ export function main(argv, io) {
50
104
  ci: { type: "boolean" },
51
105
  strict: { type: "boolean" },
52
106
  repo: { type: "string" },
107
+ tag: { type: "string" },
108
+ from: { type: "string" },
109
+ flows: { type: "string" },
110
+ actors: { type: "string" },
111
+ "default-branch": { type: "string" },
112
+ verify: { type: "string" },
113
+ preset: { type: "string" },
114
+ "threat-pack-dir": { type: "string" },
115
+ yes: { type: "boolean", short: "y" },
116
+ force: { type: "boolean" },
53
117
  help: { type: "boolean", short: "h" },
54
118
  version: { type: "boolean", short: "v" },
55
119
  },
@@ -68,18 +132,49 @@ export function main(argv, io) {
68
132
  (command || values.help ? stdout : stderr)(USAGE);
69
133
  return values.help ? 0 : 2;
70
134
  }
71
- if (command === "init" || command === "upgrade") {
72
- stderr(`agent-kit ${command} is not available in ${VERSION} yet; it ships in a later release.`);
73
- return 2;
74
- }
75
- if (command !== "doctor") {
135
+ if (!(command in COMMAND_OPTIONS)) {
76
136
  stderr(`Unknown command: ${command}\n\n${USAGE}`);
77
137
  return 2;
78
138
  }
79
139
  if (rest.length) {
80
- stderr(`doctor takes no arguments, got: ${rest.join(" ")}`);
140
+ stderr(`${command} takes no arguments, got: ${rest.join(" ")}`);
141
+ return 2;
142
+ }
143
+ const foreign = Object.keys(values).filter(
144
+ (k) => k !== "help" && k !== "version" && !COMMAND_OPTIONS[command].includes(k),
145
+ );
146
+ if (foreign.length) {
147
+ stderr(`${command} does not take ${foreign.map((k) => `--${k}`).join(", ")}\n\n${USAGE}`);
81
148
  return 2;
82
149
  }
150
+
151
+ if (command === "init") {
152
+ stdout(`agent-kit init ${VERSION}`);
153
+ return runInit({
154
+ cwd,
155
+ runner,
156
+ flags: {
157
+ tag: values.tag,
158
+ flows: values.flows,
159
+ actors: values.actors,
160
+ defaultBranch: values["default-branch"],
161
+ verify: values.verify,
162
+ preset: values.preset,
163
+ threatPackDir: values["threat-pack-dir"],
164
+ },
165
+ force: Boolean(values.force),
166
+ yes: Boolean(values.yes),
167
+ prompt,
168
+ confirm: prompt ? (q) => askYesNo(prompt, q) : null,
169
+ out: stdout,
170
+ err: stderr,
171
+ ...(openTag ? { openTag } : {}),
172
+ });
173
+ }
174
+ if (command === "upgrade") {
175
+ return upgrade(values, { cwd, runner, openRepo, prompt, stdout, stderr });
176
+ }
177
+
83
178
  if (values.repo !== undefined && !REPO_PATTERN.test(values.repo)) {
84
179
  stderr(`--repo must be owner/name, got: ${JSON.stringify(values.repo)}`);
85
180
  return 2;
@@ -95,3 +190,40 @@ export function main(argv, io) {
95
190
  stdout(formatReport(report, { version: VERSION }));
96
191
  return report.exitCode;
97
192
  }
193
+
194
+ async function upgrade(values, { cwd, runner, openRepo, prompt, stdout, stderr }) {
195
+ if (!prompt) {
196
+ // No terminal: every answer must come from a flag.
197
+ const needed = [];
198
+ if (values.tag === undefined) needed.push("--tag <vY>");
199
+ if (!values.yes) needed.push("--yes");
200
+ if (needed.length) {
201
+ stderr(`agent-kit upgrade: there is no terminal to ask on, so pass ${needed.join(" and ")}.`);
202
+ return 2;
203
+ }
204
+ }
205
+ let tag = values.tag;
206
+ if (tag === undefined) tag = await prompt("Agent-kit tag to upgrade to (for example v1.0.0): ");
207
+ if (values.from !== undefined && !isReleaseTag(values.from)) {
208
+ stderr(`--from must be a Release-line tag, got: ${JSON.stringify(values.from)}`);
209
+ return 2;
210
+ }
211
+ if (!isReleaseTag(tag)) {
212
+ stderr(
213
+ `--tag must be a Release-line tag (vX.Y.Z or vX.Y.Z-stage.N), got: ${JSON.stringify(tag)}`,
214
+ );
215
+ return 2;
216
+ }
217
+ stdout(`agent-kit upgrade ${VERSION}`);
218
+ return runUpgrade({
219
+ cwd,
220
+ runner,
221
+ tag,
222
+ from: values.from,
223
+ force: Boolean(values.force),
224
+ yes: Boolean(values.yes),
225
+ confirm: prompt ? (q) => askYesNo(prompt, q) : null,
226
+ out: stdout,
227
+ ...(openRepo ? { openRepo } : {}),
228
+ });
229
+ }
package/src/doctor.mjs CHANGED
@@ -24,9 +24,7 @@ import {
24
24
  } from "./checks/secrets-and-variables.mjs";
25
25
  import { firstLine } from "./exec.mjs";
26
26
  import { createGitHub } from "./github.mjs";
27
- import { DEFAULT_SERVER, openKitTag, PreManifestTagError } from "./kit-tag.mjs";
28
-
29
- const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
27
+ import { openKitTag, PreManifestTagError, serverFromEnv } from "./kit-tag.mjs";
30
28
 
31
29
  /**
32
30
  * The GitHub server the pins resolve on: the repository's host in a local
@@ -35,8 +33,7 @@ const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
35
33
  */
36
34
  function serverFor(info, env) {
37
35
  if (info?.host) return `https://${info.host}`;
38
- const fromEnv = String(env.GITHUB_SERVER_URL ?? "").replace(/\/$/, "");
39
- return SERVER.test(fromEnv) ? fromEnv : DEFAULT_SERVER;
36
+ return serverFromEnv(env);
40
37
  }
41
38
 
42
39
  /** The checks that need a `gh` login with admin on the repository. */
@@ -358,7 +355,7 @@ function formatLines(report, { version } = {}) {
358
355
  return lines;
359
356
  }
360
357
 
361
- function formatResult(r) {
358
+ export function formatResult(r) {
362
359
  const out = [
363
360
  `${LABEL[r.status]} ${r.title}: ${r.summary}${r.strict ? " (warning; failed by --strict)" : ""}`,
364
361
  ];
@@ -366,3 +363,43 @@ function formatResult(r) {
366
363
  for (const f of r.fix) out.push(` $ ${f}`);
367
364
  return out;
368
365
  }
366
+
367
+ /** doctor --ci's pin and Kit-owned path checks, against an already-fetched tag. */
368
+ export function fileAndPinChecks({ runner, root, snap }) {
369
+ let pins;
370
+ try {
371
+ pins = checkPins(readWorkflows(root).files);
372
+ } catch (err) {
373
+ return [result("pins", "uses: pins agree", "fail", firstLine(err?.message ?? err))];
374
+ }
375
+ const results = [pins.result];
376
+ if (pins.tag !== snap.tag) {
377
+ results.push(
378
+ result(
379
+ "kit-owned-trees",
380
+ "Kit-owned paths match the pinned tag",
381
+ "fail",
382
+ `The pins do not all name ${snap.tag}.`,
383
+ ),
384
+ );
385
+ return results;
386
+ }
387
+ try {
388
+ results.push(checkKitOwnedTrees({ runner, cwd: root, snapshot: snap }));
389
+ } catch (err) {
390
+ results.push(
391
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
392
+ );
393
+ }
394
+ return results;
395
+ }
396
+
397
+ /**
398
+ * Print fileAndPinChecks results under a heading, for init and upgrade.
399
+ * Returns true when one failed.
400
+ */
401
+ export function printFileAndPinChecks(out, results) {
402
+ out("Checks (as doctor --ci):");
403
+ for (const r of results) for (const line of formatResult(r)) out(line);
404
+ return results.some((r) => r.status === "fail");
405
+ }
package/src/exec.mjs CHANGED
@@ -8,7 +8,10 @@ const ALLOWED = new Set(["git", "gh"]);
8
8
 
9
9
  /**
10
10
  * @typedef {{ status: number | null, stdout: string, stderr: string }} RunResult
11
- * @typedef {(cmd: string, args: string[], opts?: { cwd?: string, env?: NodeJS.ProcessEnv }) => RunResult} Runner
11
+ * @typedef {{ status: number | null, stdout: Buffer, stderr: string }} BufferResult
12
+ * @typedef {(cmd: string, args: string[], opts?: { cwd?: string, env?: NodeJS.ProcessEnv, encoding?: "utf8" | "buffer" }) => RunResult} Runner
13
+ * With `encoding: "buffer"`, stdout is the raw bytes (a Buffer): use it for
14
+ * file contents, which need not be UTF-8. stderr is always text.
12
15
  */
13
16
 
14
17
  /** @type {Runner} */
@@ -20,15 +23,25 @@ export function systemRunner(cmd, args, opts = {}) {
20
23
  const result = spawnSync(cmd, args, {
21
24
  cwd: opts.cwd,
22
25
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", ...opts.env },
23
- encoding: "utf8",
26
+ encoding: "buffer",
24
27
  shell: false,
25
28
  windowsHide: true,
26
29
  maxBuffer: 64 * 1024 * 1024,
27
30
  });
31
+ const raw = opts.encoding === "buffer";
28
32
  if (result.error) {
29
- return { status: null, stdout: "", stderr: `${cmd}: ${result.error.message}` };
33
+ return {
34
+ status: null,
35
+ stdout: raw ? Buffer.alloc(0) : "",
36
+ stderr: `${cmd}: ${result.error.message}`,
37
+ };
30
38
  }
31
- return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
39
+ const stdout = result.stdout ?? Buffer.alloc(0);
40
+ return {
41
+ status: result.status,
42
+ stdout: raw ? stdout : stdout.toString("utf8"),
43
+ stderr: (result.stderr ?? Buffer.alloc(0)).toString("utf8"),
44
+ };
32
45
  }
33
46
 
34
47
  /**