@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,199 @@
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.MINT_TTL_MS = exports.MANUAL_LABEL = void 0;
37
+ exports.isManualJob = isManualJob;
38
+ exports.sessionRunnerName = sessionRunnerName;
39
+ exports.isManualRunnerName = isManualRunnerName;
40
+ exports.loadGrants = loadGrants;
41
+ exports.mintGrant = mintGrant;
42
+ exports.findMintable = findMintable;
43
+ exports.redeemGrant = redeemGrant;
44
+ exports.findSession = findSession;
45
+ exports.looksLikeSessionToken = looksLikeSessionToken;
46
+ exports.looksLikeMintToken = looksLikeMintToken;
47
+ const crypto = __importStar(require("crypto"));
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ const atomic_1 = require("../atomic");
51
+ const runs_1 = require("./runs");
52
+ // Manual jobs: a job whose runs-on names the reserved label `manual` is never
53
+ // handed to a registered runner. It runs when someone with the write role
54
+ // mints a command from the run page and pastes it on a machine of their
55
+ // choosing, which is the arrangement for a machine that should execute
56
+ // repository code only while a person is watching it do so.
57
+ //
58
+ // The pasted command carries a *mint token*: single-use, scoped to one run,
59
+ // and dead fifteen minutes after minting if nobody redeems it. Redeeming it
60
+ // returns a *session token* that lives only in the redeeming process and
61
+ // works until the run completes. The split is the point: a command lands in
62
+ // shell history and terminal scrollback, and a copy found there later must
63
+ // buy nothing. Only hashes of either token are stored, as with every other
64
+ // credential in a vault.
65
+ //
66
+ // Grants live in <run dir>/manual.json, beside run.json rather than inside
67
+ // it, so that the run records served by the API and rendered by the UI never
68
+ // carry them. They are pruned with the run.
69
+ exports.MANUAL_LABEL = 'manual';
70
+ /** Whether a job (by its resolved runs-on labels) runs only by pasted command. */
71
+ function isManualJob(runsOn) {
72
+ return runsOn.includes(exports.MANUAL_LABEL);
73
+ }
74
+ /** How long a minted command waits to be pasted before it dies. */
75
+ exports.MINT_TTL_MS = 15 * 60 * 1000;
76
+ const MINT_PREFIX = 'mochi_run_';
77
+ const SESSION_PREFIX = 'mochi_manual_';
78
+ /** The runner name a session's leases are held under. */
79
+ function sessionRunnerName(grant) {
80
+ return `manual:${grant.id}`;
81
+ }
82
+ function isManualRunnerName(name) {
83
+ return name.startsWith('manual:');
84
+ }
85
+ function grantsPath(root, collection, repo, n) {
86
+ const base = (0, runs_1.runsDir)(root, collection, repo);
87
+ return base ? path.join(base, String(n), 'manual.json') : null;
88
+ }
89
+ function loadGrants(root, collection, repo, n) {
90
+ const file = grantsPath(root, collection, repo, n);
91
+ if (!file)
92
+ return [];
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ if (!Array.isArray(parsed))
101
+ return [];
102
+ return parsed.filter((g) => typeof g === 'object' &&
103
+ g !== null &&
104
+ typeof g.id === 'string' &&
105
+ typeof g.hash === 'string');
106
+ }
107
+ function saveGrants(root, collection, repo, n, grants) {
108
+ const file = grantsPath(root, collection, repo, n);
109
+ if (!file)
110
+ throw new Error('invalid repository');
111
+ (0, atomic_1.writeFileAtomic)(file, JSON.stringify(grants, null, 1) + '\n', { mode: 0o600 });
112
+ }
113
+ function hashToken(token) {
114
+ return crypto.createHash('sha256').update(token).digest('hex');
115
+ }
116
+ function sameHash(a, b) {
117
+ let x;
118
+ let y;
119
+ try {
120
+ x = Buffer.from(a, 'hex');
121
+ y = Buffer.from(b, 'hex');
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
127
+ }
128
+ /**
129
+ * Mint a grant for a run and return the token, which is shown once and only
130
+ * its hash kept. Expired grants that were never redeemed are dropped on the
131
+ * way through: they are dead weight, and pruning them here means the file
132
+ * never accumulates the residue of commands nobody pasted.
133
+ */
134
+ function mintGrant(root, collection, repo, n, mintedBy) {
135
+ const now = Date.now();
136
+ const grants = loadGrants(root, collection, repo, n).filter((g) => g.redeemedAt !== undefined || Date.parse(g.expiresAt) > now);
137
+ const token = MINT_PREFIX + crypto.randomBytes(24).toString('hex');
138
+ const grant = {
139
+ id: crypto.randomBytes(4).toString('hex'),
140
+ hash: hashToken(token),
141
+ mintedBy,
142
+ mintedAt: new Date(now).toISOString(),
143
+ expiresAt: new Date(now + exports.MINT_TTL_MS).toISOString(),
144
+ };
145
+ grants.push(grant);
146
+ saveGrants(root, collection, repo, n, grants);
147
+ return { token, grant };
148
+ }
149
+ /** The grant a presented mint token names, if it is unexpired and unredeemed. */
150
+ function findMintable(grants, token) {
151
+ if (!token.startsWith(MINT_PREFIX))
152
+ return null;
153
+ const presented = hashToken(token);
154
+ for (const g of grants) {
155
+ if (g.redeemedAt !== undefined)
156
+ continue;
157
+ if (Date.parse(g.expiresAt) <= Date.now())
158
+ continue;
159
+ if (sameHash(g.hash, presented))
160
+ return g;
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Redeem a grant: mark it redeemed, record the reported host, and mint the
166
+ * session token whose hash it will answer to from now on. The mint token is
167
+ * spent by this; presenting it again finds nothing.
168
+ */
169
+ function redeemGrant(root, collection, repo, n, grantId, host) {
170
+ const grants = loadGrants(root, collection, repo, n);
171
+ const grant = grants.find((g) => g.id === grantId);
172
+ if (!grant || grant.redeemedAt !== undefined)
173
+ return null;
174
+ const session = SESSION_PREFIX + crypto.randomBytes(32).toString('hex');
175
+ grant.sessionHash = hashToken(session);
176
+ grant.redeemedAt = new Date().toISOString();
177
+ grant.host = host.slice(0, 200);
178
+ saveGrants(root, collection, repo, n, grants);
179
+ return session;
180
+ }
181
+ /** The redeemed grant a presented session token belongs to, or null. */
182
+ function findSession(grants, token) {
183
+ if (!token.startsWith(SESSION_PREFIX))
184
+ return null;
185
+ const presented = hashToken(token);
186
+ for (const g of grants) {
187
+ if (g.sessionHash === undefined)
188
+ continue;
189
+ if (sameHash(g.sessionHash, presented))
190
+ return g;
191
+ }
192
+ return null;
193
+ }
194
+ function looksLikeSessionToken(token) {
195
+ return token.startsWith(SESSION_PREFIX);
196
+ }
197
+ function looksLikeMintToken(token) {
198
+ return token.startsWith(MINT_PREFIX);
199
+ }
@@ -0,0 +1,82 @@
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.hasCiState = hasCiState;
37
+ const fs = __importStar(require("fs"));
38
+ const runs_1 = require("./runs");
39
+ // Whether to show a repository's Actions tab. Two cheap questions: does the
40
+ // repository have any run history on disk, and does its default branch carry
41
+ // a workflow file? The second needs git, so the answer is memoized against
42
+ // the branch tip: one ls-tree per repository per new commit, not one per page
43
+ // view. A vault with no CI in it never grows the tab.
44
+ const memo = new Map();
45
+ async function hasWorkflowsAt(repo, sha) {
46
+ const cached = memo.get(repo.dir);
47
+ if (cached && cached.sha === sha)
48
+ return cached.has;
49
+ let has = false;
50
+ for (const dir of ['.github/workflows', '.mochi/workflows']) {
51
+ try {
52
+ const entries = await repo.listTree(sha, dir);
53
+ if (entries.some((e) => e.type === 'blob' && /\.(yml|yaml)$/i.test(e.name))) {
54
+ has = true;
55
+ break;
56
+ }
57
+ }
58
+ catch {
59
+ // no such directory at this commit
60
+ }
61
+ }
62
+ if (memo.size > 2000)
63
+ memo.clear();
64
+ memo.set(repo.dir, { sha, has });
65
+ return has;
66
+ }
67
+ async function hasCiState(root, repo, defaultBranch, branches) {
68
+ const dir = (0, runs_1.runsDir)(root, repo.collection, repo.name);
69
+ if (dir) {
70
+ try {
71
+ if (fs.statSync(dir).isDirectory())
72
+ return true;
73
+ }
74
+ catch {
75
+ // no runs yet
76
+ }
77
+ }
78
+ const tip = branches.find((b) => b.name === defaultBranch);
79
+ if (!tip)
80
+ return false;
81
+ return hasWorkflowsAt(repo, tip.sha);
82
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ // The wire types of the runner protocol: what the server hands a runner when
3
+ // it acquires a job, and what the runner reports back. Both sides of the
4
+ // protocol live in this package (src/ci for the server, src/runner for the
5
+ // client), so these types are the single definition.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,256 @@
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.RUNNERS_FILE = void 0;
37
+ exports.runnersFilePath = runnersFilePath;
38
+ exports.loadRunners = loadRunners;
39
+ exports.hashRunnerToken = hashRunnerToken;
40
+ exports.registerRunner = registerRunner;
41
+ exports.regenerateRunnerToken = regenerateRunnerToken;
42
+ exports.setRunnerWake = setRunnerWake;
43
+ exports.removeRunner = removeRunner;
44
+ exports.noteRunnerSeen = noteRunnerSeen;
45
+ exports.runnerLastSeen = runnerLastSeen;
46
+ exports.authenticateRunner = authenticateRunner;
47
+ exports.runnerAllows = runnerAllows;
48
+ const crypto = __importStar(require("crypto"));
49
+ const fs = __importStar(require("fs"));
50
+ const path = __importStar(require("path"));
51
+ const atomic_1 = require("../atomic");
52
+ const filecache_1 = require("../filecache");
53
+ const vault_1 = require("../vault");
54
+ // The runner registry, in <vault>/runners.json. A runner is not a user: it
55
+ // reads repositories (which are world-readable anyway) and writes run state
56
+ // for jobs it holds a lease on, and it can never push, edit, or administer.
57
+ // Keeping it out of vault.json keeps the two credential kinds from being
58
+ // confused for each other, and keeps a machine credential out of the file
59
+ // that governs human access.
60
+ //
61
+ // Only a hash of each runner token is stored, as with user tokens.
62
+ exports.RUNNERS_FILE = 'runners.json';
63
+ function runnersFilePath(root) {
64
+ return path.join(root, exports.RUNNERS_FILE);
65
+ }
66
+ function normalize(parsed) {
67
+ if (typeof parsed !== 'object' || parsed === null)
68
+ throw new Error('runners.json must be a JSON object');
69
+ const raw = parsed.runners;
70
+ if (raw === undefined)
71
+ return { runners: {} };
72
+ if (typeof raw !== 'object' || raw === null)
73
+ throw new Error('runners.json must have a "runners" object');
74
+ const runners = {};
75
+ for (const [name, v] of Object.entries(raw)) {
76
+ if (typeof v !== 'object' || v === null)
77
+ throw new Error(`runner ${name} must be an object`);
78
+ const r = v;
79
+ if (typeof r.hash !== 'string')
80
+ throw new Error(`runner ${name} needs a "hash"`);
81
+ const strings = (x, field) => {
82
+ if (x === undefined)
83
+ return [];
84
+ if (Array.isArray(x) && x.every((s) => typeof s === 'string'))
85
+ return x;
86
+ throw new Error(`runner ${name}: "${field}" must be a list of strings`);
87
+ };
88
+ runners[name] = {
89
+ hash: r.hash,
90
+ labels: strings(r.labels, 'labels'),
91
+ allow: strings(r.allow, 'allow'),
92
+ createdBy: typeof r.createdBy === 'string' ? r.createdBy : '',
93
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
94
+ ...(typeof r.tokenUpdatedAt === 'string' ? { tokenUpdatedAt: r.tokenUpdatedAt } : {}),
95
+ ...(typeof r.wakeUrl === 'string' ? { wakeUrl: r.wakeUrl } : {}),
96
+ ...(typeof r.wakeSecret === 'string' ? { wakeSecret: r.wakeSecret } : {}),
97
+ };
98
+ }
99
+ return { runners };
100
+ }
101
+ const cache = (0, filecache_1.fileCache)({
102
+ read: (file) => {
103
+ try {
104
+ return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
105
+ }
106
+ catch (e) {
107
+ console.error(`runners.json could not be read: ${e instanceof Error ? e.message : e}`);
108
+ return { runners: {} };
109
+ }
110
+ },
111
+ missing: () => ({ runners: {} }),
112
+ });
113
+ function loadRunners(root) {
114
+ return cache.get(runnersFilePath(root));
115
+ }
116
+ function write(root, registry) {
117
+ (0, atomic_1.writeFileAtomic)(runnersFilePath(root), JSON.stringify(registry, null, 2) + '\n', { mode: 0o600 });
118
+ cache.invalidate(runnersFilePath(root));
119
+ }
120
+ /**
121
+ * Hold the registry's lock across a read, an edit, and the write back.
122
+ *
123
+ * `mochi runner add` from a shell and the same call through the API edit
124
+ * this file the same way, and either one overlapping the other loses a runner
125
+ * outright. The cache is dropped on the way in because the decision it makes,
126
+ * that a file with an unchanged mtime and size has unchanged contents, is one
127
+ * this function cannot afford: another process may have written in the interval
128
+ * since the last read, and the copy about to be edited must be the one on disk.
129
+ */
130
+ function editRunners(root, fn) {
131
+ return (0, atomic_1.withFileLock)(`${runnersFilePath(root)}.lock`, () => {
132
+ cache.invalidate(runnersFilePath(root));
133
+ return fn();
134
+ });
135
+ }
136
+ function hashRunnerToken(token) {
137
+ return crypto.createHash('sha256').update(token).digest('hex');
138
+ }
139
+ function newRunnerToken() {
140
+ return 'mochi_runner_' + crypto.randomBytes(32).toString('hex');
141
+ }
142
+ function registerRunner(root, name, opts) {
143
+ return editRunners(root, () => {
144
+ const registry = loadRunners(root);
145
+ const token = newRunnerToken();
146
+ const runner = {
147
+ hash: hashRunnerToken(token),
148
+ labels: opts.labels,
149
+ allow: opts.allow,
150
+ createdBy: opts.createdBy,
151
+ createdAt: new Date().toISOString(),
152
+ ...(opts.wake ? { wakeUrl: opts.wake.url, wakeSecret: opts.wake.secret } : {}),
153
+ };
154
+ registry.runners[name] = runner;
155
+ write(root, registry);
156
+ return { token, runner };
157
+ });
158
+ }
159
+ /**
160
+ * Issue a new token for an existing runner, keeping its labels and allow list.
161
+ *
162
+ * The old token stops working the moment this returns, so a runner still
163
+ * polling with it gets a 401 and must be restarted with the new one. Returns
164
+ * null if no runner by that name is registered.
165
+ */
166
+ function regenerateRunnerToken(root, name) {
167
+ return editRunners(root, () => {
168
+ const registry = loadRunners(root);
169
+ const existing = registry.runners[name];
170
+ if (!existing)
171
+ return null;
172
+ const token = newRunnerToken();
173
+ const runner = {
174
+ ...existing,
175
+ hash: hashRunnerToken(token),
176
+ tokenUpdatedAt: new Date().toISOString(),
177
+ };
178
+ registry.runners[name] = runner;
179
+ write(root, registry);
180
+ return { token, runner };
181
+ });
182
+ }
183
+ /**
184
+ * Point a runner's wake address somewhere, or clear it with null.
185
+ *
186
+ * Separate from registration because the two are learned at different times:
187
+ * `mochi deploy fly runner` registers the runner before the app it will
188
+ * run on exists, and only afterwards knows the URL that starts it. Returns
189
+ * null if no runner by that name is registered.
190
+ */
191
+ function setRunnerWake(root, name, wake) {
192
+ return editRunners(root, () => {
193
+ const registry = loadRunners(root);
194
+ const existing = registry.runners[name];
195
+ if (!existing)
196
+ return null;
197
+ const runner = { ...existing };
198
+ delete runner.wakeUrl;
199
+ delete runner.wakeSecret;
200
+ if (wake) {
201
+ runner.wakeUrl = wake.url;
202
+ runner.wakeSecret = wake.secret;
203
+ }
204
+ registry.runners[name] = runner;
205
+ write(root, registry);
206
+ return runner;
207
+ });
208
+ }
209
+ function removeRunner(root, name) {
210
+ return editRunners(root, () => {
211
+ const registry = loadRunners(root);
212
+ if (!registry.runners[name])
213
+ return false;
214
+ delete registry.runners[name];
215
+ write(root, registry);
216
+ return true;
217
+ });
218
+ }
219
+ // When each runner last authenticated, kept in memory rather than in
220
+ // runners.json. A runner long-polls continuously, so recording this on disk
221
+ // would mean a write every few seconds per runner, for a fact that is only
222
+ // interesting while the server is up: after a restart every live runner
223
+ // re-announces itself within one poll, and one that does not is exactly the
224
+ // one worth reporting as absent.
225
+ const lastSeen = new Map();
226
+ function noteRunnerSeen(name) {
227
+ lastSeen.set(name, Date.now());
228
+ }
229
+ /** When this runner last spoke to the vault, or null if it has not since the server started. */
230
+ function runnerLastSeen(name) {
231
+ const at = lastSeen.get(name);
232
+ return at === undefined ? null : new Date(at).toISOString();
233
+ }
234
+ function authenticateRunner(root, token) {
235
+ const registry = loadRunners(root);
236
+ const presented = Buffer.from(hashRunnerToken(token), 'hex');
237
+ for (const [name, runner] of Object.entries(registry.runners)) {
238
+ let stored;
239
+ try {
240
+ stored = Buffer.from(runner.hash, 'hex');
241
+ }
242
+ catch {
243
+ continue;
244
+ }
245
+ if (stored.length === presented.length && crypto.timingSafeEqual(stored, presented)) {
246
+ return { name, runner };
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+ // Whether a runner is allowed to take jobs for a repository. An empty allow
252
+ // list means nothing, not everything: a runner handed out without a scope
253
+ // should sit idle rather than pick up every repository in the vault.
254
+ function runnerAllows(runner, collection, repo) {
255
+ return runner.allow.some((g) => (0, vault_1.globMatch)(g, `${collection}/${repo}`));
256
+ }