@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
@@ -36,11 +36,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.importCmd = importCmd;
37
37
  exports.collectionAddCmd = collectionAddCmd;
38
38
  exports.collectionListCmd = collectionListCmd;
39
- const child_process_1 = require("child_process");
39
+ exports.forkCmd = forkCmd;
40
40
  const fs = __importStar(require("fs"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const cli_api_1 = require("./cli-api");
44
+ const gitrun_1 = require("./cli/gitrun");
44
45
  const scan_1 = require("./scan");
45
46
  const source_1 = require("./source");
46
47
  function parseImportArgs(args, usage) {
@@ -100,24 +101,11 @@ function parseImportArgs(args, usage) {
100
101
  }
101
102
  return out;
102
103
  }
103
- /** Run git with its output going straight to the terminal, so a long clone shows progress. */
104
104
  function git(args, env) {
105
- return new Promise((resolve, reject) => {
106
- const child = (0, child_process_1.spawn)('git', args, { stdio: 'inherit', env: env ?? process.env });
107
- child.on('error', (e) => reject(new Error(e.code === 'ENOENT' ? 'git is not on PATH' : String(e.message))));
108
- child.on('close', (code) => resolve(code ?? 1));
109
- });
105
+ return (0, gitrun_1.run)('git', args, env);
110
106
  }
111
- // The token is handed to the push through a helper that reads it from the
112
- // environment rather than through the command line, where every process on the
113
- // machine could read it. The empty helper first clears the list, so the answer
114
- // comes from here and nowhere else, and no prompt can appear.
115
- function pushEnv(username, token) {
116
- return { ...process.env, MOCHI_USER: username, MOCHI_TOKEN: token, GIT_TERMINAL_PROMPT: '0' };
117
- }
118
- const CREDENTIAL_HELPER = `!f() { test "$1" = get && printf 'username=%s\\npassword=%s\\n' "$MOCHI_USER" "$MOCHI_TOKEN"; }; f`;
119
107
  function pushArgs(dir) {
120
- return ['-C', dir, '-c', 'credential.helper=', '-c', `credential.helper=${CREDENTIAL_HELPER}`];
108
+ return ['-C', dir, ...(0, gitrun_1.mochiCredentialArgs)()];
121
109
  }
122
110
  /**
123
111
  * A repository's one-line description is not part of its git data, so a clone
@@ -144,14 +132,20 @@ async function githubDescription(gh) {
144
132
  return null;
145
133
  }
146
134
  }
147
- async function importCmd(args, usage) {
135
+ async function importCmd(args, usage, mode = {}) {
148
136
  const a = parseImportArgs(args, usage);
137
+ const cmd = mode.fork ? 'fork' : 'import';
149
138
  if (!a.src) {
150
- console.error('Usage: mochi import <source> <collection>[/<name>]');
151
- console.error(" e.g. mochi import https://github.com/octocat/Hello-World mycollection");
139
+ console.error(`Usage: mochi ${cmd} <source> <collection>[/<name>]`);
140
+ console.error(` e.g. mochi ${cmd} https://github.com/octocat/Hello-World mycollection`);
152
141
  process.exit(1);
153
142
  }
154
143
  const source = (0, source_1.parseSource)(a.src);
144
+ if (mode.fork && source && path.isAbsolute(source.url)) {
145
+ console.error('A fork records the URL it came from, and a local directory has no URL to record.');
146
+ console.error(`Use mochi import instead: mochi import ${a.src} <collection>`);
147
+ process.exit(1);
148
+ }
155
149
  if (!source) {
156
150
  console.error(`Not a source we can clone: ${a.src}`);
157
151
  console.error('Give an https or ssh git URL, owner/repo for GitHub, or the path to a repository here.');
@@ -161,7 +155,7 @@ async function importCmd(args, usage) {
161
155
  const name = a.name ?? source.name;
162
156
  if (!collection) {
163
157
  console.error(`Which collection should ${name} go into?`);
164
- console.error(` mochi import ${a.src} mycollection`);
158
+ console.error(` mochi ${cmd} ${a.src} mycollection`);
165
159
  process.exit(1);
166
160
  }
167
161
  if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name)) {
@@ -186,7 +180,7 @@ async function importCmd(args, usage) {
186
180
  console.error(`${collection}/${name} already exists on ${target.host}.`);
187
181
  console.error('A mirror push would replace its branches and tags, so this stops here. Import');
188
182
  console.error('under another name,');
189
- console.error(` mochi import ${a.src} ${collection}/another-name`);
183
+ console.error(` mochi ${cmd} ${a.src} ${collection}/another-name`);
190
184
  console.error('or delete the existing repository first, from its settings page.');
191
185
  process.exit(1);
192
186
  }
@@ -217,7 +211,7 @@ async function importCmd(args, usage) {
217
211
  }
218
212
  }
219
213
  console.log(`Pushing to ${dest} as ${username}`);
220
- const env = pushEnv(username, target.token);
214
+ const env = (0, gitrun_1.mochiCredentialEnv)(username, target.token);
221
215
  if ((await git([...pushArgs(tmp), 'push', '--mirror', dest], env)) !== 0) {
222
216
  throw new Error(`Could not push to ${dest}.\nIf it refused the token, check that you may create in ${collection}.`);
223
217
  }
@@ -233,8 +227,23 @@ async function importCmd(args, usage) {
233
227
  // here would skip this and leave the bare clone behind.
234
228
  cleanup();
235
229
  }
236
- // The description is set after the push rather than before it, since the
237
- // repository does not exist until the push creates it.
230
+ // A fork remembers where it came from: the source URL lands in the
231
+ // repository's config as mochi.upstream, where the repo header, `mochi
232
+ // sync`, and `mochi pr export` read it back. Recorded after the push, since
233
+ // the repository does not exist until the push creates it.
234
+ let upstream = null;
235
+ if (mode.fork) {
236
+ const patch = await (0, cli_api_1.apiTry)(target, 'PATCH', `/api/repos/${encodeURIComponent(collection)}/${encodeURIComponent(name)}`, {
237
+ upstream: source.url,
238
+ });
239
+ if (patch.ok)
240
+ upstream = source.url;
241
+ else {
242
+ console.error(`The repository arrived, but its upstream could not be recorded: ${patch.data.error ?? `HTTP ${patch.status}`}`);
243
+ console.error(` mochi repo edit ${collection}/${name} --upstream ${source.url}`);
244
+ }
245
+ }
246
+ // The description too is set after the push rather than before it.
238
247
  let described = a.description?.trim() || null;
239
248
  if (!described && !a.noDescription && source.github)
240
249
  described = await githubDescription(source.github);
@@ -248,8 +257,10 @@ async function importCmd(args, usage) {
248
257
  }
249
258
  }
250
259
  console.log('');
251
- console.log(`Imported ${collection}/${name}`);
260
+ console.log(`${mode.fork ? 'Forked' : 'Imported'} ${collection}/${name}`);
252
261
  console.log(` ${dest}`);
262
+ if (upstream)
263
+ console.log(` forked from ${upstream}`);
253
264
  // Worth saying rather than leaving to be discovered: a mirror push carries
254
265
  // LFS pointer files, so the files look present and read as missing.
255
266
  if (described)
@@ -314,3 +325,7 @@ async function collectionListCmd(args, usage) {
314
325
  console.log(`${c.name.padEnd(width)} ${c.repoCount} ${c.repoCount === 1 ? 'repository' : 'repositories'}`);
315
326
  }
316
327
  }
328
+ /** `mochi fork`: `mochi import` that records where the repository came from. */
329
+ function forkCmd(args, usage) {
330
+ return importCmd(args, usage, { fork: true });
331
+ }
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ const child_process_1 = require("child_process");
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
39
40
  const credentials_1 = require("./credentials");
@@ -52,6 +53,7 @@ const output_1 = require("./cli/output");
52
53
  const parse_1 = require("./cli/parse");
53
54
  const target_1 = require("./cli/target");
54
55
  const import_cli_1 = require("./import-cli");
56
+ const sync_cmd_1 = require("./cli/sync-cmd");
55
57
  const deploy_cli_1 = require("./deploy-cli");
56
58
  const deploy_runner_cli_1 = require("./deploy-runner-cli");
57
59
  const job_cli_1 = require("./job-cli");
@@ -72,7 +74,7 @@ no keyring and possibly no writable home directory.
72
74
 
73
75
  Vault layout, where <repos> is <vault>/collections/<collection>/repos:
74
76
  <repos>/<repo>.git bare repositories (the .git suffix is optional)
75
- <repos>/<repo>.site optional static site for a repo
77
+ <repos>/<repo>.site static site for a repo, served once enabled in its settings
76
78
  <repos>/<repo>.lfs Git LFS objects, when no bucket is configured
77
79
  <repos>/<repo>.runs workflow run history and logs
78
80
  <vault>/vault.json users and hashed tokens (server-managed)
@@ -129,6 +131,22 @@ async function serveCmd(args, usage) {
129
131
  // in a loop should not pay for the server it is not starting.
130
132
  const { createApp } = await Promise.resolve().then(() => __importStar(require('./server')));
131
133
  const app = createApp(vault);
134
+ // What one request cannot be allowed to do is take the vault down for
135
+ // everyone else. Node's default for an uncaught exception or an unhandled
136
+ // rejection is to exit, on the reasoning that the process may be left in
137
+ // a state nothing can trust. That reasoning is weaker here than usual: the
138
+ // process holds no state that matters -- the vault is on disk, written by
139
+ // rename, and re-read on every request -- so what an escaped error has
140
+ // corrupted is at most one response, which is already lost. So the error is
141
+ // logged with its stack and the process goes on. Anything that recurs will
142
+ // recur in the log, which is where an operator can find it; a crash would
143
+ // have said the same thing once and then stopped serving.
144
+ process.on('uncaughtException', (err) => {
145
+ console.error('uncaught exception (the server continues):', err);
146
+ });
147
+ process.on('unhandledRejection', (reason) => {
148
+ console.error('unhandled rejection (the server continues):', reason);
149
+ });
132
150
  app.listen(port, host, () => {
133
151
  const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
134
152
  if (boot && boot.preset) {
@@ -408,6 +426,37 @@ async function loginCmd(inv) {
408
426
  console.log(`and mochi commands talk to it by default (${(0, credentials_1.loginPath)()}).`);
409
427
  console.log('Run `mochi logout` to remove it again.');
410
428
  }
429
+ // ---- web ----
430
+ // Best effort and platform-shaped. The URL is printed first, so a machine
431
+ // with no opener, or an SSH session, still leaves the person one paste away.
432
+ function openBrowser(url) {
433
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
434
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
435
+ try {
436
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true });
437
+ child.on('error', () => { });
438
+ child.unref();
439
+ }
440
+ catch {
441
+ // The link is already on the terminal.
442
+ }
443
+ }
444
+ async function webCmd(inv) {
445
+ const target = await (0, target_1.targetFrom)(inv);
446
+ const next = inv.args[0] ?? '/';
447
+ const data = await (0, cli_api_1.api)(target, 'POST', '/api/login-url', { next });
448
+ const json = (0, output_1.jsonMode)(inv);
449
+ if (json.enabled) {
450
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
451
+ return;
452
+ }
453
+ console.log(`A one-time sign-in link for '${data.username}' at ${target.host}:`);
454
+ console.log('');
455
+ console.log(` ${data.url}`);
456
+ console.log('');
457
+ console.log(`It works once, expires in ${data.expiresInSeconds} seconds, and asks before signing in.`);
458
+ openBrowser(String(data.url));
459
+ }
411
460
  async function logoutCmd(inv) {
412
461
  if (inv.str('token') || inv.str('helper')) {
413
462
  throw new exit_1.CliError('logout takes only --host: it removes a stored credential rather than making one.', exit_1.EXIT_USAGE);
@@ -463,6 +512,16 @@ Options:
463
512
  --lfs carry Git LFS objects too
464
513
  --description <text> set this description instead of the source's
465
514
  --no-description leave the description empty`, import_cli_1.importCmd),
515
+ raw(['fork'], 'Import a repository and remember where it came from', `Usage: mochi fork <source> <collection>[/<name>] [--lfs]
516
+
517
+ Everything mochi import does, plus the source URL is recorded as the
518
+ repository's upstream, which the repository header shows, mochi sync
519
+ fast-forwards from, and mochi pr export sends pull requests back to. The
520
+ source must be a URL (or owner/repo for GitHub): a local directory has no
521
+ upstream to record.
522
+
523
+ Options are those of mochi import.`, import_cli_1.forkCmd),
524
+ sync_cmd_1.syncCommand,
466
525
  raw(['collection', 'add'], 'Create an empty collection', `Pushing to a new path creates its collection on the way, so this is for the
467
526
  other order: making the collection first and filling it afterwards.`, import_cli_1.collectionAddCmd),
468
527
  raw(['collection', 'list'], "Show the vault's collections and how many repositories each holds", '', import_cli_1.collectionListCmd),
@@ -580,6 +639,20 @@ nothing when that is nothing.`,
580
639
  ],
581
640
  run: loginCmd,
582
641
  },
642
+ {
643
+ path: ['web'],
644
+ summary: 'Open the vault in a browser, signed in as you',
645
+ description: `Mints a one-time sign-in link from the stored token and opens it in the
646
+ default browser (the link is printed too, for machines without one). The link
647
+ lands on a page that names the account and signs in on a click; it works once
648
+ and expires after two minutes. The browser session is bound to the same token
649
+ this CLI holds, so revoking that token signs the browser out as well.
650
+
651
+ An optional path says where to land: mochi web /alice/myrepo`,
652
+ args: [{ name: 'path' }],
653
+ options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
654
+ run: webCmd,
655
+ },
583
656
  {
584
657
  path: ['logout'],
585
658
  summary: "Remove this vault's stored credential and forget the vault",
@@ -612,9 +685,11 @@ Docker rather than Fly's builder. --image <ref> deploys some other published tag
612
685
  See also: mochi deploy fly show <app>, mochi deploy fly destroy <app>.
613
686
  `, deploy_cli_1.deployFlyCmd),
614
687
  raw(['deploy', 'fly', 'runner'], 'Put a workflow runner on Fly.io, which stops when idle', `Usage: mochi deploy fly runner <app> [--allow <glob>...] [--labels <l,...>]
615
- [--idle <5m>] [--region <r>] [--volume <gb>]
688
+ [--job-timeout <45m>] [--idle <5m>]
689
+ [--region <r>] [--volume <gb>]
616
690
  [--vm-size <s>] [--vm-memory <m>] [--org <o>]
617
691
  [--image <ref> | --from-source [--local-build]]
692
+ [--image-only]
618
693
 
619
694
  Needs flyctl, and a login to the vault this runner will serve. Registers the
620
695
  runner (named after the app unless --name says otherwise), creates the app and a
@@ -630,17 +705,42 @@ it costs while stopped is the volume alone.
630
705
  take jobs for; it executes whatever their workflows contain, on this machine.
631
706
  Run the same command again to deploy a new version.
632
707
 
708
+ --job-timeout is the longest a single job may run on the machine, 20 minutes by
709
+ default, which on hardware billed by the minute is the bound worth setting: it
710
+ caps what a workflow's own timeout-minutes may ask for.
711
+
712
+ --image-only moves an already-deployed runner to a new image and touches
713
+ nothing else: no registration, no token, no wake rewrite, so it needs flyctl
714
+ and no vault login. A runner and the vault it serves speak one protocol, so
715
+ the pipeline that redeploys the vault should redeploy the runner beside it,
716
+ and this flag is what lets a job holding only a Fly credential do that.
717
+
633
718
  See also: mochi deploy fly runner show <app>, destroy <app>, mochi runner list.
634
719
  `, deploy_runner_cli_1.deployFlyRunnerCmd),
635
720
  raw(['deploy', 'fly', 'runner', 'show'], 'What Fly has for this runner app, and which runner it serves', '', deploy_runner_cli_1.deployFlyRunnerShowCmd),
636
721
  raw(['deploy', 'fly', 'runner', 'destroy'], 'Destroy the runner app, and offer to remove its registration', 'No undo, though a runner keeps nothing that matters. Pass --yes to skip the confirmation.', deploy_runner_cli_1.deployFlyRunnerDestroyCmd),
637
722
  raw(['deploy', 'fly', 'show'], 'What Fly has for this app, and whether the vault answers', '', deploy_cli_1.deployShowCmd),
638
723
  raw(['deploy', 'fly', 'destroy'], 'Destroy the app and its volume, and with them the vault', 'No undo. Pass --yes to skip the confirmation.', deploy_cli_1.deployDestroyCmd),
639
- raw(['runner', 'add'], 'Register a machine that will execute workflow jobs', `Usage: mochi runner add <name> --allow <glob>... [--labels <l,...>] [--save]
724
+ raw(['runner', 'add'], 'Register a machine that will execute workflow jobs', `Usage: mochi runner add <name> --allow <glob>... [--labels <l,...>]
725
+ [--job-timeout <45m>] [--save]
640
726
 
641
727
  Prints its token once. --allow says which repositories it may take jobs for, as
642
728
  globs over collection/repo; you must own every collection they name (a site admin may name any). Jobs never run on
643
- the vault's machine, so a vault with no runner queues its runs and waits.`, runner_cli_1.runnerAddCmd),
729
+ the vault's machine, so a vault with no runner queues its runs and waits.
730
+
731
+ --job-timeout is the longest a single job may run on this machine, 20 minutes by
732
+ default and stated in minutes or as a duration like 2h. It is a ceiling on what
733
+ a job's own timeout-minutes may ask for, not a default it can override; change
734
+ it later with mochi runner edit.`, runner_cli_1.runnerAddCmd),
735
+ raw(['runner', 'edit'], "Change a registered runner's job timeout", `Usage: mochi runner edit <name> --job-timeout <45m|default>
736
+
737
+ The longest a single job may run on that machine. A job asking for less with
738
+ timeout-minutes keeps what it asked for; one asking for more, or asking for
739
+ nothing, is held to this. --job-timeout default puts the runner back on the
740
+ vault's own default of 20 minutes.
741
+
742
+ Applies to the next job the runner takes: one already running keeps the timeout
743
+ it was handed with the job.`, runner_cli_1.runnerEditCmd),
644
744
  raw(['runner', 'run'], 'Take jobs and run them, one at a time, each in a Docker container', `Usage: mochi runner run [--host <url>] [--runner-token <t>] [--labels <l,...>]
645
745
 
646
746
  Reads ~/.config/mochi/runner.json when given no arguments. Needs a working
@@ -653,6 +753,12 @@ on the next run; --no-action-cache downloads every time. --work-dir sets where
653
753
  job workspaces are made, --network which Docker network the container joins,
654
754
  and MOCHI_RUNNER_TOKEN supplies the token instead of --runner-token.
655
755
 
756
+ Each job runs in a container of its own, held to --job-pids <4096> processes
757
+ (0 for no limit) and, when given, --job-memory <2g> and --job-cpus <2>, as
758
+ docker run takes them. A job over the process limit cannot fork; one over the
759
+ memory limit is killed. There is no default for memory or CPU, since the right
760
+ figure is the machine's to decide.
761
+
656
762
  --idle <5m> stops the runner when no job has arrived for that long, which is
657
763
  what makes a runner that costs money while it is up affordable: it exits, and
658
764
  whatever hosts it stops. Something then has to start it again, so --wake-port
package/dist/issueweb.js CHANGED
@@ -40,6 +40,7 @@ const icons_1 = require("./icons");
40
40
  const issues = __importStar(require("./issues"));
41
41
  const html_1 = require("./html");
42
42
  const markdown_1 = require("./markdown");
43
+ const mdedit_1 = require("./mdedit");
43
44
  const ops_1 = require("./ops");
44
45
  const render_1 = require("./render");
45
46
  const session_1 = require("./session");
@@ -69,6 +70,7 @@ function body(ctx, text) {
69
70
  blobBase: `${base}/blob/${ref}`,
70
71
  issueBase: `${base}/issues`,
71
72
  commitBase: `${base}/commit`,
73
+ mentions: ctx.hasUser,
72
74
  }));
73
75
  }
74
76
  function stateBadge(state) {
@@ -117,7 +119,7 @@ function listPage(ctx, list, filter, counts, labels, authors) {
117
119
  const rows = list.map((i) => (0, html_1.html) `<tr>
118
120
  <td class="issue-cell">${(0, icons_1.icon)(i.state === 'open' ? 'issue-opened' : 'issue-closed', i.state === 'open' ? 'issue-open' : 'issue-closed')}<span>
119
121
  <a class="issue-link" href="${base}/${i.number}">${i.title}</a>${labelChips(i.labels, base, filter)}
120
- <div class="muted small">#${i.number} opened ${(0, render_1.timeTag)(i.created)} by ${i.author}${i.state === 'closed' && i.closedAt ? (0, html_1.html) ` &middot; closed ${(0, render_1.timeTag)(i.closedAt)}` : ''}</div></span></td>
122
+ <div class="muted small">#${i.number} opened ${(0, render_1.timeTag)(i.created)} by ${(0, views_1.userLink)(i.author)}${i.state === 'closed' && i.closedAt ? (0, html_1.html) ` &middot; closed ${(0, render_1.timeTag)(i.closedAt)}` : ''}</div></span></td>
121
123
  <td class="right muted small">${i.comments > 0
122
124
  ? (0, html_1.html) `<a class="issue-comments" href="${base}/${i.number}" title="${i.comments} comment${i.comments === 1 ? '' : 's'}">${(0, icons_1.icon)('comment')}<span>${i.comments}</span></a>`
123
125
  : ''}</td>
@@ -183,7 +185,7 @@ ${rows.length
183
185
  }
184
186
  function commentCard(author, when, rendered, note = '') {
185
187
  return (0, html_1.html) `<article class="issue-comment">
186
- <div class="issue-comment-head">${(0, avatar_1.avatar)(author, 24)}<b>${author}</b><span class="muted small">${note || 'commented'} ${(0, render_1.timeTag)(when)}</span></div>
188
+ <div class="issue-comment-head">${(0, views_1.userLink)(author, { face: 24, bold: true })}<span class="muted small">${note || 'commented'} ${(0, render_1.timeTag)(when)}</span></div>
187
189
  <div class="issue-comment-body markdown-body">${rendered}</div>
188
190
  </article>`;
189
191
  }
@@ -194,7 +196,7 @@ function issuePage(ctx, issue, canWrite) {
194
196
  const canClose = canWrite || mine;
195
197
  const comments = issue.commentList.map((c) => commentCard(c.author, c.created, body(ctx, c.body)));
196
198
  const closedNote = issue.state === 'closed' && issue.closedAt
197
- ? (0, html_1.html) `<div class="issue-event">${(0, icons_1.icon)('issue-closed', 'issue-closed')}<span><b>${issue.closedBy ?? 'someone'}</b> closed this ${(0, render_1.timeTag)(issue.closedAt)}</span></div>`
199
+ ? (0, html_1.html) `<div class="issue-event">${(0, icons_1.icon)('issue-closed', 'issue-closed')}<span>${issue.closedBy ? (0, views_1.userLink)(issue.closedBy, { bold: true }) : (0, html_1.html) `<b>someone</b>`} closed this ${(0, render_1.timeTag)(issue.closedAt)}</span></div>`
198
200
  : '';
199
201
  let replyBox;
200
202
  if (viewer) {
@@ -203,7 +205,7 @@ function issuePage(ctx, issue, canWrite) {
203
205
  : '';
204
206
  replyBox = (0, html_1.html) `<form class="issue-reply" method="post" action="${base}/${issue.number}/comment">${(0, views_1.csrfField)(viewer)}
205
207
  <div class="issue-comment-head">${(0, avatar_1.avatar)(viewer.auth.username, 24)}<b>${viewer.auth.username}</b></div>
206
- <textarea class="code-editor" name="body" rows="6" placeholder="Leave a comment"></textarea>
208
+ ${(0, mdedit_1.markdownEditor)({ name: 'body', rows: 6, placeholder: 'Leave a comment', preview: (0, mdedit_1.previewUrl)(ctx) })}
207
209
  <div class="actions">${toggle}<button class="btn btn-primary" type="submit">Comment</button></div>
208
210
  </form>`;
209
211
  }
@@ -219,7 +221,9 @@ function issuePage(ctx, issue, canWrite) {
219
221
  <h1 class="issue-title">${issue.title} <span class="muted">#${issue.number}</span></h1>
220
222
  <span class="right-group">${edit}<a class="btn" href="${base}">Back to issues</a></span>
221
223
  </div>
222
- <div class="issue-sub">${stateBadge(issue.state)}<span class="muted"><b>${issue.author}</b> opened this ${(0, render_1.timeTag)(issue.created)} &middot; ${issue.commentList.length} comment${issue.commentList.length === 1 ? '' : 's'}</span>${labelChips(issue.labels)}</div>
224
+ <div class="issue-sub">${stateBadge(issue.state)}<span class="muted">${(0, views_1.userLink)(issue.author, {
225
+ bold: true,
226
+ })} opened this ${(0, render_1.timeTag)(issue.created)} &middot; ${issue.commentList.length} comment${issue.commentList.length === 1 ? '' : 's'}</span>${labelChips(issue.labels)}</div>
223
227
  <div class="issue-thread">
224
228
  ${commentCard(issue.author, issue.created, description.text ? description : (0, html_1.raw)('<p class="muted">No description.</p>'), 'opened this')}
225
229
  ${comments}
@@ -239,7 +243,7 @@ ${error ? (0, html_1.html) `<div class="form-error">${error}</div>` : ''}
239
243
  <div class="field"><label for="title">Title</label>
240
244
  <input type="text" id="title" name="title" value="${values.title}" maxlength="${issues.MAX_TITLE}" required autofocus></div>
241
245
  <div class="field"><label for="body">Description</label>
242
- <textarea class="code-editor" id="body" name="body" rows="12" placeholder="Markdown is welcome">${values.body}</textarea></div>
246
+ ${(0, mdedit_1.markdownEditor)({ name: 'body', id: 'body', rows: 12, placeholder: 'Markdown is welcome', value: values.body, preview: (0, mdedit_1.previewUrl)(ctx) })}</div>
243
247
  ${ctx.canPush
244
248
  ? (0, html_1.html) `<div class="field"><label for="labels">Labels</label>
245
249
  <input type="text" id="labels" name="labels" value="${values.labels.join(', ')}" placeholder="bug, ui">
package/dist/lfs.js CHANGED
@@ -251,8 +251,15 @@ function registerLfs(app, root, lfs, authLimiter) {
251
251
  };
252
252
  }
253
253
  // Already present: omitting actions tells the client to skip the
254
- // upload; this is the deduplication mechanism.
255
- if (info)
254
+ // upload; this is the deduplication mechanism. Present at the size
255
+ // the pointer claims, that is. A bucket takes whatever bytes a
256
+ // presigned PUT delivers, so a client that lied about the size in
257
+ // one batch and uploaded something else left an object under this
258
+ // id that is not this object; it fails verification, but it would
259
+ // otherwise also stand in for every honest upload of the real one
260
+ // from then on. An object of the wrong size is treated as absent
261
+ // and the upload it is given overwrites it.
262
+ if (info && info.size === o.size)
256
263
  return { oid: o.oid, size: o.size, authenticated: true };
257
264
  if (o.size > maxSize) {
258
265
  return {
package/dist/limit.js CHANGED
@@ -218,7 +218,7 @@ function createGates(limits) {
218
218
  const AUTH_WINDOW_MS = 15 * 60 * 1000;
219
219
  const AUTH_MAX_KEYS = 20000;
220
220
  /** Bearer and runner tokens carry no username, so they share one bucket in the fine-grained window. */
221
- const NO_USERNAME = 'token';
221
+ const NO_USERNAME = '\x00token';
222
222
  /**
223
223
  * A limiter charged only on failure, so a working credential is never throttled
224
224
  * however often it is used. That matters for /api/runner/*, which a runner calls
@@ -240,7 +240,7 @@ function createAuthLimiter(authFailures) {
240
240
  const coarse = createLimiter({ limit: authFailures * 5, windowMs: AUTH_WINDOW_MS, maxKeys: AUTH_MAX_KEYS });
241
241
  const keys = (req, username) => {
242
242
  const address = clientKey(req);
243
- return { address, fine: `${address}${username ?? NO_USERNAME}` };
243
+ return { address, fine: `${address}\x00${username ?? NO_USERNAME}` };
244
244
  };
245
245
  return {
246
246
  allow(req, username) {
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HANDOFF_TTL_MS = exports.LOGIN_LINK_TTL_MS = void 0;
4
+ exports.mintLoginLink = mintLoginLink;
5
+ exports.peekLoginLink = peekLoginLink;
6
+ exports.takeLoginLink = takeLoginLink;
7
+ exports.mintHandoff = mintHandoff;
8
+ exports.takeHandoff = takeHandoff;
9
+ const onetime_1 = require("./onetime");
10
+ exports.LOGIN_LINK_TTL_MS = 2 * 60 * 1000;
11
+ exports.HANDOFF_TTL_MS = 5 * 60 * 1000;
12
+ const loginLinks = (0, onetime_1.createOneTimeStore)();
13
+ const handoffCodes = (0, onetime_1.createOneTimeStore)(onetime_1.mintHandoffCode);
14
+ function mintLoginLink(root, username, binding, next) {
15
+ return loginLinks.put({ root, username, binding, next }, exports.LOGIN_LINK_TTL_MS);
16
+ }
17
+ function peekLoginLink(root, code) {
18
+ const p = loginLinks.peek(code);
19
+ return p && p.root === root ? p : null;
20
+ }
21
+ function takeLoginLink(root, code) {
22
+ const p = loginLinks.take(code);
23
+ return p && p.root === root ? p : null;
24
+ }
25
+ function mintHandoff(root, username, binding) {
26
+ return handoffCodes.put({ root, username, binding }, exports.HANDOFF_TTL_MS);
27
+ }
28
+ function takeHandoff(root, typed) {
29
+ const p = handoffCodes.take((0, onetime_1.normalizeHandoffCode)(typed));
30
+ return p && p.root === root ? p : null;
31
+ }