@magland/mochi 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
@@ -0,0 +1,859 @@
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.IMAGE_REPO = void 0;
37
+ exports.fly = fly;
38
+ exports.flyStream = flyStream;
39
+ exports.die = die;
40
+ exports.parseVmSize = parseVmSize;
41
+ exports.normalizeMemory = normalizeMemory;
42
+ exports.ownVersion = ownVersion;
43
+ exports.sourceRoot = sourceRoot;
44
+ exports.requireFly = requireFly;
45
+ exports.appExists = appExists;
46
+ exports.namedVolume = namedVolume;
47
+ exports.machines = machines;
48
+ exports.secretNames = secretNames;
49
+ exports.appUrl = appUrl;
50
+ exports.deployFlyCmd = deployFlyCmd;
51
+ exports.deployShowCmd = deployShowCmd;
52
+ exports.promptLine = promptLine;
53
+ exports.deployDestroyCmd = deployDestroyCmd;
54
+ const child_process_1 = require("child_process");
55
+ const fs = __importStar(require("fs"));
56
+ const os = __importStar(require("os"));
57
+ const path = __importStar(require("path"));
58
+ const credentials_1 = require("./credentials");
59
+ const backup_cmd_1 = require("./cli/backup-cmd");
60
+ const vault_1 = require("./vault");
61
+ // `mochi deploy fly`: put a vault on Fly.io from one command, and deploy
62
+ // updates to it with the same one. This is a thin driver of the fly command
63
+ // rather than a client of Fly's API, so it inherits `fly auth login` and the
64
+ // user's existing organization; the only prerequisite is that flyctl is
65
+ // installed and logged in.
66
+ //
67
+ // Nothing about a deployment is remembered on this machine. Fly already knows
68
+ // the region, the volume size, and the machine's shape, so this reads them back
69
+ // from the live app and applies only what the flags change. A generated
70
+ // fly.toml goes to a temporary directory for the length of the deploy, which is
71
+ // why there is no fly.toml in this repository to keep in sync or to explain.
72
+ exports.IMAGE_REPO = 'ghcr.io/magland/mochi';
73
+ const VOLUME_NAME = 'vault';
74
+ const OWNER_TOKEN_SECRET = 'MOCHI_OWNER_TOKEN';
75
+ const INTERNAL_PORT = 3000;
76
+ const DEFAULTS = { region: 'ewr', volumeGb: 10, cpuKind: 'shared', cpus: 1, memory: '512mb' };
77
+ const FLY_NOT_FOUND = 'Neither fly nor flyctl is on PATH. Install it from https://fly.io/docs/flyctl/install/';
78
+ /**
79
+ * The name flyctl goes by here.
80
+ *
81
+ * A normal install provides both `fly` and `flyctl`, but not every install is
82
+ * normal: flyctl's own GitHub Action unpacks the release tarball, which carries
83
+ * `flyctl` alone. So preferring `fly` and falling back keeps a deploy from a CI
84
+ * runner working, which is the one place nobody is watching to fix the PATH.
85
+ * Either name is the same binary, so which one was found never matters again.
86
+ */
87
+ function flyBin() {
88
+ if (cachedFlyBin === null)
89
+ cachedFlyBin = onPath('fly') ?? onPath('flyctl') ?? 'fly';
90
+ return cachedFlyBin;
91
+ }
92
+ let cachedFlyBin = null;
93
+ function onPath(name) {
94
+ const names = process.platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, name] : [name];
95
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
96
+ if (!dir)
97
+ continue;
98
+ for (const n of names) {
99
+ try {
100
+ fs.accessSync(path.join(dir, n), fs.constants.X_OK);
101
+ return name;
102
+ }
103
+ catch {
104
+ /* not this one */
105
+ }
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ // Quiet commands, whose output this code reads rather than the user. A non-zero
111
+ // exit is often the answer and not a failure (`fly status` on an app that does
112
+ // not exist), so the code is reported instead of thrown.
113
+ //
114
+ // The optional stdin is how a secret is handed over. An argument would be
115
+ // readable in `ps` by every other user on this machine for as long as the child
116
+ // runs, and a token is worth more than that.
117
+ function fly(args, stdin) {
118
+ return new Promise((resolve, reject) => {
119
+ const child = (0, child_process_1.execFile)(flyBin(), args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
120
+ const code = err?.code;
121
+ if (code === 'ENOENT') {
122
+ reject(new Error(FLY_NOT_FOUND));
123
+ return;
124
+ }
125
+ resolve({ code: typeof code === 'number' ? code : err ? 1 : 0, stdout: String(stdout), stderr: String(stderr) });
126
+ });
127
+ if (stdin !== undefined) {
128
+ // A child that exits before reading breaks the pipe; that failure is
129
+ // already reported by its exit code above, so it is not raised twice.
130
+ child.stdin?.on('error', () => undefined);
131
+ child.stdin?.end(stdin);
132
+ }
133
+ });
134
+ }
135
+ async function flyJson(args) {
136
+ const r = await fly([...args, '--json']);
137
+ if (r.code !== 0)
138
+ return null;
139
+ try {
140
+ return JSON.parse(r.stdout);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ // The commands whose progress the user should watch: deploying, creating a
147
+ // volume, provisioning a bucket. Their output is fly's to format, and hiding a
148
+ // three-minute deploy behind a spinner of our own would only lose detail.
149
+ function flyStream(args, cwd) {
150
+ return new Promise((resolve, reject) => {
151
+ const child = (0, child_process_1.spawn)(flyBin(), args, { stdio: 'inherit', cwd });
152
+ child.on('error', (e) => {
153
+ reject(e.code === 'ENOENT' ? new Error(FLY_NOT_FOUND) : e);
154
+ });
155
+ child.on('close', (code) => resolve(code ?? 1));
156
+ });
157
+ }
158
+ function die(message) {
159
+ console.error(message);
160
+ process.exit(1);
161
+ }
162
+ function parseDeployArgs(args, usage) {
163
+ const out = {
164
+ app: null,
165
+ region: null,
166
+ volumeGb: null,
167
+ vmSize: null,
168
+ memory: null,
169
+ image: null,
170
+ fromSource: false,
171
+ localBuild: false,
172
+ org: null,
173
+ lfsBucket: false,
174
+ yes: false,
175
+ };
176
+ for (let i = 0; i < args.length; i++) {
177
+ const a = args[i];
178
+ if (a === '-h' || a === '--help')
179
+ usage();
180
+ else if (a === '--region')
181
+ out.region = args[++i];
182
+ else if (a === '--volume') {
183
+ const gb = parseInt(args[++i], 10);
184
+ if (!Number.isInteger(gb) || gb < 1)
185
+ die('--volume takes a size in whole gigabytes, e.g. --volume 10');
186
+ out.volumeGb = gb;
187
+ }
188
+ else if (a === '--vm-size')
189
+ out.vmSize = args[++i];
190
+ else if (a === '--vm-memory')
191
+ out.memory = args[++i];
192
+ else if (a === '--image')
193
+ out.image = args[++i];
194
+ else if (a === '--from-source')
195
+ out.fromSource = true;
196
+ else if (a === '--local-build')
197
+ out.localBuild = true;
198
+ else if (a === '--org')
199
+ out.org = args[++i];
200
+ else if (a === '--lfs-bucket')
201
+ out.lfsBucket = true;
202
+ else if (a === '-y' || a === '--yes')
203
+ out.yes = true;
204
+ else if (a.startsWith('-'))
205
+ die(`Unknown option: ${a}`);
206
+ else if (!out.app)
207
+ out.app = a;
208
+ else
209
+ die(`Unexpected argument: ${a}`);
210
+ }
211
+ return out;
212
+ }
213
+ // `deploy fly show` and `deploy fly destroy` take an app name and nothing else,
214
+ // apart from --yes on destroy. The flags that shape a deployment are parsed by the
215
+ // same function they share, so accepting one here and then ignoring it would
216
+ // look like it had been applied. Only the first one found is named, since fixing
217
+ // it means dropping it and running the command again either way.
218
+ function rejectFlyFlags(a, usage, allowYes) {
219
+ const used = [
220
+ ['--region', a.region !== null],
221
+ ['--volume', a.volumeGb !== null],
222
+ ['--vm-size', a.vmSize !== null],
223
+ ['--vm-memory', a.memory !== null],
224
+ ['--image', a.image !== null],
225
+ ['--from-source', a.fromSource],
226
+ ['--local-build', a.localBuild],
227
+ ['--org', a.org !== null],
228
+ ['--lfs-bucket', a.lfsBucket],
229
+ ['--yes', a.yes && !allowYes],
230
+ ];
231
+ const flag = used.find(([, given]) => given);
232
+ if (!flag)
233
+ return;
234
+ if (flag[0] === '--yes')
235
+ die(`--yes confirms a destroy, and there is nothing here to confirm.\nUsage: ${usage}`);
236
+ die(`${flag[0]} says how to deploy, and this command deploys nothing.\nUsage: ${usage}`);
237
+ }
238
+ // Fly's own machine sizes name a CPU kind and a count: shared-cpu-4x,
239
+ // performance-2x. The generated config sets cpu_kind and cpus separately, since
240
+ // spelling those two out avoids having to know which shorthands Fly accepts
241
+ // today, so the shorthand is taken apart here.
242
+ function parseVmSize(size) {
243
+ const m = /^(shared|performance)(?:-cpu)?-(\d+)x$/.exec(size.trim());
244
+ if (!m) {
245
+ die(`Not a Fly machine size: ${size}\n` +
246
+ 'Expected something like shared-cpu-1x, shared-cpu-4x, or performance-2x.\n' +
247
+ 'See: fly platform vm-sizes');
248
+ }
249
+ return { cpuKind: m[1], cpus: parseInt(m[2], 10) };
250
+ }
251
+ function normalizeMemory(memory) {
252
+ const m = /^(\d+)\s*(mb|gb)?$/i.exec(memory.trim());
253
+ if (!m)
254
+ die(`Not a memory size: ${memory}\nExpected something like 512mb, 1gb, or 2048.`);
255
+ const n = parseInt(m[1], 10);
256
+ const unit = (m[2] ?? 'mb').toLowerCase();
257
+ const mb = unit === 'gb' ? n * 1024 : n;
258
+ if (mb < 256)
259
+ die(`Memory of ${memory} is below Fly's 256mb minimum.`);
260
+ return `${mb}mb`;
261
+ }
262
+ /** The version of this CLI, which is the image tag deployed unless --image says otherwise. */
263
+ function ownVersion() {
264
+ try {
265
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
266
+ if (typeof pkg.version === 'string' && pkg.version)
267
+ return pkg.version;
268
+ }
269
+ catch {
270
+ /* fall through to the message below */
271
+ }
272
+ die('Could not read this package\'s version, so there is no image tag to deploy. Pass --image <ref>.');
273
+ }
274
+ /**
275
+ * The checkout this CLI is running out of, for --from-source.
276
+ *
277
+ * dist/deploy-cli.js and src/deploy-cli.ts are both one directory below the
278
+ * package root, so this is the same answer either way: a built checkout, or one
279
+ * being run through tsx. The published npm package ships only dist, so a globally
280
+ * installed mochi has no Dockerfile and no src to build from, and that is
281
+ * worth saying rather than letting docker fail on a missing file.
282
+ */
283
+ function sourceRoot() {
284
+ const root = path.resolve(__dirname, '..');
285
+ if (!fs.existsSync(path.join(root, 'Dockerfile')) || !fs.existsSync(path.join(root, 'src'))) {
286
+ die('--from-source builds the image from a mochi checkout, and this is not one:\n' +
287
+ ` ${root}\n\n` +
288
+ 'The published package contains only the compiled output, so there is nothing to\n' +
289
+ 'build. Clone the repository and run the deploy from there:\n\n' +
290
+ ' git clone https://github.com/magland/mochi && cd mochi && npm install\n' +
291
+ ' npm run build && node dist/index.js deploy fly <app> --from-source\n');
292
+ }
293
+ return root;
294
+ }
295
+ async function requireFly() {
296
+ const who = await fly(['auth', 'whoami']);
297
+ if (who.code !== 0) {
298
+ die('Not logged in to Fly. Run:\n\n fly auth login\n');
299
+ }
300
+ }
301
+ async function appExists(app) {
302
+ const r = await fly(['status', '-a', app]);
303
+ return r.code === 0;
304
+ }
305
+ async function namedVolume(app, name) {
306
+ const vols = (await flyJson(['volumes', 'list', '-a', app])) ?? [];
307
+ return vols.find((v) => v.name === name) ?? null;
308
+ }
309
+ async function vaultVolume(app) {
310
+ return namedVolume(app, VOLUME_NAME);
311
+ }
312
+ async function machines(app) {
313
+ return (await flyJson(['machines', 'list', '-a', app])) ?? [];
314
+ }
315
+ /**
316
+ * The hostnames Fly serves this app under besides <app>.fly.dev, from its
317
+ * certificates. A vault with a domain of its own is reached by that name, so
318
+ * anything asking "is this app the one at <url>" has to know both.
319
+ */
320
+ async function certHostnames(app) {
321
+ const certs = (await flyJson(['certs', 'list', '-a', app])) ?? [];
322
+ // A wildcard covers each repository's site rather than the vault itself, so it
323
+ // is not a name the vault answers on.
324
+ return certs.map((c) => c.hostname ?? '').filter((h) => h !== '' && !h.startsWith('*.'));
325
+ }
326
+ async function secretNames(app) {
327
+ const secrets = (await flyJson(['secrets', 'list', '-a', app])) ?? [];
328
+ return secrets.map((s) => s.Name ?? s.name ?? '').filter(Boolean);
329
+ }
330
+ /** What Fly currently has, so that a flag-less redeploy changes nothing and one flag changes one thing. */
331
+ async function liveSettings(app) {
332
+ const out = {};
333
+ const vol = await vaultVolume(app);
334
+ if (vol) {
335
+ out.region = vol.region;
336
+ out.volumeGb = vol.size_gb;
337
+ }
338
+ const guest = (await machines(app)).find((m) => m.config?.guest)?.config?.guest;
339
+ if (guest) {
340
+ if (guest.cpu_kind)
341
+ out.cpuKind = guest.cpu_kind;
342
+ if (guest.cpus)
343
+ out.cpus = guest.cpus;
344
+ if (guest.memory_mb)
345
+ out.memory = `${guest.memory_mb}mb`;
346
+ }
347
+ return out;
348
+ }
349
+ function resolveSettings(a, live) {
350
+ const base = { ...DEFAULTS, ...live };
351
+ const vm = a.vmSize ? parseVmSize(a.vmSize) : null;
352
+ return {
353
+ region: a.region ?? base.region,
354
+ volumeGb: a.volumeGb ?? base.volumeGb,
355
+ cpuKind: vm?.cpuKind ?? base.cpuKind,
356
+ cpus: vm?.cpus ?? base.cpus,
357
+ memory: a.memory ? normalizeMemory(a.memory) : base.memory,
358
+ };
359
+ }
360
+ // A vault is a directory on one volume, so this app runs as exactly one
361
+ // machine: --ha=false at deploy time, and min_machines_running = 0 with
362
+ // auto-start here. A second machine would mean a second volume and a second
363
+ // vault, diverging silently from the first. For the same reason, a busier vault
364
+ // wants a bigger machine rather than more of them.
365
+ function flyToml(app, s) {
366
+ return `# Generated by mochi deploy for '${app}'. Written to a temporary
367
+ # directory for the length of one deploy; edit the deploy command, not this.
368
+ app = "${app}"
369
+ primary_region = "${s.region}"
370
+
371
+ [mounts]
372
+ source = "${VOLUME_NAME}"
373
+ destination = "/vault"
374
+
375
+ # Fly always terminates TLS in front, so the forwarded headers are the only place
376
+ # the real scheme and address appear. The server records this in the vault's
377
+ # config.json on the next start, where it can be changed by hand afterwards.
378
+ [env]
379
+ MOCHI_TRUST_PROXY = "1"
380
+
381
+ [http_service]
382
+ internal_port = ${INTERNAL_PORT}
383
+ force_https = true
384
+ auto_stop_machines = "stop"
385
+ auto_start_machines = true
386
+ min_machines_running = 0
387
+ [http_service.concurrency]
388
+ type = "requests"
389
+ hard_limit = 250
390
+ soft_limit = 200
391
+
392
+ [[vm]]
393
+ cpu_kind = "${s.cpuKind}"
394
+ cpus = ${s.cpus}
395
+ memory = "${s.memory}"
396
+ `;
397
+ }
398
+ function writeTempConfig(app, s) {
399
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mochi-deploy-'));
400
+ const file = path.join(dir, 'fly.toml');
401
+ fs.writeFileSync(file, flyToml(app, s));
402
+ return file;
403
+ }
404
+ function appUrl(app) {
405
+ return `https://${app}.fly.dev`;
406
+ }
407
+ /**
408
+ * Wait for the deployed vault to answer as the token's owner. This is both the
409
+ * health check and the proof that the injected token was adopted: a machine
410
+ * that boots and then fails to read its volume answers nothing, and a vault
411
+ * that was already initialized answers 401.
412
+ */
413
+ async function waitForVault(url, token, seconds = 120) {
414
+ const deadline = Date.now() + seconds * 1000;
415
+ let last = 'no answer yet';
416
+ while (Date.now() < deadline) {
417
+ try {
418
+ const resp = await fetch(`${url}/api/whoami`, { headers: { authorization: `Bearer ${token}` } });
419
+ if (resp.ok) {
420
+ const data = (await resp.json());
421
+ if (typeof data.username === 'string' && data.username)
422
+ return { ok: true, username: data.username };
423
+ last = 'the vault answered without saying who the token belongs to';
424
+ }
425
+ else if (resp.status === 401 || resp.status === 403) {
426
+ // Conclusive rather than worth retrying: the server is up and has
427
+ // rejected this token, which means the vault was initialized before.
428
+ return { ok: false, reason: `the vault did not accept the new owner token (HTTP ${resp.status})` };
429
+ }
430
+ else {
431
+ last = `HTTP ${resp.status} from ${url}`;
432
+ }
433
+ }
434
+ catch (e) {
435
+ last = e instanceof Error ? e.message : String(e);
436
+ }
437
+ await new Promise((r) => setTimeout(r, 3000));
438
+ }
439
+ return { ok: false, reason: `timed out after ${seconds}s: ${last}` };
440
+ }
441
+ async function deployFlyCmd(args, usage) {
442
+ const a = parseDeployArgs(args, usage);
443
+ if (!a.app) {
444
+ die('Which app? Fly app names are globally unique, and the name becomes the URL:\n\n' +
445
+ ' mochi deploy fly my-vault-name -> https://my-vault-name.fly.dev\n');
446
+ }
447
+ const app = a.app;
448
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(app)) {
449
+ die(`Not a valid Fly app name: ${app}\nUse lowercase letters, digits, and dashes.`);
450
+ }
451
+ // What gets deployed: a published image to pull, or this checkout to build.
452
+ // Settled from the flags before anything reaches the network, so a
453
+ // contradiction between them costs no round trip and creates no app.
454
+ if (a.fromSource && a.image !== null) {
455
+ die('--image names an image to pull and --from-source builds one instead. Pass one or the other.');
456
+ }
457
+ if (a.localBuild && !a.fromSource) {
458
+ die('--local-build says how to build, and without --from-source there is nothing to build.');
459
+ }
460
+ const buildRoot = a.fromSource ? sourceRoot() : null;
461
+ const image = buildRoot === null ? a.image ?? `${exports.IMAGE_REPO}:${ownVersion()}` : null;
462
+ await requireFly();
463
+ const existed = await appExists(app);
464
+ const live = existed ? await liveSettings(app) : {};
465
+ const settings = resolveSettings(a, live);
466
+ // A volume cannot move, so a region flag that disagrees with the volume that
467
+ // exists is a request this cannot carry out. Saying so beats deploying a
468
+ // machine in one region that can never attach the disk in another.
469
+ if (existed && live.region && a.region && a.region !== live.region) {
470
+ die(`This vault's volume is in ${live.region}, and a volume cannot be moved to ${a.region}.\n` +
471
+ 'Deploying to another region means a new vault and copying the data across.');
472
+ }
473
+ if (existed) {
474
+ console.log(`==> Updating '${app}' (${appUrl(app)})`);
475
+ }
476
+ else {
477
+ console.log(`==> Creating '${app}' in ${settings.region}`);
478
+ const created = await flyStream(['apps', 'create', app, ...(a.org ? ['--org', a.org] : [])]);
479
+ if (created !== 0) {
480
+ die(`\nCould not create the Fly app '${app}'.\n` +
481
+ 'App names are globally unique, so a name in use by anyone stops this. Try another.');
482
+ }
483
+ }
484
+ const vol = await vaultVolume(app);
485
+ if (!vol) {
486
+ console.log(`==> Creating a ${settings.volumeGb}GB volume '${VOLUME_NAME}' in ${settings.region}`);
487
+ const code = await flyStream([
488
+ 'volumes',
489
+ 'create',
490
+ VOLUME_NAME,
491
+ '-a',
492
+ app,
493
+ '--region',
494
+ settings.region,
495
+ '--size',
496
+ String(settings.volumeGb),
497
+ '--yes',
498
+ ]);
499
+ if (code !== 0)
500
+ die('\nCould not create the volume, so there is nowhere to keep the vault.');
501
+ }
502
+ else if (settings.volumeGb > vol.size_gb) {
503
+ console.log(`==> Extending volume '${VOLUME_NAME}' from ${vol.size_gb}GB to ${settings.volumeGb}GB`);
504
+ const code = await flyStream(['volumes', 'extend', vol.id, '-a', app, '--size', String(settings.volumeGb)]);
505
+ if (code !== 0)
506
+ die('\nCould not extend the volume.');
507
+ }
508
+ else if (a.volumeGb !== null && a.volumeGb < vol.size_gb) {
509
+ // Fly volumes only grow. Ignoring this quietly would leave the operator
510
+ // believing the vault had been shrunk, and paying for the old size.
511
+ die(`The volume is ${vol.size_gb}GB and Fly volumes cannot be shrunk, so --volume ${a.volumeGb} cannot be applied.\n` +
512
+ 'Leave the flag off to keep the size it has.');
513
+ }
514
+ const secrets = existed ? await secretNames(app) : [];
515
+ if (a.lfsBucket && !secrets.includes('BUCKET_NAME')) {
516
+ // Tigris' own secret names are the ones the LFS store already reads, so
517
+ // provisioning a bucket is the whole configuration step. It is the only
518
+ // provider a deploy can set up unattended, which is why this flag uses it
519
+ // and not the one the documentation recommends.
520
+ console.log('==> Provisioning a Tigris bucket for Git LFS objects');
521
+ // Said here rather than only in the documentation, because this is the
522
+ // moment the choice is being made. LFS bytes leave the bucket rather than
523
+ // the app, so they are outside the vault's daily egress cap and are billed
524
+ // on the bucket's own terms; R2 charges nothing for them.
525
+ console.log(' Tigris is what a deploy can provision unattended, not what costs least to');
526
+ console.log(' serve from: LFS downloads leave the bucket, so they are billed by the bucket');
527
+ console.log(' and are not covered by the vault\'s daily egress limit. Cloudflare R2 charges');
528
+ console.log(' no egress fees; see docs/lfs.md#storage-providers to point this vault there.');
529
+ const code = await flyStream(['storage', 'create', '-a', app, '-n', `${app}-lfs`, '--yes']);
530
+ if (code !== 0) {
531
+ die('\nCould not provision the bucket. The app and volume are already there, so\n' +
532
+ 'this command is safe to run again once the bucket problem is sorted out.');
533
+ }
534
+ }
535
+ else if (a.lfsBucket) {
536
+ console.log('==> A bucket is already configured (BUCKET_NAME is set), leaving it alone');
537
+ }
538
+ // Set once the operator has been shown the token. It is what the exit hook
539
+ // below checks before printing it as a last resort.
540
+ let tokenDelivered = false;
541
+ // An owner token is minted only for a vault that has none. The question is
542
+ // whether the vault has been initialized rather than whether the app exists,
543
+ // because a first deploy that fails leaves the app behind: on the next
544
+ // attempt the app is not new but the vault still is, and that retry should
545
+ // end with a usable owner token like any other first deploy. Whether a
546
+ // machine has ever run is as close to that question as Fly can be asked.
547
+ //
548
+ // The secret already being set is deliberately not part of it. A Fly secret
549
+ // can be written and not read, so a token from an abandoned attempt is a
550
+ // token nobody has; overwriting it with one this run knows is the only way
551
+ // the retry can end with a token the operator holds.
552
+ //
553
+ // Minting here rather than on the server is what makes that possible: the
554
+ // server adopts this token when it initializes the vault, stores only its
555
+ // hash, and never prints it.
556
+ const ownerToken = existed && (await machines(app)).length > 0 ? null : (0, vault_1.mintToken)().token;
557
+ if (ownerToken) {
558
+ console.log('==> Setting the one-time owner token as a Fly secret');
559
+ // `secrets import` reads KEY=VALUE lines from stdin, which keeps the token
560
+ // off the child's argv and so out of `ps`.
561
+ const r = await fly(['secrets', 'import', '-a', app, '--stage'], `${OWNER_TOKEN_SECRET}=${ownerToken}\n`);
562
+ if (r.code !== 0)
563
+ die(`Could not set the owner token secret:\n${r.stderr.trim() || r.stdout.trim()}`);
564
+ // From here the token exists in two places: this process, and a Fly secret
565
+ // that can be written but never read back. So every way out of the rest of
566
+ // this command has to end with the operator looking at it, and an exit hook
567
+ // is the one place that covers them all, including a `die()` from deeper
568
+ // down and an unexpected throw.
569
+ process.on('exit', () => {
570
+ if (tokenDelivered)
571
+ return;
572
+ // Written with writeSync rather than console.error, which is the whole
573
+ // point of doing it here: on Linux a write to a pipe is asynchronous, so
574
+ // console.error from an exit handler is discarded when the output is
575
+ // piped into a file or a pager, which is a plausible thing to do with a
576
+ // deploy. The one message that must not be lost cannot go out that way.
577
+ fs.writeSync(2, '\nThe owner token this run staged as a Fly secret, shown here because nothing\n' +
578
+ 'else has a copy of it. A Fly secret can be written but not read back, and this\n' +
579
+ 'token is the owner of the vault if this app initialized one:\n' +
580
+ `\n ${ownerToken}\n` +
581
+ `\nKeep it, then: mochi login ${appUrl(app)} --token ${ownerToken}\n`);
582
+ });
583
+ }
584
+ const config = writeTempConfig(app, settings);
585
+ // A source build runs fly in the checkout, so the build context is the checkout
586
+ // and fly finds its Dockerfile without being told where it is. --config still
587
+ // points at the generated fly.toml in a temporary directory, which is why there
588
+ // is no fly.toml in the repository for a build to pick up by accident.
589
+ //
590
+ // Without --local-only, flyctl builds on a Fly builder machine, which needs no
591
+ // Docker here and provisions a builder app on first use. --local-build asks for
592
+ // the local daemon instead and pushes the result to Fly's registry.
593
+ const source = buildRoot !== null;
594
+ if (source) {
595
+ console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
596
+ }
597
+ else {
598
+ console.log(`==> Deploying ${image}`);
599
+ }
600
+ const code = await flyStream([
601
+ 'deploy',
602
+ '--app',
603
+ app,
604
+ '--config',
605
+ config,
606
+ ...(source ? (a.localBuild ? ['--local-only'] : []) : ['--image', image]),
607
+ '--ha=false',
608
+ '--yes',
609
+ ], buildRoot ?? undefined);
610
+ fs.rmSync(path.dirname(config), { recursive: true, force: true });
611
+ if (code !== 0) {
612
+ console.error('');
613
+ console.error('The deploy failed. The app and the volume survive, so fix the cause and run the');
614
+ console.error('same command again.');
615
+ if (ownerToken) {
616
+ // The retry will not mint a second token if a machine was left behind by
617
+ // this attempt, and it could not overwrite a secret it cannot read even
618
+ // if it did. So this run's token is the one the vault ends up with, and
619
+ // the exit hook is about to print it.
620
+ console.error('');
621
+ console.error('The retry may not mint a token of its own, because the one shown at the end of');
622
+ console.error('this output is already the token the vault will be initialized with. Keep it,');
623
+ console.error('and log in with it once a deploy succeeds.');
624
+ }
625
+ if (source) {
626
+ console.error('');
627
+ console.error('If the build is the problem, `npm run build` in the checkout reproduces it locally');
628
+ console.error(a.localBuild
629
+ ? 'without Fly in the way; check that the Docker daemon here is running.'
630
+ : "without Fly in the way. --local-build uses this machine's Docker instead of Fly's builder.");
631
+ }
632
+ else if (a.image === null) {
633
+ console.error('');
634
+ console.error(`If the image is the problem, check that ${image} exists,`);
635
+ console.error('or deploy another tag with --image <ref>, or build this checkout with --from-source.');
636
+ }
637
+ process.exit(1);
638
+ }
639
+ const url = appUrl(app);
640
+ console.log('');
641
+ if (!ownerToken) {
642
+ console.log(`==> Deployed: ${url}`);
643
+ console.log('');
644
+ console.log('The vault it serves is whichever vault was already on the volume, users and all.');
645
+ // No token was minted because a machine had run before, which usually means
646
+ // a vault that has been in use and an operator who is already logged in.
647
+ // With nothing stored here, the other reading is possible: an earlier
648
+ // attempt staged a token and left a machine behind, and this deploy has
649
+ // just initialized the vault with it. Saying so costs a line only in the
650
+ // case where it might be the answer.
651
+ if (!(await (0, credentials_1.readCredential)((0, credentials_1.credentialTarget)(url)))) {
652
+ console.log('');
653
+ console.log('No token for it is stored on this machine, so log in with one the vault knows:');
654
+ console.log('');
655
+ console.log(` mochi login ${url}`);
656
+ console.log('');
657
+ console.log('If an earlier deploy of this app failed, the vault was initialized just now with');
658
+ console.log('the owner token that run printed, and that is the token to use.');
659
+ console.log('');
660
+ }
661
+ console.log(` fly logs -a ${app}`);
662
+ return;
663
+ }
664
+ console.log('==> Waiting for the vault to answer');
665
+ const ready = await waitForVault(url, ownerToken);
666
+ if (!ready.ok) {
667
+ console.error('');
668
+ console.error(`Deployed, but ${ready.reason}.`);
669
+ console.error(`Look at what the server said: fly logs -a ${app}`);
670
+ if (existed) {
671
+ // The likeliest cause when the app was already there: a volume carrying
672
+ // a vault that was initialized by an earlier machine. Its own tokens are
673
+ // still the way in, and no token minted here will ever work on it.
674
+ console.error('');
675
+ console.error('If this app has served a vault before, that vault keeps the users and tokens it');
676
+ console.error('already had, and a token minted now is not one of them. Log in with one of those:');
677
+ console.error('');
678
+ console.error(` mochi login ${url}`);
679
+ }
680
+ // The token this run minted is printed on the way out by the exit hook,
681
+ // since a vault that has not answered yet may still adopt it.
682
+ process.exit(1);
683
+ }
684
+ // The token is shown rather than stored. A deploy that logged you in quietly
685
+ // left the operator holding a vault whose token they had never seen, which is
686
+ // no way to sign in to the web UI and nothing to keep anywhere; and the token
687
+ // cannot be recovered later, since the server keeps only its hash and a Fly
688
+ // secret cannot be read back. So it is printed once, with the two ways to use
689
+ // it, and `mochi login` stays the one thing that stores a credential.
690
+ console.log('');
691
+ console.log(`==> Ready: ${url}`);
692
+ console.log('');
693
+ console.log(`The vault is initialized, and '${ready.username}' owns it. This is its token, shown`);
694
+ console.log('here once and nowhere else: the server keeps only its hash, and the Fly secret it');
695
+ console.log('was staged in cannot be read back. Keep it somewhere safe now.');
696
+ console.log('');
697
+ console.log(` ${ownerToken}`);
698
+ // Only now: the exit hook is the backstop for a token that never reached the
699
+ // operator, and stdout can fail (a closed pipe) between here and there.
700
+ tokenDelivered = true;
701
+ console.log('');
702
+ console.log(`To administer the vault in a browser, open its sign-in page and give that token as`);
703
+ console.log(`'${ready.username}':`);
704
+ console.log('');
705
+ console.log(` ${url}/login`);
706
+ console.log('');
707
+ console.log('The form asks for a username and a token, since a vault has no passwords. From');
708
+ console.log('there the Admin page creates the users and the repositories, which is the usual');
709
+ console.log('way to bootstrap a fresh vault.');
710
+ console.log('');
711
+ console.log('To use the CLI and git instead, hand the same token to git\'s credential store,');
712
+ console.log('which is what login is for:');
713
+ console.log('');
714
+ console.log(` mochi login ${url}`);
715
+ console.log('');
716
+ console.log('It asks for the token without echoing it, checks it, and remembers this vault, so');
717
+ console.log('these need no arguments afterwards and git stops asking on a push:');
718
+ console.log('');
719
+ console.log(' mochi whoami');
720
+ console.log(" mochi user add alice --scope 'alice/*'");
721
+ console.log(` mochi import https://github.com/someone/something.git mine`);
722
+ console.log('');
723
+ console.log('Deploy an update, or change a setting, with the same command:');
724
+ console.log('');
725
+ console.log(` mochi deploy fly ${app}`);
726
+ console.log(` mochi deploy fly ${app} --volume 50 --vm-memory 1gb`);
727
+ }
728
+ async function deployShowCmd(args, usage) {
729
+ const a = parseDeployArgs(args, usage);
730
+ if (!a.app)
731
+ die('Which app? Usage: mochi deploy fly show <app>');
732
+ rejectFlyFlags(a, 'mochi deploy fly show <app>', false);
733
+ const app = a.app;
734
+ await requireFly();
735
+ if (!(await appExists(app))) {
736
+ die(`No Fly app named '${app}' that you can see. Check the name, or: fly apps list`);
737
+ }
738
+ const url = appUrl(app);
739
+ const vol = await vaultVolume(app);
740
+ const ms = await machines(app);
741
+ const secrets = await secretNames(app);
742
+ console.log(`${app} ${url}`);
743
+ console.log('');
744
+ if (ms.length === 0) {
745
+ console.log(' machines none, so nothing is serving this vault');
746
+ }
747
+ else {
748
+ // More than one machine is worth naming rather than summarizing: it means
749
+ // two volumes and two vaults, which is the failure --ha=false prevents.
750
+ if (ms.length > 1)
751
+ console.log(` machines ${ms.length}, which is one too many for a single-volume vault`);
752
+ for (const m of ms) {
753
+ const g = m.config?.guest;
754
+ const shape = g ? `${g.cpu_kind}-cpu-${g.cpus}x, ${g.memory_mb}mb` : 'unknown shape';
755
+ console.log(` machine ${m.id} ${m.state ?? '?'} ${m.region ?? '?'} ${shape}`);
756
+ console.log(` image ${m.config?.image ?? 'unknown'}`);
757
+ }
758
+ }
759
+ console.log(vol ? ` volume ${vol.size_gb}GB in ${vol.region} (${vol.state ?? 'created'})` : ' volume none');
760
+ console.log(` lfs ${secrets.includes('BUCKET_NAME') ? 'objects in a bucket (BUCKET_NAME is set)' : 'objects on the volume'}`);
761
+ // Whether it works, which is the question `fly status` cannot answer. A
762
+ // stored credential turns this into a report of who you are on it.
763
+ const target = (0, credentials_1.credentialTarget)(url);
764
+ const stored = await (0, credentials_1.readCredential)(target);
765
+ let vault = 'not reachable';
766
+ try {
767
+ const resp = await fetch(`${url}/api/whoami`, {
768
+ headers: stored ? { authorization: `Bearer ${stored.password}` } : {},
769
+ });
770
+ if (resp.ok) {
771
+ const data = (await resp.json());
772
+ vault = `answering, and you are '${String(data.username)}' on it`;
773
+ }
774
+ else if (resp.status === 401) {
775
+ vault = stored ? 'answering, but your stored token is not valid on it' : 'answering (no token stored here)';
776
+ }
777
+ else {
778
+ vault = `answering with HTTP ${resp.status}`;
779
+ }
780
+ }
781
+ catch (e) {
782
+ vault = `not reachable: ${e instanceof Error ? e.message : e}`;
783
+ }
784
+ console.log(` vault ${vault}`);
785
+ const saved = (0, credentials_1.loadLogin)();
786
+ if (saved && saved.host.replace(/\/+$/, '') === url)
787
+ console.log(' login this is the vault mochi commands use');
788
+ // Fly's own volume snapshots live at the same provider as the volume, so they
789
+ // are a complement to a backup on a disk of your own rather than a substitute
790
+ // for one. Whether this machine keeps such a copy is worth one line.
791
+ //
792
+ // Both names are offered, because a vault with a domain of its own was almost
793
+ // certainly backed up by that name rather than by <app>.fly.dev, and a report
794
+ // that said "none" in that case would be worse than no report at all.
795
+ const backup = (0, backup_cmd_1.backupLineFor)([url, ...(await certHostnames(app)).map((h) => `https://${h}`)]);
796
+ console.log(backup ? ` backup ${backup}` : ' backup none on this machine (mochi backup <dir>)');
797
+ console.log('');
798
+ console.log(` fly logs -a ${app}`);
799
+ }
800
+ function promptLine(prompt) {
801
+ return new Promise((resolve, reject) => {
802
+ if (!process.stdin.isTTY) {
803
+ reject(new Error('Nothing to ask on: not a terminal. Pass --yes to confirm.'));
804
+ return;
805
+ }
806
+ process.stdout.write(prompt);
807
+ process.stdin.resume();
808
+ process.stdin.setEncoding('utf8');
809
+ const onData = (chunk) => {
810
+ process.stdin.removeListener('data', onData);
811
+ process.stdin.pause();
812
+ resolve(chunk.trim());
813
+ };
814
+ process.stdin.on('data', onData);
815
+ });
816
+ }
817
+ async function deployDestroyCmd(args, usage) {
818
+ const a = parseDeployArgs(args, usage);
819
+ if (!a.app)
820
+ die('Which app? Usage: mochi deploy fly destroy <app> [--yes]');
821
+ rejectFlyFlags(a, 'mochi deploy fly destroy <app> [--yes]', true);
822
+ const app = a.app;
823
+ await requireFly();
824
+ if (!(await appExists(app))) {
825
+ die(`No Fly app named '${app}' that you can see. Check the name, or: fly apps list`);
826
+ }
827
+ const vol = await vaultVolume(app);
828
+ const hadBucket = (await secretNames(app)).includes('BUCKET_NAME');
829
+ if (!a.yes) {
830
+ console.log(`This destroys the Fly app '${app}' and its ${vol ? `${vol.size_gb}GB ` : ''}volume.`);
831
+ console.log('Everything in the vault goes with it: repositories, issues, pull requests, users.');
832
+ console.log('There is no undo, and Fly keeps no backup of a destroyed volume.');
833
+ console.log('');
834
+ const answer = await promptLine(`Type the app name to confirm: `);
835
+ if (answer !== app)
836
+ die('Not destroyed.');
837
+ }
838
+ const code = await flyStream(['apps', 'destroy', app, '--yes']);
839
+ if (code !== 0)
840
+ die('\nCould not destroy the app.');
841
+ // A credential for a vault that no longer exists is litter, and a saved
842
+ // login pointing at it would send the next command nowhere.
843
+ const url = appUrl(app);
844
+ const target = (0, credentials_1.credentialTarget)(url);
845
+ const stored = await (0, credentials_1.readCredential)(target);
846
+ if (stored) {
847
+ await (0, credentials_1.rejectCredential)(target, stored.username);
848
+ console.log(`Removed the stored credential for ${url}.`);
849
+ }
850
+ (0, credentials_1.clearLogin)(url);
851
+ if (hadBucket) {
852
+ console.log('');
853
+ console.log('The Tigris bucket that held this vault\'s LFS objects is a separate resource and');
854
+ console.log('was not destroyed. Remove it, and its contents, with:');
855
+ console.log('');
856
+ console.log(' fly storage list');
857
+ console.log(' fly storage destroy <name>');
858
+ }
859
+ }