@pingroom/cli 0.7.2 → 0.7.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.
@@ -0,0 +1,726 @@
1
+ // Connecting: QR pairing, the email fallback, Agent Inbox activation, and the
2
+ // bare `pingroom` status/prompt. Everything a human touches once and then never
3
+ // thinks about again.
4
+
5
+ import { EXIT } from '../constants.js';
6
+ import {
7
+ fail, isInteractive, isJsonObject, isNonEmptyString, isNullableString, sleep, stripControlChars,
8
+ } from '../util.js';
9
+ import { HELP, commandHelp } from '../help.js';
10
+ import { apiDetail, httpJson, requireSafeUrl, retryAfterMs } from '../http.js';
11
+ import {
12
+ credentialsPath, readStoredCredential, resolveApiBase, resolveRoom, saveCredential,
13
+ } from '../config.js';
14
+
15
+ // --- connecting (pairing + email fallback) ---------------------------------
16
+ //
17
+ // Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
18
+ // scanning the QR is where the human picks BOTH the account and the delivery
19
+ // room, so an agent can never end up connected with nobody's say-so about where
20
+ // it pings. There is no `login` subcommand: `pingroom` resolves the state.
21
+
22
+ // The scopes this CLI can actually use, one per command surface. Requested at
23
+ // registration so the approval screen shows exactly what it is granting; the
24
+ // server intersects, so asking for less is always safe and asking for more than
25
+ // the human approves is impossible.
26
+ const CLI_SCOPES = [
27
+ 'pingroom:rooms:read', // resolve/display the connected room
28
+ 'pingroom:broadcast:send', // ping
29
+ 'pingroom:attachments:write', // ping --attach (the upload leg)
30
+ 'pingroom:notifications:read',// listen
31
+ 'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
32
+ 'pingroom:handoffs:create', // handoff / handoffs
33
+ 'pingroom:live:write', // live start/update/end/get
34
+ ];
35
+
36
+ // What the human reads on the approval screen. A product name, not a package
37
+ // id: the phone shows it verbatim ("PingRoom CLI wants to connect").
38
+ const AGENT_LABEL = 'PingRoom CLI';
39
+ // A connect command should prove the phone round-trip, but it must not hold a
40
+ // terminal for the onboarding Question's full 24-hour server TTL. The Question
41
+ // remains answerable after this local deadline and the credential is already
42
+ // durable before the wait begins.
43
+ const ACTIVATION_MAX_WAIT_MS = 2 * 60 * 1000;
44
+ // The wait route is limited to 30 requests/minute. Keep immediate pending or
45
+ // answered-without-completion observations safely below that ceiling while a
46
+ // mixed-version or commit-propagation race is still being reconciled.
47
+ const ACTIVATION_MIN_POLL_INTERVAL_MS = 2100;
48
+
49
+ function activationMaxWaitMs() {
50
+ // Keep production fixed at two minutes. The guarded override lets the real
51
+ // subprocess tests exercise deadline behavior without holding the suite for
52
+ // two minutes; it is ignored outside NODE_ENV=test.
53
+ if (process.env.NODE_ENV === 'test') {
54
+ const testValue = Number(process.env.PINGROOM_INTERNAL_ACTIVATION_TIMEOUT_MS);
55
+ if (Number.isInteger(testValue) && testValue > 0 && testValue <= ACTIVATION_MAX_WAIT_MS) {
56
+ return testValue;
57
+ }
58
+ }
59
+ return ACTIVATION_MAX_WAIT_MS;
60
+ }
61
+
62
+ // Widest QR we render (compact half-block form of a ~110-char pair URL is 39
63
+ // columns). Anything narrower would wrap and become unscannable, so we print
64
+ // the URL alone instead of a broken QR.
65
+ const QR_MIN_COLUMNS = 41;
66
+
67
+ /**
68
+ * Draw the pair URL as a scannable QR. Returns false when it could not — a too
69
+ * narrow terminal, or the optional dependency being absent (someone vendored
70
+ * just bin/) — and the caller falls back to the printed URL, which always works.
71
+ */
72
+ async function renderQr(url) {
73
+ // A real terminal reports its width on the stream; COLUMNS covers the rest.
74
+ // Unknown width is treated as wide enough — the URL is printed either way.
75
+ const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
76
+ if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
77
+
78
+ let qr;
79
+ try {
80
+ const mod = await import('qrcode-terminal');
81
+ qr = mod.default || mod;
82
+ } catch { return false; }
83
+ if (!qr || typeof qr.generate !== 'function') return false;
84
+
85
+ try {
86
+ let art = '';
87
+ // Call it as a method: qrcode-terminal reads its error-correction level off
88
+ // `this`, so a detached `generate` reference silently builds a version-1
89
+ // code and throws on anything longer than a few characters.
90
+ // `small` is the half-block form: two module rows per text row, so the code
91
+ // stays square-ish and fits an 80-column terminal.
92
+ qr.generate(url, { small: true }, (rendered) => { art = rendered; });
93
+ if (!art) return false;
94
+ process.stdout.write(`\n${art}\n`);
95
+ return true;
96
+ } catch { return false; }
97
+ }
98
+
99
+ /**
100
+ * A line-at-a-time reader over stdin.
101
+ *
102
+ * Deliberately not node:readline: its Interface keeps consuming while we are
103
+ * awaiting an HTTP round trip between two questions and drops the lines nobody
104
+ * is listening for, which silently loses piped answers. This queues every line
105
+ * instead, so the answers can arrive in one blob or one keystroke at a time.
106
+ *
107
+ * ask() resolves `null` — never a string — once the input is closed, so it can
108
+ * never be confused with a real empty line. That distinction is load-bearing:
109
+ * callers treat an empty line as "take the default", and a caller that reads EOF
110
+ * as an empty line will take that default again on the next question, and the
111
+ * next, forever, because nothing will ever arrive to change its mind. Callers
112
+ * that genuinely want the empty-line behaviour opt in with `?? ''`.
113
+ */
114
+ function createPrompter() {
115
+ const queued = [];
116
+ const waiting = [];
117
+ let buffer = '';
118
+ let closed = false;
119
+
120
+ const deliver = (line) => {
121
+ const waiter = waiting.shift();
122
+ if (waiter) waiter(line);
123
+ else queued.push(line);
124
+ };
125
+ const onData = (chunk) => {
126
+ buffer += chunk;
127
+ let idx;
128
+ while ((idx = buffer.indexOf('\n')) !== -1) {
129
+ deliver(buffer.slice(0, idx).replace(/\r$/, ''));
130
+ buffer = buffer.slice(idx + 1);
131
+ }
132
+ };
133
+ const onEnd = () => {
134
+ if (closed) return;
135
+ closed = true;
136
+ if (buffer) { deliver(buffer); buffer = ''; }
137
+ while (waiting.length) waiting.shift()(null);
138
+ };
139
+
140
+ process.stdin.setEncoding('utf8');
141
+ process.stdin.on('data', onData);
142
+ process.stdin.once('end', onEnd);
143
+ process.stdin.resume();
144
+
145
+ return {
146
+ ask(question) {
147
+ process.stdout.write(question);
148
+ if (queued.length > 0) return Promise.resolve(queued.shift());
149
+ if (closed) return Promise.resolve(null);
150
+ return new Promise((resolve) => { waiting.push(resolve); });
151
+ },
152
+ close() {
153
+ process.stdin.off('data', onData);
154
+ process.stdin.off('end', onEnd);
155
+ process.stdin.pause();
156
+ },
157
+ };
158
+ }
159
+
160
+ /** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
161
+ async function registerAnonymous(apiBase) {
162
+ const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
163
+ body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
164
+ });
165
+ if (!res.ok || !json || typeof json.credential !== 'string') {
166
+ const detail = apiDetail(res, json);
167
+ fail(`could not start a connection: ${detail}`);
168
+ }
169
+ return json.credential;
170
+ }
171
+
172
+ /**
173
+ * "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown,
174
+ * and widened to "→ all rooms" / "→ #Project X +2 more" when the human granted
175
+ * this agent more than the one delivery room.
176
+ */
177
+ function connectedLine(cred) {
178
+ const who = cred.handle ? `@${cred.handle}` : 'this machine';
179
+ const room = cred.room && (cred.room.name || cred.room.invite_code);
180
+ const access = cred.room_access ?? cred.roomAccess;
181
+
182
+ if (access === 'all') return `✓ Connected as ${who} → all rooms`;
183
+
184
+ if (!room) return `✓ Connected as ${who}`;
185
+
186
+ const extra = Math.max(0, (Array.isArray(cred.rooms) ? cred.rooms.length : 0) - 1);
187
+ return `✓ Connected as ${who} → #${room}${extra > 0 ? ` +${extra} more` : ''}`;
188
+ }
189
+
190
+ function activationFailureDetail(result) {
191
+ if (result.error) return result.error.message;
192
+ const status = result.res ? `HTTP ${result.res.status}` : 'request failed';
193
+ return (result.json && (result.json.message || result.json.error || result.json.code)) || status;
194
+ }
195
+
196
+ function validateActivationEnsure(json) {
197
+ const room = json?.room;
198
+ const question = json?.question;
199
+ const validState = question?.state === 'pending'
200
+ || question?.state === 'answered'
201
+ || question?.state === 'expired'
202
+ || question?.state === 'cancelled';
203
+ if (
204
+ !isJsonObject(json)
205
+ || json.onboarded !== true
206
+ || typeof json.replayed !== 'boolean'
207
+ || !isJsonObject(room)
208
+ || !isNonEmptyString(room.id)
209
+ || typeof room.name !== 'string'
210
+ || !isNonEmptyString(room.invite_code)
211
+ || typeof room.is_agent_inbox !== 'boolean'
212
+ || !isJsonObject(question)
213
+ || !isNonEmptyString(question.id)
214
+ || question.kind !== 'question'
215
+ || !isNonEmptyString(question.prompt)
216
+ || !Array.isArray(question.options)
217
+ || question.options.some((option) => (
218
+ !isJsonObject(option)
219
+ || !isNonEmptyString(option.value)
220
+ || !isNonEmptyString(option.label)
221
+ ))
222
+ || !validState
223
+ || !isNullableString(question.expires_at)
224
+ || !isNullableString(question.created_at)
225
+ ) {
226
+ return { error: 'PingRoom returned an incomplete Agent Inbox ensure response' };
227
+ }
228
+ return { question };
229
+ }
230
+
231
+ function validateActivationWait(json, questionId) {
232
+ const state = json?.state;
233
+ const validState = state === 'pending' || state === 'answered' || state === 'expired' || state === 'cancelled';
234
+ if (
235
+ !isJsonObject(json)
236
+ || !isNonEmptyString(json.id)
237
+ || json.id !== questionId
238
+ || json.kind !== 'question'
239
+ || !validState
240
+ || (json.activation_completed !== undefined && typeof json.activation_completed !== 'boolean')
241
+ || (state !== 'answered' && json.activation_completed === true)
242
+ ) {
243
+ return { error: 'PingRoom returned a mismatched Agent Inbox wait response' };
244
+ }
245
+
246
+ if (state === 'answered') {
247
+ const answer = json.answer;
248
+ const responder = answer?.responder;
249
+ if (
250
+ !isJsonObject(answer)
251
+ || !isNullableString(answer.value)
252
+ || !isNullableString(answer.label)
253
+ || !isNullableString(answer.text)
254
+ || (!isNonEmptyString(answer.value) && !isNonEmptyString(answer.text))
255
+ || !isNullableString(answer.answered_at)
256
+ || (responder !== null && !isJsonObject(responder))
257
+ || (isJsonObject(responder)
258
+ && (!isNullableString(responder.id) || !isNullableString(responder.display_name)))
259
+ ) {
260
+ return { error: 'PingRoom returned an answered activation without a valid answer' };
261
+ }
262
+ } else if (json.answer !== undefined && json.answer !== null) {
263
+ return { error: 'PingRoom returned an answer for an unresolved activation' };
264
+ }
265
+
266
+ return { value: json };
267
+ }
268
+
269
+ function activationRetryDelay(result, transientRun, deadline) {
270
+ const fromHeader = result.res?.status === 429 ? retryAfterMs(result.res) : null;
271
+ const fallback = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 10_000);
272
+ return Math.max(0, Math.min(fromHeader ?? fallback, deadline - Date.now()));
273
+ }
274
+
275
+ function activationIncomplete(detail, instruction = 'Run "pingroom activate" to retry with this saved connection.') {
276
+ const safeDetail = detail ? `: ${stripControlChars(detail)}` : '';
277
+ process.stdout.write(` Agent Inbox activation is not complete${safeDetail}\n`);
278
+ process.stdout.write(' Your connection is saved and usable.\n');
279
+ process.stdout.write(` ${instruction}\n`);
280
+ }
281
+
282
+ /**
283
+ * Prove the freshly paired credential can complete a human round-trip. This is
284
+ * intentionally best-effort: saveCredential() has already committed the active
285
+ * bearer atomically, so no activation outage can roll back or corrupt it.
286
+ */
287
+ async function activateInboxAfterPairing(cred) {
288
+ const headers = { Authorization: `Bearer ${cred.token}` };
289
+ const overallDeadline = Date.now() + activationMaxWaitMs();
290
+ process.stdout.write(' Sending a test question to PingRoom…\n');
291
+
292
+ let ensured;
293
+ let ensureTransientRun = 0;
294
+ while (Date.now() < overallDeadline) {
295
+ ensured = await httpJson('POST', `${cred.apiBase}/api/agent/inbox/ensure`, {
296
+ body: {},
297
+ headers,
298
+ soft: true,
299
+ signal: AbortSignal.timeout(Math.max(1, Math.min(15_000, overallDeadline - Date.now()))),
300
+ });
301
+ const transient = ensured.error || ensured.res?.status === 429 || ensured.res?.status >= 500;
302
+ if (!transient) break;
303
+ ensureTransientRun += 1;
304
+ await sleep(activationRetryDelay(ensured, ensureTransientRun, overallDeadline));
305
+ }
306
+
307
+ if (!ensured.res?.ok) {
308
+ const detail = Date.now() >= overallDeadline
309
+ ? 'the two-minute activation deadline elapsed while PingRoom was unavailable'
310
+ : activationFailureDetail(ensured);
311
+ activationIncomplete(detail);
312
+ return false;
313
+ }
314
+
315
+ const ensureEnvelope = validateActivationEnsure(ensured.json);
316
+ if (ensureEnvelope.error) {
317
+ activationIncomplete(ensureEnvelope.error);
318
+ return false;
319
+ }
320
+ const { question } = ensureEnvelope;
321
+
322
+ process.stdout.write(' Answer “PingRoom connected. Can you answer this?” on your phone.\n');
323
+ // The server stamp, not the terminal state by itself, is the activation
324
+ // authority. A terminal answer without the stamp cannot become a valid
325
+ // receipt-before-answer sequence later, so fail clearly instead of polling a
326
+ // state the server intentionally will not rewrite.
327
+ const deadline = overallDeadline;
328
+ let transientRun = 0;
329
+
330
+ while (Date.now() < deadline) {
331
+ const pollStartedAt = Date.now();
332
+ const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
333
+ const hold = Math.min(20, remainingSeconds);
334
+ const waited = await httpJson(
335
+ 'GET',
336
+ `${cred.apiBase}/api/agent/handoffs/${encodeURIComponent(question.id)}/wait?timeout=${hold}`,
337
+ {
338
+ headers,
339
+ soft: true,
340
+ signal: AbortSignal.timeout(Math.max(1, Math.min(
341
+ hold * 1000 + 10_000,
342
+ deadline - Date.now(),
343
+ ))),
344
+ },
345
+ );
346
+
347
+ const transient = waited.error || waited.res?.status === 429 || waited.res?.status >= 500;
348
+ if (transient) {
349
+ transientRun += 1;
350
+ const retryDelay = activationRetryDelay(waited, transientRun, deadline);
351
+ const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
352
+ await sleep(Math.max(0, Math.min(Math.max(retryDelay, cadenceDelay), deadline - Date.now())));
353
+ continue;
354
+ }
355
+ transientRun = 0;
356
+
357
+ if (!waited.res?.ok) {
358
+ activationIncomplete(activationFailureDetail(waited));
359
+ return false;
360
+ }
361
+
362
+ const waitEnvelope = validateActivationWait(waited.json, question.id);
363
+ if (waitEnvelope.error) {
364
+ activationIncomplete(waitEnvelope.error);
365
+ return false;
366
+ }
367
+ const resolved = waitEnvelope.value;
368
+ const state = resolved.state;
369
+ if (state === 'answered') {
370
+ if (resolved.activation_completed !== true) {
371
+ activationIncomplete(
372
+ 'the test question was answered without verified phone receipt before the answer',
373
+ 'Update the PingRoom app if needed, then run "pingroom activate" to send a fresh test with this saved connection.',
374
+ );
375
+ return false;
376
+ }
377
+ const answer = resolved.answer.text || resolved.answer.label || resolved.answer.value;
378
+ process.stdout.write(`✓ Test question answered (${stripControlChars(answer)}). Agent Inbox is ready.\n`);
379
+ return true;
380
+ }
381
+ if (state === 'expired' || state === 'cancelled') {
382
+ activationIncomplete(
383
+ `the test question ${state}`,
384
+ 'Run "pingroom activate" to send a fresh test with this saved connection.',
385
+ );
386
+ return false;
387
+ }
388
+ // `pending` at the bounded hold timeout — continue at a throttle-safe
389
+ // cadence until the local/server deadline.
390
+ const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
391
+ await sleep(Math.max(0, Math.min(cadenceDelay, deadline - Date.now())));
392
+ }
393
+
394
+ activationIncomplete(
395
+ 'still waiting for the test answer at the activation deadline',
396
+ );
397
+ return false;
398
+ }
399
+
400
+ /** Retry activation only for the durable credential created by QR pairing. */
401
+ export async function activateStoredInbox(args) {
402
+ if (args.help) { process.stdout.write(`${commandHelp('activate')}\n`); return EXIT.OK; }
403
+ if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
404
+ if (args.token !== undefined) {
405
+ fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
406
+ }
407
+ const unsupported = Object.keys(args).filter((key) => !['_', 'help', 'api', 'token'].includes(key));
408
+ if (unsupported.length > 0) {
409
+ fail('usage: pingroom activate [--api <url>]', EXIT.USAGE);
410
+ }
411
+
412
+ const credential = readStoredCredential();
413
+ if (!credential) {
414
+ fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
415
+ }
416
+ if (!credential.room || !isNonEmptyString(credential.room.invite_code)) {
417
+ // Granting every room is a valid answer that pins no destination, so the
418
+ // fix there is picking one — not pairing again, which would only offer the
419
+ // same choice back.
420
+ fail(
421
+ credential.room_access === 'all'
422
+ ? 'this agent was granted all rooms but no delivery room; pick one in the PingRoom app under Connected Agents, then run "pingroom activate" again'
423
+ : 'the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"',
424
+ EXIT.USAGE,
425
+ );
426
+ }
427
+ if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
428
+ fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
429
+ }
430
+
431
+ const apiBase = resolveApiBase(args);
432
+ requireSafeUrl('--api', apiBase);
433
+ if (!isNonEmptyString(credential.api_url)) {
434
+ fail('the saved QR-paired credential has no trusted API origin; pair again before running "pingroom activate"', EXIT.USAGE);
435
+ }
436
+ let credentialOrigin;
437
+ let targetOrigin;
438
+ try {
439
+ credentialOrigin = new URL(credential.api_url).origin;
440
+ targetOrigin = new URL(apiBase).origin;
441
+ } catch {
442
+ fail('the saved QR-paired credential has an invalid API origin; pair again', EXIT.USAGE);
443
+ }
444
+ if (credentialOrigin !== targetOrigin) {
445
+ fail(`stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}`, EXIT.USAGE);
446
+ }
447
+ process.stdout.write(`${connectedLine(credential)}\n`);
448
+
449
+ const completed = await activateInboxAfterPairing({
450
+ ...credential,
451
+ apiBase,
452
+ });
453
+ return completed ? EXIT.OK : EXIT.ERROR;
454
+ }
455
+
456
+ /**
457
+ * The QR path. Mints a pre-claim credential, asks the server for a pairing
458
+ * token, renders it, then polls until the human approves. Returns a credential
459
+ * object, or null when the pairing lapsed and the user declined a fresh one.
460
+ */
461
+ async function connectByPairing(apiBase, ask) {
462
+ for (;;) {
463
+ const preClaim = await registerAnonymous(apiBase);
464
+ const headers = { Authorization: `Bearer ${preClaim}` };
465
+
466
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
467
+ body: { scopes: CLI_SCOPES },
468
+ headers,
469
+ });
470
+ if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
471
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
472
+ || `HTTP ${start.res.status}`;
473
+ fail(`could not start pairing: ${detail}`);
474
+ }
475
+
476
+ // The URL is server-controlled and goes straight to the terminal, so strip
477
+ // C0/C1 controls: an --api / config api_url pointing at a hostile host could
478
+ // otherwise emit ANSI escapes that repaint or hide the line the user is
479
+ // about to trust with their account.
480
+ const pairUrl = stripControlChars(start.json.pair_url);
481
+ // 900s is the server's pre-claim lifetime; never poll past it, and clamp the
482
+ // server's suggested interval so a bad value can't busy-loop or stall.
483
+ // The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
484
+ // pair/status at `60,1`, so a faster floor spends the pairing window
485
+ // collecting 429s instead of the approval.
486
+ const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
487
+ const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
488
+ const deadline = Date.now() + lifetimeMs;
489
+
490
+ const drew = await renderQr(pairUrl);
491
+ process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
492
+ process.stdout.write(' Waiting for approval… ');
493
+
494
+ // A transient failure must not end a wait the human is mid-way through.
495
+ // Network errors, 5xx and 429 are the load balancer / rate limiter talking,
496
+ // not the pairing being over; hard-failing on the first one throws away the
497
+ // whole 15 minutes over a single blip. 401/403/404 still exit immediately —
498
+ // those say the pre-claim is gone, and retrying can only spin.
499
+ // The `Date.now() < deadline` bound is what keeps a *persistent* outage from
500
+ // retrying forever: it ends at the same moment a clean poll would have.
501
+ let transientRun = 0;
502
+ let lastTransient = null;
503
+ let warnedTransient = false;
504
+
505
+ while (Date.now() < deadline) {
506
+ const { res, json, error } = await httpJson(
507
+ 'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
508
+ );
509
+
510
+ if (error || res.status >= 500 || res.status === 429) {
511
+ transientRun += 1;
512
+ lastTransient = error
513
+ ? error.message
514
+ : `HTTP ${res.status}`;
515
+ // Say something rather than sitting mute: a user watching a QR with no
516
+ // output cannot tell a slow approval from a broken endpoint.
517
+ if (transientRun === 3 && !warnedTransient) {
518
+ warnedTransient = true;
519
+ process.stdout.write(`\n (still trying — ${lastTransient}) `);
520
+ }
521
+ // Ride out a short blip at the normal cadence, then back off
522
+ // geometrically so a real outage is not also a thundering herd. Never
523
+ // sleep past the deadline this loop is bounded by.
524
+ const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
525
+ await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
526
+ continue;
527
+ }
528
+
529
+ transientRun = 0;
530
+
531
+ if (!res.ok) {
532
+ process.stdout.write('\n');
533
+ const detail = apiDetail(res, json);
534
+ fail(`pairing failed: ${detail}`);
535
+ }
536
+ const status = json && json.status;
537
+ if (status === 'active') {
538
+ // A server that says "active" with no credential has not paired us.
539
+ // Without this, `token: undefined` is written to credentials.json and
540
+ // every later command reads a credential file that exists but cannot
541
+ // authenticate — a far more confusing failure than stopping here.
542
+ if (typeof json.credential !== 'string' || json.credential === '') {
543
+ process.stdout.write('\n');
544
+ fail('pairing succeeded but the server returned no credential');
545
+ }
546
+ const cred = {
547
+ token: json.credential,
548
+ handle: json.handle,
549
+ room: json.room,
550
+ rooms: Array.isArray(json.rooms) ? json.rooms : [],
551
+ roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
552
+ account: json.account,
553
+ scopes: json.scopes,
554
+ apiBase,
555
+ };
556
+ saveCredential(cred);
557
+ process.stdout.write(`${connectedLine(cred)}\n`);
558
+ // Connecting deliberately sends nothing to the human's phone. The
559
+ // approval they just tapped IS the round-trip; a test Question on top of
560
+ // it was one more thing to answer before the tool could be used, and it
561
+ // made a healthy connection look broken whenever the answer was slow.
562
+ // `pingroom activate` still sends one for anyone who wants the proof.
563
+ return cred;
564
+ }
565
+ if (status === 'expired') break;
566
+ // `pending` (or anything unrecognized) — keep waiting.
567
+ await sleep(intervalMs);
568
+ }
569
+
570
+ if (transientRun > 0) {
571
+ process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
572
+ } else {
573
+ process.stdout.write(`\n That code expired.\n`);
574
+ }
575
+
576
+ // `null` means the input is closed, and that is the whole point of this
577
+ // guard. Reading EOF as "" would fall through the y/yes test below (empty
578
+ // means "take the default: yes"), restart the for(;;), mint another
579
+ // anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
580
+ // a single pairing attempt into thousands of registrations against the API.
581
+ const again = await ask(' Show a fresh QR code? [Y/n]: ');
582
+ if (again === null) { process.stdout.write('\n'); return null; }
583
+ const answer = again.trim().toLowerCase();
584
+ if (answer && answer !== 'y' && answer !== 'yes') return null;
585
+ }
586
+ }
587
+
588
+ /**
589
+ * The email fallback, over the unchanged claim/* endpoints: the server mails a
590
+ * link, the web page shows a 6-digit code, the user reads it back here.
591
+ */
592
+ async function connectByEmail(apiBase, ask) {
593
+ const preClaim = await registerAnonymous(apiBase);
594
+ const headers = { Authorization: `Bearer ${preClaim}` };
595
+
596
+ // `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
597
+ // at EOF, and without the coalesce this would throw a TypeError on `.trim()`
598
+ // instead of reaching the "this is required" error the user should see.
599
+ const email = (await ask(' Your PingRoom email: ') ?? '').trim();
600
+ if (!email) fail('an email address is required', EXIT.USAGE);
601
+
602
+ const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
603
+ body: { email },
604
+ headers,
605
+ });
606
+ if (!start.res.ok) {
607
+ const detail = (start.json && (start.json.message || start.json.error || start.json.code))
608
+ || `HTTP ${start.res.status}`;
609
+ fail(`could not send the email: ${detail}`);
610
+ }
611
+
612
+ process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
613
+
614
+ // A mistyped code is the common case, so allow a few tries before giving up.
615
+ // The server locks the registration out after its own attempt cap anyway.
616
+ for (let attempt = 1; attempt <= 3; attempt++) {
617
+ // Same reason as the email prompt: EOF stays an empty answer, which the
618
+ // server rejects, rather than a TypeError on null.
619
+ const otp = (await ask(' Code: ') ?? '').trim();
620
+ const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
621
+ body: { email, otp },
622
+ headers,
623
+ });
624
+ if (done.res.ok && done.json && typeof done.json.credential === 'string') {
625
+ const cred = {
626
+ token: done.json.credential,
627
+ handle: done.json.handle,
628
+ // claim/complete carries no room — the email flow does not choose one.
629
+ room: done.json.room,
630
+ account: done.json.account,
631
+ scopes: done.json.scopes,
632
+ apiBase,
633
+ };
634
+ saveCredential(cred);
635
+ process.stdout.write(`${connectedLine(cred)}\n`);
636
+ if (!cred.room) {
637
+ process.stdout.write(' For room commands: pingroom config set default_room <invite code>\n');
638
+ process.stdout.write(' For private Inbox/Handoff delivery, reconnect with QR pairing.\n');
639
+ }
640
+ return cred;
641
+ }
642
+ const detail = (done.json && (done.json.message || done.json.error || done.json.code))
643
+ || `HTTP ${done.res.status}`;
644
+ if (attempt === 3) fail(`could not connect: ${detail}`);
645
+ process.stderr.write(`pingroom: ${detail}\n`);
646
+ }
647
+ return null;
648
+ }
649
+
650
+ /**
651
+ * Resolve the unconnected state interactively. Refuses outright when there is no
652
+ * TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
653
+ * PINGROOM_TOKEN, not a QR nobody can scan.
654
+ */
655
+ export async function connect(args) {
656
+ if (!isInteractive()) {
657
+ fail(
658
+ 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
659
+ EXIT.USAGE,
660
+ );
661
+ }
662
+
663
+ const apiBase = resolveApiBase(args);
664
+ requireSafeUrl('--api', apiBase);
665
+
666
+ const prompter = createPrompter();
667
+ const ask = (question) => prompter.ask(question);
668
+ try {
669
+ process.stdout.write(' Not connected. How do you want to connect?\n');
670
+ process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
671
+ process.stdout.write(' 2) Email me a code\n');
672
+ // EOF here means "no answer", which is what the default already covers, so
673
+ // coalesce rather than crash on null — the pairing branch below is the one
674
+ // that must distinguish EOF, and it does.
675
+ const choice = (await ask(' Choose [1]: ') ?? '').trim();
676
+ if (choice && choice !== '1' && choice !== '2') {
677
+ process.stderr.write('pingroom: choose 1 or 2\n');
678
+ return EXIT.USAGE;
679
+ }
680
+
681
+ const cred = choice === '2'
682
+ ? await connectByEmail(apiBase, ask)
683
+ : await connectByPairing(apiBase, ask);
684
+
685
+ return cred ? EXIT.OK : EXIT.EXPIRED;
686
+ } finally {
687
+ prompter.close();
688
+ }
689
+ }
690
+
691
+ // --- status / bare invocation ----------------------------------------------
692
+
693
+ /**
694
+ * `pingroom` with no arguments. Connected -> one status line then the usual
695
+ * help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
696
+ * and still print the help rather than prompting into the void.
697
+ */
698
+ export async function bare(args) {
699
+ const envToken = process.env.PINGROOM_TOKEN;
700
+ const stored = readStoredCredential();
701
+
702
+ if (envToken) {
703
+ process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
704
+ if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
705
+ const room = resolveRoom(args);
706
+ if (room) process.stdout.write(`Default room: ${room}\n`);
707
+ process.stdout.write(`\n${HELP}\n`);
708
+ return EXIT.OK;
709
+ }
710
+
711
+ if (stored) {
712
+ process.stdout.write(`${connectedLine(stored)}\n`);
713
+ const room = resolveRoom(args);
714
+ if (room) process.stdout.write(`Default room: ${room}\n`);
715
+ process.stdout.write(`\n${HELP}\n`);
716
+ return EXIT.OK;
717
+ }
718
+
719
+ if (!isInteractive()) {
720
+ process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
721
+ process.stdout.write(`${HELP}\n`);
722
+ return EXIT.OK;
723
+ }
724
+
725
+ return connect(args);
726
+ }