@pingroom/cli 0.7.2 → 0.7.3
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 +41 -11
- package/bin/pingroom.js +22 -3016
- package/lib/commands/ask.js +146 -0
- package/lib/commands/config.js +114 -0
- package/lib/commands/connect.js +726 -0
- package/lib/commands/handoff.js +149 -0
- package/lib/commands/hook.js +301 -0
- package/lib/commands/listen.js +83 -0
- package/lib/commands/live.js +165 -0
- package/lib/commands/mcp.js +47 -0
- package/lib/commands/ping.js +123 -0
- package/lib/config.js +206 -0
- package/lib/constants.js +7 -0
- package/lib/github-output.js +76 -0
- package/lib/help.js +305 -0
- package/lib/http.js +214 -0
- package/lib/parser.js +214 -0
- package/lib/render.js +174 -0
- package/lib/util.js +105 -0
- package/lib/version.js +10 -0
- package/package.json +3 -2
package/lib/help.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// All --help output. One section per command plus intro/shared/tail, so
|
|
2
|
+
// `pingroom <command> --help` can print a focused excerpt (see commandHelp) and
|
|
3
|
+
// `pingroom --help` still prints the historical single blob.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here is decorative: the subprocess tests assert this text verbatim.
|
|
6
|
+
|
|
7
|
+
import { BUILTIN_API, DEFAULT_API } from './constants.js';
|
|
8
|
+
|
|
9
|
+
// The help text lives as one section per command plus intro/shared/tail, so
|
|
10
|
+
// `pingroom <command> --help` can print a focused excerpt (see commandHelp).
|
|
11
|
+
// The full HELP below joins them in the historical order — `pingroom --help`
|
|
12
|
+
// output is byte-identical to the pre-split single blob.
|
|
13
|
+
export const HELP_INTRO = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
pingroom <command> [options]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
ping Send a ping to a room (webhook URL, or agent token + room)
|
|
20
|
+
ask Ask a human a question; with --wait, block until they answer
|
|
21
|
+
watch Block until a question resolves and print the outcome (alias: await)
|
|
22
|
+
list List the agent's questions by state
|
|
23
|
+
cancel Withdraw a pending question
|
|
24
|
+
handoff Hand a decision (ack or question) to a specific human; with --wait,
|
|
25
|
+
block until they acknowledge or answer
|
|
26
|
+
handoffs List the agent's open handoffs or bounded recent history
|
|
27
|
+
listen Block on pings arriving in your rooms and print them as they land
|
|
28
|
+
live Drive a live progress card on the lock screen (Live Activity)
|
|
29
|
+
hook Claude Code hook: ping on Stop/Notification, and route tool
|
|
30
|
+
permission prompts to a PingRoom question you answer from your phone
|
|
31
|
+
mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
|
|
32
|
+
Claude Desktop
|
|
33
|
+
activate Retry Agent Inbox activation with the saved QR-paired credential
|
|
34
|
+
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
35
|
+
logout Forget the stored credential`;
|
|
36
|
+
|
|
37
|
+
export const HELP_PING = `ping options:
|
|
38
|
+
-m, --message <text> Ping body text (required)
|
|
39
|
+
-t, --title <text> Ping title (<= 40 chars)
|
|
40
|
+
-a, --action <1-4> Quick-action slot to attribute the ping to
|
|
41
|
+
-d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
|
|
42
|
+
--url <https-url> Make the ping a tappable link (absolute http(s) URL)
|
|
43
|
+
--button-label <t> Link button text (<= 26 chars; requires --url)
|
|
44
|
+
--require-ack Keep the ping open until an eligible recipient acknowledges it
|
|
45
|
+
--ack-timeout <s> Ack deadline in seconds (requires --require-ack)
|
|
46
|
+
--attach <path> Attach a file (md/pdf/html/txt/jpg/jpeg/png/zip, <= 5 MiB);
|
|
47
|
+
repeat for up to 4. Requires --token and a Pro account
|
|
48
|
+
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
49
|
+
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
50
|
+
--room <code> Room invite code (used with --token)`;
|
|
51
|
+
|
|
52
|
+
export const HELP_ASK = `ask options (agent token required):
|
|
53
|
+
-p, --prompt <text> The question a human reads (required)
|
|
54
|
+
-o, --option <v:label[:style]>
|
|
55
|
+
An answer option (style: primary|danger|default);
|
|
56
|
+
repeat for 2–4. Omit for Approve/Deny
|
|
57
|
+
-c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
|
|
58
|
+
--scope <s> Who answers: 'direct' (default) or 'room'
|
|
59
|
+
--target <uuid> For --scope direct: a specific room member
|
|
60
|
+
--ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
|
|
61
|
+
--text-input <ph> Invite a short typed answer; <ph> is the placeholder
|
|
62
|
+
--text-max <n> Max typed-answer length (1..60)
|
|
63
|
+
--wait Block until answered/expired/cancelled
|
|
64
|
+
--timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
|
|
65
|
+
-d, --data <json> Structured data object echoed back on the answer
|
|
66
|
+
--correlation-id <id> Opaque id echoed on every read of this question
|
|
67
|
+
--reply-to <id> Id of the ping this question replies to
|
|
68
|
+
--room <code> Room invite code (required for ask)
|
|
69
|
+
--github-output <path> Safely append question outputs for GitHub Actions`;
|
|
70
|
+
|
|
71
|
+
export const HELP_LIST = `list options:
|
|
72
|
+
--state <s> pending | answered | expired | cancelled | all`;
|
|
73
|
+
|
|
74
|
+
export const HELP_HANDOFF = `handoff options (agent token required; consent scope pingroom:handoffs:create):
|
|
75
|
+
-m, --message <text> The prompt a human reads (required)
|
|
76
|
+
--question Make it a question (else a simple acknowledge). Also
|
|
77
|
+
implied whenever one or more --option is given.
|
|
78
|
+
-o, --option <v:label> A question option; repeat for 2–4. Requires --question.
|
|
79
|
+
--target <id> Recipient: 'me' (default) or a specific user uuid
|
|
80
|
+
--expires-in <s> Expiry in seconds (120..86400, default 900)
|
|
81
|
+
--urgency <u> 'active' (default) or 'passive'
|
|
82
|
+
--idempotency-key <key> Dedupe key; retries reuse it (Idempotency-Key)
|
|
83
|
+
--correlation-id <id> Opaque id echoed on every read of this handoff
|
|
84
|
+
--reply-to <id> Opaque reply-to id echoed back
|
|
85
|
+
-d, --data <json> Structured data object echoed on the handoff
|
|
86
|
+
--wait Block until acked / answered / expired / cancelled
|
|
87
|
+
--timeout <sec> Per long-poll hold with --wait (0–20, server caps 25)
|
|
88
|
+
--github-output <path> Safely append handoff outputs for GitHub Actions`;
|
|
89
|
+
|
|
90
|
+
export const HELP_HANDOFFS = `handoffs options (agent token required; consent scope pingroom:handoffs:create):
|
|
91
|
+
--state <s> open | all (default open)`;
|
|
92
|
+
|
|
93
|
+
export const HELP_LISTEN = `listen options (agent token required; consent scope pingroom:notifications:read):
|
|
94
|
+
--timeout <sec> Per long-poll hold (0-30, default 25)
|
|
95
|
+
--limit <n> Max pings per batch (1-100, default 50)
|
|
96
|
+
--from <id> Start after this ping id instead of "now"
|
|
97
|
+
--once Print one batch and exit instead of blocking forever
|
|
98
|
+
--json One JSON object per line instead of a readable line`;
|
|
99
|
+
|
|
100
|
+
export const HELP_LIVE = `live <start|update|end|get> options (agent token, or a room webhook):
|
|
101
|
+
-c, --correlation-id <id> The stream key — reuse it for every ping (required)
|
|
102
|
+
--template <name> start only: status | steps | progress | metrics |
|
|
103
|
+
countdown | decision | matchup (fixed at creation;
|
|
104
|
+
'decision' is the app's name for the wire id
|
|
105
|
+
'question', which is still accepted)
|
|
106
|
+
--category <name> start only: status | steps | alert. Legacy, but
|
|
107
|
+
'alert' has no template equivalent and is the only
|
|
108
|
+
way to start time-sensitive without --require-ack
|
|
109
|
+
--steps <a,b,c> start only: 2-8 comma-separated step labels
|
|
110
|
+
-m, --message <text> The card's live message line
|
|
111
|
+
--progress <0..1> Progress bar / Dynamic Island gauge
|
|
112
|
+
--step <n> Current step index (steps template)
|
|
113
|
+
--metric <label:value> Repeatable, up to 3 (metrics template)
|
|
114
|
+
--deadline-at <epoch> Countdown target (countdown template)
|
|
115
|
+
--eta-at <epoch> Live ETA (status/progress templates)
|
|
116
|
+
--prompt <text> The ask (decision template)
|
|
117
|
+
--option <value:label> Repeatable, up to 4 (decision template). A bare
|
|
118
|
+
token is both value and label
|
|
119
|
+
--left <label:value> Left side (matchup template)
|
|
120
|
+
--right <label:value> Right side (matchup template)
|
|
121
|
+
--center <text> Center score/clock, <= 40 (matchup template)
|
|
122
|
+
--accent-override <#rrggbb> Semantic accent for this frame
|
|
123
|
+
--failed end only: finish as failed instead of done
|
|
124
|
+
-d, --data <json> Structured data object carried on this frame
|
|
125
|
+
-t, --title <text> Card title (<= 40 chars)
|
|
126
|
+
-a, --action <1-4> Quick-action slot supplying the icon and sound
|
|
127
|
+
--require-ack Add an Acknowledge button
|
|
128
|
+
--ack-timeout <s> Ack deadline in seconds
|
|
129
|
+
--room <code> Room invite code (used with --token)
|
|
130
|
+
-w, --webhook <url> Room webhook URL instead of a token`;
|
|
131
|
+
|
|
132
|
+
export const HELP_HOOK = `hook options (reads a Claude Code event; defaults to stored credentials/config):
|
|
133
|
+
--room <code> Room invite code (or env/config/paired room)
|
|
134
|
+
--ttl <seconds> Approval-question expiry for PreToolUse (default 900)
|
|
135
|
+
--quiet Suppress the informational stderr lines
|
|
136
|
+
--print-config Print a ready-to-paste ~/.claude/settings.json block`;
|
|
137
|
+
|
|
138
|
+
export const HELP_MCP = `mcp:
|
|
139
|
+
pingroom mcp Print the endpoint and client setup snippets
|
|
140
|
+
pingroom mcp add claude-code Print the Claude Code setup command
|
|
141
|
+
(output-only; does not change client config)`;
|
|
142
|
+
|
|
143
|
+
export const HELP_ACTIVATE = `activate:
|
|
144
|
+
pingroom activate Send one test Question to your phone to prove the
|
|
145
|
+
saved QR-paired credential works (optional —
|
|
146
|
+
connecting no longer does this for you)`;
|
|
147
|
+
|
|
148
|
+
export const HELP_CONFIG = `config options:
|
|
149
|
+
pingroom config list Print the stored settings
|
|
150
|
+
pingroom config get <key> Print one setting
|
|
151
|
+
pingroom config set <key> <val> Store a setting (an empty value clears it)
|
|
152
|
+
Keys: default_room, api_url`;
|
|
153
|
+
|
|
154
|
+
export const HELP_SHARED = `Shared:
|
|
155
|
+
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
156
|
+
--api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
|
|
157
|
+
--json Print the raw JSON response
|
|
158
|
+
-h, --help Show this help
|
|
159
|
+
-v, --version Show the CLI version`;
|
|
160
|
+
|
|
161
|
+
export const HELP_TAIL = `Connecting:
|
|
162
|
+
Install globally, then run with no arguments:
|
|
163
|
+
npm install --global @pingroom/cli
|
|
164
|
+
pingroom
|
|
165
|
+
|
|
166
|
+
Or connect without installing globally:
|
|
167
|
+
npx --yes @pingroom/cli
|
|
168
|
+
|
|
169
|
+
It prints a QR code you scan with the PingRoom app — you pick the account and
|
|
170
|
+
the rooms it may reach there (one, several, or all of them). Once paired, it
|
|
171
|
+
saves the credential and you are done; connecting sends nothing to your phone.
|
|
172
|
+
Run "pingroom activate" if you want to prove the round-trip with one test
|
|
173
|
+
Question. The emailed-code fallback stores no server-side delivery room.
|
|
174
|
+
"config set default_room" enables room-addressed commands, but private
|
|
175
|
+
Inbox/Handoff delivery requires QR pairing.
|
|
176
|
+
There is no "login" command: being unconnected is a state the tool resolves,
|
|
177
|
+
not one you have to discover.
|
|
178
|
+
|
|
179
|
+
The credential is written to ~/.pingroom/credentials.json (mode 0600, in a
|
|
180
|
+
0700 directory). PINGROOM_HOME overrides that directory. PINGROOM_TOKEN in the
|
|
181
|
+
environment ALWAYS wins over the stored credential, so CI is unaffected.
|
|
182
|
+
"pingroom logout" forgets it.
|
|
183
|
+
|
|
184
|
+
Settings precedence, highest first:
|
|
185
|
+
explicit flag > env var > ~/.pingroom/config.json > the paired
|
|
186
|
+
credential > built-in default
|
|
187
|
+
So --room beats PINGROOM_ROOM beats "config set default_room", and --api beats
|
|
188
|
+
PINGROOM_API_URL beats "config set api_url" beats the host you paired against,
|
|
189
|
+
beats ${BUILTIN_API}. A stored credential is bound to the origin it was paired
|
|
190
|
+
against: an API override may change the path on that origin, but a different
|
|
191
|
+
origin is refused before the token is sent. To target another origin
|
|
192
|
+
intentionally, provide that host's token with --token or PINGROOM_TOKEN.
|
|
193
|
+
|
|
194
|
+
Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
|
|
195
|
+
PINGROOM_TOKEN there instead.
|
|
196
|
+
|
|
197
|
+
Examples:
|
|
198
|
+
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
|
|
199
|
+
pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
|
|
200
|
+
|
|
201
|
+
# Link ping — a tappable button that opens a URL:
|
|
202
|
+
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
|
|
203
|
+
--url https://ci.example.com/builds/512 --button-label "Open build"
|
|
204
|
+
|
|
205
|
+
# Gate a deploy on a human tap — the chosen value prints to stdout:
|
|
206
|
+
if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
|
|
207
|
+
-p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
|
|
208
|
+
|
|
209
|
+
# Multi-option question, blocking:
|
|
210
|
+
pingroom ask --token "$T" --room ab12cd --scope room --wait \\
|
|
211
|
+
-p 'Which environment?' -o prod:Production -o staging:Staging
|
|
212
|
+
|
|
213
|
+
pingroom list --token "$T" --state pending
|
|
214
|
+
pingroom watch --token "$T" q_01H... # block on an existing question
|
|
215
|
+
pingroom cancel --token "$T" q_01H...
|
|
216
|
+
|
|
217
|
+
# Hand a deploy decision to yourself and block on the acknowledgement:
|
|
218
|
+
pingroom handoff --token "$T" -m "Prod deploy 1.4.0 — ack to proceed" --wait
|
|
219
|
+
|
|
220
|
+
# A blocking question handed to a specific human; branch in CI on exit code:
|
|
221
|
+
pingroom handoff --token "$T" -m "Ship 1.4.0?" --question \\
|
|
222
|
+
-o deploy:Deploy -o hold:Hold --wait
|
|
223
|
+
# -> exit 0 (answered, any value incl. 'hold'); 3 expired; 4 recipient-not-ready
|
|
224
|
+
|
|
225
|
+
pingroom handoffs --token "$T" --state all # recent history (up to 200/kind)
|
|
226
|
+
|
|
227
|
+
# A live deploy card on everyone's lock screen — one stream, three calls:
|
|
228
|
+
pingroom live start --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
229
|
+
--template steps --steps "Build,Test,Stage,Ship" -t "Deploy 2.1.0"
|
|
230
|
+
pingroom live update --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
231
|
+
--step 2 -m "Smoke tests green"
|
|
232
|
+
pingroom live end --token "$T" --room ab12cd -c "deploy-$GITHUB_RUN_ID" \\
|
|
233
|
+
-m "Live on production"
|
|
234
|
+
# ...or end it as a failure, which still delivers one completion alert:
|
|
235
|
+
# pingroom live end ... --failed -m "Rollback triggered"
|
|
236
|
+
|
|
237
|
+
# Connect Claude Code hooks to your paired credential (no env vars needed):
|
|
238
|
+
pingroom hook --print-config
|
|
239
|
+
|
|
240
|
+
# Connect an MCP client through browser OAuth (no API key needed):
|
|
241
|
+
pingroom mcp
|
|
242
|
+
|
|
243
|
+
Security:
|
|
244
|
+
Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
|
|
245
|
+
secrets as --webhook / --token flags: argv is visible to other users via the
|
|
246
|
+
process table (ps) and may be captured in shell history. URLs must use https
|
|
247
|
+
(loopback http is allowed for local dev).
|
|
248
|
+
|
|
249
|
+
A paired credential is only sent to its recorded API origin. --api,
|
|
250
|
+
PINGROOM_API_URL and config.api_url cannot redirect that stored bearer to a
|
|
251
|
+
different origin; provide an explicit --token or PINGROOM_TOKEN to override.
|
|
252
|
+
|
|
253
|
+
Exit codes: 0 on success (answered / acked), 1 on error (network/auth/5xx),
|
|
254
|
+
2 on bad usage, 3 when a handoff or question expired, 4 when it was cancelled
|
|
255
|
+
or the recipient was not ready (409 recipient_not_ready). A question answered
|
|
256
|
+
with ANY value — including a negative one like 'hold' or 'deny' — exits 0: a
|
|
257
|
+
human decision is not an infrastructure failure.`;
|
|
258
|
+
|
|
259
|
+
export const HELP = [
|
|
260
|
+
HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
|
|
261
|
+
HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
|
|
262
|
+
HELP_SHARED, HELP_TAIL,
|
|
263
|
+
].join('\n\n');
|
|
264
|
+
|
|
265
|
+
// Sections for `pingroom <command> --help`. watch/cancel/logout have no block
|
|
266
|
+
// of their own in the full help, so they get a minimal one here.
|
|
267
|
+
export const COMMAND_HELP_SECTIONS = {
|
|
268
|
+
ping: HELP_PING,
|
|
269
|
+
ask: HELP_ASK,
|
|
270
|
+
watch: `watch:
|
|
271
|
+
pingroom watch <question-id> Block until the question resolves and
|
|
272
|
+
print the outcome
|
|
273
|
+
--timeout <sec> Per long-poll hold (0–30, default 25)`,
|
|
274
|
+
cancel: `cancel:
|
|
275
|
+
pingroom cancel <question-id> Withdraw a pending question`,
|
|
276
|
+
list: HELP_LIST,
|
|
277
|
+
handoff: HELP_HANDOFF,
|
|
278
|
+
handoffs: HELP_HANDOFFS,
|
|
279
|
+
listen: HELP_LISTEN,
|
|
280
|
+
live: HELP_LIVE,
|
|
281
|
+
hook: HELP_HOOK,
|
|
282
|
+
activate: HELP_ACTIVATE,
|
|
283
|
+
config: HELP_CONFIG,
|
|
284
|
+
logout: `logout:
|
|
285
|
+
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
|
286
|
+
in the environment is unaffected)`,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// config and logout are local-only commands that reject --token/--api (and,
|
|
290
|
+
// for logout, --json), so their help gets a footer that only lists what they
|
|
291
|
+
// actually accept instead of the full shared block.
|
|
292
|
+
export const COMMAND_HELP_FOOTERS = {
|
|
293
|
+
config: `Shared:
|
|
294
|
+
--json Print the raw JSON response
|
|
295
|
+
-h, --help Show this help`,
|
|
296
|
+
logout: `Shared:
|
|
297
|
+
-h, --help Show this help`,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// `<command> --help`: that command's section plus the shared flags, instead of
|
|
301
|
+
// the full reference `pingroom --help` / `pingroom help` still print.
|
|
302
|
+
export function commandHelp(name) {
|
|
303
|
+
const section = COMMAND_HELP_SECTIONS[name];
|
|
304
|
+
return section ? `${section}\n\n${COMMAND_HELP_FOOTERS[name] ?? HELP_SHARED}` : HELP;
|
|
305
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Everything that touches the network, plus the URL safety rule that decides
|
|
2
|
+
// whether a bearer token may ride along at all.
|
|
3
|
+
|
|
4
|
+
import { EXIT } from './constants.js';
|
|
5
|
+
import { fail, stripControlChars } from './util.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fixes that live on THIS side of the wire. The server's message always
|
|
9
|
+
* leads; these are appended only for the codes where the operator would
|
|
10
|
+
* otherwise have no way to know what to do next, and where the answer is a
|
|
11
|
+
* local action rather than "try again".
|
|
12
|
+
*/
|
|
13
|
+
const API_HINTS = {
|
|
14
|
+
room_not_granted:
|
|
15
|
+
'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom" to reconnect and pick it.',
|
|
16
|
+
insufficient_scope:
|
|
17
|
+
'This credential was approved before the command needed that permission. Run "pingroom" to reconnect and re-approve.',
|
|
18
|
+
no_room_configured:
|
|
19
|
+
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What to print when an API call fails: the server's own wording, plus the one
|
|
24
|
+
* thing that would fix it when we know one.
|
|
25
|
+
*/
|
|
26
|
+
export function apiDetail(res, json) {
|
|
27
|
+
// The server's wording is untrusted text headed for the terminal — strip
|
|
28
|
+
// escapes so a hostile API can't smuggle ANSI (same threat model as pair_url).
|
|
29
|
+
const base = stripControlChars(
|
|
30
|
+
(json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`,
|
|
31
|
+
);
|
|
32
|
+
const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
|
|
33
|
+
return hint ? `${base}\n ${hint}` : base;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when a URL is safe to attach a bearer token or webhook secret to: https,
|
|
37
|
+
// or http on loopback so local dev against http://localhost still works.
|
|
38
|
+
// Split out of requireSafeUrl for the `hook` command, which must apply the same
|
|
39
|
+
// rule but fails open (it defers instead of exiting — see hook()).
|
|
40
|
+
export function isSafeUrl(raw) {
|
|
41
|
+
let u;
|
|
42
|
+
try {
|
|
43
|
+
u = new URL(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
48
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && isLoopback);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Refuse to send a bearer token or webhook secret over cleartext http. A
|
|
52
|
+
// loopback host is allowed so local dev against http://localhost still works.
|
|
53
|
+
export function requireSafeUrl(kind, raw) {
|
|
54
|
+
try {
|
|
55
|
+
new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
fail(`${kind} is not a valid URL`, EXIT.USAGE);
|
|
58
|
+
}
|
|
59
|
+
if (!isSafeUrl(raw)) {
|
|
60
|
+
fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
|
|
61
|
+
}
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `soft: true` returns { error } instead of exiting on a transport failure. The
|
|
66
|
+
// bounded pairing and activation loops use it so a single DNS blip or dropped
|
|
67
|
+
// connection does not discard an otherwise recoverable human workflow. Every
|
|
68
|
+
// other caller keeps the hard exit.
|
|
69
|
+
export async function httpJson(method, url, { body, headers = {}, soft = false, signal } = {}) {
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await fetch(url, {
|
|
73
|
+
method,
|
|
74
|
+
headers: {
|
|
75
|
+
Accept: 'application/json',
|
|
76
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
77
|
+
...headers,
|
|
78
|
+
},
|
|
79
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
80
|
+
...(signal ? { signal } : {}),
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (soft) return { res: null, text: '', json: null, error: err };
|
|
84
|
+
fail(`network error: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = await res.text();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A connection dropped mid-body throws here, not at fetch().
|
|
92
|
+
if (soft) return { res: null, text: '', json: null, error: err };
|
|
93
|
+
fail(`network error: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
let json = null;
|
|
96
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
97
|
+
|
|
98
|
+
return { res, text, json };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The extensions the attachment endpoint accepts. Mirrored here so a typo is a
|
|
102
|
+
// local usage error instead of a 422 after the bytes have already been sent.
|
|
103
|
+
// Keep in lockstep with laravel config/attachments.php `allowed_extensions`.
|
|
104
|
+
const ATTACHMENT_EXTENSIONS = ['md', 'pdf', 'html', 'txt', 'jpg', 'jpeg', 'png', 'zip'];
|
|
105
|
+
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
|
|
106
|
+
const ATTACHMENT_MAX_COUNT = 4;
|
|
107
|
+
const ATTACHMENT_MIME = {
|
|
108
|
+
md: 'text/markdown',
|
|
109
|
+
pdf: 'application/pdf',
|
|
110
|
+
html: 'text/html',
|
|
111
|
+
txt: 'text/plain',
|
|
112
|
+
jpg: 'image/jpeg',
|
|
113
|
+
jpeg: 'image/jpeg',
|
|
114
|
+
png: 'image/png',
|
|
115
|
+
zip: 'application/zip',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Upload each --attach path and return the ids in flag order. Bytes go up as
|
|
120
|
+
* multipart; only the resulting ids ride the ping body. An id we never manage
|
|
121
|
+
* to attach expires server-side after 24h, so a mid-run failure leaks nothing
|
|
122
|
+
* permanent.
|
|
123
|
+
*/
|
|
124
|
+
export async function uploadAttachments(paths, apiBase, token) {
|
|
125
|
+
if (paths.length > ATTACHMENT_MAX_COUNT) {
|
|
126
|
+
fail(`--attach accepts at most ${ATTACHMENT_MAX_COUNT} files`, EXIT.USAGE);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { readFile, stat } = await import('node:fs/promises');
|
|
130
|
+
const { basename, extname } = await import('node:path');
|
|
131
|
+
const ids = [];
|
|
132
|
+
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
const name = basename(path);
|
|
135
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
136
|
+
if (!ATTACHMENT_EXTENSIONS.includes(ext)) {
|
|
137
|
+
fail(`--attach ${name}: only ${ATTACHMENT_EXTENSIONS.join(', ')} files are supported`, EXIT.USAGE);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let info;
|
|
141
|
+
try {
|
|
142
|
+
info = await stat(path);
|
|
143
|
+
} catch {
|
|
144
|
+
fail(`--attach ${path}: file not found`, EXIT.USAGE);
|
|
145
|
+
}
|
|
146
|
+
if (!info.isFile()) fail(`--attach ${path}: not a file`, EXIT.USAGE);
|
|
147
|
+
if (info.size < 1) fail(`--attach ${name}: file is empty`, EXIT.USAGE);
|
|
148
|
+
if (info.size > ATTACHMENT_MAX_BYTES) {
|
|
149
|
+
fail(`--attach ${name}: file exceeds the 5 MiB limit`, EXIT.USAGE);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const body = new FormData();
|
|
153
|
+
body.append('file', new Blob([await readFile(path)], { type: ATTACHMENT_MIME[ext] }), name);
|
|
154
|
+
|
|
155
|
+
let res;
|
|
156
|
+
try {
|
|
157
|
+
// Not httpJson: that helper JSON-encodes the body and would strip the
|
|
158
|
+
// multipart boundary the runtime generates for us.
|
|
159
|
+
res = await fetch(`${apiBase}/api/agent/attachments`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
|
162
|
+
body,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
fail(`network error uploading ${name}: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const text = await res.text().catch(() => '');
|
|
169
|
+
let json = null;
|
|
170
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
171
|
+
|
|
172
|
+
if (res.status === 402) {
|
|
173
|
+
fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
|
|
174
|
+
}
|
|
175
|
+
if (!res.ok || !json?.attachment?.id) {
|
|
176
|
+
const detail = apiDetail(res, json);
|
|
177
|
+
fail(`upload failed for ${name}: ${detail}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ids.push(json.attachment.id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ids;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A minimal HTTP helper for the hook path that THROWS instead of calling fail(),
|
|
187
|
+
// so every failure funnels into a fail-open decision. Mirrors httpJson's header
|
|
188
|
+
// handling but leaves control flow to the caller.
|
|
189
|
+
export async function hookFetch(method, url, { body, token } = {}) {
|
|
190
|
+
const res = await fetch(url, {
|
|
191
|
+
method,
|
|
192
|
+
headers: {
|
|
193
|
+
Accept: 'application/json',
|
|
194
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
195
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
196
|
+
},
|
|
197
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
198
|
+
});
|
|
199
|
+
const text = await res.text();
|
|
200
|
+
let json = null;
|
|
201
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
throw new Error(apiDetail(res, json));
|
|
204
|
+
}
|
|
205
|
+
return json;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function retryAfterMs(response) {
|
|
209
|
+
const raw = response?.headers?.get('retry-after')?.trim();
|
|
210
|
+
if (!raw) return null;
|
|
211
|
+
if (/^\d+(?:\.\d+)?$/.test(raw)) return Number(raw) * 1000;
|
|
212
|
+
const at = Date.parse(raw);
|
|
213
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
214
|
+
}
|