@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.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
@@ -0,0 +1,441 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runsPage = runsPage;
4
+ exports.runPage = runPage;
5
+ exports.execCommandPage = execCommandPage;
6
+ exports.runnersPage = runnersPage;
7
+ exports.runnerPage = runnerPage;
8
+ exports.runnerTokenPage = runnerTokenPage;
9
+ exports.runnerWakePage = runnerWakePage;
10
+ const ansi_1 = require("../ansi");
11
+ const avatar_1 = require("../avatar");
12
+ const html_1 = require("../html");
13
+ const icons_1 = require("../icons");
14
+ const render_1 = require("../render");
15
+ const forms_1 = require("../forms");
16
+ const views_1 = require("../views");
17
+ const manual_1 = require("./manual");
18
+ function statusOf(x) {
19
+ if (x.status !== 'completed')
20
+ return x.status === 'running' ? 'running' : 'queued';
21
+ const c = x.conclusion;
22
+ if (c === 'success' || c === 'failure' || c === 'cancelled' || c === 'skipped')
23
+ return c;
24
+ return 'failure';
25
+ }
26
+ const STATUS_LABEL = {
27
+ queued: 'Queued',
28
+ running: 'Running',
29
+ success: 'Success',
30
+ failure: 'Failure',
31
+ cancelled: 'Cancelled',
32
+ skipped: 'Skipped',
33
+ };
34
+ // The status glyphs are the ringed ones from icons.ts: a tick in a ring for
35
+ // success, a cross in a ring for failure, a turning arc while a job runs, and
36
+ // grey for the states where nothing happened. Ringed rather than filled, so a
37
+ // column of them reads at the weight of the text beside it.
38
+ const STATUS_ICON = {
39
+ queued: 'clock',
40
+ running: 'sync',
41
+ success: 'check-circle',
42
+ failure: 'x-circle',
43
+ cancelled: 'stop',
44
+ skipped: 'skip',
45
+ };
46
+ function statusIcon(s) {
47
+ return (0, html_1.html) `<span class="run-status ${s}" title="${STATUS_LABEL[s]}" aria-label="${STATUS_LABEL[s]}" role="img">${(0, icons_1.icon)(STATUS_ICON[s])}</span>`;
48
+ }
49
+ function duration(from, to) {
50
+ if (!from)
51
+ return '';
52
+ const start = new Date(from).getTime();
53
+ const end = to ? new Date(to).getTime() : Date.now();
54
+ const secs = Math.max(0, Math.round((end - start) / 1000));
55
+ if (secs < 60)
56
+ return `${secs}s`;
57
+ const m = Math.floor(secs / 60);
58
+ const s = secs % 60;
59
+ if (m < 60)
60
+ return `${m}m ${s}s`;
61
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
62
+ }
63
+ function runTitle(run) {
64
+ if (run.message)
65
+ return run.message;
66
+ if (run.event === 'workflow_dispatch')
67
+ return `${run.workflowName} (manual)`;
68
+ return run.workflowName;
69
+ }
70
+ // ---- the runs list ----
71
+ function runsPage(ctx, runs, workflows, selectedWorkflow, flash) {
72
+ const base = (0, views_1.repoUrl)(ctx);
73
+ const actionsBase = `${base}/actions`;
74
+ const rows = runs.map((r) => {
75
+ const s = statusOf(r);
76
+ const when = r.createdAt ? (0, render_1.timeTag)(r.createdAt) : '';
77
+ const dur = duration(r.startedAt, r.completedAt);
78
+ const sha = r.sha
79
+ ? (0, html_1.html) ` <a class="sha" href="${base}/commit/${r.sha}">${r.sha.slice(0, 7)}</a>`
80
+ : '';
81
+ return (0, html_1.html) `<tr>
82
+ <td class="run-cell">${statusIcon(s)}<span><a href="${actionsBase}/runs/${r.number}"><b>${runTitle(r)}</b></a>
83
+ <div class="muted small run-sub">${r.workflowName} #${r.number}: ${r.event} by ${(0, avatar_1.avatar)(r.actor, 16)}${r.actor}${sha}</div></span></td>
84
+ <td class="right muted small"><span class="chip">${(0, icons_1.icon)('git-branch')}${r.refName}</span></td>
85
+ <td class="right muted small">${when}${dur ? (0, html_1.html) ` &middot; ${dur}` : ''}</td>
86
+ </tr>`;
87
+ });
88
+ // GitHub lists the workflows down the side of the runs, which is both the
89
+ // filter and the answer to "what can this repository do".
90
+ const sidebar = workflows.length
91
+ ? (0, html_1.html) `<aside class="wf-side"><div class="side-block"><h3>${(0, icons_1.icon)('workflow')}Workflows</h3><div class="side-links">${[
92
+ (0, html_1.html) `<a class="${selectedWorkflow === null ? 'current' : ''}" href="${actionsBase}">${(0, icons_1.icon)('history')}<span>All workflows</span></a>`,
93
+ ...workflows.map((w) => (0, html_1.html) `<a class="${selectedWorkflow === w.path ? 'current' : ''}" href="${actionsBase}?workflow=${encodeURIComponent(w.path)}" title="${w.path}">${(0, icons_1.icon)('play')}<span>${w.name}</span></a>`),
94
+ ]}</div></div></aside>`
95
+ : '';
96
+ const brokenList = workflows.filter((w) => w.error);
97
+ const broken = brokenList.length
98
+ ? (0, html_1.html) `<div class="form-error">${brokenList.map((w) => (0, html_1.html) `<div>${w.path}: ${w.error}</div>`)}</div>`
99
+ : '';
100
+ const dispatchable = workflows.filter((w) => w.dispatch !== null);
101
+ const dispatchForm = ctx.canPush && ctx.viewer && dispatchable.length
102
+ ? dispatchBox(ctx, ctx.viewer, dispatchable)
103
+ : '';
104
+ const body = runs.length
105
+ ? (0, html_1.html) `<table class="listing runs"><tbody>${rows}</tbody></table>`
106
+ : (0, html_1.html) `<div class="empty-state"><p><b>No workflow runs yet.</b></p><p class="muted">Runs appear here when a push matches a workflow in <code>.github/workflows</code> or <code>.mochi/workflows</code>.</p><p class="muted small">Workflows run without credentials: this vault holds no secrets, and a workflow that references <code>secrets.*</code> is refused with a message saying so.</p></div>`;
107
+ const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'actions')}
108
+ ${flash ? (0, html_1.html) `<div class="flash">${flash}</div>` : ''}
109
+ ${broken}
110
+ <div class="page-head"><h2>Workflow runs</h2>${dispatchForm}</div>
111
+ <div class="actions-layout">${sidebar}<div class="actions-main">${body}</div></div>`;
112
+ return (0, views_1.layout)(`Actions - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, actionsBase));
113
+ }
114
+ function dispatchBox(ctx, viewer, workflows) {
115
+ const base = (0, views_1.repoUrl)(ctx);
116
+ const panels = workflows.map((w, i) => {
117
+ const inputs = Object.entries(w.dispatch ?? {}).map(([name, def]) => {
118
+ const id = `wf${i}-${name}`;
119
+ const label = (0, html_1.html) `<label for="${id}">${name}${def.required ? ' *' : ''}</label>`;
120
+ const help = def.description ? (0, html_1.html) `<p class="muted small">${def.description}</p>` : '';
121
+ if (def.type === 'choice' && def.options?.length) {
122
+ const opts = def.options.map((o) => (0, html_1.html) `<option value="${o}"${String(def.default ?? '') === o ? (0, html_1.raw)(' selected') : ''}>${o}</option>`);
123
+ return (0, html_1.html) `<div class="field">${label}<select id="${id}" name="input.${name}">${opts}</select>${help}</div>`;
124
+ }
125
+ if (def.type === 'boolean') {
126
+ return (0, html_1.html) `<div class="field"><label class="checkbox"><input type="checkbox" id="${id}" name="input.${name}" value="true"${def.default === true || def.default === 'true' ? (0, html_1.raw)(' checked') : ''}> ${name}</label>${help}</div>`;
127
+ }
128
+ return (0, html_1.html) `<div class="field">${label}<input type="text" id="${id}" name="input.${name}" value="${def.default ?? ''}"${def.required ? (0, html_1.raw)(' required') : ''}>${help}</div>`;
129
+ });
130
+ const refOptions = ctx.branches.map((b) => (0, html_1.html) `<option value="${b.name}"${b.name === ctx.defaultBranch ? (0, html_1.raw)(' selected') : ''}>${b.name}</option>`);
131
+ return (0, html_1.html) `<form method="post" action="${base}/actions/dispatch" class="dispatch-panel" data-wf="${w.path}"${i === 0 ? '' : (0, html_1.raw)(' hidden')}>
132
+ ${(0, views_1.csrfField)(viewer)}
133
+ <input type="hidden" name="workflow" value="${w.path}">
134
+ <div class="field"><label>Use branch</label><select name="ref">${refOptions}</select></div>
135
+ ${inputs}
136
+ <button type="submit" class="btn btn-primary">Run workflow</button>
137
+ </form>`;
138
+ });
139
+ const picker = workflows.length > 1
140
+ ? (0, html_1.html) `<div class="field"><label>Workflow</label><select data-workflow-picker>${workflows.map((w) => (0, html_1.html) `<option value="${w.path}">${w.name}</option>`)}</select></div>`
141
+ : '';
142
+ return (0, html_1.html) `<details class="dropdown dispatch">
143
+ <summary class="btn">${(0, icons_1.icon)('play')}<span>Run workflow</span>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
144
+ <div class="dropdown-menu dd-right dispatch-body">${picker}${panels}</div>
145
+ </details>`;
146
+ }
147
+ // ---- one run ----
148
+ // The runner logs its own setup and cleanup against step index -1, which is
149
+ // not a workflow step at all; it gets its own block rather than being
150
+ // mistaken for one.
151
+ function stepBlocks(job, logLines) {
152
+ const byStep = new Map();
153
+ for (const line of logLines) {
154
+ if (!byStep.has(line.s))
155
+ byStep.set(line.s, []);
156
+ byStep.get(line.s).push(line.l);
157
+ }
158
+ const states = job.stepStates ?? [];
159
+ const indices = new Set([...byStep.keys(), ...states.map((_, i) => i)]);
160
+ const ordered = [...indices].sort((a, b) => a - b);
161
+ if (ordered.length === 0)
162
+ return '';
163
+ // ansiLineHtml escapes the log text itself and adds only its own spans, so
164
+ // a rendered line is HTML by the time it lands here.
165
+ const log = (lines) => (0, html_1.raw)(lines.map((l) => (0, ansi_1.ansiLineHtml)(l)).join('\n'));
166
+ return (0, html_1.joinHtml)(ordered.map((i) => {
167
+ const lines = byStep.get(i) ?? [];
168
+ if (i < 0) {
169
+ return (0, html_1.html) `<details class="step">
170
+ <summary><span class="run-status queued" aria-hidden="true">&middot;</span><span class="step-name">Runner</span></summary>
171
+ <pre class="joblog">${log(lines)}</pre>
172
+ </details>`;
173
+ }
174
+ const st = states[i];
175
+ const name = st?.name ?? `Step ${i + 1}`;
176
+ const s = st ? statusOf({ status: st.status, conclusion: st.conclusion }) : 'queued';
177
+ const open = s === 'failure' || s === 'running';
178
+ return (0, html_1.html) `<details class="step"${open ? (0, html_1.raw)(' open') : ''}>
179
+ <summary>${statusIcon(s)}<span class="step-name">${name}</span><span class="muted small">${duration(st?.startedAt, st?.completedAt)}</span></summary>
180
+ <pre class="joblog">${log(lines)}</pre>
181
+ </details>`;
182
+ }));
183
+ }
184
+ function runPage(ctx, run, jobs, selected, logLines, logOffset, artifacts = []) {
185
+ const base = (0, views_1.repoUrl)(ctx);
186
+ const actionsBase = `${base}/actions`;
187
+ const runBase = `${actionsBase}/runs/${run.number}`;
188
+ const s = statusOf(run);
189
+ const viewer = ctx.viewer;
190
+ const jobList = jobs.map((j) => {
191
+ const js = statusOf(j);
192
+ const current = selected && j.id === selected.id;
193
+ return (0, html_1.html) `<a class="job-item${current ? ' current' : ''}" href="${runBase}?job=${encodeURIComponent(j.id)}">${statusIcon(js)}<span>${j.name}</span><span class="muted small">${duration(j.startedAt, j.completedAt)}</span></a>`;
194
+ });
195
+ let detail;
196
+ if (run.error) {
197
+ detail = (0, html_1.html) `<div class="form-error"><b>${run.workflowPath}</b> could not be used: ${run.error}</div>`;
198
+ }
199
+ else if (!selected) {
200
+ detail = (0, html_1.html) `<div class="empty-state">This run has no jobs.</div>`;
201
+ }
202
+ else if (selected.error && selected.stepStates.length === 0) {
203
+ detail = (0, html_1.html) `<div class="form-error">${selected.error}</div>`;
204
+ }
205
+ else {
206
+ const js = statusOf(selected);
207
+ const live = js === 'running' || js === 'queued';
208
+ const summaries = (selected.summaries ?? []).filter((x) => x.trim() !== '');
209
+ const summaryBox = summaries.length
210
+ ? (0, html_1.html) `<div class="box"><div class="box-header">Summary</div><div class="box-body"><pre class="joblog">${summaries.join('\n')}</pre></div></div>`
211
+ : '';
212
+ const errorBox = selected.error ? (0, html_1.html) `<div class="form-error">${selected.error}</div>` : '';
213
+ // The audit line for a job that executed through a pasted command: who
214
+ // authorized it and where it said it ran. The host is what the session
215
+ // reported about itself, so it is presented as a report, not a finding.
216
+ const manualBy = selected.manual
217
+ ? (0, html_1.html) ` <span class="muted small">&middot; run manually by ${selected.manual.user}${selected.manual.host ? (0, html_1.html) ` on ${selected.manual.host}` : ''}</span>`
218
+ : '';
219
+ if (live) {
220
+ // The tailer appends as textContent, so a live log is stripped of
221
+ // escapes rather than coloured; colour arrives with the step view when
222
+ // the job completes and the page reloads into it.
223
+ const initial = logLines.map((l) => (0, ansi_1.stripAnsi)(l.l)).join('\n');
224
+ // The endpoint and the offset ride on the element rather than in a
225
+ // script, which is what lets /assets/page.js stay one cacheable file
226
+ // and this page carry no executable markup; the tailer there picks
227
+ // them up.
228
+ const waitingFor = (0, manual_1.isManualJob)(selected.runsOn)
229
+ ? ctx.canPush
230
+ ? 'waiting for someone to run it (Run it yourself, above)'
231
+ : 'waiting for someone to run it'
232
+ : 'waiting for a runner';
233
+ detail = (0, html_1.html) `${errorBox}<div class="job-head"><b>${selected.name}</b> ${statusIcon(js)} <span class="muted small">${js === 'queued' ? waitingFor : 'running'}</span>${manualBy}</div>
234
+ <pre class="joblog live" id="livelog" data-log-url="${runBase}/log/${encodeURIComponent(selected.id)}" data-log-offset="${logOffset}">${initial}</pre>`;
235
+ }
236
+ else {
237
+ const rawLink = (0, html_1.html) `<a class="btn raw-log-link" href="${runBase}/log/${encodeURIComponent(selected.id)}/raw" title="Download the log as the runner wrote it">Raw log</a>`;
238
+ detail = (0, html_1.html) `${errorBox}<div class="job-head"><b>${selected.name}</b> ${statusIcon(js)} <span class="muted small">${duration(selected.startedAt, selected.completedAt)}</span>${manualBy}${rawLink}</div>
239
+ ${stepBlocks(selected, logLines)}
240
+ ${summaryBox}`;
241
+ }
242
+ }
243
+ const canOperate = ctx.canPush && viewer;
244
+ // Offered while a manual job is waiting: the command that runs this run's
245
+ // manual jobs on a machine of the viewer's. Minting is a POST because it
246
+ // issues a credential; the token appears on the page it lands on and
247
+ // nowhere else.
248
+ const manualWaiting = jobs.some((j) => (0, manual_1.isManualJob)(j.runsOn) && j.status === 'queued');
249
+ const execBtn = canOperate && run.status !== 'completed' && manualWaiting
250
+ ? (0, html_1.html) `<form method="post" action="${runBase}/exec-command">${(0, views_1.csrfField)(viewer)}<button type="submit" class="btn btn-primary" title="Get a command that runs this run's manual jobs on a machine of yours">Run it yourself</button></form>`
251
+ : '';
252
+ const cancelBtn = canOperate && run.status !== 'completed'
253
+ ? (0, html_1.html) `<form method="post" action="${runBase}/cancel">${(0, views_1.csrfField)(viewer)}<button type="submit" class="btn btn-danger-outline">Cancel run</button></form>`
254
+ : '';
255
+ const rerunBtn = canOperate
256
+ ? (0, html_1.html) `<form method="post" action="${runBase}/rerun">${(0, views_1.csrfField)(viewer)}<button type="submit" class="btn">Re-run</button></form>`
257
+ : '';
258
+ const artifactBox = artifacts.length
259
+ ? (0, html_1.html) `<div class="box artifacts"><div class="box-header">Artifacts</div><div class="box-body">${artifacts.map((a) => (0, html_1.html) `<a class="artifact" href="${runBase}/artifacts/${encodeURIComponent(a.name)}"><b>${a.name}</b><span class="muted small">${(0, render_1.formatSize)(a.size)}</span></a>`)}<p class="muted small">Artifacts are tar archives, and are removed when the run is pruned.</p></div></div>`
260
+ : '';
261
+ const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'actions')}
262
+ <div class="run-head">
263
+ <div class="run-title">${statusIcon(s)}<h2>${runTitle(run)}</h2></div>
264
+ <div class="right-group">${execBtn}${rerunBtn}${cancelBtn}</div>
265
+ </div>
266
+ <div class="run-meta muted small">
267
+ <a href="${actionsBase}?workflow=${encodeURIComponent(run.workflowPath)}">${run.workflowName}</a>
268
+ &middot; #${run.number}
269
+ &middot; ${run.event} by <span class="run-actor">${(0, avatar_1.avatar)(run.actor, 16)}${run.actor}</span>
270
+ &middot; <span class="chip">${run.refName}</span>
271
+ ${run.sha ? (0, html_1.html) `&middot; <a class="sha" href="${base}/commit/${run.sha}">${run.sha.slice(0, 7)}</a>` : ''}
272
+ &middot; <a href="${base}/blob/${(0, views_1.encPath)(run.refName)}/${(0, views_1.encPath)(run.workflowPath)}">${run.workflowPath}</a>
273
+ ${run.createdAt ? (0, html_1.html) `&middot; ${(0, render_1.timeTag)(run.createdAt, '')}` : ''}
274
+ </div>
275
+ <div class="run-body">
276
+ <div class="job-list">${jobList}</div>
277
+ <div class="job-detail">${detail}</div>
278
+ </div>
279
+ ${artifactBox}`;
280
+ return (0, views_1.layout)(`${runTitle(run)} - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, runBase));
281
+ }
282
+ /**
283
+ * The page a minted exec command lands on, and the only place it appears:
284
+ * only its hash is stored, as with tokens. Everything the person at the other
285
+ * terminal needs is here, because by the time they are pasting they are no
286
+ * longer looking at this page.
287
+ */
288
+ function execCommandPage(ctx, runNumber, command, expiresInMinutes, back) {
289
+ const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'actions')}
290
+ <div class="form-box wide">
291
+ <h1>Run it yourself</h1>
292
+ <p>On a machine with Docker or Podman and Node, paste this. It shows run #${runNumber}'s manual jobs step by step and asks before executing anything; what it runs reports back to this run as any runner would.</p>
293
+ ${(0, views_1.copyRow)(command)}
294
+ <p class="muted small">The command must be pasted within ${expiresInMinutes} minutes and works once: redeeming it trades the token here for a session that lives only in that process, so a copy left in scrollback or shell history buys nothing afterwards. Reloading this page will not show it again; minting another command is the way to get one.</p>
295
+ <p class="muted small">The jobs run as whoever pastes this, on that machine. Read the steps it shows before agreeing to them.</p>
296
+ <p><a class="btn" href="${back}">Back to run #${runNumber}</a></p>
297
+ </div>`;
298
+ return (0, views_1.layout)(`Run it yourself - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, back));
299
+ }
300
+ // A runner is either working on something, idle but in touch, or not there at
301
+ // all. The third case is the one an operator is usually looking for, so it gets
302
+ // a plain word rather than an empty cell.
303
+ function runnerStatus(r) {
304
+ if (r.running) {
305
+ const at = `/${encodeURIComponent(r.running.collection)}/${encodeURIComponent(r.running.repo)}/actions/runs/${r.running.run}?job=${encodeURIComponent(r.running.job)}`;
306
+ return (0, html_1.html) `${statusIcon('running')}<span>running <a href="${at}">${r.running.collection}/${r.running.repo} #${r.running.run}</a> ${r.running.job}</span>`;
307
+ }
308
+ if (r.lastSeen) {
309
+ return (0, html_1.html) `${statusIcon('success')}<span>idle, last heard from ${(0, render_1.timeTag)(r.lastSeen, '')}</span>`;
310
+ }
311
+ // A runner with a wake address is meant to be absent between jobs, so the
312
+ // absence is the arrangement working rather than something to look into.
313
+ if (r.wakeUrl) {
314
+ return (0, html_1.html) `${statusIcon('queued')}<span class="muted">stopped; the vault starts it when a job is waiting</span>`;
315
+ }
316
+ return (0, html_1.html) `${statusIcon('queued')}<span class="muted">not seen since the vault restarted</span>`;
317
+ }
318
+ function runnersPage(viewer, runners, flash, error) {
319
+ const rows = runners.map((r) => (0, html_1.html) `<tr><td class="with-avatar-row">${(0, icons_1.icon)('server', 'icon')}<span><b><a href="/admin/runners/${encodeURIComponent(r.name)}">${r.name}</a></b><div class="muted small">registered by ${r.createdBy}${r.createdAt ? (0, html_1.html) ` ${(0, render_1.timeTag)(r.createdAt, '')}` : ''}</div></span></td>
320
+ <td class="small"><div class="runner-status">${runnerStatus(r)}</div></td>
321
+ <td class="small">${(0, html_1.joinHtml)(r.labels.map((l) => (0, html_1.html) `<span class="chip">${l}</span>`), ' ')}</td>
322
+ <td class="small mono">${r.allow.join(' ')}</td>
323
+ <td class="right"><a class="btn" href="/admin/runners/${encodeURIComponent(r.name)}">Details</a></td></tr>`);
324
+ const content = (0, html_1.html) `<div class="page-head"><h1>Runners</h1></div>
325
+ ${flash ? (0, html_1.html) `<div class="flash">${flash}</div>` : ''}
326
+ ${error ? (0, html_1.html) `<div class="form-error">${error}</div>` : ''}
327
+ <p class="muted">A runner is a machine that executes workflow jobs. Jobs never run on the vault's own machine: register a runner, then start it with <code>mochi runner run</code> somewhere with Docker.</p>
328
+ ${runners.length
329
+ ? (0, html_1.html) `<table class="listing"><tbody>${rows}</tbody></table>`
330
+ : (0, html_1.raw)('<div class="empty-state">No runners registered.</div>')}
331
+ <div class="form-box wide" style="margin-top:24px">
332
+ <h2>Register a runner</h2>
333
+ <form method="post" action="/admin/runners">
334
+ ${(0, views_1.csrfField)(viewer)}
335
+ <div class="field"><label for="name">Name</label><input type="text" id="name" name="name" placeholder="laptop" required>
336
+ <p class="muted small">Identifies the machine in job history.</p></div>
337
+ <div class="field"><label for="labels">Labels</label><input type="text" id="labels" name="labels" value="ubuntu-latest">
338
+ <p class="muted small">Matched against a job's <code>runs-on</code>. Space or comma separated.</p></div>
339
+ <div class="field"><label for="allow">Repositories</label><input type="text" id="allow" name="allow" placeholder="mycollection/*" required>
340
+ <p class="muted small">Globs over <code>collection/repo</code>. A runner executes whatever those repositories' workflows say, on the machine you start it on, so grant it only what you trust.</p></div>
341
+ <button type="submit" class="btn btn-primary">Register runner</button>
342
+ </form>
343
+ </div>`;
344
+ return (0, forms_1.adminShell)(viewer, 'runners', 'Runners', '/admin/runners', content);
345
+ }
346
+ // One runner: what it is allowed to do, whether it is there, and the two
347
+ // operations an operator comes here for, which are getting the start command
348
+ // and replacing a token that was lost or leaked.
349
+ function runnerPage(viewer, r, host, flash) {
350
+ const fact = (label, value) => value === '' ? '' : (0, html_1.html) `<div class="fact"><span class="k">${label}</span><span class="v">${value}</span></div>`;
351
+ const facts = (0, html_1.html) `<div class="facts">
352
+ ${fact('Status', (0, html_1.html) `<span class="runner-status">${runnerStatus(r)}</span>`)}
353
+ ${fact('Labels', r.labels.length
354
+ ? (0, html_1.joinHtml)(r.labels.map((l) => (0, html_1.html) `<span class="chip">${l}</span>`), ' ')
355
+ : (0, html_1.raw)('<span class="muted">none</span>'))}
356
+ ${fact('Repositories', (0, html_1.html) `<span class="mono">${r.allow.join(' ')}</span>`)}
357
+ ${fact('Registered', (0, html_1.html) `by ${r.createdBy}${r.createdAt ? (0, html_1.html) ` ${(0, render_1.timeTag)(r.createdAt, '')}` : ''}`)}
358
+ ${fact('Token', r.tokenUpdatedAt ? (0, html_1.html) `regenerated ${(0, render_1.timeTag)(r.tokenUpdatedAt, '')}` : (0, html_1.html) `the one issued at registration`)}
359
+ ${fact('Wake', r.wakeUrl ? (0, html_1.html) `<span class="mono">${r.wakeUrl}</span>` : (0, html_1.raw)('<span class="muted">nothing starts this runner</span>'))}
360
+ </div>`;
361
+ const content = (0, html_1.html) `<div class="page-head"><h1>${(0, icons_1.icon)('server', 'icon')}${r.name}</h1></div>
362
+ ${flash ? (0, html_1.html) `<div class="flash">${flash}</div>` : ''}
363
+ <p class="muted"><a href="/admin/runners">Runners</a> &middot; a machine that takes jobs for ${r.allow.join(', ')} and runs them under Docker.</p>
364
+ ${facts}
365
+ <div class="form-box wide" style="margin-top:24px">
366
+ <h2>Start this runner</h2>
367
+ <p class="muted">On the machine that will execute the jobs, with Docker installed and running, and the <code>mochi</code> CLI on the path (<code>npm install -g @magland/mochi</code>):</p>
368
+ ${(0, views_1.copyRow)(`mochi runner run --host ${host} --runner-token <token>`)}
369
+ <p class="muted small">The token is shown only when it is issued, so if you no longer have it, regenerate it below and the command will be filled in for you. Adding <code>--save</code> writes the host and token to <code>~/.config/mochi/runner.json</code>, after which <code>mochi runner run</code> needs no arguments; <code>MOCHI_RUNNER_TOKEN</code> supplies the token where a command line is the wrong place for it, as in a systemd unit. Leave the process running; it polls for work and exits only when you stop it.</p>
370
+ <p class="muted small">Jobs are matched by label, so this runner will be offered jobs whose <code>runs-on</code> names ${r.labels.length
371
+ ? (0, html_1.joinHtml)(r.labels.map((l) => (0, html_1.html) `<code>${l}</code>`), ' or ')
372
+ : 'nothing yet'}.</p>
373
+ </div>
374
+ <div class="form-box wide" style="margin-top:24px">
375
+ <h2>Wake address</h2>
376
+ <p class="muted">A runner started with <code>--idle</code> stops when it has had no job for that long, which is how a runner on hardware billed by the minute stops costing anything between runs. It cannot be told that work has arrived, though, so the vault sends a request to this address instead, and whatever is in front of the runner (a Fly proxy, a socket unit) starts it. The request carries a secret and nothing else; a new one is generated when you save an address, and the runner has to be started with it.</p>
377
+ <p class="muted">Sent at most once a minute per runner, however many jobs are waiting, and only when the runner has not been heard from.</p>
378
+ <form method="post" action="/admin/runners/${encodeURIComponent(r.name)}/wake">
379
+ ${(0, views_1.csrfField)(viewer)}
380
+ <div class="field"><label for="wakeUrl">URL</label><input type="text" id="wakeUrl" name="wakeUrl" value="${r.wakeUrl ?? ''}" placeholder="https://my-runner.fly.dev/wake">
381
+ <p class="muted small">Leave empty to remove the address, after which nothing starts this runner.</p></div>
382
+ <button type="submit" class="btn">${(0, icons_1.icon)('sync')}<span>Save wake address</span></button>
383
+ </form>
384
+ ${r.wakeUrl
385
+ ? (0, html_1.html) `<form method="post" action="/admin/runners/${encodeURIComponent(r.name)}/wake/send" style="margin-top:12px">
386
+ ${(0, views_1.csrfField)(viewer)}
387
+ <button type="submit" class="btn">${(0, icons_1.icon)('play')}<span>Send a wake request now</span></button>
388
+ <p class="muted small">Tests the address without queuing a job. A machine that has to boot may take half a minute to answer.</p>
389
+ </form>`
390
+ : ''}
391
+ </div>
392
+ <div class="form-box wide" style="margin-top:24px">
393
+ <h2>Regenerate token</h2>
394
+ <p class="muted">Issues a new token for ${r.name} and invalidates the current one. Its labels and repositories are kept, but a runner still running with the old token will start failing to poll and has to be restarted.</p>
395
+ <form method="post" action="/admin/runners/${encodeURIComponent(r.name)}/token" data-confirm="Regenerate the token for ${r.name}? The current token stops working immediately.">
396
+ ${(0, views_1.csrfField)(viewer)}
397
+ <button type="submit" class="btn">${(0, icons_1.icon)('sync')}<span>Regenerate token</span></button>
398
+ </form>
399
+ </div>
400
+ <div class="form-box wide" style="margin-top:24px">
401
+ <h2>Remove runner</h2>
402
+ <p class="muted">Removes ${r.name} from the registry. It stops being able to take jobs; a job it is running now will be handed back to the queue when its lease expires.</p>
403
+ <form method="post" action="/admin/runners/${encodeURIComponent(r.name)}/remove" data-confirm="Remove runner ${r.name}? It will stop being able to take jobs.">
404
+ ${(0, views_1.csrfField)(viewer)}
405
+ <button type="submit" class="btn btn-danger-outline">${(0, icons_1.icon)('trash')}<span>Remove runner</span></button>
406
+ </form>
407
+ </div>`;
408
+ return (0, forms_1.adminShell)(viewer, 'runners', `Runner ${r.name}`, '/admin/runners', content);
409
+ }
410
+ function runnerTokenPage(viewer, name, token, host, regenerated = false) {
411
+ const heading = regenerated ? `New token for ${name}` : 'Runner registered';
412
+ const content = (0, html_1.html) `<div class="form-box wide">
413
+ <h1>${heading}</h1>
414
+ <p>The token for <b>${name}</b> is shown once; only its hash is stored.${regenerated ? ' The previous token no longer works.' : ''}</p>
415
+ ${(0, views_1.copyRow)(token)}
416
+ <h2>Start it</h2>
417
+ <p class="muted">On a machine with Docker:</p>
418
+ ${(0, views_1.copyRow)(`mochi runner run --host ${host} --runner-token ${token}`)}
419
+ <p class="muted small">Adding <code>--save</code> keeps the host and token in <code>~/.config/mochi/runner.json</code>, so <code>mochi runner run</code> needs no arguments afterwards.${regenerated ? ' If the runner is already running with the old token, restart it now.' : ''}</p>
420
+ <p><a class="btn" href="/admin/runners/${encodeURIComponent(name)}">Back to ${name}</a> <a class="btn" href="/admin/runners">All runners</a></p>
421
+ </div>`;
422
+ return (0, views_1.layout)(heading, content, { viewer, path: '/admin/runners' });
423
+ }
424
+ // The secret a saved wake address is given, shown once for the same reason a
425
+ // token is: the vault keeps it in order to send it, and the runner has to be
426
+ // started with the same one, so this page is the only place the two halves
427
+ // meet.
428
+ function runnerWakePage(viewer, name, url, secret) {
429
+ const content = (0, html_1.html) `<div class="form-box wide">
430
+ <h1>Wake address saved</h1>
431
+ <p>The vault will start <b>${name}</b> by sending a request to <span class="mono">${url}</span> when a job it could take is waiting and it has not been heard from.</p>
432
+ <p>The secret that request carries, shown once here because the runner has to be started with it:</p>
433
+ ${(0, views_1.copyRow)(secret)}
434
+ <h2>Start it</h2>
435
+ <p class="muted">With an idle timeout, so that there is something to wake, and a port for the request to arrive on:</p>
436
+ ${(0, views_1.copyRow)(`MOCHI_WAKE_SECRET=${secret} mochi runner run --idle 5m --wake-port 3000`)}
437
+ <p class="muted small">A runner deployed with <code>mochi deploy fly runner</code> is given all of this already; this page is for a runner you start yourself. Saving an address again issues a new secret, so the runner has to be restarted with it.</p>
438
+ <p><a class="btn" href="/admin/runners/${encodeURIComponent(name)}">Back to ${name}</a> <a class="btn" href="/admin/runners">All runners</a></p>
439
+ </div>`;
440
+ return (0, views_1.layout)('Wake address saved', content, { viewer, path: '/admin/runners' });
441
+ }
@@ -0,0 +1,194 @@
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.sendWake = sendWake;
37
+ exports.newWakeSecret = newWakeSecret;
38
+ exports.wakeOf = wakeOf;
39
+ exports.startWakeDispatcher = startWakeDispatcher;
40
+ const crypto = __importStar(require("crypto"));
41
+ const runners_1 = require("./runners");
42
+ // Starting a runner that is not there.
43
+ //
44
+ // A runner reaches the vault and never the other way round, which is what lets
45
+ // one sit behind NAT with nothing open. The cost of that arrangement is that a
46
+ // runner which has stopped to save money cannot be told there is work: it has
47
+ // to be started by whatever is in front of it. A runner may therefore carry a
48
+ // wake address, and this pokes it when there is a job it could take and no
49
+ // sign of it.
50
+ //
51
+ // The request means nothing by itself. Nothing is sent but the shared secret,
52
+ // nothing is expected back, and the runner learns what to do the ordinary way,
53
+ // by polling. All that matters is that the thing in front of the runner - a
54
+ // Fly proxy, a systemd socket, anything - treats an arriving request as the
55
+ // signal to start it.
56
+ /** How recently a runner must have spoken to the vault to count as present. */
57
+ const PRESENT_MS = 60 * 1000;
58
+ /** The least time between two wake requests to the same runner. */
59
+ const RETRY_MS = 60 * 1000;
60
+ /**
61
+ * How long to give one wake request.
62
+ *
63
+ * Generous, because the platform in front of a stopped runner usually holds
64
+ * the request open while it starts the machine rather than answering and
65
+ * starting one behind it: a cold Fly machine with a Docker daemon to bring up
66
+ * took half a minute to answer in testing. The answer is discarded either way;
67
+ * this is only how long the attempt is allowed to occupy.
68
+ */
69
+ const REQUEST_TIMEOUT_MS = 120 * 1000;
70
+ /** How often to look for work waiting on a runner that is not there. */
71
+ const SWEEP_MS = 20 * 1000;
72
+ /**
73
+ * Send one wake request, and say what happened.
74
+ *
75
+ * Exported because both the timer below and `mochi runner wake` want the
76
+ * same request with the same headers; a wake tested by hand that differed
77
+ * from the one the vault sends would test the wrong thing.
78
+ */
79
+ async function sendWake(wake, timeoutMs = REQUEST_TIMEOUT_MS) {
80
+ const res = await fetch(wake.url, {
81
+ method: 'POST',
82
+ headers: { 'x-mochi-wake': wake.secret, 'content-length': '0' },
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ });
85
+ // A runner that answers 401 is running somebody else's secret, and one that
86
+ // answers 404 is not the endpoint anybody meant. Both are worth a message,
87
+ // since the visible symptom otherwise is a run that never starts.
88
+ if (!res.ok && res.status !== 204) {
89
+ throw new Error(`the wake endpoint answered HTTP ${res.status}`);
90
+ }
91
+ }
92
+ /**
93
+ * The secret a wake request must present.
94
+ *
95
+ * Not a token in the vault's sense: it authenticates the vault to a runner
96
+ * rather than a caller to the vault, both sides keep it in the clear because
97
+ * both sides have to send it, and all it buys is the right to start a machine
98
+ * that will then ask for work in the ordinary way. It exists because starting
99
+ * a machine costs its owner money, and an address anyone could poke would be a
100
+ * way to spend it.
101
+ */
102
+ function newWakeSecret() {
103
+ return crypto.randomBytes(32).toString('hex');
104
+ }
105
+ function wakeOf(runner) {
106
+ if (!runner.wakeUrl || !runner.wakeSecret)
107
+ return null;
108
+ return { url: runner.wakeUrl, secret: runner.wakeSecret };
109
+ }
110
+ /**
111
+ * Watch for queued jobs whose runner is not present, and start it.
112
+ *
113
+ * The unit throttled here is the runner, not the job. A run of twelve jobs
114
+ * that all wait on the same stopped runner is one machine to start, and
115
+ * twelve requests would be eleven wasted and a good way to look like an
116
+ * attack to whatever is in front of it.
117
+ */
118
+ function startWakeDispatcher(root, engine) {
119
+ const attempts = new Map();
120
+ let stopped = false;
121
+ const wakeRunner = (name, wake, attempt) => {
122
+ attempt.at = Date.now();
123
+ attempt.inFlight = true;
124
+ void sendWake(wake)
125
+ .then(() => {
126
+ if (attempt.lastError)
127
+ console.log(`CI: runner ${name} answered a wake request`);
128
+ attempt.lastError = null;
129
+ })
130
+ .catch((e) => {
131
+ const message = e instanceof Error ? e.message : String(e);
132
+ // Once per distinct failure. A runner whose wake address is wrong
133
+ // stays wrong, and a line a minute about it for as long as a job is
134
+ // queued would bury everything else in the log.
135
+ if (attempt.lastError !== message) {
136
+ console.error(`CI: could not wake runner ${name} at ${wake.url}: ${message}`);
137
+ attempt.lastError = message;
138
+ }
139
+ })
140
+ .finally(() => {
141
+ attempt.inFlight = false;
142
+ // The request is answered only once the runner is up, so this is the
143
+ // moment its first poll is imminent: the throttle should count from
144
+ // here rather than from when the attempt began.
145
+ attempt.at = Date.now();
146
+ });
147
+ };
148
+ const sweep = () => {
149
+ if (stopped)
150
+ return;
151
+ const load = engine.runnerLoad();
152
+ if (load.queued.length === 0)
153
+ return;
154
+ const registry = (0, runners_1.loadRunners)(root);
155
+ const now = Date.now();
156
+ for (const [name, runner] of Object.entries(registry.runners)) {
157
+ const wake = wakeOf(runner);
158
+ if (!wake)
159
+ continue;
160
+ // Present, by either measure: holding a job, or having polled within
161
+ // the last minute. Waking a runner that is already there costs nothing
162
+ // but is a request nobody needed.
163
+ if (load.running[name])
164
+ continue;
165
+ const seen = (0, runners_1.runnerLastSeen)(name);
166
+ if (seen && now - Date.parse(seen) < PRESENT_MS)
167
+ continue;
168
+ // A manual job is waiting for a person, not a machine; starting a
169
+ // runner for one would cost its owner a boot for a job it may not take.
170
+ const waiting = load.queued.some((j) => !j.manual && (0, runners_1.runnerAllows)(runner, j.collection, j.repo) && j.runsOn.some((l) => runner.labels.includes(l)));
171
+ if (!waiting)
172
+ continue;
173
+ const attempt = attempts.get(name) ?? { at: 0, inFlight: false, lastError: null };
174
+ attempts.set(name, attempt);
175
+ if (attempt.inFlight || now - attempt.at < RETRY_MS)
176
+ continue;
177
+ wakeRunner(name, wake, attempt);
178
+ }
179
+ };
180
+ // Both a timer and the engine's own signal: the signal makes the usual case
181
+ // immediate, since a push that queues a job should not wait out a sweep,
182
+ // and the timer is what keeps trying for a job that is still sitting there.
183
+ const timer = setInterval(sweep, SWEEP_MS);
184
+ timer.unref();
185
+ const unsubscribe = engine.onQueueChanged(sweep);
186
+ return {
187
+ sweep,
188
+ stop() {
189
+ stopped = true;
190
+ clearInterval(timer);
191
+ unsubscribe();
192
+ },
193
+ };
194
+ }