@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.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +138 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +192 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/onetime.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|