@pingroom/cli 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +37 -2
  2. package/bin/pingroom.js +339 -42
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @pingroom/cli
2
2
 
3
- Send PingRoom pings from CI, scripts, and agents deploy notifications in one line,
4
- delivered as push straight to your phone.
3
+ Send PingRoom pings and ask a human a question and block for their answer —
4
+ from CI, scripts, and agents. Delivered as push straight to your phone.
5
5
 
6
6
  Zero dependencies. Works anywhere Node ≥ 20 runs.
7
7
 
@@ -9,6 +9,9 @@ Zero dependencies. Works anywhere Node ≥ 20 runs.
9
9
  npx @pingroom/cli ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
10
10
  ```
11
11
 
12
+ Commands: `ping` (send), `ask` (ask a human), `watch` (block on an existing
13
+ question), `list`, `cancel`. Run `pingroom --help` for the full reference.
14
+
12
15
  ## Getting a webhook URL
13
16
 
14
17
  In the PingRoom app, open a room → **Connections → Incoming webhooks → Add**. Copy the
@@ -86,6 +89,38 @@ pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped" \
86
89
  -d '{"version":"1.4.0"}'
87
90
  ```
88
91
 
92
+ ## Ask a human (Questions)
93
+
94
+ Turn a human decision into a shell gate. `ask --wait` blocks until someone taps
95
+ an answer on their phone, prints the chosen option **value** to stdout, and
96
+ encodes the outcome in the exit code — `0` answered, `3` expired, `4` cancelled.
97
+ Needs an agent token and a room.
98
+
99
+ ```bash
100
+ # Gate a production deploy on a lock-screen tap (Approve/Deny is the default):
101
+ if [ "$(pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd --wait \
102
+ -p 'Deploy 1.4.0 to production?')" = approve ]; then
103
+ ./deploy-prod.sh
104
+ fi
105
+
106
+ # A multi-option question, answerable by anyone in the room:
107
+ pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd --scope room --wait \
108
+ -p 'Which environment?' -o prod:Production -o staging:Staging -o cancel:Cancel
109
+
110
+ # Fire-and-forget (prints the question id), then watch it later:
111
+ ID=$(pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd -p 'Merge PR #42?' --ttl 1800)
112
+ pingroom watch --token "$PINGROOM_TOKEN" "$ID"
113
+
114
+ pingroom list --token "$PINGROOM_TOKEN" --state pending
115
+ pingroom cancel --token "$PINGROOM_TOKEN" "$ID"
116
+ ```
117
+
118
+ Options are `value:label` pairs (repeat `-o` for 2–4). Omit them for the binary
119
+ Approve/Deny default — two options is the lock-screen fast path. `--ttl` sets the
120
+ expiry in seconds (default 1h; 30–86400). `--scope room` lets any eligible member
121
+ answer (first tap wins); the default `direct` asks your bound user.
122
+
123
+ For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
89
124
  See <https://pingroom.io/connect-mcp.md> to connect Cursor, Claude Desktop, or Claude Code.
90
125
 
91
126
  ## License
package/bin/pingroom.js CHANGED
@@ -1,21 +1,33 @@
1
1
  #!/usr/bin/env node
2
- // @pingroom/cli — send a PingRoom ping from CI, a script, or an agent.
2
+ // @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
3
3
  // Zero dependencies: uses Node's built-in fetch (Node >= 20).
4
4
  //
5
- // Two delivery modes:
6
- // 1. Webhook (best for CI) a room webhook URL carries its own secret, so no
7
- // browser auth is needed. `pingroom ping -w <url> -m "deploy ok"`.
8
- // 2. Agent token a Bearer credential + room invite code hits the agent API.
9
- // `pingroom ping --token <t> --room <code> -m "deploy ok"`.
5
+ // Commands:
6
+ // ping Send a ping to a room. Webhook mode (a room URL carries its own
7
+ // secret best for CI) or agent-token mode (Bearer + room code).
8
+ // ask Ask a human a question in a room and, with --wait, block until they
9
+ // tap an answer turning a human decision into a shell gate.
10
+ // watch Block until a question resolves and print the outcome.
11
+ // list List the agent's questions by state.
12
+ // cancel Withdraw a pending question.
13
+ //
14
+ // Exit codes: 0 success/answered · 1 error · 2 bad usage · 3 expired · 4 cancelled.
10
15
 
11
16
  const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
12
17
 
13
- const HELP = `pingroom — send a ping to a PingRoom room
18
+ const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
14
19
 
15
20
  Usage:
16
- pingroom ping [options]
21
+ pingroom <command> [options]
22
+
23
+ Commands:
24
+ ping Send a ping to a room (webhook URL, or agent token + room)
25
+ ask Ask a human a question; with --wait, block until they answer
26
+ watch Block until a question resolves and print the outcome
27
+ list List the agent's questions by state
28
+ cancel Withdraw a pending question
17
29
 
18
- Options:
30
+ ping options:
19
31
  -m, --message <text> Ping body text (required)
20
32
  -t, --title <text> Ping title (<= 40 chars)
21
33
  -a, --action <1-4> Quick-action slot to attribute the ping to
@@ -23,17 +35,62 @@ Options:
23
35
  -w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
24
36
  --token <token> Agent access token (or env PINGROOM_TOKEN)
25
37
  --room <code> Room invite code (used with --token)
38
+
39
+ ask options (agent token required):
40
+ -p, --prompt <text> The question a human reads (required)
41
+ -o, --option <v:label> An answer option; repeat for 2–4. Omit for Approve/Deny
42
+ -c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
43
+ --scope <s> Who answers: 'direct' (default) or 'room'
44
+ --target <uuid> For --scope direct: a specific room member
45
+ --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
46
+ --wait Block until answered/expired/cancelled
47
+ --timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
48
+ -d, --data <json> Structured data object echoed back on the answer
49
+ --correlation-id <id> Opaque id echoed on every read of this question
50
+ --room <code> Room invite code (required for ask)
51
+
52
+ list options:
53
+ --state <s> pending | answered | expired | cancelled | all
54
+
55
+ Shared:
56
+ --token <token> Agent access token (or env PINGROOM_TOKEN)
26
57
  --api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
27
58
  --json Print the raw JSON response
28
59
  -h, --help Show this help
29
60
 
30
61
  Examples:
31
62
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
32
- pingroom ping -w "$PINGROOM_WEBHOOK_URL" -t "CI" -m "Build #42 failed" -a 2
33
- pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped" \\
34
- -d '{"version":"1.4.0","env":"prod"}'
63
+ pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
64
+
65
+ # Gate a deploy on a human tap — the chosen value prints to stdout:
66
+ if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
67
+ -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
68
+
69
+ # Multi-option question, blocking:
70
+ pingroom ask --token "$T" --room ab12cd --scope room --wait \\
71
+ -p 'Which environment?' -o prod:Production -o staging:Staging
72
+
73
+ pingroom list --token "$T" --state pending
74
+ pingroom watch --token "$T" q_01H... # block on an existing question
75
+ pingroom cancel --token "$T" q_01H...
76
+
77
+ Security:
78
+ Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
79
+ secrets as --webhook / --token flags: argv is visible to other users via the
80
+ process table (ps) and may be captured in shell history. URLs must use https
81
+ (loopback http is allowed for local dev).
82
+
83
+ Exit codes: 0 on success (answered), 1 on error, 2 on bad usage,
84
+ 3 when a question expired, 4 when it was cancelled.`;
85
+
86
+ const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
35
87
 
36
- Exit codes: 0 on success, 1 on delivery failure, 2 on bad usage.`;
88
+ function fail(message, code = EXIT.ERROR) {
89
+ process.stderr.write(`pingroom: ${message}\n`);
90
+ process.exit(code);
91
+ }
92
+
93
+ // --- ping (unchanged wire behaviour) ---------------------------------------
37
94
 
38
95
  function parseArgs(argv) {
39
96
  const args = { _: [] };
@@ -60,7 +117,7 @@ function parseArgs(argv) {
60
117
  if (key) {
61
118
  args[key] = argv[++i];
62
119
  } else if (token.startsWith('-')) {
63
- fail(`Unknown option: ${token}`, 2);
120
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
64
121
  } else {
65
122
  args._.push(token);
66
123
  }
@@ -68,18 +125,95 @@ function parseArgs(argv) {
68
125
  return args;
69
126
  }
70
127
 
71
- function fail(message, code = 1) {
72
- process.stderr.write(`pingroom: ${message}\n`);
73
- process.exit(code);
128
+ // Parser for the question commands: supports repeatable --option and a trailing
129
+ // positional (a question id). Unknown flags fail like the ping parser.
130
+ function parseQArgs(argv) {
131
+ const args = { _: [] };
132
+ const alias = {
133
+ '-p': 'prompt', '--prompt': 'prompt',
134
+ '-o': 'option', '--option': 'option',
135
+ '-c': 'context', '--context': 'context',
136
+ '--scope': 'scope',
137
+ '--target': 'target',
138
+ '--ttl': 'ttl',
139
+ '-d': 'data', '--data': 'data',
140
+ '--correlation-id': 'correlation_id',
141
+ '--timeout': 'timeout',
142
+ '--state': 'state',
143
+ '--token': 'token',
144
+ '--room': 'room',
145
+ '--api': 'api',
146
+ '--wait': 'wait',
147
+ '--json': 'json',
148
+ '-h': 'help', '--help': 'help',
149
+ };
150
+ const booleans = new Set(['wait', 'json', 'help']);
151
+ const multi = new Set(['option']);
152
+
153
+ for (let i = 0; i < argv.length; i++) {
154
+ const token = argv[i];
155
+ const key = alias[token];
156
+ if (key && booleans.has(key)) {
157
+ args[key] = true;
158
+ } else if (key) {
159
+ const value = argv[++i];
160
+ if (value === undefined) {
161
+ fail(`option ${token} needs a value`, EXIT.USAGE);
162
+ }
163
+ if (multi.has(key)) {
164
+ (args[key] ||= []).push(value);
165
+ } else {
166
+ args[key] = value;
167
+ }
168
+ } else if (token.startsWith('-') && token !== '-') {
169
+ fail(`Unknown option: ${token}`, EXIT.USAGE);
170
+ } else {
171
+ args._.push(token);
172
+ }
173
+ }
174
+ return args;
74
175
  }
75
176
 
76
- async function postJson(url, body, headers = {}) {
177
+ // Refuse to send a bearer token or webhook secret over cleartext http. A
178
+ // loopback host is allowed so local dev against http://localhost still works.
179
+ function requireSafeUrl(kind, raw) {
180
+ let u;
181
+ try {
182
+ u = new URL(raw);
183
+ } catch {
184
+ fail(`${kind} is not a valid URL`, EXIT.USAGE);
185
+ }
186
+ const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
187
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopback)) {
188
+ fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
189
+ }
190
+ return raw;
191
+ }
192
+
193
+ function parseDataObject(raw) {
194
+ let data;
195
+ try {
196
+ data = JSON.parse(raw);
197
+ } catch {
198
+ fail('--data must be valid JSON', EXIT.USAGE);
199
+ }
200
+ if (typeof data !== 'object' || Array.isArray(data) || data === null) {
201
+ fail('--data must be a JSON object', EXIT.USAGE);
202
+ }
203
+ return data;
204
+ }
205
+
206
+ async function httpJson(method, url, { body, headers = {} } = {}) {
77
207
  let res;
78
208
  try {
79
209
  res = await fetch(url, {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
82
- body: JSON.stringify(body),
210
+ method,
211
+ headers: {
212
+ Accept: 'application/json',
213
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
214
+ ...headers,
215
+ },
216
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
83
217
  });
84
218
  } catch (err) {
85
219
  fail(`network error: ${err.message}`);
@@ -93,25 +227,18 @@ async function postJson(url, body, headers = {}) {
93
227
  }
94
228
 
95
229
  async function ping(args) {
96
- if (args.help) { process.stdout.write(`${HELP}\n`); return 0; }
230
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
97
231
 
98
232
  const message = args.message;
99
- if (!message) fail('a --message is required', 2);
233
+ if (!message) fail('a --message is required', EXIT.USAGE);
100
234
 
101
235
  if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
102
- fail('--action must be an integer 1–4', 2);
236
+ fail('--action must be an integer 1–4', EXIT.USAGE);
103
237
  }
104
238
 
105
239
  let data;
106
240
  if (args.data !== undefined) {
107
- try {
108
- data = JSON.parse(args.data);
109
- } catch {
110
- fail('--data must be valid JSON', 2);
111
- }
112
- if (typeof data !== 'object' || Array.isArray(data) || data === null) {
113
- fail('--data must be a JSON object', 2);
114
- }
241
+ data = parseDataObject(args.data);
115
242
  }
116
243
 
117
244
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
@@ -121,21 +248,23 @@ async function ping(args) {
121
248
  let result;
122
249
 
123
250
  if (webhook) {
251
+ requireSafeUrl('--webhook', webhook);
124
252
  const body = { message };
125
253
  if (args.title) body.title = args.title;
126
254
  if (args.action !== undefined) body.action = Number(args.action);
127
255
  if (data) body.data = data;
128
- result = await postJson(webhook, body);
256
+ result = await httpJson('POST', webhook, { body });
129
257
  } else if (token) {
130
- if (!args.room) fail('--room is required when using --token', 2);
258
+ if (!args.room) fail('--room is required when using --token', EXIT.USAGE);
259
+ requireSafeUrl('--api', apiBase);
131
260
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
132
261
  const body = { message };
133
262
  if (args.title) body.title = args.title;
134
263
  if (args.action !== undefined) body.action_number = Number(args.action);
135
264
  if (data) body.data = data;
136
- result = await postJson(url, body, { Authorization: `Bearer ${token}` });
265
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
137
266
  } else {
138
- fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', 2);
267
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
139
268
  }
140
269
 
141
270
  const { res, text, json } = result;
@@ -152,7 +281,174 @@ async function ping(args) {
152
281
  }
153
282
 
154
283
  if (!args.json) process.stdout.write('ping sent ✅\n');
155
- return 0;
284
+ return EXIT.OK;
285
+ }
286
+
287
+ // --- questions -------------------------------------------------------------
288
+
289
+ function agentContext(args, { needRoom = false } = {}) {
290
+ const token = args.token || process.env.PINGROOM_TOKEN;
291
+ if (!token) fail('an agent token is required (--token or PINGROOM_TOKEN)', EXIT.USAGE);
292
+ const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
293
+ requireSafeUrl('--api', apiBase);
294
+ if (needRoom && !args.room) fail('--room is required', EXIT.USAGE);
295
+ return { token, apiBase, room: args.room };
296
+ }
297
+
298
+ // value:label -> {value, label}. Labels may contain colons (only the first
299
+ // splits). A bare token is both value and label. Omit all for Approve/Deny.
300
+ function buildOptions(list) {
301
+ if (!list || list.length === 0) return undefined;
302
+ return list.map((spec) => {
303
+ const idx = spec.indexOf(':');
304
+ const value = idx === -1 ? spec : spec.slice(0, idx);
305
+ const label = idx === -1 ? spec : spec.slice(idx + 1);
306
+ if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
307
+ return { value, label };
308
+ });
309
+ }
310
+
311
+ function exitForState(state) {
312
+ switch (state) {
313
+ case 'answered': return EXIT.OK;
314
+ case 'expired': return EXIT.EXPIRED;
315
+ case 'cancelled': return EXIT.CANCELLED;
316
+ default: return EXIT.ERROR;
317
+ }
318
+ }
319
+
320
+ // Print the outcome. On `answered`, the chosen value (or typed text) goes to
321
+ // stdout so `$(pingroom ask --wait ...)` captures it; other outcomes report to
322
+ // stderr and leave stdout empty.
323
+ function printResolution(q) {
324
+ if (q.state === 'answered') {
325
+ const out = q.answer && (q.answer.text || q.answer.value) || '';
326
+ process.stdout.write(`${out}\n`);
327
+ } else {
328
+ process.stderr.write(`pingroom: question ${q.state}\n`);
329
+ }
330
+ }
331
+
332
+ // Long-poll the wait endpoint until the question leaves `pending`, then print
333
+ // and return the state's exit code. The server expires it at its ttl, so this
334
+ // always terminates.
335
+ async function waitForResolution(id, args, { token, apiBase }) {
336
+ let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
337
+ if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
338
+ hold = Math.min(hold, 30);
339
+
340
+ for (;;) {
341
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
342
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
343
+ if (!res.ok) {
344
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
345
+ fail(`wait failed: ${detail}`);
346
+ }
347
+ if (json && json.state && json.state !== 'pending') {
348
+ if (args.json) process.stdout.write(`${text}\n`);
349
+ else printResolution(json);
350
+ return exitForState(json.state);
351
+ }
352
+ // Still pending at the hold timeout — poll again.
353
+ }
354
+ }
355
+
356
+ async function ask(args) {
357
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
358
+
359
+ const prompt = args.prompt;
360
+ if (!prompt) fail('a --prompt is required', EXIT.USAGE);
361
+
362
+ const { token, apiBase, room } = agentContext(args, { needRoom: true });
363
+
364
+ const body = { prompt };
365
+ const options = buildOptions(args.option);
366
+ if (options) body.options = options;
367
+ if (args.context) body.context = args.context;
368
+ if (args.scope !== undefined) {
369
+ if (args.scope !== 'direct' && args.scope !== 'room') fail("--scope must be 'direct' or 'room'", EXIT.USAGE);
370
+ body.responder_scope = args.scope;
371
+ }
372
+ if (args.target !== undefined) body.target_user_id = args.target;
373
+ if (args.ttl !== undefined) {
374
+ if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
375
+ body.ttl = Number(args.ttl);
376
+ }
377
+ if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
378
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
379
+
380
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
381
+ const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
382
+ if (!res.ok) {
383
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
384
+ fail(`ask failed: ${detail}`);
385
+ }
386
+
387
+ if (!args.wait) {
388
+ if (args.json) process.stdout.write(`${text}\n`);
389
+ else process.stdout.write(`${json.id}\n`);
390
+ return EXIT.OK;
391
+ }
392
+
393
+ return waitForResolution(json.id, args, { token, apiBase });
394
+ }
395
+
396
+ async function watch(args) {
397
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
398
+ const id = args._[0];
399
+ if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
400
+ const { token, apiBase } = agentContext(args);
401
+ return waitForResolution(id, args, { token, apiBase });
402
+ }
403
+
404
+ async function cancel(args) {
405
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
406
+ const id = args._[0];
407
+ if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
408
+ const { token, apiBase } = agentContext(args);
409
+ const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
410
+ const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
411
+ if (!res.ok) {
412
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
413
+ fail(`cancel failed: ${detail}`);
414
+ }
415
+ if (args.json) process.stdout.write(`${text}\n`);
416
+ else process.stdout.write(`cancelled (${json && json.state})\n`);
417
+ return EXIT.OK;
418
+ }
419
+
420
+ async function list(args) {
421
+ if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
422
+ const { token, apiBase } = agentContext(args);
423
+ const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
424
+ const url = `${apiBase}/api/agent/questions${qs}`;
425
+ const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
426
+ if (!res.ok) {
427
+ const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
428
+ fail(`list failed: ${detail}`);
429
+ }
430
+ if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
431
+
432
+ const questions = (json && json.questions) || [];
433
+ if (questions.length === 0) { process.stdout.write('no questions\n'); return EXIT.OK; }
434
+ for (const q of questions) {
435
+ const answer = q.answer && q.answer.value ? ` → ${q.answer.value}` : '';
436
+ process.stdout.write(`${q.id} ${String(q.state).padEnd(9)} ${q.prompt}${answer}\n`);
437
+ }
438
+ return EXIT.OK;
439
+ }
440
+
441
+ const COMMANDS = {
442
+ ping: (rest) => ping(parseArgs(rest)),
443
+ ask: (rest) => ask(parseQArgs(rest)),
444
+ watch: (rest) => waitFrom(watch, rest),
445
+ await: (rest) => waitFrom(watch, rest),
446
+ cancel: (rest) => cancel(parseQArgs(rest)),
447
+ list: (rest) => list(parseQArgs(rest)),
448
+ };
449
+
450
+ function waitFrom(handler, rest) {
451
+ return handler(parseQArgs(rest));
156
452
  }
157
453
 
158
454
  async function main() {
@@ -161,15 +457,16 @@ async function main() {
161
457
 
162
458
  if (!command || command === '-h' || command === '--help' || command === 'help') {
163
459
  process.stdout.write(`${HELP}\n`);
164
- process.exit(0);
460
+ process.exit(EXIT.OK);
165
461
  }
166
462
 
167
- if (command === 'ping') {
168
- const code = await ping(parseArgs(argv.slice(1)));
169
- process.exit(code);
463
+ const handler = COMMANDS[command];
464
+ if (!handler) {
465
+ fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);
170
466
  }
171
467
 
172
- fail(`unknown command: ${command}\nRun "pingroom --help".`, 2);
468
+ const code = await handler(argv.slice(1));
469
+ process.exit(code);
173
470
  }
174
471
 
175
472
  main();
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.1.0",
4
- "description": "Send PingRoom pings from CI, scripts, and agents — deploy notifications in one line.",
3
+ "version": "0.2.0",
4
+ "description": "Send PingRoom pings and ask humans blocking questions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pingroom": "bin/pingroom.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test \"test/*.test.mjs\""
11
+ },
9
12
  "files": [
10
13
  "bin"
11
14
  ],