agmsg-cloud 0.0.1 → 0.1.0-rc.4

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 (44) hide show
  1. package/README.md +39 -2
  2. package/dist/src/api.js +517 -0
  3. package/dist/src/authenticated-digest.js +234 -0
  4. package/dist/src/browser.js +241 -0
  5. package/dist/src/ceremony.js +181 -0
  6. package/dist/src/commands/approve.js +392 -0
  7. package/dist/src/commands/connect.js +273 -0
  8. package/dist/src/commands/fetch.js +249 -0
  9. package/dist/src/commands/login.js +334 -0
  10. package/dist/src/commands/logout.js +74 -0
  11. package/dist/src/commands/pull.js +80 -0
  12. package/dist/src/commands/request.js +371 -0
  13. package/dist/src/commands/sync.js +138 -0
  14. package/dist/src/commands/vault.js +478 -0
  15. package/dist/src/commands/watch.js +47 -0
  16. package/dist/src/config.js +34 -0
  17. package/dist/src/credentials.js +374 -0
  18. package/dist/src/device-slot.js +148 -0
  19. package/dist/src/filelock.js +167 -0
  20. package/dist/src/index.js +242 -0
  21. package/dist/src/ledger.js +296 -0
  22. package/dist/src/machine-name.js +90 -0
  23. package/dist/src/oss-env.js +49 -0
  24. package/dist/src/oss.js +289 -0
  25. package/dist/src/paths.js +8 -0
  26. package/dist/src/pending.js +330 -0
  27. package/dist/src/pick-request.js +56 -0
  28. package/dist/src/preflight.js +257 -0
  29. package/dist/src/recovery-key.js +386 -0
  30. package/dist/src/sas.js +18 -0
  31. package/dist/src/secure-store.js +176 -0
  32. package/dist/src/shell-arg.js +18 -0
  33. package/dist/src/slot-advice.js +74 -0
  34. package/dist/src/vault-container.js +115 -0
  35. package/dist/src/vault-crypto.js +190 -0
  36. package/dist/src/vault-protocol.js +358 -0
  37. package/dist/src/version.js +57 -0
  38. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
  39. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
  40. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
  41. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
  42. package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
  43. package/package.json +50 -7
  44. package/bin/agmsg-cloud.js +0 -4
@@ -0,0 +1,334 @@
1
+ import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
2
+ import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
3
+ import { settleMachineName, validateMachineName } from '../machine-name.js';
4
+ // `login` — the device-authorization flow, from this machine's side.
5
+ //
6
+ // It is the one subcommand that runs with no credential, so it takes its
7
+ // endpoint as a flag and everything else from the server's own response. The
8
+ // ordering below is the contract, not a preference: the capability URL is
9
+ // written to disk BEFORE activation, because a credential minted but never
10
+ // durably written is exactly the crash the provisional/activate split exists to
11
+ // survive.
12
+ // Must agree with the server's mint (edge/capability.ts). The secret is pulled
13
+ // out of the capability URL's last path segment, so a response that does not
14
+ // carry the expected shape is refused rather than stored: a wrong value here
15
+ // would be sent as this machine's Bearer credential on every later command.
16
+ const SECRET_RE = /^agsy_[a-f0-9]{8}_[A-Za-z0-9_-]{43}$/;
17
+ // The binary is `agmsg-cloud`, and the approval screen shows the name the
18
+ // SERVER holds for this client id — so the id the CLI sends is what decides
19
+ // whether the person sees "agmsg CLI" (a different program, which never does
20
+ // this login) or "agmsg-cloud CLI". Reported from a real approval screen.
21
+ //
22
+ // The server allowlist carries both ids during the swap (#103), so this side
23
+ // can move without an ordering constraint; the old entry is removed afterwards.
24
+ const CLIENT_ID = 'agmsg-cloud-cli';
25
+ // Terminal outcomes get the server's own reason back, verbatim and named: each
26
+ // one is a different thing for the operator to do, and collapsing them into
27
+ // "login failed" throws that away.
28
+ const TERMINAL = {
29
+ access_denied: 'the request was denied in the browser',
30
+ expired_token: 'the code expired before it was approved — run login again',
31
+ machine_exists: 'that org already runs a machine under this name — run login again with a different --machine-name, or remove the existing machine first',
32
+ over_machines_cap: "this org is at its machine limit — remove a machine or move to a plan that allows more",
33
+ payment_required: 'this org has no active subscription — start one in the console, then run login again',
34
+ };
35
+ function secretFromCapabilityUrl(capabilityUrl) {
36
+ const segments = new URL(capabilityUrl).pathname.split('/');
37
+ const secret = segments[segments.length - 1] ?? '';
38
+ if (!SECRET_RE.test(secret)) {
39
+ throw new Error('the server returned a capability URL in an unrecognised shape; refusing to store it');
40
+ }
41
+ return secret;
42
+ }
43
+ // A network error is fatal for the request that OPENS the grant — there is
44
+ // nothing to wait for yet — but not for the poll that follows it. That poll
45
+ // runs for as long as the operator takes to reach their browser, over a
46
+ // connection the server is free to close when it goes idle, and from here an
47
+ // idle-timeout reset is indistinguishable from a real outage. Ending a login on
48
+ // one discards a code that is still valid and still approvable.
49
+ class Unreachable extends Error {
50
+ code;
51
+ constructor(host, code) {
52
+ super(`could not reach ${host}${code ? ` (${code})` : ''} — check the address and your network, or pass --endpoint if you run your own agmsg cloud`);
53
+ this.code = code;
54
+ this.name = 'Unreachable';
55
+ }
56
+ }
57
+ export async function cmdLogin(opts) {
58
+ const fetchImpl = opts.fetchImpl ?? fetch;
59
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
60
+ const now = opts.now ?? (() => Date.now());
61
+ const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
62
+ // The flag, validated, before anything is asked. `settleMachineName` returns
63
+ // it verbatim when it is given, so nothing below needs the prompt to know
64
+ // what the caller wants — and a malformed value should be refused before the
65
+ // command starts talking to a server.
66
+ const asked = opts.machineName === undefined ? undefined : validateMachineName(opts.machineName);
67
+ // Said out loud on EVERY run, default or not. A default is convenient, and
68
+ // the thing it takes away is the moment the operator typed the destination —
69
+ // so the destination is printed instead. Nobody should have to guess which
70
+ // server their machine is about to be registered with.
71
+ process.stdout.write(`Connecting to ${new URL(endpoint).host}\n`);
72
+ const post = async (path, body, bearer) => {
73
+ try {
74
+ return await fetchImpl(`${endpoint}${path}`, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'content-type': 'application/json',
78
+ ...(bearer === undefined ? {} : { authorization: `Bearer ${bearer}` }),
79
+ },
80
+ body: JSON.stringify(body),
81
+ });
82
+ }
83
+ catch (err) {
84
+ // Node's network failures surface as a bare "fetch failed" with the cause
85
+ // buried. That is the first thing a new user sees when the host is wrong,
86
+ // down, or unreachable, and on its own it names neither the destination
87
+ // nor anything to do about it.
88
+ throw new Unreachable(new URL(endpoint).host, err.cause?.code);
89
+ }
90
+ };
91
+ // P1: the recovery this command's own error message promises must exist.
92
+ // Two crashes leave a credential on disk that the server has not been told
93
+ // about, or has: (a) the durable write landed but the activate never reached
94
+ // the server, and (b) it reached the server and the response was lost. Both
95
+ // are repaired by retrying the IDEMPOTENT activate with what is already
96
+ // stored, before opening a new grant — a fresh grant would instead collide
97
+ // with the machine name the stored credential is holding.
98
+ //
99
+ // Read BEFORE the machine name is asked for. The prompt used to run first, so
100
+ // a machine that was already signed in was asked to name itself and only then
101
+ // told the question had no bearing — and the asking is not a wasted
102
+ // keystroke, it is a claim. Two people read it as evidence that the stored
103
+ // credential had been deleted, and went looking for a file that was on disk
104
+ // the whole time (#193).
105
+ //
106
+ // rc.2 checked first. #169 moved the prompt to the top of the command to
107
+ // reuse the name on both screens; nothing in it was about ordering, so
108
+ // nothing looked at the ordering.
109
+ const stored = readCredential(originOf(endpoint), process.env);
110
+ if (stored && (asked === undefined || stored.machineName === asked)) {
111
+ const res = await post('/v1/device/activate', {}, stored.secret);
112
+ if (res.ok) {
113
+ process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
114
+ return;
115
+ }
116
+ const code = await errorCode(res);
117
+ // 401 not_found / 410 revoked|expired: the credential is genuinely dead, so
118
+ // a fresh login is the right move (the machine name was freed with it).
119
+ // Anything else is the server failing, and starting a new grant on top of a
120
+ // credential that may still be live would be the wrong repair.
121
+ if (res.status !== 401 && res.status !== 410) {
122
+ throw new Error(`a credential for ${originOf(endpoint)} is stored but could not be activated (${res.status} ${code}) — not starting a new login on top of it`);
123
+ }
124
+ }
125
+ // Only now is the name needed: this run is opening a grant, so there really
126
+ // is a machine to name.
127
+ //
128
+ // Asked for, not assumed. The name is what an approver reads to tell one
129
+ // machine from another, and defaulting to the hostname without showing it is
130
+ // how two machines came to be called the same thing in production.
131
+ // --machine-name still wins outright; a terminal gets a pre-filled question;
132
+ // a headless run gets the hostname and is told so.
133
+ const machineName = await settleMachineName(opts.machineName, {
134
+ ...(opts.nameDeps ?? {}),
135
+ });
136
+ const codeRes = await post('/v1/device/code', {
137
+ client_id: CLIENT_ID,
138
+ scope: 'login',
139
+ machine_name: machineName,
140
+ });
141
+ if (!codeRes.ok) {
142
+ const code = await errorCode(codeRes);
143
+ throw new Error(`could not start login: ${codeRes.status} ${code}`);
144
+ }
145
+ const grant = (await codeRes.json());
146
+ process.stdout.write(`\nOpen this page and check the code matches:\n\n`);
147
+ process.stdout.write(` ${grant.verification_uri_complete}\n\n`);
148
+ process.stdout.write(` code: ${grant.user_code}\n`);
149
+ process.stdout.write(` machine: ${machineName}\n\n`);
150
+ // Armed, never awaited: an approval done from a phone, or from a URL typed by
151
+ // hand, must still be noticed — so the poll below runs whether or not any key
152
+ // is ever pressed. `disarm` is bound to every exit path, because a live stdin
153
+ // listener would hold the process open after login has already finished.
154
+ const opener = (opts.armOpener ?? armEnterToOpen)(grant.verification_uri_complete, endpoint);
155
+ process.stdout.write(`Waiting for approval — nothing is granted until you approve it.\n`);
156
+ try {
157
+ return await pollUntilDecided(grant, {
158
+ post,
159
+ sleep,
160
+ now,
161
+ endpoint,
162
+ armOpener: opts.armOpener ?? armEnterToOpen,
163
+ });
164
+ }
165
+ finally {
166
+ opener.disarm();
167
+ }
168
+ }
169
+ async function pollUntilDecided(grant, ctx) {
170
+ const { post, sleep, now, endpoint, armOpener } = ctx;
171
+ // The poll is single-flight by construction: one loop, one request in flight.
172
+ // A concurrent second poll on the same grant would revoke the credential the
173
+ // first poll received.
174
+ let intervalMs = Math.max(grant.interval, 1) * 1000;
175
+ const deadline = now() + grant.expires_in * 1000;
176
+ for (;;) {
177
+ if (now() >= deadline) {
178
+ throw new Error('the code expired before it was approved — run login again');
179
+ }
180
+ await sleep(intervalMs);
181
+ let res;
182
+ try {
183
+ res = await post('/v1/device/token', {
184
+ device_code: grant.device_code,
185
+ client_id: CLIENT_ID,
186
+ });
187
+ }
188
+ catch (err) {
189
+ // Keep waiting. The grant's own expiry is the deadline that ends this
190
+ // loop; a dropped connection is not evidence that anything is wrong with
191
+ // it. Measured against a control plane that never restarted: the process
192
+ // was up throughout and one keep-alive connection was closed.
193
+ if (err instanceof Unreachable) {
194
+ // Said out loud rather than swallowed: an operator watching a long wait
195
+ // should see that contact was lost and regained, not silence.
196
+ process.stdout.write(` (lost contact with the server, still waiting)\n`);
197
+ continue;
198
+ }
199
+ throw err;
200
+ }
201
+ if (res.ok) {
202
+ const body = (await res.json());
203
+ // Checked, not cast. `org` is half the storage key, and a cast turns a
204
+ // missing field into `undefined` — which would key this credential under
205
+ // a literal "undefined" and put it in the way of the next real one. A
206
+ // server that does not send it is a server this build cannot store a
207
+ // credential for, and saying so is better than inventing a slot.
208
+ const issued = {
209
+ capability_url: String(body['capability_url'] ?? ''),
210
+ machine_name: String(body['machine_name'] ?? ''),
211
+ org: typeof body['org'] === 'string' ? body['org'] : '',
212
+ };
213
+ if (!issued.capability_url || !issued.org) {
214
+ throw new Error('the server answered without the fields this needs (capability_url, org) — it may be older than this CLI');
215
+ }
216
+ // Present is not the same as usable. `org` becomes half a storage key,
217
+ // and the key's delimiter is a space on the grounds that an org address
218
+ // cannot contain one — so a value like `x`, or one carrying a space or a
219
+ // newline, does not fail loudly. It writes a slot under a key nothing
220
+ // will look up again. Checked here so nothing durable happens first, and
221
+ // checked again in `keyFor` so a future caller cannot route around this.
222
+ if (!isOrgAddress(issued.org)) {
223
+ throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
224
+ }
225
+ return finish(issued, endpoint, post);
226
+ }
227
+ const body = await errorBody(res);
228
+ const code = body.code;
229
+ if (code === 'authorization_pending')
230
+ continue;
231
+ if (code === 'slow_down') {
232
+ // The server's own backoff. Honouring it is what keeps a slow approval
233
+ // from turning into a rate-limit refusal.
234
+ intervalMs += 5000;
235
+ continue;
236
+ }
237
+ if (code === 'over_machines_cap') {
238
+ // Not a generic refusal: the operator is one upgrade away, and the
239
+ // numbers to decide with came back with the 403.
240
+ throw new Error(atMachineLimit(body, grant, endpoint, armOpener));
241
+ }
242
+ const explained = TERMINAL[code];
243
+ throw new Error(explained ? `login stopped: ${explained}` : `login failed: ${res.status} ${code}`);
244
+ }
245
+ }
246
+ async function finish(issued, endpoint, post) {
247
+ const secret = secretFromCapabilityUrl(issued.capability_url);
248
+ // Durable write FIRST. If the process dies between here and activate, the
249
+ // credential is on disk and the machine can be activated by running login
250
+ // again; the reverse order loses the only copy of the secret.
251
+ //
252
+ // The org comes from the token response because it is decided while this
253
+ // process is polling — the approver picks it in the console. It is half the
254
+ // storage key: without it, signing in to a second org on this host would
255
+ // overwrite the first org's secret.
256
+ writeCredential({
257
+ endpoint: originOf(endpoint),
258
+ org: issued.org,
259
+ secret,
260
+ capabilityUrl: issued.capability_url,
261
+ machineName: issued.machine_name,
262
+ });
263
+ const res = await post('/v1/device/activate', {}, secret);
264
+ if (!res.ok) {
265
+ const code = await errorCode(res);
266
+ throw new Error(`the credential was saved but could not be activated (${res.status} ${code}) — run login again`);
267
+ }
268
+ process.stdout.write(`\nSigned in as machine "${issued.machine_name}".\n`);
269
+ process.stdout.write(`Its sync address is saved on this machine; no token to copy.\n`);
270
+ }
271
+ async function errorCode(res) {
272
+ return (await errorBody(res)).code;
273
+ }
274
+ // A response body can be read once. Every caller that wants more than the code
275
+ // — the machine-limit message wants the numbers beside it — has to come
276
+ // through here, or the second read finds an already-consumed stream and the
277
+ // facts vanish silently. (Found by the test that asserted the numbers.)
278
+ async function errorBody(res) {
279
+ try {
280
+ const parsed = (await res.json());
281
+ return {
282
+ code: typeof parsed.error === 'string' ? parsed.error : 'unknown',
283
+ ...(typeof parsed.plan === 'string' ? { plan: parsed.plan } : {}),
284
+ ...(typeof parsed.limit === 'number' ? { limit: parsed.limit } : {}),
285
+ ...(typeof parsed.in_use === 'number' ? { inUse: parsed.in_use } : {}),
286
+ };
287
+ }
288
+ catch {
289
+ return { code: 'unknown' };
290
+ }
291
+ }
292
+ // "Your plan is at its machine limit" — with the plan, the numbers, and a way
293
+ // to act on it.
294
+ //
295
+ // The old text named the limit and nothing else: not which plan, not how many
296
+ // machines are in use, not where to change it. Someone reading it had to go
297
+ // and find all three. The numbers come from the server's 403 rather than a
298
+ // copy of the plan table here, so what is displayed and what is enforced
299
+ // cannot drift apart.
300
+ //
301
+ // The Enter offer is the same one login already makes for the approval page,
302
+ // deliberately: it declines to say "press Enter" where no keypress can arrive
303
+ // and says what to do instead. Writing a second one here would recreate the
304
+ // defect that one exists to fix.
305
+ function atMachineLimit(body, grant, endpoint, armOpener) {
306
+ const lines = ['Cannot add this machine — your plan is at its machine limit.', ''];
307
+ if (body.plan !== undefined)
308
+ lines.push(` plan: ${body.plan}`);
309
+ if (body.limit !== undefined && body.inUse !== undefined) {
310
+ lines.push(` machines: ${body.inUse} of ${body.limit} in use`);
311
+ }
312
+ // The console's own origin, taken from the address the SERVER gave for this
313
+ // login rather than assembled here — a self-hosted stack has its own, and a
314
+ // guess would send the operator somewhere that is not their console. Only
315
+ // the origin is used: the console selects Billing in the page, so there is
316
+ // no path to link to (see the report accompanying this change).
317
+ let consoleUrl = null;
318
+ try {
319
+ consoleUrl = new URL(grant.verification_uri).origin;
320
+ }
321
+ catch {
322
+ consoleUrl = null;
323
+ }
324
+ // The same check the approval page goes through. A URL we would not open is
325
+ // not one to put in front of someone either.
326
+ if (consoleUrl && verificationUrlIsSafe(consoleUrl, endpoint)) {
327
+ lines.push('', 'Upgrade in the console (open Billing there):', ` ${consoleUrl}`);
328
+ armOpener(consoleUrl, endpoint);
329
+ }
330
+ else if (consoleUrl) {
331
+ lines.push('', 'Upgrade in the console.');
332
+ }
333
+ return lines.join('\n');
334
+ }
@@ -0,0 +1,74 @@
1
+ import { DEFAULT_ENDPOINT } from '../browser.js';
2
+ import { originOf, removeCredentials } from '../credentials.js';
3
+ import { clearRecordsForOrigin } from '../pending.js';
4
+ /**
5
+ * The endpoint `logout` was asked for, or a refusal.
6
+ *
7
+ * The argv this command accepts is CLOSED — `[]`, or exactly
8
+ * `--endpoint <url>` — rather than filtered for the one flag it reads.
9
+ *
10
+ * Because it is destructive AND defaulted. Reading only `--endpoint` and
11
+ * ignoring the rest means `logout --endpont https://self.test` finds no flag,
12
+ * falls back to the hosted service, and deletes the production credential
13
+ * while the person believed they had named their own stack. So does
14
+ * `logout https://self.test`. Neither is an exotic input: they are a typo and
15
+ * a reasonable guess at the syntax.
16
+ *
17
+ * A command that removes a secret must not act on an argv it did not
18
+ * understand.
19
+ */
20
+ export function logoutEndpoint(argv) {
21
+ const refuse = () => {
22
+ throw new Error('usage: agmsg-cloud logout [--endpoint <url>]');
23
+ };
24
+ if (argv.length === 0)
25
+ return undefined;
26
+ if (argv.length !== 2 || argv[0] !== '--endpoint')
27
+ return refuse();
28
+ const value = argv[1];
29
+ // A missing value would otherwise swallow the next token; there is no next
30
+ // token here, but the same shape (`--endpoint --force`) is what the flag
31
+ // reader elsewhere in this file guards against.
32
+ if (value.length === 0 || value.startsWith('--'))
33
+ return refuse();
34
+ try {
35
+ // Parsed here rather than at the point of deletion: an unparseable address
36
+ // should be refused before anything is read or removed.
37
+ void new URL(value);
38
+ }
39
+ catch {
40
+ return refuse();
41
+ }
42
+ return value;
43
+ }
44
+ export function cmdLogout(opts = {}) {
45
+ const env = opts.env ?? process.env;
46
+ const out = opts.out ?? ((text) => void process.stdout.write(text));
47
+ const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
48
+ const origin = originOf(endpoint);
49
+ const removed = removeCredentials(origin, env);
50
+ const pending = clearRecordsForOrigin(origin, env);
51
+ if (removed.length === 0) {
52
+ // Not an error. "There was nothing to remove" is the state someone running
53
+ // this twice is in, and the state someone checking is in — and it is the
54
+ // same end state they asked for. Exiting non-zero would make a script that
55
+ // signs out defensively fail on the run where it worked.
56
+ out(`No credential stored for ${origin}.\n`);
57
+ if (pending > 0)
58
+ out(`Cleared ${pending} pending enrollment record(s).\n`);
59
+ return;
60
+ }
61
+ // Named, not counted. The person signing out is entitled to know which
62
+ // identity just left this machine — one host can hold credentials for
63
+ // several orgs, and "signed out" without saying whose is how someone
64
+ // discovers later that the wrong one went.
65
+ for (const credential of removed) {
66
+ out(`Signed out machine "${credential.machineName}" from ${origin} (org ${credential.org}).\n`);
67
+ }
68
+ if (pending > 0)
69
+ out(`Cleared ${pending} pending enrollment record(s).\n`);
70
+ // Said because the omission is the surprising part: someone who ran this to
71
+ // clean up a machine should not have to wonder whether their history went
72
+ // with it.
73
+ out(`This machine's device key and local team keys were not touched.\n`);
74
+ }
@@ -0,0 +1,80 @@
1
+ import { spawnOssInherit } from '../oss-env.js';
2
+ import { shellArg } from '../shell-arg.js';
3
+ import { join } from 'node:path';
4
+ import { CourierClient, isUuid } from '../api.js';
5
+ import { originOf, readCredential } from '../credentials.js';
6
+ import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
7
+ function runInherit(command, args) {
8
+ return new Promise((resolve, reject) => {
9
+ // stdio inherited: `remote.sh pull` prints its own progress, and this is
10
+ // the slowest step of joining a second machine.
11
+ const child = spawnOssInherit(command, args);
12
+ child.on('error', reject);
13
+ child.on('close', (code) => resolve(code ?? 1));
14
+ });
15
+ }
16
+ export async function cmdPull(config, opts) {
17
+ ensurePreflight(preflight(config.scriptsDir, NEEDS.pull));
18
+ // The capability URL, not the control-plane endpoint: the sync gateway
19
+ // answers the former and never sees the latter.
20
+ const credential = readCredential(originOf(config.baseUrl));
21
+ if (!credential) {
22
+ throw new Error(`no stored credential for ${originOf(config.baseUrl)} — run \`agmsg-cloud login\` on this machine first`);
23
+ }
24
+ // `--team-id` skips the lookup, and with it every check the lookup's
25
+ // response boundary performs. Whatever is typed here goes to the OSS side
26
+ // as an identity, so it is checked in the one place left that can.
27
+ if (opts.teamId !== undefined && !isUuid(opts.teamId)) {
28
+ throw new Error(`--team-id must be a team UUID, not "${opts.teamId}"`);
29
+ }
30
+ const teamId = opts.teamId ?? (await resolveTeamId(config, opts));
31
+ process.stdout.write(`\nPulling "${opts.team}" onto machine "${credential.machineName}".\n`);
32
+ const run = opts.runner ?? runInherit;
33
+ const code = await run('bash', [
34
+ join(config.scriptsDir, 'remote.sh'),
35
+ 'pull',
36
+ '--endpoint',
37
+ credential.capabilityUrl,
38
+ '--team-id',
39
+ teamId,
40
+ opts.team,
41
+ ]);
42
+ if (code !== 0) {
43
+ // The OSS script has already said what went wrong on this terminal.
44
+ throw new Error(`pull failed (remote.sh exited ${code})`);
45
+ }
46
+ // Says nothing about whether it is locked, because the OSS script now says
47
+ // that accurately for THIS machine: it decides on whether the current
48
+ // epoch's key is here, not on how many envelopes arrived, so it prints the
49
+ // locked line or the usable one. Restating it from here would either
50
+ // duplicate it or contradict it, and this side cannot tell which.
51
+ //
52
+ // What it adds is the route, which the OSS script no longer names because
53
+ // its route is not ours. Phrased as a condition rather than a claim, so it
54
+ // is true after either line above.
55
+ process.stdout.write(`\n"${opts.team}" is on this machine.\n`);
56
+ if (opts.nextStepsFromCaller !== true) {
57
+ process.stdout.write(`If it is still locked, \`agmsg-cloud sync ${shellArg(opts.team)}\` completes the key handoff:\n` +
58
+ 'it asks a machine that already has the team, and the two of you compare eight digits.\n');
59
+ }
60
+ }
61
+ async function resolveTeamId(config, opts) {
62
+ const client = opts.client ?? new CourierClient(config);
63
+ const matches = await client.resolveTeamByName(opts.team);
64
+ if (matches.length === 0) {
65
+ // Named as what it is: this org has no such team. The alternative reading
66
+ // — that the team exists under someone else — is not ours to confirm or
67
+ // deny, and the answer is the same either way.
68
+ throw new Error(`no team named "${opts.team}" in this organization.\n\n` +
69
+ 'Connect it from the machine that runs it first, or pass --team-id if you have it.');
70
+ }
71
+ if (matches.length > 1) {
72
+ // A name is not unique by construction, so this stops rather than picking.
73
+ // Pulling the wrong team writes another team's messages into this machine's
74
+ // store under a name the operator chose, which no later step would notice.
75
+ const ids = matches.map((m) => ` ${m.teamId}`).join('\n');
76
+ throw new Error(`"${opts.team}" is ambiguous — this organization has ${matches.length} teams with that name:\n\n${ids}\n\n` +
77
+ 'Re-run with --team-id <id> to say which one.');
78
+ }
79
+ return matches[0].teamId;
80
+ }