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 +20 -1
- package/dist/src/commands/approve.js +15 -1
- package/dist/src/commands/connect.js +110 -24
- package/dist/src/commands/fetch.js +36 -2
- package/dist/src/commands/login.js +39 -14
- package/dist/src/commands/pull.js +60 -2
- package/dist/src/commands/request.js +97 -33
- package/dist/src/commands/sync.js +48 -8
- package/dist/src/commands/vault.js +117 -24
- package/dist/src/commands/whoami.js +44 -0
- package/dist/src/config.js +71 -2
- package/dist/src/index.js +31 -3
- package/dist/src/machine-id.js +44 -0
- package/dist/src/oss.js +15 -1
- package/dist/src/preflight.js +120 -1
- package/dist/src/recovery-key.js +24 -9
- package/dist/src/slot-advice.js +85 -0
- package/dist/src/vault-container.js +39 -0
- package/dist/src/vault-filing.js +62 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
213
|
-
* would fix it, and it is a change nobody should make inside a
|
|
214
|
-
* decides who may take over a team whose first machine is gone, and
|
|
215
|
-
* answer — any live capability in the org — turns a revoked machine
|
|
216
|
-
* machine may become device-0 for that team", which is part of what
|
|
217
|
-
* was for. Named here so the next person who finds this message
|
|
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.
|
|
257
|
-
'
|
|
258
|
-
'
|
|
259
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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.
|