@pingroom/cli 0.7.5 โ 0.8.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/README.md +70 -6
- package/bin/pingroom.js +17 -2
- package/lib/commands/ask.js +14 -1
- package/lib/commands/manage.js +260 -0
- package/lib/commands/mcp.js +19 -1
- package/lib/commands/skills.js +178 -0
- package/lib/config.js +11 -3
- package/lib/help.js +53 -1
- package/lib/http.js +8 -10
- package/lib/parser.js +49 -0
- package/lib/strict-json.js +93 -0
- package/lib/update-check.js +153 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -243,6 +243,36 @@ on the Lock Screen (iOS Live Activity / Dynamic Island, Android live update, and
|
|
|
243
243
|
a full inline card in the app). `start` opens it with one alert, `update` moves
|
|
244
244
|
it **silently**, `end` closes it with one completion alert.
|
|
245
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
|
+
|
|
246
276
|
## Hearing replies
|
|
247
277
|
|
|
248
278
|
Everything else here talks; `listen` is how an agent hears โ replies to its own
|
|
@@ -506,6 +536,9 @@ Options are `value:label` pairs (repeat `-o` for 2โ4). Omit them for the binar
|
|
|
506
536
|
Approve/Deny default โ two options is the lock-screen fast path. `--ttl` sets the
|
|
507
537
|
expiry in seconds (default 1h; 30โ86400). `--scope room` lets any eligible member
|
|
508
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`.
|
|
509
542
|
|
|
510
543
|
## Handoffs (agent โ human)
|
|
511
544
|
|
|
@@ -590,22 +623,53 @@ tune the approval-question expiry with `--ttl <seconds>` (default 900).
|
|
|
590
623
|
|
|
591
624
|
## MCP client setup
|
|
592
625
|
|
|
593
|
-
Print the canonical remote endpoint,
|
|
594
|
-
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:
|
|
595
628
|
|
|
596
629
|
```bash
|
|
597
630
|
pingroom mcp
|
|
598
631
|
# no global install: npx --yes @pingroom/cli mcp
|
|
599
632
|
```
|
|
600
633
|
|
|
601
|
-
`pingroom mcp add
|
|
602
|
-
not execute
|
|
603
|
-
client's MCP controls to authenticate in the browser; no
|
|
604
|
-
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.
|
|
605
640
|
|
|
606
641
|
For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
|
|
607
642
|
See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
|
|
608
643
|
|
|
644
|
+
## Agent skills
|
|
645
|
+
|
|
646
|
+
Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
|
|
647
|
+
teach an agent when and how to reach a human โ `pingroom-mcp` for conversational
|
|
648
|
+
sessions, `pingroom-cli` for shells, CI, and hooks.
|
|
649
|
+
|
|
650
|
+
```bash
|
|
651
|
+
pingroom skills # list them and every install route (prints only)
|
|
652
|
+
pingroom skills install # copy both into ~/.claude/skills (needs git)
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
`install` refuses to replace a skill that is already there; pass `--force` to
|
|
656
|
+
replace it, or `--dir <path>` to install somewhere other than
|
|
657
|
+
`~/.claude/skills`. Inside Claude Code you can instead use the plugin system,
|
|
658
|
+
which keeps them updated:
|
|
659
|
+
|
|
660
|
+
```
|
|
661
|
+
/plugin marketplace add pingroom/skills
|
|
662
|
+
/plugin install pingroom-mcp
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
## Update notifications
|
|
666
|
+
|
|
667
|
+
When a newer `@pingroom/cli` is published the CLI prints a one-line notice on
|
|
668
|
+
stderr, at most once every 24 hours. It is deliberately invisible to automation:
|
|
669
|
+
the check is skipped entirely unless both stdout and stderr are a TTY, and
|
|
670
|
+
whenever `CI` or `PINGROOM_NO_UPDATE_CHECK=1` is set. A failed or slow check is
|
|
671
|
+
silent and can never change a command's output or exit code.
|
|
672
|
+
|
|
609
673
|
## License
|
|
610
674
|
|
|
611
675
|
MIT
|
package/bin/pingroom.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// live Drive a live progress card (iOS Live Activity / Android live
|
|
24
24
|
// update) on the room members' lock screen: start / update / end.
|
|
25
25
|
// mcp Print the canonical remote MCP endpoint and client setup snippets.
|
|
26
|
+
// skills List the published agent skills, or install them for Claude Code.
|
|
26
27
|
// activate Send one optional test Question with the saved QR-paired credential.
|
|
27
28
|
// config Read/write ~/.pingroom/config.json (default_room, api_url).
|
|
28
29
|
// logout Forget the credential in ~/.pingroom/credentials.json.
|
|
@@ -39,10 +40,12 @@ import { EXIT } from '../lib/constants.js';
|
|
|
39
40
|
import { fail, stripControlChars } from '../lib/util.js';
|
|
40
41
|
import { VERSION } from '../lib/version.js';
|
|
41
42
|
import { HELP } from '../lib/help.js';
|
|
43
|
+
import { maybeNotifyUpdate } from '../lib/update-check.js';
|
|
42
44
|
import {
|
|
43
45
|
parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
|
|
44
|
-
parseLiveArgs, parseLogoutArgs, parseQArgs,
|
|
46
|
+
parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseSkillsArgs,
|
|
45
47
|
} from '../lib/parser.js';
|
|
48
|
+
import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
|
|
46
49
|
import { ping } from '../lib/commands/ping.js';
|
|
47
50
|
import { ask, cancel, list, watch } from '../lib/commands/ask.js';
|
|
48
51
|
import { handoff, listHandoffs } from '../lib/commands/handoff.js';
|
|
@@ -50,6 +53,7 @@ import { listen } from '../lib/commands/listen.js';
|
|
|
50
53
|
import { live } from '../lib/commands/live.js';
|
|
51
54
|
import { hook } from '../lib/commands/hook.js';
|
|
52
55
|
import { mcp } from '../lib/commands/mcp.js';
|
|
56
|
+
import { skills } from '../lib/commands/skills.js';
|
|
53
57
|
import { activateStoredInbox, bare } from '../lib/commands/connect.js';
|
|
54
58
|
import { config, logout } from '../lib/commands/config.js';
|
|
55
59
|
|
|
@@ -65,8 +69,14 @@ const COMMANDS = {
|
|
|
65
69
|
listen: (rest) => listen(parseQArgs(rest)),
|
|
66
70
|
hook: (rest) => hook(parseHookArgs(rest)),
|
|
67
71
|
mcp,
|
|
72
|
+
skills: (rest) => skills(parseSkillsArgs(rest)),
|
|
68
73
|
activate: (rest) => activateStoredInbox(parseQArgs(rest)),
|
|
69
74
|
live: (rest) => live(parseLiveArgs(rest)),
|
|
75
|
+
rooms: (rest) => rooms(parseManageArgs(rest)),
|
|
76
|
+
webhooks: (rest) => webhooks(parseManageArgs(rest)),
|
|
77
|
+
actions: (rest) => actions(parseManageArgs(rest)),
|
|
78
|
+
approval: (rest) => approval(parseManageArgs(rest)),
|
|
79
|
+
attachment: (rest) => attachment(parseManageArgs(rest)),
|
|
70
80
|
config: (rest) => config(parseConfigArgs(rest)),
|
|
71
81
|
logout: (rest) => logout(parseLogoutArgs(rest)),
|
|
72
82
|
};
|
|
@@ -94,7 +104,9 @@ async function main() {
|
|
|
94
104
|
// A leading flag with no subcommand (`pingroom --api โฆ`) counts as bare โ it
|
|
95
105
|
// configures the connect attempt rather than naming a command.
|
|
96
106
|
if (!command || command.startsWith('-')) {
|
|
97
|
-
|
|
107
|
+
const bareCode = await bare(parseQArgs(argv));
|
|
108
|
+
await maybeNotifyUpdate(VERSION);
|
|
109
|
+
process.exit(bareCode);
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
const handler = COMMANDS[command];
|
|
@@ -103,6 +115,9 @@ async function main() {
|
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
const code = await handler(argv.slice(1));
|
|
118
|
+
// After the command's own output, never before, and never in place of it:
|
|
119
|
+
// the notice is advisory and must not lead. It cannot alter `code`.
|
|
120
|
+
await maybeNotifyUpdate(VERSION);
|
|
106
121
|
process.exit(code);
|
|
107
122
|
}
|
|
108
123
|
|
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}`);
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `skills` โ the agent skills published at github.com/pingroom/skills.
|
|
2
|
+
//
|
|
3
|
+
// Bare `skills` prints the catalog and every install route, the same
|
|
4
|
+
// output-only contract `mcp` keeps. `skills install` is the one command in this
|
|
5
|
+
// CLI that writes outside ~/.pingroom, so it is explicit, refuses to clobber,
|
|
6
|
+
// and names every path it touched.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { EXIT } from '../constants.js';
|
|
14
|
+
import { fail } from '../util.js';
|
|
15
|
+
import { commandHelp } from '../help.js';
|
|
16
|
+
|
|
17
|
+
export const SKILLS_REPO = 'https://github.com/pingroom/skills';
|
|
18
|
+
const SKILLS_CLONE_URL = `${SKILLS_REPO}.git`;
|
|
19
|
+
|
|
20
|
+
// Path in the repo -> the skill directory name it installs as.
|
|
21
|
+
//
|
|
22
|
+
// The repo is laid out as two Claude Code plugins (`mcp/`, `cli/`), each with a
|
|
23
|
+
// `skills/<name>/` directory whose name already matches the skill's frontmatter
|
|
24
|
+
// `name` โ that agreement is what lets the same tree also be installed with
|
|
25
|
+
// `/plugin marketplace add`. So the source path is deep and the install name is
|
|
26
|
+
// simply its last segment; a copy install and a plugin install land the same
|
|
27
|
+
// directory name either way.
|
|
28
|
+
const SKILLS = [
|
|
29
|
+
{
|
|
30
|
+
source: ['mcp', 'skills', 'pingroom-mcp'],
|
|
31
|
+
install: 'pingroom-mcp',
|
|
32
|
+
summary: 'conversational agents โ the hosted MCP connector',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
source: ['cli', 'skills', 'pingroom-cli'],
|
|
36
|
+
install: 'pingroom-cli',
|
|
37
|
+
summary: 'shells, CI, and Claude Code hooks',
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function claudeSkillsDir() {
|
|
42
|
+
return process.env.CLAUDE_SKILLS_DIR || join(homedir(), '.claude', 'skills');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function catalogLines() {
|
|
46
|
+
const width = Math.max(...SKILLS.map((s) => s.install.length));
|
|
47
|
+
return SKILLS.map((s) => ` ${s.install.padEnd(width)} ${s.summary}`).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printCatalog() {
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
`PingRoom agent skills โ ${SKILLS_REPO}
|
|
53
|
+
|
|
54
|
+
${catalogLines()}
|
|
55
|
+
|
|
56
|
+
Install with this CLI (copies into ${claudeSkillsDir()}):
|
|
57
|
+
pingroom skills install
|
|
58
|
+
|
|
59
|
+
Install as a Claude Code plugin (auto-updates, no copy):
|
|
60
|
+
/plugin marketplace add pingroom/skills
|
|
61
|
+
/plugin install pingroom-mcp
|
|
62
|
+
/plugin install pingroom-cli
|
|
63
|
+
|
|
64
|
+
Or by hand:
|
|
65
|
+
git clone ${SKILLS_CLONE_URL} /tmp/pingroom-skills
|
|
66
|
+
${SKILLS.map((s) => ` cp -r /tmp/pingroom-skills/${s.source.join('/')} ${claudeSkillsDir()}`).join('\n')}
|
|
67
|
+
|
|
68
|
+
Only "pingroom skills install" writes anything; this listing does not.
|
|
69
|
+
`);
|
|
70
|
+
return EXIT.OK;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function directoryExists(path) {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(path).isDirectory();
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Clone the skills repo into a fresh temp directory and return its path.
|
|
83
|
+
*
|
|
84
|
+
* `git` rather than a tarball fetch: the repo is public and shallow-clones in
|
|
85
|
+
* one round trip, Node ships no tar reader, and vendoring one would put an
|
|
86
|
+
* archive parser in the dependency-free path every ping goes through. When git
|
|
87
|
+
* is missing the manual recipe above is still exact, so this fails with that
|
|
88
|
+
* rather than half-installing.
|
|
89
|
+
*/
|
|
90
|
+
function cloneSkills() {
|
|
91
|
+
const probe = spawnSync('git', ['--version'], { stdio: 'ignore' });
|
|
92
|
+
if (probe.error || probe.status !== 0) {
|
|
93
|
+
fail(`git is required for "skills install".\nInstall git, or copy the skills by hand:\n pingroom skills`, EXIT.ERROR);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const workspace = mkdtempSync(join(tmpdir(), 'pingroom-skills-'));
|
|
97
|
+
const clone = spawnSync(
|
|
98
|
+
'git',
|
|
99
|
+
['clone', '--depth', '1', '--quiet', SKILLS_CLONE_URL, workspace],
|
|
100
|
+
{ stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (clone.error || clone.status !== 0) {
|
|
104
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
105
|
+
const detail = (clone.stderr || clone.error?.message || 'git clone failed').trim().split('\n')[0];
|
|
106
|
+
fail(`could not fetch ${SKILLS_REPO}: ${detail}`, EXIT.ERROR);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return workspace;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function install(args) {
|
|
113
|
+
const target = args.dir ? String(args.dir) : claudeSkillsDir();
|
|
114
|
+
const force = Boolean(args.force);
|
|
115
|
+
|
|
116
|
+
// Resolve collisions BEFORE the network call. Cloning first and refusing
|
|
117
|
+
// afterwards would spend the round trip to tell the operator something that
|
|
118
|
+
// was knowable from the filesystem alone.
|
|
119
|
+
if (!force) {
|
|
120
|
+
const existing = SKILLS.filter((s) => directoryExists(join(target, s.install)));
|
|
121
|
+
if (existing.length > 0) {
|
|
122
|
+
const names = existing.map((s) => join(target, s.install)).join('\n ');
|
|
123
|
+
fail(
|
|
124
|
+
`already installed:\n ${names}\nRe-run with --force to replace ${existing.length === 1 ? 'it' : 'them'}.`,
|
|
125
|
+
EXIT.USAGE,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const workspace = cloneSkills();
|
|
131
|
+
const installed = [];
|
|
132
|
+
try {
|
|
133
|
+
for (const skill of SKILLS) {
|
|
134
|
+
const from = join(workspace, ...skill.source);
|
|
135
|
+
if (!directoryExists(from)) {
|
|
136
|
+
fail(`${SKILLS_REPO} has no "${skill.source.join('/')}" directory โ the repo layout changed.`, EXIT.ERROR);
|
|
137
|
+
}
|
|
138
|
+
const to = join(target, skill.install);
|
|
139
|
+
mkdirSync(target, { recursive: true });
|
|
140
|
+
// Replace rather than merge: a stale SKILL.md left beside a new one is a
|
|
141
|
+
// skill that half-describes two versions, and Claude Code would load it.
|
|
142
|
+
rmSync(to, { recursive: true, force: true });
|
|
143
|
+
cpSync(from, to, { recursive: true });
|
|
144
|
+
installed.push({ name: skill.install, path: to, files: countFiles(to) });
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lines = installed.map((s) => ` ${s.name} -> ${s.path} (${s.files} file${s.files === 1 ? '' : 's'})`);
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
`Installed ${installed.length} skill${installed.length === 1 ? '' : 's'}:
|
|
153
|
+
${lines.join('\n')}
|
|
154
|
+
|
|
155
|
+
Restart Claude Code (or start a new session) to load them.
|
|
156
|
+
Connect the MCP server so the pingroom-mcp skill has tools to call:
|
|
157
|
+
pingroom mcp
|
|
158
|
+
`);
|
|
159
|
+
return EXIT.OK;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function countFiles(dir) {
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
165
|
+
total += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1;
|
|
166
|
+
}
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function skills(args) {
|
|
171
|
+
if (args.help) { process.stdout.write(`${commandHelp('skills')}\n`); return EXIT.OK; }
|
|
172
|
+
|
|
173
|
+
const [sub] = args._;
|
|
174
|
+
if (sub === undefined || sub === 'list') return printCatalog();
|
|
175
|
+
if (sub === 'install') return install(args);
|
|
176
|
+
|
|
177
|
+
fail('usage: pingroom skills [list|install] [--dir <path>] [--force]', EXIT.USAGE);
|
|
178
|
+
}
|
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/help.js
CHANGED
|
@@ -30,7 +30,13 @@ Commands:
|
|
|
30
30
|
permission prompts to a PingRoom question you answer from your phone
|
|
31
31
|
mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
|
|
32
32
|
Claude Desktop
|
|
33
|
+
skills List the PingRoom agent skills, or install them for Claude Code
|
|
33
34
|
activate Retry Agent Inbox activation with the saved QR-paired credential
|
|
35
|
+
rooms List, inspect, create, or join rooms (rooms list|get|create|join)
|
|
36
|
+
webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
|
|
37
|
+
actions List, configure, or trigger a room's quick actions (actions list|set|trigger)
|
|
38
|
+
approval Send an approve/deny request; with --wait, block on the decision
|
|
39
|
+
attachment Download or delete an attachment by id (attachment get|delete)
|
|
34
40
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
35
41
|
logout Forget the stored credential`;
|
|
36
42
|
|
|
@@ -65,6 +71,7 @@ export const HELP_ASK = `ask options (agent token required):
|
|
|
65
71
|
--scope <s> Who answers: 'direct' (default) or 'room'
|
|
66
72
|
--target <uuid> For --scope direct: a specific room member
|
|
67
73
|
--ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
|
|
74
|
+
--idempotency-key <key> Dedupe this exact create request across retries
|
|
68
75
|
--text-input <ph> Invite a short typed answer; <ph> is the placeholder
|
|
69
76
|
--text-max <n> Max typed-answer length (1..60)
|
|
70
77
|
--wait Block until answered/expired/cancelled
|
|
@@ -73,6 +80,8 @@ export const HELP_ASK = `ask options (agent token required):
|
|
|
73
80
|
--correlation-id <id> Opaque id echoed on every read of this question
|
|
74
81
|
--reply-to <id> Id of the ping this question replies to
|
|
75
82
|
--room <code> Room invite code (required for ask)
|
|
83
|
+
--expected-room-sha256 <hex>
|
|
84
|
+
Fail unless the resolved room matches this SHA-256
|
|
76
85
|
--github-output <path> Safely append question outputs for GitHub Actions`;
|
|
77
86
|
|
|
78
87
|
export const HELP_LIST = `list options:
|
|
@@ -148,6 +157,13 @@ export const HELP_MCP = `mcp:
|
|
|
148
157
|
pingroom mcp add claude-code Print the Claude Code setup command
|
|
149
158
|
(output-only; does not change client config)`;
|
|
150
159
|
|
|
160
|
+
export const HELP_SKILLS = `skills:
|
|
161
|
+
pingroom skills List the published agent skills and every
|
|
162
|
+
install route (output-only)
|
|
163
|
+
pingroom skills install Copy them into ~/.claude/skills (needs git)
|
|
164
|
+
--dir <path> Install somewhere other than ~/.claude/skills
|
|
165
|
+
--force Replace skills that are already installed`;
|
|
166
|
+
|
|
151
167
|
export const HELP_ACTIVATE = `activate:
|
|
152
168
|
pingroom activate Send one test Question to your phone to prove the
|
|
153
169
|
saved QR-paired credential works (optional โ
|
|
@@ -270,7 +286,7 @@ human decision is not an infrastructure failure.`;
|
|
|
270
286
|
|
|
271
287
|
export const HELP = [
|
|
272
288
|
HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
|
|
273
|
-
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
|
|
289
|
+
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_SKILLS, HELP_ACTIVATE, HELP_CONFIG,
|
|
274
290
|
HELP_SHARED, HELP_TAIL,
|
|
275
291
|
].join('\n\n');
|
|
276
292
|
|
|
@@ -291,7 +307,39 @@ export const COMMAND_HELP_SECTIONS = {
|
|
|
291
307
|
listen: HELP_LISTEN,
|
|
292
308
|
live: HELP_LIVE,
|
|
293
309
|
hook: HELP_HOOK,
|
|
310
|
+
skills: HELP_SKILLS,
|
|
294
311
|
activate: HELP_ACTIVATE,
|
|
312
|
+
rooms: `rooms (agent token required):
|
|
313
|
+
pingroom rooms list List the rooms this account belongs to
|
|
314
|
+
pingroom rooms get <code> Show one room
|
|
315
|
+
pingroom rooms create -n <name> --icon <id> --color <hex>
|
|
316
|
+
Create a private room (add --description);
|
|
317
|
+
--public --handle <handle> creates a public
|
|
318
|
+
room under its own consent scope
|
|
319
|
+
pingroom rooms join <code> Join a room by invite code`,
|
|
320
|
+
webhooks: `webhooks (agent token + --room required):
|
|
321
|
+
pingroom webhooks list --room <code>
|
|
322
|
+
pingroom webhooks create --room <code> --name <n>
|
|
323
|
+
Optional: --title --message --icon --color
|
|
324
|
+
--sound --action <1-4> --cooldown <sec>
|
|
325
|
+
Prints the secret trigger URL once
|
|
326
|
+
pingroom webhooks update <id> --room <code> [same fields, plus --enabled true|false]
|
|
327
|
+
pingroom webhooks delete <id> --room <code>`,
|
|
328
|
+
actions: `actions (agent token + --room required):
|
|
329
|
+
pingroom actions list --room <code>
|
|
330
|
+
pingroom actions set <1-4> --room <code> --label <text> --icon <emoji>
|
|
331
|
+
Optional: --sound, --require-ack
|
|
332
|
+
pingroom actions trigger <1-4> --room <code>`,
|
|
333
|
+
approval: `approval (agent token + --room required):
|
|
334
|
+
pingroom approval -p <prompt> Send an approve/deny request
|
|
335
|
+
-c, --context <text> Secondary line (<= 40 chars)
|
|
336
|
+
--ttl <seconds> Expiry; omit for the server default
|
|
337
|
+
--wait Block until approved/denied/expired
|
|
338
|
+
(exit 0 approved ยท 4 denied ยท 3 expired)`,
|
|
339
|
+
attachment: `attachment (agent token required):
|
|
340
|
+
pingroom attachment get <id> [--out <path>]
|
|
341
|
+
Download; bytes go to --out or stdout
|
|
342
|
+
pingroom attachment delete <id> Delete an unclaimed upload`,
|
|
295
343
|
config: HELP_CONFIG,
|
|
296
344
|
logout: `logout:
|
|
297
345
|
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
|
@@ -307,6 +355,10 @@ export const COMMAND_HELP_FOOTERS = {
|
|
|
307
355
|
-h, --help Show this help`,
|
|
308
356
|
logout: `Shared:
|
|
309
357
|
-h, --help Show this help`,
|
|
358
|
+
// `skills` reaches GitHub, never the PingRoom API, so the shared credential
|
|
359
|
+
// and --json flags would all be lies here.
|
|
360
|
+
skills: `Shared:
|
|
361
|
+
-h, --help Show this help`,
|
|
310
362
|
};
|
|
311
363
|
|
|
312
364
|
// `<command> --help`: that command's section plus the shared flags, instead of
|
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
|
@@ -70,6 +70,7 @@ export const parseArgs = makeParser({
|
|
|
70
70
|
'--attach': 'attach',
|
|
71
71
|
'--token': 'token',
|
|
72
72
|
'--room': 'room',
|
|
73
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
73
74
|
'--api': 'api',
|
|
74
75
|
'--json': 'json',
|
|
75
76
|
'-h': 'help', '--help': 'help',
|
|
@@ -88,6 +89,7 @@ export const parseQArgs = makeParser({
|
|
|
88
89
|
'--scope': 'scope',
|
|
89
90
|
'--target': 'target',
|
|
90
91
|
'--ttl': 'ttl',
|
|
92
|
+
'--idempotency-key': 'idempotency_key',
|
|
91
93
|
'-d': 'data', '--data': 'data',
|
|
92
94
|
'--correlation-id': 'correlation_id',
|
|
93
95
|
'--reply-to': 'reply_to',
|
|
@@ -101,6 +103,7 @@ export const parseQArgs = makeParser({
|
|
|
101
103
|
'--github-output': 'github_output',
|
|
102
104
|
'--token': 'token',
|
|
103
105
|
'--room': 'room',
|
|
106
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
104
107
|
'--api': 'api',
|
|
105
108
|
'--wait': 'wait',
|
|
106
109
|
'--json': 'json',
|
|
@@ -208,6 +211,52 @@ export const parseLogoutArgs = makeParser({
|
|
|
208
211
|
bareDashIsPositional: true,
|
|
209
212
|
});
|
|
210
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
|
+
// `skills` takes no credential and touches no API: only where to install and
|
|
218
|
+
// whether to replace what is already there. A dedicated vocabulary keeps
|
|
219
|
+
// --force from leaking into the parsers whose commands send real pings.
|
|
220
|
+
export const parseSkillsArgs = makeParser({
|
|
221
|
+
aliases: {
|
|
222
|
+
'--dir': 'dir',
|
|
223
|
+
'--force': 'force',
|
|
224
|
+
'-h': 'help', '--help': 'help',
|
|
225
|
+
},
|
|
226
|
+
booleans: ['force', 'help'],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
export const parseManageArgs = makeParser({
|
|
230
|
+
aliases: {
|
|
231
|
+
'-n': 'name', '--name': 'name',
|
|
232
|
+
'--description': 'description',
|
|
233
|
+
'--icon': 'icon',
|
|
234
|
+
'--color': 'color',
|
|
235
|
+
'--sound': 'sound',
|
|
236
|
+
'--label': 'label',
|
|
237
|
+
'-t': 'title', '--title': 'title',
|
|
238
|
+
'-m': 'message', '--message': 'message',
|
|
239
|
+
'-p': 'prompt', '--prompt': 'prompt',
|
|
240
|
+
'-c': 'context', '--context': 'context',
|
|
241
|
+
'--ttl': 'ttl',
|
|
242
|
+
'--public': 'public',
|
|
243
|
+
'--handle': 'handle',
|
|
244
|
+
'-a': 'action', '--action': 'action',
|
|
245
|
+
'--cooldown': 'cooldown',
|
|
246
|
+
'--enabled': 'enabled',
|
|
247
|
+
'--require-ack': 'require_ack',
|
|
248
|
+
'--out': 'out',
|
|
249
|
+
'--wait': 'wait',
|
|
250
|
+
'--token': 'token',
|
|
251
|
+
'--room': 'room',
|
|
252
|
+
'--expected-room-sha256': 'expected_room_sha256',
|
|
253
|
+
'--api': 'api',
|
|
254
|
+
'--json': 'json',
|
|
255
|
+
'-h': 'help', '--help': 'help',
|
|
256
|
+
},
|
|
257
|
+
booleans: ['public', 'require_ack', 'wait', 'json', 'help'],
|
|
258
|
+
});
|
|
259
|
+
|
|
211
260
|
export const parseHandoffsArgs = makeParser({
|
|
212
261
|
aliases: {
|
|
213
262
|
'--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
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// "A newer @pingroom/cli is available" โ the one piece of output this tool
|
|
2
|
+
// prints that nobody asked for. Everything here exists to make that safe.
|
|
3
|
+
//
|
|
4
|
+
// The hard rule: this must never change what a command does. Not its exit code,
|
|
5
|
+
// not its stdout, not whether it succeeds. A version notice that breaks a
|
|
6
|
+
// deploy pipeline is worse than never shipping the notice at all, so every
|
|
7
|
+
// failure path below is a silent return.
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { pingroomHome, readJsonFile } from './config.js';
|
|
14
|
+
|
|
15
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@pingroom/cli/latest';
|
|
16
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
// Awaited before exit, so this is a real ceiling on how long a successful
|
|
19
|
+
// command can be delayed by a check nobody asked for. Measured round trips to
|
|
20
|
+
// the registry ran 0.63-1.19s warm, so a tighter budget (1.2s was tried) times
|
|
21
|
+
// out often enough to look like "there is never an update".
|
|
22
|
+
const TIMEOUT_MS = 2500;
|
|
23
|
+
|
|
24
|
+
export function updateCachePath() { return join(pingroomHome(), 'update-check.json'); }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write the cache, or give up without a word.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately NOT config.js's writeJsonFile: that one calls fail() on an
|
|
30
|
+
* unwritable path, and fail() calls process.exit() โ which no try/catch can
|
|
31
|
+
* intercept. Borrowing it here would mean a read-only or full ~/.pingroom turns
|
|
32
|
+
* an advisory version check into the thing that kills the operator's ping. The
|
|
33
|
+
* write is still atomic (temp file + rename) so a crash mid-write cannot leave
|
|
34
|
+
* a torn file for the next run to parse.
|
|
35
|
+
*/
|
|
36
|
+
function writeCacheQuietly(path, value) {
|
|
37
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
|
|
40
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
41
|
+
renameSync(tmp, path);
|
|
42
|
+
} catch {
|
|
43
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Why an env var AND a TTY test:
|
|
49
|
+
*
|
|
50
|
+
* `CI` covers the graders that set it (GitHub Actions, GitLab, CircleCI). The
|
|
51
|
+
* TTY test covers everything else โ cron, systemd units, Docker builds, a
|
|
52
|
+
* pipeline that forgot to set CI, and `pingroom ... | jq`. Neither alone is
|
|
53
|
+
* enough, and the notice is worthless to a machine either way.
|
|
54
|
+
*/
|
|
55
|
+
function suppressed() {
|
|
56
|
+
if (process.env.PINGROOM_NO_UPDATE_CHECK === '1') return true;
|
|
57
|
+
if (process.env.CI) return true;
|
|
58
|
+
if (process.env.NODE_ENV === 'test') return true;
|
|
59
|
+
return !process.stdout.isTTY || !process.stderr.isTTY;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compare two dotted release numbers. Returns true when `candidate` is strictly
|
|
64
|
+
* newer than `current`.
|
|
65
|
+
*
|
|
66
|
+
* Anything carrying a prerelease or build suffix (`-beta.1`, `+sha`) is refused
|
|
67
|
+
* outright rather than guessed at: npm's `latest` tag should never point at one,
|
|
68
|
+
* and a wrong guess here nags every single run. Only the numeric release line is
|
|
69
|
+
* compared, and only when both sides parse.
|
|
70
|
+
*/
|
|
71
|
+
export function isNewer(candidate, current) {
|
|
72
|
+
const parse = (value) => {
|
|
73
|
+
if (typeof value !== 'string') return null;
|
|
74
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
|
|
75
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
76
|
+
};
|
|
77
|
+
const a = parse(candidate);
|
|
78
|
+
const b = parse(current);
|
|
79
|
+
if (!a || !b) return false;
|
|
80
|
+
for (let i = 0; i < 3; i++) {
|
|
81
|
+
if (a[i] > b[i]) return true;
|
|
82
|
+
if (a[i] < b[i]) return false;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetch the published `latest` version, or null for any failure at all โ no
|
|
89
|
+
* network, DNS refusal, a 5xx, a proxy returning HTML, a body without a version
|
|
90
|
+
* string. The caller cannot distinguish these and should not try to.
|
|
91
|
+
*/
|
|
92
|
+
async function fetchLatest() {
|
|
93
|
+
try {
|
|
94
|
+
// Plain application/json, NOT npm's abbreviated-metadata type: that one is
|
|
95
|
+
// only accepted on the full packument, and asking for it here gets a 406
|
|
96
|
+
// that this function would swallow into a permanent "no update available".
|
|
97
|
+
// The single-version document is ~1.5 KB, smaller than the packument the
|
|
98
|
+
// abbreviated type would have saved us from.
|
|
99
|
+
const res = await fetch(REGISTRY_URL, {
|
|
100
|
+
headers: { Accept: 'application/json' },
|
|
101
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) return null;
|
|
104
|
+
const json = await res.json();
|
|
105
|
+
return typeof json?.version === 'string' ? json.version : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check at most once every 24h and print a notice when a newer release exists.
|
|
113
|
+
*
|
|
114
|
+
* The cache timestamp is written on every completed check, including one that
|
|
115
|
+
* found nothing and one whose fetch failed. Writing it only on success would
|
|
116
|
+
* turn an offline machine into a machine that retries the registry on every
|
|
117
|
+
* single invocation.
|
|
118
|
+
*
|
|
119
|
+
* Note this is awaited by the caller rather than detached: the CLI ends in an
|
|
120
|
+
* explicit process.exit(), which would kill a floating promise mid-flight and
|
|
121
|
+
* leave the cache unwritten โ so a detached check would re-fetch forever while
|
|
122
|
+
* appearing to cost nothing.
|
|
123
|
+
*/
|
|
124
|
+
export async function maybeNotifyUpdate(currentVersion) {
|
|
125
|
+
try {
|
|
126
|
+
if (suppressed()) return;
|
|
127
|
+
|
|
128
|
+
const path = updateCachePath();
|
|
129
|
+
const cached = readJsonFile(path);
|
|
130
|
+
const checkedAt = Number(cached?.checked_at);
|
|
131
|
+
const fresh = Number.isFinite(checkedAt) && Date.now() - checkedAt < CHECK_INTERVAL_MS;
|
|
132
|
+
|
|
133
|
+
// Inside the window, still report what the last check found: the notice
|
|
134
|
+
// should persist until the operator actually upgrades, not appear once a day
|
|
135
|
+
// and vanish.
|
|
136
|
+
const latest = fresh ? cached?.latest : await fetchLatest();
|
|
137
|
+
if (!fresh) {
|
|
138
|
+
writeCacheQuietly(path, { checked_at: Date.now(), latest: latest ?? null });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof latest !== 'string' || !isNewer(latest, currentVersion)) return;
|
|
142
|
+
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`\nnote: @pingroom/cli ${latest} is available (you have ${currentVersion})\n`
|
|
145
|
+
+ ' npm i -g @pingroom/cli\n'
|
|
146
|
+
+ ' set PINGROOM_NO_UPDATE_CHECK=1 to silence this\n',
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
// Unreachable by design โ every step above already swallows its own
|
|
150
|
+
// failures. This is the backstop that guarantees the promise this function
|
|
151
|
+
// returns can never reject into the caller's exit path.
|
|
152
|
+
}
|
|
153
|
+
}
|