agmsg-cloud 0.1.0-rc.5 → 0.1.0-rc.7

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.
package/dist/src/api.js CHANGED
@@ -324,7 +324,26 @@ export class CourierClient {
324
324
  // machine resolves the name here and pulls by id, rather than handing a
325
325
  // name to the data plane.
326
326
  async resolveTeamByName(name) {
327
- const out = await this.call('GET', `/v1/edge/teams?name=${encodeURIComponent(name)}`);
327
+ return this.teams(`/v1/edge/teams?name=${encodeURIComponent(name)}`);
328
+ }
329
+ /**
330
+ * Every team in the caller's organization.
331
+ *
332
+ * The same endpoint without the `name` filter, so it discloses nothing the
333
+ * lookup above does not: the control plane scopes both to the caller's org
334
+ * (`WHERE org_id = $1`), which is the property that lets the name lookup live
335
+ * here instead of on the data plane.
336
+ *
337
+ * It exists so a failed lookup can tell two situations apart. "No team by
338
+ * that name" is the same answer whether nothing has ever been connected to
339
+ * this organization or the operator is signed in to the wrong one, and those
340
+ * need opposite next steps.
341
+ */
342
+ async listTeams() {
343
+ return this.teams('/v1/edge/teams');
344
+ }
345
+ async teams(path) {
346
+ const out = await this.call('GET', path);
328
347
  // A response this cannot read is not an answer about which teams exist,
329
348
  // and it must not be turned into one. `teams` must be PRESENT and an
330
349
  // array, with an empty array the only way to say "none" — the same rule
@@ -360,7 +360,21 @@ args, deps = {}) {
360
360
  done();
361
361
  // Closed, not refunded: §4.1 keeps a success counted in the window.
362
362
  closeAttempt(scope, requestId, 'succeeded', undefined, env);
363
- out(`approved; registered device ${device_id}\n`);
363
+ // The LABEL, not the id.
364
+ //
365
+ // `device_id` is a UUID the approver cannot use: no subcommand of this
366
+ // CLI accepts one (derived from index.ts — approve, connect, fetch,
367
+ // login, logout, pull, recovery, request, sync, watch, whoami, and none
368
+ // take a device). So it fails all three tests for reaching a person: it
369
+ // decides nothing for them, it cannot help them repair anything, and the
370
+ // label they just compared eight digits against is what actually names
371
+ // the machine they approved (#275 — the operator gets the minimum, the
372
+ // rest belongs in a log).
373
+ //
374
+ // Not routed to a log instead, because this CLI has no log to route it
375
+ // to. Building one for a single value would be the wrong size; when a
376
+ // second diagnostic needs a home, that is the moment to give it one.
377
+ out(`approved; "${start.label}" can now read this team\n`);
364
378
  }
365
379
  finally {
366
380
  rmSync(scratch, { recursive: true, force: true });
@@ -6,7 +6,10 @@ import { originOf, readCredential } from '../credentials.js';
6
6
  import { generateDeviceIdentity, publicKeyOf, remoteTeamId } from '../oss.js';
7
7
  import { deviceIdentityPath } from '../paths.js';
8
8
  import { NEEDS, ensurePreflight, formatPreflight, preflight } from '../preflight.js';
9
+ import { adviseOnSlotForSilentFiling, renderSlotAdvice } from '../slot-advice.js';
10
+ import { setupCommand } from '../recovery-key.js';
9
11
  import { shellArg } from '../shell-arg.js';
12
+ import { fileTeamInVaultSilently } from '../vault-filing.js';
10
13
  function runInherit(command, args) {
11
14
  return new Promise((resolve, reject) => {
12
15
  // stdio inherited: `remote.sh connect` prints its own progress, and
@@ -120,9 +123,45 @@ export async function cmdConnect(config, opts) {
120
123
  // opening it — which needs the recovery key nobody has typed here. A
121
124
  // sentence about where the only copy is would be a guess whichever way it
122
125
  // went. The condition is named instead, and it is true in every case.
123
- process.stdout.write(`\nBack these keys up: \`agmsg-cloud recovery setup ${shellArg(opts.team)}\`.\n` +
124
- 'Until a team is in your recovery vault, the only copies of its keys are on\n' +
125
- 'the machines that hold them; once it is there, the recovery key opens it again.\n' +
126
+ // Filed here rather than left to an instruction. The key was made moments
127
+ // ago by the step above, and until it is in the vault the only copies are on
128
+ // the machines holding them — a window that used to last until someone acted
129
+ // on a line that had already scrolled away (#181).
130
+ //
131
+ // Silently or not at all. `connect` does not ask for a recovery key: a
132
+ // command that stopped to collect one would be a ceremony, and the person
133
+ // running it did not come here for that. Both ways of being unable — no
134
+ // vault, no slot on this machine — fall back to naming the route, which is
135
+ // what this printed before.
136
+ //
137
+ // And the two are told apart, because "nothing happened, silently" is the
138
+ // outcome that leaves someone believing they are backed up.
139
+ const filing = await fileTeamInVaultSilently(config, new CourierClient(config), opts.team);
140
+ const teams = `${filing.filed ? filing.teamCount : 0} team${(filing.filed ? filing.teamCount : 0) === 1 ? '' : 's'}`;
141
+ const backup = filing.filed
142
+ ? filing.wrote
143
+ ? `Backed up: revision ${filing.revision}, ${teams} in this account's vault.\n` +
144
+ 'Losing this machine no longer loses this team — the recovery key opens it again.'
145
+ : // Already there, with the same keys. Said as its own sentence rather
146
+ // than as a write, because claiming a revision this run did not produce
147
+ // is the same kind of false as claiming a backup that never happened —
148
+ // and a re-run of `connect` reaches here every time.
149
+ `Already backed up: this team's keys were already in the vault at revision ${filing.revision},\n` +
150
+ `unchanged, so nothing new was stored. ${teams} in this account's vault.`
151
+ : filing.reason === 'no-vault'
152
+ ? // NAMED, for the same reason as the four below: creating the vault and
153
+ // backing up THIS team is what the sentence above promises, and the
154
+ // broad form can refuse over a team that has nothing to do with it.
155
+ `Not backed up: this account has no recovery vault yet.\n` +
156
+ `Run \`${setupCommand(opts.team)}\` to make one — it shows a recovery key\n` +
157
+ "once. Until then the only copies of this team's keys are on the machines\n" +
158
+ 'that hold them.'
159
+ : // Four ways of having no slot, four routes, and only one of them is
160
+ // "type the key once and this stops happening". Asked rather than
161
+ // written here, so a machine with no secure store is not sent after a
162
+ // fix that does not exist for it.
163
+ renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot, { team: opts.team })).trimEnd();
164
+ process.stdout.write(`${backup}\n` +
126
165
  `\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
127
166
  // The subject is the operator, and it has to be. Something IS carried
128
167
  // between the machines after the yes — the sealed bundle, which is what
@@ -132,11 +171,33 @@ export async function cmdConnect(config, opts) {
132
171
  'You do not carry any value between the machines yourself: both screens show\n' +
133
172
  'eight digits and you check they are the same.\n');
134
173
  if (code !== 0) {
135
- // Now that it is true, the operator can be told the failure needs nothing
136
- // further — but only about the case where that is so. The error itself is
137
- // still theirs to read; this does not summarise it.
138
- process.stdout.write('Read the error above: if it was a uniqueness conflict, the team was already connected\n' +
139
- 'and there is nothing else to do.\n');
174
+ // THE CASE THIS USED TO NAME NO LONGER REACHES HERE.
175
+ //
176
+ // It said: "if it was a uniqueness conflict, the team was already connected
177
+ // and there is nothing else to do". That conflict was the OSS step refusing
178
+ // a second connect — `a team_id registers once ... not a transient error to
179
+ // retry` — and it is gone. Searched at the pin this repo carries
180
+ // (`92ddbe5`): no match in `scripts/`. It was removed by `8c2bd4b`, "a
181
+ // connect that already registered resumes instead of dead-ending", which is
182
+ // an ancestor of that pin. The script now adopts the registration and
183
+ // carries on, so a duplicate does not end non-zero at all.
184
+ //
185
+ // Leaving the sentence would have been worse than saying nothing: it asks
186
+ // the reader to look for a message that cannot appear, and the one it
187
+ // describes was the reassuring case, so a real failure would be read as the
188
+ // harmless one.
189
+ //
190
+ // Said as a property of this command rather than as a diagnosis of an error
191
+ // this side has not read — and only the part that was checked. Re-running
192
+ // adds nothing: the OSS step adopts a registration that is already there,
193
+ // and `bootstrapDevice` returns the existing device when the key matches
194
+ // (`devices.ts:309`). It is NOT "it will work": this same machine can be
195
+ // refused for reasons a second run cannot change, and the key having
196
+ // changed is refused rather than registered twice — which is why the
197
+ // sentence stops at "adds nothing".
198
+ process.stdout.write('Read the error above. Running connect again adds nothing that is already\n' +
199
+ 'there: a registered team is adopted rather than refused, and this machine\n' +
200
+ 'is registered once. It is worth doing if the error looks transient.\n');
140
201
  }
141
202
  }
142
203
  async function registerThisMachine(config, opts, machineName) {
@@ -192,7 +253,12 @@ async function registerThisMachine(config, opts, machineName) {
192
253
  // that is not the claimant never becomes one. Connect from here returns to
193
254
  // this same 403 forever.
194
255
  if (err instanceof CourierError && err.status === 403) {
195
- throw new Error(await forbiddenAdvice(client, opts.team, pubkey));
256
+ // The id goes in, so the message can PRINT it. The release is asked for
257
+ // by team id, and the console shows teams by slug (`Connections.tsx:154`)
258
+ // — telling someone to go and find it is the placeholder problem this
259
+ // file already refuses elsewhere. It is in hand here; it is the value
260
+ // `bootstrapDevice` was just called with.
261
+ throw new Error(await forbiddenAdvice(client, opts.team, teamId, pubkey));
196
262
  }
197
263
  throw err;
198
264
  }
@@ -205,19 +271,30 @@ async function registerThisMachine(config, opts, machineName) {
205
271
  * account have a machine that could approve? Enrollment needs an approver, and
206
272
  * an approver is a registered device.
207
273
  *
208
- * The case with no approver is a real one and it has no way out today. That is
209
- * written plainly instead of offering a command that will wait forever, because
210
- * a person who is told there is no recovery stops spending time looking for one.
274
+ * The case with no approver has no way out THIS MACHINE CAN TAKE, which is not
275
+ * the same as no way out — and the message used to say the second one.
276
+ *
277
+ * There is a route: releasing the team's claim (`app/scripts/release-claim.ts`)
278
+ * frees the id, and a later connect claims it again. It is run by whoever
279
+ * operates the service, not by the person holding this terminal, and that is
280
+ * the decision rather than an omission — releases are rare enough to be worth a
281
+ * human checkpoint each time, and a self-service release would need an
282
+ * owner-authenticated mutation surface that does not exist. Both are recorded
283
+ * on #71, with the condition that would reopen the question.
284
+ *
285
+ * So the sentence to print is what to ASK FOR, not a command to run. A person
286
+ * told there is no recovery stops looking for one — which is right when there
287
+ * is none and expensive when there is, and #143 is the case where there was.
211
288
  *
212
- * WHY NOT BUILD A WAY BACK. Reassigning a team's claim to a live capability
213
- * would fix it, and it is a change nobody should make inside a bug fix: it
214
- * decides who may take over a team whose first machine is gone, and the obvious
215
- * answer — any live capability in the org — turns a revoked machine into "any
216
- * machine may become device-0 for that team", which is part of what revoking
217
- * was for. Named here so the next person who finds this message unhelpful
218
- * reaches the same question rather than the same guess.
289
+ * WHY NOT BUILD A WAY BACK HERE. Reassigning a team's claim to a live
290
+ * capability would also fix it, and it is a change nobody should make inside a
291
+ * bug fix: it decides who may take over a team whose first machine is gone, and
292
+ * the obvious answer — any live capability in the org — turns a revoked machine
293
+ * into "any machine may become device-0 for that team", which is part of what
294
+ * revoking was for. Named here so the next person who finds this message
295
+ * unhelpful reaches the same question rather than the same guess.
219
296
  */
220
- async function forbiddenAdvice(client, team, ownPubkey) {
297
+ async function forbiddenAdvice(client, team, teamId, ownPubkey) {
221
298
  const head = `the server would not register this machine for '${team}'.\n\n` +
222
299
  'It is not the machine that first connected this team, and only that one can register\n' +
223
300
  'without an approver. That is recorded once and never moves, so running connect again\n' +
@@ -253,10 +330,19 @@ async function forbiddenAdvice(client, team, ownPubkey) {
253
330
  if (approvers === 0) {
254
331
  return (head +
255
332
  'There is also no OTHER machine on this account that could approve a request — and a\n' +
256
- 'machine cannot approve its own — so joining is not open either. THERE IS NO WAY TO\n' +
257
- 'RECOVER THIS TEAM FROM THIS ACCOUNT TODAY: the machine that claimed it can no longer\n' +
258
- 'register, and enrollment needs an approver that does not exist. Reported rather than\n' +
259
- 'dressed up as a command, so you do not spend the evening on one that cannot work.');
333
+ 'machine cannot approve its own — so joining is not open either. NOTHING YOU CAN RUN\n' +
334
+ 'FROM HERE RECOVERS THIS TEAM: the machine that claimed it can no longer register, and\n' +
335
+ 'enrollment needs an approver that does not exist.\n\n' +
336
+ 'What does recover it is releasing the team, which frees the id so a connect can claim\n' +
337
+ 'it again. That is run by whoever operates this service, not from this terminal. Ask\n' +
338
+ 'them to release this team, and say why:\n\n' +
339
+ ` team ${team}\n` +
340
+ ` id ${teamId}\n\n` +
341
+ 'Releasing DELETES EVERYTHING THIS SERVICE HOLDS FOR THE TEAM — its messages, its\n' +
342
+ 'member list and their agent registrations, and its policy and identity history. That\n' +
343
+ 'is the point of it: the id is freed for a fresh start, not handed over with its past.\n' +
344
+ 'Your machines stay signed in and nothing on them is touched; it is this team on the\n' +
345
+ 'service that goes. If any of it matters, say so before asking.');
260
346
  }
261
347
  return (head +
262
348
  'Join it from here instead — this account has another machine that can approve the\n' +
@@ -222,7 +222,25 @@ env = process.env) {
222
222
  // The choice is made from the VERIFIED matching set, never from the raw
223
223
  // queue: picking an unverified blob by position would not be identification.
224
224
  const { blob, file } = matches[0];
225
- await unlockBundle(config.scriptsDir, args.team, file, expected);
225
+ const unlockSaid = await unlockBundle(config.scriptsDir, args.team, file, expected);
226
+ // Relayed HERE, before the ack, because it is already true here.
227
+ //
228
+ // The script has finished: the team is unlocked on this machine and its
229
+ // sync engine is running under a pid the script verified. Printing it after
230
+ // `ackBlob` meant a failed ack threw that away — the local half had
231
+ // succeeded, the operator was shown only the error, and the machine they
232
+ // were about to debug was already working. That is this issue's own defect
233
+ // one step later (raised in review).
234
+ //
235
+ // NOT verbatim, and the difference is worth naming rather than glossing:
236
+ // `trimEnd()` drops trailing whitespace and a single '\n' is re-appended.
237
+ // The body is untouched; only the tail is normalised. A script that says
238
+ // nothing prints nothing, because a blank line would read as an empty
239
+ // answer (raised in review — the comment said "verbatim", which is wider
240
+ // than what this does).
241
+ const said = (unlockSaid ?? '').trimEnd();
242
+ if (said !== '')
243
+ process.stdout.write(said + '\n');
226
244
  await client.ackBlob(blob.id);
227
245
  // Consumed only now, and the order is load-bearing.
228
246
  //
@@ -238,7 +256,23 @@ env = process.env) {
238
256
  clearAuthenticatedDigest(originOf(config.baseUrl), consumed.requestId, config.secret, devicePubkey, env);
239
257
  const others = blobs.length - 1;
240
258
  const duplicates = matches.length - 1;
241
- process.stdout.write(`unlocked and acked the confirmed bundle` +
259
+ // What `remote.sh unlock` said — its body, with the trailing newline
260
+ // normalised to exactly one — and then what THIS command did.
261
+ //
262
+ // The script's line is the stronger one and it was being thrown away: it
263
+ // waits for the sync engine to report ready, checks the pidfile it recorded
264
+ // matches the process it started, checks that process is alive, and exits
265
+ // non-zero otherwise — so `engine running (pid N)` is a claim about a live
266
+ // pid, and it names the team.
267
+ //
268
+ // In its place this printed `unlocked and acked the confirmed bundle`,
269
+ // which names no object at all. Four lines later the operator was told to
270
+ // "unlock" — a DIFFERENT object, the team's history — with the same verb.
271
+ // Two objects, one word, and nothing on screen to tell them apart (#147).
272
+ //
273
+ // The ack is reported separately because it is this command's own act
274
+ // against the server, not something the script did.
275
+ process.stdout.write(`acked the confirmed bundle` +
242
276
  (others > 0 ? `; ${others} other bundle(s) left queued` : '') +
243
277
  (duplicates > 0 ? ` (${duplicates} of them carry the same digest)` : '') +
244
278
  '\n');
@@ -1,3 +1,4 @@
1
+ import { SECRET_RE } from '../machine-id.js';
1
2
  import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
2
3
  import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
3
4
  import { settleMachineName, validateMachineName } from '../machine-name.js';
@@ -13,7 +14,11 @@ import { settleMachineName, validateMachineName } from '../machine-name.js';
13
14
  // out of the capability URL's last path segment, so a response that does not
14
15
  // carry the expected shape is refused rather than stored: a wrong value here
15
16
  // 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
+ //
18
+ // Imported, not restated. `machine-id.ts` reads the prefix out of the same
19
+ // shape, and a second copy here would let the two drift: changing the mint in
20
+ // one place would leave the other answering about a format that no longer
21
+ // exists (raised in review).
17
22
  // The binary is `agmsg-cloud`, and the approval screen shows the name the
18
23
  // SERVER holds for this client id — so the id the CLI sends is what decides
19
24
  // whether the person sees "agmsg CLI" (a different program, which never does
@@ -58,6 +63,7 @@ export async function cmdLogin(opts) {
58
63
  const fetchImpl = opts.fetchImpl ?? fetch;
59
64
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
60
65
  const now = opts.now ?? (() => Date.now());
66
+ const out = opts.out ?? ((text) => void process.stdout.write(text));
61
67
  const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
62
68
  // The flag, validated, before anything is asked. `settleMachineName` returns
63
69
  // it verbatim when it is given, so nothing below needs the prompt to know
@@ -68,7 +74,7 @@ export async function cmdLogin(opts) {
68
74
  // the thing it takes away is the moment the operator typed the destination —
69
75
  // so the destination is printed instead. Nobody should have to guess which
70
76
  // server their machine is about to be registered with.
71
- process.stdout.write(`Connecting to ${new URL(endpoint).host}\n`);
77
+ out(`Connecting to ${new URL(endpoint).host}\n`);
72
78
  const post = async (path, body, bearer) => {
73
79
  try {
74
80
  return await fetchImpl(`${endpoint}${path}`, {
@@ -110,7 +116,7 @@ export async function cmdLogin(opts) {
110
116
  if (stored && (asked === undefined || stored.machineName === asked)) {
111
117
  const res = await post('/v1/device/activate', {}, stored.secret);
112
118
  if (res.ok) {
113
- process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
119
+ out(`Already signed in as machine "${stored.machineName}".\n`);
114
120
  return;
115
121
  }
116
122
  const code = await errorCode(res);
@@ -143,16 +149,32 @@ export async function cmdLogin(opts) {
143
149
  throw new Error(`could not start login: ${codeRes.status} ${code}`);
144
150
  }
145
151
  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`);
152
+ out(`\nOpen this page and check the code matches:\n\n`);
153
+ out(` ${grant.verification_uri_complete}\n\n`);
154
+ out(` code: ${grant.user_code}\n`);
155
+ out(` machine: ${machineName}\n\n`);
150
156
  // Armed, never awaited: an approval done from a phone, or from a URL typed by
151
157
  // hand, must still be noticed — so the poll below runs whether or not any key
152
158
  // is ever pressed. `disarm` is bound to every exit path, because a live stdin
153
159
  // listener would hold the process open after login has already finished.
154
160
  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`);
161
+ // SAYS THAT THE BLOCKING IS THE DESIGN, and that the two lines above are
162
+ // already usable.
163
+ //
164
+ // An agent held the URL and the code for 42 seconds waiting for this command
165
+ // to return before relaying them — and it cannot return until someone
166
+ // approves, which nobody can do without seeing what it has already printed.
167
+ // The one step that needs a person was the step the person was locked out of
168
+ // (issue 279).
169
+ //
170
+ // The prompt handed to an agent carries four sentences about this. They are a
171
+ // description of behaviour this output can state itself, and prose in a
172
+ // prompt does not reach anyone who did not read that prompt — someone running
173
+ // the command by hand gets nothing.
174
+ out(`Waiting for approval — nothing is granted until you approve it.\n`);
175
+ out(`This command will not return until then. That is expected, not a failure:\n` +
176
+ `the page and the code above are ready to use now — open them, do not wait\n` +
177
+ `for this to finish.\n`);
156
178
  try {
157
179
  return await pollUntilDecided(grant, {
158
180
  post,
@@ -160,6 +182,7 @@ export async function cmdLogin(opts) {
160
182
  now,
161
183
  endpoint,
162
184
  armOpener: opts.armOpener ?? armEnterToOpen,
185
+ out,
163
186
  });
164
187
  }
165
188
  finally {
@@ -167,7 +190,7 @@ export async function cmdLogin(opts) {
167
190
  }
168
191
  }
169
192
  async function pollUntilDecided(grant, ctx) {
170
- const { post, sleep, now, endpoint, armOpener } = ctx;
193
+ const { post, sleep, now, endpoint, armOpener, out } = ctx;
171
194
  // The poll is single-flight by construction: one loop, one request in flight.
172
195
  // A concurrent second poll on the same grant would revoke the credential the
173
196
  // first poll received.
@@ -193,7 +216,7 @@ async function pollUntilDecided(grant, ctx) {
193
216
  if (err instanceof Unreachable) {
194
217
  // Said out loud rather than swallowed: an operator watching a long wait
195
218
  // should see that contact was lost and regained, not silence.
196
- process.stdout.write(` (lost contact with the server, still waiting)\n`);
219
+ out(` (lost contact with the server, still waiting)\n`);
197
220
  continue;
198
221
  }
199
222
  throw err;
@@ -222,7 +245,7 @@ async function pollUntilDecided(grant, ctx) {
222
245
  if (!isOrgAddress(issued.org)) {
223
246
  throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
224
247
  }
225
- return finish(issued, endpoint, post);
248
+ return finish(issued, endpoint, post, out);
226
249
  }
227
250
  const body = await errorBody(res);
228
251
  const code = body.code;
@@ -243,7 +266,9 @@ async function pollUntilDecided(grant, ctx) {
243
266
  throw new Error(explained ? `login stopped: ${explained}` : `login failed: ${res.status} ${code}`);
244
267
  }
245
268
  }
246
- async function finish(issued, endpoint, post) {
269
+ async function finish(issued, endpoint, post,
270
+ /** The caller's sink, so every line this command prints goes one place. */
271
+ out) {
247
272
  const secret = secretFromCapabilityUrl(issued.capability_url);
248
273
  // Durable write FIRST. If the process dies between here and activate, the
249
274
  // credential is on disk and the machine can be activated by running login
@@ -265,8 +290,8 @@ async function finish(issued, endpoint, post) {
265
290
  const code = await errorCode(res);
266
291
  throw new Error(`the credential was saved but could not be activated (${res.status} ${code}) — run login again`);
267
292
  }
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`);
293
+ out(`\nSigned in as machine "${issued.machine_name}".\n`);
294
+ out(`Its sync address is saved on this machine; no token to copy.\n`);
270
295
  }
271
296
  async function errorCode(res) {
272
297
  return (await errorBody(res)).code;
@@ -58,6 +58,57 @@ export async function cmdPull(config, opts) {
58
58
  'it asks a machine that already has the team, and the two of you compare eight digits.\n');
59
59
  }
60
60
  }
61
+ /**
62
+ * The advice that follows "no team named X", chosen from what this
63
+ * organization actually holds.
64
+ *
65
+ * The observation and the advice are separated, because only the observation
66
+ * differs. **No answer this command can get establishes whether the team was
67
+ * connected anywhere** — the lookup is scoped to the caller's organization, so
68
+ * it cannot see the rest of the world at all:
69
+ *
70
+ * - A populated organization does not mean the team was connected elsewhere.
71
+ * An org holding `billing` and not `ops` is equally consistent with `ops`
72
+ * never having been connected.
73
+ * - An empty organization does not mean it was not. The case in #224 had it
74
+ * connected into a different org, and that leaves this one empty.
75
+ * - A list that could not be fetched says nothing in either direction.
76
+ *
77
+ * So the advice is one block, the same in all three, and names both
78
+ * possibilities as conditions. The original defect was a remedy stated as an
79
+ * instruction — "connect it from the machine that runs it" — which asserted the
80
+ * connect had not happened. Stating it unconditionally in ANY branch brings the
81
+ * defect back, which is why the regression pins the conditional form rather
82
+ * than the presence of new words.
83
+ *
84
+ * The list is printed in full. A "…and N more" would make the interesting case
85
+ * — the team IS here under a spelling the operator did not expect — the one
86
+ * most likely to be cut off, and an operator who cannot see it reads the
87
+ * message as saying it is not there. That is a deliberate trade against very
88
+ * large organizations, not an oversight.
89
+ */
90
+ const WHERE_TO_LOOK = 'Where the team stands outside this organization is not visible from here.\n' +
91
+ 'Check which account and organization this machine is signed in to, then:\n' +
92
+ ' - if another machine already connected it, sign in to the organization it\n' +
93
+ ' was connected into, or pass --team-id if you know the id;\n' +
94
+ ' - if it has not been connected anywhere yet, connect it from the machine\n' +
95
+ ' that runs it.';
96
+ async function whyNot(client, team) {
97
+ let teams;
98
+ try {
99
+ teams = await client.listTeams();
100
+ }
101
+ catch {
102
+ return `This machine could not list the organization's teams, so what it holds is unknown.\n\n${WHERE_TO_LOOK}`;
103
+ }
104
+ if (teams.length === 0) {
105
+ return `This organization holds no teams, so nothing has been connected into it.\n\n${WHERE_TO_LOOK}`;
106
+ }
107
+ const named = teams.map((t) => ` ${t.teamName ?? '(unnamed)'} ${t.teamId}`).join('\n');
108
+ return (`This organization holds ${teams.length === 1 ? '1 team' : `${teams.length} teams`}, and none of them is "${team}":\n\n` +
109
+ `${named}\n\n` +
110
+ `Check the spelling against that list.\n\n${WHERE_TO_LOOK}`);
111
+ }
61
112
  async function resolveTeamId(config, opts) {
62
113
  const client = opts.client ?? new CourierClient(config);
63
114
  const matches = await client.resolveTeamByName(opts.team);
@@ -65,8 +116,15 @@ async function resolveTeamId(config, opts) {
65
116
  // Named as what it is: this org has no such team. The alternative reading
66
117
  // — that the team exists under someone else — is not ours to confirm or
67
118
  // 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.');
119
+ //
120
+ // What is ours to say is whether this organization holds ANY team, and the
121
+ // two cases need opposite moves. An empty organization means the connect
122
+ // has not happened; a populated one means it happened somewhere else, and
123
+ // "connect it from the machine that runs it" then points at work that is
124
+ // already done — which is what #224 reported. Asking the org for its own
125
+ // list adds no disclosure: it is the same org-scoped endpoint, without the
126
+ // filter.
127
+ throw new Error(`no team named "${opts.team}" in this organization.\n\n${await whyNot(client, opts.team)}`);
70
128
  }
71
129
  if (matches.length > 1) {
72
130
  // A name is not unique by construction, so this stops rather than picking.