@pingroom/cli 0.2.0 → 0.6.0
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/LICENSE +21 -0
- package/README.md +268 -2
- package/bin/pingroom.js +1835 -45
- package/package.json +4 -1
package/bin/pingroom.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
|
|
3
|
-
//
|
|
3
|
+
// Node's built-in fetch (Node >= 20) plus one optional dependency,
|
|
4
|
+
// `qrcode-terminal`, used only to draw the pairing QR. Its absence degrades to
|
|
5
|
+
// printing the pair URL, so every non-interactive path stays dependency-free.
|
|
6
|
+
//
|
|
7
|
+
// Run bare (`pingroom`) it resolves its own auth: connected -> a status line and
|
|
8
|
+
// this help; not connected -> the pairing picker. There is deliberately no
|
|
9
|
+
// `login` subcommand.
|
|
4
10
|
//
|
|
5
11
|
// Commands:
|
|
6
12
|
// ping Send a ping to a room. Webhook mode (a room URL carries its own
|
|
@@ -10,10 +16,32 @@
|
|
|
10
16
|
// watch Block until a question resolves and print the outcome.
|
|
11
17
|
// list List the agent's questions by state.
|
|
12
18
|
// cancel Withdraw a pending question.
|
|
19
|
+
// handoff Hand a decision to a specific human (ack or question) and, with
|
|
20
|
+
// --wait, block until they acknowledge / answer.
|
|
21
|
+
// handoffs List the agent's open handoffs or bounded recent history.
|
|
22
|
+
// live Drive a live progress card (iOS Live Activity / Android live
|
|
23
|
+
// update) on the room members' lock screen: start / update / end.
|
|
24
|
+
// config Read/write ~/.pingroom/config.json (default_room, api_url).
|
|
25
|
+
// logout Forget the credential in ~/.pingroom/credentials.json.
|
|
13
26
|
//
|
|
14
|
-
// Exit codes: 0 success/answered · 1 error · 2 bad usage · 3 expired ·
|
|
27
|
+
// Exit codes: 0 success/answered/acked · 1 error · 2 bad usage · 3 expired ·
|
|
28
|
+
// 4 cancelled/recipient-not-ready.
|
|
29
|
+
|
|
30
|
+
import { randomBytes } from 'node:crypto';
|
|
31
|
+
import {
|
|
32
|
+
appendFileSync, chmodSync, closeSync, fchmodSync, mkdirSync, openSync,
|
|
33
|
+
readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
34
|
+
} from 'node:fs';
|
|
35
|
+
import { homedir } from 'node:os';
|
|
36
|
+
import { join } from 'node:path';
|
|
15
37
|
|
|
16
|
-
|
|
38
|
+
// Kept in lockstep with package.json / package-lock.json / action.yml (a test
|
|
39
|
+
// asserts the GitHub Action pins this exact version). `hook --print-config`
|
|
40
|
+
// emits an `npx @pingroom/cli@<VERSION>` command, so it must match too.
|
|
41
|
+
const VERSION = '0.6.0';
|
|
42
|
+
|
|
43
|
+
const BUILTIN_API = 'https://api.pingroom.io';
|
|
44
|
+
const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
|
|
17
45
|
|
|
18
46
|
const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
19
47
|
|
|
@@ -26,42 +54,145 @@ Commands:
|
|
|
26
54
|
watch Block until a question resolves and print the outcome
|
|
27
55
|
list List the agent's questions by state
|
|
28
56
|
cancel Withdraw a pending question
|
|
57
|
+
handoff Hand a decision (ack or question) to a specific human; with --wait,
|
|
58
|
+
block until they acknowledge or answer
|
|
59
|
+
handoffs List the agent's open handoffs or bounded recent history
|
|
60
|
+
live Drive a live progress card on the lock screen (Live Activity)
|
|
61
|
+
hook Claude Code hook: ping on Stop/Notification, and route tool
|
|
62
|
+
permission prompts to a PingRoom question you answer from your phone
|
|
63
|
+
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
64
|
+
logout Forget the stored credential
|
|
29
65
|
|
|
30
66
|
ping options:
|
|
31
67
|
-m, --message <text> Ping body text (required)
|
|
32
68
|
-t, --title <text> Ping title (<= 40 chars)
|
|
33
69
|
-a, --action <1-4> Quick-action slot to attribute the ping to
|
|
34
70
|
-d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
|
|
71
|
+
--url <https-url> Make the ping a tappable link (absolute http(s) URL)
|
|
72
|
+
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
73
|
+
--require-ack Keep the ping open until an eligible recipient acknowledges it
|
|
74
|
+
--ack-timeout <s> Ack deadline in seconds (requires --require-ack)
|
|
35
75
|
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
36
76
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
37
77
|
--room <code> Room invite code (used with --token)
|
|
38
78
|
|
|
39
79
|
ask options (agent token required):
|
|
40
80
|
-p, --prompt <text> The question a human reads (required)
|
|
41
|
-
-o, --option <v:label>
|
|
81
|
+
-o, --option <v:label[:style]>
|
|
82
|
+
An answer option (style: primary|danger|default);
|
|
83
|
+
repeat for 2–4. Omit for Approve/Deny
|
|
42
84
|
-c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
|
|
43
85
|
--scope <s> Who answers: 'direct' (default) or 'room'
|
|
44
86
|
--target <uuid> For --scope direct: a specific room member
|
|
45
87
|
--ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
|
|
88
|
+
--text-input <ph> Invite a short typed answer; <ph> is the placeholder
|
|
89
|
+
--text-max <n> Max typed-answer length (1..60)
|
|
46
90
|
--wait Block until answered/expired/cancelled
|
|
47
91
|
--timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
|
|
48
92
|
-d, --data <json> Structured data object echoed back on the answer
|
|
49
93
|
--correlation-id <id> Opaque id echoed on every read of this question
|
|
94
|
+
--reply-to <id> Id of the ping this question replies to
|
|
50
95
|
--room <code> Room invite code (required for ask)
|
|
51
96
|
|
|
52
97
|
list options:
|
|
53
98
|
--state <s> pending | answered | expired | cancelled | all
|
|
54
99
|
|
|
100
|
+
handoff options (agent token required; consent scope pingroom:handoffs:create):
|
|
101
|
+
-m, --message <text> The prompt a human reads (required)
|
|
102
|
+
--question Make it a question (else a simple acknowledge). Also
|
|
103
|
+
implied whenever one or more --option is given.
|
|
104
|
+
-o, --option <v:label> A question option; repeat for 2–4. Requires --question.
|
|
105
|
+
--target <id> Recipient: 'me' (default) or a specific user uuid
|
|
106
|
+
--expires-in <s> Expiry in seconds (120..86400, default 900)
|
|
107
|
+
--urgency <u> 'active' (default) or 'passive'
|
|
108
|
+
--idempotency-key <key> Dedupe key; retries reuse it (Idempotency-Key)
|
|
109
|
+
--correlation-id <id> Opaque id echoed on every read of this handoff
|
|
110
|
+
--reply-to <id> Opaque reply-to id echoed back
|
|
111
|
+
-d, --data <json> Structured data object echoed on the handoff
|
|
112
|
+
--wait Block until acked / answered / expired / cancelled
|
|
113
|
+
--timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
|
|
114
|
+
--github-output <path> Safely append handoff outputs for GitHub Actions
|
|
115
|
+
|
|
116
|
+
handoffs options (agent token required; consent scope pingroom:handoffs:create):
|
|
117
|
+
--state <s> open | all (default open)
|
|
118
|
+
|
|
119
|
+
live <start|update|end|get> options (agent token, or a room webhook):
|
|
120
|
+
-c, --correlation-id <id> The stream key — reuse it for every ping (required)
|
|
121
|
+
--template <name> start only: status | steps | progress | metrics |
|
|
122
|
+
countdown | question | matchup (fixed at creation)
|
|
123
|
+
--category <name> start only: status | steps | alert. Legacy, but
|
|
124
|
+
'alert' has no template equivalent and is the only
|
|
125
|
+
way to start time-sensitive without --require-ack
|
|
126
|
+
--steps <a,b,c> start only: 2-8 comma-separated step labels
|
|
127
|
+
-m, --message <text> The card's live message line
|
|
128
|
+
--progress <0..1> Progress bar / Dynamic Island gauge
|
|
129
|
+
--step <n> Current step index (steps template)
|
|
130
|
+
--metric <label:value> Repeatable, up to 3 (metrics template)
|
|
131
|
+
--deadline-at <epoch> Countdown target (countdown template)
|
|
132
|
+
--eta-at <epoch> Live ETA (status/progress templates)
|
|
133
|
+
--prompt <text> The ask (question template)
|
|
134
|
+
--option <value:label> Repeatable, up to 4 (question template). A bare
|
|
135
|
+
token is both value and label
|
|
136
|
+
--left <label:value> Left side (matchup template)
|
|
137
|
+
--right <label:value> Right side (matchup template)
|
|
138
|
+
--center <text> Center score/clock, <= 40 (matchup template)
|
|
139
|
+
--accent-override <#rrggbb> Semantic accent for this frame
|
|
140
|
+
--failed end only: finish as failed instead of done
|
|
141
|
+
-t, --title <text> Card title (<= 40 chars)
|
|
142
|
+
-a, --action <1-4> Quick-action slot supplying the icon and sound
|
|
143
|
+
--require-ack Add an Acknowledge button
|
|
144
|
+
--ack-timeout <s> Ack deadline in seconds
|
|
145
|
+
--room <code> Room invite code (used with --token)
|
|
146
|
+
-w, --webhook <url> Room webhook URL instead of a token
|
|
147
|
+
|
|
148
|
+
hook options (agent token required; reads a Claude Code hook event on stdin):
|
|
149
|
+
--room <code> Room invite code (or env PINGROOM_ROOM)
|
|
150
|
+
--ttl <seconds> Approval-question expiry for PreToolUse (default 900)
|
|
151
|
+
--quiet Suppress the informational stderr lines
|
|
152
|
+
--print-config Print a ready-to-paste ~/.claude/settings.json block
|
|
153
|
+
|
|
154
|
+
config options:
|
|
155
|
+
pingroom config list Print the stored settings
|
|
156
|
+
pingroom config get <key> Print one setting
|
|
157
|
+
pingroom config set <key> <val> Store a setting (an empty value clears it)
|
|
158
|
+
Keys: default_room, api_url
|
|
159
|
+
|
|
55
160
|
Shared:
|
|
56
161
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
57
162
|
--api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
|
|
58
163
|
--json Print the raw JSON response
|
|
59
164
|
-h, --help Show this help
|
|
60
165
|
|
|
166
|
+
Connecting:
|
|
167
|
+
Run "pingroom" with no arguments to connect. It prints a QR code you scan with
|
|
168
|
+
the PingRoom app — you pick the account and the delivery room there — or you
|
|
169
|
+
can choose the emailed-code fallback. There is no "login" command: being
|
|
170
|
+
unconnected is a state the tool resolves, not one you have to discover.
|
|
171
|
+
|
|
172
|
+
The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
|
|
173
|
+
0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
|
|
174
|
+
environment ALWAYS wins over the stored credential, so CI is unaffected.
|
|
175
|
+
"pingroom logout" forgets it.
|
|
176
|
+
|
|
177
|
+
Settings precedence, highest first:
|
|
178
|
+
explicit flag > env var > ~/.pingroom/config.json > the paired
|
|
179
|
+
credential > built-in default
|
|
180
|
+
So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
|
|
181
|
+
PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
|
|
182
|
+
beats ${BUILTIN_API}. The credential layer is why a token minted by a
|
|
183
|
+
self-hosted server is never presented to ${BUILTIN_API}.
|
|
184
|
+
|
|
185
|
+
Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
|
|
186
|
+
PINGROOM_TOKEN there instead.
|
|
187
|
+
|
|
61
188
|
Examples:
|
|
62
189
|
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
|
|
63
190
|
pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
|
|
64
191
|
|
|
192
|
+
# Link ping — a tappable button that opens a URL:
|
|
193
|
+
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
|
|
194
|
+
--url https://ci.example.com/builds/512 --button-label "Open build"
|
|
195
|
+
|
|
65
196
|
# Gate a deploy on a human tap — the chosen value prints to stdout:
|
|
66
197
|
if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
|
|
67
198
|
-p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
|
|
@@ -74,14 +205,40 @@ Examples:
|
|
|
74
205
|
pingroom watch --token "$T" q_01H... # block on an existing question
|
|
75
206
|
pingroom cancel --token "$T" q_01H...
|
|
76
207
|
|
|
208
|
+
# Hand a deploy decision to yourself and block on the acknowledgement:
|
|
209
|
+
pingroom handoff --token "$T" -m "Prod deploy 1.4.0 — ack to proceed" --wait
|
|
210
|
+
|
|
211
|
+
# A blocking question handed to a specific human; branch in CI on exit code:
|
|
212
|
+
pingroom handoff --token "$T" -m "Ship 1.4.0?" --question \\
|
|
213
|
+
-o deploy:Deploy -o hold:Hold --wait
|
|
214
|
+
# -> exit 0 (answered, any value incl. 'hold'); 3 expired; 4 recipient-not-ready
|
|
215
|
+
|
|
216
|
+
pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
|
|
217
|
+
|
|
218
|
+
# A live deploy card on everyone's lock screen — one stream, three calls:
|
|
219
|
+
pingroom live start --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
220
|
+
--template steps --steps "Build,Test,Stage,Ship" -t "Deploy 2.1.0"
|
|
221
|
+
pingroom live update --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
222
|
+
--step 2 -m "Smoke tests green"
|
|
223
|
+
pingroom live end --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
224
|
+
-m "Live on production"
|
|
225
|
+
# ...or end it as a failure, which still delivers one completion alert:
|
|
226
|
+
# pingroom live end ... --failed -m "Rollback triggered"
|
|
227
|
+
|
|
228
|
+
# Connect Claude Code to your phone (prints the settings.json to paste):
|
|
229
|
+
pingroom hook --print-config
|
|
230
|
+
|
|
77
231
|
Security:
|
|
78
232
|
Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
|
|
79
233
|
secrets as --webhook / --token flags: argv is visible to other users via the
|
|
80
234
|
process table (ps) and may be captured in shell history. URLs must use https
|
|
81
235
|
(loopback http is allowed for local dev).
|
|
82
236
|
|
|
83
|
-
Exit codes: 0 on success (answered), 1 on error
|
|
84
|
-
3 when a question expired, 4 when it was cancelled
|
|
237
|
+
Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
|
|
238
|
+
2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
|
|
239
|
+
or the recipient was not ready (409 recipient_not_ready). A question answered
|
|
240
|
+
with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
|
|
241
|
+
human decision is not an infrastructure failure.`;
|
|
85
242
|
|
|
86
243
|
const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
|
|
87
244
|
|
|
@@ -90,6 +247,149 @@ function fail(message, code = EXIT.ERROR) {
|
|
|
90
247
|
process.exit(code);
|
|
91
248
|
}
|
|
92
249
|
|
|
250
|
+
// --- local state (~/.pingroom) ---------------------------------------------
|
|
251
|
+
//
|
|
252
|
+
// Two files, both under a 0700 directory:
|
|
253
|
+
// credentials.json the agent credential this machine paired (mode 0600)
|
|
254
|
+
// config.json user settings: default_room, api_url
|
|
255
|
+
//
|
|
256
|
+
// PINGROOM_HOME relocates the directory (tests, sandboxes, multi-account
|
|
257
|
+
// shells). Every lookup is layered: explicit flag > env var > config file >
|
|
258
|
+
// the paired credential > built-in default. PINGROOM_TOKEN is the one env var
|
|
259
|
+
// that also outranks the stored credential, which is what keeps CI working
|
|
260
|
+
// untouched.
|
|
261
|
+
|
|
262
|
+
function pingroomHome() {
|
|
263
|
+
return process.env.PINGROOM_HOME || join(homedir(), '.pingroom');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function credentialsPath() { return join(pingroomHome(), 'credentials.json'); }
|
|
267
|
+
function configPath() { return join(pingroomHome(), 'config.json'); }
|
|
268
|
+
|
|
269
|
+
// Read a JSON object, or null for anything unreadable/corrupt. Local state must
|
|
270
|
+
// never be able to crash a ping: a hand-edited file degrades to "not set".
|
|
271
|
+
function readJsonFile(path) {
|
|
272
|
+
let raw;
|
|
273
|
+
try { raw = readFileSync(path, 'utf8'); } catch { return null; }
|
|
274
|
+
let value;
|
|
275
|
+
try { value = JSON.parse(raw); } catch { return null; }
|
|
276
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Write JSON with restrictive permissions, atomically.
|
|
281
|
+
//
|
|
282
|
+
// Writing in place truncates first, so a crash or a full disk between truncate
|
|
283
|
+
// and write leaves a half-written file — and readJsonFile() degrades anything
|
|
284
|
+
// unparseable to {}, so the *next* `config set` would silently drop every other
|
|
285
|
+
// setting. Writing a sibling temp file and renaming over the target means a
|
|
286
|
+
// reader only ever sees the old file or the new one, never a torn one.
|
|
287
|
+
//
|
|
288
|
+
// The temp file is opened 'wx' with mode 0600 and fchmod'd before a single byte
|
|
289
|
+
// is written: `mode` on an existing file is ignored and a post-write chmod
|
|
290
|
+
// leaves a window where the credential is world-readable. rename() carries the
|
|
291
|
+
// 0600 over the target, so a pre-existing loose file is tightened too.
|
|
292
|
+
//
|
|
293
|
+
// mkdirSync(recursive) returns the first path it created, or undefined when the
|
|
294
|
+
// directory already existed. chmod'ing only on the former keeps this from
|
|
295
|
+
// narrowing a directory the user deliberately created at 0755.
|
|
296
|
+
function writeJsonFile(path, value) {
|
|
297
|
+
const dir = pingroomHome();
|
|
298
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
302
|
+
if (created !== undefined) chmodSync(dir, 0o700);
|
|
303
|
+
|
|
304
|
+
fd = openSync(tmp, 'wx', 0o600);
|
|
305
|
+
fchmodSync(fd, 0o600); // defeat a permissive umask masking the open mode
|
|
306
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
|
|
307
|
+
closeSync(fd);
|
|
308
|
+
fd = undefined;
|
|
309
|
+
renameSync(tmp, path);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
312
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
313
|
+
fail(`could not write ${path}: ${err.message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readStoredCredential() {
|
|
318
|
+
const cred = readJsonFile(credentialsPath());
|
|
319
|
+
if (!cred || typeof cred.token !== 'string' || cred.token === '') return null;
|
|
320
|
+
return cred;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function readConfigFile() {
|
|
324
|
+
return readJsonFile(configPath()) || {};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Agent token: --token > PINGROOM_TOKEN > the paired credential. */
|
|
328
|
+
function resolveToken(args) {
|
|
329
|
+
return args.token || process.env.PINGROOM_TOKEN || readStoredCredential()?.token || undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* API base: --api > PINGROOM_API_URL > config.api_url > the host the credential
|
|
334
|
+
* was paired against > built-in, no trailing slash.
|
|
335
|
+
*
|
|
336
|
+
* The credential layer is not optional. saveCredential() records `api_url`, and
|
|
337
|
+
* a token minted by a self-hosted / staging server is only valid there; without
|
|
338
|
+
* this layer the next command would present that bearer to api.pingroom.io —
|
|
339
|
+
* leaking it to a host it was never issued for. resolveRoom() already consults
|
|
340
|
+
* the credential last, so the two layerings now agree.
|
|
341
|
+
*/
|
|
342
|
+
function resolveApiBase(args) {
|
|
343
|
+
const raw = args.api
|
|
344
|
+
|| process.env.PINGROOM_API_URL
|
|
345
|
+
|| readConfigFile().api_url
|
|
346
|
+
|| readStoredCredential()?.api_url
|
|
347
|
+
|| BUILTIN_API;
|
|
348
|
+
return String(raw).replace(/\/$/, '');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
|
|
353
|
+
* credential was paired to. The paired room is last because it is the weakest
|
|
354
|
+
* signal — it is where the agent was told to deliver, not necessarily where
|
|
355
|
+
* this invocation means to.
|
|
356
|
+
*/
|
|
357
|
+
function resolveRoom(args) {
|
|
358
|
+
return args.room
|
|
359
|
+
|| process.env.PINGROOM_ROOM
|
|
360
|
+
|| readConfigFile().default_room
|
|
361
|
+
|| readStoredCredential()?.room?.invite_code
|
|
362
|
+
|| undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* True when it is safe to prompt / draw a QR. Both streams must be a TTY: a
|
|
367
|
+
* piped stdin cannot answer a prompt and a piped stdout would capture the QR as
|
|
368
|
+
* garbage.
|
|
369
|
+
*
|
|
370
|
+
* The override is deliberately double-locked (internal-looking name AND
|
|
371
|
+
* NODE_ENV=test) and not documented in --help. A single well-known env var
|
|
372
|
+
* shipping in the published binary is one stray `export` away from making a CI
|
|
373
|
+
* job prompt into the void and poll for the full 15-minute pairing window
|
|
374
|
+
* instead of failing in a second.
|
|
375
|
+
*/
|
|
376
|
+
function isInteractive() {
|
|
377
|
+
if (process.env.PINGROOM_INTERNAL_TEST_TTY === '1' && process.env.NODE_ENV === 'test') return true;
|
|
378
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sleep(ms) {
|
|
382
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Drop C0/C1 control characters before echoing server-supplied text to the
|
|
386
|
+
// terminal. Without this an attacker-controlled API base can smuggle ANSI
|
|
387
|
+
// escapes into the output and repaint, erase or overwrite the lines around them.
|
|
388
|
+
function stripControlChars(value) {
|
|
389
|
+
// eslint-disable-next-line no-control-regex
|
|
390
|
+
return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
391
|
+
}
|
|
392
|
+
|
|
93
393
|
// --- ping (unchanged wire behaviour) ---------------------------------------
|
|
94
394
|
|
|
95
395
|
function parseArgs(argv) {
|
|
@@ -100,22 +400,33 @@ function parseArgs(argv) {
|
|
|
100
400
|
'-a': 'action', '--action': 'action',
|
|
101
401
|
'-d': 'data', '--data': 'data',
|
|
102
402
|
'-w': 'webhook', '--webhook': 'webhook',
|
|
403
|
+
'--url': 'url',
|
|
404
|
+
'--button-label': 'button_label',
|
|
405
|
+
'--require-ack': 'require_ack',
|
|
406
|
+
'--ack-timeout': 'ack_timeout',
|
|
103
407
|
'--token': 'token',
|
|
104
408
|
'--room': 'room',
|
|
105
409
|
'--api': 'api',
|
|
106
410
|
'--json': 'json',
|
|
107
411
|
'-h': 'help', '--help': 'help',
|
|
108
412
|
};
|
|
413
|
+
const booleans = new Set(['require_ack', 'json', 'help']);
|
|
109
414
|
|
|
110
415
|
for (let i = 0; i < argv.length; i++) {
|
|
111
416
|
const token = argv[i];
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const key = alias[token];
|
|
117
|
-
if (key) {
|
|
118
|
-
args[key] =
|
|
417
|
+
// Object.hasOwn, not alias[token]: a bare lookup walks the prototype chain,
|
|
418
|
+
// so `constructor` / `toString` / `__proto__` in flag position resolve to a
|
|
419
|
+
// truthy inherited value, get treated as an option, and swallow the next
|
|
420
|
+
// argument instead of failing as an unknown flag.
|
|
421
|
+
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
422
|
+
if (key && booleans.has(key)) {
|
|
423
|
+
args[key] = true;
|
|
424
|
+
} else if (key) {
|
|
425
|
+
const value = argv[++i];
|
|
426
|
+
if (value === undefined) {
|
|
427
|
+
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
428
|
+
}
|
|
429
|
+
args[key] = value;
|
|
119
430
|
} else if (token.startsWith('-')) {
|
|
120
431
|
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
121
432
|
} else {
|
|
@@ -138,6 +449,9 @@ function parseQArgs(argv) {
|
|
|
138
449
|
'--ttl': 'ttl',
|
|
139
450
|
'-d': 'data', '--data': 'data',
|
|
140
451
|
'--correlation-id': 'correlation_id',
|
|
452
|
+
'--reply-to': 'reply_to',
|
|
453
|
+
'--text-input': 'text_input',
|
|
454
|
+
'--text-max': 'text_max',
|
|
141
455
|
'--timeout': 'timeout',
|
|
142
456
|
'--state': 'state',
|
|
143
457
|
'--token': 'token',
|
|
@@ -152,7 +466,59 @@ function parseQArgs(argv) {
|
|
|
152
466
|
|
|
153
467
|
for (let i = 0; i < argv.length; i++) {
|
|
154
468
|
const token = argv[i];
|
|
155
|
-
|
|
469
|
+
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
470
|
+
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
471
|
+
if (key && booleans.has(key)) {
|
|
472
|
+
args[key] = true;
|
|
473
|
+
} else if (key) {
|
|
474
|
+
const value = argv[++i];
|
|
475
|
+
if (value === undefined) {
|
|
476
|
+
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
477
|
+
}
|
|
478
|
+
if (multi.has(key)) {
|
|
479
|
+
(args[key] ||= []).push(value);
|
|
480
|
+
} else {
|
|
481
|
+
args[key] = value;
|
|
482
|
+
}
|
|
483
|
+
} else if (token.startsWith('-') && token !== '-') {
|
|
484
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
485
|
+
} else {
|
|
486
|
+
args._.push(token);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return args;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Parser for `handoff`: --message plus repeatable --option, boolean --question,
|
|
493
|
+
// and the handoff-specific flags. Unknown flags fail like the other parsers.
|
|
494
|
+
function parseHandoffArgs(argv) {
|
|
495
|
+
const args = { _: [] };
|
|
496
|
+
const alias = {
|
|
497
|
+
'-m': 'message', '--message': 'message',
|
|
498
|
+
'--question': 'question',
|
|
499
|
+
'-o': 'option', '--option': 'option',
|
|
500
|
+
'--target': 'target',
|
|
501
|
+
'--expires-in': 'expires_in',
|
|
502
|
+
'--urgency': 'urgency',
|
|
503
|
+
'--idempotency-key': 'idempotency_key',
|
|
504
|
+
'--correlation-id': 'correlation_id',
|
|
505
|
+
'--reply-to': 'reply_to',
|
|
506
|
+
'-d': 'data', '--data': 'data',
|
|
507
|
+
'--timeout': 'timeout',
|
|
508
|
+
'--github-output': 'github_output',
|
|
509
|
+
'--token': 'token',
|
|
510
|
+
'--api': 'api',
|
|
511
|
+
'--wait': 'wait',
|
|
512
|
+
'--json': 'json',
|
|
513
|
+
'-h': 'help', '--help': 'help',
|
|
514
|
+
};
|
|
515
|
+
const booleans = new Set(['question', 'wait', 'json', 'help']);
|
|
516
|
+
const multi = new Set(['option']);
|
|
517
|
+
|
|
518
|
+
for (let i = 0; i < argv.length; i++) {
|
|
519
|
+
const token = argv[i];
|
|
520
|
+
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
521
|
+
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
156
522
|
if (key && booleans.has(key)) {
|
|
157
523
|
args[key] = true;
|
|
158
524
|
} else if (key) {
|
|
@@ -203,7 +569,11 @@ function parseDataObject(raw) {
|
|
|
203
569
|
return data;
|
|
204
570
|
}
|
|
205
571
|
|
|
206
|
-
|
|
572
|
+
// `soft: true` returns { error } instead of exiting on a transport failure. Only
|
|
573
|
+
// the pairing poll passes it: there, a single DNS blip or dropped connection
|
|
574
|
+
// would otherwise kill a 15-minute wait the human is still walking towards their
|
|
575
|
+
// phone for. Every other caller keeps the hard exit.
|
|
576
|
+
async function httpJson(method, url, { body, headers = {}, soft = false } = {}) {
|
|
207
577
|
let res;
|
|
208
578
|
try {
|
|
209
579
|
res = await fetch(url, {
|
|
@@ -216,10 +586,18 @@ async function httpJson(method, url, { body, headers = {} } = {}) {
|
|
|
216
586
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
217
587
|
});
|
|
218
588
|
} catch (err) {
|
|
589
|
+
if (soft) return { res: null, text: '', json: null, error: err };
|
|
219
590
|
fail(`network error: ${err.message}`);
|
|
220
591
|
}
|
|
221
592
|
|
|
222
|
-
|
|
593
|
+
let text;
|
|
594
|
+
try {
|
|
595
|
+
text = await res.text();
|
|
596
|
+
} catch (err) {
|
|
597
|
+
// A connection dropped mid-body throws here, not at fetch().
|
|
598
|
+
if (soft) return { res: null, text: '', json: null, error: err };
|
|
599
|
+
fail(`network error: ${err.message}`);
|
|
600
|
+
}
|
|
223
601
|
let json = null;
|
|
224
602
|
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
225
603
|
|
|
@@ -236,35 +614,82 @@ async function ping(args) {
|
|
|
236
614
|
fail('--action must be an integer 1–4', EXIT.USAGE);
|
|
237
615
|
}
|
|
238
616
|
|
|
617
|
+
let ackTimeout;
|
|
618
|
+
if (args.ack_timeout !== undefined) {
|
|
619
|
+
if (!args.require_ack) {
|
|
620
|
+
fail('--ack-timeout requires --require-ack', EXIT.USAGE);
|
|
621
|
+
}
|
|
622
|
+
if (!/^\d+$/.test(String(args.ack_timeout))) {
|
|
623
|
+
fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
|
|
624
|
+
}
|
|
625
|
+
ackTimeout = Number(args.ack_timeout);
|
|
626
|
+
}
|
|
627
|
+
|
|
239
628
|
let data;
|
|
240
629
|
if (args.data !== undefined) {
|
|
241
630
|
data = parseDataObject(args.data);
|
|
242
631
|
}
|
|
243
632
|
|
|
633
|
+
// Link ping: --url/--button-label fold into the structured data object
|
|
634
|
+
// (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
|
|
635
|
+
if (args.button_label !== undefined && args.url === undefined) {
|
|
636
|
+
fail('--button-label requires --url', EXIT.USAGE);
|
|
637
|
+
}
|
|
638
|
+
if (args.url !== undefined) {
|
|
639
|
+
let linkUrl;
|
|
640
|
+
try {
|
|
641
|
+
linkUrl = new URL(args.url);
|
|
642
|
+
} catch {
|
|
643
|
+
fail('--url is not a valid URL', EXIT.USAGE);
|
|
644
|
+
}
|
|
645
|
+
if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
|
|
646
|
+
fail('--url must be an absolute http(s) URL', EXIT.USAGE);
|
|
647
|
+
}
|
|
648
|
+
if (args.url.length > 2048) {
|
|
649
|
+
fail('--url must be at most 2048 characters', EXIT.USAGE);
|
|
650
|
+
}
|
|
651
|
+
if (args.button_label !== undefined && args.button_label.length > 26) {
|
|
652
|
+
fail('--button-label must be at most 26 characters', EXIT.USAGE);
|
|
653
|
+
}
|
|
654
|
+
data = { ...(data || {}), url: args.url };
|
|
655
|
+
if (args.button_label !== undefined) data.button_label = args.button_label;
|
|
656
|
+
}
|
|
657
|
+
|
|
244
658
|
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
245
|
-
const token = args
|
|
246
|
-
const apiBase = (args
|
|
659
|
+
const token = resolveToken(args);
|
|
660
|
+
const apiBase = resolveApiBase(args);
|
|
661
|
+
const room = resolveRoom(args);
|
|
247
662
|
|
|
248
663
|
let result;
|
|
249
664
|
|
|
250
665
|
if (webhook) {
|
|
666
|
+
if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
|
|
667
|
+
fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
|
|
668
|
+
}
|
|
251
669
|
requireSafeUrl('--webhook', webhook);
|
|
252
670
|
const body = { message };
|
|
253
671
|
if (args.title) body.title = args.title;
|
|
254
672
|
if (args.action !== undefined) body.action = Number(args.action);
|
|
255
673
|
if (data) body.data = data;
|
|
674
|
+
if (args.require_ack) body.requires_ack = true;
|
|
675
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
256
676
|
result = await httpJson('POST', webhook, { body });
|
|
257
677
|
} else if (token) {
|
|
258
|
-
if (!
|
|
678
|
+
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
679
|
+
if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
|
|
680
|
+
fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
|
|
681
|
+
}
|
|
259
682
|
requireSafeUrl('--api', apiBase);
|
|
260
|
-
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(
|
|
683
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
|
|
261
684
|
const body = { message };
|
|
262
685
|
if (args.title) body.title = args.title;
|
|
263
686
|
if (args.action !== undefined) body.action_number = Number(args.action);
|
|
264
687
|
if (data) body.data = data;
|
|
688
|
+
if (args.require_ack) body.requires_ack = true;
|
|
689
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
265
690
|
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
266
691
|
} else {
|
|
267
|
-
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
|
|
692
|
+
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
|
|
268
693
|
}
|
|
269
694
|
|
|
270
695
|
const { res, text, json } = result;
|
|
@@ -284,15 +709,275 @@ async function ping(args) {
|
|
|
284
709
|
return EXIT.OK;
|
|
285
710
|
}
|
|
286
711
|
|
|
712
|
+
// --- live status -----------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
// Parser for `live`: a leading subcommand (start|update|end|get) plus the
|
|
715
|
+
// live-status flags. Unknown flags fail like the other parsers.
|
|
716
|
+
function parseLiveArgs(argv) {
|
|
717
|
+
const args = { _: [] };
|
|
718
|
+
const alias = {
|
|
719
|
+
'-c': 'correlation_id', '--correlation-id': 'correlation_id',
|
|
720
|
+
'-t': 'title', '--title': 'title',
|
|
721
|
+
'-m': 'message', '--message': 'message',
|
|
722
|
+
'--template': 'template',
|
|
723
|
+
'--category': 'category',
|
|
724
|
+
'--progress': 'progress',
|
|
725
|
+
'--step': 'step',
|
|
726
|
+
'--steps': 'steps',
|
|
727
|
+
'--metric': 'metric',
|
|
728
|
+
'--deadline-at': 'deadline_at',
|
|
729
|
+
'--eta-at': 'eta_at',
|
|
730
|
+
'--prompt': 'prompt',
|
|
731
|
+
'--option': 'option',
|
|
732
|
+
'--left': 'left',
|
|
733
|
+
'--right': 'right',
|
|
734
|
+
'--center': 'center',
|
|
735
|
+
'--accent-override': 'accent_override',
|
|
736
|
+
'--failed': 'failed',
|
|
737
|
+
'-a': 'action', '--action': 'action',
|
|
738
|
+
'-d': 'data', '--data': 'data',
|
|
739
|
+
'--require-ack': 'require_ack',
|
|
740
|
+
'--ack-timeout': 'ack_timeout',
|
|
741
|
+
'-w': 'webhook', '--webhook': 'webhook',
|
|
742
|
+
'--token': 'token',
|
|
743
|
+
'--room': 'room',
|
|
744
|
+
'--api': 'api',
|
|
745
|
+
'--json': 'json',
|
|
746
|
+
'-h': 'help', '--help': 'help',
|
|
747
|
+
};
|
|
748
|
+
const booleans = new Set(['require_ack', 'json', 'help', 'failed']);
|
|
749
|
+
const repeatable = new Set(['metric', 'option']);
|
|
750
|
+
|
|
751
|
+
for (let i = 0; i < argv.length; i++) {
|
|
752
|
+
const token = argv[i];
|
|
753
|
+
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
754
|
+
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
755
|
+
if (key && booleans.has(key)) {
|
|
756
|
+
args[key] = true;
|
|
757
|
+
} else if (key) {
|
|
758
|
+
const value = argv[++i];
|
|
759
|
+
if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
760
|
+
if (repeatable.has(key)) (args[key] ||= []).push(value);
|
|
761
|
+
else args[key] = value;
|
|
762
|
+
} else if (token.startsWith('-')) {
|
|
763
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
764
|
+
} else {
|
|
765
|
+
args._.push(token);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return args;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// "label:value" -> {label, value}. Only the first colon splits.
|
|
772
|
+
function buildMetrics(list) {
|
|
773
|
+
if (!list || list.length === 0) return undefined;
|
|
774
|
+
return list.map((spec) => {
|
|
775
|
+
const idx = spec.indexOf(':');
|
|
776
|
+
if (idx <= 0) fail(`--metric must be "label:value" (got "${spec}")`, EXIT.USAGE);
|
|
777
|
+
return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// "value:label" -> {value, label}; a bare token is both. Matches the `ask`
|
|
782
|
+
// command's option syntax minus `style`, which live_status options don't carry.
|
|
783
|
+
function buildLiveOptions(list) {
|
|
784
|
+
if (!list || list.length === 0) return undefined;
|
|
785
|
+
return list.map((spec) => {
|
|
786
|
+
const idx = spec.indexOf(':');
|
|
787
|
+
if (idx < 0) return { value: spec, label: spec };
|
|
788
|
+
if (idx === 0) fail(`--option needs a value before the colon (got "${spec}")`, EXIT.USAGE);
|
|
789
|
+
return { value: spec.slice(0, idx), label: spec.slice(idx + 1) };
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// "label:value" -> {label, value}, for --left / --right on the matchup template.
|
|
794
|
+
function buildSide(spec, flag) {
|
|
795
|
+
if (spec === undefined) return undefined;
|
|
796
|
+
const idx = spec.indexOf(':');
|
|
797
|
+
if (idx <= 0) fail(`${flag} must be "label:value" (got "${spec}")`, EXIT.USAGE);
|
|
798
|
+
return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// The server accepts #rrggbb with or without the leading #; normalize to one
|
|
802
|
+
// form so a shell that ate the # (unquoted) still produces a valid payload.
|
|
803
|
+
function normalizeAccent(raw) {
|
|
804
|
+
if (raw === undefined) return undefined;
|
|
805
|
+
const hex = raw.trim().replace(/^#/, '');
|
|
806
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
|
|
807
|
+
fail(`--accent-override must be a 6-digit hex color (got "${raw}")`, EXIT.USAGE);
|
|
808
|
+
}
|
|
809
|
+
return `#${hex.toLowerCase()}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function numberOption(raw, flag, { min, max, integer = false } = {}) {
|
|
813
|
+
if (raw === undefined) return undefined;
|
|
814
|
+
const value = Number(raw);
|
|
815
|
+
if (!Number.isFinite(value)) fail(`${flag} must be a number`, EXIT.USAGE);
|
|
816
|
+
if (integer && !Number.isInteger(value)) fail(`${flag} must be an integer`, EXIT.USAGE);
|
|
817
|
+
if (min !== undefined && value < min) fail(`${flag} must be at least ${min}`, EXIT.USAGE);
|
|
818
|
+
if (max !== undefined && value > max) fail(`${flag} must be at most ${max}`, EXIT.USAGE);
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Drive a live progress card on the room members' lock screen.
|
|
824
|
+
*
|
|
825
|
+
* One correlation id = one stream: `start` opens it (one alert), `update` moves
|
|
826
|
+
* it silently, `end` closes it with one completion alert. Works with either an
|
|
827
|
+
* agent token (--token, needs pingroom:live:write) or a room's incoming webhook
|
|
828
|
+
* (--webhook), which speak the same `live_status` contract.
|
|
829
|
+
*/
|
|
830
|
+
async function live(args) {
|
|
831
|
+
const sub = args._[0];
|
|
832
|
+
const known = ['start', 'update', 'end', 'get'];
|
|
833
|
+
if (!sub || !known.includes(sub)) {
|
|
834
|
+
fail(`live needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const correlationId = args.correlation_id;
|
|
838
|
+
if (!correlationId) fail('--correlation-id is required', EXIT.USAGE);
|
|
839
|
+
|
|
840
|
+
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
841
|
+
const token = resolveToken(args);
|
|
842
|
+
const apiBase = resolveApiBase(args);
|
|
843
|
+
const room = resolveRoom(args);
|
|
844
|
+
|
|
845
|
+
if (sub === 'get') {
|
|
846
|
+
if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
|
|
847
|
+
if (!room) fail('--room is required', EXIT.USAGE);
|
|
848
|
+
requireSafeUrl('--api', apiBase);
|
|
849
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
|
|
850
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
851
|
+
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
852
|
+
if (!res.ok) {
|
|
853
|
+
fail(`read failed: ${(json && (json.message || json.code)) || `HTTP ${res.status}`}`);
|
|
854
|
+
}
|
|
855
|
+
if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
|
|
856
|
+
return EXIT.OK;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const liveStatus = {
|
|
860
|
+
state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
if (args.message !== undefined) liveStatus.message = args.message;
|
|
864
|
+
if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
|
|
865
|
+
|
|
866
|
+
const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
|
|
867
|
+
if (progress !== undefined) liveStatus.progress = progress;
|
|
868
|
+
|
|
869
|
+
const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
|
|
870
|
+
if (step !== undefined) liveStatus.current_step = step;
|
|
871
|
+
|
|
872
|
+
const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
|
|
873
|
+
if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
|
|
874
|
+
|
|
875
|
+
const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
|
|
876
|
+
if (etaAt !== undefined) liveStatus.eta_at = etaAt;
|
|
877
|
+
|
|
878
|
+
const metrics = buildMetrics(args.metric);
|
|
879
|
+
if (metrics) liveStatus.metrics = metrics;
|
|
880
|
+
|
|
881
|
+
const options = buildLiveOptions(args.option);
|
|
882
|
+
if (options) {
|
|
883
|
+
if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
|
|
884
|
+
liveStatus.options = options;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const left = buildSide(args.left, '--left');
|
|
888
|
+
if (left) liveStatus.left = left;
|
|
889
|
+
const right = buildSide(args.right, '--right');
|
|
890
|
+
if (right) liveStatus.right = right;
|
|
891
|
+
if (args.center !== undefined) liveStatus.center = args.center;
|
|
892
|
+
|
|
893
|
+
const accent = normalizeAccent(args.accent_override);
|
|
894
|
+
if (accent) liveStatus.accent_override = accent;
|
|
895
|
+
|
|
896
|
+
// Template, category and step labels are fixed when the stream is created;
|
|
897
|
+
// sending them on an update is a no-op server-side, so only `start` takes them.
|
|
898
|
+
if (sub === 'start') {
|
|
899
|
+
if (args.template) liveStatus.template = args.template;
|
|
900
|
+
// `alert` has no template equivalent and is the only way to start a stream
|
|
901
|
+
// time-sensitive (breaking through Focus) without also demanding an ack.
|
|
902
|
+
if (args.category) {
|
|
903
|
+
if (!['status', 'steps', 'alert'].includes(args.category)) {
|
|
904
|
+
fail('--category must be status, steps or alert', EXIT.USAGE);
|
|
905
|
+
}
|
|
906
|
+
liveStatus.category = args.category;
|
|
907
|
+
}
|
|
908
|
+
if (args.steps) {
|
|
909
|
+
const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
|
|
910
|
+
if (labels.length < 2 || labels.length > 8) {
|
|
911
|
+
fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
|
|
912
|
+
}
|
|
913
|
+
liveStatus.steps = labels;
|
|
914
|
+
}
|
|
915
|
+
} else if (args.template || args.steps || args.category) {
|
|
916
|
+
fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const body = { correlation_id: correlationId, live_status: liveStatus };
|
|
920
|
+
if (args.title) body.title = args.title;
|
|
921
|
+
if (args.action !== undefined) body.action = Number(args.action);
|
|
922
|
+
// Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
|
|
923
|
+
// an array, which the server then rejects — a wasted round trip for what is a
|
|
924
|
+
// local usage error.
|
|
925
|
+
// `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
|
|
926
|
+
// truthiness test drops it on the floor and ships the ping without the data
|
|
927
|
+
// the caller believed they attached. ping/ask/handoff all reject it loudly.
|
|
928
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
929
|
+
if (args.require_ack) body.requires_ack = true;
|
|
930
|
+
const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
|
|
931
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
932
|
+
|
|
933
|
+
let result;
|
|
934
|
+
if (webhook) {
|
|
935
|
+
requireSafeUrl('--webhook', webhook);
|
|
936
|
+
result = await httpJson('POST', webhook, { body });
|
|
937
|
+
} else if (token) {
|
|
938
|
+
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
939
|
+
requireSafeUrl('--api', apiBase);
|
|
940
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
|
|
941
|
+
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
942
|
+
} else {
|
|
943
|
+
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const { res, text, json } = result;
|
|
947
|
+
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
948
|
+
|
|
949
|
+
if (!res.ok || (json && json.success === false)) {
|
|
950
|
+
const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
|
|
951
|
+
fail(`live ${sub} failed: ${detail}`);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (!args.json) {
|
|
955
|
+
const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
|
|
956
|
+
process.stdout.write(`live ${sub} → ${state} ✅\n`);
|
|
957
|
+
}
|
|
958
|
+
return EXIT.OK;
|
|
959
|
+
}
|
|
960
|
+
|
|
287
961
|
// --- questions -------------------------------------------------------------
|
|
288
962
|
|
|
963
|
+
// Resolve the credential + endpoint a token-only command needs. When nothing is
|
|
964
|
+
// available this is a usage error pointing at PINGROOM_TOKEN — never a prompt,
|
|
965
|
+
// so a CI job fails in a second instead of hanging on an invisible question.
|
|
289
966
|
function agentContext(args, { needRoom = false } = {}) {
|
|
290
|
-
const token = args
|
|
291
|
-
if (!token)
|
|
292
|
-
|
|
967
|
+
const token = resolveToken(args);
|
|
968
|
+
if (!token) {
|
|
969
|
+
fail(
|
|
970
|
+
'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
|
|
971
|
+
EXIT.USAGE,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
const apiBase = resolveApiBase(args);
|
|
293
975
|
requireSafeUrl('--api', apiBase);
|
|
294
|
-
|
|
295
|
-
|
|
976
|
+
const room = resolveRoom(args);
|
|
977
|
+
if (needRoom && !room) {
|
|
978
|
+
fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
979
|
+
}
|
|
980
|
+
return { token, apiBase, room };
|
|
296
981
|
}
|
|
297
982
|
|
|
298
983
|
// value:label -> {value, label}. Labels may contain colons (only the first
|
|
@@ -302,9 +987,20 @@ function buildOptions(list) {
|
|
|
302
987
|
return list.map((spec) => {
|
|
303
988
|
const idx = spec.indexOf(':');
|
|
304
989
|
const value = idx === -1 ? spec : spec.slice(0, idx);
|
|
305
|
-
|
|
306
|
-
if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
|
|
307
|
-
|
|
990
|
+
let label = idx === -1 ? spec : spec.slice(idx + 1);
|
|
991
|
+
if (!value) fail(`--option must be "value", "value:label" or "value:label:style" (got "${spec}")`, EXIT.USAGE);
|
|
992
|
+
// A trailing :primary|:danger|:default segment styles the button; any other
|
|
993
|
+
// trailing segment stays part of the label (labels may contain colons).
|
|
994
|
+
let style;
|
|
995
|
+
const lastColon = label.lastIndexOf(':');
|
|
996
|
+
if (lastColon !== -1) {
|
|
997
|
+
const candidate = label.slice(lastColon + 1);
|
|
998
|
+
if (candidate === 'primary' || candidate === 'danger' || candidate === 'default') {
|
|
999
|
+
style = candidate;
|
|
1000
|
+
label = label.slice(0, lastColon);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return style ? { value, label, style } : { value, label };
|
|
308
1004
|
});
|
|
309
1005
|
}
|
|
310
1006
|
|
|
@@ -375,6 +1071,19 @@ async function ask(args) {
|
|
|
375
1071
|
body.ttl = Number(args.ttl);
|
|
376
1072
|
}
|
|
377
1073
|
if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
|
|
1074
|
+
if (args.reply_to !== undefined) body.reply_to = args.reply_to;
|
|
1075
|
+
if (args.text_input !== undefined || args.text_max !== undefined) {
|
|
1076
|
+
const textInput = {};
|
|
1077
|
+
if (args.text_input) textInput.placeholder = String(args.text_input).slice(0, 60);
|
|
1078
|
+
if (args.text_max !== undefined) {
|
|
1079
|
+
const n = Number(args.text_max);
|
|
1080
|
+
if (!/^\d+$/.test(String(args.text_max)) || n < 1 || n > 60) {
|
|
1081
|
+
fail('--text-max must be an integer between 1 and 60', EXIT.USAGE);
|
|
1082
|
+
}
|
|
1083
|
+
textInput.max_length = n;
|
|
1084
|
+
}
|
|
1085
|
+
body.text_input = textInput;
|
|
1086
|
+
}
|
|
378
1087
|
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
379
1088
|
|
|
380
1089
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
|
|
@@ -438,26 +1147,1107 @@ async function list(args) {
|
|
|
438
1147
|
return EXIT.OK;
|
|
439
1148
|
}
|
|
440
1149
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
};
|
|
1150
|
+
async function listHandoffs(args) {
|
|
1151
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
1152
|
+
const { token, apiBase } = agentContext(args);
|
|
1153
|
+
const state = args.state || 'open';
|
|
1154
|
+
if (state !== 'open' && state !== 'all') {
|
|
1155
|
+
fail("--state must be 'open' or 'all' for handoffs", EXIT.USAGE);
|
|
1156
|
+
}
|
|
449
1157
|
|
|
450
|
-
|
|
451
|
-
|
|
1158
|
+
const url = `${apiBase}/api/agent/handoffs?state=${encodeURIComponent(state)}`;
|
|
1159
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1160
|
+
if (!res.ok) {
|
|
1161
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
1162
|
+
fail(`handoffs list failed: ${detail}`);
|
|
1163
|
+
}
|
|
1164
|
+
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
1165
|
+
|
|
1166
|
+
const handoffs = (json && json.handoffs) || [];
|
|
1167
|
+
if (handoffs.length === 0) { process.stdout.write('no handoffs\n'); return EXIT.OK; }
|
|
1168
|
+
for (const h of handoffs) {
|
|
1169
|
+
const answer = h.answer && (h.answer.value ?? h.answer.text);
|
|
1170
|
+
const outcome = answer !== undefined && answer !== null ? ` → ${answer}` : '';
|
|
1171
|
+
process.stdout.write(
|
|
1172
|
+
`${h.id} ${String(h.kind || '').padEnd(8)} ${String(h.state || '').padEnd(9)} ${h.prompt || ''}${outcome}\n`,
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
return EXIT.OK;
|
|
452
1176
|
}
|
|
453
1177
|
|
|
454
|
-
|
|
455
|
-
const argv = process.argv.slice(2);
|
|
456
|
-
const command = argv[0];
|
|
1178
|
+
// --- handoff ---------------------------------------------------------------
|
|
457
1179
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1180
|
+
// Terminal wire states across both kinds. ack: open→acked|expired.
|
|
1181
|
+
// question: pending→answered|expired|cancelled. `open`/`pending` are the only
|
|
1182
|
+
// non-terminal states, so a wait loop against these always terminates.
|
|
1183
|
+
const HANDOFF_PENDING = new Set(['open', 'pending']);
|
|
1184
|
+
|
|
1185
|
+
// Map a terminal handoff state to an exit code. A `question` answered with ANY
|
|
1186
|
+
// value is a success (0) — a negative human decision ('hold'/'deny') is NOT an
|
|
1187
|
+
// infra failure. `acked` is likewise 0. `expired` is a distinct 3 so CI can
|
|
1188
|
+
// branch; `cancelled` shares 4 with recipient_not_ready.
|
|
1189
|
+
function exitForHandoffState(state) {
|
|
1190
|
+
switch (state) {
|
|
1191
|
+
case 'acked': return EXIT.OK;
|
|
1192
|
+
case 'answered': return EXIT.OK;
|
|
1193
|
+
case 'expired': return EXIT.EXPIRED;
|
|
1194
|
+
case 'cancelled': return EXIT.CANCELLED;
|
|
1195
|
+
default: return EXIT.ERROR;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Print a machine-readable summary of a handoff: id, state, delivery-state, and
|
|
1200
|
+
// the answer value / acked-by when present, one `key=value` per line to stdout.
|
|
1201
|
+
function printHandoff(h) {
|
|
1202
|
+
const lines = [`id=${h.id ?? ''}`, `state=${h.state ?? ''}`];
|
|
1203
|
+
if (h.delivery_state != null) lines.push(`delivery-state=${h.delivery_state}`);
|
|
1204
|
+
if (h.correlation_id) lines.push(`correlation-id=${h.correlation_id}`);
|
|
1205
|
+
if (h.state === 'answered') {
|
|
1206
|
+
const value = h.answer && (h.answer.value ?? h.answer.text) || '';
|
|
1207
|
+
lines.push(`answer=${value}`);
|
|
1208
|
+
}
|
|
1209
|
+
if (h.state === 'acked') {
|
|
1210
|
+
// The Handoff API returns a privacy-aware actor object. Only expose its id
|
|
1211
|
+
// in the machine-readable CLI/GitHub Action output; a redacted actor yields
|
|
1212
|
+
// an empty value instead of the unhelpful "[object Object]" string.
|
|
1213
|
+
const ackerId = h.acked_by && typeof h.acked_by === 'object'
|
|
1214
|
+
? h.acked_by.id
|
|
1215
|
+
: h.acked_by;
|
|
1216
|
+
lines.push(`acked-by=${ackerId ?? ''}`);
|
|
1217
|
+
if (h.acked_at) lines.push(`acked-at=${h.acked_at}`);
|
|
1218
|
+
}
|
|
1219
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Append the composite Action's declared outputs without interpreting stdout.
|
|
1224
|
+
* Values use GitHub's multiline protocol with a fresh random delimiter. Output
|
|
1225
|
+
* names are a fixed allowlist; untrusted answer text can never create a key.
|
|
1226
|
+
*/
|
|
1227
|
+
function writeGitHubHandoffOutputs(path, h) {
|
|
1228
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
1229
|
+
fail('--github-output must be a non-empty path', EXIT.USAGE);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const ackerId = h.acked_by && typeof h.acked_by === 'object'
|
|
1233
|
+
? h.acked_by.id
|
|
1234
|
+
: h.acked_by;
|
|
1235
|
+
const fields = [
|
|
1236
|
+
['handoff-id', h.id ?? ''],
|
|
1237
|
+
['state', h.state ?? ''],
|
|
1238
|
+
];
|
|
1239
|
+
if (h.delivery_state != null) fields.push(['delivery-state', h.delivery_state]);
|
|
1240
|
+
if (h.state === 'answered') {
|
|
1241
|
+
fields.push(['answer', h.answer && (h.answer.value ?? h.answer.text) || '']);
|
|
1242
|
+
}
|
|
1243
|
+
if (h.state === 'acked') fields.push(['acknowledged-by', ackerId ?? '']);
|
|
1244
|
+
|
|
1245
|
+
const blocks = fields.map(([name, rawValue]) => {
|
|
1246
|
+
const value = String(rawValue ?? '');
|
|
1247
|
+
let delimiter;
|
|
1248
|
+
do {
|
|
1249
|
+
delimiter = `pingroom_${randomBytes(24).toString('hex')}`;
|
|
1250
|
+
} while (value.includes(delimiter));
|
|
1251
|
+
// Keep the collision check next to serialization: a delimiter must never
|
|
1252
|
+
// occur in an untrusted value, even though a 192-bit collision is remote.
|
|
1253
|
+
if (value.includes(delimiter)) {
|
|
1254
|
+
fail('could not create a safe GitHub output delimiter');
|
|
1255
|
+
}
|
|
1256
|
+
return `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
try {
|
|
1260
|
+
appendFileSync(path, blocks.join(''), { encoding: 'utf8' });
|
|
1261
|
+
} catch {
|
|
1262
|
+
fail('could not write GitHub outputs');
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Long-poll GET /handoffs/{id}/wait until the handoff leaves open/pending, then
|
|
1267
|
+
// print it and return the state's exit code. Reuses the shared bounded hold.
|
|
1268
|
+
async function waitForHandoff(id, args, { token, apiBase }, initialDeliveryState) {
|
|
1269
|
+
let hold = args.timeout !== undefined ? Number(args.timeout) : 20;
|
|
1270
|
+
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
1271
|
+
hold = Math.min(hold, 25);
|
|
1272
|
+
|
|
1273
|
+
for (;;) {
|
|
1274
|
+
const url = `${apiBase}/api/agent/handoffs/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
1275
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
1276
|
+
if (!res.ok) {
|
|
1277
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
1278
|
+
fail(`wait failed: ${detail}`);
|
|
1279
|
+
}
|
|
1280
|
+
if (json && json.state && !HANDOFF_PENDING.has(json.state)) {
|
|
1281
|
+
// Read/wait responses intentionally carry delivery_state=null. Preserve
|
|
1282
|
+
// the create response's durable delivery result so --wait callers and
|
|
1283
|
+
// the GitHub Action do not lose it at the terminal read boundary.
|
|
1284
|
+
const resolved = json.delivery_state == null && initialDeliveryState != null
|
|
1285
|
+
? { ...json, delivery_state: initialDeliveryState }
|
|
1286
|
+
: json;
|
|
1287
|
+
if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, resolved);
|
|
1288
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
1289
|
+
else printHandoff(resolved);
|
|
1290
|
+
return exitForHandoffState(resolved.state);
|
|
1291
|
+
}
|
|
1292
|
+
// Still open/pending at the hold timeout — poll again.
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function handoff(args) {
|
|
1297
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
1298
|
+
|
|
1299
|
+
const message = args.message;
|
|
1300
|
+
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
1301
|
+
|
|
1302
|
+
const { token, apiBase } = agentContext(args);
|
|
1303
|
+
|
|
1304
|
+
const options = buildOptions(args.option);
|
|
1305
|
+
// Any --option (or an explicit --question) makes this a question handoff.
|
|
1306
|
+
const isQuestion = Boolean(args.question) || Boolean(options);
|
|
1307
|
+
if (isQuestion && (!options || options.length < 2)) {
|
|
1308
|
+
fail('a question handoff needs at least 2 --option values', EXIT.USAGE);
|
|
1309
|
+
}
|
|
1310
|
+
if (isQuestion && options && options.length > 4) {
|
|
1311
|
+
fail('a question handoff accepts at most 4 --option values', EXIT.USAGE);
|
|
1312
|
+
}
|
|
1313
|
+
if (!isQuestion && options) {
|
|
1314
|
+
fail('--option requires --question', EXIT.USAGE);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const body = { kind: isQuestion ? 'question' : 'ack', prompt: message };
|
|
1318
|
+
|
|
1319
|
+
const target = args.target || 'me';
|
|
1320
|
+
body.audience = { type: 'direct', user_id: target };
|
|
1321
|
+
|
|
1322
|
+
if (options) body.options = options;
|
|
1323
|
+
|
|
1324
|
+
if (args.expires_in !== undefined) {
|
|
1325
|
+
if (!/^\d+$/.test(String(args.expires_in))) fail('--expires-in must be an integer number of seconds', EXIT.USAGE);
|
|
1326
|
+
const secs = Number(args.expires_in);
|
|
1327
|
+
if (secs < 120 || secs > 86_400) fail('--expires-in must be between 120 and 86400 seconds', EXIT.USAGE);
|
|
1328
|
+
body.expires_in = secs;
|
|
1329
|
+
}
|
|
1330
|
+
if (args.urgency !== undefined) {
|
|
1331
|
+
if (args.urgency !== 'active' && args.urgency !== 'passive') fail("--urgency must be 'active' or 'passive'", EXIT.USAGE);
|
|
1332
|
+
body.urgency = args.urgency;
|
|
1333
|
+
}
|
|
1334
|
+
if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
|
|
1335
|
+
if (args.reply_to !== undefined) body.reply_to = args.reply_to;
|
|
1336
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
1337
|
+
|
|
1338
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
1339
|
+
// A stable Idempotency-Key lets network retries collapse to one resource; the
|
|
1340
|
+
// server returns the same handoff for a matching key+hash (409 on conflict).
|
|
1341
|
+
if (args.idempotency_key !== undefined) {
|
|
1342
|
+
if (!args.idempotency_key) fail('--idempotency-key must be non-empty', EXIT.USAGE);
|
|
1343
|
+
headers['Idempotency-Key'] = args.idempotency_key;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const url = `${apiBase}/api/agent/handoffs`;
|
|
1347
|
+
const { res, text, json } = await httpJson('POST', url, { body, headers });
|
|
1348
|
+
if (!res.ok) {
|
|
1349
|
+
const code = json && json.code;
|
|
1350
|
+
const detail = (json && (json.message || code)) || `HTTP ${res.status}`;
|
|
1351
|
+
// A recipient who isn't reachable yet is a distinct, retriable outcome (4),
|
|
1352
|
+
// not a generic error — CI may want to wait and retry rather than fail hard.
|
|
1353
|
+
if (res.status === 409 && code === 'recipient_not_ready') {
|
|
1354
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
1355
|
+
else process.stderr.write(`pingroom: recipient not ready\n`);
|
|
1356
|
+
return EXIT.CANCELLED;
|
|
1357
|
+
}
|
|
1358
|
+
fail(`handoff failed: ${detail}`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (!args.wait) {
|
|
1362
|
+
if (args.github_output !== undefined) writeGitHubHandoffOutputs(args.github_output, json);
|
|
1363
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
1364
|
+
else printHandoff(json);
|
|
1365
|
+
return EXIT.OK;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return waitForHandoff(json.id, args, { token, apiBase }, json.delivery_state);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// --- hook (Claude Code integration) ----------------------------------------
|
|
1372
|
+
//
|
|
1373
|
+
// A single command wired into several Claude Code hook events. It reads the
|
|
1374
|
+
// hook's JSON payload on stdin and switches on `hook_event_name`:
|
|
1375
|
+
// Stop / SubagentStop / SessionEnd -> ping the room ("Claude finished")
|
|
1376
|
+
// Notification -> ping the room (idle / needs-input)
|
|
1377
|
+
// PreToolUse -> ask a PingRoom question and gate the
|
|
1378
|
+
// tool call on the phone's Approve/Deny.
|
|
1379
|
+
//
|
|
1380
|
+
// Safety: the hook FAILS OPEN. It never blocks the agent and never
|
|
1381
|
+
// auto-approves. Any missing config / network error / non-answer defers to the
|
|
1382
|
+
// normal local prompt (PreToolUse -> permissionDecision "ask") and exits 0. It
|
|
1383
|
+
// must not call fail() (a non-zero exit — 2 especially — would break the run).
|
|
1384
|
+
|
|
1385
|
+
function parseHookArgs(argv) {
|
|
1386
|
+
const args = { _: [] };
|
|
1387
|
+
const alias = {
|
|
1388
|
+
'--room': 'room',
|
|
1389
|
+
'--ttl': 'ttl',
|
|
1390
|
+
'--quiet': 'quiet',
|
|
1391
|
+
'--print-config': 'print_config',
|
|
1392
|
+
'--token': 'token',
|
|
1393
|
+
'--api': 'api',
|
|
1394
|
+
'--json': 'json',
|
|
1395
|
+
'-h': 'help', '--help': 'help',
|
|
1396
|
+
};
|
|
1397
|
+
const booleans = new Set(['quiet', 'print_config', 'json', 'help']);
|
|
1398
|
+
|
|
1399
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1400
|
+
const token = argv[i];
|
|
1401
|
+
// hasOwn, not a bare lookup — see parseArgs: an inherited key would swallow args.
|
|
1402
|
+
const key = Object.hasOwn(alias, token) ? alias[token] : undefined;
|
|
1403
|
+
if (key && booleans.has(key)) {
|
|
1404
|
+
args[key] = true;
|
|
1405
|
+
} else if (key) {
|
|
1406
|
+
const value = argv[++i];
|
|
1407
|
+
if (value === undefined) fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
1408
|
+
args[key] = value;
|
|
1409
|
+
} else if (token.startsWith('-') && token !== '-') {
|
|
1410
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
1411
|
+
} else {
|
|
1412
|
+
args._.push(token);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return args;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Read all of stdin as a string. Resolves '' when nothing is piped (TTY), so a
|
|
1419
|
+
// stray `pingroom hook` in a terminal is a silent no-op rather than a hang.
|
|
1420
|
+
function readStdin() {
|
|
1421
|
+
return new Promise((resolve) => {
|
|
1422
|
+
if (process.stdin.isTTY) { resolve(''); return; }
|
|
1423
|
+
let data = '';
|
|
1424
|
+
process.stdin.setEncoding('utf8');
|
|
1425
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
1426
|
+
process.stdin.on('end', () => resolve(data));
|
|
1427
|
+
process.stdin.on('error', () => resolve(data));
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function truncate(value, max) {
|
|
1432
|
+
const str = String(value ?? '');
|
|
1433
|
+
return str.length <= max ? str : `${str.slice(0, max - 1)}…`;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// A minimal HTTP helper for the hook path that THROWS instead of calling fail(),
|
|
1437
|
+
// so every failure funnels into a fail-open decision. Mirrors httpJson's header
|
|
1438
|
+
// handling but leaves control flow to the caller.
|
|
1439
|
+
async function hookFetch(method, url, { body, token } = {}) {
|
|
1440
|
+
const res = await fetch(url, {
|
|
1441
|
+
method,
|
|
1442
|
+
headers: {
|
|
1443
|
+
Accept: 'application/json',
|
|
1444
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
1445
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
1446
|
+
},
|
|
1447
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
1448
|
+
});
|
|
1449
|
+
const text = await res.text();
|
|
1450
|
+
let json = null;
|
|
1451
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
1452
|
+
if (!res.ok) {
|
|
1453
|
+
throw new Error((json && (json.message || json.code)) || `HTTP ${res.status}`);
|
|
1454
|
+
}
|
|
1455
|
+
return json;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Pull the readable text out of a Claude transcript message's content, which is
|
|
1459
|
+
// either a plain string or an array of typed blocks.
|
|
1460
|
+
function extractAssistantText(content) {
|
|
1461
|
+
if (typeof content === 'string') return content;
|
|
1462
|
+
if (Array.isArray(content)) {
|
|
1463
|
+
return content
|
|
1464
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
1465
|
+
.map((b) => b.text)
|
|
1466
|
+
.join(' ');
|
|
1467
|
+
}
|
|
1468
|
+
return '';
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Tail a Claude Code transcript (JSONL) and return the last assistant message as
|
|
1472
|
+
// a single truncated line. Best-effort: any read/parse failure yields ''.
|
|
1473
|
+
function summarizeTranscript(path) {
|
|
1474
|
+
if (!path || typeof path !== 'string') return '';
|
|
1475
|
+
let content;
|
|
1476
|
+
try { content = readFileSync(path, 'utf8'); } catch { return ''; }
|
|
1477
|
+
const lines = content.split('\n');
|
|
1478
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1479
|
+
const line = lines[i].trim();
|
|
1480
|
+
if (!line) continue;
|
|
1481
|
+
let entry;
|
|
1482
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
1483
|
+
const msg = entry && entry.message;
|
|
1484
|
+
if (!msg || msg.role !== 'assistant') continue;
|
|
1485
|
+
const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
|
|
1486
|
+
if (text) return truncate(text, 500);
|
|
1487
|
+
}
|
|
1488
|
+
return '';
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// A short, single-line description of the tool call for the question prompt.
|
|
1492
|
+
// Never emits more than a truncated line, and strips whitespace/newlines so an
|
|
1493
|
+
// untrusted command can't reshape the message.
|
|
1494
|
+
function summarizeToolInput(input) {
|
|
1495
|
+
if (!input || typeof input !== 'object') return '';
|
|
1496
|
+
let raw = '';
|
|
1497
|
+
if (typeof input.command === 'string') raw = input.command; // Bash
|
|
1498
|
+
else if (typeof input.file_path === 'string') raw = input.file_path; // Read/Write/Edit
|
|
1499
|
+
else if (typeof input.path === 'string') raw = input.path;
|
|
1500
|
+
else if (typeof input.url === 'string') raw = input.url; // WebFetch
|
|
1501
|
+
else if (typeof input.pattern === 'string') raw = input.pattern; // Grep/Glob
|
|
1502
|
+
else { try { raw = JSON.stringify(input); } catch { raw = ''; } }
|
|
1503
|
+
return truncate(String(raw).replace(/\s+/g, ' ').trim(), 160);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function emitPreToolUseDecision(decision, reason) {
|
|
1507
|
+
process.stdout.write(`${JSON.stringify({
|
|
1508
|
+
hookSpecificOutput: {
|
|
1509
|
+
hookEventName: 'PreToolUse',
|
|
1510
|
+
permissionDecision: decision,
|
|
1511
|
+
permissionDecisionReason: reason,
|
|
1512
|
+
},
|
|
1513
|
+
})}\n`);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Long-poll the wait endpoint until the question leaves `pending`. The server
|
|
1517
|
+
// expires it at its ttl, so this always terminates; a mid-poll throw propagates
|
|
1518
|
+
// to the caller's fail-open handler.
|
|
1519
|
+
async function hookWaitForAnswer(id, { token, apiBase }) {
|
|
1520
|
+
for (;;) {
|
|
1521
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=25`;
|
|
1522
|
+
const json = await hookFetch('GET', url, { token });
|
|
1523
|
+
if (json && json.state && json.state !== 'pending') return json;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
1528
|
+
if (!token || !room) {
|
|
1529
|
+
emitPreToolUseDecision('ask', 'PingRoom not configured (set PINGROOM_TOKEN and PINGROOM_ROOM)');
|
|
1530
|
+
return EXIT.OK;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const toolName = event.tool_name || 'a tool';
|
|
1534
|
+
const summary = summarizeToolInput(event.tool_input);
|
|
1535
|
+
const prompt = truncate(`Run ${toolName}${summary ? `: ${summary}` : ''}?`, 500);
|
|
1536
|
+
|
|
1537
|
+
let ttl = 900;
|
|
1538
|
+
if (args.ttl !== undefined && /^\d+$/.test(String(args.ttl))) ttl = Number(args.ttl);
|
|
1539
|
+
|
|
1540
|
+
let questionId;
|
|
1541
|
+
let cancelled = false;
|
|
1542
|
+
const cancelQuestion = async () => {
|
|
1543
|
+
if (!questionId || cancelled) return;
|
|
1544
|
+
cancelled = true;
|
|
1545
|
+
try {
|
|
1546
|
+
await hookFetch('POST', `${apiBase}/api/agent/questions/${encodeURIComponent(questionId)}/cancel`, { body: {}, token });
|
|
1547
|
+
} catch { /* best-effort — a leftover question expires on its own ttl */ }
|
|
1548
|
+
};
|
|
1549
|
+
// If the agent aborts the tool call, withdraw the question so it doesn't linger
|
|
1550
|
+
// on the phone. Exit 0 so the abort itself isn't reported as a hook failure.
|
|
1551
|
+
const onSignal = () => { cancelQuestion().finally(() => process.exit(EXIT.OK)); };
|
|
1552
|
+
process.on('SIGINT', onSignal);
|
|
1553
|
+
process.on('SIGTERM', onSignal);
|
|
1554
|
+
|
|
1555
|
+
try {
|
|
1556
|
+
const data = { tool_name: String(toolName) };
|
|
1557
|
+
if (event.cwd) data.cwd = String(event.cwd);
|
|
1558
|
+
const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
|
|
1559
|
+
token,
|
|
1560
|
+
body: {
|
|
1561
|
+
prompt,
|
|
1562
|
+
context: 'Claude Code',
|
|
1563
|
+
options: [
|
|
1564
|
+
{ value: 'allow', label: 'Approve', style: 'primary' },
|
|
1565
|
+
{ value: 'deny', label: 'Deny', style: 'danger' },
|
|
1566
|
+
],
|
|
1567
|
+
ttl,
|
|
1568
|
+
data,
|
|
1569
|
+
...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
|
|
1570
|
+
},
|
|
1571
|
+
});
|
|
1572
|
+
questionId = created && created.id;
|
|
1573
|
+
if (!questionId) {
|
|
1574
|
+
emitPreToolUseDecision('ask', 'PingRoom did not return a question — deferring to local prompt');
|
|
1575
|
+
return EXIT.OK;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
|
|
1579
|
+
if (resolved.state === 'answered') {
|
|
1580
|
+
const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
|
|
1581
|
+
if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
|
|
1582
|
+
if (value === 'deny') { emitPreToolUseDecision('deny', 'Denied via PingRoom'); return EXIT.OK; }
|
|
1583
|
+
emitPreToolUseDecision('ask', `PingRoom answer "${value}" — deferring to local prompt`);
|
|
1584
|
+
return EXIT.OK;
|
|
1585
|
+
}
|
|
1586
|
+
emitPreToolUseDecision('ask', `PingRoom question ${resolved.state} — deferring to local prompt`);
|
|
1587
|
+
return EXIT.OK;
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
emitPreToolUseDecision('ask', `PingRoom unavailable (${err.message}) — deferring to local prompt`);
|
|
1590
|
+
return EXIT.OK;
|
|
1591
|
+
} finally {
|
|
1592
|
+
process.removeListener('SIGINT', onSignal);
|
|
1593
|
+
process.removeListener('SIGTERM', onSignal);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
1598
|
+
if (!token || !room) {
|
|
1599
|
+
if (!args.quiet) process.stderr.write('pingroom: hook skipped (set PINGROOM_TOKEN and PINGROOM_ROOM)\n');
|
|
1600
|
+
return EXIT.OK;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
let title;
|
|
1604
|
+
let message;
|
|
1605
|
+
if (name === 'Stop' || name === 'SubagentStop') {
|
|
1606
|
+
title = 'Claude finished';
|
|
1607
|
+
message = summarizeTranscript(event.transcript_path) || 'Session finished — waiting for you.';
|
|
1608
|
+
} else if (name === 'Notification') {
|
|
1609
|
+
message = truncate(event.message || 'Claude is waiting for your input.', 500);
|
|
1610
|
+
// A PreToolUse hook already turns permission prompts into a question; skip
|
|
1611
|
+
// the duplicate "needs your permission" Notification so you aren't paged twice.
|
|
1612
|
+
if (/permission/i.test(message)) return EXIT.OK;
|
|
1613
|
+
title = 'Claude needs you';
|
|
1614
|
+
} else if (name === 'SessionEnd') {
|
|
1615
|
+
if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
|
|
1616
|
+
title = 'Session ended';
|
|
1617
|
+
message = `Claude Code session ended (${event.reason || 'unknown'}).`;
|
|
1618
|
+
} else {
|
|
1619
|
+
return EXIT.OK; // unknown event — stay silent rather than send noise
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const data = { event: name };
|
|
1623
|
+
if (event.session_id) data.session_id = String(event.session_id);
|
|
1624
|
+
if (event.cwd) data.cwd = String(event.cwd);
|
|
1625
|
+
|
|
1626
|
+
try {
|
|
1627
|
+
await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
|
|
1628
|
+
token,
|
|
1629
|
+
body: {
|
|
1630
|
+
message,
|
|
1631
|
+
title,
|
|
1632
|
+
data,
|
|
1633
|
+
...(event.session_id ? { correlation_id: String(event.session_id) } : {}),
|
|
1634
|
+
},
|
|
1635
|
+
});
|
|
1636
|
+
if (!args.quiet) process.stderr.write('pingroom: pinged ✅\n');
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
// A broken ping must never break the agent — report to stderr and exit 0.
|
|
1639
|
+
if (!args.quiet) process.stderr.write(`pingroom: hook ping failed (${err.message})\n`);
|
|
1640
|
+
}
|
|
1641
|
+
return EXIT.OK;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function printHookConfig() {
|
|
1645
|
+
const command = `npx --yes @pingroom/cli@${VERSION} hook`;
|
|
1646
|
+
const config = {
|
|
1647
|
+
hooks: {
|
|
1648
|
+
Stop: [{ hooks: [{ type: 'command', command }] }],
|
|
1649
|
+
Notification: [{ hooks: [{ type: 'command', command }] }],
|
|
1650
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command, timeout: 960 }] }],
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
process.stdout.write(
|
|
1654
|
+
`# PingRoom × Claude Code — merge this into ~/.claude/settings.json
|
|
1655
|
+
#
|
|
1656
|
+
# 1. Set your credentials in the environment (e.g. in your shell profile):
|
|
1657
|
+
# export PINGROOM_TOKEN="<your agent token>"
|
|
1658
|
+
# export PINGROOM_ROOM="<room invite code>"
|
|
1659
|
+
#
|
|
1660
|
+
# 2. Merge the "hooks" block below into ~/.claude/settings.json.
|
|
1661
|
+
# Stop / Notification -> ping your phone.
|
|
1662
|
+
# PreToolUse (Bash) -> ask a question you Approve/Deny from the lock
|
|
1663
|
+
# screen before the command runs. Add or change the
|
|
1664
|
+
# matcher to gate other tools.
|
|
1665
|
+
#
|
|
1666
|
+
# If PingRoom is unreachable the hook defers to the normal local prompt — it
|
|
1667
|
+
# never auto-approves and never blocks the agent.
|
|
1668
|
+
|
|
1669
|
+
${JSON.stringify(config, null, 2)}
|
|
1670
|
+
`);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
async function hook(args) {
|
|
1674
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
1675
|
+
if (args.print_config) { printHookConfig(); return EXIT.OK; }
|
|
1676
|
+
|
|
1677
|
+
let event = {};
|
|
1678
|
+
const raw = await readStdin();
|
|
1679
|
+
if (raw) { try { event = JSON.parse(raw); } catch { event = {}; } }
|
|
1680
|
+
const name = event.hook_event_name || '';
|
|
1681
|
+
|
|
1682
|
+
// The hook fails open, so it reads the same layered config as everything else
|
|
1683
|
+
// but never complains about a missing piece — it just defers.
|
|
1684
|
+
const token = resolveToken(args);
|
|
1685
|
+
const room = resolveRoom(args);
|
|
1686
|
+
const apiBase = resolveApiBase(args);
|
|
1687
|
+
|
|
1688
|
+
if (name === 'PreToolUse') {
|
|
1689
|
+
return hookPreToolUse(event, { token, room, apiBase, args });
|
|
1690
|
+
}
|
|
1691
|
+
return hookNotify(event, name, { token, room, apiBase, args });
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// --- connecting (pairing + email fallback) ---------------------------------
|
|
1695
|
+
//
|
|
1696
|
+
// Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
|
|
1697
|
+
// scanning the QR is where the human picks BOTH the account and the delivery
|
|
1698
|
+
// room, so an agent can never end up connected with nobody's say-so about where
|
|
1699
|
+
// it pings. There is no `login` subcommand: `pingroom` resolves the state.
|
|
1700
|
+
|
|
1701
|
+
// The scopes this CLI can actually use, one per command surface. Requested at
|
|
1702
|
+
// registration so the approval screen shows exactly what it is granting; the
|
|
1703
|
+
// server intersects, so asking for less is always safe and asking for more than
|
|
1704
|
+
// the human approves is impossible.
|
|
1705
|
+
const CLI_SCOPES = [
|
|
1706
|
+
'pingroom:rooms:read', // resolve/display the connected room
|
|
1707
|
+
'pingroom:broadcast:send', // ping
|
|
1708
|
+
'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
|
|
1709
|
+
'pingroom:handoffs:create', // handoff / handoffs
|
|
1710
|
+
'pingroom:live:write', // live start/update/end/get
|
|
1711
|
+
];
|
|
1712
|
+
|
|
1713
|
+
const AGENT_LABEL = 'pingroom-cli';
|
|
1714
|
+
|
|
1715
|
+
// Widest QR we render (compact half-block form of a ~110-char pair URL is 39
|
|
1716
|
+
// columns). Anything narrower would wrap and become unscannable, so we print
|
|
1717
|
+
// the URL alone instead of a broken QR.
|
|
1718
|
+
const QR_MIN_COLUMNS = 41;
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Draw the pair URL as a scannable QR. Returns false when it could not — a too
|
|
1722
|
+
* narrow terminal, or the optional dependency being absent (someone vendored
|
|
1723
|
+
* just bin/) — and the caller falls back to the printed URL, which always works.
|
|
1724
|
+
*/
|
|
1725
|
+
async function renderQr(url) {
|
|
1726
|
+
// A real terminal reports its width on the stream; COLUMNS covers the rest.
|
|
1727
|
+
// Unknown width is treated as wide enough — the URL is printed either way.
|
|
1728
|
+
const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
|
|
1729
|
+
if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
|
|
1730
|
+
|
|
1731
|
+
let qr;
|
|
1732
|
+
try {
|
|
1733
|
+
const mod = await import('qrcode-terminal');
|
|
1734
|
+
qr = mod.default || mod;
|
|
1735
|
+
} catch { return false; }
|
|
1736
|
+
if (!qr || typeof qr.generate !== 'function') return false;
|
|
1737
|
+
|
|
1738
|
+
try {
|
|
1739
|
+
let art = '';
|
|
1740
|
+
// Call it as a method: qrcode-terminal reads its error-correction level off
|
|
1741
|
+
// `this`, so a detached `generate` reference silently builds a version-1
|
|
1742
|
+
// code and throws on anything longer than a few characters.
|
|
1743
|
+
// `small` is the half-block form: two module rows per text row, so the code
|
|
1744
|
+
// stays square-ish and fits an 80-column terminal.
|
|
1745
|
+
qr.generate(url, { small: true }, (rendered) => { art = rendered; });
|
|
1746
|
+
if (!art) return false;
|
|
1747
|
+
process.stdout.write(`\n${art}\n`);
|
|
1748
|
+
return true;
|
|
1749
|
+
} catch { return false; }
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* A line-at-a-time reader over stdin.
|
|
1754
|
+
*
|
|
1755
|
+
* Deliberately not node:readline: its Interface keeps consuming while we are
|
|
1756
|
+
* awaiting an HTTP round trip between two questions and drops the lines nobody
|
|
1757
|
+
* is listening for, which silently loses piped answers. This queues every line
|
|
1758
|
+
* instead, so the answers can arrive in one blob or one keystroke at a time.
|
|
1759
|
+
*
|
|
1760
|
+
* ask() resolves `null` — never a string — once the input is closed, so it can
|
|
1761
|
+
* never be confused with a real empty line. That distinction is load-bearing:
|
|
1762
|
+
* callers treat an empty line as "take the default", and a caller that reads EOF
|
|
1763
|
+
* as an empty line will take that default again on the next question, and the
|
|
1764
|
+
* next, forever, because nothing will ever arrive to change its mind. Callers
|
|
1765
|
+
* that genuinely want the empty-line behaviour opt in with `?? ''`.
|
|
1766
|
+
*/
|
|
1767
|
+
function createPrompter() {
|
|
1768
|
+
const queued = [];
|
|
1769
|
+
const waiting = [];
|
|
1770
|
+
let buffer = '';
|
|
1771
|
+
let closed = false;
|
|
1772
|
+
|
|
1773
|
+
const deliver = (line) => {
|
|
1774
|
+
const waiter = waiting.shift();
|
|
1775
|
+
if (waiter) waiter(line);
|
|
1776
|
+
else queued.push(line);
|
|
1777
|
+
};
|
|
1778
|
+
const onData = (chunk) => {
|
|
1779
|
+
buffer += chunk;
|
|
1780
|
+
let idx;
|
|
1781
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
1782
|
+
deliver(buffer.slice(0, idx).replace(/\r$/, ''));
|
|
1783
|
+
buffer = buffer.slice(idx + 1);
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
const onEnd = () => {
|
|
1787
|
+
if (closed) return;
|
|
1788
|
+
closed = true;
|
|
1789
|
+
if (buffer) { deliver(buffer); buffer = ''; }
|
|
1790
|
+
while (waiting.length) waiting.shift()(null);
|
|
1791
|
+
};
|
|
1792
|
+
|
|
1793
|
+
process.stdin.setEncoding('utf8');
|
|
1794
|
+
process.stdin.on('data', onData);
|
|
1795
|
+
process.stdin.once('end', onEnd);
|
|
1796
|
+
process.stdin.resume();
|
|
1797
|
+
|
|
1798
|
+
return {
|
|
1799
|
+
ask(question) {
|
|
1800
|
+
process.stdout.write(question);
|
|
1801
|
+
if (queued.length > 0) return Promise.resolve(queued.shift());
|
|
1802
|
+
if (closed) return Promise.resolve(null);
|
|
1803
|
+
return new Promise((resolve) => { waiting.push(resolve); });
|
|
1804
|
+
},
|
|
1805
|
+
close() {
|
|
1806
|
+
process.stdin.off('data', onData);
|
|
1807
|
+
process.stdin.off('end', onEnd);
|
|
1808
|
+
process.stdin.pause();
|
|
1809
|
+
},
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
|
|
1814
|
+
async function registerAnonymous(apiBase) {
|
|
1815
|
+
const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
|
|
1816
|
+
body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
|
|
1817
|
+
});
|
|
1818
|
+
if (!res.ok || !json || typeof json.credential !== 'string') {
|
|
1819
|
+
const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
|
|
1820
|
+
fail(`could not start a connection: ${detail}`);
|
|
1821
|
+
}
|
|
1822
|
+
return json.credential;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/** Persist the active credential plus the bits the status line prints. */
|
|
1826
|
+
function saveCredential({ token, handle, room, account, scopes, apiBase }) {
|
|
1827
|
+
writeJsonFile(credentialsPath(), {
|
|
1828
|
+
version: 1,
|
|
1829
|
+
token,
|
|
1830
|
+
handle: handle || null,
|
|
1831
|
+
room: room || null,
|
|
1832
|
+
account: account || null,
|
|
1833
|
+
scopes: scopes || [],
|
|
1834
|
+
api_url: apiBase,
|
|
1835
|
+
created_at: new Date().toISOString(),
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown. */
|
|
1840
|
+
function connectedLine(cred) {
|
|
1841
|
+
const who = cred.handle ? `@${cred.handle}` : 'this machine';
|
|
1842
|
+
const room = cred.room && (cred.room.name || cred.room.invite_code);
|
|
1843
|
+
return `✓ Connected as ${who}${room ? ` → #${room}` : ''}`;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* The QR path. Mints a pre-claim credential, asks the server for a pairing
|
|
1848
|
+
* token, renders it, then polls until the human approves. Returns a credential
|
|
1849
|
+
* object, or null when the pairing lapsed and the user declined a fresh one.
|
|
1850
|
+
*/
|
|
1851
|
+
async function connectByPairing(apiBase, ask) {
|
|
1852
|
+
for (;;) {
|
|
1853
|
+
const preClaim = await registerAnonymous(apiBase);
|
|
1854
|
+
const headers = { Authorization: `Bearer ${preClaim}` };
|
|
1855
|
+
|
|
1856
|
+
const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
|
|
1857
|
+
body: { scopes: CLI_SCOPES },
|
|
1858
|
+
headers,
|
|
1859
|
+
});
|
|
1860
|
+
if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
|
|
1861
|
+
const detail = (start.json && (start.json.message || start.json.error || start.json.code))
|
|
1862
|
+
|| `HTTP ${start.res.status}`;
|
|
1863
|
+
fail(`could not start pairing: ${detail}`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// The URL is server-controlled and goes straight to the terminal, so strip
|
|
1867
|
+
// C0/C1 controls: an --api / config api_url pointing at a hostile host could
|
|
1868
|
+
// otherwise emit ANSI escapes that repaint or hide the line the user is
|
|
1869
|
+
// about to trust with their account.
|
|
1870
|
+
const pairUrl = stripControlChars(start.json.pair_url);
|
|
1871
|
+
// 900s is the server's pre-claim lifetime; never poll past it, and clamp the
|
|
1872
|
+
// server's suggested interval so a bad value can't busy-loop or stall.
|
|
1873
|
+
// The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
|
|
1874
|
+
// pair/status at `60,1`, so a faster floor spends the pairing window
|
|
1875
|
+
// collecting 429s instead of the approval.
|
|
1876
|
+
const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
|
|
1877
|
+
const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
|
|
1878
|
+
const deadline = Date.now() + lifetimeMs;
|
|
1879
|
+
|
|
1880
|
+
const drew = await renderQr(pairUrl);
|
|
1881
|
+
process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
|
|
1882
|
+
process.stdout.write(' Waiting for approval… ');
|
|
1883
|
+
|
|
1884
|
+
// A transient failure must not end a wait the human is mid-way through.
|
|
1885
|
+
// Network errors, 5xx and 429 are the load balancer / rate limiter talking,
|
|
1886
|
+
// not the pairing being over; hard-failing on the first one throws away the
|
|
1887
|
+
// whole 15 minutes over a single blip. 401/403/404 still exit immediately —
|
|
1888
|
+
// those say the pre-claim is gone, and retrying can only spin.
|
|
1889
|
+
// The `Date.now() < deadline` bound is what keeps a *persistent* outage from
|
|
1890
|
+
// retrying forever: it ends at the same moment a clean poll would have.
|
|
1891
|
+
let transientRun = 0;
|
|
1892
|
+
let lastTransient = null;
|
|
1893
|
+
let warnedTransient = false;
|
|
1894
|
+
|
|
1895
|
+
while (Date.now() < deadline) {
|
|
1896
|
+
const { res, json, error } = await httpJson(
|
|
1897
|
+
'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
if (error || res.status >= 500 || res.status === 429) {
|
|
1901
|
+
transientRun += 1;
|
|
1902
|
+
lastTransient = error
|
|
1903
|
+
? error.message
|
|
1904
|
+
: `HTTP ${res.status}`;
|
|
1905
|
+
// Say something rather than sitting mute: a user watching a QR with no
|
|
1906
|
+
// output cannot tell a slow approval from a broken endpoint.
|
|
1907
|
+
if (transientRun === 3 && !warnedTransient) {
|
|
1908
|
+
warnedTransient = true;
|
|
1909
|
+
process.stdout.write(`\n (still trying — ${lastTransient}) `);
|
|
1910
|
+
}
|
|
1911
|
+
// Ride out a short blip at the normal cadence, then back off
|
|
1912
|
+
// geometrically so a real outage is not also a thundering herd. Never
|
|
1913
|
+
// sleep past the deadline this loop is bounded by.
|
|
1914
|
+
const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
|
|
1915
|
+
await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
transientRun = 0;
|
|
1920
|
+
|
|
1921
|
+
if (!res.ok) {
|
|
1922
|
+
process.stdout.write('\n');
|
|
1923
|
+
const detail = (json && (json.message || json.error || json.code)) || `HTTP ${res.status}`;
|
|
1924
|
+
fail(`pairing failed: ${detail}`);
|
|
1925
|
+
}
|
|
1926
|
+
const status = json && json.status;
|
|
1927
|
+
if (status === 'active') {
|
|
1928
|
+
// A server that says "active" with no credential has not paired us.
|
|
1929
|
+
// Without this, `token: undefined` is written to credentials.json and
|
|
1930
|
+
// every later command reads a credential file that exists but cannot
|
|
1931
|
+
// authenticate — a far more confusing failure than stopping here.
|
|
1932
|
+
if (typeof json.credential !== 'string' || json.credential === '') {
|
|
1933
|
+
process.stdout.write('\n');
|
|
1934
|
+
fail('pairing succeeded but the server returned no credential');
|
|
1935
|
+
}
|
|
1936
|
+
const cred = {
|
|
1937
|
+
token: json.credential,
|
|
1938
|
+
handle: json.handle,
|
|
1939
|
+
room: json.room,
|
|
1940
|
+
account: json.account,
|
|
1941
|
+
scopes: json.scopes,
|
|
1942
|
+
apiBase,
|
|
1943
|
+
};
|
|
1944
|
+
saveCredential(cred);
|
|
1945
|
+
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
1946
|
+
return cred;
|
|
1947
|
+
}
|
|
1948
|
+
if (status === 'expired') break;
|
|
1949
|
+
// `pending` (or anything unrecognized) — keep waiting.
|
|
1950
|
+
await sleep(intervalMs);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
if (transientRun > 0) {
|
|
1954
|
+
process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
|
|
1955
|
+
} else {
|
|
1956
|
+
process.stdout.write(`\n That code expired.\n`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// `null` means the input is closed, and that is the whole point of this
|
|
1960
|
+
// guard. Reading EOF as "" would fall through the y/yes test below (empty
|
|
1961
|
+
// means "take the default: yes"), restart the for(;;), mint another
|
|
1962
|
+
// anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
|
|
1963
|
+
// a single pairing attempt into thousands of registrations against the API.
|
|
1964
|
+
const again = await ask(' Show a fresh QR code? [Y/n]: ');
|
|
1965
|
+
if (again === null) { process.stdout.write('\n'); return null; }
|
|
1966
|
+
const answer = again.trim().toLowerCase();
|
|
1967
|
+
if (answer && answer !== 'y' && answer !== 'yes') return null;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The email fallback, over the unchanged claim/* endpoints: the server mails a
|
|
1973
|
+
* link, the web page shows a 6-digit code, the user reads it back here.
|
|
1974
|
+
*/
|
|
1975
|
+
async function connectByEmail(apiBase, ask) {
|
|
1976
|
+
const preClaim = await registerAnonymous(apiBase);
|
|
1977
|
+
const headers = { Authorization: `Bearer ${preClaim}` };
|
|
1978
|
+
|
|
1979
|
+
// `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
|
|
1980
|
+
// at EOF, and without the coalesce this would throw a TypeError on `.trim()`
|
|
1981
|
+
// instead of reaching the "this is required" error the user should see.
|
|
1982
|
+
const email = (await ask(' Your PingRoom email: ') ?? '').trim();
|
|
1983
|
+
if (!email) fail('an email address is required', EXIT.USAGE);
|
|
1984
|
+
|
|
1985
|
+
const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
|
|
1986
|
+
body: { email },
|
|
1987
|
+
headers,
|
|
1988
|
+
});
|
|
1989
|
+
if (!start.res.ok) {
|
|
1990
|
+
const detail = (start.json && (start.json.message || start.json.error || start.json.code))
|
|
1991
|
+
|| `HTTP ${start.res.status}`;
|
|
1992
|
+
fail(`could not send the email: ${detail}`);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
|
|
1996
|
+
|
|
1997
|
+
// A mistyped code is the common case, so allow a few tries before giving up.
|
|
1998
|
+
// The server locks the registration out after its own attempt cap anyway.
|
|
1999
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
2000
|
+
// Same reason as the email prompt: EOF stays an empty answer, which the
|
|
2001
|
+
// server rejects, rather than a TypeError on null.
|
|
2002
|
+
const otp = (await ask(' Code: ') ?? '').trim();
|
|
2003
|
+
const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
|
|
2004
|
+
body: { email, otp },
|
|
2005
|
+
headers,
|
|
2006
|
+
});
|
|
2007
|
+
if (done.res.ok && done.json && typeof done.json.credential === 'string') {
|
|
2008
|
+
const cred = {
|
|
2009
|
+
token: done.json.credential,
|
|
2010
|
+
handle: done.json.handle,
|
|
2011
|
+
// claim/complete carries no room — the email flow does not choose one.
|
|
2012
|
+
room: done.json.room,
|
|
2013
|
+
account: done.json.account,
|
|
2014
|
+
scopes: done.json.scopes,
|
|
2015
|
+
apiBase,
|
|
2016
|
+
};
|
|
2017
|
+
saveCredential(cred);
|
|
2018
|
+
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
2019
|
+
if (!cred.room) {
|
|
2020
|
+
process.stdout.write(' Pick a delivery room with: pingroom config set default_room <invite code>\n');
|
|
2021
|
+
}
|
|
2022
|
+
return cred;
|
|
2023
|
+
}
|
|
2024
|
+
const detail = (done.json && (done.json.message || done.json.error || done.json.code))
|
|
2025
|
+
|| `HTTP ${done.res.status}`;
|
|
2026
|
+
if (attempt === 3) fail(`could not connect: ${detail}`);
|
|
2027
|
+
process.stderr.write(`pingroom: ${detail}\n`);
|
|
2028
|
+
}
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Resolve the unconnected state interactively. Refuses outright when there is no
|
|
2034
|
+
* TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
|
|
2035
|
+
* PINGROOM_TOKEN, not a QR nobody can scan.
|
|
2036
|
+
*/
|
|
2037
|
+
async function connect(args) {
|
|
2038
|
+
if (!isInteractive()) {
|
|
2039
|
+
fail(
|
|
2040
|
+
'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
|
|
2041
|
+
EXIT.USAGE,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const apiBase = resolveApiBase(args);
|
|
2046
|
+
requireSafeUrl('--api', apiBase);
|
|
2047
|
+
|
|
2048
|
+
const prompter = createPrompter();
|
|
2049
|
+
const ask = (question) => prompter.ask(question);
|
|
2050
|
+
try {
|
|
2051
|
+
process.stdout.write(' Not connected. How do you want to connect?\n');
|
|
2052
|
+
process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
|
|
2053
|
+
process.stdout.write(' 2) Email me a code\n');
|
|
2054
|
+
// EOF here means "no answer", which is what the default already covers, so
|
|
2055
|
+
// coalesce rather than crash on null — the pairing branch below is the one
|
|
2056
|
+
// that must distinguish EOF, and it does.
|
|
2057
|
+
const choice = (await ask(' Choose [1]: ') ?? '').trim();
|
|
2058
|
+
if (choice && choice !== '1' && choice !== '2') {
|
|
2059
|
+
process.stderr.write('pingroom: choose 1 or 2\n');
|
|
2060
|
+
return EXIT.USAGE;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const cred = choice === '2'
|
|
2064
|
+
? await connectByEmail(apiBase, ask)
|
|
2065
|
+
: await connectByPairing(apiBase, ask);
|
|
2066
|
+
|
|
2067
|
+
return cred ? EXIT.OK : EXIT.EXPIRED;
|
|
2068
|
+
} finally {
|
|
2069
|
+
prompter.close();
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// --- status / bare invocation ----------------------------------------------
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* `pingroom` with no arguments. Connected -> one status line then the usual
|
|
2077
|
+
* help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
|
|
2078
|
+
* and still print the help rather than prompting into the void.
|
|
2079
|
+
*/
|
|
2080
|
+
async function bare(args) {
|
|
2081
|
+
const envToken = process.env.PINGROOM_TOKEN;
|
|
2082
|
+
const stored = readStoredCredential();
|
|
2083
|
+
|
|
2084
|
+
if (envToken) {
|
|
2085
|
+
process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
|
|
2086
|
+
if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
|
|
2087
|
+
const room = resolveRoom(args);
|
|
2088
|
+
if (room) process.stdout.write(`Default room: ${room}\n`);
|
|
2089
|
+
process.stdout.write(`\n${HELP}\n`);
|
|
2090
|
+
return EXIT.OK;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
if (stored) {
|
|
2094
|
+
process.stdout.write(`${connectedLine(stored)}\n`);
|
|
2095
|
+
const room = resolveRoom(args);
|
|
2096
|
+
if (room) process.stdout.write(`Default room: ${room}\n`);
|
|
2097
|
+
process.stdout.write(`\n${HELP}\n`);
|
|
2098
|
+
return EXIT.OK;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (!isInteractive()) {
|
|
2102
|
+
process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
|
|
2103
|
+
process.stdout.write(`${HELP}\n`);
|
|
2104
|
+
return EXIT.OK;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
return connect(args);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// --- config ----------------------------------------------------------------
|
|
2111
|
+
|
|
2112
|
+
// Only these keys are storable. An unknown key is a usage error rather than a
|
|
2113
|
+
// silently-ignored setting the user then blames the tool for not honouring.
|
|
2114
|
+
const CONFIG_KEYS = {
|
|
2115
|
+
default_room: {
|
|
2116
|
+
describe: 'Room invite code used when --room / PINGROOM_ROOM is absent',
|
|
2117
|
+
validate: (value) => {
|
|
2118
|
+
if (/\s/.test(value) || value.length > 64) return 'default_room must be an invite code (no spaces, <= 64 chars)';
|
|
2119
|
+
return null;
|
|
2120
|
+
},
|
|
2121
|
+
},
|
|
2122
|
+
api_url: {
|
|
2123
|
+
describe: `API base URL (default ${BUILTIN_API})`,
|
|
2124
|
+
validate: (value) => {
|
|
2125
|
+
let u;
|
|
2126
|
+
try { u = new URL(value); } catch { return 'api_url must be a valid URL'; }
|
|
2127
|
+
const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
2128
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
2129
|
+
return 'api_url must use https (refusing to send credentials over cleartext)';
|
|
2130
|
+
}
|
|
2131
|
+
return null;
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
async function config(args) {
|
|
2137
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
2138
|
+
|
|
2139
|
+
const sub = args._[0];
|
|
2140
|
+
const known = ['list', 'get', 'set'];
|
|
2141
|
+
if (!sub || !known.includes(sub)) {
|
|
2142
|
+
fail(`config needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
const stored = readConfigFile();
|
|
2146
|
+
|
|
2147
|
+
if (sub === 'list') {
|
|
2148
|
+
if (args.json) { process.stdout.write(`${JSON.stringify(stored)}\n`); return EXIT.OK; }
|
|
2149
|
+
const keys = Object.keys(CONFIG_KEYS).filter((k) => stored[k] !== undefined && stored[k] !== '');
|
|
2150
|
+
if (keys.length === 0) {
|
|
2151
|
+
process.stdout.write(`no settings stored in ${configPath()}\n`);
|
|
2152
|
+
return EXIT.OK;
|
|
2153
|
+
}
|
|
2154
|
+
for (const key of keys) process.stdout.write(`${key}=${stored[key]}\n`);
|
|
2155
|
+
return EXIT.OK;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const key = args._[1];
|
|
2159
|
+
if (!key) fail(`config ${sub} needs a key (${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
|
|
2160
|
+
if (!Object.hasOwn(CONFIG_KEYS, key)) {
|
|
2161
|
+
fail(`unknown config key: ${key} (known keys: ${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (sub === 'get') {
|
|
2165
|
+
const value = stored[key];
|
|
2166
|
+
if (value === undefined || value === '') return EXIT.OK; // unset: print nothing, exit 0
|
|
2167
|
+
process.stdout.write(`${value}\n`);
|
|
2168
|
+
return EXIT.OK;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// set
|
|
2172
|
+
const raw = args._[2];
|
|
2173
|
+
if (raw === undefined) fail(`config set needs a value (pass "" to clear ${key})`, EXIT.USAGE);
|
|
2174
|
+
const value = String(raw).trim();
|
|
2175
|
+
|
|
2176
|
+
if (value === '') {
|
|
2177
|
+
delete stored[key];
|
|
2178
|
+
writeJsonFile(configPath(), stored);
|
|
2179
|
+
process.stdout.write(`${key} cleared\n`);
|
|
2180
|
+
return EXIT.OK;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
const problem = CONFIG_KEYS[key].validate(value);
|
|
2184
|
+
if (problem) fail(problem, EXIT.USAGE);
|
|
2185
|
+
|
|
2186
|
+
stored[key] = value;
|
|
2187
|
+
writeJsonFile(configPath(), stored);
|
|
2188
|
+
process.stdout.write(`${key}=${value}\n`);
|
|
2189
|
+
return EXIT.OK;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// --- logout ----------------------------------------------------------------
|
|
2193
|
+
|
|
2194
|
+
async function logout(args) {
|
|
2195
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
2196
|
+
|
|
2197
|
+
const path = credentialsPath();
|
|
2198
|
+
const stored = readStoredCredential();
|
|
2199
|
+
try {
|
|
2200
|
+
unlinkSync(path);
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
if (err.code === 'ENOENT') {
|
|
2203
|
+
process.stdout.write('not connected — there was no stored credential to clear\n');
|
|
2204
|
+
return EXIT.OK;
|
|
2205
|
+
}
|
|
2206
|
+
fail(`could not clear ${path}: ${err.message}`);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const who = stored && stored.handle ? ` (@${stored.handle})` : '';
|
|
2210
|
+
process.stdout.write(`logged out${who} — cleared ${path}\n`);
|
|
2211
|
+
if (process.env.PINGROOM_TOKEN) {
|
|
2212
|
+
process.stdout.write('note: PINGROOM_TOKEN is still set in this environment and will keep being used\n');
|
|
2213
|
+
}
|
|
2214
|
+
return EXIT.OK;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const COMMANDS = {
|
|
2218
|
+
ping: (rest) => ping(parseArgs(rest)),
|
|
2219
|
+
ask: (rest) => ask(parseQArgs(rest)),
|
|
2220
|
+
watch: (rest) => waitFrom(watch, rest),
|
|
2221
|
+
await: (rest) => waitFrom(watch, rest),
|
|
2222
|
+
cancel: (rest) => cancel(parseQArgs(rest)),
|
|
2223
|
+
list: (rest) => list(parseQArgs(rest)),
|
|
2224
|
+
handoff: (rest) => handoff(parseHandoffArgs(rest)),
|
|
2225
|
+
handoffs: (rest) => listHandoffs(parseQArgs(rest)),
|
|
2226
|
+
hook: (rest) => hook(parseHookArgs(rest)),
|
|
2227
|
+
live: (rest) => live(parseLiveArgs(rest)),
|
|
2228
|
+
config: (rest) => config(parseQArgs(rest)),
|
|
2229
|
+
logout: (rest) => logout(parseQArgs(rest)),
|
|
2230
|
+
};
|
|
2231
|
+
|
|
2232
|
+
function waitFrom(handler, rest) {
|
|
2233
|
+
return handler(parseQArgs(rest));
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
async function main() {
|
|
2237
|
+
const argv = process.argv.slice(2);
|
|
2238
|
+
const command = argv[0];
|
|
2239
|
+
|
|
2240
|
+
if (command === '-h' || command === '--help' || command === 'help') {
|
|
2241
|
+
process.stdout.write(`${HELP}\n`);
|
|
2242
|
+
process.exit(EXIT.OK);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Bare `pingroom` resolves the auth state instead of only printing help:
|
|
2246
|
+
// connected -> status + help; not connected -> pair (interactive only).
|
|
2247
|
+
// A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
|
|
2248
|
+
// configures the connect attempt rather than naming a command.
|
|
2249
|
+
if (!command || command.startsWith('-')) {
|
|
2250
|
+
process.exit(await bare(parseQArgs(argv)));
|
|
461
2251
|
}
|
|
462
2252
|
|
|
463
2253
|
const handler = COMMANDS[command];
|