@magland/mochi 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +138 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +192 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
@@ -0,0 +1,112 @@
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.createOneTimeStore = createOneTimeStore;
37
+ exports.mintHandoffCode = mintHandoffCode;
38
+ exports.normalizeHandoffCode = normalizeHandoffCode;
39
+ const crypto = __importStar(require("crypto"));
40
+ /** How many entries a store holds before new ones are refused a slot: the
41
+ * mints are all authenticated or rate-limited, so this is a backstop, not a
42
+ * quota anyone should meet. When it is hit, the oldest entry is dropped,
43
+ * which turns an attempted flood into self-inflicted expiry. */
44
+ const MAX_ENTRIES = 5000;
45
+ function createOneTimeStore(mint = mintOpaqueId) {
46
+ const entries = new Map();
47
+ const sweep = (now) => {
48
+ for (const [id, e] of entries) {
49
+ if (e.expiresAt <= now)
50
+ entries.delete(id);
51
+ }
52
+ };
53
+ const live = (id) => {
54
+ const e = entries.get(id);
55
+ if (!e)
56
+ return null;
57
+ if (e.expiresAt <= Date.now()) {
58
+ entries.delete(id);
59
+ return null;
60
+ }
61
+ return e;
62
+ };
63
+ return {
64
+ put(value, ttlMs) {
65
+ const now = Date.now();
66
+ sweep(now);
67
+ if (entries.size >= MAX_ENTRIES) {
68
+ const oldest = entries.keys().next();
69
+ if (!oldest.done)
70
+ entries.delete(oldest.value);
71
+ }
72
+ const id = mint();
73
+ entries.set(id, { value, expiresAt: now + ttlMs });
74
+ return id;
75
+ },
76
+ take(id) {
77
+ const e = live(id);
78
+ if (!e)
79
+ return null;
80
+ entries.delete(id);
81
+ return e.value;
82
+ },
83
+ peek(id) {
84
+ return live(id)?.value ?? null;
85
+ },
86
+ };
87
+ }
88
+ function mintOpaqueId() {
89
+ return crypto.randomBytes(24).toString('base64url');
90
+ }
91
+ // The alphabet a person retypes across the room: no 0/O, 1/I/L, or lowercase.
92
+ // Eight characters carry just under 40 bits, which with a five-minute life
93
+ // and the sign-in rate limit is far beyond guessing.
94
+ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
95
+ /** A code like MQ4V-7XKP, for typing on another device. */
96
+ function mintHandoffCode() {
97
+ const chars = [];
98
+ for (let i = 0; i < 8; i++) {
99
+ chars.push(CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]);
100
+ if (i === 3)
101
+ chars.push('-');
102
+ }
103
+ return chars.join('');
104
+ }
105
+ /** The reading of a typed code: case, spaces, and the dash are the display's
106
+ * business and are dropped; the characters themselves must match, which the
107
+ * alphabet makes unambiguous by never containing 0/O or 1/I/L. The dash is
108
+ * re-inserted so the normalized form is the minted form. */
109
+ function normalizeHandoffCode(typed) {
110
+ const s = typed.toUpperCase().replace(/[^A-Z0-9]/g, '');
111
+ return s.length === 8 ? `${s.slice(0, 4)}-${s.slice(4)}` : s;
112
+ }
package/dist/ops.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.NewBranchError = exports.MAX_UPLOAD_SIZE = exports.MAX_EDIT_SIZE = exports.OpError = void 0;
36
+ exports.NewBranchError = exports.MAX_DESCRIPTION_LENGTH = exports.MAX_UPLOAD_SIZE = exports.MAX_EDIT_SIZE = exports.OpError = void 0;
37
37
  exports.opErrorStatus = opErrorStatus;
38
38
  exports.createCollection = createCollection;
39
39
  exports.createRepo = createRepo;
@@ -43,14 +43,17 @@ exports.commitFileChange = commitFileChange;
43
43
  exports.previewMerge = previewMerge;
44
44
  exports.mergeBranch = mergeBranch;
45
45
  exports.createBranch = createBranch;
46
+ exports.collectRepo = collectRepo;
46
47
  exports.deleteBranch = deleteBranch;
47
48
  exports.createTag = createTag;
48
49
  exports.deleteTag = deleteTag;
49
50
  exports.setDefaultBranch = setDefaultBranch;
51
+ exports.setUpstream = setUpstream;
50
52
  exports.setDescription = setDescription;
51
53
  exports.containedIn = containedIn;
52
54
  exports.renameRepo = renameRepo;
53
55
  exports.renameCollection = renameCollection;
56
+ exports.deleteCollection = deleteCollection;
54
57
  exports.deleteRepo = deleteRepo;
55
58
  exports.writeFile = writeFile;
56
59
  exports.createRepoWithReadme = createRepoWithReadme;
@@ -61,8 +64,11 @@ const path = __importStar(require("path"));
61
64
  const atomic_1 = require("./atomic");
62
65
  const git_1 = require("./git");
63
66
  const perms_1 = require("./perms");
67
+ const sitesettings_1 = require("./sitesettings");
64
68
  const vault_1 = require("./vault");
65
69
  const pointer_1 = require("./pointer");
70
+ const source_1 = require("./source");
71
+ const domains_1 = require("./domains");
66
72
  const redirects_1 = require("./redirects");
67
73
  const layout_1 = require("./layout");
68
74
  const scan_1 = require("./scan");
@@ -134,24 +140,66 @@ function tmpFile(prefix) {
134
140
  function createCollection(root, name) {
135
141
  if (!(0, scan_1.isValidName)(name) || (0, scan_1.isDotName)(name))
136
142
  throw new OpError('invalid collection name');
143
+ checkNewName('collection', name);
137
144
  const dir = (0, layout_1.collectionDir)(root, name);
138
145
  if (fs.existsSync(dir))
139
146
  throw new OpError(`collection ${name} already exists`, 'exists');
147
+ checkCollectionCase(root, name);
140
148
  fs.mkdirSync(dir, { recursive: true });
141
149
  fs.mkdirSync(path.join(dir, layout_1.REPOS_DIR));
142
150
  return dir;
143
151
  }
152
+ /**
153
+ * The checks every route a collection or repository name comes into being
154
+ * through shares, beyond the character rules isValidName applies. Only asked
155
+ * at creation and rename: reading stays permissive, so a vault that already
156
+ * holds such a name keeps serving it.
157
+ *
158
+ * The length cap keeps the on-disk form (`<name>.git`, `<name>.issues`) well
159
+ * clear of the 255-byte filename limit, which a longer name would otherwise
160
+ * hit inside the filesystem and surface as a bare failure. A trailing dot is
161
+ * refused because `name..git` cannot exist on Windows and reads like a typo
162
+ * everywhere else.
163
+ */
164
+ function checkNewName(kind, name) {
165
+ if (name.length > scan_1.MAX_NAME_LENGTH) {
166
+ throw new OpError(`a ${kind} name may be at most ${scan_1.MAX_NAME_LENGTH} characters (this one is ${name.length})`);
167
+ }
168
+ if (name.endsWith('.'))
169
+ throw new OpError(`a ${kind} name may not end with a dot`);
170
+ }
144
171
  /**
145
172
  * Refuse a name that would place a new repository on top of the directories
146
173
  * another repository keeps beside it. Creation is the only place this is
147
174
  * asked: a repository that already carries such a name goes on working.
148
175
  */
149
176
  function checkNewRepoName(name) {
177
+ checkNewName('repository', name);
150
178
  const suffix = (0, scan_1.reservedRepoSuffix)(name);
151
179
  if (suffix) {
152
180
  throw new OpError(`a repository may not be named to end in ${suffix}, which is reserved for the directories a repository keeps beside it`);
153
181
  }
154
182
  }
183
+ /**
184
+ * Refuse a collection or repository name that matches an existing one apart
185
+ * from letter case. Two names telling apart only by case are confusing side by
186
+ * side in every listing, and a vault holding both would not survive a copy to
187
+ * a case-insensitive filesystem (macOS, Windows) or backup target.
188
+ * `allowSelf`, on a rename, is the name being moved away from, so a rename
189
+ * that only changes case is not refused as a collision with itself.
190
+ */
191
+ function checkCollectionCase(root, name, allowSelf) {
192
+ const clash = (0, scan_1.collectionCaseClash)(root, name);
193
+ if (clash && clash !== allowSelf) {
194
+ throw new OpError(`collection ${clash} already exists; names may not differ only in letter case`, 'exists');
195
+ }
196
+ }
197
+ function checkRepoCase(root, collection, name, allowSelf) {
198
+ const clash = (0, scan_1.repoCaseClash)(root, collection, name);
199
+ if (clash && clash !== allowSelf) {
200
+ throw new OpError(`${collection}/${clash} already exists; names may not differ only in letter case`, 'exists');
201
+ }
202
+ }
155
203
  /**
156
204
  * Refuse a dot-prefixed collection. A repository's collection is created on
157
205
  * the way when it does not exist, here and in fork and move alike, so the
@@ -167,11 +215,25 @@ async function createRepo(root, collection, name, opts = {}) {
167
215
  if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name))
168
216
  throw new OpError('invalid collection or repository name');
169
217
  checkNewCollectionName(collection);
218
+ checkNewName('collection', collection);
170
219
  checkNewRepoName(name);
220
+ if ((0, scan_1.findRepo)(root, collection, name))
221
+ throw new OpError(`${collection}/${name} already exists`, 'exists');
222
+ // The case check on the collection only matters when this create would bring
223
+ // the collection into being: a collection that already exists under exactly
224
+ // this name is simply being added to, whatever else the vault holds.
225
+ if (!fs.existsSync((0, layout_1.collectionDir)(root, collection)))
226
+ checkCollectionCase(root, collection);
227
+ checkRepoCase(root, collection, name);
171
228
  fs.mkdirSync((0, layout_1.reposDir)(root, collection), { recursive: true });
172
229
  const dir = (0, layout_1.repoPath)(root, collection, `${name}.git`);
173
230
  await (0, git_1.execGit)(root, ['init', '--bare', '--initial-branch=main', dir]);
174
- await (0, git_1.execGit)(dir, ['config', 'receive.denyNonFastForwards', 'true']);
231
+ // Deletes are refused on push and force pushes are not, which is GitHub's
232
+ // arrangement for a branch nothing protects: rewriting a branch is how a
233
+ // history is corrected, and what it abandons is collected by the sweep in
234
+ // src/maintenance.ts, while deleting one is done through the web or the API,
235
+ // where it is confirmed. A vault upgraded from before this ran with
236
+ // receive.denyNonFastForwards set; migratePushPolicy unsets it.
175
237
  await (0, git_1.execGit)(dir, ['config', 'receive.denyDeletes', 'true']);
176
238
  await (0, git_1.execGit)(dir, ['config', 'receive.maxInputSize', String(2 * 1024 * 1024 * 1024)]);
177
239
  // Written before the repository is announced anywhere, so a repository asked
@@ -201,6 +263,7 @@ async function forkRepo(root, collection, name, toCollection, toName) {
201
263
  throw new OpError('invalid collection or repository name');
202
264
  }
203
265
  checkNewCollectionName(toCollection);
266
+ checkNewName('collection', toCollection);
204
267
  checkNewRepoName(toName);
205
268
  if (toCollection === collection && toName === name) {
206
269
  throw new OpError('a repository cannot be forked onto itself');
@@ -208,12 +271,15 @@ async function forkRepo(root, collection, name, toCollection, toName) {
208
271
  if ((0, scan_1.findRepo)(root, toCollection, toName)) {
209
272
  throw new OpError(`${toCollection}/${toName} already exists`, 'exists');
210
273
  }
274
+ if (!fs.existsSync((0, layout_1.collectionDir)(root, toCollection)))
275
+ checkCollectionCase(root, toCollection);
276
+ checkRepoCase(root, toCollection, toName);
211
277
  fs.mkdirSync((0, layout_1.reposDir)(root, toCollection), { recursive: true });
212
278
  const dir = (0, layout_1.repoPath)(root, toCollection, `${toName}.git`);
213
279
  await (0, git_1.execGit)(root, ['clone', '--bare', source.dir, dir]);
214
280
  await (0, git_1.execGit)(dir, ['remote', 'remove', 'origin']).catch(() => undefined);
215
281
  await (0, git_1.execGit)(dir, ['config', 'mochi.forkedFrom', `${collection}/${name}`]);
216
- await (0, git_1.execGit)(dir, ['config', 'receive.denyNonFastForwards', 'true']);
282
+ // As createRepo: deletes refused on push, force pushes allowed.
217
283
  await (0, git_1.execGit)(dir, ['config', 'receive.denyDeletes', 'true']);
218
284
  await (0, git_1.execGit)(dir, ['config', 'receive.maxInputSize', String(2 * 1024 * 1024 * 1024)]);
219
285
  const description = fs.existsSync(path.join(source.dir, 'description'))
@@ -269,10 +335,16 @@ async function commitFiles(repoDir, args) {
269
335
  const removals = args.removals ?? [];
270
336
  if (!(0, git_1.isValidRefName)(branch) || branch.startsWith('-'))
271
337
  throw new OpError('invalid branch name');
338
+ // A first commit is also the branch coming into being, so the name takes
339
+ // the same rules explicit branch creation applies.
340
+ if (expectedHead === null)
341
+ checkNewRefName('branch', branch);
272
342
  if (files.length === 0 && removals.length === 0)
273
343
  throw new OpError('no files to commit');
274
344
  for (const file of files) {
275
- if (!(0, git_1.isValidRepoPath)(file.path) || file.path === '')
345
+ // The stricter write-side rule: paths only being removed stay on the
346
+ // permissive one, so whatever the repository already holds can go.
347
+ if (!(0, git_1.isValidNewRepoPath)(file.path) || file.path === '')
276
348
  throw new OpError(`invalid file path: ${file.path}`);
277
349
  }
278
350
  for (const target of removals) {
@@ -348,12 +420,19 @@ async function commitFileChange(repoDir, args) {
348
420
  const { branch, filePath, message, author, expectedHead, action } = args;
349
421
  if (!(0, git_1.isValidRefName)(branch) || branch.startsWith('-'))
350
422
  throw new OpError('invalid branch name');
351
- if (!(0, git_1.isValidRepoPath)(filePath) || filePath === '')
423
+ // A first commit is also the branch coming into being; see commitFiles.
424
+ if (expectedHead === null)
425
+ checkNewRefName('branch', branch);
426
+ // A path being created takes the stricter write-side rule; a path that is
427
+ // only being edited in place or deleted stays on the permissive one, so a
428
+ // file the repository already holds under an awkward name can still go.
429
+ const pathCheck = action.kind === 'create' ? git_1.isValidNewRepoPath : git_1.isValidRepoPath;
430
+ if (!pathCheck(filePath) || filePath === '')
352
431
  throw new OpError('invalid file path');
353
432
  if (expectedHead !== null && !(0, git_1.isValidSha)(expectedHead))
354
433
  throw new OpError('invalid expected commit');
355
434
  const toPath = action.kind === 'edit' && action.toPath && action.toPath !== filePath ? action.toPath : null;
356
- if (toPath !== null && !(0, git_1.isValidRepoPath)(toPath))
435
+ if (toPath !== null && !(0, git_1.isValidNewRepoPath)(toPath))
357
436
  throw new OpError('invalid file path');
358
437
  if (expectedHead !== null) {
359
438
  if (action.kind === 'create') {
@@ -531,9 +610,60 @@ async function moveBranch(repoDir, branch, to, from) {
531
610
  throw new OpError(`branch ${branch} has moved since this page was loaded`, 'conflict');
532
611
  }
533
612
  }
613
+ /**
614
+ * Refuse a name no new branch or tag may carry, saying which rule it broke.
615
+ * The rules themselves live in isValidNewRefName (src/git.ts); this spells
616
+ * each refusal out, because "invalid branch name" helps nobody whose name was
617
+ * refused for being a pseudo-ref or for carrying a pasted refs/ prefix.
618
+ */
619
+ function checkNewRefName(kind, name) {
620
+ if ((0, git_1.isValidNewRefName)(name))
621
+ return;
622
+ if (name === 'HEAD' || /^[A-Z_]+_HEAD$/.test(name)) {
623
+ throw new OpError(`${name} is reserved by git; a ${kind} of that name would be ambiguous on every clone`);
624
+ }
625
+ if (name === 'refs' || name.startsWith('refs/')) {
626
+ throw new OpError(`name the ${kind} without the refs/ prefix`);
627
+ }
628
+ if (name.length > 200) {
629
+ throw new OpError(`a ${kind} name may be at most 200 characters (this one is ${name.length})`);
630
+ }
631
+ throw new OpError(`invalid ${kind} name`);
632
+ }
633
+ /**
634
+ * Why creating `refs/<space>/<name>` failed, as an OpError a person can act
635
+ * on. update-ref fails the same way whether the ref exists or its name
636
+ * collides with the file/directory shape of another ref (creating `feat` when
637
+ * `feat/sub` exists, or the reverse), so the two are told apart here rather
638
+ * than both reported as "already exists".
639
+ */
640
+ async function refCreateError(repoDir, kind, space, name, fallback) {
641
+ if (await refTip(repoDir, `${space}/${name}`)) {
642
+ return new OpError(`${kind} ${name} already exists`, 'exists');
643
+ }
644
+ try {
645
+ const under = (await (0, git_1.execGit)(repoDir, ['for-each-ref', '--count=1', '--format=%(refname)', `${space}/${name}/`]))
646
+ .toString('utf8')
647
+ .trim();
648
+ if (under !== '') {
649
+ const other = under.slice(space.length + 1);
650
+ return new OpError(`${kind} ${name} conflicts with existing ${kind} ${other}; a ${kind} cannot also name a prefix of another`, 'conflict');
651
+ }
652
+ const parts = name.split('/');
653
+ for (let i = 1; i < parts.length; i++) {
654
+ const prefix = parts.slice(0, i).join('/');
655
+ if (await refTip(repoDir, `${space}/${prefix}`)) {
656
+ return new OpError(`${kind} ${name} conflicts with existing ${kind} ${prefix}; a ${kind} cannot also name a prefix of another`, 'conflict');
657
+ }
658
+ }
659
+ }
660
+ catch {
661
+ // Diagnosis is best-effort; the fallback below still names the failure.
662
+ }
663
+ return new OpError(`could not create ${kind} ${name}: ${fallback instanceof Error ? fallback.message : fallback}`);
664
+ }
534
665
  async function createBranch(repoDir, name, fromRef) {
535
- if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
536
- throw new OpError('invalid branch name');
666
+ checkNewRefName('branch', name);
537
667
  if (!(0, git_1.isValidRefName)(fromRef) || fromRef.startsWith('-'))
538
668
  throw new OpError('invalid source ref');
539
669
  await (0, git_1.execGit)(repoDir, ['check-ref-format', `refs/heads/${name}`]);
@@ -543,10 +673,65 @@ async function createBranch(repoDir, name, fromRef) {
543
673
  try {
544
674
  await (0, git_1.execGit)(repoDir, ['update-ref', `refs/heads/${name}`, sha, '']);
545
675
  }
546
- catch {
547
- throw new OpError(`branch ${name} already exists`, 'exists');
676
+ catch (e) {
677
+ throw await refCreateError(repoDir, 'branch', 'refs/heads', name, e);
548
678
  }
549
679
  }
680
+ async function objectStats(repoDir) {
681
+ const out = (await (0, git_1.execGit)(repoDir, ['count-objects', '-v'])).toString('utf8');
682
+ const field = (name) => {
683
+ const line = out.split('\n').find((l) => l.startsWith(`${name}: `));
684
+ const value = line ? Number(line.slice(name.length + 2).trim()) : 0;
685
+ return Number.isFinite(value) ? value : 0;
686
+ };
687
+ return {
688
+ objects: field('count') + field('in-pack'),
689
+ kilobytes: field('size') + field('size-pack'),
690
+ };
691
+ }
692
+ /**
693
+ * How recent an unreachable object has to be to survive an on-demand
694
+ * collection.
695
+ *
696
+ * Not `now`, deliberately. A push uploads its objects before it moves the
697
+ * branch that will make them reachable, so during those seconds its objects are
698
+ * unreachable in exactly the way abandoned ones are, and a collection sparing
699
+ * nothing could delete a push in flight. Five minutes is far longer than that
700
+ * window and short enough that anything a person is asking to be rid of, which
701
+ * is by definition something already pushed and then rewritten away, is gone.
702
+ * The periodic sweep in src/maintenance.ts is far more generous, because it runs
703
+ * unattended and nobody is watching it.
704
+ */
705
+ const ON_DEMAND_PRUNE = '5.minutes.ago';
706
+ /** A collection this slow is one the caller should not be waiting on. */
707
+ const GC_TIMEOUT_MS = 10 * 60 * 1000;
708
+ /**
709
+ * Collect one repository now: drop every object no ref can reach, and repack
710
+ * what is left.
711
+ *
712
+ * This is the answer to "I pushed something I should not have, rewrote it out of
713
+ * the history, and want it gone", which the periodic sweep cannot be: that one
714
+ * spares anything unreachable but recent, so it does not promise promptness.
715
+ * Reflogs are expired first, since a vault configured to keep them would
716
+ * otherwise hold a reference to exactly the commits being disowned.
717
+ */
718
+ async function collectRepo(repoDir) {
719
+ const before = await objectStats(repoDir);
720
+ await (0, git_1.execGit)(repoDir, ['reflog', 'expire', '--expire=now', '--expire-unreachable=now', '--all']).catch(() => undefined);
721
+ try {
722
+ await (0, git_1.execGit)(repoDir, ['gc', '--quiet', `--prune=${ON_DEMAND_PRUNE}`], { timeoutMs: GC_TIMEOUT_MS });
723
+ }
724
+ catch (e) {
725
+ // The commonest failure is another gc holding the repository's gc.pid lock,
726
+ // which is a conflict rather than a fault: the work is already happening.
727
+ const message = e instanceof Error ? e.message : String(e);
728
+ if (/gc is already running|gc\.pid/i.test(message)) {
729
+ throw new OpError('a collection is already running on this repository', 'conflict');
730
+ }
731
+ throw new OpError(`could not collect the repository: ${message}`);
732
+ }
733
+ return { before, after: await objectStats(repoDir) };
734
+ }
550
735
  async function deleteBranch(repoDir, name) {
551
736
  if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
552
737
  throw new OpError('invalid branch name');
@@ -556,8 +741,7 @@ async function deleteBranch(repoDir, name) {
556
741
  await (0, git_1.execGit)(repoDir, ['update-ref', '-d', `refs/heads/${name}`]);
557
742
  }
558
743
  async function createTag(repoDir, name, atRef) {
559
- if (!(0, git_1.isValidRefName)(name) || name.startsWith('-'))
560
- throw new OpError('invalid tag name');
744
+ checkNewRefName('tag', name);
561
745
  if (!(0, git_1.isValidRefName)(atRef) || atRef.startsWith('-'))
562
746
  throw new OpError('invalid target ref');
563
747
  await (0, git_1.execGit)(repoDir, ['check-ref-format', `refs/tags/${name}`]);
@@ -567,8 +751,8 @@ async function createTag(repoDir, name, atRef) {
567
751
  try {
568
752
  await (0, git_1.execGit)(repoDir, ['update-ref', `refs/tags/${name}`, sha, '']);
569
753
  }
570
- catch {
571
- throw new OpError(`tag ${name} already exists`, 'exists');
754
+ catch (e) {
755
+ throw await refCreateError(repoDir, 'tag', 'refs/tags', name, e);
572
756
  }
573
757
  }
574
758
  async function deleteTag(repoDir, name) {
@@ -584,8 +768,32 @@ async function setDefaultBranch(repoDir, branch) {
584
768
  throw new OpError(`branch ${branch} not found`, 'notfound');
585
769
  await (0, git_1.execGit)(repoDir, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
586
770
  }
771
+ /**
772
+ * Record (or clear, with '') the URL outside this vault the repository was
773
+ * forked from, as `mochi.upstream`. Only the two URL shapes `mochi fork`
774
+ * clones from are accepted, so what upstreamOf later reads back always parses.
775
+ */
776
+ async function setUpstream(repoDir, url) {
777
+ if (url === '') {
778
+ await (0, git_1.execGit)(repoDir, ['config', '--unset', 'mochi.upstream']).catch(() => undefined);
779
+ return;
780
+ }
781
+ if (!(0, source_1.parseUpstream)(url))
782
+ throw new OpError('the upstream must be an https or ssh git URL');
783
+ await (0, git_1.execGit)(repoDir, ['config', 'mochi.upstream', url]);
784
+ }
785
+ /**
786
+ * The longest description a repository may carry. Descriptions are rendered on
787
+ * the listing pages, so an unbounded one is a one-field way to swell every
788
+ * page that names the repository; a few hundred characters is room for a
789
+ * sentence or two, which is what the field is for.
790
+ */
791
+ exports.MAX_DESCRIPTION_LENGTH = 500;
587
792
  function setDescription(repoDir, text) {
588
793
  const line = text.replace(/\s+/g, ' ').trim();
794
+ if (line.length > exports.MAX_DESCRIPTION_LENGTH) {
795
+ throw new OpError(`the description may be at most ${exports.MAX_DESCRIPTION_LENGTH} characters (this one is ${line.length}); longer prose belongs in the README`);
796
+ }
589
797
  (0, atomic_1.writeFileAtomic)(path.join(repoDir, 'description'), line === '' ? '' : line + '\n');
590
798
  }
591
799
  function containedIn(rootReal, target) {
@@ -624,12 +832,19 @@ async function renameRepo(root, collection, name, toCollection, toName, ctx = {}
624
832
  throw new OpError('invalid collection or repository name');
625
833
  }
626
834
  checkNewCollectionName(toCollection);
835
+ checkNewName('collection', toCollection);
627
836
  checkNewRepoName(toName);
628
837
  if (toCollection === collection && toName === name)
629
838
  throw new OpError('that is already its name', 'nochange');
630
839
  if ((0, scan_1.findRepo)(root, toCollection, toName)) {
631
840
  throw new OpError(`${toCollection}/${toName} already exists`, 'exists');
632
841
  }
842
+ if (!fs.existsSync((0, layout_1.collectionDir)(root, toCollection)))
843
+ checkCollectionCase(root, toCollection);
844
+ // A rename that only changes the name's case is the repository colliding
845
+ // with itself, which is allowed; anything else that matches apart from case
846
+ // is refused.
847
+ checkRepoCase(root, toCollection, toName, toCollection === collection ? name : undefined);
633
848
  const rootReal = fs.realpathSync(root);
634
849
  if (!containedIn(rootReal, repo.dir)) {
635
850
  throw new OpError('repository directory is outside the vault; refusing to move it');
@@ -673,6 +888,10 @@ async function renameRepo(root, collection, name, toCollection, toName, ctx = {}
673
888
  // and a move that then failed would point at a repository that is not there.
674
889
  // See src/redirects.ts for what the redirect does and does not survive.
675
890
  (0, redirects_1.recordRepoRename)(root, collection, name, toCollection, toName);
891
+ // A custom domain maps to the repository by name, so the mapping is
892
+ // re-pointed with the rename; the domain itself does not change, which is
893
+ // the point of having one.
894
+ (0, domains_1.moveRepoDomains)(root, collection, name, toCollection, toName);
676
895
  }
677
896
  /**
678
897
  * Rename a collection.
@@ -694,6 +913,7 @@ async function renameCollection(root, name, toName, ctx = {}) {
694
913
  if (!(0, scan_1.isValidName)(name) || !(0, scan_1.isValidName)(toName) || (0, scan_1.isDotName)(toName)) {
695
914
  throw new OpError('invalid collection name');
696
915
  }
916
+ checkNewName('collection', toName);
697
917
  const dir = (0, layout_1.collectionDir)(root, name);
698
918
  let isDir = false;
699
919
  try {
@@ -709,6 +929,9 @@ async function renameCollection(root, name, toName, ctx = {}) {
709
929
  const dest = (0, layout_1.collectionDir)(root, toName);
710
930
  if (fs.existsSync(dest))
711
931
  throw new OpError(`collection ${toName} already exists`, 'exists');
932
+ // A rename that only changes the case of this collection's own name is
933
+ // allowed; a case-variant of any other collection is refused.
934
+ checkCollectionCase(root, toName, name);
712
935
  const rootReal = fs.realpathSync(root);
713
936
  if (!containedIn(rootReal, dir)) {
714
937
  throw new OpError('collection directory is outside the vault; refusing to move it');
@@ -744,6 +967,54 @@ async function renameCollection(root, name, toName, ctx = {}) {
744
967
  // - the collection page and every repository in it - is redirected to the new
745
968
  // one until something else is created under that name.
746
969
  (0, redirects_1.recordCollectionRename)(root, name, toName);
970
+ // And every custom domain into the collection follows it, as the repository
971
+ // hook above does for one.
972
+ (0, domains_1.moveCollectionDomains)(root, name, toName);
973
+ }
974
+ /**
975
+ * Delete a collection, and only an empty one: deletion is for a name that is
976
+ * no longer wanted, not a way to remove many repositories at once, so a
977
+ * collection holding any repository - or anything a repository keeps beside
978
+ * it - is refused rather than emptied. The collection's own metadata
979
+ * (collection.json, the owners file, and site.json, its site alias) does not
980
+ * count against emptiness: it describes the collection and goes with it, as a
981
+ * repository's issues go with the repository. A file this layer does not
982
+ * recognize is refused rather than deleted, since it is not the collection's
983
+ * to lose.
984
+ */
985
+ function deleteCollection(root, name) {
986
+ if (!(0, scan_1.isValidName)(name))
987
+ throw new OpError('invalid collection name');
988
+ const dir = (0, layout_1.collectionDir)(root, name);
989
+ let isDir = false;
990
+ try {
991
+ isDir = fs.statSync(dir).isDirectory();
992
+ }
993
+ catch {
994
+ isDir = false;
995
+ }
996
+ if (!isDir)
997
+ throw new OpError(`collection ${name} not found`, 'notfound');
998
+ const rootReal = fs.realpathSync(root);
999
+ if (!containedIn(rootReal, dir)) {
1000
+ throw new OpError('collection directory is outside the vault; refusing to delete it');
1001
+ }
1002
+ const repos = (0, layout_1.reposDir)(root, name);
1003
+ const inRepos = fs.existsSync(repos) ? fs.readdirSync(repos) : [];
1004
+ if (inRepos.length > 0)
1005
+ throw new OpError(`collection ${name} is not empty`, 'conflict');
1006
+ const own = fs
1007
+ .readdirSync(dir)
1008
+ .filter((n) => n !== layout_1.REPOS_DIR && n !== perms_1.COLLECTION_FILE && n !== sitesettings_1.COLLECTION_SITE_FILE);
1009
+ if (own.length > 0) {
1010
+ throw new OpError(`collection ${name} holds ${own[0]}, which this server did not put there; refusing to delete it`, 'conflict');
1011
+ }
1012
+ fs.rmSync(dir, { recursive: true });
1013
+ // Any redirect that led here goes with it. A collection created later under
1014
+ // this name would otherwise inherit the traffic a former name of this one
1015
+ // still sends.
1016
+ (0, redirects_1.forgetCollectionRedirects)(root, name);
1017
+ (0, domains_1.dropCollectionDomains)(root, name);
747
1018
  }
748
1019
  /**
749
1020
  * Delete a repository and everything it accumulated: the bare repository, its
@@ -787,8 +1058,9 @@ async function deleteRepo(root, collection, name, ctx = {}) {
787
1058
  // And any redirect that led here. A redirect pointing at a repository that
788
1059
  // no longer exists is inert, but one left pointing at a name that is later
789
1060
  // re-created would send traffic meant for the deleted repository to whatever
790
- // took its place.
1061
+ // took its place. A custom domain is dropped on exactly the same reasoning.
791
1062
  (0, redirects_1.forgetRepoRedirects)(root, collection, name);
1063
+ (0, domains_1.dropRepoDomains)(root, collection, name);
792
1064
  }
793
1065
  /**
794
1066
  * A write that failed before it began, because the branch it was to go on could
@@ -851,8 +1123,13 @@ async function writeFile(repoDir, request) {
851
1123
  * The caller fires the push event, since it holds the CI engine.
852
1124
  */
853
1125
  async function createRepoWithReadme(root, collection, name, opts) {
1126
+ const description = (opts.description ?? '').replace(/\s+/g, ' ').trim();
1127
+ // Checked before the repository exists, so a refused description does not
1128
+ // leave a created repository behind the error page.
1129
+ if (description.length > exports.MAX_DESCRIPTION_LENGTH) {
1130
+ throw new OpError(`the description may be at most ${exports.MAX_DESCRIPTION_LENGTH} characters (this one is ${description.length}); longer prose belongs in the README`);
1131
+ }
854
1132
  const repo = await createRepo(root, collection, name, { private: opts.private });
855
- const description = (opts.description ?? '').trim();
856
1133
  if (description)
857
1134
  setDescription(repo.dir, description);
858
1135
  if (!opts.readme)