@pingroom/cli 0.7.6 → 0.8.1
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 +46 -8
- package/bin/pingroom.js +14 -3
- package/lib/commands/ask.js +4 -42
- package/lib/commands/connect.js +97 -15
- package/lib/commands/hook.js +17 -2
- package/lib/commands/manage.js +25 -18
- package/lib/commands/skills.js +178 -0
- package/lib/continuations.js +106 -0
- package/lib/help.js +38 -4
- package/lib/http.js +8 -3
- package/lib/parser.js +23 -0
- package/lib/question-wait.js +45 -0
- package/lib/render.js +39 -0
- package/lib/scopes.js +95 -0
- package/lib/update-check.js +153 -0
- package/lib/util.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,8 +85,9 @@ and is not retried as if history could be rewritten.
|
|
|
85
85
|
An incomplete run exits `1`; it never deletes or replaces the saved credential.
|
|
86
86
|
The command does not fall back to `PINGROOM_TOKEN`, an email-only credential, or
|
|
87
87
|
a credential without `pingroom:handoffs:create` and a delivery room. A grant of
|
|
88
|
-
all rooms pins no delivery room — pick one under Connected Agents in
|
|
89
|
-
|
|
88
|
+
all rooms pins no delivery room up front — pick one under Connected Agents in
|
|
89
|
+
the app, or run `pingroom rooms create`, which adopts the new room as the
|
|
90
|
+
delivery room when the agent was granted every room.
|
|
90
91
|
|
|
91
92
|
There is deliberately no `login` command: being unconnected is a state the tool
|
|
92
93
|
resolves, not one you have to discover. Once connected, bare `pingroom` prints
|
|
@@ -96,18 +97,26 @@ that status line followed by the usual help.
|
|
|
96
97
|
|
|
97
98
|
Approving on the phone grants two separate things, and both are enforced:
|
|
98
99
|
|
|
99
|
-
- **Permissions** —
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
- **Permissions** — one approval covers every command this CLI ships, so it
|
|
101
|
+
asks for all 16 scopes its commands can need (`lib/scopes.js` is the list):
|
|
102
|
+
send pings, upload attachments, read pings, ask questions, request approvals,
|
|
103
|
+
create handoffs, drive live status; read/create/publish/join rooms and
|
|
104
|
+
set/trigger quick actions; and read/create/delete incoming webhooks. It does
|
|
105
|
+
**not** ask for `profile:write` (no command uses it) or the retired
|
|
106
|
+
`agents:ping`. Nothing widens them later — consent is an intersection, so a
|
|
107
|
+
scope the pairing did not request can never be granted afterwards; a command
|
|
108
|
+
needing one you did not approve returns `403 insufficient_scope`.
|
|
103
109
|
- **Rooms** — one room, several, or all of them. A room outside that grant
|
|
104
110
|
returns `403 room_not_granted` on every room-scoped call — writes such as
|
|
105
111
|
pings, questions and live streams, and reads such as listing a room's quick
|
|
106
112
|
actions or webhooks. Widen it under Connected Agents in the app.
|
|
107
113
|
|
|
108
114
|
Both refusals print the fix, not just the code. A credential paired by an older
|
|
109
|
-
CLI carries the scope set that version asked for —
|
|
110
|
-
command starts reporting
|
|
115
|
+
CLI carries the scope set that version asked for — CLIs before 0.8.1 asked for
|
|
116
|
+
only seven — so run `pingroom reconnect` if a command starts reporting
|
|
117
|
+
`insufficient_scope`. That re-approves with the current set, keeps the existing
|
|
118
|
+
connection working until you approve the new one, and revokes the old one only
|
|
119
|
+
afterwards, so cancelling changes nothing.
|
|
111
120
|
|
|
112
121
|
The credential lands in `~/.pingroom/credentials.json` (mode `0600`, inside a
|
|
113
122
|
`0700` directory). `PINGROOM_HOME` moves that directory; `pingroom logout`
|
|
@@ -641,6 +650,35 @@ shared by ChatGPT and Codex.
|
|
|
641
650
|
For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
|
|
642
651
|
See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
|
|
643
652
|
|
|
653
|
+
## Agent skills
|
|
654
|
+
|
|
655
|
+
Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
|
|
656
|
+
teach an agent when and how to reach a human — `pingroom-mcp` for conversational
|
|
657
|
+
sessions, `pingroom-cli` for shells, CI, and hooks.
|
|
658
|
+
|
|
659
|
+
```bash
|
|
660
|
+
pingroom skills # list them and every install route (prints only)
|
|
661
|
+
pingroom skills install # copy both into ~/.claude/skills (needs git)
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`install` refuses to replace a skill that is already there; pass `--force` to
|
|
665
|
+
replace it, or `--dir <path>` to install somewhere other than
|
|
666
|
+
`~/.claude/skills`. Inside Claude Code you can instead use the plugin system,
|
|
667
|
+
which keeps them updated:
|
|
668
|
+
|
|
669
|
+
```
|
|
670
|
+
/plugin marketplace add pingroom/skills
|
|
671
|
+
/plugin install pingroom-mcp
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
## Update notifications
|
|
675
|
+
|
|
676
|
+
When a newer `@pingroom/cli` is published the CLI prints a one-line notice on
|
|
677
|
+
stderr, at most once every 24 hours. It is deliberately invisible to automation:
|
|
678
|
+
the check is skipped entirely unless both stdout and stderr are a TTY, and
|
|
679
|
+
whenever `CI` or `PINGROOM_NO_UPDATE_CHECK=1` is set. A failed or slow check is
|
|
680
|
+
silent and can never change a command's output or exit code.
|
|
681
|
+
|
|
644
682
|
## License
|
|
645
683
|
|
|
646
684
|
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,9 +40,11 @@ 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, parseManageArgs, parseQArgs,
|
|
46
|
+
parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseReconnectArgs,
|
|
47
|
+
parseSkillsArgs,
|
|
45
48
|
} from '../lib/parser.js';
|
|
46
49
|
import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
|
|
47
50
|
import { ping } from '../lib/commands/ping.js';
|
|
@@ -51,7 +54,8 @@ import { listen } from '../lib/commands/listen.js';
|
|
|
51
54
|
import { live } from '../lib/commands/live.js';
|
|
52
55
|
import { hook } from '../lib/commands/hook.js';
|
|
53
56
|
import { mcp } from '../lib/commands/mcp.js';
|
|
54
|
-
import {
|
|
57
|
+
import { skills } from '../lib/commands/skills.js';
|
|
58
|
+
import { activateStoredInbox, bare, reconnect } from '../lib/commands/connect.js';
|
|
55
59
|
import { config, logout } from '../lib/commands/config.js';
|
|
56
60
|
|
|
57
61
|
const COMMANDS = {
|
|
@@ -66,6 +70,7 @@ const COMMANDS = {
|
|
|
66
70
|
listen: (rest) => listen(parseQArgs(rest)),
|
|
67
71
|
hook: (rest) => hook(parseHookArgs(rest)),
|
|
68
72
|
mcp,
|
|
73
|
+
skills: (rest) => skills(parseSkillsArgs(rest)),
|
|
69
74
|
activate: (rest) => activateStoredInbox(parseQArgs(rest)),
|
|
70
75
|
live: (rest) => live(parseLiveArgs(rest)),
|
|
71
76
|
rooms: (rest) => rooms(parseManageArgs(rest)),
|
|
@@ -74,6 +79,7 @@ const COMMANDS = {
|
|
|
74
79
|
approval: (rest) => approval(parseManageArgs(rest)),
|
|
75
80
|
attachment: (rest) => attachment(parseManageArgs(rest)),
|
|
76
81
|
config: (rest) => config(parseConfigArgs(rest)),
|
|
82
|
+
reconnect: (rest) => reconnect(parseReconnectArgs(rest)),
|
|
77
83
|
logout: (rest) => logout(parseLogoutArgs(rest)),
|
|
78
84
|
};
|
|
79
85
|
|
|
@@ -100,7 +106,9 @@ async function main() {
|
|
|
100
106
|
// A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
|
|
101
107
|
// configures the connect attempt rather than naming a command.
|
|
102
108
|
if (!command || command.startsWith('-')) {
|
|
103
|
-
|
|
109
|
+
const bareCode = await bare(parseQArgs(argv));
|
|
110
|
+
await maybeNotifyUpdate(VERSION);
|
|
111
|
+
process.exit(bareCode);
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
const handler = COMMANDS[command];
|
|
@@ -109,6 +117,9 @@ async function main() {
|
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
const code = await handler(argv.slice(1));
|
|
120
|
+
// After the command's own output, never before, and never in place of it:
|
|
121
|
+
// the notice is advisory and must not lead. It cannot alter `code`.
|
|
122
|
+
await maybeNotifyUpdate(VERSION);
|
|
112
123
|
process.exit(code);
|
|
113
124
|
}
|
|
114
125
|
|
package/lib/commands/ask.js
CHANGED
|
@@ -2,41 +2,14 @@
|
|
|
2
2
|
// existing one), `cancel`, and `list`.
|
|
3
3
|
|
|
4
4
|
import { EXIT } from '../constants.js';
|
|
5
|
-
import { fail, parseDataObject, requireMaxLength, resolveWaitHold
|
|
5
|
+
import { applyIdempotencyKey, fail, parseDataObject, requireMaxLength, resolveWaitHold } from '../util.js';
|
|
6
6
|
import { commandHelp } from '../help.js';
|
|
7
7
|
import { apiDetail, httpJson } from '../http.js';
|
|
8
8
|
import { agentContext } from '../config.js';
|
|
9
|
-
import { buildOptions
|
|
9
|
+
import { buildOptions } from '../render.js';
|
|
10
|
+
import { waitForResolution } from '../question-wait.js';
|
|
10
11
|
import { writeGitHubQuestionOutputs } from '../github-output.js';
|
|
11
12
|
|
|
12
|
-
// Long-poll the wait endpoint until the question leaves `pending`, then print
|
|
13
|
-
// and return the state's exit code. The server expires it at its ttl, so this
|
|
14
|
-
// always terminates.
|
|
15
|
-
async function waitForResolution(id, args, { token, apiBase }) {
|
|
16
|
-
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
17
|
-
|
|
18
|
-
for (;;) {
|
|
19
|
-
const started = Date.now();
|
|
20
|
-
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
21
|
-
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
22
|
-
if (!res.ok) {
|
|
23
|
-
const detail = apiDetail(res, json);
|
|
24
|
-
fail(`wait failed: ${detail}`);
|
|
25
|
-
}
|
|
26
|
-
if (json && json.state && json.state !== 'pending') {
|
|
27
|
-
if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
|
|
28
|
-
if (args.json) process.stdout.write(`${text}\n`);
|
|
29
|
-
else printResolution(json);
|
|
30
|
-
return exitForState(json.state);
|
|
31
|
-
}
|
|
32
|
-
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
33
|
-
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
34
|
-
// would otherwise be hammered at full speed.
|
|
35
|
-
const elapsed = Date.now() - started;
|
|
36
|
-
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
13
|
export async function ask(args) {
|
|
41
14
|
if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
|
|
42
15
|
|
|
@@ -76,18 +49,7 @@ export async function ask(args) {
|
|
|
76
49
|
}
|
|
77
50
|
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
78
51
|
|
|
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
|
-
}
|
|
52
|
+
const headers = applyIdempotencyKey(args, { Authorization: `Bearer ${token}` });
|
|
91
53
|
|
|
92
54
|
// Pre-flight: reject a bad --timeout before the question exists.
|
|
93
55
|
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
package/lib/commands/connect.js
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
import { HELP, commandHelp } from '../help.js';
|
|
10
10
|
import { apiDetail, httpJson, requireSafeUrl, retryAfterMs } from '../http.js';
|
|
11
11
|
import {
|
|
12
|
-
credentialsPath, readStoredCredential,
|
|
12
|
+
credentialsPath, readStoredCredential, requireStoredCredentialOrigin, resolveApiBase,
|
|
13
|
+
resolveRoom, saveCredential,
|
|
13
14
|
} from '../config.js';
|
|
15
|
+
import { CLI_SCOPES } from '../scopes.js';
|
|
14
16
|
|
|
15
17
|
// --- connecting (pairing + email fallback) ---------------------------------
|
|
16
18
|
//
|
|
@@ -19,20 +21,6 @@ import {
|
|
|
19
21
|
// room, so an agent can never end up connected with nobody's say-so about where
|
|
20
22
|
// it pings. There is no `login` subcommand: `pingroom` resolves the state.
|
|
21
23
|
|
|
22
|
-
// The scopes this CLI can actually use, one per command surface. Requested at
|
|
23
|
-
// registration so the approval screen shows exactly what it is granting; the
|
|
24
|
-
// server intersects, so asking for less is always safe and asking for more than
|
|
25
|
-
// the human approves is impossible.
|
|
26
|
-
const CLI_SCOPES = [
|
|
27
|
-
'pingroom:rooms:read', // resolve/display the connected room
|
|
28
|
-
'pingroom:broadcast:send', // ping
|
|
29
|
-
'pingroom:attachments:write', // ping --attach (the upload leg)
|
|
30
|
-
'pingroom:notifications:read',// listen
|
|
31
|
-
'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
|
|
32
|
-
'pingroom:handoffs:create', // handoff / handoffs
|
|
33
|
-
'pingroom:live:write', // live start/update/end/get
|
|
34
|
-
];
|
|
35
|
-
|
|
36
24
|
// What the human reads on the approval screen. A product name, not a package
|
|
37
25
|
// id: the phone shows it verbatim ("PingRoom CLI wants to connect").
|
|
38
26
|
const AGENT_LABEL = 'PingRoom CLI';
|
|
@@ -724,3 +712,97 @@ export async function bare(args) {
|
|
|
724
712
|
|
|
725
713
|
return connect(args);
|
|
726
714
|
}
|
|
715
|
+
|
|
716
|
+
// --- reconnect --------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Re-pair an existing connection so it carries the scopes this CLI version
|
|
720
|
+
* needs. Ordered so that any failure is a no-op:
|
|
721
|
+
*
|
|
722
|
+
* 1. the old credential stays in place and keeps working throughout;
|
|
723
|
+
* 2. the human approves a NEW pairing — a separate registration, since the
|
|
724
|
+
* server puts no uniqueness on user_id, so both are live at once;
|
|
725
|
+
* 3. the new credential is written atomically (temp file + rename);
|
|
726
|
+
* 4. only THEN is the old one revoked.
|
|
727
|
+
*
|
|
728
|
+
* Cancelling, Ctrl-C, or any error before step 3 leaves the machine exactly as
|
|
729
|
+
* it was. Revocation is last and best-effort on purpose: a crash between the
|
|
730
|
+
* rename and the revoke leaves a stale-but-harmless registration the human can
|
|
731
|
+
* remove from Connected Agents, whereas the reverse order would leave a working
|
|
732
|
+
* credentials file holding a dead token.
|
|
733
|
+
*
|
|
734
|
+
* This is NOT `logout && pingroom`. `logout` only unlinks the local file; the
|
|
735
|
+
* server-side credential stays active forever, so that sequence leaks a live
|
|
736
|
+
* credential every time.
|
|
737
|
+
*/
|
|
738
|
+
export async function reconnect(args) {
|
|
739
|
+
if (args.help) { process.stdout.write(`${commandHelp('reconnect')}\n`); return EXIT.OK; }
|
|
740
|
+
|
|
741
|
+
// An env token is not ours to replace: it was pasted here from somewhere else
|
|
742
|
+
// (a CI secret, a password manager), the same registration is probably in use
|
|
743
|
+
// on other machines, and revoking it would break all of them from a terminal
|
|
744
|
+
// whose owner may not even know. Refuse rather than guess.
|
|
745
|
+
if (process.env.PINGROOM_TOKEN) {
|
|
746
|
+
fail(
|
|
747
|
+
'PINGROOM_TOKEN is set, and reconnect would revoke whatever credential it names.\n'
|
|
748
|
+
+ ' Unset it and run "pingroom reconnect" against the stored credential, or re-pair\n'
|
|
749
|
+
+ ' from scratch with "pingroom" and update the token where it is configured.',
|
|
750
|
+
EXIT.USAGE,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const stored = readStoredCredential();
|
|
755
|
+
if (!stored) {
|
|
756
|
+
fail(`not connected — there is no credential in ${credentialsPath()} to replace. Run "pingroom" to pair.`, EXIT.USAGE);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!isInteractive()) {
|
|
760
|
+
fail('reconnect needs an interactive terminal to show the QR code.', EXIT.USAGE);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const apiBase = resolveApiBase(args);
|
|
764
|
+
requireSafeUrl('--api', apiBase);
|
|
765
|
+
// The stored credential is about to be sent to `apiBase` in the revoke below,
|
|
766
|
+
// so it is bound by the same origin rule every other stored-bearer command
|
|
767
|
+
// obeys. Without this, `--api` (or a stale config.json api_url) redirects a
|
|
768
|
+
// live production credential to an arbitrary host.
|
|
769
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
770
|
+
|
|
771
|
+
process.stdout.write(' Reconnecting updates the permissions this CLI holds.\n');
|
|
772
|
+
process.stdout.write(' Your current connection keeps working until the new one is approved,\n');
|
|
773
|
+
process.stdout.write(' and is then revoked — any other machine or CI job using that same\n');
|
|
774
|
+
process.stdout.write(' credential will stop working.\n\n');
|
|
775
|
+
|
|
776
|
+
const prompter = createPrompter();
|
|
777
|
+
const ask = (question) => prompter.ask(question);
|
|
778
|
+
let cred;
|
|
779
|
+
try {
|
|
780
|
+
cred = await connectByPairing(apiBase, ask);
|
|
781
|
+
} finally {
|
|
782
|
+
prompter.close();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Declined, expired, or the user closed stdin: nothing was written, so the old
|
|
786
|
+
// credential is still the one on disk and still valid.
|
|
787
|
+
if (!cred) {
|
|
788
|
+
process.stdout.write(' Kept your current connection.\n');
|
|
789
|
+
return EXIT.EXPIRED;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The new credential is already durable (connectByPairing saved it). From
|
|
793
|
+
// here on, failing to revoke is untidy, not dangerous.
|
|
794
|
+
const { res, json, error } = await httpJson('POST', `${apiBase}/api/agent/auth/revoke`, {
|
|
795
|
+
headers: { Authorization: `Bearer ${stored.token}` },
|
|
796
|
+
body: {},
|
|
797
|
+
soft: true,
|
|
798
|
+
});
|
|
799
|
+
if (error || !res || !res.ok) {
|
|
800
|
+
const detail = error ? error.message : apiDetail(res, json);
|
|
801
|
+
process.stdout.write(` Note: the previous connection could not be revoked (${detail}).\n`);
|
|
802
|
+
process.stdout.write(' Remove it from PingRoom → Settings → Connected Agents when convenient.\n');
|
|
803
|
+
return EXIT.OK;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
process.stdout.write(' Previous connection revoked.\n');
|
|
807
|
+
return EXIT.OK;
|
|
808
|
+
}
|
package/lib/commands/hook.js
CHANGED
|
@@ -5,6 +5,7 @@ import { truncate } from '../util.js';
|
|
|
5
5
|
import { commandHelp } from '../help.js';
|
|
6
6
|
import { hookFetch, isSafeUrl } from '../http.js';
|
|
7
7
|
import { resolveApiBase, resolveRoom, resolveToken, storedCredentialOriginError } from '../config.js';
|
|
8
|
+
import { forgetContinuation, recordContinuation } from '../continuations.js';
|
|
8
9
|
import { VERSION } from '../version.js';
|
|
9
10
|
|
|
10
11
|
// --- hook (Claude Code integration) ----------------------------------------
|
|
@@ -139,8 +140,11 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
|
139
140
|
process.on('SIGTERM', onSignal);
|
|
140
141
|
|
|
141
142
|
try {
|
|
143
|
+
// `data` is echoed into every room member's push AND into the room's
|
|
144
|
+
// outgoing webhook, so it carries only what a recipient should see. The
|
|
145
|
+
// working directory is a local filesystem path: it goes to
|
|
146
|
+
// ~/.pingroom/continuations.json below, never on the wire.
|
|
142
147
|
const data = { tool_name: String(toolName) };
|
|
143
|
-
if (event.cwd) data.cwd = String(event.cwd);
|
|
144
148
|
const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
|
|
145
149
|
token,
|
|
146
150
|
body: {
|
|
@@ -161,7 +165,16 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
|
161
165
|
return EXIT.OK;
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
// Where this session would need to resume from, kept locally. Best-effort:
|
|
169
|
+
// a failure here must not change the tool decision.
|
|
170
|
+
recordContinuation(questionId, {
|
|
171
|
+
sessionId: event.session_id,
|
|
172
|
+
cwd: event.cwd,
|
|
173
|
+
transcriptPath: event.transcript_path,
|
|
174
|
+
});
|
|
175
|
+
|
|
164
176
|
const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
|
|
177
|
+
forgetContinuation(questionId);
|
|
165
178
|
if (resolved.state === 'answered') {
|
|
166
179
|
const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
|
|
167
180
|
if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
|
|
@@ -210,9 +223,11 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
|
210
223
|
return EXIT.OK; // unknown event — stay silent rather than send noise
|
|
211
224
|
}
|
|
212
225
|
|
|
226
|
+
// Same rule as the question path: nothing here is private to this machine.
|
|
227
|
+
// `session_id` is already the ping's correlation_id, so it is on the wire by
|
|
228
|
+
// design; `cwd` is a local path and stays local.
|
|
213
229
|
const data = { event: name };
|
|
214
230
|
if (event.session_id) data.session_id = String(event.session_id);
|
|
215
|
-
if (event.cwd) data.cwd = String(event.cwd);
|
|
216
231
|
|
|
217
232
|
try {
|
|
218
233
|
await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
|
package/lib/commands/manage.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// response; the default prints a compact human line per record).
|
|
5
5
|
|
|
6
6
|
import { EXIT } from '../constants.js';
|
|
7
|
-
import { fail } from '../util.js';
|
|
7
|
+
import { applyIdempotencyKey, fail, requireMaxLength, resolveWaitHold } from '../util.js';
|
|
8
8
|
import { commandHelp } from '../help.js';
|
|
9
9
|
import { apiDetail, httpJson } from '../http.js';
|
|
10
10
|
import { agentContext } from '../config.js';
|
|
11
|
+
import { APPROVAL_OPTIONS, exitForApproval, printApproval } from '../render.js';
|
|
12
|
+
import { waitForResolution } from '../question-wait.js';
|
|
11
13
|
|
|
12
14
|
function sub(args, allowed, noun) {
|
|
13
15
|
const name = args._[0];
|
|
@@ -182,43 +184,48 @@ export async function actions(args) {
|
|
|
182
184
|
|
|
183
185
|
// ------------------------------------------------------------- approval
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The deploy gate. An approval is the canonical two-option Question, so this
|
|
189
|
+
* creates one rather than using the older `/approvals` endpoint: only Questions
|
|
190
|
+
* reach the phone with real Approve/Deny buttons on the lock screen, and only
|
|
191
|
+
* Questions get idempotency, the expiry sweep and the resolution webhook.
|
|
192
|
+
*/
|
|
187
193
|
export async function approval(args) {
|
|
188
194
|
if (args.help) { process.stdout.write(`${commandHelp('approval')}\n`); return EXIT.OK; }
|
|
189
195
|
|
|
190
196
|
if (!args.prompt) fail('an approval needs --prompt', EXIT.USAGE);
|
|
197
|
+
requireMaxLength(args.prompt, 500, '--prompt');
|
|
198
|
+
requireMaxLength(args.context, 40, '--context');
|
|
199
|
+
|
|
191
200
|
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
192
201
|
|
|
193
|
-
const body = { prompt: args.prompt };
|
|
202
|
+
const body = { prompt: args.prompt, options: APPROVAL_OPTIONS };
|
|
194
203
|
if (args.context) body.context = args.context;
|
|
195
204
|
if (args.ttl !== undefined) {
|
|
196
205
|
if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
|
|
197
206
|
body.ttl = Number(args.ttl);
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
const headers = applyIdempotencyKey(args, auth(token));
|
|
210
|
+
|
|
211
|
+
// Pre-flight: reject a bad --timeout before the approval is on someone's phone.
|
|
212
|
+
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
|
213
|
+
|
|
200
214
|
const { text, json } = await requireOk(
|
|
201
|
-
httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/
|
|
215
|
+
httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, { headers, body }),
|
|
202
216
|
'approval',
|
|
203
217
|
);
|
|
204
218
|
|
|
205
219
|
if (!args.wait) {
|
|
206
|
-
process.stdout.write(`${text}\n`);
|
|
220
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
221
|
+
else process.stdout.write(`${json.id}\n`);
|
|
207
222
|
return EXIT.OK;
|
|
208
223
|
}
|
|
209
224
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
}
|
|
225
|
+
return waitForResolution(json.id, args, { token, apiBase }, {
|
|
226
|
+
exitFor: exitForApproval,
|
|
227
|
+
print: printApproval,
|
|
228
|
+
});
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
// ----------------------------------------------------------- attachment
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Where a blocked agent left off, kept on THIS machine only.
|
|
2
|
+
//
|
|
3
|
+
// When a hook turns a tool-permission prompt into a PingRoom question, the
|
|
4
|
+
// thing that will eventually need to resume — the Claude Code session, its
|
|
5
|
+
// working directory, its transcript — is local. The server never needs to know
|
|
6
|
+
// any of it: the question already carries `correlation_id = session_id`, which
|
|
7
|
+
// is enough to match an answer back to a row here.
|
|
8
|
+
//
|
|
9
|
+
// That is deliberate, not incidental. A question's `data` object is echoed into
|
|
10
|
+
// every room member's push payload AND into the room's outgoing webhook, so a
|
|
11
|
+
// `cwd` put there would publish a local filesystem path to everyone in the room
|
|
12
|
+
// and to whatever URL the room forwards to. Keeping the record local leaks
|
|
13
|
+
// nothing and needs no wire contract.
|
|
14
|
+
//
|
|
15
|
+
// Nothing consumes this yet. It exists so that resuming an ended session is a
|
|
16
|
+
// matter of writing the consumer, not of changing the protocol — and until that
|
|
17
|
+
// consumer exists, PingRoom only claims to block an agent that is still running.
|
|
18
|
+
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { chmodSync, closeSync, fchmodSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { pingroomHome, readJsonFile } from './config.js';
|
|
24
|
+
|
|
25
|
+
/** Enough for any plausible backlog of open questions on one machine. */
|
|
26
|
+
const MAX_ENTRIES = 100;
|
|
27
|
+
/** A question cannot outlive its TTL by this much; anything older is dead. */
|
|
28
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
export function continuationsPath() { return join(pingroomHome(), 'continuations.json'); }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Atomic write that REPORTS failure instead of exiting.
|
|
34
|
+
*
|
|
35
|
+
* config.js's writeJsonFile calls fail() — correct when a human ran `config
|
|
36
|
+
* set` and needs to know it didn't take, fatal here: this is called from the
|
|
37
|
+
* hook, which must never break the agent it is advising. A full disk loses a
|
|
38
|
+
* resume hint; it must not kill the session.
|
|
39
|
+
*/
|
|
40
|
+
function writeQuietly(path, value) {
|
|
41
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
42
|
+
let fd;
|
|
43
|
+
try {
|
|
44
|
+
const created = mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
|
|
45
|
+
if (created !== undefined) chmodSync(pingroomHome(), 0o700);
|
|
46
|
+
fd = openSync(tmp, 'wx', 0o600);
|
|
47
|
+
fchmodSync(fd, 0o600);
|
|
48
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
fd = undefined;
|
|
51
|
+
renameSync(tmp, path);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
55
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function load() {
|
|
61
|
+
const stored = readJsonFile(continuationsPath());
|
|
62
|
+
const entries = stored && typeof stored.entries === 'object' && !Array.isArray(stored.entries)
|
|
63
|
+
? stored.entries
|
|
64
|
+
: {};
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Drop expired rows, then the oldest ones, so the file cannot grow forever. */
|
|
69
|
+
function bound(entries, now) {
|
|
70
|
+
const live = Object.entries(entries).filter(([, e]) => {
|
|
71
|
+
const at = Date.parse(e && e.recorded_at);
|
|
72
|
+
return Number.isFinite(at) && now - at < MAX_AGE_MS;
|
|
73
|
+
});
|
|
74
|
+
live.sort((a, b) => Date.parse(a[1].recorded_at) - Date.parse(b[1].recorded_at));
|
|
75
|
+
return Object.fromEntries(live.slice(-MAX_ENTRIES));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Remember where to come back to for one question. Best-effort by design: the
|
|
80
|
+
* caller is a hook that has already decided the agent proceeds either way.
|
|
81
|
+
*/
|
|
82
|
+
export function recordContinuation(questionId, { sessionId, cwd, transcriptPath } = {}) {
|
|
83
|
+
if (!questionId || typeof questionId !== 'string') return false;
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const entries = bound(load(), now);
|
|
86
|
+
entries[questionId] = {
|
|
87
|
+
recorded_at: new Date(now).toISOString(),
|
|
88
|
+
...(sessionId ? { session_id: String(sessionId) } : {}),
|
|
89
|
+
...(cwd ? { cwd: String(cwd) } : {}),
|
|
90
|
+
...(transcriptPath ? { transcript_path: String(transcriptPath) } : {}),
|
|
91
|
+
};
|
|
92
|
+
return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, now) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The record for one question, or null. */
|
|
96
|
+
export function readContinuation(questionId) {
|
|
97
|
+
return load()[questionId] ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Forget one question once it has resolved — the hint has done its job. */
|
|
101
|
+
export function forgetContinuation(questionId) {
|
|
102
|
+
const entries = load();
|
|
103
|
+
if (!(questionId in entries)) return false;
|
|
104
|
+
delete entries[questionId];
|
|
105
|
+
return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, Date.now()) });
|
|
106
|
+
}
|
package/lib/help.js
CHANGED
|
@@ -30,6 +30,7 @@ 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
|
|
34
35
|
rooms List, inspect, create, or join rooms (rooms list|get|create|join)
|
|
35
36
|
webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
|
|
@@ -37,6 +38,7 @@ Commands:
|
|
|
37
38
|
approval Send an approve/deny request; with --wait, block on the decision
|
|
38
39
|
attachment Download or delete an attachment by id (attachment get|delete)
|
|
39
40
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
41
|
+
reconnect Re-approve this CLI so it holds every permission it needs
|
|
40
42
|
logout Forget the stored credential`;
|
|
41
43
|
|
|
42
44
|
export const HELP_PING = `ping options:
|
|
@@ -156,6 +158,13 @@ export const HELP_MCP = `mcp:
|
|
|
156
158
|
pingroom mcp add claude-code Print the Claude Code setup command
|
|
157
159
|
(output-only; does not change client config)`;
|
|
158
160
|
|
|
161
|
+
export const HELP_SKILLS = `skills:
|
|
162
|
+
pingroom skills List the published agent skills and every
|
|
163
|
+
install route (output-only)
|
|
164
|
+
pingroom skills install Copy them into ~/.claude/skills (needs git)
|
|
165
|
+
--dir <path> Install somewhere other than ~/.claude/skills
|
|
166
|
+
--force Replace skills that are already installed`;
|
|
167
|
+
|
|
159
168
|
export const HELP_ACTIVATE = `activate:
|
|
160
169
|
pingroom activate Send one test Question to your phone to prove the
|
|
161
170
|
saved QR-paired credential works (optional —
|
|
@@ -278,7 +287,7 @@ human decision is not an infrastructure failure.`;
|
|
|
278
287
|
|
|
279
288
|
export const HELP = [
|
|
280
289
|
HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
|
|
281
|
-
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
|
|
290
|
+
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_SKILLS, HELP_ACTIVATE, HELP_CONFIG,
|
|
282
291
|
HELP_SHARED, HELP_TAIL,
|
|
283
292
|
].join('\n\n');
|
|
284
293
|
|
|
@@ -299,6 +308,7 @@ export const COMMAND_HELP_SECTIONS = {
|
|
|
299
308
|
listen: HELP_LISTEN,
|
|
300
309
|
live: HELP_LIVE,
|
|
301
310
|
hook: HELP_HOOK,
|
|
311
|
+
skills: HELP_SKILLS,
|
|
302
312
|
activate: HELP_ACTIVATE,
|
|
303
313
|
rooms: `rooms (agent token required):
|
|
304
314
|
pingroom rooms list List the rooms this account belongs to
|
|
@@ -323,18 +333,34 @@ export const COMMAND_HELP_SECTIONS = {
|
|
|
323
333
|
pingroom actions trigger <1-4> --room <code>`,
|
|
324
334
|
approval: `approval (agent token + --room required):
|
|
325
335
|
pingroom approval -p <prompt> Send an approve/deny request
|
|
336
|
+
Prints the id; --wait prints the decision
|
|
326
337
|
-c, --context <text> Secondary line (<= 40 chars)
|
|
327
338
|
--ttl <seconds> Expiry; omit for the server default
|
|
328
|
-
--
|
|
329
|
-
|
|
339
|
+
--idempotency-key <k> Safe replay of the create after a network failure
|
|
340
|
+
--wait Block until the human decides
|
|
341
|
+
(exit 0 approve · 4 deny/cancelled · 3 expired)
|
|
342
|
+
--timeout <0-30> Seconds per long-poll while waiting (default 25)`,
|
|
330
343
|
attachment: `attachment (agent token required):
|
|
331
344
|
pingroom attachment get <id> [--out <path>]
|
|
332
345
|
Download; bytes go to --out or stdout
|
|
333
346
|
pingroom attachment delete <id> Delete an unclaimed upload`,
|
|
334
347
|
config: HELP_CONFIG,
|
|
348
|
+
reconnect: `reconnect:
|
|
349
|
+
pingroom reconnect Re-approve this CLI with the permissions the
|
|
350
|
+
current version needs, then revoke the old
|
|
351
|
+
connection. Your existing connection keeps
|
|
352
|
+
working until you approve the new one, and
|
|
353
|
+
cancelling changes nothing.
|
|
354
|
+
Any other machine or CI job sharing the same
|
|
355
|
+
credential will stop working once it is revoked.
|
|
356
|
+
--api <url> API base URL (default https://api.pingroom.io)`,
|
|
335
357
|
logout: `logout:
|
|
336
358
|
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
|
337
|
-
in the environment is unaffected)
|
|
359
|
+
in the environment is unaffected).
|
|
360
|
+
This is LOCAL ONLY — the connection stays
|
|
361
|
+
active on the server. To replace it, use
|
|
362
|
+
"pingroom reconnect"; to end it, revoke it in
|
|
363
|
+
PingRoom -> Settings -> Connected Agents.`,
|
|
338
364
|
};
|
|
339
365
|
|
|
340
366
|
// config and logout are local-only commands that reject --token/--api (and,
|
|
@@ -346,6 +372,14 @@ export const COMMAND_HELP_FOOTERS = {
|
|
|
346
372
|
-h, --help Show this help`,
|
|
347
373
|
logout: `Shared:
|
|
348
374
|
-h, --help Show this help`,
|
|
375
|
+
// reconnect drives the QR pairing flow; a --token would have nothing to do
|
|
376
|
+
// with the credential it is about to replace.
|
|
377
|
+
reconnect: `Shared:
|
|
378
|
+
-h, --help Show this help`,
|
|
379
|
+
// `skills` reaches GitHub, never the PingRoom API, so the shared credential
|
|
380
|
+
// and --json flags would all be lies here.
|
|
381
|
+
skills: `Shared:
|
|
382
|
+
-h, --help Show this help`,
|
|
349
383
|
};
|
|
350
384
|
|
|
351
385
|
// `<command> --help`: that command's section plus the shared flags, instead of
|
package/lib/http.js
CHANGED
|
@@ -13,11 +13,16 @@ import { readStrictJsonResponse } from './strict-json.js';
|
|
|
13
13
|
*/
|
|
14
14
|
const API_HINTS = {
|
|
15
15
|
room_not_granted:
|
|
16
|
-
'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom" to
|
|
16
|
+
'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom reconnect" to re-approve and pick it.',
|
|
17
|
+
// The remedy has to be `reconnect`, not `pingroom`: on a machine that is
|
|
18
|
+
// already connected, bare `pingroom` just prints the status line and the help,
|
|
19
|
+
// so it reads as a fix and does nothing. Consent is an intersection
|
|
20
|
+
// server-side, so a scope the old pairing never requested can only be gained
|
|
21
|
+
// by approving a new one.
|
|
17
22
|
insufficient_scope:
|
|
18
|
-
'This credential was approved before the
|
|
23
|
+
'This credential was approved before the CLI needed that permission. Run "pingroom reconnect" to re-approve with the current permissions — your existing connection keeps working until you approve the new one.',
|
|
19
24
|
no_room_configured:
|
|
20
|
-
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
|
|
25
|
+
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app, or — if it was granted every room — let it create one with "pingroom rooms create".',
|
|
21
26
|
};
|
|
22
27
|
|
|
23
28
|
/**
|
package/lib/parser.js
CHANGED
|
@@ -205,6 +205,15 @@ export const parseConfigArgs = makeParser({
|
|
|
205
205
|
bareDashIsPositional: true,
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
// reconnect re-runs the pairing flow against the stored credential. It takes no
|
|
209
|
+
// --token deliberately: the credential it replaces is the one on disk, and an
|
|
210
|
+
// env token is refused outright by the command.
|
|
211
|
+
export const parseReconnectArgs = makeParser({
|
|
212
|
+
aliases: { '--api': 'api', '-h': 'help', '--help': 'help' },
|
|
213
|
+
booleans: ['help'],
|
|
214
|
+
bareDashIsPositional: true,
|
|
215
|
+
});
|
|
216
|
+
|
|
208
217
|
export const parseLogoutArgs = makeParser({
|
|
209
218
|
aliases: { '-h': 'help', '--help': 'help' },
|
|
210
219
|
booleans: ['help'],
|
|
@@ -214,6 +223,18 @@ export const parseLogoutArgs = makeParser({
|
|
|
214
223
|
// Parser for the management nouns (rooms / webhooks / actions / approval /
|
|
215
224
|
// attachment). One shared vocabulary: each sub-command reads the flags it
|
|
216
225
|
// needs and the rest are usage errors at the command layer, same as everywhere.
|
|
226
|
+
// `skills` takes no credential and touches no API: only where to install and
|
|
227
|
+
// whether to replace what is already there. A dedicated vocabulary keeps
|
|
228
|
+
// --force from leaking into the parsers whose commands send real pings.
|
|
229
|
+
export const parseSkillsArgs = makeParser({
|
|
230
|
+
aliases: {
|
|
231
|
+
'--dir': 'dir',
|
|
232
|
+
'--force': 'force',
|
|
233
|
+
'-h': 'help', '--help': 'help',
|
|
234
|
+
},
|
|
235
|
+
booleans: ['force', 'help'],
|
|
236
|
+
});
|
|
237
|
+
|
|
217
238
|
export const parseManageArgs = makeParser({
|
|
218
239
|
aliases: {
|
|
219
240
|
'-n': 'name', '--name': 'name',
|
|
@@ -235,6 +256,8 @@ export const parseManageArgs = makeParser({
|
|
|
235
256
|
'--require-ack': 'require_ack',
|
|
236
257
|
'--out': 'out',
|
|
237
258
|
'--wait': 'wait',
|
|
259
|
+
'--timeout': 'timeout',
|
|
260
|
+
'--idempotency-key': 'idempotency_key',
|
|
238
261
|
'--token': 'token',
|
|
239
262
|
'--room': 'room',
|
|
240
263
|
'--expected-room-sha256': 'expected_room_sha256',
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The one long-poll loop behind every command that blocks on a human: `ask`,
|
|
2
|
+
// `watch`, and `approval`. It lives here rather than in ask.js so the hot-loop
|
|
3
|
+
// floor and the terminal-state handling cannot drift apart — `approval` shipped
|
|
4
|
+
// its own copy of this loop once, and that copy read a field the server never
|
|
5
|
+
// sends, so it spun forever after the human had already decided.
|
|
6
|
+
|
|
7
|
+
import { fail, resolveWaitHold, sleep } from './util.js';
|
|
8
|
+
import { apiDetail, httpJson } from './http.js';
|
|
9
|
+
import { exitForState, printResolution } from './render.js';
|
|
10
|
+
import { writeGitHubQuestionOutputs } from './github-output.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Long-poll until the question leaves `pending`, then print and return its exit
|
|
14
|
+
* code. The server expires it at its ttl, so this always terminates.
|
|
15
|
+
*
|
|
16
|
+
* `exitFor` and `print` take the whole resolved question, not just its state:
|
|
17
|
+
* a deploy gate has to read the chosen *value* to tell approve from deny, which
|
|
18
|
+
* the state alone ("answered") cannot express.
|
|
19
|
+
*/
|
|
20
|
+
export async function waitForResolution(id, args, { token, apiBase }, opts = {}) {
|
|
21
|
+
const exitFor = opts.exitFor ?? ((q) => exitForState(q.state));
|
|
22
|
+
const print = opts.print ?? printResolution;
|
|
23
|
+
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
24
|
+
|
|
25
|
+
for (;;) {
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
28
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const detail = apiDetail(res, json);
|
|
31
|
+
fail(`wait failed: ${detail}`);
|
|
32
|
+
}
|
|
33
|
+
if (json && json.state && json.state !== 'pending') {
|
|
34
|
+
if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
|
|
35
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
36
|
+
else print(json);
|
|
37
|
+
return exitFor(json);
|
|
38
|
+
}
|
|
39
|
+
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
40
|
+
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
41
|
+
// would otherwise be hammered at full speed.
|
|
42
|
+
const elapsed = Date.now() - started;
|
|
43
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -116,6 +116,45 @@ export function printResolution(q) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The two options behind `pingroom approval`. An approval IS a two-option
|
|
121
|
+
* Question — same wire shape, same lock-screen buttons, same first-answer-wins
|
|
122
|
+
* — so the command builds one rather than reaching for the older Approvals API,
|
|
123
|
+
* which delivers no answer actions to the phone at all.
|
|
124
|
+
*/
|
|
125
|
+
export const APPROVAL_OPTIONS = Object.freeze([
|
|
126
|
+
Object.freeze({ value: 'approve', label: 'Approve', style: 'primary' }),
|
|
127
|
+
Object.freeze({ value: 'deny', label: 'Deny', style: 'danger' }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A deploy gate reads the chosen VALUE, not just the state.
|
|
132
|
+
*
|
|
133
|
+
* `exitForState` maps every answer to 0, which is right for `ask` (a human
|
|
134
|
+
* answering "hold" is not an infra failure) and catastrophic for a gate:
|
|
135
|
+
* `if pingroom approval --wait; then deploy; fi` must refuse on a denial.
|
|
136
|
+
* So `approve` is 0 and anything else answered is 4, and only the non-answered
|
|
137
|
+
* states fall through to the shared mapping.
|
|
138
|
+
*/
|
|
139
|
+
export function exitForApproval(q) {
|
|
140
|
+
if (q.state === 'answered') {
|
|
141
|
+
const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
|
|
142
|
+
return value === 'approve' ? EXIT.OK : EXIT.CANCELLED;
|
|
143
|
+
}
|
|
144
|
+
return exitForState(q.state);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Same split as printResolution: the decision goes to stdout so `$(...)` can
|
|
148
|
+
// capture it, every other outcome reports to stderr and leaves stdout empty.
|
|
149
|
+
export function printApproval(q) {
|
|
150
|
+
if (q.state === 'answered') {
|
|
151
|
+
const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
|
|
152
|
+
process.stdout.write(`${value}\n`);
|
|
153
|
+
} else {
|
|
154
|
+
process.stderr.write(`pingroom: approval ${q.state}\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
/** One readable line per incoming ping. */
|
|
120
159
|
export function formatIncoming(item) {
|
|
121
160
|
const room = item?.room?.name || item?.room?.code || '?';
|
package/lib/scopes.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// What the official CLI asks for, and what each command actually needs.
|
|
2
|
+
//
|
|
3
|
+
// One pairing approval enables every command PingRoom ships. That is a
|
|
4
|
+
// deliberate reading of OAuth's minimum-required principle (RFC 9700 §2.3): the
|
|
5
|
+
// unit being authorized here is the complete official CLI, not the server's
|
|
6
|
+
// whole capability surface. Consent is an intersection server-side
|
|
7
|
+
// (AgentAuthController::pairingClaim), so this list is a hard CEILING — a scope
|
|
8
|
+
// missing here can never be granted, and the only remedy is a fresh pairing.
|
|
9
|
+
// That is why the set is not trimmed to "what you happen to run today".
|
|
10
|
+
//
|
|
11
|
+
// Two catalog scopes are deliberately absent:
|
|
12
|
+
// pingroom:profile:write — no CLI command sets the agent avatar or handle.
|
|
13
|
+
// pingroom:agents:ping — retired; the route answers 410.
|
|
14
|
+
//
|
|
15
|
+
// Ordered by the group the consent screen shows, so the approval reads as three
|
|
16
|
+
// coherent sections even on a client that has not shipped the grouping yet.
|
|
17
|
+
export const CLI_SCOPES = [
|
|
18
|
+
// Communication and human decisions
|
|
19
|
+
'pingroom:broadcast:send', // ping
|
|
20
|
+
'pingroom:attachments:write', // ping --attach (upload), attachment delete
|
|
21
|
+
'pingroom:notifications:read',// listen, attachment get
|
|
22
|
+
'pingroom:questions:ask', // ask / watch / cancel / list, approval, the hook
|
|
23
|
+
'pingroom:approvals:request', // the legacy approvals surface (SDK/MCP parity)
|
|
24
|
+
'pingroom:handoffs:create', // handoff / handoffs / activate
|
|
25
|
+
'pingroom:live:write', // live start/update/end/get
|
|
26
|
+
// Rooms and quick actions
|
|
27
|
+
'pingroom:rooms:read', // rooms list/get, actions list, room icons
|
|
28
|
+
'pingroom:rooms:write', // rooms create
|
|
29
|
+
'pingroom:rooms:publish', // rooms create --public
|
|
30
|
+
'pingroom:rooms:join', // rooms join
|
|
31
|
+
'pingroom:actions:trigger', // actions trigger
|
|
32
|
+
'pingroom:actions:write', // actions set
|
|
33
|
+
// Webhooks
|
|
34
|
+
'pingroom:webhooks:read', // webhooks list
|
|
35
|
+
'pingroom:webhooks:write', // webhooks create/update
|
|
36
|
+
'pingroom:webhooks:delete', // webhooks delete
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Command -> every scope any of its verbs can need, taken from the route
|
|
41
|
+
* middleware in laravel/routes/api.php.
|
|
42
|
+
*
|
|
43
|
+
* This exists to be TESTED, not consulted at runtime for authorization: the
|
|
44
|
+
* server is the authority. `test/cli.test.mjs` asserts both directions — every
|
|
45
|
+
* dispatched command appears here, and every scope named here is in
|
|
46
|
+
* CLI_SCOPES — so a new command whose scope nobody requested cannot ship. That
|
|
47
|
+
* check is the thing that would have caught `approval` 403ing on a fresh
|
|
48
|
+
* pairing for as long as it did.
|
|
49
|
+
*
|
|
50
|
+
* An empty array means the command touches no agent-authenticated route.
|
|
51
|
+
*/
|
|
52
|
+
export const COMMAND_SCOPES = {
|
|
53
|
+
ping: ['pingroom:broadcast:send', 'pingroom:attachments:write'],
|
|
54
|
+
ask: ['pingroom:questions:ask'],
|
|
55
|
+
watch: ['pingroom:questions:ask'],
|
|
56
|
+
await: ['pingroom:questions:ask'],
|
|
57
|
+
cancel: ['pingroom:questions:ask'],
|
|
58
|
+
list: ['pingroom:questions:ask'],
|
|
59
|
+
approval: ['pingroom:questions:ask'],
|
|
60
|
+
handoff: ['pingroom:handoffs:create'],
|
|
61
|
+
handoffs: ['pingroom:handoffs:create'],
|
|
62
|
+
activate: ['pingroom:handoffs:create'],
|
|
63
|
+
listen: ['pingroom:notifications:read'],
|
|
64
|
+
hook: ['pingroom:questions:ask', 'pingroom:broadcast:send'],
|
|
65
|
+
live: ['pingroom:live:write'],
|
|
66
|
+
rooms: [
|
|
67
|
+
'pingroom:rooms:read', 'pingroom:rooms:write',
|
|
68
|
+
'pingroom:rooms:publish', 'pingroom:rooms:join',
|
|
69
|
+
],
|
|
70
|
+
actions: ['pingroom:rooms:read', 'pingroom:actions:write', 'pingroom:actions:trigger'],
|
|
71
|
+
webhooks: ['pingroom:webhooks:read', 'pingroom:webhooks:write', 'pingroom:webhooks:delete'],
|
|
72
|
+
attachment: ['pingroom:notifications:read', 'pingroom:attachments:write'],
|
|
73
|
+
// Local-only: no agent-authenticated route, so nothing to request.
|
|
74
|
+
mcp: [],
|
|
75
|
+
skills: [],
|
|
76
|
+
config: [],
|
|
77
|
+
logout: [],
|
|
78
|
+
reconnect: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scopes a command needs that this credential was never granted.
|
|
83
|
+
*
|
|
84
|
+
* Returns [] whenever the answer is not knowable — no stored scope list at all
|
|
85
|
+
* (a PINGROOM_TOKEN in CI has none), or an unrecognized command. That is the
|
|
86
|
+
* whole contract: this is a courtesy that turns a 403 into a sentence naming
|
|
87
|
+
* the fix, never a gate. Refusing on unknown scopes would break every CI job
|
|
88
|
+
* holding a perfectly good token.
|
|
89
|
+
*/
|
|
90
|
+
export function missingScopesFor(command, grantedScopes) {
|
|
91
|
+
if (!Array.isArray(grantedScopes) || grantedScopes.length === 0) return [];
|
|
92
|
+
const needed = COMMAND_SCOPES[command];
|
|
93
|
+
if (!needed || needed.length === 0) return [];
|
|
94
|
+
return needed.filter((scope) => !grantedScopes.includes(scope));
|
|
95
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/util.js
CHANGED
|
@@ -73,6 +73,24 @@ export function resolveWaitHold(args, { def, cap }) {
|
|
|
73
73
|
return Math.min(hold, cap);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Validate --idempotency-key and stamp it on the outgoing headers.
|
|
78
|
+
*
|
|
79
|
+
* Creating a human gate is the one CLI write a caller genuinely wants to replay
|
|
80
|
+
* after an ambiguous transport failure, so the key must survive a shell round
|
|
81
|
+
* trip intact: printable ASCII, no spaces. The server returns 409 if the same
|
|
82
|
+
* key is ever reused for a different payload.
|
|
83
|
+
*/
|
|
84
|
+
export function applyIdempotencyKey(args, headers) {
|
|
85
|
+
if (args.idempotency_key === undefined) return headers;
|
|
86
|
+
const key = String(args.idempotency_key);
|
|
87
|
+
if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
|
|
88
|
+
fail('--idempotency-key must be 1–255 printable ASCII characters without spaces', EXIT.USAGE);
|
|
89
|
+
}
|
|
90
|
+
headers['Idempotency-Key'] = key;
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
export function numberOption(raw, flag, { min, max, integer = false } = {}) {
|
|
77
95
|
if (raw === undefined) return undefined;
|
|
78
96
|
const value = Number(raw);
|