@magland/mochi 0.3.1 → 0.4.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 (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 +166 -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 +193 -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
@@ -1,7 +1,45 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.prCommands = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
4
40
  const cli_api_1 = require("../cli-api");
41
+ const source_1 = require("../source");
42
+ const gitrun_1 = require("./gitrun");
5
43
  const exit_1 = require("./exit");
6
44
  const input_1 = require("./input");
7
45
  const output_1 = require("./output");
@@ -266,6 +304,123 @@ commit. That is what this reports, and it is worth knowing that is what it means
266
304
  (0, output_1.printTable)(matching.map((r) => [`#${r.number}`, String(r.status), String(r.conclusion ?? ''), String(r.workflowName ?? r.workflow ?? '')]));
267
305
  },
268
306
  },
307
+ {
308
+ path: ['pr', 'export'],
309
+ summary: 'Send a pull request on to the GitHub repository this one was forked from',
310
+ description: `The repository must have a GitHub upstream recorded (mochi fork records one;
311
+ mochi repo edit --upstream sets one). The pull request's head branch is pushed
312
+ to a fork under your GitHub account, made with gh if it does not exist yet,
313
+ and a pull request with the same title and body is opened against the
314
+ upstream. Running it again force-pushes the branch and finds the pull request
315
+ already open rather than opening a second one.
316
+
317
+ GitHub is only ever touched from this machine, through gh's own credentials:
318
+ the vault holds no GitHub token, so run gh auth login once first.`,
319
+ args: [{ name: 'number', required: true }],
320
+ options: [
321
+ { name: 'base', type: 'string', value: '<b>', summary: 'Base branch on GitHub (default: the pull request base)' },
322
+ { name: 'fork', type: 'string', value: '<o/r>', summary: 'Your GitHub fork (default: <your login>/<upstream name>)' },
323
+ ...COMMON,
324
+ ],
325
+ async run(inv) {
326
+ const target = await (0, target_1.targetFrom)(inv);
327
+ const ref = await (0, repo_1.resolveRepo)(inv, target);
328
+ const n = pullRef(inv);
329
+ const pull = await (0, cli_api_1.api)(target, 'GET', `${(0, repo_1.repoPath)(ref)}/pulls/${n}`);
330
+ if (pull.state !== 'open') {
331
+ throw new exit_1.CliError(`Pull request #${n} is ${pull.state}; only an open one can be exported.`, exit_1.EXIT_USAGE);
332
+ }
333
+ const head = String(pull.head ?? '');
334
+ const repoData = await (0, cli_api_1.api)(target, 'GET', (0, repo_1.repoPath)(ref));
335
+ const upstream = (0, source_1.parseUpstream)(String(repoData.upstream ?? ''));
336
+ if (!upstream?.github) {
337
+ throw new exit_1.CliError(`${ref.collection}/${ref.repo} has no GitHub upstream recorded, so there is nowhere to send this.\n` +
338
+ `Fork with mochi fork, or record one:\n` +
339
+ ` mochi repo edit ${ref.collection}/${ref.repo} --upstream https://github.com/owner/repo`, exit_1.EXIT_USAGE);
340
+ }
341
+ const gh = upstream.github;
342
+ const login = (await (0, gitrun_1.capture)('gh', ['api', 'user', '--jq', '.login']))?.trim();
343
+ if (!login) {
344
+ throw new exit_1.CliError('GitHub is reached through gh, which is not installed or not logged in. Run: gh auth login', exit_1.EXIT_FAIL);
345
+ }
346
+ const forkSpec = inv.str('fork') ?? `${login}/${gh.repo}`;
347
+ if (!/^[^/\s]+\/[^/\s]+$/.test(forkSpec)) {
348
+ throw new exit_1.CliError(`--fork should be <owner>/<repo>, not '${forkSpec}'`, exit_1.EXIT_USAGE);
349
+ }
350
+ // The head branch has to live on GitHub for GitHub to accept it, so a
351
+ // fork under your account is the unavoidable middle step. It is a
352
+ // publishing mirror: it holds the branches you have exported and nothing
353
+ // else, and gh creates it the first time.
354
+ if ((await (0, gitrun_1.capture)('gh', ['repo', 'view', forkSpec, '--json', 'name'])) === null) {
355
+ if (inv.str('fork') !== null) {
356
+ throw new exit_1.CliError(`${forkSpec} was not found on GitHub.`, exit_1.EXIT_FAIL);
357
+ }
358
+ console.log(`Forking ${gh.owner}/${gh.repo} on GitHub`);
359
+ if ((await (0, gitrun_1.run)('gh', ['repo', 'fork', `${gh.owner}/${gh.repo}`, '--clone=false'])) !== 0) {
360
+ throw new exit_1.CliError(`Could not fork ${gh.owner}/${gh.repo} on GitHub.`, exit_1.EXIT_FAIL);
361
+ }
362
+ }
363
+ const vaultUrl = `${target.host}/${encodeURIComponent(ref.collection)}/${encodeURIComponent(ref.repo)}`;
364
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mochi-export-'));
365
+ try {
366
+ console.log(`Fetching ${ref.collection}/${ref.repo} @ ${head}`);
367
+ const env = (0, gitrun_1.mochiCredentialEnv)('mochi', target.token);
368
+ if ((await (0, gitrun_1.run)('git', [...(0, gitrun_1.mochiCredentialArgs)(), 'clone', '--bare', '--quiet', '--single-branch', '--branch', head, vaultUrl, tmp], env)) !== 0) {
369
+ throw new exit_1.CliError(`Could not fetch branch ${head} from ${vaultUrl}.`, exit_1.EXIT_FAIL);
370
+ }
371
+ // Forced, deliberately: the branch on the fork exists only as this
372
+ // pull request's published copy, and a re-export after a rebase in
373
+ // the vault must win.
374
+ console.log(`Pushing ${head} to ${forkSpec}`);
375
+ if ((await (0, gitrun_1.run)('git', ['-C', tmp, ...(0, gitrun_1.ghCredentialArgs)(), 'push', '--quiet', '--force', `https://github.com/${forkSpec}.git`, `refs/heads/${head}:refs/heads/${head}`])) !== 0) {
376
+ throw new exit_1.CliError(`Could not push ${head} to ${forkSpec}.`, exit_1.EXIT_FAIL);
377
+ }
378
+ }
379
+ finally {
380
+ try {
381
+ fs.rmSync(tmp, { recursive: true, force: true });
382
+ }
383
+ catch {
384
+ // A leftover temporary directory is not worth failing the export over.
385
+ }
386
+ }
387
+ const ghHead = `${forkSpec.split('/')[0]}:${head}`;
388
+ const findExisting = async () => {
389
+ const out = await (0, gitrun_1.capture)('gh', ['pr', 'list', '--repo', `${gh.owner}/${gh.repo}`, '--head', ghHead, '--state', 'open', '--json', 'url']);
390
+ if (out === null)
391
+ return null;
392
+ try {
393
+ const arr = JSON.parse(out);
394
+ return arr.length ? String(arr[0].url ?? '') || null : null;
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ };
400
+ const existing = await findExisting();
401
+ if (existing) {
402
+ console.log(`Updated the branch behind the pull request already open: ${existing}`);
403
+ return;
404
+ }
405
+ const base = inv.str('base') ?? String(pull.base ?? '');
406
+ const body = `${String(pull.body ?? '').trim()}\n\n---\n\nExported from ${vaultUrl}/pulls/${n}\n`;
407
+ const bodyFile = path.join(os.tmpdir(), `mochi-export-body-${process.pid}.md`);
408
+ fs.writeFileSync(bodyFile, body);
409
+ try {
410
+ if ((await (0, gitrun_1.run)('gh', ['pr', 'create', '--repo', `${gh.owner}/${gh.repo}`, '--base', base, '--head', ghHead, '--title', String(pull.title ?? `Pull request ${n}`), '--body-file', bodyFile])) !== 0) {
411
+ throw new exit_1.CliError(`gh could not open the pull request on ${gh.owner}/${gh.repo}.`, exit_1.EXIT_FAIL);
412
+ }
413
+ }
414
+ finally {
415
+ fs.rmSync(bodyFile, { force: true });
416
+ }
417
+ // Close the loop in the vault: the mochi pull request says where it went.
418
+ const url = await findExisting();
419
+ if (url) {
420
+ await (0, cli_api_1.apiTry)(target, 'POST', `${(0, repo_1.repoPath)(ref)}/pulls/${n}/comments`, { body: `Exported to ${url}` });
421
+ }
422
+ },
423
+ },
269
424
  ...['close', 'reopen'].map((verb) => ({
270
425
  path: ['pr', verb],
271
426
  summary: verb === 'close' ? 'Close a pull request without merging it' : 'Reopen a closed pull request',
@@ -30,6 +30,18 @@ function requireYes(inv, what) {
30
30
  if (!inv.bool('yes'))
31
31
  throw new exit_1.CliError(`${what} cannot be undone. Pass --yes to go ahead.`, exit_1.EXIT_USAGE);
32
32
  }
33
+ // One line about the site for `repo view`: off, or where it is served, with
34
+ // the source when it is workflow deploys, since that is the half a reader
35
+ // cannot guess.
36
+ function siteSummary(data) {
37
+ const site = data.site;
38
+ if (!site)
39
+ return data.hasSite ? 'yes' : 'no';
40
+ if (!site.enabled)
41
+ return 'off';
42
+ const source = site.source === 'actions' ? ', deployed by workflows' : site.source === 'repository' ? ', published from the repository' : '';
43
+ return `${site.url ?? 'on'}${source}`;
44
+ }
33
45
  function refQuery(ref, extra = {}) {
34
46
  const q = new URLSearchParams(extra);
35
47
  if (ref !== null)
@@ -42,10 +54,15 @@ exports.repoCommands = [
42
54
  path: ['repo', 'list'],
43
55
  summary: 'List the repositories in the vault',
44
56
  args: [{ name: 'collection' }],
45
- options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
57
+ options: [
58
+ { name: 'topic', type: 'string', value: '<t>', summary: 'Only repositories carrying this topic' },
59
+ output_1.JSON_OPTION,
60
+ ...target_1.TARGET_OPTIONS,
61
+ ],
46
62
  async run(inv) {
47
63
  const target = await (0, target_1.targetFrom)(inv);
48
- const data = await (0, cli_api_1.api)(target, 'GET', '/api/repos');
64
+ const topic = inv.str('topic');
65
+ const data = await (0, cli_api_1.api)(target, 'GET', `/api/repos${topic !== null ? `?topic=${encodeURIComponent(topic)}` : ''}`);
49
66
  const only = inv.args[0] ?? null;
50
67
  const repos = (data.repos ?? []).filter((r) => only === null || r.collection === only);
51
68
  const json = (0, output_1.jsonMode)(inv);
@@ -84,8 +101,12 @@ exports.repoCommands = [
84
101
  const from = data.forkedFrom;
85
102
  console.log(`forked from ${from.collection}/${from.repo}`);
86
103
  }
104
+ if (data.upstream)
105
+ console.log(`forked from ${data.upstream}`);
106
+ const topics = (data.topics ?? []);
87
107
  (0, output_1.printTable)([
88
108
  ['visibility', data.private ? 'private' : 'public'],
109
+ ...(topics.length ? [['topics', topics.join(', ')]] : []),
89
110
  ['default branch', String(data.defaultBranch ?? '(none)')],
90
111
  ['last updated', (0, output_1.shortDate)(data.updated)],
91
112
  ['branches', String(data.branches)],
@@ -93,7 +114,7 @@ exports.repoCommands = [
93
114
  ['releases', String(data.releases)],
94
115
  ['open issues', String(data.openIssues)],
95
116
  ['open pull requests', String(data.openPulls)],
96
- ['site', data.hasSite ? 'yes' : 'no'],
117
+ ['site', siteSummary(data)],
97
118
  ]);
98
119
  },
99
120
  },
@@ -155,32 +176,98 @@ exports.repoCommands = [
155
176
  },
156
177
  {
157
178
  path: ['repo', 'edit'],
158
- summary: 'Change a repository description, default branch, or visibility',
179
+ summary: 'Change a repository description, topics, default branch, visibility, or site settings',
180
+ description: `--topic replaces the whole set, which is what the API does; --add-topic and
181
+ --remove-topic keep what is there and change it, which means reading the
182
+ repository first. A topic is lowercase letters, digits, and hyphens.
183
+
184
+ The site settings take the admin role. --enable-site serves the repository's
185
+ <repo>.site directory; --site-source actions additionally lets a workflow's
186
+ deploy-pages step publish it, and --site-source repository has the server
187
+ publish the default branch itself on every push (--site-path picks a
188
+ directory within it, such as docs; '' is the root). --site-label picks the label under the vault's
189
+ sites host ('' goes back to the derived <repo>--<alias>, where the alias stands
190
+ in for the collection's name), and --site-domain attaches a custom domain,
191
+ which takes a site admin ('' detaches it). A label another repository holds, or
192
+ one the vault reserves for its operator, is refused.`,
159
193
  args: [{ name: 'repo' }],
160
194
  options: [
161
195
  { name: 'description', type: 'string', value: '<d>', summary: 'New description' },
196
+ { name: 'topic', type: 'string[]', value: '<t>', summary: 'Replace the topics with these' },
197
+ { name: 'add-topic', type: 'string[]', value: '<t>', summary: 'Keep the existing topics and add these' },
198
+ { name: 'remove-topic', type: 'string[]', value: '<t>', summary: 'Keep the existing topics apart from these' },
199
+ { name: 'clear-topics', type: 'boolean', summary: 'Remove every topic' },
162
200
  { name: 'default-branch', type: 'string', value: '<b>', summary: 'New default branch' },
201
+ { name: 'upstream', type: 'string', value: '<url>', summary: "URL this was forked from; '' clears it" },
163
202
  { name: 'private', type: 'boolean', summary: 'Make the repository private (takes the admin role)' },
164
203
  { name: 'public', type: 'boolean', summary: 'Make the repository public' },
204
+ { name: 'enable-site', type: 'boolean', summary: 'Serve the repository site (takes the admin role)' },
205
+ { name: 'disable-site', type: 'boolean', summary: 'Stop serving the site; its files stay on disk' },
206
+ { name: 'site-source', type: 'string', value: '<s>', summary: "How the site is published: 'copy', 'actions', or 'repository'" },
207
+ { name: 'site-path', type: 'string', value: '<dir>', summary: "Directory published by a 'repository' site; '' for the root" },
208
+ { name: 'site-label', type: 'string', value: '<l>', summary: "Label under the sites host; '' for the default" },
209
+ { name: 'site-domain', type: 'string', value: '<h>', summary: "Custom domain (site admin); '' detaches it" },
165
210
  output_1.JSON_OPTION,
166
211
  ...COMMON,
167
212
  ],
168
213
  async run(inv) {
169
214
  const description = inv.str('description');
170
215
  const defaultBranch = inv.str('default-branch');
216
+ const upstream = inv.str('upstream');
171
217
  if (inv.bool('private') && inv.bool('public')) {
172
218
  throw new exit_1.CliError('Pass --private or --public, not both.', exit_1.EXIT_USAGE);
173
219
  }
174
220
  const priv = inv.bool('private') ? true : inv.bool('public') ? false : undefined;
175
- if (description === null && defaultBranch === null && priv === undefined) {
176
- throw new exit_1.CliError('Nothing to change. Pass --description, --default-branch, --private, or --public.', exit_1.EXIT_USAGE);
221
+ if (inv.bool('enable-site') && inv.bool('disable-site')) {
222
+ throw new exit_1.CliError('Pass --enable-site or --disable-site, not both.', exit_1.EXIT_USAGE);
223
+ }
224
+ const siteEnabled = inv.bool('enable-site') ? true : inv.bool('disable-site') ? false : undefined;
225
+ const siteSource = inv.str('site-source');
226
+ if (siteSource !== null && siteSource !== 'copy' && siteSource !== 'actions' && siteSource !== 'repository') {
227
+ throw new exit_1.CliError("--site-source takes 'copy', 'actions', or 'repository'.", exit_1.EXIT_USAGE);
228
+ }
229
+ const sitePath = inv.str('site-path');
230
+ const siteLabel = inv.str('site-label');
231
+ const siteDomain = inv.str('site-domain');
232
+ const set = inv.list('topic');
233
+ const add = inv.list('add-topic');
234
+ const remove = inv.list('remove-topic');
235
+ const clear = inv.bool('clear-topics');
236
+ // --topic and --clear-topics each name the whole set, so they combine
237
+ // with nothing; --add-topic and --remove-topic edit it and may combine.
238
+ if ((set.length !== 0 ? 1 : 0) + (clear ? 1 : 0) + (add.length !== 0 || remove.length !== 0 ? 1 : 0) > 1) {
239
+ throw new exit_1.CliError('Pass --topic or --clear-topics on their own, or --add-topic/--remove-topic together.', exit_1.EXIT_USAGE);
177
240
  }
178
241
  const target = await (0, target_1.targetFrom)(inv);
179
242
  const repo = await (0, repo_1.resolveRepo)(inv, target, inv.args[0] ?? null);
243
+ let topics = clear ? [] : set.length ? set : undefined;
244
+ if (add.length || remove.length) {
245
+ const current = (await (0, cli_api_1.api)(target, 'GET', (0, repo_1.repoPath)(repo))).topics;
246
+ topics = [...new Set([...(current ?? []), ...add])].filter((t) => !remove.includes(t));
247
+ }
248
+ if (description === null &&
249
+ defaultBranch === null &&
250
+ upstream === null &&
251
+ topics === undefined &&
252
+ priv === undefined &&
253
+ siteEnabled === undefined &&
254
+ siteSource === null &&
255
+ sitePath === null &&
256
+ siteLabel === null &&
257
+ siteDomain === null) {
258
+ throw new exit_1.CliError('Nothing to change. Pass --description, --topic, --add-topic, --remove-topic, --clear-topics, --default-branch, --upstream, --private, --public, --enable-site, --disable-site, --site-source, --site-path, --site-label, or --site-domain.', exit_1.EXIT_USAGE);
259
+ }
180
260
  const data = await (0, cli_api_1.api)(target, 'PATCH', (0, repo_1.repoPath)(repo), {
181
261
  description: description ?? undefined,
262
+ topics,
182
263
  defaultBranch: defaultBranch ?? undefined,
264
+ upstream: upstream ?? undefined,
183
265
  private: priv,
266
+ siteEnabled,
267
+ siteSource: siteSource ?? undefined,
268
+ sitePath: sitePath ?? undefined,
269
+ siteLabel: siteLabel ?? undefined,
270
+ siteDomain: siteDomain ?? undefined,
184
271
  });
185
272
  const json = (0, output_1.jsonMode)(inv);
186
273
  if (json.enabled) {
@@ -188,6 +275,11 @@ exports.repoCommands = [
188
275
  return;
189
276
  }
190
277
  console.log(`Updated ${data.collection}/${data.name}`);
278
+ const published = data.sitePublish;
279
+ if (published?.error)
280
+ console.error(`The site could not be published from the repository: ${published.error}`);
281
+ else if (published?.files !== undefined)
282
+ console.log(`Site published from the repository: ${published.files} file(s)`);
191
283
  },
192
284
  },
193
285
  {
@@ -246,6 +338,42 @@ together.`,
246
338
  console.log(`Deleted ${full}`);
247
339
  },
248
340
  },
341
+ {
342
+ path: ['repo', 'gc'],
343
+ summary: 'Drop every object no branch or tag can reach',
344
+ description: `Git adds objects and never removes them, so a force push, a deleted branch, or a
345
+ rewritten history leaves what it abandoned in the repository: unreachable from
346
+ any ref, still readable by anyone who knows the hash, and still counted against
347
+ the disk. This drops all of it now.
348
+
349
+ The vault collects each repository on its own every few hours, sparing anything
350
+ unreachable but newer than two days so that a push in flight is never pruned.
351
+ This command is for when that is not soon enough: a file rewritten out of a
352
+ history is unreachable at once, but until it is collected it can still be
353
+ fetched by naming its commit. Objects younger than five minutes are still
354
+ spared, which is the same in-flight push the sweep protects.
355
+
356
+ Takes the admin role on the repository. What it removes cannot be recovered.`,
357
+ args: [{ name: 'repo' }],
358
+ options: [YES_OPTION, output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
359
+ async run(inv) {
360
+ requireYes(inv, 'Dropping unreachable objects');
361
+ const target = await (0, target_1.targetFrom)(inv);
362
+ const repo = await (0, repo_1.resolveRepo)(inv, target, inv.args[0]);
363
+ const full = `${repo.collection}/${repo.repo}`;
364
+ const data = await (0, cli_api_1.api)(target, 'POST', `${(0, repo_1.repoPath)(repo)}/gc?confirm=${encodeURIComponent(full)}`);
365
+ const json = (0, output_1.jsonMode)(inv);
366
+ if (json.enabled) {
367
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
368
+ return;
369
+ }
370
+ const removed = Number(data.removed ?? 0);
371
+ const freed = Number(data.kilobytesFreed ?? 0);
372
+ console.log(removed === 0
373
+ ? `Collected ${full}; nothing was unreachable.`
374
+ : `Collected ${full}: ${removed} object${removed === 1 ? '' : 's'} dropped, ${freed >= 1024 ? `${(freed / 1024).toFixed(1)} MB` : `${freed} KB`} freed.`);
375
+ },
376
+ },
249
377
  {
250
378
  path: ['repo', 'clone'],
251
379
  summary: 'git clone, with the vault URL filled in',
@@ -0,0 +1,128 @@
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.syncCommand = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
40
+ const cli_api_1 = require("../cli-api");
41
+ const source_1 = require("../source");
42
+ const exit_1 = require("./exit");
43
+ const gitrun_1 = require("./gitrun");
44
+ const repo_1 = require("./repo");
45
+ const target_1 = require("./target");
46
+ // `mochi sync`: bring a fork's branch up to date with the upstream it was
47
+ // forked from. Without this a fork silently rots, and a pull request exported
48
+ // from a stale base arrives already conflicted.
49
+ //
50
+ // Like import and export, it runs on the operator's machine: a bare clone of
51
+ // the vault's branch, a fetch from the upstream URL on top of it (so only the
52
+ // new objects cross the network), and a push of the result back to the vault.
53
+ // The push is not forced, and the vault refuses non-fast-forwards anyway, so a
54
+ // branch that has grown its own commits is reported as diverged rather than
55
+ // overwritten.
56
+ exports.syncCommand = {
57
+ path: ['sync'],
58
+ summary: 'Fast-forward a branch from the upstream the repository was forked from',
59
+ description: `The upstream is the URL \`mochi fork\` recorded (or \`mochi repo edit --upstream\`
60
+ set). The branch defaults to the repository's default branch. The fetch uses
61
+ whatever git credentials this machine already has, the push uses your mochi
62
+ token, and only a fast-forward is ever pushed: a branch that has diverged from
63
+ its upstream is reported and left alone.`,
64
+ args: [{ name: 'repo' }],
65
+ options: [
66
+ { name: 'branch', type: 'string', value: '<b>', summary: 'Branch to sync (default: the default branch)' },
67
+ repo_1.REPO_OPTION,
68
+ ...target_1.TARGET_OPTIONS,
69
+ ],
70
+ async run(inv) {
71
+ const target = await (0, target_1.targetFrom)(inv);
72
+ const ref = await (0, repo_1.resolveRepo)(inv, target, inv.args[0] ?? null);
73
+ const data = await (0, cli_api_1.api)(target, 'GET', (0, repo_1.repoPath)(ref));
74
+ const upstream = (0, source_1.parseUpstream)(String(data.upstream ?? ''));
75
+ if (!upstream) {
76
+ throw new exit_1.CliError(`${ref.collection}/${ref.repo} has no upstream recorded. Fork with mochi fork, or record one:\n` +
77
+ ` mochi repo edit ${ref.collection}/${ref.repo} --upstream https://github.com/owner/repo`, exit_1.EXIT_USAGE);
78
+ }
79
+ const branch = inv.str('branch') ?? String(data.defaultBranch ?? '');
80
+ if (!branch)
81
+ throw new exit_1.CliError('No branch to sync: the repository is empty. Pass --branch.', exit_1.EXIT_USAGE);
82
+ const vaultUrl = `${target.host}/${encodeURIComponent(ref.collection)}/${encodeURIComponent(ref.repo)}`;
83
+ const env = (0, gitrun_1.mochiCredentialEnv)('mochi', target.token);
84
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mochi-sync-'));
85
+ try {
86
+ // The vault side first, so the upstream fetch only carries what is new.
87
+ console.log(`Fetching ${ref.collection}/${ref.repo} @ ${branch}`);
88
+ if ((await (0, gitrun_1.run)('git', [...(0, gitrun_1.mochiCredentialArgs)(), 'clone', '--bare', '--quiet', '--single-branch', '--branch', branch, vaultUrl, tmp], env)) !== 0) {
89
+ throw new exit_1.CliError(`Could not fetch branch ${branch} from ${vaultUrl}.`, exit_1.EXIT_FAIL);
90
+ }
91
+ console.log(`Fetching ${upstream.label} @ ${branch}`);
92
+ if ((await (0, gitrun_1.run)('git', ['-C', tmp, 'fetch', '--quiet', upstream.url, branch])) !== 0) {
93
+ throw new exit_1.CliError(`Could not fetch branch ${branch} from ${upstream.url}.`, exit_1.EXIT_FAIL);
94
+ }
95
+ const count = async (range) => {
96
+ const out = await (0, gitrun_1.capture)('git', ['-C', tmp, 'rev-list', '--count', range]);
97
+ if (out === null)
98
+ throw new exit_1.CliError('git rev-list failed in the temporary clone.', exit_1.EXIT_FAIL);
99
+ return parseInt(out.trim(), 10);
100
+ };
101
+ const behind = await count(`refs/heads/${branch}..FETCH_HEAD`);
102
+ const ahead = await count(`FETCH_HEAD..refs/heads/${branch}`);
103
+ if (behind === 0) {
104
+ console.log(ahead === 0
105
+ ? `${branch} is up to date with ${upstream.label}.`
106
+ : `${branch} is up to date with ${upstream.label}, and ${ahead} commit${ahead === 1 ? '' : 's'} ahead of it.`);
107
+ return;
108
+ }
109
+ if (ahead > 0) {
110
+ throw new exit_1.CliError(`${branch} has diverged from ${upstream.label}: ${ahead} commit${ahead === 1 ? '' : 's'} of its own, ` +
111
+ `${behind} upstream. Nothing was pushed; merge or rebase by hand in a clone.`, exit_1.EXIT_CONFLICT);
112
+ }
113
+ console.log(`Pushing ${behind} commit${behind === 1 ? '' : 's'} to ${vaultUrl}`);
114
+ if ((await (0, gitrun_1.run)('git', ['-C', tmp, ...(0, gitrun_1.mochiCredentialArgs)(), 'push', '--quiet', vaultUrl, `FETCH_HEAD:refs/heads/${branch}`], env)) !== 0) {
115
+ throw new exit_1.CliError(`Could not push ${branch} to ${vaultUrl}.`, exit_1.EXIT_FAIL);
116
+ }
117
+ console.log(`Fast-forwarded ${branch} by ${behind} commit${behind === 1 ? '' : 's'} from ${upstream.label}.`);
118
+ }
119
+ finally {
120
+ try {
121
+ fs.rmSync(tmp, { recursive: true, force: true });
122
+ }
123
+ catch {
124
+ // A leftover temporary directory is not worth failing the sync over.
125
+ }
126
+ }
127
+ },
128
+ };
@@ -3,23 +3,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TARGET_OPTIONS = void 0;
4
4
  exports.targetFrom = targetFrom;
5
5
  const cli_api_1 = require("../cli-api");
6
+ const naming_1 = require("../naming");
6
7
  const exit_1 = require("./exit");
7
8
  const input_1 = require("./input");
8
9
  // Which vault a command talks to, and with what token. Every command that
9
10
  // reaches a vault takes the same three options, declared once here so that they
10
11
  // are spelled and documented identically everywhere.
12
+ // The summaries read naming at module load, which is after a sibling
13
+ // application's branding module has run (it is that application's first
14
+ // import), and is simply the mochi defaults everywhere else.
11
15
  exports.TARGET_OPTIONS = [
12
16
  {
13
17
  name: 'host',
14
18
  type: 'string',
15
19
  value: '<url>',
16
- summary: 'Vault URL, ahead of MOCHI_HOST and the last login',
20
+ summary: `${naming_1.naming.rootNoun[0].toUpperCase()}${naming_1.naming.rootNoun.slice(1)} URL, ahead of ${naming_1.naming.envPrefix}_HOST and the last login`,
17
21
  },
18
22
  {
19
23
  name: 'token',
20
24
  type: 'string',
21
25
  value: '<t>',
22
- summary: "Token, ahead of MOCHI_TOKEN and git's credential store",
26
+ summary: `Token, ahead of ${naming_1.naming.envPrefix}_TOKEN and git's credential store`,
23
27
  },
24
28
  {
25
29
  name: 'token-stdin',
@@ -49,6 +53,6 @@ async function targetFrom(inv, opts = {}) {
49
53
  if (!token)
50
54
  throw new exit_1.CliError('--token-stdin was given but stdin was empty.', exit_1.EXIT_AUTH);
51
55
  }
52
- const host = inv.str('host') ?? process.env.MOCHI_HOST?.trim() ?? opts.host ?? null;
56
+ const host = inv.str('host') ?? process.env[`${naming_1.naming.envPrefix}_HOST`]?.trim() ?? opts.host ?? null;
53
57
  return await (0, cli_api_1.remoteTarget)({ host, token });
54
58
  }
package/dist/config.js CHANGED
@@ -67,6 +67,7 @@ function configFilePath(root) {
67
67
  }
68
68
  const DEFAULTS = {
69
69
  theme: themes_1.DEFAULT_THEME,
70
+ auth: { githubClientId: '' },
70
71
  ci: { runs: 100, days: 0, artifactMb: 500 },
71
72
  sites: { host: '' },
72
73
  network: { trustProxy: false },
@@ -78,6 +79,7 @@ const DEFAULTS = {
78
79
  function defaults() {
79
80
  return {
80
81
  ...DEFAULTS,
82
+ auth: { ...DEFAULTS.auth },
81
83
  ci: { ...DEFAULTS.ci },
82
84
  sites: { ...DEFAULTS.sites },
83
85
  network: { ...DEFAULTS.network },
@@ -96,6 +98,14 @@ function readConfig(file) {
96
98
  // request: a typo in config.json should not take the vault down.
97
99
  if (typeof parsed.theme === 'string' && (0, themes_1.findTheme)(parsed.theme))
98
100
  config.theme = parsed.theme;
101
+ // The client id is public and its exact shape is GitHub's business; the
102
+ // check refuses only whitespace and control characters, which could not
103
+ // be one and would otherwise ride into a redirect URL.
104
+ if (typeof parsed.auth === 'object' && parsed.auth !== null) {
105
+ const auth = parsed.auth;
106
+ const id = typeof auth.githubClientId === 'string' ? auth.githubClientId.trim() : '';
107
+ config.auth = { githubClientId: /^[\x21-\x7e]{1,100}$/.test(id) ? id : DEFAULTS.auth.githubClientId };
108
+ }
99
109
  // Run retention: a vault's run history is the one part of its state that
100
110
  // grows without bound, so it is bounded by default and tunable here.
101
111
  if (typeof parsed.ci === 'object' && parsed.ci !== null) {
@@ -49,6 +49,7 @@ const child_process_1 = require("child_process");
49
49
  const fs = __importStar(require("fs"));
50
50
  const os = __importStar(require("os"));
51
51
  const path = __importStar(require("path"));
52
+ const naming_1 = require("./naming");
52
53
  // The client side of authentication. A token is the password git sends over
53
54
  // Basic auth, so the place to keep it is git's own credential store: clone,
54
55
  // fetch, push, and git-lfs all read credentials through the same `git
@@ -171,7 +172,7 @@ async function rejectCredential(target, username) {
171
172
  // makes `mochi user list` work with no arguments and no environment.
172
173
  function loginPath() {
173
174
  const base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
174
- return path.join(base, 'mochi', 'login.json');
175
+ return path.join(base, naming_1.naming.configDirName, 'login.json');
175
176
  }
176
177
  function loadLogin(file = loginPath()) {
177
178
  try {
@@ -212,13 +213,13 @@ function clearLogin(host, file = loginPath()) {
212
213
  // configures, and the runner already took this shape with
213
214
  // MOCHI_RUNNER_TOKEN.
214
215
  async function vaultTarget(args) {
215
- const envHost = process.env.MOCHI_HOST?.trim() || null;
216
- const envToken = process.env.MOCHI_TOKEN?.trim() || null;
216
+ const envHost = process.env[`${naming_1.naming.envPrefix}_HOST`]?.trim() || null;
217
+ const envToken = process.env[`${naming_1.naming.envPrefix}_TOKEN`]?.trim() || null;
217
218
  const host = (args.host ?? envHost ?? loadLogin()?.host ?? '').replace(/\/+$/, '');
218
219
  if (!host) {
219
- throw new CredentialError('No vault. Log in to one first:\n\n' +
220
- ' mochi login https://vault.example.com\n\n' +
221
- 'or pass --host <url>, or set MOCHI_HOST.');
220
+ throw new CredentialError(`No ${naming_1.naming.rootNoun}. Log in to one first:\n\n` +
221
+ ` ${naming_1.naming.product} login https://${naming_1.naming.rootNoun}.example.com\n\n` +
222
+ `or pass --host <url>, or set ${naming_1.naming.envPrefix}_HOST.`);
222
223
  }
223
224
  if (args.token)
224
225
  return { host, token: args.token };
@@ -228,8 +229,8 @@ async function vaultTarget(args) {
228
229
  const stored = await readCredential(target);
229
230
  if (!stored) {
230
231
  throw new CredentialError(`No stored token for ${target.url}. Log in again:\n\n` +
231
- ` mochi login ${target.url}\n\n` +
232
- 'or pass --token <token>, or set MOCHI_TOKEN.');
232
+ ` ${naming_1.naming.product} login ${target.url}\n\n` +
233
+ `or pass --token <token>, or set ${naming_1.naming.envPrefix}_TOKEN.`);
233
234
  }
234
235
  return { host, token: stored.password };
235
236
  }