@coderook/cli 0.9.0 → 0.10.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/dist/cli/src/api.js +157 -0
- package/dist/cli/src/cli.js +430 -63
- package/dist/cli/src/help.js +88 -0
- package/dist/cli/src/project_commands.js +150 -0
- package/dist/cli/src/registry.js +73 -0
- package/dist/cli/src/runner.js +423 -0
- package/dist/cli/src/service_commands.js +238 -0
- package/package.json +48 -47
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The project commands that had no home in the original CLI.
|
|
4
|
+
*
|
|
5
|
+
* Kept beside the main file rather than inside it. `cli.ts` was already 1,400
|
|
6
|
+
* lines of argument handling and worktree logic, and the commands here are
|
|
7
|
+
* about a project on the account rather than a folder on the disk — a
|
|
8
|
+
* different subject, and one that will keep growing as more of the service
|
|
9
|
+
* becomes reachable from a terminal.
|
|
10
|
+
*/
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.resolveProject = resolveProject;
|
|
16
|
+
exports.commandAi = commandAi;
|
|
17
|
+
exports.commandVersions = commandVersions;
|
|
18
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
19
|
+
const api_js_1 = require("./api.js");
|
|
20
|
+
const config_js_1 = require("./config.js");
|
|
21
|
+
const dim = (value) => `[2m${value}[0m`;
|
|
22
|
+
const bold = (value) => `[1m${value}[0m`;
|
|
23
|
+
const red = (value) => `[31m${value}[0m`;
|
|
24
|
+
const green = (value) => `[32m${value}[0m`;
|
|
25
|
+
const accent = (value) => `[33m${value}[0m`;
|
|
26
|
+
function bytes(value) {
|
|
27
|
+
if (value >= 1024 ** 3)
|
|
28
|
+
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
|
29
|
+
if (value >= 1024 ** 2)
|
|
30
|
+
return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
|
31
|
+
if (value >= 1024)
|
|
32
|
+
return `${(value / 1024).toFixed(0)} KB`;
|
|
33
|
+
return `${value} B`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Which project a command is about.
|
|
37
|
+
*
|
|
38
|
+
* Named explicitly, or the one this folder is linked to. Falling back to the
|
|
39
|
+
* folder is what makes these usable from inside a project without repeating
|
|
40
|
+
* its name every time.
|
|
41
|
+
*/
|
|
42
|
+
async function resolveProject(reference) {
|
|
43
|
+
const wanted = reference ?? (await (0, config_js_1.readLink)(node_process_1.default.cwd()))?.slug;
|
|
44
|
+
if (!wanted) {
|
|
45
|
+
console.error(red("Which project? Name one, or run this inside a linked folder."));
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const project = await (0, api_js_1.findProject)(wanted);
|
|
49
|
+
if (!project) {
|
|
50
|
+
console.error(red(`No project matching "${wanted}" on your account.`));
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return project;
|
|
54
|
+
}
|
|
55
|
+
/** The four answers, in the order the site shows them. */
|
|
56
|
+
const AI_SWITCHES = [
|
|
57
|
+
[
|
|
58
|
+
"read",
|
|
59
|
+
"aiRead",
|
|
60
|
+
"Can AI read this?",
|
|
61
|
+
"reading, indexing, summarising and model training",
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
"download",
|
|
65
|
+
"aiDownload",
|
|
66
|
+
"Can AI download this?",
|
|
67
|
+
"taking the files, one at a time or as an archive",
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"contribute",
|
|
71
|
+
"aiContribute",
|
|
72
|
+
"Can AI contribute to this?",
|
|
73
|
+
"automated clients opening contributions",
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
"request",
|
|
77
|
+
"aiRequest",
|
|
78
|
+
"Can AI make requests?",
|
|
79
|
+
"automated clients calling this project's endpoints",
|
|
80
|
+
],
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* Show or change what a project says about machines.
|
|
84
|
+
*
|
|
85
|
+
* The one part of the product with no command at all. Showing is the default
|
|
86
|
+
* because most of the time the question is "what is this set to", and a
|
|
87
|
+
* command that needs arguments to answer that is one people stop using.
|
|
88
|
+
*/
|
|
89
|
+
async function commandAi(parsed) {
|
|
90
|
+
const project = await resolveProject(parsed.positional[0]);
|
|
91
|
+
if (!project)
|
|
92
|
+
return 1;
|
|
93
|
+
const change = {};
|
|
94
|
+
for (const [name, field] of AI_SWITCHES) {
|
|
95
|
+
const value = parsed.flags.get(name);
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
/*
|
|
99
|
+
`--read off` and `--read=false` both read naturally, and a bare `--read`
|
|
100
|
+
means on, because that is what a flag without a value means everywhere
|
|
101
|
+
else in this tool.
|
|
102
|
+
*/
|
|
103
|
+
const text = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
104
|
+
change[field] =
|
|
105
|
+
value === true ? true : !["off", "false", "no", "n", "0"].includes(text);
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(change).length) {
|
|
108
|
+
await (0, api_js_1.setAiAccess)(project.id, change);
|
|
109
|
+
console.log(green(`Updated ${project.name}.`));
|
|
110
|
+
}
|
|
111
|
+
const access = await (0, api_js_1.aiAccess)(project.id);
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(bold(project.name));
|
|
114
|
+
for (const [name, field, question, meaning] of AI_SWITCHES) {
|
|
115
|
+
const mark = access[field] ? green("allowed") : red("refused");
|
|
116
|
+
console.log(` ${question.padEnd(30)} ${mark} ${dim("--" + name)}`);
|
|
117
|
+
console.log(` ${dim(meaning)}`);
|
|
118
|
+
}
|
|
119
|
+
console.log("");
|
|
120
|
+
console.log(dim("Everything is allowed until you say otherwise. Turning reading off is the"));
|
|
121
|
+
console.log(dim("one that refuses model training."));
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
/** Every saved version of a project, newest first. */
|
|
125
|
+
async function commandVersions(parsed) {
|
|
126
|
+
const project = await resolveProject(parsed.positional[0]);
|
|
127
|
+
if (!project)
|
|
128
|
+
return 1;
|
|
129
|
+
const saved = await (0, api_js_1.versions)(project.id);
|
|
130
|
+
if (!saved.length) {
|
|
131
|
+
console.log(dim("Nothing has been saved to this project yet."));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const asked = Number(parsed.flags.get("limit") ?? 20);
|
|
135
|
+
const limit = Number.isFinite(asked) && asked > 0 ? asked : 20;
|
|
136
|
+
console.log(bold(project.name));
|
|
137
|
+
for (const version of saved.slice(0, limit)) {
|
|
138
|
+
const when = version.createdAt
|
|
139
|
+
? new Date(version.createdAt).toLocaleString()
|
|
140
|
+
: "";
|
|
141
|
+
console.log(` ${accent(`v${version.sequence}`).padEnd(16)} ${when.padEnd(22)}` +
|
|
142
|
+
`${String(version.fileCount).padStart(5)} files ${bytes(version.storedSize)}`);
|
|
143
|
+
if (version.message)
|
|
144
|
+
console.log(` ${dim(version.message)}`);
|
|
145
|
+
}
|
|
146
|
+
if (saved.length > limit) {
|
|
147
|
+
console.log(dim(` … ${saved.length - limit} older. Use --limit to see more.`));
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* One table that both runs the commands and describes them.
|
|
4
|
+
*
|
|
5
|
+
* The usage text used to be a hand-written string beside a separate dispatch
|
|
6
|
+
* table, which is the arrangement where help slowly stops being true: a
|
|
7
|
+
* command gets added and the text does not mention it, or an option is renamed
|
|
8
|
+
* and the text still lists the old one. Nobody notices, because nothing checks.
|
|
9
|
+
*
|
|
10
|
+
* Here the description is part of the command. `coderook help` is generated
|
|
11
|
+
* from the same rows that decide what runs, so the two cannot disagree — and a
|
|
12
|
+
* command added without a summary is a type error rather than an omission.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.GROUP_ORDER = void 0;
|
|
16
|
+
exports.buildRegistry = buildRegistry;
|
|
17
|
+
exports.nearestCommand = nearestCommand;
|
|
18
|
+
/** The order groups appear in help, which is the order somebody meets them. */
|
|
19
|
+
exports.GROUP_ORDER = [
|
|
20
|
+
"Getting started",
|
|
21
|
+
"Working with a folder",
|
|
22
|
+
"Your projects",
|
|
23
|
+
"People",
|
|
24
|
+
"When somebody saved first",
|
|
25
|
+
"Bundles",
|
|
26
|
+
"Actions",
|
|
27
|
+
"Other",
|
|
28
|
+
];
|
|
29
|
+
function buildRegistry(specs) {
|
|
30
|
+
const lookup = new Map();
|
|
31
|
+
for (const spec of specs) {
|
|
32
|
+
lookup.set(spec.name, spec);
|
|
33
|
+
for (const alias of spec.aliases ?? [])
|
|
34
|
+
lookup.set(alias, spec);
|
|
35
|
+
}
|
|
36
|
+
return { specs, lookup };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The nearest command to something mistyped.
|
|
40
|
+
*
|
|
41
|
+
* Levenshtein distance, capped so that a genuinely unrelated word gets no
|
|
42
|
+
* suggestion at all — "did you mean bundle?" in answer to "deploy" is worse
|
|
43
|
+
* than saying nothing, because it sends somebody to read the wrong help.
|
|
44
|
+
*/
|
|
45
|
+
function nearestCommand(registry, typed) {
|
|
46
|
+
let best = null;
|
|
47
|
+
for (const name of registry.lookup.keys()) {
|
|
48
|
+
const distance = editDistance(typed, name);
|
|
49
|
+
if (!best || distance < best.distance)
|
|
50
|
+
best = { name, distance };
|
|
51
|
+
}
|
|
52
|
+
if (!best)
|
|
53
|
+
return null;
|
|
54
|
+
const tolerance = typed.length <= 4 ? 1 : 3;
|
|
55
|
+
return best.distance <= tolerance ? best.name : null;
|
|
56
|
+
}
|
|
57
|
+
function editDistance(a, b) {
|
|
58
|
+
/*
|
|
59
|
+
One row at a time rather than a full matrix. Both are short, so this is
|
|
60
|
+
not about speed — a flat array of numbers is simply easier to convince a
|
|
61
|
+
type checker about than a grid of possibly-absent rows.
|
|
62
|
+
*/
|
|
63
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
64
|
+
for (let row = 1; row <= a.length; row += 1) {
|
|
65
|
+
const current = [row];
|
|
66
|
+
for (let column = 1; column <= b.length; column += 1) {
|
|
67
|
+
const cost = a[row - 1] === b[column - 1] ? 0 : 1;
|
|
68
|
+
current[column] = Math.min((previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, (previous[column - 1] ?? 0) + cost);
|
|
69
|
+
}
|
|
70
|
+
previous = current;
|
|
71
|
+
}
|
|
72
|
+
return previous[b.length] ?? 0;
|
|
73
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.withoutCredentials = withoutCredentials;
|
|
7
|
+
exports.claim = claim;
|
|
8
|
+
exports.defaultLabels = defaultLabels;
|
|
9
|
+
exports.uploadArtifacts = uploadArtifacts;
|
|
10
|
+
exports.performRun = performRun;
|
|
11
|
+
exports.report = report;
|
|
12
|
+
/**
|
|
13
|
+
* The machine that does the work.
|
|
14
|
+
*
|
|
15
|
+
* CodeRook records runs; it does not execute them. This is the other half of
|
|
16
|
+
* that arrangement: a small agent somebody installs on a machine they already
|
|
17
|
+
* own, which asks a project whether there is anything to do, does it there,
|
|
18
|
+
* and reports back. The service never holds a sandbox, never writes an egress
|
|
19
|
+
* policy, and never pays for compute — and the button on the Actions screen
|
|
20
|
+
* still does a real thing.
|
|
21
|
+
*
|
|
22
|
+
* It is the same shape as a GitHub self-hosted runner, deliberately, because
|
|
23
|
+
* that is the arrangement people already understand.
|
|
24
|
+
*
|
|
25
|
+
* The trust boundary is worth stating plainly, and the agent states it out
|
|
26
|
+
* loud at startup: anybody who can set a workflow's command on this project
|
|
27
|
+
* can run that command on this machine, as whoever started the agent. That is
|
|
28
|
+
* not a flaw to be fixed, it is what a runner is — but it should be a
|
|
29
|
+
* decision somebody made rather than one they discovered.
|
|
30
|
+
*/
|
|
31
|
+
const node_child_process_1 = require("node:child_process");
|
|
32
|
+
const node_crypto_1 = require("node:crypto");
|
|
33
|
+
const promises_1 = require("node:fs/promises");
|
|
34
|
+
const node_os_1 = require("node:os");
|
|
35
|
+
const node_os_2 = __importDefault(require("node:os"));
|
|
36
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
37
|
+
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
38
|
+
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
39
|
+
const config_js_1 = require("./config.js");
|
|
40
|
+
/** How often to tell the service the machine is still working. */
|
|
41
|
+
const HEARTBEAT_MS = 30_000;
|
|
42
|
+
/** How often log lines are sent up while a command is running. */
|
|
43
|
+
const FLUSH_MS = 2_000;
|
|
44
|
+
async function call(route, options = {}) {
|
|
45
|
+
const token = await (0, config_js_1.loadToken)();
|
|
46
|
+
if (!token)
|
|
47
|
+
throw new Error("Not signed in. Run: coderook sign-in");
|
|
48
|
+
const response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
|
|
49
|
+
method: options.method ?? "GET",
|
|
50
|
+
headers: {
|
|
51
|
+
accept: "application/json",
|
|
52
|
+
authorization: `Bearer ${token}`,
|
|
53
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
54
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
55
|
+
},
|
|
56
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
57
|
+
});
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
const body = text ? JSON.parse(text) : {};
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(body?.error?.message ?? `${route} failed (${response.status})`);
|
|
62
|
+
}
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Send output up in batches rather than a line at a time.
|
|
67
|
+
*
|
|
68
|
+
* A build that prints ten thousand lines would otherwise be ten thousand
|
|
69
|
+
* requests, and the run would spend longer reporting than working. Lines are
|
|
70
|
+
* numbered from where the last batch ended, so what arrives is in the order
|
|
71
|
+
* it was printed even when a flush is slow.
|
|
72
|
+
*/
|
|
73
|
+
class LogShipper {
|
|
74
|
+
repositoryId;
|
|
75
|
+
runId;
|
|
76
|
+
pending = [];
|
|
77
|
+
sent = 0;
|
|
78
|
+
failed = false;
|
|
79
|
+
constructor(repositoryId, runId) {
|
|
80
|
+
this.repositoryId = repositoryId;
|
|
81
|
+
this.runId = runId;
|
|
82
|
+
}
|
|
83
|
+
add(chunk, stream) {
|
|
84
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
85
|
+
if (line !== "")
|
|
86
|
+
this.pending.push({ line: line.slice(0, 2000), stream });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async flush() {
|
|
90
|
+
if (!this.pending.length || this.failed)
|
|
91
|
+
return;
|
|
92
|
+
const batch = this.pending.splice(0, 500);
|
|
93
|
+
try {
|
|
94
|
+
await call(`/v1/repositories/${this.repositoryId}/runs/${this.runId}/logs`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
body: {
|
|
97
|
+
lines: batch.map((entry, at) => ({
|
|
98
|
+
line: entry.line,
|
|
99
|
+
stream: entry.stream,
|
|
100
|
+
lineNumber: this.sent + at,
|
|
101
|
+
})),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
this.sent += batch.length;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
/*
|
|
108
|
+
Losing the log is not a reason to lose the run. The verdict still
|
|
109
|
+
matters — and it is the thing somebody is waiting for — so a shipper
|
|
110
|
+
that cannot deliver says so once and stops trying.
|
|
111
|
+
*/
|
|
112
|
+
this.failed = true;
|
|
113
|
+
console.error(` (log upload stopped: ${errorText(error)})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The environment a workflow command gets, minus this machine's own keys.
|
|
119
|
+
*
|
|
120
|
+
* Running somebody's command on your machine is the deal a runner makes, and
|
|
121
|
+
* it is stated at every startup. Handing them the token that machine signs in
|
|
122
|
+
* with is not: `coderook runner` reads CODEROOK_TOKEN from the environment so
|
|
123
|
+
* automated runs can supply it, and every child process inherited it. A
|
|
124
|
+
* workflow set to `env` by anyone with write access printed it straight into
|
|
125
|
+
* the run log, which read access is enough to see — so a collaborator could
|
|
126
|
+
* walk off with the account of whoever was hosting the runner.
|
|
127
|
+
*
|
|
128
|
+
* The rest of the environment is left alone. PATH, HOME and the compiler
|
|
129
|
+
* settings are what makes a build possible, and stripping them would break
|
|
130
|
+
* every real workflow to guard against a risk the runner already announces.
|
|
131
|
+
*/
|
|
132
|
+
function withoutCredentials(source) {
|
|
133
|
+
const safe = { ...source };
|
|
134
|
+
for (const name of Object.keys(safe)) {
|
|
135
|
+
if (/^CODEROOK_(TOKEN|SECRET|PASSWORD|API_KEY)$/i.test(name)) {
|
|
136
|
+
delete safe[name];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return safe;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Where the command is allowed to run.
|
|
143
|
+
*
|
|
144
|
+
* `path.join` resolves `..`, so a working directory of `../../..` walks out
|
|
145
|
+
* of the throwaway folder and runs the command somewhere on this machine
|
|
146
|
+
* instead — the project's own directory, a home folder, anywhere. The service
|
|
147
|
+
* refuses to store one now, but a row written before it did is still a row,
|
|
148
|
+
* and this is the side that actually starts the process.
|
|
149
|
+
*
|
|
150
|
+
* Refused rather than clamped. Silently running somewhere other than where a
|
|
151
|
+
* workflow asked would be its own kind of surprise.
|
|
152
|
+
*/
|
|
153
|
+
function insideWorkspace(workspace, requested) {
|
|
154
|
+
const root = node_path_1.default.resolve(workspace);
|
|
155
|
+
const resolved = node_path_1.default.resolve(root, requested);
|
|
156
|
+
if (resolved !== root && !resolved.startsWith(root + node_path_1.default.sep)) {
|
|
157
|
+
throw new Error(`This workflow asks to run outside its own folder (${requested})`);
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
function errorText(error) {
|
|
162
|
+
return error instanceof Error ? error.message : String(error);
|
|
163
|
+
}
|
|
164
|
+
/** Ask for work. Null means the queue is empty, which is the usual answer. */
|
|
165
|
+
async function claim(repositoryId, runner, version, labels) {
|
|
166
|
+
const body = await call(`/v1/repositories/${repositoryId}/runs/claim`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: {
|
|
169
|
+
runner,
|
|
170
|
+
platform: `${node_os_2.default.platform()}-${node_os_2.default.arch()}`,
|
|
171
|
+
version,
|
|
172
|
+
labels,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return body.run ?? null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* What this machine answers to when nobody says.
|
|
179
|
+
*
|
|
180
|
+
* The platform it actually is, because that is what somebody means when they
|
|
181
|
+
* write a workflow that has to produce a Windows installer — and getting it
|
|
182
|
+
* wrong is not a crash, it is a build that runs on the wrong operating system
|
|
183
|
+
* and fails for a reason nobody asked about.
|
|
184
|
+
*/
|
|
185
|
+
function defaultLabels() {
|
|
186
|
+
const platform = node_os_2.default.platform();
|
|
187
|
+
if (platform === "win32")
|
|
188
|
+
return ["windows"];
|
|
189
|
+
if (platform === "darwin")
|
|
190
|
+
return ["macos"];
|
|
191
|
+
return ["linux"];
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Hand back what the run built.
|
|
195
|
+
*
|
|
196
|
+
* The bytes go up through the object store's own route, which verifies the
|
|
197
|
+
* digest on arrival — the same route versions use, rather than a second way
|
|
198
|
+
* to put bytes in that would be a second place to get that wrong. Then the
|
|
199
|
+
* run is told which object was which file.
|
|
200
|
+
*
|
|
201
|
+
* Failing to upload does not fail the run. The verdict is what somebody is
|
|
202
|
+
* waiting for, and losing a build that passed because the network went odd
|
|
203
|
+
* afterwards would be a worse answer than a pass with a missing download.
|
|
204
|
+
*/
|
|
205
|
+
async function uploadArtifacts(repositoryId, runId, workspace, patterns, say) {
|
|
206
|
+
const wanted = await collectArtifacts(workspace, patterns);
|
|
207
|
+
let kept = 0;
|
|
208
|
+
for (const file of wanted) {
|
|
209
|
+
try {
|
|
210
|
+
const bytes = await (0, promises_1.readFile)(file.absolute);
|
|
211
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
|
|
212
|
+
const token = await (0, config_js_1.loadToken)();
|
|
213
|
+
const stored = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
|
|
214
|
+
`/objects/${digest}?kind=chunk&role=chunk`, {
|
|
215
|
+
method: "PUT",
|
|
216
|
+
headers: {
|
|
217
|
+
authorization: `Bearer ${token}`,
|
|
218
|
+
"content-type": "application/octet-stream",
|
|
219
|
+
"content-length": String(bytes.byteLength),
|
|
220
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
221
|
+
},
|
|
222
|
+
body: bytes,
|
|
223
|
+
});
|
|
224
|
+
if (!stored.ok)
|
|
225
|
+
throw new Error(`upload failed (${stored.status})`);
|
|
226
|
+
const object = (await stored.json());
|
|
227
|
+
await call(`/v1/repositories/${repositoryId}/runs/${runId}/artifacts`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
body: {
|
|
230
|
+
path: file.relative,
|
|
231
|
+
objectId: object.objectId,
|
|
232
|
+
sizeBytes: bytes.byteLength,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
kept += 1;
|
|
236
|
+
say(`Kept ${file.relative} (${bytes.byteLength} bytes)`);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
say(`Could not keep ${file.relative}: ${errorText(error)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return kept;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Find the files a workflow asked to keep.
|
|
246
|
+
*
|
|
247
|
+
* Patterns are shell-ish rather than a full glob library: a directory keeps
|
|
248
|
+
* everything under it, and a `*` matches within one path segment. That covers
|
|
249
|
+
* `dist/`, `build/*.exe` and `out/**` — which is what people write — without
|
|
250
|
+
* taking a dependency to parse the rest.
|
|
251
|
+
*/
|
|
252
|
+
async function collectArtifacts(workspace, patterns) {
|
|
253
|
+
const everything = [];
|
|
254
|
+
const walk = async (directory) => {
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const at = node_path_1.default.join(directory, entry.name);
|
|
264
|
+
if (entry.isDirectory())
|
|
265
|
+
await walk(at);
|
|
266
|
+
else if (entry.isFile())
|
|
267
|
+
everything.push(at);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
await walk(workspace);
|
|
271
|
+
const found = new Map();
|
|
272
|
+
for (const raw of patterns) {
|
|
273
|
+
const pattern = normalisePattern(raw);
|
|
274
|
+
if (!pattern)
|
|
275
|
+
continue;
|
|
276
|
+
const matcher = patternMatcher(pattern);
|
|
277
|
+
for (const absolute of everything) {
|
|
278
|
+
const relative = node_path_1.default.relative(workspace, absolute).split(node_path_1.default.sep).join("/");
|
|
279
|
+
if (matcher(relative))
|
|
280
|
+
found.set(relative, { absolute, relative });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return [...found.values()];
|
|
284
|
+
}
|
|
285
|
+
/** Tidy a pattern, and refuse one that tries to leave the checkout. */
|
|
286
|
+
function normalisePattern(raw) {
|
|
287
|
+
const cleaned = raw.split("\\").join("/").replace(/^\/+/, "").trim();
|
|
288
|
+
if (!cleaned)
|
|
289
|
+
return null;
|
|
290
|
+
return cleaned.split("/").includes("..") ? null : cleaned;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Match one path against one pattern.
|
|
294
|
+
*
|
|
295
|
+
* `dist` keeps everything under it, `*` matches within a segment and `**`
|
|
296
|
+
* across them. Built by escaping the pattern and then putting the wildcards
|
|
297
|
+
* back, rather than by escaping around them — doing it the other way is how
|
|
298
|
+
* a dot in `*.exe` quietly becomes "any character".
|
|
299
|
+
*/
|
|
300
|
+
function patternMatcher(pattern) {
|
|
301
|
+
const expression = new RegExp(`^${pattern
|
|
302
|
+
.split("/")
|
|
303
|
+
.map((part) => part === "**"
|
|
304
|
+
? "[SPAN]"
|
|
305
|
+
: part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").split("\\*").join("[^/]*"))
|
|
306
|
+
.join("/")
|
|
307
|
+
.split("[SPAN]")
|
|
308
|
+
.join(".*")}$`);
|
|
309
|
+
return (candidate) => expression.test(candidate) || candidate.startsWith(`${pattern}/`);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Carry out one run, from a fresh copy of the version to a verdict.
|
|
313
|
+
*
|
|
314
|
+
* The version is materialised into a throwaway directory rather than into
|
|
315
|
+
* anybody's working folder: a run that wrote over the checkout somebody was
|
|
316
|
+
* editing would be a way to lose work, and a run that reused the last one's
|
|
317
|
+
* directory would let one job's leftovers decide the next job's result.
|
|
318
|
+
*/
|
|
319
|
+
async function performRun(repositoryId, run, runner) {
|
|
320
|
+
const logs = new LogShipper(repositoryId, run.runId);
|
|
321
|
+
const started = Date.now();
|
|
322
|
+
const workspace = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-run-"));
|
|
323
|
+
let beating;
|
|
324
|
+
let flushing;
|
|
325
|
+
try {
|
|
326
|
+
beating = setInterval(() => {
|
|
327
|
+
void call(`/v1/repositories/${repositoryId}/runs/${run.runId}/heartbeat`, {
|
|
328
|
+
method: "POST",
|
|
329
|
+
body: { runner },
|
|
330
|
+
}).catch(() => {
|
|
331
|
+
/*
|
|
332
|
+
A lost lease means the service has given this run to somebody else,
|
|
333
|
+
and the local process is now doing work nobody will accept. Stopping
|
|
334
|
+
the heartbeat is enough: the report at the end will be refused too,
|
|
335
|
+
and the run that matters is the one the other machine is doing.
|
|
336
|
+
*/
|
|
337
|
+
});
|
|
338
|
+
}, HEARTBEAT_MS);
|
|
339
|
+
if (run.versionId) {
|
|
340
|
+
logs.add(`Fetching v${run.versionSequence ?? "?"}…`, "stdout");
|
|
341
|
+
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
342
|
+
const got = await downloader.run(repositoryId, run.versionId, workspace, () => { }, null, "replace");
|
|
343
|
+
logs.add(`Fetched ${got.files} files.`, "stdout");
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
logs.add("This run has no version; running in an empty folder.", "stdout");
|
|
347
|
+
}
|
|
348
|
+
await logs.flush();
|
|
349
|
+
const command = run.command?.trim();
|
|
350
|
+
if (!command) {
|
|
351
|
+
return { status: "failed", summary: "This workflow has no command" };
|
|
352
|
+
}
|
|
353
|
+
const directory = run.workingDirectory
|
|
354
|
+
? insideWorkspace(workspace, run.workingDirectory)
|
|
355
|
+
: workspace;
|
|
356
|
+
flushing = setInterval(() => void logs.flush(), FLUSH_MS);
|
|
357
|
+
const code = await new Promise((settle) => {
|
|
358
|
+
/*
|
|
359
|
+
Through a shell, because the command is one shell line and people
|
|
360
|
+
write them expecting a shell — pipes, `&&`, an environment variable.
|
|
361
|
+
Which shell is the platform's own, so a Windows machine reads it the
|
|
362
|
+
way a Windows user wrote it.
|
|
363
|
+
*/
|
|
364
|
+
const child = (0, node_child_process_1.spawn)(command, {
|
|
365
|
+
cwd: directory,
|
|
366
|
+
shell: true,
|
|
367
|
+
env: {
|
|
368
|
+
...withoutCredentials(process.env),
|
|
369
|
+
CI: "true",
|
|
370
|
+
CODEROOK_RUN: String(run.number),
|
|
371
|
+
CODEROOK_VERSION: run.versionSequence
|
|
372
|
+
? `v${run.versionSequence}`
|
|
373
|
+
: "",
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
child.stdout?.on("data", (chunk) => logs.add(chunk.toString("utf8"), "stdout"));
|
|
377
|
+
child.stderr?.on("data", (chunk) => logs.add(chunk.toString("utf8"), "stderr"));
|
|
378
|
+
child.on("error", (error) => {
|
|
379
|
+
logs.add(errorText(error), "stderr");
|
|
380
|
+
settle(1);
|
|
381
|
+
});
|
|
382
|
+
child.on("close", (status) => settle(status ?? 1));
|
|
383
|
+
});
|
|
384
|
+
/*
|
|
385
|
+
What it built, before the folder goes.
|
|
386
|
+
|
|
387
|
+
Kept whether the run passed or failed: a failed build often produces the
|
|
388
|
+
very log or partial output somebody needs to work out why, and throwing
|
|
389
|
+
that away is exactly when they would have wanted it.
|
|
390
|
+
*/
|
|
391
|
+
if (run.artifactPaths?.length) {
|
|
392
|
+
const kept = await uploadArtifacts(repositoryId, run.runId, workspace, run.artifactPaths, (line) => logs.add(line, "stdout"));
|
|
393
|
+
if (!kept) {
|
|
394
|
+
logs.add("Nothing matched the paths this workflow keeps.", "stdout");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
await logs.flush();
|
|
398
|
+
const seconds = Math.round((Date.now() - started) / 1000);
|
|
399
|
+
return code === 0
|
|
400
|
+
? { status: "passed", summary: `Passed in ${seconds}s` }
|
|
401
|
+
: { status: "failed", summary: `Exited ${code} after ${seconds}s` };
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
logs.add(errorText(error), "stderr");
|
|
405
|
+
await logs.flush();
|
|
406
|
+
return { status: "failed", summary: errorText(error).slice(0, 200) };
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
if (beating)
|
|
410
|
+
clearInterval(beating);
|
|
411
|
+
if (flushing)
|
|
412
|
+
clearInterval(flushing);
|
|
413
|
+
await logs.flush();
|
|
414
|
+
await (0, promises_1.rm)(workspace, { recursive: true, force: true }).catch(() => { });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Say how it went. */
|
|
418
|
+
async function report(repositoryId, runId, verdict, durationMs) {
|
|
419
|
+
await call(`/v1/repositories/${repositoryId}/runs/${runId}`, {
|
|
420
|
+
method: "PATCH",
|
|
421
|
+
body: { ...verdict, durationMs },
|
|
422
|
+
});
|
|
423
|
+
}
|