@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,428 @@
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.parseBasicAuth = parseBasicAuth;
37
+ exports.checkReadAuth = checkReadAuth;
38
+ exports.checkPushAuth = checkPushAuth;
39
+ exports.registerGitHttp = registerGitHttp;
40
+ const child_process_1 = require("child_process");
41
+ const zlib = __importStar(require("zlib"));
42
+ const git_1 = require("./git");
43
+ const jobtoken_1 = require("./jobtoken");
44
+ const ops_1 = require("./ops");
45
+ const perms_1 = require("./perms");
46
+ const scan_1 = require("./scan");
47
+ const limit_1 = require("./limit");
48
+ const vault_1 = require("./vault");
49
+ const web_1 = require("./web");
50
+ // git smart HTTP. Anonymous fetch (upload-pack) stays open for public
51
+ // repositories; a private one asks for Basic auth and then serves only a
52
+ // reader. Push (receive-pack) always requires a token presented over HTTP
53
+ // Basic auth. Session cookies are never consulted here: git and the browser
54
+ // present distinct credentials by design.
55
+ function pkt(s) {
56
+ return (s.length + 4).toString(16).padStart(4, '0') + s;
57
+ }
58
+ // The environment a git subprocess is given. GIT_PROTOCOL is how the version
59
+ // the client asked for reaches git, and its value arrives in a request header,
60
+ // so it is held to the shape git itself defines (`version=2`, and the
61
+ // colon-separated key or key=value list the protocol allows) rather than being
62
+ // passed on as written. Anything else is dropped, which leaves git at the
63
+ // version it would have negotiated without the header.
64
+ const GIT_PROTOCOL_RE = /^[A-Za-z0-9_.=-]{1,64}(:[A-Za-z0-9_.=-]{1,64})*$/;
65
+ function gitEnv(req) {
66
+ const env = { ...process.env };
67
+ const proto = req.get('git-protocol');
68
+ if (proto && GIT_PROTOCOL_RE.test(proto))
69
+ env.GIT_PROTOCOL = proto;
70
+ return env;
71
+ }
72
+ function parseBasicAuth(req) {
73
+ const h = req.get('authorization');
74
+ if (!h || !/^basic /i.test(h))
75
+ return null;
76
+ let decoded;
77
+ try {
78
+ decoded = Buffer.from(h.slice(6).trim(), 'base64').toString('utf8');
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const i = decoded.indexOf(':');
84
+ if (i === -1)
85
+ return null;
86
+ return { username: decoded.slice(0, i), password: decoded.slice(i + 1) };
87
+ }
88
+ // The vault has to be loadable before any credential means anything.
89
+ function vaultOf(root, verb) {
90
+ const state = (0, vault_1.loadVault)(root);
91
+ if (state.status === 'missing') {
92
+ return {
93
+ ok: false,
94
+ status: 401,
95
+ message: `${verb} denied: no vault.json in this vault; restart the server to initialize one`,
96
+ };
97
+ }
98
+ if (state.status === 'error') {
99
+ return { ok: false, status: 500, message: `${verb} denied: vault.json could not be read: ${state.message}` };
100
+ }
101
+ return { vault: state.vault };
102
+ }
103
+ function checkCreds(root, limiter, req, verb) {
104
+ const v = vaultOf(root, verb);
105
+ if ('ok' in v)
106
+ return v;
107
+ // git's first request carries no Basic auth by protocol and always will, so
108
+ // a missing credential is not a failed attempt and is not charged.
109
+ const creds = parseBasicAuth(req);
110
+ if (!creds) {
111
+ return { ok: false, status: 401, message: `authentication required to ${verb}` };
112
+ }
113
+ const allowed = limiter.allow(req, creds.username);
114
+ if (!allowed.ok) {
115
+ return {
116
+ ok: false,
117
+ status: 429,
118
+ message: 'too many failed authentication attempts; try again later',
119
+ retryAfter: allowed.retryAfter,
120
+ };
121
+ }
122
+ // The username first, and the token alone as the fallback: a token is 64
123
+ // random hex characters and identifies its owner by itself, so any username
124
+ // with a valid token works, as it does on GitHub. That is what lets a
125
+ // client that holds only a token (the backup client, a script) clone
126
+ // without also being told whose it is.
127
+ const auth = (0, vault_1.authenticate)(v.vault, creds.username, creds.password) ?? (0, vault_1.authenticateToken)(v.vault, creds.password);
128
+ if (!auth) {
129
+ limiter.fail(req, creds.username);
130
+ return { ok: false, status: 401, message: 'invalid username or token' };
131
+ }
132
+ return { auth };
133
+ }
134
+ /**
135
+ * Whether this request may read the repository over git. A public repository
136
+ * reads anonymously. A private one and an absent one answer identically, so
137
+ * the wire cannot distinguish them: no credential gets the 401 challenge
138
+ * (which is also what makes git ask the user for one), and a credential that
139
+ * proves no read access gets the same "repository not found" a truly absent
140
+ * repository gets. A workflow job's ephemeral token (src/jobtoken.ts) reads
141
+ * exactly the repository it was minted for.
142
+ */
143
+ function checkReadAuth(root, limiter, req, repo) {
144
+ if (repo && !(0, perms_1.repoIsPrivate)(repo.dir))
145
+ return { ok: true, auth: null };
146
+ const creds = parseBasicAuth(req);
147
+ if (repo && creds) {
148
+ const grant = (0, jobtoken_1.verifyJobToken)(root, creds.password);
149
+ if (grant && (0, jobtoken_1.grantCovers)(grant, repo.collection, repo.name))
150
+ return { ok: true, auth: null };
151
+ }
152
+ const checked = checkCreds(root, limiter, req, 'read');
153
+ if ('ok' in checked)
154
+ return checked;
155
+ if (!repo || !(0, perms_1.canReadRepo)(root, checked.auth, repo)) {
156
+ return { ok: false, status: 404, message: 'repository not found' };
157
+ }
158
+ return { ok: true, auth: checked.auth };
159
+ }
160
+ /**
161
+ * Whether this request may push. `repo` is null for push-to-create, where the
162
+ * question is creation in the collection rather than the write role on an
163
+ * existing repository. On an existing private repository a credential without
164
+ * even the read role gets "repository not found", as on the read path.
165
+ */
166
+ function checkPushAuth(root, limiter, req, collection, repoName, repo) {
167
+ const checked = checkCreds(root, limiter, req, 'push');
168
+ if ('ok' in checked)
169
+ return checked;
170
+ const auth = checked.auth;
171
+ if (repo) {
172
+ const role = (0, perms_1.repoRole)(root, auth, repo);
173
+ if (role === null)
174
+ return { ok: false, status: 404, message: 'repository not found' };
175
+ if (!(0, perms_1.atLeast)(role, 'write')) {
176
+ return {
177
+ ok: false,
178
+ status: 403,
179
+ message: `user ${auth.username} is not allowed to push to ${collection}/${repoName}`,
180
+ };
181
+ }
182
+ return { ok: true, auth };
183
+ }
184
+ if (!(0, perms_1.canCreateRepo)(root, auth, collection, repoName)) {
185
+ return {
186
+ ok: false,
187
+ status: 403,
188
+ message: `user ${auth.username} is not allowed to create ${collection}/${repoName}`,
189
+ };
190
+ }
191
+ return { ok: true, auth };
192
+ }
193
+ // The refs a push may have changed, read before and after receive-pack. A
194
+ // snapshot diff rather than a post-receive hook script: it needs no hook
195
+ // installed in each repository, so it works for repositories imported by
196
+ // `git clone --bare` as well as ones this server created.
197
+ async function refSnapshot(repo) {
198
+ const snap = new Map();
199
+ try {
200
+ const out = (await (0, git_1.execGit)(repo.dir, ['for-each-ref', '--format=%(refname)%00%(objectname)', 'refs/heads', 'refs/tags'])).toString('utf8');
201
+ for (const line of out.split('\n')) {
202
+ if (!line)
203
+ continue;
204
+ const [name, sha] = line.split('\0');
205
+ if (name && sha)
206
+ snap.set(name, sha);
207
+ }
208
+ }
209
+ catch {
210
+ // an unreadable ref list yields no events rather than failing the push
211
+ }
212
+ return snap;
213
+ }
214
+ const ZERO = '0'.repeat(40);
215
+ function registerGitHttp(app, root, gates, authLimiter, engine) {
216
+ // git shows the body of a 503 on the RPC to the person who ran the command, so
217
+ // it is one sentence. Not 429: this is server capacity rather than a client
218
+ // quota, and git's own error surface reads better with 503.
219
+ function denyBusy(res) {
220
+ res.status(503).setHeader('Retry-After', String(limit_1.BUSY_RETRY_SECONDS));
221
+ res.type('text/plain').send('the server is busy with other git work; please try again in a moment\n');
222
+ }
223
+ function deny(res, status, message, retryAfter) {
224
+ if (status === 401)
225
+ res.setHeader('WWW-Authenticate', 'Basic realm="mochi"');
226
+ if (retryAfter !== undefined)
227
+ res.setHeader('Retry-After', String(retryAfter));
228
+ res.status(status).type('text/plain').send(message + '\n');
229
+ }
230
+ function requirePushAuth(req, res, collection, repoName, repo) {
231
+ const check = checkPushAuth(root, authLimiter, req, collection, repoName, repo);
232
+ if (!check.ok) {
233
+ deny(res, check.status, check.message, check.retryAfter);
234
+ return null;
235
+ }
236
+ return check.auth;
237
+ }
238
+ /** The repository, readable by this request, or null having sent the refusal. */
239
+ function requireReadAuth(req, res) {
240
+ const repo = (0, scan_1.findRepo)(root, req.params.collection, req.params.repo);
241
+ const check = checkReadAuth(root, authLimiter, req, repo);
242
+ if (!check.ok) {
243
+ deny(res, check.status, check.message, check.retryAfter);
244
+ return null;
245
+ }
246
+ if (!repo) {
247
+ // Unreachable: checkReadAuth answers ok only for a repository it saw.
248
+ deny(res, 404, 'repository not found');
249
+ return null;
250
+ }
251
+ return repo;
252
+ }
253
+ async function ensureHead(repo) {
254
+ const branches = await repo.listRefs('heads');
255
+ if (branches.length === 0)
256
+ return;
257
+ try {
258
+ const head = (await (0, git_1.execGit)(repo.dir, ['symbolic-ref', '--short', 'HEAD'])).toString('utf8').trim();
259
+ if (branches.some((b) => b.name === head))
260
+ return;
261
+ }
262
+ catch {
263
+ // detached or unreadable HEAD; repoint below
264
+ }
265
+ const names = branches.map((b) => b.name).sort();
266
+ const pick = names.includes('main') ? 'main' : names.includes('master') ? 'master' : names[0];
267
+ await (0, git_1.execGit)(repo.dir, ['symbolic-ref', 'HEAD', `refs/heads/${pick}`]);
268
+ }
269
+ // Both of these spawn git, and both hold their gate slot until the child is
270
+ // gone rather than until the handler returns: runService pipes and returns
271
+ // immediately, so releasing on return would bound nothing at all. The gate's
272
+ // release is idempotent, which is what lets it be wired to both the child
273
+ // closing and the response closing; an aborted clone is ordinary traffic, and
274
+ // a gate that leaked a slot per abort would stop answering after four of them.
275
+ function gateFor(service) {
276
+ return service === 'git-upload-pack' ? gates.clone : gates.push;
277
+ }
278
+ async function advertise(req, res, service, dir) {
279
+ const release = await gateFor(service).enter();
280
+ if (!release) {
281
+ denyBusy(res);
282
+ return;
283
+ }
284
+ res.on('close', release);
285
+ res.setHeader('Content-Type', `application/x-${service}-advertisement`);
286
+ res.setHeader('Cache-Control', 'no-cache');
287
+ res.write(pkt(`# service=${service}\n`) + '0000');
288
+ const child = (0, child_process_1.spawn)('git', [service.slice(4), '--stateless-rpc', '--advertise-refs', dir], {
289
+ env: gitEnv(req),
290
+ });
291
+ child.on('close', release);
292
+ child.stdout.pipe(res);
293
+ child.on('error', () => {
294
+ release();
295
+ res.end();
296
+ });
297
+ }
298
+ async function runService(req, res, service, dir, onClose) {
299
+ const release = await gateFor(service).enter();
300
+ if (!release) {
301
+ denyBusy(res);
302
+ return;
303
+ }
304
+ res.on('close', release);
305
+ res.setHeader('Content-Type', `application/x-${service}-result`);
306
+ res.setHeader('Cache-Control', 'no-cache');
307
+ const child = (0, child_process_1.spawn)('git', [service.slice(4), '--stateless-rpc', dir], { env: gitEnv(req) });
308
+ let body = req;
309
+ if (req.headers['content-encoding'] === 'gzip') {
310
+ body = req.pipe(zlib.createGunzip());
311
+ }
312
+ body.pipe(child.stdin);
313
+ child.stdout.pipe(res);
314
+ child.on('error', () => {
315
+ release();
316
+ res.status(500).end();
317
+ });
318
+ child.on('close', (code) => {
319
+ release();
320
+ if (onClose)
321
+ onClose(code);
322
+ });
323
+ }
324
+ app.get('/:collection/:repo/info/refs', (0, web_1.ah)(async (req, res) => {
325
+ const service = req.query.service;
326
+ const collectionName = req.params.collection;
327
+ const repoName = (0, scan_1.displayName)(req.params.repo);
328
+ if (service === 'git-upload-pack') {
329
+ const repo = requireReadAuth(req, res);
330
+ if (!repo)
331
+ return;
332
+ await advertise(req, res, 'git-upload-pack', repo.dir);
333
+ return;
334
+ }
335
+ if (service === 'git-receive-pack') {
336
+ if (!(0, scan_1.isValidName)(collectionName) || !(0, scan_1.isValidName)(repoName)) {
337
+ res.status(404).type('text/plain').send('invalid repository name\n');
338
+ return;
339
+ }
340
+ let repo = (0, scan_1.findRepo)(root, collectionName, req.params.repo);
341
+ const auth = requirePushAuth(req, res, collectionName, repoName, repo);
342
+ if (!auth)
343
+ return;
344
+ if (!repo) {
345
+ // Push-to-create, so the name has to survive the same check the web
346
+ // form applies. An existing repository is served whatever it is
347
+ // called; only a new one is refused.
348
+ const reserved = (0, scan_1.reservedRepoSuffix)(repoName);
349
+ if (reserved) {
350
+ res
351
+ .status(400)
352
+ .type('text/plain')
353
+ .send(`repository names may not end in ${reserved}, which is reserved for the directories a repository keeps beside it\n`);
354
+ return;
355
+ }
356
+ // A push creates the collection too when it does not exist, and only
357
+ // a repository may carry a leading dot.
358
+ if ((0, scan_1.isDotName)(collectionName)) {
359
+ res.status(400).type('text/plain').send('collection names may not begin with a dot\n');
360
+ return;
361
+ }
362
+ repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
363
+ }
364
+ await advertise(req, res, 'git-receive-pack', repo.dir);
365
+ return;
366
+ }
367
+ res.status(403).type('text/plain').send('unsupported service\n');
368
+ }));
369
+ app.post('/:collection/:repo/git-upload-pack', (0, web_1.ah)(async (req, res) => {
370
+ const repo = requireReadAuth(req, res);
371
+ if (!repo)
372
+ return;
373
+ // The gate is entered after the read check and before anything expensive.
374
+ await runService(req, res, 'git-upload-pack', repo.dir);
375
+ }));
376
+ app.post('/:collection/:repo/git-receive-pack', (0, web_1.ah)(async (req, res) => {
377
+ const collectionName = req.params.collection;
378
+ const repoName = (0, scan_1.displayName)(req.params.repo);
379
+ if (!(0, scan_1.isValidName)(collectionName) || !(0, scan_1.isValidName)(repoName)) {
380
+ res.status(404).type('text/plain').send('invalid repository name\n');
381
+ return;
382
+ }
383
+ let repo = (0, scan_1.findRepo)(root, collectionName, req.params.repo);
384
+ const auth = requirePushAuth(req, res, collectionName, repoName, repo);
385
+ if (!auth)
386
+ return;
387
+ if (!repo) {
388
+ const reserved = (0, scan_1.reservedRepoSuffix)(repoName);
389
+ if (reserved) {
390
+ res
391
+ .status(400)
392
+ .type('text/plain')
393
+ .send(`repository names may not end in ${reserved}, which is reserved for the directories a repository keeps beside it\n`);
394
+ return;
395
+ }
396
+ if ((0, scan_1.isDotName)(collectionName)) {
397
+ res.status(400).type('text/plain').send('collection names may not begin with a dot\n');
398
+ return;
399
+ }
400
+ repo = await (0, ops_1.createRepo)(root, collectionName, repoName);
401
+ }
402
+ const target = repo;
403
+ const actor = auth.username;
404
+ const before = await refSnapshot(target);
405
+ // The push gate is entered only now, after requirePushAuth has succeeded:
406
+ // an unauthenticated request must not be able to occupy a slot that
407
+ // authorized users depend on.
408
+ await runService(req, res, 'git-receive-pack', repo.dir, (code) => {
409
+ if (code !== 0)
410
+ return;
411
+ ensureHead(target)
412
+ .then(() => (engine ? firePushEvents(engine, target, before, actor) : undefined))
413
+ .catch((e) => console.error(`post-receive handling failed: ${e instanceof Error ? e.message : e}`));
414
+ });
415
+ }));
416
+ }
417
+ // Turn a before/after ref snapshot into push events for the CI engine. Ref
418
+ // deletions are reported (with an all-zero "after") and ignored downstream;
419
+ // a workflow file that fails to parse still produces a visible failed run,
420
+ // which is why nothing here filters on content.
421
+ async function firePushEvents(engine, repo, before, actor) {
422
+ const after = await refSnapshot(repo);
423
+ for (const [ref, sha] of after) {
424
+ if (before.get(ref) === sha)
425
+ continue;
426
+ await engine.handlePush(repo, { ref, before: before.get(ref) ?? ZERO, after: sha, actor });
427
+ }
428
+ }
package/dist/html.js ADDED
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ // HTML with escaping as the default rather than the discipline.
3
+ //
4
+ // Every page here is built from template literals, and for a long time each
5
+ // interpolation escaped its own value by hand: `${esc(name)}`. The rule was
6
+ // simple and the failure mode was simpler: one forgotten esc() is an
7
+ // injection, and nothing but review stood between the two. This module turns
8
+ // the rule into the type system. The html`` tag escapes every interpolated
9
+ // value unless it is already Html, and a value is only Html because a
10
+ // template built it or because raw() was told, in so many words, to trust it.
11
+ //
12
+ // The composition rule falls out of the type: a fragment built by html`` can
13
+ // be interpolated into another html`` template and is not escaped again, so
14
+ // pages nest the way they always did, and the only places that need a
15
+ // decision are the borders where pre-rendered HTML comes in from elsewhere --
16
+ // markdown output, highlighted code, a diff -- which is exactly where the
17
+ // decision belongs.
18
+ //
19
+ // One trap is worth naming. An array of Html fragments interpolates
20
+ // correctly, joined by nothing, but calling .join() on one produces a plain
21
+ // string, which the tag then escapes: `${items.join('\n')}` is the bug and
22
+ // `${joinHtml(items, '\n')}` is the spelling. The type system cannot refuse
23
+ // .join on an array, so this is the one place the old discipline survives.
24
+ //
25
+ // Where escaping is checked: layout() takes Html and nothing else, so a page
26
+ // cannot be assembled from a plain string at all, and tests/escaping.test.ts
27
+ // drives the real page functions with a script tag and an attribute break
28
+ // where a name, a path, or a description goes. What it asserts is both halves:
29
+ // that nothing executable comes out, and that the payload reached the page, so
30
+ // a test cannot pass by rendering nothing.
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.Html = void 0;
33
+ exports.esc = esc;
34
+ exports.raw = raw;
35
+ exports.html = html;
36
+ exports.joinHtml = joinHtml;
37
+ function esc(s) {
38
+ return s
39
+ .replace(/&/g, '&amp;')
40
+ .replace(/</g, '&lt;')
41
+ .replace(/>/g, '&gt;')
42
+ .replace(/"/g, '&quot;')
43
+ .replace(/'/g, '&#39;');
44
+ }
45
+ /** A string of HTML that is what it says it is, and may be interpolated without escaping. */
46
+ class Html {
47
+ text;
48
+ constructor(text) {
49
+ this.text = text;
50
+ }
51
+ toString() {
52
+ return this.text;
53
+ }
54
+ }
55
+ exports.Html = Html;
56
+ /**
57
+ * Trust a string as HTML. This is the whole of the escape hatch, and the name
58
+ * is a flag for review: every raw() should be able to say where its argument
59
+ * was made safe.
60
+ */
61
+ function raw(text) {
62
+ return new Html(text);
63
+ }
64
+ function render(v) {
65
+ if (v instanceof Html)
66
+ return v.text;
67
+ if (Array.isArray(v))
68
+ return v.map(render).join('');
69
+ // Dropping the non-values is what makes `${cond && fragment}` a
70
+ // conditional and `${maybe?.thing}` an optional, without either spelling
71
+ // out an empty-string alternative.
72
+ if (v === null || v === undefined || typeof v === 'boolean')
73
+ return '';
74
+ return esc(String(v));
75
+ }
76
+ /** The tag. Literal text passes through; every value is escaped unless it is Html. */
77
+ function html(strings, ...values) {
78
+ let out = strings[0];
79
+ for (let i = 0; i < values.length; i++) {
80
+ out += render(values[i]) + strings[i + 1];
81
+ }
82
+ return new Html(out);
83
+ }
84
+ /** Fragments joined by a literal separator, since Array.prototype.join would flatten them to escaped text. */
85
+ function joinHtml(items, separator = '') {
86
+ return new Html(items.map(render).join(separator));
87
+ }
package/dist/icons.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.icon = icon;
4
+ const html_1 = require("./html");
5
+ // The icon set: drawn here, in the same language as the logo.
6
+ //
7
+ // mochi ships no static files, so an icon is a string of SVG markup rather
8
+ // than a sprite sheet or a font. Each entry below is the interior of one glyph
9
+ // on a 24-unit grid. The wrapper is added by icon() so every icon carries the
10
+ // same box, the same class, and the same accessibility treatment.
11
+ //
12
+ // The drawings are monoline: a constant stroke of 2 units with round caps and
13
+ // joins, no fills except where a shape has to read as solid at 16 pixels (the
14
+ // dot in an open issue, the three dots of a menu). This is the construction
15
+ // the mark and the logotype use, so the interface and the brand are one
16
+ // drawing rather than two. It is also why the shapes are built from circles,
17
+ // arcs, and straight runs, and why nothing here is an outline of a filled
18
+ // form: an outline would need a second weight, and there is only one.
19
+ //
20
+ // The grid is 24 while the box is 16, which gives half-unit positions at the
21
+ // rendered size without fractional coordinates in the source. Strokes are kept
22
+ // two units clear of the edges so a round cap is never cut by the viewBox.
23
+ //
24
+ // Icons take their colour from the surrounding text (stroke="currentColor"),
25
+ // which is what lets them sit inside links, buttons, and muted labels without
26
+ // a per-theme variant. Where a different colour is wanted, pass a class and
27
+ // give that class a colour token in style.ts.
28
+ const DOT = (cx, cy, r = 1.6) => `<circle cx="${cx}" cy="${cy}" r="${r}" fill="currentColor" stroke="none"/>`;
29
+ const PATHS = {
30
+ // Marks and arrows.
31
+ check: '<path d="M4.5 12.5 9.5 17.5 19.5 6.5"/>',
32
+ x: '<path d="M6 6 18 18M18 6 6 18"/>',
33
+ plus: '<path d="M12 4.5V19.5M4.5 12H19.5"/>',
34
+ 'chevron-down': '<path d="M6 9.5 12 15.5 18 9.5"/>',
35
+ circle: '<circle cx="12" cy="12" r="9"/>',
36
+ dot: `<circle cx="12" cy="12" r="9"/>${DOT(12, 12, 3.2)}`,
37
+ 'check-circle': '<circle cx="12" cy="12" r="9"/><path d="M7.5 12.2 10.6 15.3 16.5 9.2"/>',
38
+ 'x-circle': '<circle cx="12" cy="12" r="9"/><path d="M8.8 8.8 15.2 15.2M15.2 8.8 8.8 15.2"/>',
39
+ alert: `<path d="M10.3 3.9a2 2 0 0 1 3.4 0l7.5 13a2 2 0 0 1-1.7 3H4.5a2 2 0 0 1-1.7-3Z"/><path d="M12 9.5V14"/>${DOT(12, 17.3, 1.2)}`,
40
+ skip: '<circle cx="12" cy="12" r="9"/><path d="M8 12H16"/>',
41
+ stop: '<circle cx="12" cy="12" r="9"/><path d="M5.6 18.4 18.4 5.6"/>',
42
+ play: '<circle cx="12" cy="12" r="9"/><path d="M10 8.4 16 12 10 15.6Z"/>',
43
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 6.8V12L15.6 14.1"/>',
44
+ sync: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/><path d="M1.9 14.6 3.5 12 5.1 14.6"/>',
45
+ kebab: `${DOT(5, 12)}${DOT(12, 12)}${DOT(19, 12)}`,
46
+ // Repositories and their contents.
47
+ repo: '<rect x="4" y="3" width="16" height="18" rx="2.5"/><path d="M8.5 3V21"/>',
48
+ 'repo-forked': '<circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="5" r="2.5"/><circle cx="12" cy="19" r="2.5"/><path d="M6 7.5v1.5a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7.5M12 12v4.5"/>',
49
+ book: '<path d="M2.5 4.5h5.5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5.5v12H16a4 4 0 0 0-4 4 4 4 0 0 0-4-4H2.5Z"/><path d="M12 8.5v12"/>',
50
+ file: '<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5Z"/><path d="M13.5 3v5.5H19"/>',
51
+ 'file-zip': '<path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5Z"/><path d="M13.5 3v5.5H19"/><path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5"/>',
52
+ folder: '<path d="M3 19.5V6.5A1.5 1.5 0 0 1 4.5 5h4.1a1.5 1.5 0 0 1 1.2.6l1.3 1.8a1.5 1.5 0 0 0 1.2.6h7.2A1.5 1.5 0 0 1 21 9.5v10A1.5 1.5 0 0 1 19.5 21h-15A1.5 1.5 0 0 1 3 19.5Z"/>',
53
+ code: '<path d="M8.5 6 3.5 12 8.5 18M15.5 6 20.5 12 15.5 18"/>',
54
+ versions: '<rect x="9" y="3" width="12" height="18" rx="2"/><path d="M6 5.5V18.5M3 8V16"/>',
55
+ package: '<path d="M3.2 7.2 12 2.4l8.8 4.8v9.6L12 21.6l-8.8-4.8Z"/><path d="M3.2 7.2 12 12l8.8-4.8M12 12v9.6"/>',
56
+ tag: '<path d="M13.6 3.6A2 2 0 0 0 12.2 3H5a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l7.2 7.2a2 2 0 0 0 2.8 0l7.2-7.2a2 2 0 0 0 0-2.8Z"/><circle cx="7.6" cy="7.6" r="1.4"/>',
57
+ law: '<path d="M12 4V21M8 21h8M4.5 7.5 12 6l7.5 1.5"/><path d="M4.5 7.5 2 13a2.5 2.5 0 0 0 5 0Z"/><path d="M19.5 7.5 17 13a2.5 2.5 0 0 0 5 0Z"/>',
58
+ // The git graph.
59
+ 'git-commit': '<circle cx="12" cy="12" r="3.6"/><path d="M2.5 12h5.9M15.6 12h5.9"/>',
60
+ 'git-branch': '<circle cx="6" cy="18.5" r="2.5"/><circle cx="18" cy="6" r="2.5"/><path d="M6 16V3M18 8.5a9.5 9.5 0 0 1-9.5 9.5H8"/>',
61
+ 'git-merge': '<circle cx="6" cy="5.5" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="M6 8v13.5M6 10a8.5 8.5 0 0 0 8.5 8.5h1"/>',
62
+ 'git-pull-request': '<circle cx="6" cy="5.5" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="M6 8v13.5"/><path d="M13 5.5h2.5A2.5 2.5 0 0 1 18 8v8"/><path d="M15.5 3 13 5.5 15.5 8"/>',
63
+ 'git-pull-request-closed': '<circle cx="6" cy="5.5" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="M6 8v13.5"/><path d="M18 16v-4.5"/><path d="M15 4 21 10M21 4 15 10"/>',
64
+ 'git-compare': '<path d="M4 19.5V8.5A2.5 2.5 0 0 1 6.5 6H14"/><path d="M11.5 3.5 14 6 11.5 8.5"/><path d="M20 4.5v11a2.5 2.5 0 0 1-2.5 2.5H10"/><path d="M12.5 15.5 10 18 12.5 20.5"/>',
65
+ history: `<path d="M5.5 7.5v3M5.5 13.5v3"/>${DOT(5.5, 5.5, 2)}${DOT(5.5, 12, 2)}${DOT(5.5, 18.5, 2)}<path d="M11 5.5h9.5M11 12h9.5M11 18.5h6"/>`,
66
+ // Issues, pulls, and talk.
67
+ 'issue-opened': `<circle cx="12" cy="12" r="9"/>${DOT(12, 12, 2.2)}`,
68
+ 'issue-closed': '<circle cx="12" cy="12" r="9"/><path d="M7.5 12.2 10.6 15.3 16.5 9.2"/>',
69
+ comment: '<path d="M4.5 21V6.5A2.5 2.5 0 0 1 7 4h10A2.5 2.5 0 0 1 19.5 6.5v8A2.5 2.5 0 0 1 17 17H8.5Z"/>',
70
+ filter: '<path d="M3.5 6.5h17M6.5 12h11M10 17.5h4"/>',
71
+ search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.2 15.2 20.5 20.5"/>',
72
+ link: '<path d="M9.5 14.5 14.5 9.5"/><path d="M10.5 6.6 12.3 4.8a4.5 4.5 0 0 1 6.4 6.4l-1.8 1.8"/><path d="M13.5 17.4 11.7 19.2a4.5 4.5 0 0 1-6.4-6.4l1.8-1.8"/>',
73
+ // People.
74
+ person: '<circle cx="12" cy="8" r="3.6"/><path d="M4.5 20.5a7.5 7.5 0 0 1 15 0"/>',
75
+ people: '<circle cx="8.6" cy="8" r="3.2"/><circle cx="17" cy="8.8" r="2.4"/><path d="M2.6 19.5a6 6 0 0 1 12 0"/><path d="M16.2 14.2a5.6 5.6 0 0 1 5.2 5.3"/>',
76
+ // Actions and places.
77
+ pencil: '<path d="M4 20h4L19 9a2.83 2.83 0 0 0-4-4L4 16Z"/><path d="M14.5 6.5 17.5 9.5"/>',
78
+ trash: '<path d="M4 6.5h16"/><path d="M9.5 6.5V4.8A1.8 1.8 0 0 1 11.3 3h1.4a1.8 1.8 0 0 1 1.8 1.8V6.5"/><path d="M6.5 6.5 7.4 19.1A2 2 0 0 0 9.4 21h5.2a2 2 0 0 0 2-1.9L17.5 6.5"/><path d="M10 10.5V17M14 10.5V17"/>',
79
+ copy: '<rect x="8" y="8" width="13" height="13" rx="2.5"/><path d="M16 5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16"/>',
80
+ download: '<path d="M12 3.5V15.5M7 10.5 12 15.5 17 10.5"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>',
81
+ upload: '<path d="M12 15.5V3.5M7 8.5 12 3.5 17 8.5"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/>',
82
+ 'sign-out': '<path d="M15 4.5H6.5A1.5 1.5 0 0 0 5 6v12a1.5 1.5 0 0 0 1.5 1.5H15"/><path d="M11 12h10M17.5 8.5 21 12 17.5 15.5"/>',
83
+ sliders: '<path d="M3.5 6.5h9M16.5 6.5h4M3.5 12h3M10.5 12h10M3.5 17.5h9M16.5 17.5h4"/><circle cx="14.5" cy="6.5" r="2"/><circle cx="8.5" cy="12" r="2"/><circle cx="14.5" cy="17.5" r="2"/>',
84
+ appearance: '<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/>',
85
+ globe: '<circle cx="12" cy="12" r="9"/><path d="M3.3 9h17.4M3.3 15h17.4"/><path d="M12 3c2.4 2.5 3.7 5.6 3.7 9s-1.3 6.5-3.7 9c-2.4-2.5-3.7-5.6-3.7-9S9.6 5.5 12 3Z"/>',
86
+ server: `<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/>${DOT(7, 7.5, 1.2)}${DOT(7, 16.5, 1.2)}`,
87
+ workflow: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M6.5 10v4.5a3 3 0 0 0 3 3H14"/>',
88
+ rocket: '<path d="M12 2.5c2.6 2.6 4 5.9 4 9.5v3.5H8V12c0-3.6 1.4-6.9 4-9.5Z"/><circle cx="12" cy="9.8" r="1.7"/><path d="M8 12.6 5.2 15.4a2 2 0 0 0-.6 1.4V19l3.4-2M16 12.6l2.8 2.8a2 2 0 0 1 .6 1.4V19l-3.4-2"/><path d="M10.3 19c.3 1.1.9 2.1 1.7 2.9.8-.8 1.4-1.8 1.7-2.9"/>',
89
+ rss: `<path d="M4 11.5a8.5 8.5 0 0 1 8.5 8.5M4 4.5A15.5 15.5 0 0 1 19.5 20"/>${DOT(5, 19, 1.7)}`,
90
+ };
91
+ /**
92
+ * One icon as inline SVG. Icons are decorative next to a label, so they are
93
+ * hidden from assistive technology; where an icon stands alone (an icon-only
94
+ * button, say) the caller labels the control instead.
95
+ */
96
+ function icon(name, cls = '') {
97
+ const body = PATHS[name];
98
+ if (!body)
99
+ throw new Error(`unknown icon: ${name}`);
100
+ return (0, html_1.raw)(`<svg class="glyph${cls ? ` ${cls}` : ''}" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`);
101
+ }