@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
package/dist/index.js ADDED
@@ -0,0 +1,752 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const credentials_1 = require("./credentials");
40
+ const cli_api_1 = require("./cli-api");
41
+ const api_cmd_1 = require("./cli/api-cmd");
42
+ const issue_cmd_1 = require("./cli/issue-cmd");
43
+ const pr_cmd_1 = require("./cli/pr-cmd");
44
+ const admin_cmd_1 = require("./cli/admin-cmd");
45
+ const backup_cmd_1 = require("./cli/backup-cmd");
46
+ const release_cmd_1 = require("./cli/release-cmd");
47
+ const repo_cmd_1 = require("./cli/repo-cmd");
48
+ const run_cmd_1 = require("./cli/run-cmd");
49
+ const exit_1 = require("./cli/exit");
50
+ const input_1 = require("./cli/input");
51
+ const output_1 = require("./cli/output");
52
+ const parse_1 = require("./cli/parse");
53
+ const target_1 = require("./cli/target");
54
+ const import_cli_1 = require("./import-cli");
55
+ const deploy_cli_1 = require("./deploy-cli");
56
+ const deploy_runner_cli_1 = require("./deploy-runner-cli");
57
+ const job_cli_1 = require("./job-cli");
58
+ const runner_cli_1 = require("./runner-cli");
59
+ const config_1 = require("./config");
60
+ const scan_1 = require("./scan");
61
+ const themes_1 = require("./themes");
62
+ const vault_1 = require("./vault");
63
+ // The CLI's commands, as a registry rather than a chain of string comparisons
64
+ // with one help text covering all of them. See src/cli/parse.ts for why.
65
+ const FOOTER = `Configuration:
66
+ mochi login https://vault.example.com once, then the rest need no arguments
67
+
68
+ The vault URL is kept in ~/.config/mochi/login.json and the token in git's
69
+ own credential store. --host and --token override either for a single command,
70
+ and MOCHI_HOST and MOCHI_TOKEN sit between the two, for a caller with
71
+ no keyring and possibly no writable home directory.
72
+
73
+ Vault layout, where <repos> is <vault>/collections/<collection>/repos:
74
+ <repos>/<repo>.git bare repositories (the .git suffix is optional)
75
+ <repos>/<repo>.site optional static site for a repo
76
+ <repos>/<repo>.lfs Git LFS objects, when no bucket is configured
77
+ <repos>/<repo>.runs workflow run history and logs
78
+ <vault>/vault.json users and hashed tokens (server-managed)
79
+ <vault>/runners.json registered runners (server-managed)
80
+ <vault>/config.json vault settings: theme, sites host, CI retention, limits
81
+ <vault>/.secret session-cookie signing key (server-managed)
82
+
83
+ A vault laid out the older way, with collections directly in <vault>, is moved
84
+ to this one on the first start of a server that knows it.
85
+
86
+ Backing up a hosted vault:
87
+ mochi backup ~/backups/myvault --snapshot incremental, over HTTP; see docs/backup.md
88
+
89
+ Themes: ${(0, themes_1.themeNames)().join(', ')} (default ${themes_1.DEFAULT_THEME}). Pick one under
90
+ Admin > Appearance in the web interface, or write config.json by hand.`;
91
+ // ---- serve ----
92
+ async function serveCmd(args, usage) {
93
+ let dir = null;
94
+ let port = 3000;
95
+ let host = '127.0.0.1';
96
+ for (let i = 0; i < args.length; i++) {
97
+ const a = args[i];
98
+ if (a === '-h' || a === '--help')
99
+ usage();
100
+ else if (a === '-p' || a === '--port')
101
+ port = parseInt(args[++i], 10);
102
+ else if (a === '--host')
103
+ host = args[++i];
104
+ else if (a.startsWith('-'))
105
+ throw new exit_1.CliError(`Unknown option: ${a}`, exit_1.EXIT_USAGE);
106
+ else
107
+ dir = a;
108
+ }
109
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
110
+ throw new exit_1.CliError('Invalid port', exit_1.EXIT_USAGE);
111
+ const vault = path.resolve(dir ?? process.env.MOCHI_VAULT ?? '.');
112
+ if (!fs.existsSync(vault) || !fs.statSync(vault).isDirectory()) {
113
+ throw new exit_1.CliError(`Vault directory does not exist: ${vault}`);
114
+ }
115
+ // A vault with no vault.json is initialized on first start. The owner token
116
+ // is normally minted here and printed once; MOCHI_OWNER_TOKEN lets the
117
+ // operator supply it instead, which is how `mochi deploy` hands a remote
118
+ // vault a token it already holds. A supplied token is not printed: it is
119
+ // already where it needs to be, and a hosted server's log is not a good
120
+ // place to leave a copy.
121
+ const boot = (0, vault_1.bootstrapVault)(vault, process.env.MOCHI_OWNER_TOKEN ?? null);
122
+ // Set by `mochi deploy fly`, which knows there is a TLS proxy in front but
123
+ // cannot write to the volume before the vault exists. It only seeds the
124
+ // setting; config.json remains the place it lives and can be edited by hand.
125
+ const seeded = process.env.MOCHI_TRUST_PROXY === '1' ? (0, config_1.seedTrustProxy)(vault) : false;
126
+ // Imported here rather than at the top of the file: the server pulls in express
127
+ // and the whole rendering stack, which is most of what starting this process
128
+ // costs, and no other command needs any of it. A CLI a person or an agent runs
129
+ // in a loop should not pay for the server it is not starting.
130
+ const { createApp } = await Promise.resolve().then(() => __importStar(require('./server')));
131
+ const app = createApp(vault);
132
+ app.listen(port, host, () => {
133
+ const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
134
+ if (boot && boot.preset) {
135
+ console.log('');
136
+ console.log('Initialized a new vault (no vault.json found).');
137
+ console.log(`Owner '${boot.username}' was given the token from MOCHI_OWNER_TOKEN, so it is`);
138
+ console.log('not repeated here; only its hash is stored.');
139
+ console.log('');
140
+ }
141
+ else if (boot) {
142
+ console.log('');
143
+ console.log('Initialized a new vault (no vault.json found).');
144
+ console.log(`Owner token for user '${boot.username}' (shown once; only its hash is stored):`);
145
+ console.log('');
146
+ console.log(` ${boot.token}`);
147
+ console.log('');
148
+ console.log('Sign in on the web with it, or manage users from anywhere:');
149
+ console.log(` mochi login ${url}`);
150
+ console.log('');
151
+ }
152
+ if (seeded)
153
+ console.log('Recorded network.trustProxy: true in config.json (MOCHI_TRUST_PROXY is set).');
154
+ console.log(`Mochi Forge serving vault ${vault}`);
155
+ console.log(` ${url}`);
156
+ });
157
+ }
158
+ // ---- users ----
159
+ // Removed rather than renamed, and kept only to say so: glob scopes on users
160
+ // became roles held where they apply, so a --scope that silently became an
161
+ // unknown option would look like a typo rather than like a change of design.
162
+ const REMOVED_SCOPE_OPTIONS = [
163
+ { name: 'scope', type: 'string[]', hidden: true, summary: 'Removed: access is granted where it applies' },
164
+ { name: 'admin', type: 'string[]', hidden: true, summary: 'Removed: see --site-admin and collection owners' },
165
+ ];
166
+ function refuseScopeOptions(inv) {
167
+ if (inv.list('scope').length || inv.list('admin').length) {
168
+ throw new exit_1.CliError('--scope and --admin are gone: a user owns the collection named after them, and anything more is granted ' +
169
+ "where it applies. Use 'mochi collab add' for a repository, 'mochi collection owner add' for a " +
170
+ 'collection, or --site-admin for everything.', exit_1.EXIT_USAGE);
171
+ }
172
+ }
173
+ // Removed rather than renamed, and kept only to say so: a `--vault` that
174
+ // silently became an unknown option would look like a typo rather than like a
175
+ // change of design.
176
+ const VAULT_OPTION = {
177
+ name: 'vault',
178
+ type: 'string',
179
+ hidden: true,
180
+ summary: 'Removed: user commands talk to a running server',
181
+ };
182
+ function refuseVaultOption(inv) {
183
+ if (inv.str('vault') !== null) {
184
+ throw new exit_1.CliError('--vault is gone: user commands talk to a running server. Run `mochi login <url>` first.', exit_1.EXIT_USAGE);
185
+ }
186
+ }
187
+ function formatStanding(user) {
188
+ const name = user.username ?? user.name ?? '';
189
+ return user.siteAdmin ? 'site admin' : `owns collection '${name}' by name`;
190
+ }
191
+ async function userAddCmd(inv) {
192
+ refuseVaultOption(inv);
193
+ refuseScopeOptions(inv);
194
+ const username = inv.args[0];
195
+ if (!(0, scan_1.isValidUserName)(username)) {
196
+ throw new exit_1.CliError('A valid username is required (letters, digits, dot, underscore, dash, not starting with a dot)', exit_1.EXIT_USAGE);
197
+ }
198
+ const tokenScope = inv.list('token-scope');
199
+ const target = await (0, target_1.targetFrom)(inv);
200
+ const data = await (0, cli_api_1.api)(target, 'POST', '/api/users', {
201
+ username,
202
+ siteAdmin: inv.bool('site-admin') || undefined,
203
+ tokenScope: tokenScope.length ? tokenScope : undefined,
204
+ });
205
+ const json = (0, output_1.jsonMode)(inv);
206
+ if (json.enabled) {
207
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
208
+ return;
209
+ }
210
+ console.log(data.created
211
+ ? `Created user '${data.username}' on ${target.host}`
212
+ : `Minted a new token for existing user '${data.username}'`);
213
+ console.log(` ${formatStanding(data)}`);
214
+ if (tokenScope.length)
215
+ console.log(` this token is restricted to: ${tokenScope.join(', ')}`);
216
+ console.log('');
217
+ console.log('Token (copy it now; only its hash is stored):');
218
+ console.log(` ${data.token}`);
219
+ console.log('');
220
+ console.log(`Use it as the password with username '${data.username}' when git asks for credentials.`);
221
+ }
222
+ async function userGrantCmd(inv) {
223
+ refuseVaultOption(inv);
224
+ refuseScopeOptions(inv);
225
+ const username = inv.args[0];
226
+ const grant = inv.bool('site-admin');
227
+ const revoke = inv.bool('revoke-site-admin');
228
+ if (grant === revoke) {
229
+ throw new exit_1.CliError(`Pass exactly one of --site-admin or --revoke-site-admin. Repository and collection access is granted with ` +
230
+ `'mochi collab add' and 'mochi collection owner add'.`, exit_1.EXIT_USAGE);
231
+ }
232
+ const target = await (0, target_1.targetFrom)(inv);
233
+ const data = await (0, cli_api_1.api)(target, 'POST', `/api/users/${encodeURIComponent(username)}/grant`, {
234
+ siteAdmin: grant,
235
+ });
236
+ const json = (0, output_1.jsonMode)(inv);
237
+ if (json.enabled) {
238
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
239
+ return;
240
+ }
241
+ console.log(`${data.username}: ${data.siteAdmin ? 'now a site admin' : 'no longer a site admin'}`);
242
+ }
243
+ async function userListCmd(inv) {
244
+ refuseVaultOption(inv);
245
+ const target = await (0, target_1.targetFrom)(inv);
246
+ const data = await (0, cli_api_1.api)(target, 'GET', '/api/users');
247
+ const users = (data.users ?? []);
248
+ const json = (0, output_1.jsonMode)(inv);
249
+ if (json.enabled) {
250
+ (0, output_1.printJson)({ users: (0, output_1.pickFields)(users, json.fields) });
251
+ return;
252
+ }
253
+ if (users.length === 0) {
254
+ console.log(`No users on ${target.host}`);
255
+ return;
256
+ }
257
+ const width = Math.max(...users.map((u) => u.name.length));
258
+ for (const u of users) {
259
+ const tokens = `${u.tokens} token${u.tokens === 1 ? '' : 's'}`;
260
+ console.log(`${u.name.padEnd(width)} ${tokens.padEnd(9)} ${u.siteAdmin ? 'site admin' : ''}`.trimEnd());
261
+ }
262
+ }
263
+ async function whoamiCmd(inv) {
264
+ const target = await (0, target_1.targetFrom)(inv);
265
+ const data = await (0, cli_api_1.api)(target, 'GET', '/api/whoami');
266
+ const json = (0, output_1.jsonMode)(inv);
267
+ if (json.enabled) {
268
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
269
+ return;
270
+ }
271
+ console.log(`${data.username} @ ${target.host}`);
272
+ console.log(` ${formatStanding(data)}`);
273
+ const owned = (data.ownedCollections ?? []);
274
+ if (owned.length)
275
+ console.log(` collections: ${owned.join(', ')}`);
276
+ if (data.tokenScope)
277
+ console.log(` this token is restricted to: ${data.tokenScope.join(', ')}`);
278
+ }
279
+ // ---- login and logout ----
280
+ // The vault being logged in to or out of: the URL given, the environment, or
281
+ // the one logged in to last, which is what makes `mochi logout` need no
282
+ // arguments.
283
+ function loginTarget(host) {
284
+ const resolved = (host ?? process.env.MOCHI_HOST ?? (0, credentials_1.loadLogin)()?.host ?? '').replace(/\/+$/, '');
285
+ if (!resolved)
286
+ throw new exit_1.CliError('Which vault? Give its URL, e.g. https://vault.example.com', exit_1.EXIT_USAGE);
287
+ try {
288
+ return { host: resolved, target: (0, credentials_1.credentialTarget)(resolved) };
289
+ }
290
+ catch (e) {
291
+ throw new exit_1.CliError(e instanceof Error ? e.message : String(e), exit_1.EXIT_USAGE);
292
+ }
293
+ }
294
+ // A token is a credential and a terminal keeps scrollback, so it is read
295
+ // without echo. Passing --token instead would leave it in shell history, and
296
+ // --token-stdin hands one over with no terminal at all.
297
+ // Raw mode rather than readline: readline redraws its line through cursor
298
+ // control that bypasses any echo suppression, which erases the prompt.
299
+ function promptToken(prompt) {
300
+ return new Promise((resolve, reject) => {
301
+ const input = process.stdin;
302
+ if (!input.isTTY) {
303
+ reject(new Error('No token given and no terminal to ask on. Pass --token <t> or --token-stdin.'));
304
+ return;
305
+ }
306
+ process.stdout.write(prompt);
307
+ input.setRawMode(true);
308
+ input.resume();
309
+ input.setEncoding('utf8');
310
+ let value = '';
311
+ const finish = (err) => {
312
+ input.removeListener('data', onData);
313
+ input.setRawMode(false);
314
+ input.pause();
315
+ process.stdout.write('\n');
316
+ if (err)
317
+ reject(err);
318
+ else
319
+ resolve(value.trim());
320
+ };
321
+ // Raw mode delivers ^C as a byte rather than as SIGINT, so cancelling has
322
+ // to be handled here or it would be pasted into the token.
323
+ const onData = (chunk) => {
324
+ for (const ch of chunk) {
325
+ if (ch === '\r' || ch === '\n' || ch === '\u0004')
326
+ return finish(null);
327
+ if (ch === '\u0003')
328
+ return finish(new Error('Cancelled.'));
329
+ if (ch === '\u007f' || ch === '\b')
330
+ value = value.slice(0, -1);
331
+ else if (ch >= ' ')
332
+ value += ch;
333
+ }
334
+ };
335
+ input.on('data', onData);
336
+ });
337
+ }
338
+ // login is the one command that reads a token without contacting a vault
339
+ // first, so it resolves --token and --token-stdin itself rather than through
340
+ // targetFrom.
341
+ async function tokenFor(inv) {
342
+ const flag = inv.str('token');
343
+ if (inv.bool('token-stdin')) {
344
+ if (flag)
345
+ throw new exit_1.CliError('Pass either --token or --token-stdin, not both.', exit_1.EXIT_USAGE);
346
+ const value = (await (0, input_1.readStdin)()).trim();
347
+ // Empty stdin is 3 and not 2: the invocation was well formed, and what is
348
+ // missing is the token, which is the case exit code 3 is documented to
349
+ // cover. A pipeline whose token source came up empty gets the same code it
350
+ // would get for having supplied no token at all.
351
+ if (!value)
352
+ throw new exit_1.CliError('--token-stdin was given but stdin was empty.', exit_1.EXIT_AUTH);
353
+ return value;
354
+ }
355
+ return flag ?? process.env.MOCHI_TOKEN?.trim() ?? null;
356
+ }
357
+ async function loginCmd(inv) {
358
+ const { host, target } = loginTarget(inv.args[0] ?? inv.str('host'));
359
+ // Settle where the token would go before asking for one: being prompted for
360
+ // a token and only then told there is nowhere to put it is the wrong order.
361
+ const chosen = inv.str('helper');
362
+ if (chosen)
363
+ await (0, credentials_1.setHelper)(target.url, chosen);
364
+ const helper = await (0, credentials_1.configuredHelper)(target.url);
365
+ if (!helper) {
366
+ console.error(`No credential helper is configured for ${target.url}, so git has nowhere to keep a token.`);
367
+ console.error('Storing one would silently do nothing, so this is refused rather than reported as success.');
368
+ console.error('');
369
+ console.error('Choose where the token should live and run login again:');
370
+ console.error(' mochi login --helper store a file at ~/.git-credentials, mode 0600, in plain text');
371
+ console.error(' mochi login --helper cache memory only, forgotten after 15 minutes');
372
+ console.error(' mochi login --helper libsecret the desktop keyring, on Linux');
373
+ console.error(' mochi login --helper osxkeychain the login keychain, on macOS');
374
+ console.error('');
375
+ console.error(`The choice is recorded for ${target.url} alone; other remotes keep whatever they use now.`);
376
+ process.exit(exit_1.EXIT_FAIL);
377
+ }
378
+ const given = await tokenFor(inv);
379
+ const token = given ?? (await promptToken(`Token for ${target.url}: `));
380
+ if (!token)
381
+ throw new exit_1.CliError('No token given.', exit_1.EXIT_USAGE);
382
+ // Verified before it is stored. A token that does not work is worse stored
383
+ // than absent: git would then fail with it instead of asking for a better one.
384
+ const who = await (0, cli_api_1.api)({ host, token }, 'GET', '/api/whoami');
385
+ const username = String(who.username ?? '');
386
+ if (!username)
387
+ throw new exit_1.CliError(`${host} did not say who this token belongs to.`);
388
+ await (0, credentials_1.approveCredential)(target, username, token);
389
+ // Read back rather than trusting the exit code: approve succeeds whether or
390
+ // not the helper kept anything, and a helper that is configured but not
391
+ // installed fails only here.
392
+ const stored = await (0, credentials_1.readCredential)(target);
393
+ if (!stored || stored.username !== username || stored.password !== token) {
394
+ console.error(`The credential helper '${helper}' did not keep the token for ${target.url}.`);
395
+ console.error(`Check that git credential-${helper} is installed and working.`);
396
+ process.exit(exit_1.EXIT_FAIL);
397
+ }
398
+ // Recorded only now: a login that could not keep its token is not a login,
399
+ // and pointing later commands at a vault they cannot reach would be worse
400
+ // than pointing them nowhere.
401
+ (0, credentials_1.saveLogin)(host);
402
+ console.log(`Stored the token for '${username}' at ${target.url} (helper: ${helper}).`);
403
+ console.log(` ${formatStanding(who)}`);
404
+ if (who.tokenScope)
405
+ console.log(` this token is restricted to: ${who.tokenScope.join(', ')}`);
406
+ console.log('');
407
+ console.log('git clone, fetch, push, and git lfs against this vault will no longer ask for a password,');
408
+ console.log(`and mochi commands talk to it by default (${(0, credentials_1.loginPath)()}).`);
409
+ console.log('Run `mochi logout` to remove it again.');
410
+ }
411
+ async function logoutCmd(inv) {
412
+ if (inv.str('token') || inv.str('helper')) {
413
+ throw new exit_1.CliError('logout takes only --host: it removes a stored credential rather than making one.', exit_1.EXIT_USAGE);
414
+ }
415
+ const { host, target } = loginTarget(inv.args[0] ?? inv.str('host'));
416
+ const stored = await (0, credentials_1.readCredential)(target);
417
+ if (!stored) {
418
+ (0, credentials_1.clearLogin)(host);
419
+ console.log(`No stored credential for ${target.url}.`);
420
+ return;
421
+ }
422
+ await (0, credentials_1.rejectCredential)(target, stored.username);
423
+ const after = await (0, credentials_1.readCredential)(target);
424
+ if (after) {
425
+ throw new exit_1.CliError(`The credential for '${after.username}' at ${target.url} is still there: the helper did not erase it.`);
426
+ }
427
+ (0, credentials_1.clearLogin)(host);
428
+ console.log(`Removed the stored credential for '${stored.username}' at ${target.url}.`);
429
+ }
430
+ // ---- the registry ----
431
+ /** A command whose own argument handling is left alone; it is dispatched and documented here all the same. */
432
+ function raw(path, summary, description, run) {
433
+ return {
434
+ path,
435
+ summary,
436
+ description: description || undefined,
437
+ raw: true,
438
+ run: (inv) => run(inv.argv, () => inv.help()),
439
+ };
440
+ }
441
+ const commands = [
442
+ raw(['serve'], 'Serve a vault over HTTP', `Serve a vault: a directory of collections containing bare git repositories.
443
+ The vault defaults to $MOCHI_VAULT, then the current directory. On the
444
+ first start with no vault.json, the server initializes one and prints an owner
445
+ token once.
446
+
447
+ Options:
448
+ -p, --port <n> port to listen on (default 3000)
449
+ --host <h> address to bind (default 127.0.0.1)`, serveCmd),
450
+ raw(['import'], 'Bring an existing repository into the vault', `Usage: mochi import <source> <collection>[/<name>] [--lfs]
451
+
452
+ Clone the source into a temporary directory, push it here, which creates it,
453
+ and remove the clone again. The source is an https or ssh git URL, owner/repo
454
+ for GitHub, or a directory on this machine; the name defaults to its last
455
+ segment. Nothing happens on the server, so the source is read with whatever git
456
+ credentials this machine already has. Branches and tags come across; --lfs
457
+ carries Git LFS objects too, and needs git-lfs installed.
458
+
459
+ A description is not part of a repository's git data. For a public GitHub
460
+ source it is read from GitHub's API afterwards and set here.
461
+
462
+ Options:
463
+ --lfs carry Git LFS objects too
464
+ --description <text> set this description instead of the source's
465
+ --no-description leave the description empty`, import_cli_1.importCmd),
466
+ raw(['collection', 'add'], 'Create an empty collection', `Pushing to a new path creates its collection on the way, so this is for the
467
+ other order: making the collection first and filling it afterwards.`, import_cli_1.collectionAddCmd),
468
+ raw(['collection', 'list'], "Show the vault's collections and how many repositories each holds", '', import_cli_1.collectionListCmd),
469
+ {
470
+ path: ['collection', 'owner', 'add'],
471
+ summary: 'Make a user an owner of a collection',
472
+ description: `Owners hold the admin role on every repository in the collection, may create
473
+ repositories in it, and manage the collection itself. The user the collection
474
+ is named after owns it by name and needs no entry.`,
475
+ args: [
476
+ { name: 'collection', required: true },
477
+ { name: 'username', required: true },
478
+ ],
479
+ options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
480
+ async run(inv) {
481
+ const target = await (0, target_1.targetFrom)(inv);
482
+ const data = await (0, cli_api_1.api)(target, 'PUT', `/api/collections/${encodeURIComponent(inv.args[0])}/owners/${encodeURIComponent(inv.args[1])}`);
483
+ const json = (0, output_1.jsonMode)(inv);
484
+ if (json.enabled) {
485
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
486
+ return;
487
+ }
488
+ console.log(`Owners of ${data.name}: ${(data.owners ?? []).join(', ') || '(none listed)'}`);
489
+ },
490
+ },
491
+ {
492
+ path: ['collection', 'owner', 'remove'],
493
+ summary: 'Remove a user from the owners of a collection',
494
+ args: [
495
+ { name: 'collection', required: true },
496
+ { name: 'username', required: true },
497
+ ],
498
+ options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
499
+ async run(inv) {
500
+ const target = await (0, target_1.targetFrom)(inv);
501
+ const data = await (0, cli_api_1.api)(target, 'DELETE', `/api/collections/${encodeURIComponent(inv.args[0])}/owners/${encodeURIComponent(inv.args[1])}`);
502
+ const json = (0, output_1.jsonMode)(inv);
503
+ if (json.enabled) {
504
+ (0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
505
+ return;
506
+ }
507
+ console.log(`Owners of ${data.name}: ${(data.owners ?? []).join(', ') || '(none listed)'}`);
508
+ },
509
+ },
510
+ {
511
+ path: ['user', 'add'],
512
+ summary: 'Create a user and print its token once',
513
+ description: `A user owns the collection named after them, the way a GitHub account owns its
514
+ namespace: they create repositories there and administer them. Anything more is
515
+ granted where it applies ('mochi collab add' on a repository, 'mochi
516
+ collection owner add' on a collection) or with --site-admin. Run again on an
517
+ existing user to mint an additional token. Only a SHA-256 hash of a token is
518
+ ever stored, so the token is shown once and cannot be recovered afterwards.`,
519
+ args: [{ name: 'username', required: true }],
520
+ options: [
521
+ { name: 'site-admin', type: 'boolean', summary: 'Admin role everywhere, plus users, runners, and settings' },
522
+ { name: 'token-scope', type: 'string[]', value: '<glob>', summary: 'Restrict this token alone to these globs' },
523
+ ...REMOVED_SCOPE_OPTIONS,
524
+ VAULT_OPTION,
525
+ output_1.JSON_OPTION,
526
+ ...target_1.TARGET_OPTIONS,
527
+ ],
528
+ run: userAddCmd,
529
+ },
530
+ {
531
+ path: ['user', 'grant'],
532
+ summary: 'Grant or withdraw the site-admin bit',
533
+ description: `Per-repository access is granted on the repository ('mochi collab add') and
534
+ per-collection access on the collection ('mochi collection owner add');
535
+ this command carries only the one bit that is the vault's own.`,
536
+ args: [{ name: 'username', required: true }],
537
+ options: [
538
+ { name: 'site-admin', type: 'boolean', summary: 'Make this user a site admin' },
539
+ { name: 'revoke-site-admin', type: 'boolean', summary: 'Withdraw the site-admin bit' },
540
+ ...REMOVED_SCOPE_OPTIONS,
541
+ VAULT_OPTION,
542
+ output_1.JSON_OPTION,
543
+ ...target_1.TARGET_OPTIONS,
544
+ ],
545
+ run: userGrantCmd,
546
+ },
547
+ {
548
+ path: ['user', 'list'],
549
+ summary: 'Show users, who is a site admin, and how many tokens each has',
550
+ options: [VAULT_OPTION, output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
551
+ run: userListCmd,
552
+ },
553
+ {
554
+ path: ['whoami'],
555
+ summary: 'Show the user, their standing, and the token restriction for the current token',
556
+ options: [output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
557
+ run: whoamiCmd,
558
+ },
559
+ {
560
+ path: ['login'],
561
+ summary: 'Log in to a vault and hand the token to git',
562
+ description: `Ask for a token, check it, and hand it to git's credential store, so that clone,
563
+ fetch, push, git lfs, and every other mochi command stop asking for it. The
564
+ vault URL is remembered, so later commands need no arguments. The token is read
565
+ back after storing to confirm it was really kept.
566
+
567
+ --helper picks where it lives (store, cache, libsecret, osxkeychain) and is
568
+ recorded for this vault's host alone; without it, whatever git is already
569
+ configured to use for that host is used, and login refuses rather than storing
570
+ nothing when that is nothing.`,
571
+ args: [{ name: 'vault-url' }],
572
+ options: [
573
+ {
574
+ name: 'helper',
575
+ type: 'string',
576
+ value: '<name>',
577
+ summary: 'Where the token lives: store, cache, libsecret, osxkeychain',
578
+ },
579
+ ...target_1.TARGET_OPTIONS,
580
+ ],
581
+ run: loginCmd,
582
+ },
583
+ {
584
+ path: ['logout'],
585
+ summary: "Remove this vault's stored credential and forget the vault",
586
+ args: [{ name: 'vault-url' }],
587
+ options: [
588
+ { name: 'helper', type: 'string', value: '<name>', hidden: true, summary: 'Not accepted by logout' },
589
+ ...target_1.TARGET_OPTIONS,
590
+ ],
591
+ run: logoutCmd,
592
+ },
593
+ raw(['deploy', 'fly'], 'Put a vault on Fly.io, or deploy an update to one', `Usage: mochi deploy fly <app> [--region <r>] [--volume <gb>] [--vm-size <s>]
594
+ [--vm-memory <m>] [--lfs-bucket] [--org <o>]
595
+ [--image <ref> | --from-source [--local-build]]
596
+
597
+ Needs flyctl installed, and fly auth login done. The app name is globally
598
+ unique on Fly and becomes the URL, https://<app>.fly.dev. Creating one mints
599
+ the owner token here and hands it to the server as a secret, then prints it once
600
+ the vault answers, with how to sign in on the web and how to store it for the
601
+ CLI and git. Nothing is kept on this machine: mochi login with that token is
602
+ what does that. Run it again to deploy a new version; settings not named by a
603
+ flag keep whatever the live app has, so a single flag changes a single thing. A
604
+ vault is a directory on one volume, so the app runs as exactly one machine: a
605
+ busier vault wants a bigger one, not more.
606
+
607
+ By default the image deployed is the published one for this CLI's own version.
608
+ --from-source builds it from the checkout you are running instead, which is how
609
+ to deploy a change before it has been released; --local-build uses this machine's
610
+ Docker rather than Fly's builder. --image <ref> deploys some other published tag.
611
+
612
+ See also: mochi deploy fly show <app>, mochi deploy fly destroy <app>.
613
+ `, deploy_cli_1.deployFlyCmd),
614
+ 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>]
616
+ [--vm-size <s>] [--vm-memory <m>] [--org <o>]
617
+ [--image <ref> | --from-source [--local-build]]
618
+
619
+ Needs flyctl, and a login to the vault this runner will serve. Registers the
620
+ runner (named after the app unless --name says otherwise), creates the app and a
621
+ volume for the images jobs run in, hands the machine the vault URL and its token
622
+ as Fly secrets, and tells the vault where to send a wake request.
623
+
624
+ The machine stops when no job has arrived for --idle, and the vault starts it
625
+ again when one is queued, so a stopped machine is the resting state rather than
626
+ a fault. The first job after a stop waits about half a minute for the boot. What
627
+ it costs while stopped is the volume alone.
628
+
629
+ --allow is required the first time and says which repositories this runner may
630
+ take jobs for; it executes whatever their workflows contain, on this machine.
631
+ Run the same command again to deploy a new version.
632
+
633
+ See also: mochi deploy fly runner show <app>, destroy <app>, mochi runner list.
634
+ `, deploy_runner_cli_1.deployFlyRunnerCmd),
635
+ raw(['deploy', 'fly', 'runner', 'show'], 'What Fly has for this runner app, and which runner it serves', '', deploy_runner_cli_1.deployFlyRunnerShowCmd),
636
+ 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
+ raw(['deploy', 'fly', 'show'], 'What Fly has for this app, and whether the vault answers', '', deploy_cli_1.deployShowCmd),
638
+ 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]
640
+
641
+ Prints its token once. --allow says which repositories it may take jobs for, as
642
+ 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),
644
+ 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
+
646
+ Reads ~/.config/mochi/runner.json when given no arguments. Needs a working
647
+ docker or podman command; --engine picks one when both are present (asked
648
+ interactively otherwise), and --image <label>=<image> overrides which image a
649
+ runs-on label maps to. Actions named by uses: are fetched from github.com (--actions-url
650
+ changes that) and cached under ~/.cache/mochi (--cache-dir changes that),
651
+ keyed by the commit the ref resolves to, so a moved branch or tag is picked up
652
+ on the next run; --no-action-cache downloads every time. --work-dir sets where
653
+ job workspaces are made, --network which Docker network the container joins,
654
+ and MOCHI_RUNNER_TOKEN supplies the token instead of --runner-token.
655
+
656
+ --idle <5m> stops the runner when no job has arrived for that long, which is
657
+ what makes a runner that costs money while it is up affordable: it exits, and
658
+ whatever hosts it stops. Something then has to start it again, so --wake-port
659
+ listens for the vault's wake request and --wake-secret (or MOCHI_WAKE_SECRET)
660
+ is what that request must present. See mochi runner wake.`, runner_cli_1.runnerRunCmd),
661
+ runner_cli_1.runnerListCommand,
662
+ raw(['runner', 'wake'], 'Start a runner that stops when idle, or say where to reach it', `Usage: mochi runner wake <name> [--url <url> [--wake-secret <s>]] [--clear]
663
+
664
+ With no flags this sends the wake request now and reports how long the runner
665
+ took to answer, which is the way to test one without queuing a job. --url says
666
+ where to send it, generating the secret unless --wake-secret gives one; --clear
667
+ removes the address, after which nothing starts this runner.
668
+
669
+ The vault sends this by itself whenever a job is queued that a runner could take
670
+ and that runner has not been heard from, at most once a minute per runner
671
+ however many jobs are waiting.`, runner_cli_1.runnerWakeCmd),
672
+ raw(['runner', 'remove'], 'Remove a registered runner', '', runner_cli_1.runnerRemoveCmd),
673
+ raw(['job', 'run'], "Run a workflow run's manual jobs here, from a command minted on its run page", `Usage: mochi job run <vault-url> <token> [--job <pattern>] [--yes]
674
+ [--engine docker|podman] [--image <label>=<image>]
675
+
676
+ The run page of a run with 'runs-on: manual' jobs mints this command, token and
677
+ all (also: mochi run exec-command <n>). The token must be pasted within fifteen
678
+ minutes and works once: redeeming it starts a session that lives until the run
679
+ finishes, and a copy left in scrollback buys nothing afterwards.
680
+
681
+ Each job is shown step by step and nothing executes until you agree; --yes skips
682
+ the asking, and is required when there is no terminal to ask on. --job limits the
683
+ session to jobs matching a glob over the job key. Jobs execute in containers
684
+ exactly as on a registered runner: --engine picks docker or podman when both are
685
+ present (asked interactively otherwise), and --image, --work-dir, --network,
686
+ --cache-dir, --actions-url, --no-action-cache mean what they mean for
687
+ mochi runner run.
688
+
689
+ Exits 0 when everything it ran succeeded, 1 when something failed. Ctrl-C
690
+ finishes the job in hand and stops; a second Ctrl-C quits now, and the vault
691
+ fails the abandoned job when its lease expires.`, job_cli_1.jobRunCmd),
692
+ ...repo_cmd_1.repoCommands,
693
+ ...issue_cmd_1.issueCommands,
694
+ ...pr_cmd_1.prCommands,
695
+ ...run_cmd_1.runCommands,
696
+ ...release_cmd_1.releaseCommands,
697
+ ...admin_cmd_1.adminCommands,
698
+ ...backup_cmd_1.backupCommands,
699
+ api_cmd_1.apiCommand,
700
+ {
701
+ path: ['commands'],
702
+ summary: 'List every command, its arguments, and its options',
703
+ description: `With --json this is the whole registry as data, which is enough to discover the
704
+ command set without reading any documentation.`,
705
+ options: [output_1.JSON_OPTION],
706
+ run: (inv) => {
707
+ const json = (0, output_1.jsonMode)(inv);
708
+ if (json.enabled) {
709
+ (0, output_1.printJson)((0, parse_1.registryJson)(cli));
710
+ return;
711
+ }
712
+ for (const c of cli.commands)
713
+ console.log(`${c.path.join(' ').padEnd(24)} ${c.summary}`);
714
+ },
715
+ },
716
+ ];
717
+ const cli = {
718
+ name: 'mochi',
719
+ groups: [
720
+ { name: 'repo', summary: 'Repositories: what the vault holds' },
721
+ { name: 'branch', summary: 'Branches' },
722
+ { name: 'tag', summary: 'Tags' },
723
+ { name: 'file', summary: 'Files in a repository, at a ref' },
724
+ { name: 'commit', summary: 'Commits and their patches' },
725
+ { name: 'issue', summary: 'Issues' },
726
+ { name: 'pr', summary: 'Pull requests' },
727
+ { name: 'workflow', summary: 'Workflow files, and dispatching them by hand' },
728
+ { name: 'run', summary: 'Workflow runs, their logs, and their artifacts' },
729
+ { name: 'release', summary: 'Release notes attached to a tag' },
730
+ { name: 'config', summary: "The vault's own settings" },
731
+ { name: 'collection', summary: 'Collections: the directories a vault holds repositories in' },
732
+ { name: 'user', summary: 'Users, their scopes, and their tokens' },
733
+ { name: 'deploy', summary: 'Put a vault on Fly.io and manage it there' },
734
+ { name: 'runner', summary: 'Machines that execute workflow jobs' },
735
+ { name: 'job', summary: 'Run manual workflow jobs from a pasted command' },
736
+ ],
737
+ commands,
738
+ footer: FOOTER,
739
+ };
740
+ async function main() {
741
+ await (0, parse_1.dispatch)(cli, process.argv.slice(2));
742
+ }
743
+ main().catch((e) => {
744
+ const message = e instanceof Error ? e.message : String(e);
745
+ // A caller that asked for JSON gets JSON on failure too, so that parsing
746
+ // stderr is possible rather than nearly possible.
747
+ if ((0, exit_1.jsonErrorsWanted)())
748
+ process.stderr.write(JSON.stringify({ error: message }) + '\n');
749
+ else
750
+ console.error(message);
751
+ process.exit(e instanceof exit_1.CliError ? e.code : exit_1.EXIT_FAIL);
752
+ });