@pingroom/cli 0.7.4 โ 0.7.6
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/README.md +72 -9
- package/bin/pingroom.js +7 -1
- package/lib/commands/ask.js +14 -1
- package/lib/commands/hook.js +16 -4
- package/lib/commands/manage.js +260 -0
- package/lib/commands/mcp.js +19 -1
- package/lib/commands/ping.js +51 -1
- package/lib/config.js +11 -3
- package/lib/constants.js +5 -0
- package/lib/help.js +46 -0
- package/lib/http.js +8 -10
- package/lib/parser.js +40 -0
- package/lib/strict-json.js +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,9 @@ pingroom ping [options]
|
|
|
159
159
|
-d, --data <json> Extra JSON data, e.g. '{"commit":"abc123"}'
|
|
160
160
|
--url <https-url> Make the ping a tappable link (absolute http(s) URL)
|
|
161
161
|
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
162
|
+
--location <lat,lng> Attach a map location (latitude,longitude)
|
|
163
|
+
--location-label <t> Location label (<= 100 chars; requires --location)
|
|
164
|
+
--location-address <t> Address (<= 255 chars; requires --location)
|
|
162
165
|
--require-ack Keep the ping open until an eligible recipient acknowledges it
|
|
163
166
|
--ack-timeout <s> Ack deadline in seconds (requires --require-ack)
|
|
164
167
|
--attach <path> Attach a file; repeat for up to 4 (requires --token)
|
|
@@ -174,6 +177,23 @@ in private rooms and 160 in public rooms. A room code or webhook URL does not
|
|
|
174
177
|
reveal room visibility, so the CLI rejects only bodies over 160 locally; the
|
|
175
178
|
server applies the tighter 120-character private-room limit.
|
|
176
179
|
|
|
180
|
+
To send a location, pass decimal latitude and longitude as one comma-separated
|
|
181
|
+
value. Optional map text rides inside the same reserved `data.location` object:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pingroom ping --room ab12cd -m "Meet me here" \
|
|
185
|
+
--location "25.2048,55.2708" \
|
|
186
|
+
--location-label "Dubai Mall" \
|
|
187
|
+
--location-address "Downtown Dubai"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Latitude is inclusive -90..90 and longitude is inclusive -180..180. Labels are
|
|
191
|
+
limited to 100 Unicode characters and addresses to 255. The recipient can share
|
|
192
|
+
the point or open it in Waze, Google Maps, Apple Maps, or another installed map
|
|
193
|
+
app. These flags work with agent-token and incoming-webhook sends. When combined
|
|
194
|
+
with `--data`, the explicit flags replace only `data.location`; sibling keys are
|
|
195
|
+
preserved.
|
|
196
|
+
|
|
177
197
|
To make the ping actionable, add `--require-ack`. The first eligible recipient to
|
|
178
198
|
acknowledge it wins; `--ack-timeout` optionally expires it if nobody responds:
|
|
179
199
|
|
|
@@ -223,6 +243,36 @@ on the Lock Screen (iOS Live Activity / Dynamic Island, Android live update, and
|
|
|
223
243
|
a full inline card in the app). `start` opens it with one alert, `update` moves
|
|
224
244
|
it **silently**, `end` closes it with one completion alert.
|
|
225
245
|
|
|
246
|
+
## Managing rooms, webhooks, and quick actions
|
|
247
|
+
|
|
248
|
+
The management nouns cover the rest of the agent surface (agent token required;
|
|
249
|
+
`--room` where noted):
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
pingroom rooms list # rooms this account belongs to
|
|
253
|
+
pingroom rooms get GZNFB6BZGJIH
|
|
254
|
+
pingroom rooms create -n "Deploys" --icon bell --color "#e33122"
|
|
255
|
+
pingroom rooms create -n "Status" --icon globe --color "#0391fe" --public --handle status
|
|
256
|
+
pingroom rooms join GZNFB6BZGJIH
|
|
257
|
+
|
|
258
|
+
pingroom webhooks list --room GZNFB6BZGJIH
|
|
259
|
+
pingroom webhooks create --room GZNFB6BZGJIH --name "CI" --action 2 # prints the secret URL once
|
|
260
|
+
pingroom webhooks update <id> --room GZNFB6BZGJIH --enabled false
|
|
261
|
+
pingroom webhooks delete <id> --room GZNFB6BZGJIH
|
|
262
|
+
|
|
263
|
+
pingroom actions list --room GZNFB6BZGJIH
|
|
264
|
+
pingroom actions set 3 --room GZNFB6BZGJIH --label "Deploy done" --icon ๐
|
|
265
|
+
pingroom actions trigger 3 --room GZNFB6BZGJIH
|
|
266
|
+
|
|
267
|
+
pingroom approval -p "Ship v2 to production?" --wait # exit 0 approved ยท 4 denied ยท 3 expired
|
|
268
|
+
|
|
269
|
+
pingroom attachment get <id> --out report.md
|
|
270
|
+
pingroom attachment delete <id>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Creating webhooks and uploading attachments require a Pro account; public-room
|
|
274
|
+
creation runs under its own consent scope (`pingroom:rooms:publish`).
|
|
275
|
+
|
|
226
276
|
## Hearing replies
|
|
227
277
|
|
|
228
278
|
Everything else here talks; `listen` is how an agent hears โ replies to its own
|
|
@@ -375,13 +425,15 @@ Full protocol: <https://pingroom.io/liveactivities.md>
|
|
|
375
425
|
run: ./deploy-prod.sh
|
|
376
426
|
|
|
377
427
|
# Or ask the whole room instead of one person. Same outputs, plus question-id.
|
|
378
|
-
#
|
|
428
|
+
# `scope: room` is what opens it to the room โ without it a question is
|
|
429
|
+
# answerable only by the connecting account. Requires v0.7.5 or newer.
|
|
379
430
|
- id: env
|
|
380
|
-
uses: pingroom/cli@v0.7.
|
|
431
|
+
uses: pingroom/cli@v0.7.5
|
|
381
432
|
with:
|
|
382
433
|
token: ${{ secrets.PINGROOM_TOKEN }}
|
|
383
434
|
room: ab12cd
|
|
384
435
|
ask: 'true'
|
|
436
|
+
scope: 'room'
|
|
385
437
|
message: 'Which environment?'
|
|
386
438
|
context: 'build ${{ github.run_number }}'
|
|
387
439
|
options: |
|
|
@@ -398,7 +450,13 @@ including `@v0` before it is moved to v0.7.3 โ GitHub treats them as unexpecte
|
|
|
398
450
|
inputs, warns, and drops them; the step then falls through to the plain `ping`
|
|
399
451
|
path, `outputs.answer` is never set, and any job gated on it silently proceeds.
|
|
400
452
|
|
|
401
|
-
`
|
|
453
|
+
`scope` was added in **v0.7.5**, and without it every `ask` goes out as a direct
|
|
454
|
+
question to the connecting account โ the step still succeeds, so an Action meant
|
|
455
|
+
to poll the room silently polls one person instead.
|
|
456
|
+
|
|
457
|
+
`urgent` shipped in the **0.7.4 npm package**, but no `v0.7.4` Action tag was
|
|
458
|
+
ever cut, so **v0.7.5 is the first tag that carries it**. It has the same failure
|
|
459
|
+
mode on an older pin:
|
|
402
460
|
GitHub drops the unknown input and the Ping goes out at normal priority, which
|
|
403
461
|
looks like it worked. `require-ack` is not a substitute โ as of the same release
|
|
404
462
|
it opens the acknowledgement lifecycle without raising the interruption level,
|
|
@@ -478,6 +536,9 @@ Options are `value:label` pairs (repeat `-o` for 2โ4). Omit them for the binar
|
|
|
478
536
|
Approve/Deny default โ two options is the lock-screen fast path. `--ttl` sets the
|
|
479
537
|
expiry in seconds (default 1h; 30โ86400). `--scope room` lets any eligible member
|
|
480
538
|
answer (first tap wins); the default `direct` asks your bound user.
|
|
539
|
+
`--idempotency-key <key>` sends a printable 1โ255 character `Idempotency-Key`
|
|
540
|
+
header. Retrying the same key with the same request returns the original
|
|
541
|
+
Question; reusing it with a changed request returns `409 idempotency_conflict`.
|
|
481
542
|
|
|
482
543
|
## Handoffs (agent โ human)
|
|
483
544
|
|
|
@@ -562,18 +623,20 @@ tune the approval-question expiry with `--ttl <seconds>` (default 900).
|
|
|
562
623
|
|
|
563
624
|
## MCP client setup
|
|
564
625
|
|
|
565
|
-
Print the canonical remote endpoint,
|
|
566
|
-
JSON,
|
|
626
|
+
Print the canonical remote endpoint, copy-ready Codex and Claude Code commands,
|
|
627
|
+
Cursor JSON, Claude custom-connector steps, and the public OpenAI directory:
|
|
567
628
|
|
|
568
629
|
```bash
|
|
569
630
|
pingroom mcp
|
|
570
631
|
# no global install: npx --yes @pingroom/cli mcp
|
|
571
632
|
```
|
|
572
633
|
|
|
573
|
-
`pingroom mcp add
|
|
574
|
-
not execute
|
|
575
|
-
client's MCP controls to authenticate in the browser; no
|
|
576
|
-
pasted into its config.
|
|
634
|
+
`pingroom mcp add codex` and `pingroom mcp add claude-code` print the exact setup
|
|
635
|
+
commands but do not execute them or modify client configuration. After adding
|
|
636
|
+
the server, use the client's MCP controls to authenticate in the browser; no
|
|
637
|
+
PingRoom API key is pasted into its config. Once PingRoom's public listing is
|
|
638
|
+
approved, the same plugin will also be discoverable in the Plugins Directory
|
|
639
|
+
shared by ChatGPT and Codex.
|
|
577
640
|
|
|
578
641
|
For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
|
|
579
642
|
See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
|
package/bin/pingroom.js
CHANGED
|
@@ -41,8 +41,9 @@ import { VERSION } from '../lib/version.js';
|
|
|
41
41
|
import { HELP } from '../lib/help.js';
|
|
42
42
|
import {
|
|
43
43
|
parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
|
|
44
|
-
parseLiveArgs, parseLogoutArgs, parseQArgs,
|
|
44
|
+
parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs,
|
|
45
45
|
} from '../lib/parser.js';
|
|
46
|
+
import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
|
|
46
47
|
import { ping } from '../lib/commands/ping.js';
|
|
47
48
|
import { ask, cancel, list, watch } from '../lib/commands/ask.js';
|
|
48
49
|
import { handoff, listHandoffs } from '../lib/commands/handoff.js';
|
|
@@ -67,6 +68,11 @@ const COMMANDS = {
|
|
|
67
68
|
mcp,
|
|
68
69
|
activate: (rest) => activateStoredInbox(parseQArgs(rest)),
|
|
69
70
|
live: (rest) => live(parseLiveArgs(rest)),
|
|
71
|
+
rooms: (rest) => rooms(parseManageArgs(rest)),
|
|
72
|
+
webhooks: (rest) => webhooks(parseManageArgs(rest)),
|
|
73
|
+
actions: (rest) => actions(parseManageArgs(rest)),
|
|
74
|
+
approval: (rest) => approval(parseManageArgs(rest)),
|
|
75
|
+
attachment: (rest) => attachment(parseManageArgs(rest)),
|
|
70
76
|
config: (rest) => config(parseConfigArgs(rest)),
|
|
71
77
|
logout: (rest) => logout(parseLogoutArgs(rest)),
|
|
72
78
|
};
|
package/lib/commands/ask.js
CHANGED
|
@@ -76,11 +76,24 @@ export async function ask(args) {
|
|
|
76
76
|
}
|
|
77
77
|
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
78
78
|
|
|
79
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
80
|
+
// Question creation is the durable human gate for workflows. A printable,
|
|
81
|
+
// bounded key lets a caller safely replay the exact same create request
|
|
82
|
+
// after an ambiguous transport failure; the server returns 409 if the key is
|
|
83
|
+
// ever reused for a different payload.
|
|
84
|
+
if (args.idempotency_key !== undefined) {
|
|
85
|
+
const key = String(args.idempotency_key);
|
|
86
|
+
if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
|
|
87
|
+
fail('--idempotency-key must be 1โ255 printable ASCII characters without spaces', EXIT.USAGE);
|
|
88
|
+
}
|
|
89
|
+
headers['Idempotency-Key'] = key;
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
// Pre-flight: reject a bad --timeout before the question exists.
|
|
80
93
|
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
|
81
94
|
|
|
82
95
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
|
|
83
|
-
const { res, text, json } = await httpJson('POST', url, { body, headers
|
|
96
|
+
const { res, text, json } = await httpJson('POST', url, { body, headers });
|
|
84
97
|
if (!res.ok) {
|
|
85
98
|
const detail = apiDetail(res, json);
|
|
86
99
|
fail(`ask failed: ${detail}`);
|
package/lib/commands/hook.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
import { EXIT,
|
|
3
|
+
import { EXIT, PRIVATE_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
|
|
4
4
|
import { truncate } from '../util.js';
|
|
5
5
|
import { commandHelp } from '../help.js';
|
|
6
6
|
import { hookFetch, isSafeUrl } from '../http.js';
|
|
@@ -62,7 +62,14 @@ function summarizeTranscript(path) {
|
|
|
62
62
|
const msg = entry && entry.message;
|
|
63
63
|
if (!msg || msg.role !== 'assistant') continue;
|
|
64
64
|
const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
|
|
65
|
-
|
|
65
|
+
// The tighter PRIVATE bound, not the public one. `ping --message` may sit at
|
|
66
|
+
// the public ceiling because the caller typed that text and the server is
|
|
67
|
+
// entitled to reject it. Here the CLI COMPOSES the body, and a hook room is
|
|
68
|
+
// private (max 120), so truncating at 160 would hand the server a body it
|
|
69
|
+
// rejects โ and hookPing swallows the 422, silently dropping the ping. The
|
|
70
|
+
// text is already being truncated, so the tighter cut costs nothing and is
|
|
71
|
+
// valid in a public room too.
|
|
72
|
+
if (text) return truncate(text, PRIVATE_PING_MESSAGE_MAX_LENGTH);
|
|
66
73
|
}
|
|
67
74
|
return '';
|
|
68
75
|
}
|
|
@@ -185,7 +192,7 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
|
185
192
|
title = 'Claude finished';
|
|
186
193
|
message = summarizeTranscript(event.transcript_path) || 'Session finished โ waiting for you.';
|
|
187
194
|
} else if (name === 'Notification') {
|
|
188
|
-
message = truncate(event.message || 'Claude is waiting for your input.',
|
|
195
|
+
message = truncate(event.message || 'Claude is waiting for your input.', PRIVATE_PING_MESSAGE_MAX_LENGTH);
|
|
189
196
|
// A PreToolUse hook already turns permission prompts into a question; skip
|
|
190
197
|
// the duplicate "needs your permission" Notification so you aren't paged twice.
|
|
191
198
|
if (/permission/i.test(message)) return EXIT.OK;
|
|
@@ -193,7 +200,12 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
|
193
200
|
} else if (name === 'SessionEnd') {
|
|
194
201
|
if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
|
|
195
202
|
title = 'Session ended';
|
|
196
|
-
|
|
203
|
+
// `reason` is whatever the host put in the event, so bound the composed
|
|
204
|
+
// line rather than trusting it to stay short.
|
|
205
|
+
message = truncate(
|
|
206
|
+
`Claude Code session ended (${event.reason || 'unknown'}).`,
|
|
207
|
+
PRIVATE_PING_MESSAGE_MAX_LENGTH,
|
|
208
|
+
);
|
|
197
209
|
} else {
|
|
198
210
|
return EXIT.OK; // unknown event โ stay silent rather than send noise
|
|
199
211
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Room, webhook, quick-action, approval, and attachment management โ the
|
|
2
|
+
// agent REST surface that previously had no CLI verbs. One module, one
|
|
3
|
+
// sub-command dispatch per noun, JSON-first output (--json prints the raw
|
|
4
|
+
// response; the default prints a compact human line per record).
|
|
5
|
+
|
|
6
|
+
import { EXIT } from '../constants.js';
|
|
7
|
+
import { fail } from '../util.js';
|
|
8
|
+
import { commandHelp } from '../help.js';
|
|
9
|
+
import { apiDetail, httpJson } from '../http.js';
|
|
10
|
+
import { agentContext } from '../config.js';
|
|
11
|
+
|
|
12
|
+
function sub(args, allowed, noun) {
|
|
13
|
+
const name = args._[0];
|
|
14
|
+
if (!name || !allowed.includes(name)) {
|
|
15
|
+
fail(`usage: pingroom ${noun} <${allowed.join('|')}>\nRun "pingroom ${noun} --help".`, EXIT.USAGE);
|
|
16
|
+
}
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function auth(token) {
|
|
21
|
+
return { Authorization: `Bearer ${token}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function requireOk(promise, what) {
|
|
25
|
+
const { res, text, json } = await promise;
|
|
26
|
+
if (!res.ok) fail(`${what} failed: ${apiDetail(res, json)}`);
|
|
27
|
+
return { text, json };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printJsonOr(args, text, lines) {
|
|
31
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
32
|
+
else process.stdout.write(`${lines}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------- rooms
|
|
36
|
+
|
|
37
|
+
export async function rooms(args) {
|
|
38
|
+
if (args.help) { process.stdout.write(`${commandHelp('rooms')}\n`); return EXIT.OK; }
|
|
39
|
+
const action = sub(args, ['list', 'get', 'create', 'join'], 'rooms');
|
|
40
|
+
const { token, apiBase } = agentContext(args);
|
|
41
|
+
|
|
42
|
+
if (action === 'list') {
|
|
43
|
+
const { text, json } = await requireOk(
|
|
44
|
+
httpJson('GET', `${apiBase}/api/agent/rooms`, { headers: auth(token) }),
|
|
45
|
+
'rooms list',
|
|
46
|
+
);
|
|
47
|
+
const items = Array.isArray(json) ? json : (json.rooms ?? json.data ?? []);
|
|
48
|
+
printJsonOr(args, text, items.map((r) => `${r.invite_code} ${r.name}${r.is_public ? ' (public)' : ''}`).join('\n') || '(no rooms)');
|
|
49
|
+
return EXIT.OK;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'get') {
|
|
53
|
+
const code = args._[1];
|
|
54
|
+
if (!code) fail('usage: pingroom rooms get <invite-code>', EXIT.USAGE);
|
|
55
|
+
const { text } = await requireOk(
|
|
56
|
+
httpJson('GET', `${apiBase}/api/agent/rooms/${encodeURIComponent(code)}`, { headers: auth(token) }),
|
|
57
|
+
'rooms get',
|
|
58
|
+
);
|
|
59
|
+
process.stdout.write(`${text}\n`);
|
|
60
|
+
return EXIT.OK;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (action === 'join') {
|
|
64
|
+
const code = args._[1];
|
|
65
|
+
if (!code) fail('usage: pingroom rooms join <invite-code>', EXIT.USAGE);
|
|
66
|
+
const { text, json } = await requireOk(
|
|
67
|
+
httpJson('POST', `${apiBase}/api/agent/rooms/join`, { headers: auth(token), body: { invite_code: code } }),
|
|
68
|
+
'rooms join',
|
|
69
|
+
);
|
|
70
|
+
printJsonOr(args, text, `joined ${json.room?.name ?? code}`);
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// create โ private by default; --public requires --handle and uses the
|
|
75
|
+
// dedicated consent scope on the server.
|
|
76
|
+
if (!args.name) fail('rooms create needs --name', EXIT.USAGE);
|
|
77
|
+
if (!args.icon || !args.color) fail('rooms create needs --icon and --color (see "pingroom rooms list-icons" via the app, e.g. --icon bell --color "#e33122")', EXIT.USAGE);
|
|
78
|
+
|
|
79
|
+
const body = { name: args.name, icon: args.icon, color: args.color };
|
|
80
|
+
if (args.description) body.description = args.description;
|
|
81
|
+
|
|
82
|
+
let url = `${apiBase}/api/agent/rooms`;
|
|
83
|
+
if (args.public) {
|
|
84
|
+
if (!args.handle) fail('a public room needs --handle', EXIT.USAGE);
|
|
85
|
+
body.handle = args.handle;
|
|
86
|
+
url = `${apiBase}/api/agent/rooms/public`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { text, json } = await requireOk(httpJson('POST', url, { headers: auth(token), body }), 'rooms create');
|
|
90
|
+
printJsonOr(args, text, `created ${json.room?.invite_code ?? json.invite_code ?? ''} ${args.name}`.trim());
|
|
91
|
+
return EXIT.OK;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ------------------------------------------------------------- webhooks
|
|
95
|
+
|
|
96
|
+
export async function webhooks(args) {
|
|
97
|
+
if (args.help) { process.stdout.write(`${commandHelp('webhooks')}\n`); return EXIT.OK; }
|
|
98
|
+
const action = sub(args, ['list', 'create', 'update', 'delete'], 'webhooks');
|
|
99
|
+
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
100
|
+
const base = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/webhooks`;
|
|
101
|
+
|
|
102
|
+
if (action === 'list') {
|
|
103
|
+
const { text, json } = await requireOk(httpJson('GET', base, { headers: auth(token) }), 'webhooks list');
|
|
104
|
+
const items = Array.isArray(json) ? json : (json.webhooks ?? json.data ?? []);
|
|
105
|
+
printJsonOr(args, text, items.map((w) => `${w.id} ${w.name}${w.enabled === false ? ' (disabled)' : ''}`).join('\n') || '(no webhooks)');
|
|
106
|
+
return EXIT.OK;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (action === 'delete') {
|
|
110
|
+
const id = args._[1];
|
|
111
|
+
if (!id) fail('usage: pingroom webhooks delete <id> --room <code>', EXIT.USAGE);
|
|
112
|
+
await requireOk(httpJson('DELETE', `${base}/${encodeURIComponent(id)}`, { headers: auth(token) }), 'webhooks delete');
|
|
113
|
+
process.stdout.write('deleted\n');
|
|
114
|
+
return EXIT.OK;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const body = {};
|
|
118
|
+
for (const key of ['name', 'title', 'message', 'icon', 'color', 'sound']) {
|
|
119
|
+
if (args[key] !== undefined) body[key] = args[key];
|
|
120
|
+
}
|
|
121
|
+
if (args.action !== undefined) body.action_number = Number(args.action);
|
|
122
|
+
if (args.cooldown !== undefined) body.cooldown_seconds = Number(args.cooldown);
|
|
123
|
+
if (args.enabled !== undefined) body.enabled = args.enabled !== 'false';
|
|
124
|
+
|
|
125
|
+
if (action === 'create') {
|
|
126
|
+
if (!body.name) fail('webhooks create needs --name', EXIT.USAGE);
|
|
127
|
+
const { text, json } = await requireOk(httpJson('POST', base, { headers: auth(token), body }), 'webhooks create');
|
|
128
|
+
// The trigger URL carries the webhook secret โ print it once, like the app.
|
|
129
|
+
printJsonOr(args, text, `created ${json.webhook?.id ?? ''}\n${json.webhook?.url ?? json.url ?? ''}`.trim());
|
|
130
|
+
return EXIT.OK;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const id = args._[1];
|
|
134
|
+
if (!id) fail('usage: pingroom webhooks update <id> --room <code> [fields]', EXIT.USAGE);
|
|
135
|
+
const { text } = await requireOk(
|
|
136
|
+
httpJson('PUT', `${base}/${encodeURIComponent(id)}`, { headers: auth(token), body }),
|
|
137
|
+
'webhooks update',
|
|
138
|
+
);
|
|
139
|
+
process.stdout.write(`${text}\n`);
|
|
140
|
+
return EXIT.OK;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -------------------------------------------------------------- actions
|
|
144
|
+
|
|
145
|
+
export async function actions(args) {
|
|
146
|
+
if (args.help) { process.stdout.write(`${commandHelp('actions')}\n`); return EXIT.OK; }
|
|
147
|
+
const action = sub(args, ['list', 'set', 'trigger'], 'actions');
|
|
148
|
+
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
149
|
+
const base = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/actions`;
|
|
150
|
+
|
|
151
|
+
if (action === 'list') {
|
|
152
|
+
const { text, json } = await requireOk(httpJson('GET', base, { headers: auth(token) }), 'actions list');
|
|
153
|
+
const items = Array.isArray(json) ? json : (json.actions ?? json.quick_actions ?? json.data ?? []);
|
|
154
|
+
printJsonOr(args, text, items.map((a) => `${a.action_number} ${a.icon ?? ''} ${a.label ?? '(unset)'}`).join('\n') || '(no actions)');
|
|
155
|
+
return EXIT.OK;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const slot = args._[1];
|
|
159
|
+
if (!/^[1-4]$/.test(String(slot))) fail(`usage: pingroom actions ${action} <1-4> --room <code>`, EXIT.USAGE);
|
|
160
|
+
|
|
161
|
+
if (action === 'trigger') {
|
|
162
|
+
const { text } = await requireOk(
|
|
163
|
+
httpJson('POST', `${base}/${slot}/trigger`, { headers: auth(token), body: {} }),
|
|
164
|
+
'actions trigger',
|
|
165
|
+
);
|
|
166
|
+
process.stdout.write(`${text}\n`);
|
|
167
|
+
return EXIT.OK;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!args.label || !args.icon) fail('actions set needs --label and --icon', EXIT.USAGE);
|
|
171
|
+
const body = { label: args.label, icon: args.icon };
|
|
172
|
+
if (args.sound !== undefined) body.sound = args.sound;
|
|
173
|
+
if (args.require_ack) body.requires_ack = true;
|
|
174
|
+
|
|
175
|
+
const { text } = await requireOk(
|
|
176
|
+
httpJson('PUT', `${base}/${slot}`, { headers: auth(token), body }),
|
|
177
|
+
'actions set',
|
|
178
|
+
);
|
|
179
|
+
process.stdout.write(`${text}\n`);
|
|
180
|
+
return EXIT.OK;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------- approval
|
|
184
|
+
|
|
185
|
+
const APPROVAL_EXIT = { approved: EXIT.OK, denied: EXIT.CANCELLED, expired: EXIT.EXPIRED };
|
|
186
|
+
|
|
187
|
+
export async function approval(args) {
|
|
188
|
+
if (args.help) { process.stdout.write(`${commandHelp('approval')}\n`); return EXIT.OK; }
|
|
189
|
+
|
|
190
|
+
if (!args.prompt) fail('an approval needs --prompt', EXIT.USAGE);
|
|
191
|
+
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
192
|
+
|
|
193
|
+
const body = { prompt: args.prompt };
|
|
194
|
+
if (args.context) body.context = args.context;
|
|
195
|
+
if (args.ttl !== undefined) {
|
|
196
|
+
if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
|
|
197
|
+
body.ttl = Number(args.ttl);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { text, json } = await requireOk(
|
|
201
|
+
httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/approvals`, { headers: auth(token), body }),
|
|
202
|
+
'approval',
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (!args.wait) {
|
|
206
|
+
process.stdout.write(`${text}\n`);
|
|
207
|
+
return EXIT.OK;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (;;) {
|
|
211
|
+
const { res, text: waitText, json: state } = await httpJson(
|
|
212
|
+
'GET',
|
|
213
|
+
`${apiBase}/api/agent/approvals/${encodeURIComponent(json.id)}/wait?timeout=25`,
|
|
214
|
+
{ headers: auth(token) },
|
|
215
|
+
);
|
|
216
|
+
if (!res.ok) fail(`approval wait failed: ${apiDetail(res, state)}`);
|
|
217
|
+
if (state && state.state && state.state !== 'pending') {
|
|
218
|
+
process.stdout.write(`${args.json ? waitText : state.state}\n`);
|
|
219
|
+
return APPROVAL_EXIT[state.state] ?? EXIT.OK;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ----------------------------------------------------------- attachment
|
|
225
|
+
|
|
226
|
+
export async function attachment(args) {
|
|
227
|
+
if (args.help) { process.stdout.write(`${commandHelp('attachment')}\n`); return EXIT.OK; }
|
|
228
|
+
const action = sub(args, ['get', 'delete'], 'attachment');
|
|
229
|
+
const id = args._[1];
|
|
230
|
+
if (!id) fail(`usage: pingroom attachment ${action} <id>`, EXIT.USAGE);
|
|
231
|
+
const { token, apiBase } = agentContext(args);
|
|
232
|
+
|
|
233
|
+
if (action === 'delete') {
|
|
234
|
+
await requireOk(
|
|
235
|
+
httpJson('DELETE', `${apiBase}/api/agent/attachments/${encodeURIComponent(id)}`, { headers: auth(token) }),
|
|
236
|
+
'attachment delete',
|
|
237
|
+
);
|
|
238
|
+
process.stdout.write('deleted\n');
|
|
239
|
+
return EXIT.OK;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Binary download: raw fetch, not httpJson. Bytes go to --out or stdout.
|
|
243
|
+
const res = await fetch(`${apiBase}/api/agent/attachments/${encodeURIComponent(id)}/content`, {
|
|
244
|
+
headers: auth(token),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
let json = null;
|
|
248
|
+
try { json = await res.json(); } catch { /* binary error bodies stay generic */ }
|
|
249
|
+
fail(`attachment get failed: ${apiDetail(res, json)}`);
|
|
250
|
+
}
|
|
251
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
252
|
+
if (args.out) {
|
|
253
|
+
const { writeFileSync } = await import('node:fs');
|
|
254
|
+
writeFileSync(args.out, bytes);
|
|
255
|
+
process.stdout.write(`${args.out} ${bytes.length} bytes\n`);
|
|
256
|
+
} else {
|
|
257
|
+
process.stdout.write(bytes);
|
|
258
|
+
}
|
|
259
|
+
return EXIT.OK;
|
|
260
|
+
}
|
package/lib/commands/mcp.js
CHANGED
|
@@ -6,6 +6,8 @@ import { fail } from '../util.js';
|
|
|
6
6
|
|
|
7
7
|
export function mcp(rest) {
|
|
8
8
|
const claudeCommand = `claude mcp add --transport http pingroom ${MCP_ENDPOINT}`;
|
|
9
|
+
const codexAddCommand = `codex mcp add pingroom --url ${MCP_ENDPOINT}`;
|
|
10
|
+
const codexLoginCommand = 'codex mcp login pingroom';
|
|
9
11
|
|
|
10
12
|
if (rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))) {
|
|
11
13
|
const config = {
|
|
@@ -20,6 +22,13 @@ export function mcp(rest) {
|
|
|
20
22
|
Claude Code:
|
|
21
23
|
${claudeCommand}
|
|
22
24
|
|
|
25
|
+
Codex CLI:
|
|
26
|
+
${codexAddCommand}
|
|
27
|
+
${codexLoginCommand}
|
|
28
|
+
|
|
29
|
+
OpenAI Plugins Directory (ChatGPT and Codex):
|
|
30
|
+
Search for PingRoom after its public listing is approved.
|
|
31
|
+
|
|
23
32
|
Cursor JSON (~/.cursor/mcp.json):
|
|
24
33
|
${JSON.stringify(config, null, 2)}
|
|
25
34
|
|
|
@@ -43,5 +52,14 @@ This command only prints setup instructions and does not modify client config.
|
|
|
43
52
|
return EXIT.OK;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
if (rest.length === 2 && rest[0] === 'add' && rest[1] === 'codex') {
|
|
56
|
+
process.stdout.write(
|
|
57
|
+
`No client configuration was changed. Copy and run:
|
|
58
|
+
${codexAddCommand}
|
|
59
|
+
${codexLoginCommand}
|
|
60
|
+
`);
|
|
61
|
+
return EXIT.OK;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fail('usage: pingroom mcp [add claude-code|codex]', EXIT.USAGE);
|
|
47
65
|
}
|
package/lib/commands/ping.js
CHANGED
|
@@ -1,9 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
EXIT,
|
|
3
|
+
LOCATION_ADDRESS_MAX_LENGTH,
|
|
4
|
+
LOCATION_LABEL_MAX_LENGTH,
|
|
5
|
+
PING_TITLE_MAX_LENGTH,
|
|
6
|
+
PUBLIC_PING_MESSAGE_MAX_LENGTH,
|
|
7
|
+
} from '../constants.js';
|
|
2
8
|
import { fail, parseDataObject, requireMaxLength } from '../util.js';
|
|
3
9
|
import { commandHelp } from '../help.js';
|
|
4
10
|
import { apiDetail, httpJson, requireSafeUrl, uploadAttachments } from '../http.js';
|
|
5
11
|
import { requireStoredCredentialOrigin, resolveApiBase, resolveRoom, resolveToken } from '../config.js';
|
|
6
12
|
|
|
13
|
+
const DECIMAL_COORDINATE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
14
|
+
|
|
15
|
+
function parseLocation(value) {
|
|
16
|
+
const parts = String(value).split(',');
|
|
17
|
+
if (parts.length !== 2) {
|
|
18
|
+
fail('--location must contain exactly two coordinates formatted "latitude,longitude"', EXIT.USAGE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const [latitudeText, longitudeText] = parts.map((part) => part.trim());
|
|
22
|
+
if (!DECIMAL_COORDINATE.test(latitudeText) || !DECIMAL_COORDINATE.test(longitudeText)) {
|
|
23
|
+
fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const latitude = Number(latitudeText);
|
|
27
|
+
const longitude = Number(longitudeText);
|
|
28
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
29
|
+
fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
|
|
30
|
+
}
|
|
31
|
+
if (latitude < -90 || latitude > 90) {
|
|
32
|
+
fail('--location latitude must be between -90 and 90', EXIT.USAGE);
|
|
33
|
+
}
|
|
34
|
+
if (longitude < -180 || longitude > 180) {
|
|
35
|
+
fail('--location longitude must be between -180 and 180', EXIT.USAGE);
|
|
36
|
+
}
|
|
37
|
+
return { latitude, longitude };
|
|
38
|
+
}
|
|
39
|
+
|
|
7
40
|
export async function ping(args) {
|
|
8
41
|
if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
|
|
9
42
|
|
|
@@ -59,6 +92,23 @@ export async function ping(args) {
|
|
|
59
92
|
if (args.button_label !== undefined) data.button_label = args.button_label;
|
|
60
93
|
}
|
|
61
94
|
|
|
95
|
+
// Location ping: explicit flags own the reserved data.location object. They
|
|
96
|
+
// replace a raw --data location while leaving every sibling key untouched.
|
|
97
|
+
if (args.location_label !== undefined && args.location === undefined) {
|
|
98
|
+
fail('--location-label requires --location', EXIT.USAGE);
|
|
99
|
+
}
|
|
100
|
+
if (args.location_address !== undefined && args.location === undefined) {
|
|
101
|
+
fail('--location-address requires --location', EXIT.USAGE);
|
|
102
|
+
}
|
|
103
|
+
if (args.location !== undefined) {
|
|
104
|
+
requireMaxLength(args.location_label, LOCATION_LABEL_MAX_LENGTH, '--location-label');
|
|
105
|
+
requireMaxLength(args.location_address, LOCATION_ADDRESS_MAX_LENGTH, '--location-address');
|
|
106
|
+
const location = parseLocation(args.location);
|
|
107
|
+
if (args.location_label !== undefined) location.label = args.location_label;
|
|
108
|
+
if (args.location_address !== undefined) location.address = args.location_address;
|
|
109
|
+
data = { ...(data || {}), location };
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
63
113
|
const token = resolveToken(args);
|
|
64
114
|
const apiBase = resolveApiBase(args);
|
package/lib/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Local state (~/.pingroom) and the layered resolution every command shares:
|
|
2
2
|
// explicit flag > env var > config file > the paired credential > built-in.
|
|
3
3
|
|
|
4
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import {
|
|
6
6
|
chmodSync, closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync,
|
|
7
7
|
writeFileSync,
|
|
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { BUILTIN_API, EXIT } from './constants.js';
|
|
13
13
|
import { fail } from './util.js';
|
|
14
14
|
import { requireSafeUrl } from './http.js';
|
|
15
|
+
import { parseJsonStrict } from './strict-json.js';
|
|
15
16
|
|
|
16
17
|
// --- local state (~/.pingroom) ---------------------------------------------
|
|
17
18
|
//
|
|
@@ -36,9 +37,9 @@ export function configPath() { return join(pingroomHome(), 'config.json'); }
|
|
|
36
37
|
// never be able to crash a ping: a hand-edited file degrades to "not set".
|
|
37
38
|
export function readJsonFile(path) {
|
|
38
39
|
let raw;
|
|
39
|
-
try { raw =
|
|
40
|
+
try { raw = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path)); } catch { return null; }
|
|
40
41
|
let value;
|
|
41
|
-
try { value =
|
|
42
|
+
try { value = parseJsonStrict(raw, path); } catch { return null; }
|
|
42
43
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
43
44
|
return value;
|
|
44
45
|
}
|
|
@@ -183,6 +184,13 @@ export function agentContext(args, { needRoom = false } = {}) {
|
|
|
183
184
|
if (needRoom && !room) {
|
|
184
185
|
fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
185
186
|
}
|
|
187
|
+
if (args.expected_room_sha256 !== undefined) {
|
|
188
|
+
const expected = String(args.expected_room_sha256);
|
|
189
|
+
if (!/^[a-f0-9]{64}$/.test(expected)) fail('--expected-room-sha256 must be 64 lowercase hexadecimal characters', EXIT.USAGE);
|
|
190
|
+
if (!room) fail('--expected-room-sha256 requires a resolved room', EXIT.USAGE);
|
|
191
|
+
const actual = createHash('sha256').update(String(room), 'utf8').digest('hex');
|
|
192
|
+
if (actual !== expected) fail('resolved room does not match --expected-room-sha256', EXIT.USAGE);
|
|
193
|
+
}
|
|
186
194
|
return { token, apiBase, room };
|
|
187
195
|
}
|
|
188
196
|
|
package/lib/constants.js
CHANGED
|
@@ -12,3 +12,8 @@ export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
|
|
|
12
12
|
export const PING_TITLE_MAX_LENGTH = 40;
|
|
13
13
|
export const PRIVATE_PING_MESSAGE_MAX_LENGTH = 120;
|
|
14
14
|
export const PUBLIC_PING_MESSAGE_MAX_LENGTH = 160;
|
|
15
|
+
|
|
16
|
+
// Reserved data.location display metadata. Coordinate ranges are part of the
|
|
17
|
+
// numeric format itself; these caps mirror the API's Unicode-aware string rules.
|
|
18
|
+
export const LOCATION_LABEL_MAX_LENGTH = 100;
|
|
19
|
+
export const LOCATION_ADDRESS_MAX_LENGTH = 255;
|
package/lib/help.js
CHANGED
|
@@ -31,6 +31,11 @@ Commands:
|
|
|
31
31
|
mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
|
|
32
32
|
Claude Desktop
|
|
33
33
|
activate Retry Agent Inbox activation with the saved QR-paired credential
|
|
34
|
+
rooms List, inspect, create, or join rooms (rooms list|get|create|join)
|
|
35
|
+
webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
|
|
36
|
+
actions List, configure, or trigger a room's quick actions (actions list|set|trigger)
|
|
37
|
+
approval Send an approve/deny request; with --wait, block on the decision
|
|
38
|
+
attachment Download or delete an attachment by id (attachment get|delete)
|
|
34
39
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
35
40
|
logout Forget the stored credential`;
|
|
36
41
|
|
|
@@ -41,6 +46,9 @@ export const HELP_PING = `ping options:
|
|
|
41
46
|
-d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
|
|
42
47
|
--url <https-url> Make the ping a tappable link (absolute http(s) URL)
|
|
43
48
|
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
49
|
+
--location <lat,lng> Attach a map location (latitude,longitude)
|
|
50
|
+
--location-label <t> Location label (<= 100 chars; requires --location)
|
|
51
|
+
--location-address <t> Address (<= 255 chars; requires --location)
|
|
44
52
|
--urgent Deliver time-sensitive so it breaks through Focus / Do Not
|
|
45
53
|
Disturb. Delivery only - asks nothing of the recipient
|
|
46
54
|
--require-ack Keep the ping open until an eligible recipient acknowledges it,
|
|
@@ -62,6 +70,7 @@ export const HELP_ASK = `ask options (agent token required):
|
|
|
62
70
|
--scope <s> Who answers: 'direct' (default) or 'room'
|
|
63
71
|
--target <uuid> For --scope direct: a specific room member
|
|
64
72
|
--ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
|
|
73
|
+
--idempotency-key <key> Dedupe this exact create request across retries
|
|
65
74
|
--text-input <ph> Invite a short typed answer; <ph> is the placeholder
|
|
66
75
|
--text-max <n> Max typed-answer length (1..60)
|
|
67
76
|
--wait Block until answered/expired/cancelled
|
|
@@ -70,6 +79,8 @@ export const HELP_ASK = `ask options (agent token required):
|
|
|
70
79
|
--correlation-id <id> Opaque id echoed on every read of this question
|
|
71
80
|
--reply-to <id> Id of the ping this question replies to
|
|
72
81
|
--room <code> Room invite code (required for ask)
|
|
82
|
+
--expected-room-sha256 <hex>
|
|
83
|
+
Fail unless the resolved room matches this SHA-256
|
|
73
84
|
--github-output <path> Safely append question outputs for GitHub Actions`;
|
|
74
85
|
|
|
75
86
|
export const HELP_LIST = `list options:
|
|
@@ -207,6 +218,10 @@ Examples:
|
|
|
207
218
|
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
|
|
208
219
|
--url https://ci.example.com/builds/512 --button-label "Open build"
|
|
209
220
|
|
|
221
|
+
# Location ping โ recipients can share it or open it in a maps app:
|
|
222
|
+
pingroom ping --room ab12cd -m "Meet me here" \\
|
|
223
|
+
--location "25.2048,55.2708" --location-label "Dubai Mall"
|
|
224
|
+
|
|
210
225
|
# Gate a deploy on a human tap โ the chosen value prints to stdout:
|
|
211
226
|
if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
|
|
212
227
|
-p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
|
|
@@ -285,6 +300,37 @@ export const COMMAND_HELP_SECTIONS = {
|
|
|
285
300
|
live: HELP_LIVE,
|
|
286
301
|
hook: HELP_HOOK,
|
|
287
302
|
activate: HELP_ACTIVATE,
|
|
303
|
+
rooms: `rooms (agent token required):
|
|
304
|
+
pingroom rooms list List the rooms this account belongs to
|
|
305
|
+
pingroom rooms get <code> Show one room
|
|
306
|
+
pingroom rooms create -n <name> --icon <id> --color <hex>
|
|
307
|
+
Create a private room (add --description);
|
|
308
|
+
--public --handle <handle> creates a public
|
|
309
|
+
room under its own consent scope
|
|
310
|
+
pingroom rooms join <code> Join a room by invite code`,
|
|
311
|
+
webhooks: `webhooks (agent token + --room required):
|
|
312
|
+
pingroom webhooks list --room <code>
|
|
313
|
+
pingroom webhooks create --room <code> --name <n>
|
|
314
|
+
Optional: --title --message --icon --color
|
|
315
|
+
--sound --action <1-4> --cooldown <sec>
|
|
316
|
+
Prints the secret trigger URL once
|
|
317
|
+
pingroom webhooks update <id> --room <code> [same fields, plus --enabled true|false]
|
|
318
|
+
pingroom webhooks delete <id> --room <code>`,
|
|
319
|
+
actions: `actions (agent token + --room required):
|
|
320
|
+
pingroom actions list --room <code>
|
|
321
|
+
pingroom actions set <1-4> --room <code> --label <text> --icon <emoji>
|
|
322
|
+
Optional: --sound, --require-ack
|
|
323
|
+
pingroom actions trigger <1-4> --room <code>`,
|
|
324
|
+
approval: `approval (agent token + --room required):
|
|
325
|
+
pingroom approval -p <prompt> Send an approve/deny request
|
|
326
|
+
-c, --context <text> Secondary line (<= 40 chars)
|
|
327
|
+
--ttl <seconds> Expiry; omit for the server default
|
|
328
|
+
--wait Block until approved/denied/expired
|
|
329
|
+
(exit 0 approved ยท 4 denied ยท 3 expired)`,
|
|
330
|
+
attachment: `attachment (agent token required):
|
|
331
|
+
pingroom attachment get <id> [--out <path>]
|
|
332
|
+
Download; bytes go to --out or stdout
|
|
333
|
+
pingroom attachment delete <id> Delete an unclaimed upload`,
|
|
288
334
|
config: HELP_CONFIG,
|
|
289
335
|
logout: `logout:
|
|
290
336
|
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
package/lib/http.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { EXIT } from './constants.js';
|
|
5
5
|
import { fail, stripControlChars } from './util.js';
|
|
6
|
+
import { readStrictJsonResponse } from './strict-json.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The fixes that live on THIS side of the wire. The server's message always
|
|
@@ -85,15 +86,13 @@ export async function httpJson(method, url, { body, headers = {}, soft = false,
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
let text;
|
|
89
|
+
let json;
|
|
88
90
|
try {
|
|
89
|
-
text = await res
|
|
91
|
+
({ text, json } = await readStrictJsonResponse(res, 'PingRoom API response'));
|
|
90
92
|
} catch (err) {
|
|
91
|
-
// A connection dropped mid-body throws here, not at fetch().
|
|
92
93
|
if (soft) return { res: null, text: '', json: null, error: err };
|
|
93
|
-
fail(`
|
|
94
|
+
fail(`untrusted JSON response: ${err.message}`);
|
|
94
95
|
}
|
|
95
|
-
let json = null;
|
|
96
|
-
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
97
96
|
|
|
98
97
|
return { res, text, json };
|
|
99
98
|
}
|
|
@@ -165,9 +164,10 @@ export async function uploadAttachments(paths, apiBase, token) {
|
|
|
165
164
|
fail(`network error uploading ${name}: ${err.message}`);
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
const text = await res.text().catch(() => '');
|
|
169
167
|
let json = null;
|
|
170
|
-
try { json =
|
|
168
|
+
try { ({ json } = await readStrictJsonResponse(res, 'attachment API response')); } catch (error) {
|
|
169
|
+
fail(`untrusted JSON response uploading ${name}: ${error.message}`);
|
|
170
|
+
}
|
|
171
171
|
|
|
172
172
|
if (res.status === 402) {
|
|
173
173
|
fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
|
|
@@ -196,9 +196,7 @@ export async function hookFetch(method, url, { body, token } = {}) {
|
|
|
196
196
|
},
|
|
197
197
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
198
198
|
});
|
|
199
|
-
const
|
|
200
|
-
let json = null;
|
|
201
|
-
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
199
|
+
const { json } = await readStrictJsonResponse(res, 'hook response');
|
|
202
200
|
if (!res.ok) {
|
|
203
201
|
throw new Error(apiDetail(res, json));
|
|
204
202
|
}
|
package/lib/parser.js
CHANGED
|
@@ -61,12 +61,16 @@ export const parseArgs = makeParser({
|
|
|
61
61
|
'-w': 'webhook', '--webhook': 'webhook',
|
|
62
62
|
'--url': 'url',
|
|
63
63
|
'--button-label': 'button_label',
|
|
64
|
+
'--location': 'location',
|
|
65
|
+
'--location-label': 'location_label',
|
|
66
|
+
'--location-address': 'location_address',
|
|
64
67
|
'--require-ack': 'require_ack',
|
|
65
68
|
'--urgent': 'urgent',
|
|
66
69
|
'--ack-timeout': 'ack_timeout',
|
|
67
70
|
'--attach': 'attach',
|
|
68
71
|
'--token': 'token',
|
|
69
72
|
'--room': 'room',
|
|
73
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
70
74
|
'--api': 'api',
|
|
71
75
|
'--json': 'json',
|
|
72
76
|
'-h': 'help', '--help': 'help',
|
|
@@ -85,6 +89,7 @@ export const parseQArgs = makeParser({
|
|
|
85
89
|
'--scope': 'scope',
|
|
86
90
|
'--target': 'target',
|
|
87
91
|
'--ttl': 'ttl',
|
|
92
|
+
'--idempotency-key': 'idempotency_key',
|
|
88
93
|
'-d': 'data', '--data': 'data',
|
|
89
94
|
'--correlation-id': 'correlation_id',
|
|
90
95
|
'--reply-to': 'reply_to',
|
|
@@ -98,6 +103,7 @@ export const parseQArgs = makeParser({
|
|
|
98
103
|
'--github-output': 'github_output',
|
|
99
104
|
'--token': 'token',
|
|
100
105
|
'--room': 'room',
|
|
106
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
101
107
|
'--api': 'api',
|
|
102
108
|
'--wait': 'wait',
|
|
103
109
|
'--json': 'json',
|
|
@@ -205,6 +211,40 @@ export const parseLogoutArgs = makeParser({
|
|
|
205
211
|
bareDashIsPositional: true,
|
|
206
212
|
});
|
|
207
213
|
|
|
214
|
+
// Parser for the management nouns (rooms / webhooks / actions / approval /
|
|
215
|
+
// attachment). One shared vocabulary: each sub-command reads the flags it
|
|
216
|
+
// needs and the rest are usage errors at the command layer, same as everywhere.
|
|
217
|
+
export const parseManageArgs = makeParser({
|
|
218
|
+
aliases: {
|
|
219
|
+
'-n': 'name', '--name': 'name',
|
|
220
|
+
'--description': 'description',
|
|
221
|
+
'--icon': 'icon',
|
|
222
|
+
'--color': 'color',
|
|
223
|
+
'--sound': 'sound',
|
|
224
|
+
'--label': 'label',
|
|
225
|
+
'-t': 'title', '--title': 'title',
|
|
226
|
+
'-m': 'message', '--message': 'message',
|
|
227
|
+
'-p': 'prompt', '--prompt': 'prompt',
|
|
228
|
+
'-c': 'context', '--context': 'context',
|
|
229
|
+
'--ttl': 'ttl',
|
|
230
|
+
'--public': 'public',
|
|
231
|
+
'--handle': 'handle',
|
|
232
|
+
'-a': 'action', '--action': 'action',
|
|
233
|
+
'--cooldown': 'cooldown',
|
|
234
|
+
'--enabled': 'enabled',
|
|
235
|
+
'--require-ack': 'require_ack',
|
|
236
|
+
'--out': 'out',
|
|
237
|
+
'--wait': 'wait',
|
|
238
|
+
'--token': 'token',
|
|
239
|
+
'--room': 'room',
|
|
240
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
241
|
+
'--api': 'api',
|
|
242
|
+
'--json': 'json',
|
|
243
|
+
'-h': 'help', '--help': 'help',
|
|
244
|
+
},
|
|
245
|
+
booleans: ['public', 'require_ack', 'wait', 'json', 'help'],
|
|
246
|
+
});
|
|
247
|
+
|
|
208
248
|
export const parseHandoffsArgs = makeParser({
|
|
209
249
|
aliases: {
|
|
210
250
|
'--state': 'state',
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const MAX_JSON_BYTES = 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export function parseJsonStrict(raw, label = 'JSON') {
|
|
4
|
+
if (typeof raw !== 'string') throw new Error(`${label} must be UTF-8 JSON`);
|
|
5
|
+
rejectDuplicateKeys(raw, label);
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readStrictJsonResponse(response, label = 'response') {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
if (response.body) {
|
|
13
|
+
const reader = response.body.getReader();
|
|
14
|
+
for (;;) {
|
|
15
|
+
const { done, value } = await reader.read();
|
|
16
|
+
if (done) break;
|
|
17
|
+
size += value.byteLength;
|
|
18
|
+
if (size > MAX_JSON_BYTES) {
|
|
19
|
+
await reader.cancel().catch(() => {});
|
|
20
|
+
throw new Error(`${label} exceeded ${MAX_JSON_BYTES} bytes`);
|
|
21
|
+
}
|
|
22
|
+
chunks.push(Buffer.from(value));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const bytes = Buffer.concat(chunks);
|
|
26
|
+
let text;
|
|
27
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { throw new Error(`${label} was not valid UTF-8`); }
|
|
28
|
+
return { text, json: text ? parseJsonStrict(text, label) : null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rejectDuplicateKeys(raw, label) {
|
|
32
|
+
let index = 0;
|
|
33
|
+
const invalid = () => { throw new Error(`${label} is not valid JSON`); };
|
|
34
|
+
const whitespace = () => { while (/\s/u.test(raw[index] ?? '')) index += 1; };
|
|
35
|
+
const string = () => {
|
|
36
|
+
if (raw[index] !== '"') invalid();
|
|
37
|
+
const start = index++;
|
|
38
|
+
while (index < raw.length) {
|
|
39
|
+
if (raw[index] === '\\') { index += 2; continue; }
|
|
40
|
+
if (raw[index] === '"') {
|
|
41
|
+
index += 1;
|
|
42
|
+
try { return JSON.parse(raw.slice(start, index)); } catch { invalid(); }
|
|
43
|
+
}
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
invalid();
|
|
47
|
+
};
|
|
48
|
+
const value = () => {
|
|
49
|
+
whitespace();
|
|
50
|
+
if (raw[index] === '{') return object();
|
|
51
|
+
if (raw[index] === '[') return array();
|
|
52
|
+
if (raw[index] === '"') { string(); return; }
|
|
53
|
+
const start = index;
|
|
54
|
+
while (index < raw.length && !/[\s,\]}]/u.test(raw[index])) index += 1;
|
|
55
|
+
if (index === start) invalid();
|
|
56
|
+
};
|
|
57
|
+
const object = () => {
|
|
58
|
+
index += 1;
|
|
59
|
+
whitespace();
|
|
60
|
+
const keys = new Set();
|
|
61
|
+
if (raw[index] === '}') { index += 1; return; }
|
|
62
|
+
for (;;) {
|
|
63
|
+
whitespace();
|
|
64
|
+
const key = string();
|
|
65
|
+
if (keys.has(key)) throw new Error(`${label} contains a duplicate object key`);
|
|
66
|
+
keys.add(key);
|
|
67
|
+
whitespace();
|
|
68
|
+
if (raw[index] !== ':') invalid();
|
|
69
|
+
index += 1;
|
|
70
|
+
value();
|
|
71
|
+
whitespace();
|
|
72
|
+
if (raw[index] === '}') { index += 1; return; }
|
|
73
|
+
if (raw[index] !== ',') invalid();
|
|
74
|
+
index += 1;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const array = () => {
|
|
78
|
+
index += 1;
|
|
79
|
+
whitespace();
|
|
80
|
+
if (raw[index] === ']') { index += 1; return; }
|
|
81
|
+
for (;;) {
|
|
82
|
+
value();
|
|
83
|
+
whitespace();
|
|
84
|
+
if (raw[index] === ']') { index += 1; return; }
|
|
85
|
+
if (raw[index] !== ',') invalid();
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
whitespace();
|
|
90
|
+
value();
|
|
91
|
+
whitespace();
|
|
92
|
+
if (index !== raw.length) invalid();
|
|
93
|
+
}
|