@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runnerListCommand = void 0;
|
|
4
|
+
exports.runnerAddCmd = runnerAddCmd;
|
|
5
|
+
exports.runnerWakeCmd = runnerWakeCmd;
|
|
6
|
+
exports.runnerRemoveCmd = runnerRemoveCmd;
|
|
7
|
+
exports.runnerRunCmd = runnerRunCmd;
|
|
8
|
+
const cli_api_1 = require("./cli-api");
|
|
9
|
+
const output_1 = require("./cli/output");
|
|
10
|
+
const target_1 = require("./cli/target");
|
|
11
|
+
const credentials_1 = require("./credentials");
|
|
12
|
+
const job_cli_1 = require("./job-cli");
|
|
13
|
+
const client_1 = require("./runner/client");
|
|
14
|
+
const docker_1 = require("./runner/docker");
|
|
15
|
+
const wake_1 = require("./ci/wake");
|
|
16
|
+
const vault_1 = require("./vault");
|
|
17
|
+
/**
|
|
18
|
+
* "5m", "30s", "300" -> seconds.
|
|
19
|
+
*
|
|
20
|
+
* Written out because the one place this is used is a timeout an operator
|
|
21
|
+
* chooses in minutes and a program wants in seconds, and "--idle 300" for
|
|
22
|
+
* five minutes is a needless piece of arithmetic to ask of a reader.
|
|
23
|
+
*/
|
|
24
|
+
function parseDuration(text) {
|
|
25
|
+
const m = /^(\d+)\s*(s|m|h)?$/i.exec(text.trim());
|
|
26
|
+
if (!m) {
|
|
27
|
+
console.error(`--idle takes a duration like 5m, 90s, or 1h, got: ${text}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const n = parseInt(m[1], 10);
|
|
31
|
+
const unit = (m[2] ?? 's').toLowerCase();
|
|
32
|
+
return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
|
|
33
|
+
}
|
|
34
|
+
function parseArgs(args, usage) {
|
|
35
|
+
const out = {
|
|
36
|
+
name: null,
|
|
37
|
+
host: null,
|
|
38
|
+
token: null,
|
|
39
|
+
runnerToken: null,
|
|
40
|
+
labels: [],
|
|
41
|
+
allow: [],
|
|
42
|
+
images: {},
|
|
43
|
+
workDir: null,
|
|
44
|
+
network: null,
|
|
45
|
+
cacheDir: null,
|
|
46
|
+
actionsUrl: null,
|
|
47
|
+
actionCache: true,
|
|
48
|
+
save: false,
|
|
49
|
+
idleSeconds: null,
|
|
50
|
+
wakePort: null,
|
|
51
|
+
wakeSecret: null,
|
|
52
|
+
wakeUrl: null,
|
|
53
|
+
clear: false,
|
|
54
|
+
engine: null,
|
|
55
|
+
};
|
|
56
|
+
const list = (v) => v.split(/[\s,]+/).filter((s) => s.length > 0);
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const a = args[i];
|
|
59
|
+
if (a === '-h' || a === '--help')
|
|
60
|
+
usage();
|
|
61
|
+
else if (a === '--host')
|
|
62
|
+
out.host = args[++i];
|
|
63
|
+
else if (a === '--token')
|
|
64
|
+
out.token = args[++i];
|
|
65
|
+
else if (a === '--runner-token')
|
|
66
|
+
out.runnerToken = args[++i];
|
|
67
|
+
else if (a === '--labels' || a === '--label')
|
|
68
|
+
out.labels.push(...list(args[++i] ?? ''));
|
|
69
|
+
else if (a === '--allow')
|
|
70
|
+
out.allow.push(...list(args[++i] ?? ''));
|
|
71
|
+
else if (a === '--work-dir')
|
|
72
|
+
out.workDir = args[++i];
|
|
73
|
+
else if (a === '--cache-dir')
|
|
74
|
+
out.cacheDir = args[++i];
|
|
75
|
+
else if (a === '--actions-url')
|
|
76
|
+
out.actionsUrl = args[++i];
|
|
77
|
+
else if (a === '--no-action-cache')
|
|
78
|
+
out.actionCache = false;
|
|
79
|
+
else if (a === '--network')
|
|
80
|
+
out.network = args[++i];
|
|
81
|
+
else if (a === '--idle')
|
|
82
|
+
out.idleSeconds = parseDuration(args[++i] ?? '');
|
|
83
|
+
else if (a === '--wake-port') {
|
|
84
|
+
const port = parseInt(args[++i] ?? '', 10);
|
|
85
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
86
|
+
console.error('--wake-port takes a port number, e.g. --wake-port 3000');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
out.wakePort = port;
|
|
90
|
+
}
|
|
91
|
+
else if (a === '--wake-secret')
|
|
92
|
+
out.wakeSecret = args[++i];
|
|
93
|
+
else if (a === '--wake-url')
|
|
94
|
+
out.wakeUrl = args[++i];
|
|
95
|
+
else if (a === '--clear')
|
|
96
|
+
out.clear = true;
|
|
97
|
+
else if (a === '--save')
|
|
98
|
+
out.save = true;
|
|
99
|
+
else if (a === '--engine') {
|
|
100
|
+
const e = args[++i] ?? '';
|
|
101
|
+
if (e !== 'docker' && e !== 'podman') {
|
|
102
|
+
console.error(`--engine takes docker or podman, got: ${e}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
out.engine = e;
|
|
106
|
+
}
|
|
107
|
+
else if (a === '--image') {
|
|
108
|
+
// --image ubuntu-latest=my/image:tag
|
|
109
|
+
const spec = args[++i] ?? '';
|
|
110
|
+
const eq = spec.indexOf('=');
|
|
111
|
+
if (eq === -1) {
|
|
112
|
+
console.error(`--image needs <label>=<image>, got: ${spec}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
out.images[spec.slice(0, eq)] = spec.slice(eq + 1);
|
|
116
|
+
}
|
|
117
|
+
else if (a.startsWith('-')) {
|
|
118
|
+
console.error(`Unknown option: ${a}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
else if (!out.name)
|
|
122
|
+
out.name = a;
|
|
123
|
+
else {
|
|
124
|
+
console.error(`Unexpected argument: ${a}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
// Registration is an ordinary admin operation, so it uses the same login as
|
|
131
|
+
// `mochi user add` rather than any arrangement of its own.
|
|
132
|
+
async function runnerAddCmd(args, usage) {
|
|
133
|
+
const a = parseArgs(args, usage);
|
|
134
|
+
if (!a.name) {
|
|
135
|
+
console.error('A runner name is required: mochi runner add <name> --allow <glob>');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
if (a.allow.length === 0) {
|
|
139
|
+
console.error('Say which repositories this runner may take jobs for: --allow "mycollection/*".\n' +
|
|
140
|
+
'A runner executes whatever those repositories\' workflows contain, on the machine you start it on.');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const target = await (0, cli_api_1.remoteTarget)(a);
|
|
144
|
+
const labels = a.labels.length ? a.labels : ['ubuntu-latest'];
|
|
145
|
+
// A wake address is optional, and its secret is generated here when one is
|
|
146
|
+
// not supplied: it is a credential nobody has to remember, since the vault
|
|
147
|
+
// sends it and the runner is given it as an environment variable.
|
|
148
|
+
const wake = a.wakeUrl ? { url: a.wakeUrl, secret: a.wakeSecret ?? (0, wake_1.newWakeSecret)() } : null;
|
|
149
|
+
const data = await (0, cli_api_1.api)(target, 'POST', '/api/runners', {
|
|
150
|
+
name: a.name,
|
|
151
|
+
labels,
|
|
152
|
+
allow: a.allow,
|
|
153
|
+
...(wake ? { wakeUrl: wake.url, wakeSecret: wake.secret } : {}),
|
|
154
|
+
});
|
|
155
|
+
console.log(`Registered runner ${data.name}`);
|
|
156
|
+
console.log(` labels: ${data.labels.join(', ')}`);
|
|
157
|
+
console.log(` serving: ${data.allow.join(', ')}`);
|
|
158
|
+
if (wake)
|
|
159
|
+
console.log(` wake: ${wake.url}`);
|
|
160
|
+
console.log('');
|
|
161
|
+
console.log('Runner token (shown once; only its hash is stored):');
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(` ${data.token}`);
|
|
164
|
+
console.log('');
|
|
165
|
+
if (a.save) {
|
|
166
|
+
const config = { host: target.host, token: data.token, labels };
|
|
167
|
+
if (a.workDir)
|
|
168
|
+
config.workDir = a.workDir;
|
|
169
|
+
if (a.network)
|
|
170
|
+
config.network = a.network;
|
|
171
|
+
if (Object.keys(a.images).length)
|
|
172
|
+
config.images = a.images;
|
|
173
|
+
(0, client_1.saveRunnerConfig)(config);
|
|
174
|
+
console.log(`Saved to ${(0, client_1.configPath)()}. Start it with:`);
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(' mochi runner run');
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.log('On the machine that will run jobs (with Docker installed):');
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log(` mochi runner run --host ${target.host} --runner-token ${data.token}`);
|
|
182
|
+
}
|
|
183
|
+
if (wake) {
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('This runner has a wake address, so the vault will start it when a job is');
|
|
186
|
+
console.log('waiting and nothing is polling. Start it with the matching secret, and with an');
|
|
187
|
+
console.log('idle timeout, or there will be nothing to wake:');
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log(` MOCHI_WAKE_SECRET=${wake.secret} \\`);
|
|
190
|
+
console.log(` mochi runner run --idle 5m --wake-port 3000`);
|
|
191
|
+
}
|
|
192
|
+
console.log('');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* `mochi runner wake <name>`: send the wake request, or change where it goes.
|
|
196
|
+
*
|
|
197
|
+
* Sending it is the useful default, because the question an operator has is
|
|
198
|
+
* almost always "does this actually start" rather than "what is stored". The
|
|
199
|
+
* vault does the sending, since the vault is the only party holding the
|
|
200
|
+
* secret; what comes back is how long it took to answer, which on a machine
|
|
201
|
+
* that has to boot is the number worth knowing.
|
|
202
|
+
*/
|
|
203
|
+
async function runnerWakeCmd(args, usage) {
|
|
204
|
+
const a = parseArgs(args, usage);
|
|
205
|
+
if (!a.name) {
|
|
206
|
+
console.error('Which runner? Usage: mochi runner wake <name> [--url <url>] [--clear]');
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
if (a.clear && a.wakeUrl) {
|
|
210
|
+
console.error('--clear removes the wake address and --url sets one. Pass one or the other.');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
const target = await (0, cli_api_1.remoteTarget)(a);
|
|
214
|
+
const path = `/api/runners/${encodeURIComponent(a.name)}/wake`;
|
|
215
|
+
if (a.clear) {
|
|
216
|
+
await (0, cli_api_1.api)(target, 'PUT', path, {});
|
|
217
|
+
console.log(`Runner ${a.name} has no wake address now, so nothing will start it.`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (a.wakeUrl) {
|
|
221
|
+
const secret = a.wakeSecret ?? (0, wake_1.newWakeSecret)();
|
|
222
|
+
await (0, cli_api_1.api)(target, 'PUT', path, { wakeUrl: a.wakeUrl, wakeSecret: secret });
|
|
223
|
+
console.log(`Runner ${a.name} will be woken at ${a.wakeUrl}`);
|
|
224
|
+
if (!a.wakeSecret) {
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log('The secret it must present, generated now and shown once here because the');
|
|
227
|
+
console.log('runner has to be started with the same one:');
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(` ${secret}`);
|
|
230
|
+
console.log('');
|
|
231
|
+
console.log(' MOCHI_WAKE_SECRET=<that> mochi runner run --idle 5m --wake-port 3000');
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const data = await (0, cli_api_1.api)(target, 'POST', path, {});
|
|
236
|
+
console.log(`Woke ${a.name} at ${String(data.wakeUrl)} in ${String(data.seconds)}s.`);
|
|
237
|
+
}
|
|
238
|
+
/** "3s ago", for the one column where a bare ISO timestamp would be unreadable. */
|
|
239
|
+
function ago(iso) {
|
|
240
|
+
if (!iso)
|
|
241
|
+
return 'never';
|
|
242
|
+
const secs = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
243
|
+
if (secs < 90)
|
|
244
|
+
return `${secs}s ago`;
|
|
245
|
+
if (secs < 5400)
|
|
246
|
+
return `${Math.round(secs / 60)}m ago`;
|
|
247
|
+
return `${Math.round(secs / 3600)}h ago`;
|
|
248
|
+
}
|
|
249
|
+
function jobLabel(j) {
|
|
250
|
+
return `${j.collection}/${j.repo} #${j.run} ${j.job}`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A queued job that no registered runner can take: its labels match nobody, or
|
|
254
|
+
* nobody's allow list covers its repository. This is the answer to "the run
|
|
255
|
+
* never started" that neither the run nor the registry gives on its own, so it
|
|
256
|
+
* is worth computing here rather than leaving to the reader.
|
|
257
|
+
*/
|
|
258
|
+
function unservedJobs(queued, runners) {
|
|
259
|
+
return queued.filter((j) =>
|
|
260
|
+
// A manual job is waiting for a pasted command by design, so no runner
|
|
261
|
+
// failing to match it is not the problem this list exists to surface.
|
|
262
|
+
!j.manual &&
|
|
263
|
+
!runners.some((r) => r.allow.some((g) => (0, vault_1.globMatch)(g, `${j.collection}/${j.repo}`)) &&
|
|
264
|
+
j.runsOn.some((l) => r.labels.includes(l))));
|
|
265
|
+
}
|
|
266
|
+
exports.runnerListCommand = {
|
|
267
|
+
path: ['runner', 'list'],
|
|
268
|
+
summary: 'Show registered runners, whether each is connected, and what is queued',
|
|
269
|
+
description: `Needs an admin token, as the other runner registration commands do.
|
|
270
|
+
|
|
271
|
+
Beside the registry it reports liveness: when each runner last spoke to the vault
|
|
272
|
+
(since the vault started; a restart forgets it and every live runner re-announces
|
|
273
|
+
within one poll), the job it is holding now, and the jobs waiting for a runner. A
|
|
274
|
+
queued job that no runner's labels and allow globs match is called out, since that
|
|
275
|
+
is the usual reason a run sits at queued forever.`,
|
|
276
|
+
options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
|
|
277
|
+
async run(inv) {
|
|
278
|
+
const target = await (0, target_1.targetFrom)(inv);
|
|
279
|
+
const data = await (0, cli_api_1.api)(target, 'GET', '/api/runners');
|
|
280
|
+
const runners = (data.runners ?? []);
|
|
281
|
+
const queued = (data.queued ?? []);
|
|
282
|
+
const json = (0, output_1.jsonMode)(inv);
|
|
283
|
+
if (json.enabled) {
|
|
284
|
+
(0, output_1.printJson)({ runners: (0, output_1.pickFields)(runners, json.fields), queued });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (runners.length === 0) {
|
|
288
|
+
console.log('No runners registered. Runs will queue and wait: mochi runner add <name> --allow <glob>');
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
(0, output_1.printTable)(runners.map((r) => [
|
|
292
|
+
r.name,
|
|
293
|
+
`labels: ${r.labels.join(', ') || '(none)'}`,
|
|
294
|
+
`serving: ${r.allow.join(', ') || '(none)'}`,
|
|
295
|
+
// A vault older than liveness reporting leaves both out, and saying
|
|
296
|
+
// "seen never" of a runner that may be perfectly healthy would be
|
|
297
|
+
// worse than saying nothing.
|
|
298
|
+
r.lastSeen === undefined && !r.running
|
|
299
|
+
? r.createdBy
|
|
300
|
+
? `by ${r.createdBy}`
|
|
301
|
+
: ''
|
|
302
|
+
: r.running
|
|
303
|
+
? `running ${jobLabel(r.running)}`
|
|
304
|
+
: // A runner with a wake address is meant to be absent between
|
|
305
|
+
// jobs, so reporting it as idle since an hour ago without that
|
|
306
|
+
// context reads as a fault rather than as the arrangement
|
|
307
|
+
// working.
|
|
308
|
+
`idle, seen ${ago(r.lastSeen ?? null)}${r.wakeUrl ? ', woken on demand' : ''}`,
|
|
309
|
+
]));
|
|
310
|
+
}
|
|
311
|
+
if (queued.length > 0) {
|
|
312
|
+
const automatic = queued.filter((j) => !j.manual);
|
|
313
|
+
const manual = queued.filter((j) => j.manual);
|
|
314
|
+
console.log('');
|
|
315
|
+
if (automatic.length > 0) {
|
|
316
|
+
console.log(`${automatic.length} job${automatic.length === 1 ? '' : 's'} waiting for a runner:`);
|
|
317
|
+
for (const j of automatic)
|
|
318
|
+
console.log(` ${jobLabel(j)} (runs-on: ${j.runsOn.join(', ')})`);
|
|
319
|
+
}
|
|
320
|
+
if (manual.length > 0) {
|
|
321
|
+
console.log(`${manual.length} manual job${manual.length === 1 ? '' : 's'} waiting for a pasted command (mochi run exec-command <run>):`);
|
|
322
|
+
for (const j of manual)
|
|
323
|
+
console.log(` ${jobLabel(j)} (runs-on: ${j.runsOn.join(', ')})`);
|
|
324
|
+
}
|
|
325
|
+
const unserved = unservedJobs(queued, runners);
|
|
326
|
+
if (unserved.length > 0) {
|
|
327
|
+
console.log('');
|
|
328
|
+
console.log(`No registered runner can take ${unserved.length === automatic.length ? 'them' : `${unserved.length} of them`}: check the runs-on labels against each runner's labels, and the repository against its serving globs.`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
async function runnerRemoveCmd(args, usage) {
|
|
334
|
+
const a = parseArgs(args, usage);
|
|
335
|
+
if (!a.name) {
|
|
336
|
+
console.error('A runner name is required: mochi runner remove <name>');
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
const target = await (0, cli_api_1.remoteTarget)(a);
|
|
340
|
+
await (0, cli_api_1.api)(target, 'DELETE', `/api/runners/${encodeURIComponent(a.name)}`);
|
|
341
|
+
console.log(`Removed runner ${a.name}`);
|
|
342
|
+
}
|
|
343
|
+
async function runnerRunCmd(args, usage) {
|
|
344
|
+
const a = parseArgs(args, usage);
|
|
345
|
+
const saved = (0, client_1.loadRunnerConfig)();
|
|
346
|
+
// A runner's token is its own, not a user's, so it is not something login
|
|
347
|
+
// stored; only the vault URL can be borrowed from a login on this machine.
|
|
348
|
+
const host = (a.host ?? saved?.host ?? (0, credentials_1.loadLogin)()?.host ?? '').replace(/\/+$/, '');
|
|
349
|
+
const token = a.runnerToken ?? process.env.MOCHI_RUNNER_TOKEN ?? saved?.token ?? '';
|
|
350
|
+
if (!host || !token) {
|
|
351
|
+
console.error(`No runner credentials. Register one with:\n\n` +
|
|
352
|
+
` mochi runner add <name> --allow 'mycollection/*' --save\n\n` +
|
|
353
|
+
`or pass --host and --runner-token, or write ${(0, client_1.configPath)()}.`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
const config = {
|
|
357
|
+
host,
|
|
358
|
+
token,
|
|
359
|
+
labels: a.labels.length ? a.labels : saved?.labels,
|
|
360
|
+
images: { ...client_1.DEFAULT_IMAGES, ...(saved?.images ?? {}), ...a.images },
|
|
361
|
+
workDir: a.workDir ?? saved?.workDir,
|
|
362
|
+
network: a.network ?? saved?.network,
|
|
363
|
+
cacheDir: a.cacheDir ?? saved?.cacheDir,
|
|
364
|
+
actionsUrl: a.actionsUrl ?? saved?.actionsUrl,
|
|
365
|
+
actionCache: a.actionCache && (saved?.actionCache ?? true),
|
|
366
|
+
idleSeconds: a.idleSeconds ?? saved?.idleSeconds,
|
|
367
|
+
wakePort: a.wakePort ?? saved?.wakePort,
|
|
368
|
+
// From the environment by preference, like the runner token above: this is
|
|
369
|
+
// the credential a wake request must present, and a secret in argv is
|
|
370
|
+
// readable by every other user on the machine.
|
|
371
|
+
wakeSecret: a.wakeSecret ?? process.env.MOCHI_WAKE_SECRET ?? saved?.wakeSecret,
|
|
372
|
+
};
|
|
373
|
+
if (config.wakePort && !config.wakeSecret) {
|
|
374
|
+
console.error('A wake port needs a secret to check against: pass --wake-secret, or set MOCHI_WAKE_SECRET.\n' +
|
|
375
|
+
'Without one, anything that can reach this port could start this runner.');
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
if (config.idleSeconds && !config.wakePort) {
|
|
379
|
+
// Not an error: a runner started by cron, or by hand for one run, is meant
|
|
380
|
+
// to stop and stay stopped. But the usual reason to pass --idle is the
|
|
381
|
+
// other arrangement, and a runner that stops with nothing able to start it
|
|
382
|
+
// looks exactly like a runner that has failed.
|
|
383
|
+
console.log('Note: this runner will stop when idle, and has no wake port, so nothing can start it.');
|
|
384
|
+
console.log(' Add --wake-port to let the vault do it: mochi runner wake --help');
|
|
385
|
+
}
|
|
386
|
+
if (a.save) {
|
|
387
|
+
(0, client_1.saveRunnerConfig)(config);
|
|
388
|
+
console.log(`Saved to ${(0, client_1.configPath)()}`);
|
|
389
|
+
}
|
|
390
|
+
// Which container engine carries the jobs: the flag if given, whichever of
|
|
391
|
+
// docker and podman works if only one does, a question when both do and
|
|
392
|
+
// there is a terminal to ask on, docker otherwise. Chosen before the runner
|
|
393
|
+
// starts, because a runner must use one engine for everything or nothing.
|
|
394
|
+
const engine = await (0, job_cli_1.chooseEngine)(a.engine, process.stdin.isTTY === true && process.stdout.isTTY === true);
|
|
395
|
+
(0, docker_1.setContainerEngine)(engine);
|
|
396
|
+
const runner = new client_1.Runner(config);
|
|
397
|
+
const stop = () => {
|
|
398
|
+
console.log('\nStopping after the current job. Press Ctrl-C again to quit now.');
|
|
399
|
+
runner.stop();
|
|
400
|
+
process.once('SIGINT', () => process.exit(130));
|
|
401
|
+
};
|
|
402
|
+
process.once('SIGINT', stop);
|
|
403
|
+
process.once('SIGTERM', stop);
|
|
404
|
+
await runner.loop();
|
|
405
|
+
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.repoSiblingSuffixes = void 0;
|
|
37
|
+
exports.isValidName = isValidName;
|
|
38
|
+
exports.isDotName = isDotName;
|
|
39
|
+
exports.isValidUserName = isValidUserName;
|
|
40
|
+
exports.reservedRepoSuffix = reservedRepoSuffix;
|
|
41
|
+
exports.isBareRepo = isBareRepo;
|
|
42
|
+
exports.displayName = displayName;
|
|
43
|
+
exports.listRepoDirs = listRepoDirs;
|
|
44
|
+
exports.listCollections = listCollections;
|
|
45
|
+
exports.findRepo = findRepo;
|
|
46
|
+
exports.siteDir = siteDir;
|
|
47
|
+
exports.forkParent = forkParent;
|
|
48
|
+
exports.repoDescription = repoDescription;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const git_1 = require("./git");
|
|
52
|
+
const layout_1 = require("./layout");
|
|
53
|
+
// Names the UI owns as top-level path segments. None of these may ever be a
|
|
54
|
+
// collection or repo name, since a collection is reached at /<collection> and
|
|
55
|
+
// a repository at /<collection>/<repo>. The vault's own files are not among
|
|
56
|
+
// them: they sit beside `collections/` rather than in it, so no file of the
|
|
57
|
+
// vault's can collide with a name a user chose.
|
|
58
|
+
const RESERVED_NAMES = new Set([
|
|
59
|
+
'about',
|
|
60
|
+
'api',
|
|
61
|
+
'assets',
|
|
62
|
+
'favicon.ico',
|
|
63
|
+
'favicon.svg',
|
|
64
|
+
'login',
|
|
65
|
+
'logout',
|
|
66
|
+
'new',
|
|
67
|
+
'import',
|
|
68
|
+
'admin',
|
|
69
|
+
'settings',
|
|
70
|
+
]);
|
|
71
|
+
function isValidName(name) {
|
|
72
|
+
if (RESERVED_NAMES.has(name))
|
|
73
|
+
return false;
|
|
74
|
+
return /^\.?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes('..');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a name is dot-prefixed. A single leading dot is allowed so that a
|
|
78
|
+
* repository can carry a name the interface reads rather than a name a project
|
|
79
|
+
* chose: `.mochi` holds a collection's profile README (see
|
|
80
|
+
* src/profile.ts), and the shape is left open for whatever else the forge
|
|
81
|
+
* later wants to read out of a collection.
|
|
82
|
+
*
|
|
83
|
+
* Only a repository may carry such a name. A collection or a user named with a
|
|
84
|
+
* leading dot would be a hidden directory in the vault and a hidden address in
|
|
85
|
+
* the interface, with nothing gained, so both are refused where they are
|
|
86
|
+
* created. Reading stays permissive: isValidName above accepts the dot for
|
|
87
|
+
* every kind of name, since a route that is asked for one only has to resolve
|
|
88
|
+
* it, and a vault that somehow holds one should still serve what it holds.
|
|
89
|
+
*/
|
|
90
|
+
function isDotName(name) {
|
|
91
|
+
return name.startsWith('.');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A username. A name being looked up is checked with isValidName, which
|
|
95
|
+
* accepts a leading dot; this is the stricter rule applied where a user is
|
|
96
|
+
* created, since only a repository may carry one.
|
|
97
|
+
*/
|
|
98
|
+
function isValidUserName(name) {
|
|
99
|
+
return isValidName(name) && !isDotName(name);
|
|
100
|
+
}
|
|
101
|
+
// The directories a repository accumulates beside its bare repository, by the
|
|
102
|
+
// suffix each one carries. Every feature has its own helper for the directory
|
|
103
|
+
// it owns (siteDir, runsDir, issuesDir, pullsDir, releasesDir), but rename and
|
|
104
|
+
// delete are the two places that need the whole set, and a set spelled out in
|
|
105
|
+
// two places is a set one of them falls behind on. The list therefore lives
|
|
106
|
+
// here, beside the rest of what may and may not be a name, so a sibling added
|
|
107
|
+
// later is added once and is refused as a new repository's name for free.
|
|
108
|
+
//
|
|
109
|
+
// LFS objects are not in the list. Without a bucket they do sit in a sibling
|
|
110
|
+
// <repo>.lfs, but with one they do not sit on disk at all, so the store is
|
|
111
|
+
// asked to move or drop them rather than having its path assumed.
|
|
112
|
+
exports.repoSiblingSuffixes = ['.site', '.runs', '.issues', '.pulls', '.releases'];
|
|
113
|
+
// Suffixes a repository may not be created under. A repository named
|
|
114
|
+
// webapp.site would land on exactly the path the repository webapp keeps its
|
|
115
|
+
// site in, so without this a user with the write role could shadow another
|
|
116
|
+
// repository's site, or have their own issues served as that repository's.
|
|
117
|
+
// The list is the siblings plus two the siblings do not name: .lfs, which is
|
|
118
|
+
// where LFS objects sit when no bucket is configured, and .git, the ordinary
|
|
119
|
+
// on-disk spelling of a bare repository, since displayName would resolve a
|
|
120
|
+
// new webapp.git back to webapp. Compared case-insensitively, because a
|
|
121
|
+
// case-insensitive filesystem would collide where a case-sensitive one would
|
|
122
|
+
// not, and the refusal should not depend on which one is underneath.
|
|
123
|
+
const reservedRepoSuffixes = [...exports.repoSiblingSuffixes, '.lfs', '.git'];
|
|
124
|
+
/**
|
|
125
|
+
* The reserved suffix a proposed repository name ends in, or null if it ends
|
|
126
|
+
* in none. Callers report the suffix, since a name is much easier to fix when
|
|
127
|
+
* the refusal says which part of it is the problem. Asked only where a
|
|
128
|
+
* repository comes into being: reading stays more permissive, so a repository
|
|
129
|
+
* created before this check existed keeps working.
|
|
130
|
+
*/
|
|
131
|
+
function reservedRepoSuffix(name) {
|
|
132
|
+
const lower = name.toLowerCase();
|
|
133
|
+
return reservedRepoSuffixes.find((s) => lower.endsWith(s)) ?? null;
|
|
134
|
+
}
|
|
135
|
+
function isBareRepo(dir) {
|
|
136
|
+
try {
|
|
137
|
+
return (fs.statSync(path.join(dir, 'HEAD')).isFile() &&
|
|
138
|
+
fs.statSync(path.join(dir, 'objects')).isDirectory() &&
|
|
139
|
+
fs.statSync(path.join(dir, 'refs')).isDirectory());
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function displayName(dirName) {
|
|
146
|
+
return dirName.replace(/\.git$/, '');
|
|
147
|
+
}
|
|
148
|
+
function listRepoDirs(root, collection) {
|
|
149
|
+
const dir = (0, layout_1.reposDir)(root, collection);
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
return entries
|
|
158
|
+
.filter((e) => e.isDirectory() && isValidName(e.name) && isBareRepo(path.join(dir, e.name)))
|
|
159
|
+
.map((e) => e.name)
|
|
160
|
+
.sort();
|
|
161
|
+
}
|
|
162
|
+
function listCollections(root) {
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = fs.readdirSync((0, layout_1.collectionsDir)(root), { withFileTypes: true });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
return entries
|
|
171
|
+
.filter((e) => e.isDirectory() && isValidName(e.name) && !isDotName(e.name))
|
|
172
|
+
.map((e) => ({ name: e.name, repoCount: listRepoDirs(root, e.name).length }))
|
|
173
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
174
|
+
}
|
|
175
|
+
function findRepo(root, collection, repoName) {
|
|
176
|
+
if (!isValidName(collection) || !isValidName(repoName))
|
|
177
|
+
return null;
|
|
178
|
+
const base = displayName(repoName);
|
|
179
|
+
for (const cand of [base, base + '.git']) {
|
|
180
|
+
const dir = (0, layout_1.repoPath)(root, collection, cand);
|
|
181
|
+
if (isBareRepo(dir))
|
|
182
|
+
return new git_1.GitRepo(dir, collection, base);
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// A repository's static site lives in a sibling directory, `<repo>.site`.
|
|
187
|
+
function siteDir(root, collection, repoName) {
|
|
188
|
+
if (!isValidName(collection) || !isValidName(repoName))
|
|
189
|
+
return null;
|
|
190
|
+
const dir = (0, layout_1.repoPath)(root, collection, `${displayName(repoName)}.site`);
|
|
191
|
+
try {
|
|
192
|
+
if (fs.statSync(dir).isDirectory())
|
|
193
|
+
return dir;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// no site directory
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The repository this one was forked from, as `<collection>/<repo>`, or null
|
|
202
|
+
* if it was not. Read out of the bare repository's own config file rather
|
|
203
|
+
* than through git, since this is asked on page renders.
|
|
204
|
+
*/
|
|
205
|
+
function forkParent(dir) {
|
|
206
|
+
let text;
|
|
207
|
+
try {
|
|
208
|
+
text = fs.readFileSync(path.join(dir, 'config'), 'utf8');
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const m = text.match(/^\s*forkedFrom\s*=\s*(\S+)\s*$/m);
|
|
214
|
+
if (!m)
|
|
215
|
+
return null;
|
|
216
|
+
const [collection, repo] = m[1].split('/');
|
|
217
|
+
if (!collection || !repo || !isValidName(collection) || !isValidName(repo))
|
|
218
|
+
return null;
|
|
219
|
+
return { collection, repo };
|
|
220
|
+
}
|
|
221
|
+
function repoDescription(dir) {
|
|
222
|
+
try {
|
|
223
|
+
const t = fs.readFileSync(path.join(dir, 'description'), 'utf8').trim();
|
|
224
|
+
if (!t || t.startsWith('Unnamed repository'))
|
|
225
|
+
return null;
|
|
226
|
+
return t;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|