lintlane 2.5.0 → 4.0.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 +210 -512
- package/bin/check-commit.mjs +54 -137
- package/bin/checks.mjs +237 -0
- package/bin/cli.mjs +299 -220
- package/bin/commit-cli.mjs +36 -122
- package/bin/doctor.mjs +328 -0
- package/bin/hook.mjs +156 -0
- package/bin/proc.mjs +132 -0
- package/bin/project.mjs +276 -0
- package/bin/ui-server.mjs +244 -91
- package/package.json +22 -7
- package/presets/commitizen.cjs +4 -0
- package/presets/commitlint.mjs +47 -0
- package/templates/commit-msg +2 -2
- package/templates/pre-commit +2 -2
- package/templates/pre-push +2 -2
- package/templates/prepare-commit-msg +2 -2
- package/ui/index.html +210 -44
- package/bin/ci.mjs +0 -162
- package/bin/commit-config.mjs +0 -24
- package/bin/commit-lint.mjs +0 -143
- package/bin/commit-msg.mjs +0 -18
- package/bin/commit-prompt.mjs +0 -110
- package/bin/pre-push-check.mjs +0 -48
- package/bin/prepare-commit-msg.mjs +0 -46
package/bin/check-commit.mjs
CHANGED
|
@@ -1,89 +1,37 @@
|
|
|
1
|
-
// `lintlane check-commit` — validate commit messages
|
|
2
|
-
// Plain Node on purpose: it also runs in CI and in the pre-push hook.
|
|
1
|
+
// `lintlane check-commit` — validate commit messages with commitlint.
|
|
3
2
|
//
|
|
4
3
|
// --message "<msg>" check a string (or pipe it in on stdin)
|
|
5
|
-
// --commit-msg-file <path> check a message file
|
|
6
|
-
// --rev-range <
|
|
4
|
+
// --commit-msg-file <path> check a message file
|
|
5
|
+
// --rev-range <a..b> check every commit in a git range
|
|
7
6
|
// -d, --use-default-range same as --rev-range <default branch>..HEAD
|
|
8
|
-
// --allow-abort accept an empty message (git aborts those itself)
|
|
9
|
-
// --allowed-prefixes <p> extra message prefixes to skip (repeatable)
|
|
10
|
-
// -l, --message-length-limit N max header length (0 = no limit)
|
|
11
|
-
import { execFileSync } from "node:child_process";
|
|
12
7
|
import { readFileSync } from "node:fs";
|
|
13
|
-
import { pathToFileURL } from "node:url";
|
|
14
8
|
import { parseArgs } from "node:util";
|
|
15
|
-
import {
|
|
16
|
-
import { cleanMessage, explainFailure, validateMessage } from "./commit-lint.mjs";
|
|
9
|
+
import { resolveTool, runInherit, runProcess } from "./proc.mjs";
|
|
17
10
|
|
|
18
11
|
const HELP = `Usage: lintlane check-commit [options]
|
|
19
12
|
|
|
20
|
-
Checks that commit messages follow Conventional Commits. Give
|
|
13
|
+
Checks that commit messages follow Conventional Commits (commitlint). Give one of:
|
|
21
14
|
--message <msg> a message (or pipe one in: echo "feat: x" | lintlane check-commit)
|
|
22
15
|
--commit-msg-file <path> a file containing the message
|
|
23
|
-
--rev-range <
|
|
16
|
+
--rev-range <a..b> every commit in a range, e.g. origin/main..HEAD
|
|
24
17
|
-d, --use-default-range shorthand for <default branch>..HEAD
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
--allowed-prefixes <prefix> Skip messages starting with this (repeatable)
|
|
29
|
-
-l, --message-length-limit <n> Maximum header length (0 = no limit)
|
|
19
|
+
Rules come from commitlint.config.mjs (see \`lintlane init\`) and the optional
|
|
20
|
+
"lintlane.commit" block in package.json.
|
|
30
21
|
`;
|
|
31
22
|
|
|
32
|
-
const
|
|
33
|
-
|
|
23
|
+
const err = (text) => process.stderr.write(`${text}\n`);
|
|
24
|
+
|
|
25
|
+
async function defaultBranch(cwd) {
|
|
26
|
+
const head = await runProcess("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
34
27
|
cwd,
|
|
35
|
-
encoding: "utf8",
|
|
36
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
37
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
38
28
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return out
|
|
44
|
-
.split("\x1e")
|
|
45
|
-
.map((entry) => entry.replace(/^\n/, ""))
|
|
46
|
-
.filter((entry) => entry.trim() !== "")
|
|
47
|
-
.map((entry) => {
|
|
48
|
-
const [hash, ...message] = entry.split("\x1f");
|
|
49
|
-
return { hash: hash.trim(), message: message.join("\x1f") };
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Validate commits; returns only the failing ones, with their errors. */
|
|
54
|
-
export function findInvalidCommits(commits, options) {
|
|
55
|
-
const invalid = [];
|
|
56
|
-
for (const commit of commits) {
|
|
57
|
-
const result = validateMessage(commit.message, options);
|
|
58
|
-
if (!result.valid) invalid.push({ ...commit, errors: result.errors });
|
|
59
|
-
}
|
|
60
|
-
return invalid;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function describeInvalid(invalid) {
|
|
64
|
-
return invalid
|
|
65
|
-
.map((c) => {
|
|
66
|
-
const subject = cleanMessage(c.message).split("\n")[0] || "(empty message)";
|
|
67
|
-
return ` ${c.hash.slice(0, 8)} ${subject}\n${c.errors.map((e) => ` • ${e}`).join("\n")}`;
|
|
68
|
-
})
|
|
69
|
-
.join("\n");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function defaultBranchRange(cwd) {
|
|
73
|
-
const candidates = [];
|
|
74
|
-
try {
|
|
75
|
-
candidates.push(git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd).trim());
|
|
76
|
-
} catch {}
|
|
77
|
-
candidates.push("origin/main", "origin/master", "main", "master");
|
|
78
|
-
for (const ref of candidates) {
|
|
79
|
-
try {
|
|
80
|
-
git(["rev-parse", "--verify", "--quiet", ref], cwd);
|
|
81
|
-
return `${ref}..HEAD`;
|
|
82
|
-
} catch {}
|
|
29
|
+
if (head.code === 0) return head.stdout.toString().trim();
|
|
30
|
+
for (const name of ["origin/main", "origin/master", "main", "master"]) {
|
|
31
|
+
const probe = await runProcess("git", ["rev-parse", "--verify", "-q", name], { cwd });
|
|
32
|
+
if (probe.code === 0) return name;
|
|
83
33
|
}
|
|
84
|
-
|
|
85
|
-
"Couldn't find a default branch (tried origin/HEAD, main, master). Use --rev-range.",
|
|
86
|
-
);
|
|
34
|
+
return null;
|
|
87
35
|
}
|
|
88
36
|
|
|
89
37
|
export async function main(argv, cwd = process.cwd()) {
|
|
@@ -96,14 +44,11 @@ export async function main(argv, cwd = process.cwd()) {
|
|
|
96
44
|
"commit-msg-file": { type: "string" },
|
|
97
45
|
"rev-range": { type: "string" },
|
|
98
46
|
"use-default-range": { type: "boolean", short: "d" },
|
|
99
|
-
"allow-abort": { type: "boolean" },
|
|
100
|
-
"allowed-prefixes": { type: "string", multiple: true },
|
|
101
|
-
"message-length-limit": { type: "string", short: "l" },
|
|
102
47
|
help: { type: "boolean", short: "h" },
|
|
103
48
|
},
|
|
104
49
|
}));
|
|
105
50
|
} catch (error) {
|
|
106
|
-
|
|
51
|
+
err(`✘ ${error.message}\n\n${HELP}`);
|
|
107
52
|
return 2;
|
|
108
53
|
}
|
|
109
54
|
if (values.help) {
|
|
@@ -111,73 +56,45 @@ export async function main(argv, cwd = process.cwd()) {
|
|
|
111
56
|
return 0;
|
|
112
57
|
}
|
|
113
58
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
allowedPrefixes: [...(config.allowedPrefixes ?? []), ...(values["allowed-prefixes"] ?? [])],
|
|
119
|
-
messageLengthLimit: values["message-length-limit"] ?? config.messageLengthLimit,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const modes = [
|
|
123
|
-
values.message,
|
|
124
|
-
values["commit-msg-file"],
|
|
125
|
-
values["rev-range"],
|
|
126
|
-
values["use-default-range"],
|
|
127
|
-
].filter((v) => v !== undefined && v !== false).length;
|
|
128
|
-
if (modes > 1) {
|
|
129
|
-
process.stderr.write(
|
|
130
|
-
`✘ Choose only one of --message, --commit-msg-file, --rev-range, --use-default-range.\n`,
|
|
131
|
-
);
|
|
132
|
-
return 2;
|
|
59
|
+
const commitlint = resolveTool("@commitlint/cli", "commitlint");
|
|
60
|
+
if (!commitlint) {
|
|
61
|
+
err("✘ commitlint isn't installed. Reinstall lintlane with your package manager.");
|
|
62
|
+
return 1;
|
|
133
63
|
}
|
|
64
|
+
const base = [...commitlint.slice(1), "--cwd", cwd];
|
|
65
|
+
const run = (args, input) =>
|
|
66
|
+
input === undefined
|
|
67
|
+
? runInherit(commitlint[0], [...base, ...args], { cwd })
|
|
68
|
+
: runProcess(commitlint[0], [...base, ...args], { cwd, input }).then((result) => {
|
|
69
|
+
process.stdout.write(result.stdout);
|
|
70
|
+
process.stderr.write(result.stderr);
|
|
71
|
+
return result.code;
|
|
72
|
+
});
|
|
134
73
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
checked = commits.length;
|
|
142
|
-
invalid = findInvalidCommits(commits, options);
|
|
143
|
-
} else {
|
|
144
|
-
let raw;
|
|
145
|
-
if (values.message !== undefined) raw = values.message;
|
|
146
|
-
else if (values["commit-msg-file"]) raw = readFileSync(values["commit-msg-file"], "utf8");
|
|
147
|
-
else if (!process.stdin.isTTY) raw = readFileSync(0, "utf8");
|
|
148
|
-
else {
|
|
149
|
-
process.stderr.write(`✘ Nothing to check.\n\n${HELP}`);
|
|
150
|
-
return 2;
|
|
151
|
-
}
|
|
152
|
-
checked = 1;
|
|
153
|
-
const result = validateMessage(raw, options);
|
|
154
|
-
invalid = result.valid ? [] : [{ hash: "", message: raw, errors: result.errors }];
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (invalid.length === 0) {
|
|
158
|
-
process.stdout.write(`✔ Commit validation successful (${checked} checked).\n`);
|
|
159
|
-
return 0;
|
|
74
|
+
let range = values["rev-range"];
|
|
75
|
+
if (values["use-default-range"]) {
|
|
76
|
+
const branch = await defaultBranch(cwd);
|
|
77
|
+
if (!branch) {
|
|
78
|
+
err("✘ Couldn't find the default branch. Use --rev-range <a..b>.");
|
|
79
|
+
return 1;
|
|
160
80
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
process.stderr.write(
|
|
169
|
-
`\n✘ ${invalid.length} of ${checked} commit message(s) don't follow Conventional Commits:\n\n${describeInvalid(invalid)}\n\n` +
|
|
170
|
-
" Fix them with `git rebase -i` (reword) or `git commit --amend` for the latest one.\n\n",
|
|
171
|
-
);
|
|
81
|
+
range = `${branch}..HEAD`;
|
|
82
|
+
}
|
|
83
|
+
if (range) {
|
|
84
|
+
const [from, to = "HEAD"] = range.split("..");
|
|
85
|
+
if (!from) {
|
|
86
|
+
err('✘ --rev-range must look like "origin/main..HEAD".');
|
|
87
|
+
return 2;
|
|
172
88
|
}
|
|
173
|
-
return
|
|
174
|
-
} catch (error) {
|
|
175
|
-
const detail = error.stderr?.toString().trim().split("\n")[0] || error.message;
|
|
176
|
-
process.stderr.write(`✘ ${detail}\n`);
|
|
177
|
-
return 2;
|
|
89
|
+
return run(["--from", from, "--to", to]);
|
|
178
90
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (process.
|
|
182
|
-
|
|
91
|
+
if (values["commit-msg-file"]) return run(["--edit", values["commit-msg-file"]]);
|
|
92
|
+
if (values.message !== undefined) return run([], values.message);
|
|
93
|
+
if (!process.stdin.isTTY) {
|
|
94
|
+
try {
|
|
95
|
+
return run([], readFileSync(0, "utf8"));
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
err(`✘ Nothing to check.\n\n${HELP}`);
|
|
99
|
+
return 2;
|
|
183
100
|
}
|
package/bin/checks.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { binPath, spawnPrepared } from "./proc.mjs";
|
|
4
|
+
|
|
5
|
+
const MAX_LOG_CHARS = 2_000_000;
|
|
6
|
+
|
|
7
|
+
const RUN_SCRIPT = {
|
|
8
|
+
bun: (name) => ["bun", "run", name],
|
|
9
|
+
npm: (name) => ["npm", "run", name],
|
|
10
|
+
pnpm: (name) => ["pnpm", "run", name],
|
|
11
|
+
yarn: (name) => ["yarn", "run", name],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Same commands the generated GitHub workflow uses for its install step.
|
|
15
|
+
const FROZEN_INSTALL = {
|
|
16
|
+
bun: ["bun", "install", "--frozen-lockfile"],
|
|
17
|
+
npm: ["npm", "ci"],
|
|
18
|
+
pnpm: ["pnpm", "install", "--frozen-lockfile"],
|
|
19
|
+
yarn: ["yarn", "install", "--frozen-lockfile"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const NPM_DEFAULT_TEST = /no test specified/i;
|
|
23
|
+
const CHECK_IDS = ["biome", "typecheck", "build", "test"];
|
|
24
|
+
|
|
25
|
+
function firstScript(scripts, names) {
|
|
26
|
+
return names.find((name) => typeof scripts[name] === "string");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The one list of checks. The dashboard, `lintlane run` and the generated CI
|
|
31
|
+
* workflow all read it, so they can't drift apart.
|
|
32
|
+
*
|
|
33
|
+
* Detected from package.json (scripts, tsconfig) and tuned in package.json:
|
|
34
|
+
*
|
|
35
|
+
* "lintlane": { "checks": { "build": false, "test": "test:unit", "typecheck": true } }
|
|
36
|
+
*
|
|
37
|
+
* false turns a check off everywhere; a string names the package.json script to
|
|
38
|
+
* run; true keeps the detected one. Unknown ids and bad values are reported in
|
|
39
|
+
* `problems` and otherwise ignored. Only script names ever come from config,
|
|
40
|
+
* never command lines, and the browser only ever sends step ids back.
|
|
41
|
+
*
|
|
42
|
+
* Each step: { id, label, cmd, display, defaultOn, note?, ci, script? }.
|
|
43
|
+
* `ci` says whether the generated workflow runs it; `script` is the
|
|
44
|
+
* package.json script behind it (null for Biome and the raw tsc fallback).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveChecks({ cwd, biome, pmName, pkg }) {
|
|
47
|
+
const scripts = pkg?.scripts ?? {};
|
|
48
|
+
const run = RUN_SCRIPT[pmName] ?? RUN_SCRIPT.npm;
|
|
49
|
+
const config = pkg?.lintlane?.checks;
|
|
50
|
+
const settings = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
|
51
|
+
const problems = [];
|
|
52
|
+
|
|
53
|
+
for (const [id, value] of Object.entries(settings)) {
|
|
54
|
+
if (!CHECK_IDS.includes(id)) {
|
|
55
|
+
problems.push(`lintlane.checks.${id}: unknown check (known: ${CHECK_IDS.join(", ")})`);
|
|
56
|
+
} else if (typeof value !== "boolean" && typeof value !== "string") {
|
|
57
|
+
problems.push(`lintlane.checks.${id}: expected true, false or a script name`);
|
|
58
|
+
} else if (typeof value === "string" && id !== "biome" && typeof scripts[value] !== "string") {
|
|
59
|
+
problems.push(`lintlane.checks.${id}: package.json has no script "${value}"`);
|
|
60
|
+
} else if (typeof value === "string" && id === "biome") {
|
|
61
|
+
problems.push("lintlane.checks.biome: can only be true or false");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The script a check should run: the configured one if it exists, else detection.
|
|
66
|
+
const scriptFor = (id, candidates) => {
|
|
67
|
+
const chosen = settings[id];
|
|
68
|
+
if (typeof chosen === "string" && typeof scripts[chosen] === "string" && id !== "biome") {
|
|
69
|
+
return chosen;
|
|
70
|
+
}
|
|
71
|
+
return firstScript(scripts, candidates);
|
|
72
|
+
};
|
|
73
|
+
const enabled = (id) => settings[id] !== false;
|
|
74
|
+
|
|
75
|
+
const steps = [];
|
|
76
|
+
steps.push({
|
|
77
|
+
id: "install",
|
|
78
|
+
label: "Install (frozen lockfile)",
|
|
79
|
+
cmd: FROZEN_INSTALL[pmName] ?? FROZEN_INSTALL.npm,
|
|
80
|
+
defaultOn: false,
|
|
81
|
+
ci: false,
|
|
82
|
+
note: "What CI does first. Reinstalls node_modules, so it's off by default.",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (enabled("biome")) {
|
|
86
|
+
steps.push({
|
|
87
|
+
id: "biome",
|
|
88
|
+
label: "Biome CI (lint + format)",
|
|
89
|
+
cmd: [...biome, "ci", "--colors=off", "."],
|
|
90
|
+
display: "biome ci .",
|
|
91
|
+
defaultOn: true,
|
|
92
|
+
ci: true,
|
|
93
|
+
note: "The same check the GitHub workflow runs.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (enabled("typecheck")) {
|
|
98
|
+
const script = scriptFor("typecheck", ["typecheck", "type-check", "check-types", "tsc"]);
|
|
99
|
+
if (script) {
|
|
100
|
+
steps.push({
|
|
101
|
+
id: "typecheck",
|
|
102
|
+
label: "Typecheck",
|
|
103
|
+
cmd: run(script),
|
|
104
|
+
script,
|
|
105
|
+
defaultOn: true,
|
|
106
|
+
ci: true,
|
|
107
|
+
});
|
|
108
|
+
} else if (existsSync(`${cwd}/tsconfig.json`) && existsSync(binPath(cwd, "tsc"))) {
|
|
109
|
+
steps.push({
|
|
110
|
+
id: "typecheck",
|
|
111
|
+
label: "Typecheck",
|
|
112
|
+
cmd: [binPath(cwd, "tsc"), "--noEmit"],
|
|
113
|
+
display: "tsc --noEmit",
|
|
114
|
+
defaultOn: true,
|
|
115
|
+
ci: false, // no script to name in a workflow
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (enabled("build")) {
|
|
121
|
+
const script = scriptFor("build", ["build"]);
|
|
122
|
+
if (script) {
|
|
123
|
+
steps.push({
|
|
124
|
+
id: "build",
|
|
125
|
+
label: "Build",
|
|
126
|
+
cmd: run(script),
|
|
127
|
+
script,
|
|
128
|
+
defaultOn: true,
|
|
129
|
+
ci: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (enabled("test")) {
|
|
135
|
+
const script = scriptFor("test", ["test"]);
|
|
136
|
+
if (script && !NPM_DEFAULT_TEST.test(scripts[script])) {
|
|
137
|
+
steps.push({
|
|
138
|
+
id: "test",
|
|
139
|
+
label: "Tests",
|
|
140
|
+
cmd: run(script),
|
|
141
|
+
script,
|
|
142
|
+
defaultOn: true,
|
|
143
|
+
ci: true,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
steps: steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") })),
|
|
150
|
+
problems,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Kill the step and everything it started (a build tool's child processes too).
|
|
155
|
+
function killTree(child) {
|
|
156
|
+
if (!child || child.exitCode !== null) return;
|
|
157
|
+
try {
|
|
158
|
+
if (process.platform === "win32") child.kill();
|
|
159
|
+
else process.kill(-child.pid, "SIGTERM");
|
|
160
|
+
} catch {
|
|
161
|
+
try {
|
|
162
|
+
child.kill();
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run steps one after another, reporting events through `send`:
|
|
169
|
+
* start / log / end / skip / done
|
|
170
|
+
* Aborting `signal` (the browser's Stop button, or the page closing) kills the
|
|
171
|
+
* running step. Resolves when the run is over; never rejects.
|
|
172
|
+
*/
|
|
173
|
+
export async function runCiSteps({ cwd, steps, stopOnFail, send, signal, extraEnv = {} }) {
|
|
174
|
+
let child = null;
|
|
175
|
+
const onAbort = () => killTree(child);
|
|
176
|
+
signal?.addEventListener("abort", onAbort);
|
|
177
|
+
let failed = false;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
for (const step of steps) {
|
|
181
|
+
if (signal?.aborted) return;
|
|
182
|
+
if (failed && stopOnFail) {
|
|
183
|
+
send({ type: "skip", id: step.id });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
send({ type: "start", id: step.id });
|
|
188
|
+
const t0 = performance.now();
|
|
189
|
+
let sent = 0;
|
|
190
|
+
let truncated = false;
|
|
191
|
+
|
|
192
|
+
const pump = (stream) => {
|
|
193
|
+
const decoder = new StringDecoder("utf8");
|
|
194
|
+
stream.on("data", (chunk) => {
|
|
195
|
+
const text = decoder.write(chunk);
|
|
196
|
+
if (sent < MAX_LOG_CHARS) {
|
|
197
|
+
sent += text.length;
|
|
198
|
+
send({ type: "log", id: step.id, text });
|
|
199
|
+
} else if (!truncated) {
|
|
200
|
+
truncated = true;
|
|
201
|
+
send({ type: "log", id: step.id, text: "\n… output truncated …\n" });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const code = await new Promise((resolve) => {
|
|
207
|
+
try {
|
|
208
|
+
child = spawnPrepared(step.cmd[0], step.cmd.slice(1), {
|
|
209
|
+
cwd,
|
|
210
|
+
env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0", ...extraEnv },
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
+
detached: process.platform !== "win32", // own process group, so Stop can kill the tree
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
send({ type: "log", id: step.id, text: `\n${error.message}\n` });
|
|
216
|
+
resolve(127);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
pump(child.stdout);
|
|
220
|
+
pump(child.stderr);
|
|
221
|
+
child.on("error", (error) => {
|
|
222
|
+
send({ type: "log", id: step.id, text: `\n${error.message}\n` });
|
|
223
|
+
resolve(127);
|
|
224
|
+
});
|
|
225
|
+
child.on("close", (exitCode, killedBy) => resolve(exitCode ?? (killedBy ? 143 : 1)));
|
|
226
|
+
});
|
|
227
|
+
child = null;
|
|
228
|
+
|
|
229
|
+
if (signal?.aborted) return;
|
|
230
|
+
if (code !== 0) failed = true;
|
|
231
|
+
send({ type: "end", id: step.id, code, ms: Math.round(performance.now() - t0) });
|
|
232
|
+
}
|
|
233
|
+
send({ type: "done" });
|
|
234
|
+
} finally {
|
|
235
|
+
signal?.removeEventListener("abort", onAbort);
|
|
236
|
+
}
|
|
237
|
+
}
|