@pingroom/cli 0.7.2 → 0.7.4
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 +54 -12
- 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 +166 -0
- package/lib/commands/mcp.js +47 -0
- package/lib/commands/ping.js +127 -0
- package/lib/config.js +206 -0
- package/lib/constants.js +14 -0
- package/lib/github-output.js +76 -0
- package/lib/help.js +310 -0
- package/lib/http.js +214 -0
- package/lib/parser.js +218 -0
- package/lib/render.js +174 -0
- package/lib/util.js +109 -0
- package/lib/version.js +10 -0
- package/package.json +3 -2
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { EXIT } from '../constants.js';
|
|
2
|
+
import { fail, numberOption, parseDataObject, requireMaxLength } from '../util.js';
|
|
3
|
+
import { commandHelp } from '../help.js';
|
|
4
|
+
import { apiDetail, httpJson, requireSafeUrl } from '../http.js';
|
|
5
|
+
import { requireStoredCredentialOrigin, resolveApiBase, resolveRoom, resolveToken } from '../config.js';
|
|
6
|
+
import {
|
|
7
|
+
buildLiveOptions, buildMetrics, buildSide, canonicalTemplate, LIVE_TEMPLATE_NAMES, LIVE_TEMPLATES,
|
|
8
|
+
normalizeAccent,
|
|
9
|
+
} from '../render.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drive a live progress card on the room members' lock screen.
|
|
13
|
+
*
|
|
14
|
+
* One correlation id = one stream: `start` opens it (one alert), `update` moves
|
|
15
|
+
* it silently, `end` closes it with one completion alert. Works with either an
|
|
16
|
+
* agent token (--token, needs pingroom:live:write) or a room's incoming webhook
|
|
17
|
+
* (--webhook), which speak the same `live_status` contract.
|
|
18
|
+
*/
|
|
19
|
+
export async function live(args) {
|
|
20
|
+
if (args.help) { process.stdout.write(`${commandHelp('live')}\n`); return EXIT.OK; }
|
|
21
|
+
const sub = args._[0];
|
|
22
|
+
const known = ['start', 'update', 'end', 'get'];
|
|
23
|
+
if (!sub || !known.includes(sub)) {
|
|
24
|
+
fail(`live needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const correlationId = args.correlation_id;
|
|
28
|
+
if (!correlationId) fail('--correlation-id is required', EXIT.USAGE);
|
|
29
|
+
|
|
30
|
+
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
31
|
+
const token = resolveToken(args);
|
|
32
|
+
const apiBase = resolveApiBase(args);
|
|
33
|
+
const room = resolveRoom(args);
|
|
34
|
+
|
|
35
|
+
if (sub === 'get') {
|
|
36
|
+
if (!token) fail('live get requires an agent token (--token or PINGROOM_TOKEN)', EXIT.USAGE);
|
|
37
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
38
|
+
if (!room) fail('--room is required', EXIT.USAGE);
|
|
39
|
+
requireSafeUrl('--api', apiBase);
|
|
40
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live/${encodeURIComponent(correlationId)}`;
|
|
41
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
42
|
+
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
fail(`read failed: ${apiDetail(res, json)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!args.json) process.stdout.write(`${(json && json.state) || 'unknown'}\n`);
|
|
47
|
+
return EXIT.OK;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const liveStatus = {
|
|
51
|
+
state: sub === 'end' ? (args.failed ? 'failed' : 'done') : 'running',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// This live-status payload field has its own 256-character contract; ordinary
|
|
55
|
+
// Ping bodies are 120 in private rooms and 160 in public rooms.
|
|
56
|
+
requireMaxLength(args.message, 256, '--message');
|
|
57
|
+
requireMaxLength(args.title, 40, '--title');
|
|
58
|
+
requireMaxLength(args.prompt, 256, '--prompt');
|
|
59
|
+
requireMaxLength(args.center, 40, '--center');
|
|
60
|
+
if (args.message !== undefined) liveStatus.message = args.message;
|
|
61
|
+
if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
|
|
62
|
+
|
|
63
|
+
const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
|
|
64
|
+
if (progress !== undefined) liveStatus.progress = progress;
|
|
65
|
+
|
|
66
|
+
const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
|
|
67
|
+
if (step !== undefined) liveStatus.current_step = step;
|
|
68
|
+
|
|
69
|
+
const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
|
|
70
|
+
if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
|
|
71
|
+
|
|
72
|
+
const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
|
|
73
|
+
if (etaAt !== undefined) liveStatus.eta_at = etaAt;
|
|
74
|
+
|
|
75
|
+
const metrics = buildMetrics(args.metric);
|
|
76
|
+
if (metrics) liveStatus.metrics = metrics;
|
|
77
|
+
|
|
78
|
+
const options = buildLiveOptions(args.option);
|
|
79
|
+
if (options) {
|
|
80
|
+
if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
|
|
81
|
+
liveStatus.options = options;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const left = buildSide(args.left, '--left');
|
|
85
|
+
if (left) liveStatus.left = left;
|
|
86
|
+
const right = buildSide(args.right, '--right');
|
|
87
|
+
if (right) liveStatus.right = right;
|
|
88
|
+
if (args.center !== undefined) liveStatus.center = args.center;
|
|
89
|
+
|
|
90
|
+
const accent = normalizeAccent(args.accent_override);
|
|
91
|
+
if (accent) liveStatus.accent_override = accent;
|
|
92
|
+
|
|
93
|
+
// Template, category and step labels are fixed when the stream is created;
|
|
94
|
+
// sending them on an update is a no-op server-side, so only `start` takes them.
|
|
95
|
+
if (sub === 'start') {
|
|
96
|
+
// Validated locally for the same reason --category is: a typo'd name is a
|
|
97
|
+
// usage error, and letting it reach the server turns it into a 422 round
|
|
98
|
+
// trip that reads like an outage.
|
|
99
|
+
if (args.template) {
|
|
100
|
+
const template = canonicalTemplate(args.template);
|
|
101
|
+
if (!LIVE_TEMPLATES.includes(template)) {
|
|
102
|
+
fail(`--template must be one of: ${LIVE_TEMPLATE_NAMES.join(', ')}`, EXIT.USAGE);
|
|
103
|
+
}
|
|
104
|
+
liveStatus.template = template;
|
|
105
|
+
}
|
|
106
|
+
// `alert` has no template equivalent and is the only way to start a stream
|
|
107
|
+
// time-sensitive (breaking through Focus) without also demanding an ack.
|
|
108
|
+
if (args.category) {
|
|
109
|
+
if (!['status', 'steps', 'alert'].includes(args.category)) {
|
|
110
|
+
fail('--category must be status, steps or alert', EXIT.USAGE);
|
|
111
|
+
}
|
|
112
|
+
liveStatus.category = args.category;
|
|
113
|
+
}
|
|
114
|
+
if (args.steps) {
|
|
115
|
+
const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
|
|
116
|
+
if (labels.length < 2 || labels.length > 8) {
|
|
117
|
+
fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
|
|
118
|
+
}
|
|
119
|
+
liveStatus.steps = labels;
|
|
120
|
+
}
|
|
121
|
+
} else if (args.template || args.steps || args.category) {
|
|
122
|
+
fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const body = { correlation_id: correlationId, live_status: liveStatus };
|
|
126
|
+
if (args.title) body.title = args.title;
|
|
127
|
+
if (args.action !== undefined) body.action = Number(args.action);
|
|
128
|
+
// Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
|
|
129
|
+
// an array, which the server then rejects — a wasted round trip for what is a
|
|
130
|
+
// local usage error.
|
|
131
|
+
// `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
|
|
132
|
+
// truthiness test drops it on the floor and ships the ping without the data
|
|
133
|
+
// the caller believed they attached. ping/ask/handoff all reject it loudly.
|
|
134
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
135
|
+
if (args.require_ack) body.requires_ack = true;
|
|
136
|
+
const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
|
|
137
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
138
|
+
|
|
139
|
+
let result;
|
|
140
|
+
if (webhook) {
|
|
141
|
+
requireSafeUrl('--webhook', webhook);
|
|
142
|
+
result = await httpJson('POST', webhook, { body });
|
|
143
|
+
} else if (token) {
|
|
144
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
145
|
+
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
146
|
+
requireSafeUrl('--api', apiBase);
|
|
147
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
|
|
148
|
+
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
149
|
+
} else {
|
|
150
|
+
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { res, text, json } = result;
|
|
154
|
+
if (args.json) process.stdout.write(`${text || '{}'}\n`);
|
|
155
|
+
|
|
156
|
+
if (!res.ok || (json && json.success === false)) {
|
|
157
|
+
const detail = apiDetail(res, json);
|
|
158
|
+
fail(`live ${sub} failed: ${detail}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!args.json) {
|
|
162
|
+
const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
|
|
163
|
+
process.stdout.write(`live ${sub} → ${state} ✅\n`);
|
|
164
|
+
}
|
|
165
|
+
return EXIT.OK;
|
|
166
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `mcp` — print the remote MCP endpoint and client setup. Output only: it never
|
|
2
|
+
// edits a client's configuration.
|
|
3
|
+
|
|
4
|
+
import { EXIT, MCP_ENDPOINT } from '../constants.js';
|
|
5
|
+
import { fail } from '../util.js';
|
|
6
|
+
|
|
7
|
+
export function mcp(rest) {
|
|
8
|
+
const claudeCommand = `claude mcp add --transport http pingroom ${MCP_ENDPOINT}`;
|
|
9
|
+
|
|
10
|
+
if (rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))) {
|
|
11
|
+
const config = {
|
|
12
|
+
mcpServers: {
|
|
13
|
+
pingroom: { url: MCP_ENDPOINT },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
`PingRoom MCP endpoint:
|
|
18
|
+
${MCP_ENDPOINT}
|
|
19
|
+
|
|
20
|
+
Claude Code:
|
|
21
|
+
${claudeCommand}
|
|
22
|
+
|
|
23
|
+
Cursor JSON (~/.cursor/mcp.json):
|
|
24
|
+
${JSON.stringify(config, null, 2)}
|
|
25
|
+
|
|
26
|
+
Claude Desktop:
|
|
27
|
+
Customize > Connectors > Add custom connector
|
|
28
|
+
Name: PingRoom
|
|
29
|
+
URL: ${MCP_ENDPOINT}
|
|
30
|
+
|
|
31
|
+
After adding the server, use your client's MCP controls to authenticate in the
|
|
32
|
+
browser. No API key is needed.
|
|
33
|
+
This command only prints setup instructions and does not modify client config.
|
|
34
|
+
`);
|
|
35
|
+
return EXIT.OK;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (rest.length === 2 && rest[0] === 'add' && rest[1] === 'claude-code') {
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
`No client configuration was changed. Copy and run:
|
|
41
|
+
${claudeCommand}
|
|
42
|
+
`);
|
|
43
|
+
return EXIT.OK;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fail('usage: pingroom mcp [add claude-code]', EXIT.USAGE);
|
|
47
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { EXIT, PING_TITLE_MAX_LENGTH, PUBLIC_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
|
|
2
|
+
import { fail, parseDataObject, requireMaxLength } from '../util.js';
|
|
3
|
+
import { commandHelp } from '../help.js';
|
|
4
|
+
import { apiDetail, httpJson, requireSafeUrl, uploadAttachments } from '../http.js';
|
|
5
|
+
import { requireStoredCredentialOrigin, resolveApiBase, resolveRoom, resolveToken } from '../config.js';
|
|
6
|
+
|
|
7
|
+
export async function ping(args) {
|
|
8
|
+
if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
|
|
9
|
+
|
|
10
|
+
const message = args.message;
|
|
11
|
+
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
12
|
+
// Room visibility is not encoded in a room code or webhook URL. Validate the
|
|
13
|
+
// public ceiling here; the API applies 120 for private rooms and 160 for public.
|
|
14
|
+
requireMaxLength(message, PUBLIC_PING_MESSAGE_MAX_LENGTH, '--message');
|
|
15
|
+
requireMaxLength(args.title, PING_TITLE_MAX_LENGTH, '--title');
|
|
16
|
+
|
|
17
|
+
if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
|
|
18
|
+
fail('--action must be an integer 1–4', EXIT.USAGE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let ackTimeout;
|
|
22
|
+
if (args.ack_timeout !== undefined) {
|
|
23
|
+
if (!args.require_ack) {
|
|
24
|
+
fail('--ack-timeout requires --require-ack', EXIT.USAGE);
|
|
25
|
+
}
|
|
26
|
+
if (!/^\d+$/.test(String(args.ack_timeout))) {
|
|
27
|
+
fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
|
|
28
|
+
}
|
|
29
|
+
ackTimeout = Number(args.ack_timeout);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let data;
|
|
33
|
+
if (args.data !== undefined) {
|
|
34
|
+
data = parseDataObject(args.data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Link ping: --url/--button-label fold into the structured data object
|
|
38
|
+
// (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
|
|
39
|
+
if (args.button_label !== undefined && args.url === undefined) {
|
|
40
|
+
fail('--button-label requires --url', EXIT.USAGE);
|
|
41
|
+
}
|
|
42
|
+
if (args.url !== undefined) {
|
|
43
|
+
let linkUrl;
|
|
44
|
+
try {
|
|
45
|
+
linkUrl = new URL(args.url);
|
|
46
|
+
} catch {
|
|
47
|
+
fail('--url is not a valid URL', EXIT.USAGE);
|
|
48
|
+
}
|
|
49
|
+
if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
|
|
50
|
+
fail('--url must be an absolute http(s) URL', EXIT.USAGE);
|
|
51
|
+
}
|
|
52
|
+
if (args.url.length > 2048) {
|
|
53
|
+
fail('--url must be at most 2048 characters', EXIT.USAGE);
|
|
54
|
+
}
|
|
55
|
+
if (args.button_label !== undefined && args.button_label.length > 26) {
|
|
56
|
+
fail('--button-label must be at most 26 characters', EXIT.USAGE);
|
|
57
|
+
}
|
|
58
|
+
data = { ...(data || {}), url: args.url };
|
|
59
|
+
if (args.button_label !== undefined) data.button_label = args.button_label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
63
|
+
const token = resolveToken(args);
|
|
64
|
+
const apiBase = resolveApiBase(args);
|
|
65
|
+
const room = resolveRoom(args);
|
|
66
|
+
|
|
67
|
+
let result;
|
|
68
|
+
|
|
69
|
+
// Attachments exist only on the agent-token path: an incoming webhook has no
|
|
70
|
+
// uploader identity to bind private files to, so the API takes no ids there.
|
|
71
|
+
const attachPaths = args.attach ?? [];
|
|
72
|
+
if (attachPaths.length && (webhook || !token)) {
|
|
73
|
+
fail('--attach requires an agent token (--token / PINGROOM_TOKEN), not a webhook ping', EXIT.USAGE);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (webhook) {
|
|
77
|
+
if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
|
|
78
|
+
fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
|
|
79
|
+
}
|
|
80
|
+
requireSafeUrl('--webhook', webhook);
|
|
81
|
+
const body = { message };
|
|
82
|
+
if (args.title) body.title = args.title;
|
|
83
|
+
if (args.action !== undefined) body.action = Number(args.action);
|
|
84
|
+
if (data) body.data = data;
|
|
85
|
+
if (args.require_ack) body.requires_ack = true;
|
|
86
|
+
if (args.urgent) body.is_urgent = true;
|
|
87
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
88
|
+
result = await httpJson('POST', webhook, { body });
|
|
89
|
+
} else if (token) {
|
|
90
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
91
|
+
if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
92
|
+
if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
|
|
93
|
+
fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
|
|
94
|
+
}
|
|
95
|
+
requireSafeUrl('--api', apiBase);
|
|
96
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
|
|
97
|
+
const body = { message };
|
|
98
|
+
if (args.title) body.title = args.title;
|
|
99
|
+
if (args.action !== undefined) body.action_number = Number(args.action);
|
|
100
|
+
if (data) body.data = data;
|
|
101
|
+
if (args.require_ack) body.requires_ack = true;
|
|
102
|
+
if (args.urgent) body.is_urgent = true;
|
|
103
|
+
if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
|
|
104
|
+
if (attachPaths.length) {
|
|
105
|
+
body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
|
|
106
|
+
}
|
|
107
|
+
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
108
|
+
} else {
|
|
109
|
+
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { res, text, json } = result;
|
|
113
|
+
|
|
114
|
+
if (args.json) {
|
|
115
|
+
process.stdout.write(`${text || '{}'}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ok = res.ok && !(json && json.success === false);
|
|
119
|
+
|
|
120
|
+
if (!ok) {
|
|
121
|
+
const detail = apiDetail(res, json);
|
|
122
|
+
fail(`delivery failed: ${detail}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!args.json) process.stdout.write('ping sent ✅\n');
|
|
126
|
+
return EXIT.OK;
|
|
127
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Local state (~/.pingroom) and the layered resolution every command shares:
|
|
2
|
+
// explicit flag > env var > config file > the paired credential > built-in.
|
|
3
|
+
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import {
|
|
6
|
+
chmodSync, closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { BUILTIN_API, EXIT } from './constants.js';
|
|
13
|
+
import { fail } from './util.js';
|
|
14
|
+
import { requireSafeUrl } from './http.js';
|
|
15
|
+
|
|
16
|
+
// --- local state (~/.pingroom) ---------------------------------------------
|
|
17
|
+
//
|
|
18
|
+
// Two files, both under a 0700 directory:
|
|
19
|
+
// credentials.json the agent credential this machine paired (mode 0600)
|
|
20
|
+
// config.json user settings: default_room, api_url
|
|
21
|
+
//
|
|
22
|
+
// PINGROOM_HOME relocates the directory (tests, sandboxes, multi-account
|
|
23
|
+
// shells). Every lookup is layered: explicit flag > env var > config file >
|
|
24
|
+
// the paired credential > built-in default. PINGROOM_TOKEN is the one env var
|
|
25
|
+
// that also outranks the stored credential, which is what keeps CI working
|
|
26
|
+
// untouched.
|
|
27
|
+
|
|
28
|
+
export function pingroomHome() {
|
|
29
|
+
return process.env.PINGROOM_HOME || join(homedir(), '.pingroom');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function credentialsPath() { return join(pingroomHome(), 'credentials.json'); }
|
|
33
|
+
export function configPath() { return join(pingroomHome(), 'config.json'); }
|
|
34
|
+
|
|
35
|
+
// Read a JSON object, or null for anything unreadable/corrupt. Local state must
|
|
36
|
+
// never be able to crash a ping: a hand-edited file degrades to "not set".
|
|
37
|
+
export function readJsonFile(path) {
|
|
38
|
+
let raw;
|
|
39
|
+
try { raw = readFileSync(path, 'utf8'); } catch { return null; }
|
|
40
|
+
let value;
|
|
41
|
+
try { value = JSON.parse(raw); } catch { return null; }
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Write JSON with restrictive permissions, atomically.
|
|
47
|
+
//
|
|
48
|
+
// Writing in place truncates first, so a crash or a full disk between truncate
|
|
49
|
+
// and write leaves a half-written file — and readJsonFile() degrades anything
|
|
50
|
+
// unparseable to {}, so the *next* `config set` would silently drop every other
|
|
51
|
+
// setting. Writing a sibling temp file and renaming over the target means a
|
|
52
|
+
// reader only ever sees the old file or the new one, never a torn one.
|
|
53
|
+
//
|
|
54
|
+
// The temp file is opened 'wx' with mode 0600 and fchmod'd before a single byte
|
|
55
|
+
// is written: `mode` on an existing file is ignored and a post-write chmod
|
|
56
|
+
// leaves a window where the credential is world-readable. rename() carries the
|
|
57
|
+
// 0600 over the target, so a pre-existing loose file is tightened too.
|
|
58
|
+
//
|
|
59
|
+
// mkdirSync(recursive) returns the first path it created, or undefined when the
|
|
60
|
+
// directory already existed. chmod'ing only on the former keeps this from
|
|
61
|
+
// narrowing a directory the user deliberately created at 0755.
|
|
62
|
+
export function writeJsonFile(path, value) {
|
|
63
|
+
const dir = pingroomHome();
|
|
64
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
68
|
+
if (created !== undefined) chmodSync(dir, 0o700);
|
|
69
|
+
|
|
70
|
+
fd = openSync(tmp, 'wx', 0o600);
|
|
71
|
+
fchmodSync(fd, 0o600); // defeat a permissive umask masking the open mode
|
|
72
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
fd = undefined;
|
|
75
|
+
renameSync(tmp, path);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
78
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
79
|
+
fail(`could not write ${path}: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readStoredCredential() {
|
|
84
|
+
const cred = readJsonFile(credentialsPath());
|
|
85
|
+
if (!cred || typeof cred.token !== 'string' || cred.token === '') return null;
|
|
86
|
+
return cred;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readConfigFile() {
|
|
90
|
+
return readJsonFile(configPath()) || {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Agent token: --token > PINGROOM_TOKEN > the paired credential. */
|
|
94
|
+
export function resolveToken(args) {
|
|
95
|
+
return args.token || process.env.PINGROOM_TOKEN || readStoredCredential()?.token || undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* API base: --api > PINGROOM_API_URL > config.api_url > the host the credential
|
|
100
|
+
* was paired against > built-in, no trailing slash.
|
|
101
|
+
*
|
|
102
|
+
* The credential layer is not optional. saveCredential() records `api_url`, and
|
|
103
|
+
* a token minted by a self-hosted / staging server is only valid there; without
|
|
104
|
+
* this layer the next command would present that bearer to api.pingroom.io —
|
|
105
|
+
* leaking it to a host it was never issued for. resolveRoom() already consults
|
|
106
|
+
* the credential last, so the two layerings now agree.
|
|
107
|
+
*
|
|
108
|
+
* It is also an issuer boundary when resolveToken() falls through to the stored
|
|
109
|
+
* credential. Overrides may change the path on the same origin, but
|
|
110
|
+
* requireStoredCredentialOrigin() refuses a different origin unless the caller
|
|
111
|
+
* supplies an explicit --token or PINGROOM_TOKEN for that host.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveApiBase(args) {
|
|
114
|
+
const raw = args.api
|
|
115
|
+
|| process.env.PINGROOM_API_URL
|
|
116
|
+
|| readConfigFile().api_url
|
|
117
|
+
|| readStoredCredential()?.api_url
|
|
118
|
+
|| BUILTIN_API;
|
|
119
|
+
return String(raw).replace(/\/$/, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A paired bearer belongs to the API origin that minted it. API settings still
|
|
124
|
+
* resolve independently so callers can select a path or an intentional custom
|
|
125
|
+
* host, but a stored token may only follow them within its recorded origin.
|
|
126
|
+
* Supplying --token / PINGROOM_TOKEN makes the token source explicit and opts
|
|
127
|
+
* out of this stored-credential binding.
|
|
128
|
+
*/
|
|
129
|
+
export function storedCredentialOriginError(args, apiBase) {
|
|
130
|
+
if (args.token || process.env.PINGROOM_TOKEN) return null;
|
|
131
|
+
|
|
132
|
+
const credential = readStoredCredential();
|
|
133
|
+
if (!credential || typeof credential.api_url !== 'string' || credential.api_url === '') return null;
|
|
134
|
+
|
|
135
|
+
let credentialOrigin;
|
|
136
|
+
let targetOrigin;
|
|
137
|
+
try {
|
|
138
|
+
credentialOrigin = new URL(credential.api_url).origin;
|
|
139
|
+
targetOrigin = new URL(apiBase).origin;
|
|
140
|
+
} catch {
|
|
141
|
+
// URL validation owns malformed values. This guard only compares origins.
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (credentialOrigin === targetOrigin) return null;
|
|
146
|
+
return `stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}. Provide --token or PINGROOM_TOKEN for an intentional API origin override`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function requireStoredCredentialOrigin(args, apiBase) {
|
|
150
|
+
const error = storedCredentialOriginError(args, apiBase);
|
|
151
|
+
if (error) fail(error, EXIT.USAGE);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Room invite code: --room > PINGROOM_ROOM > config.default_room > the room the
|
|
156
|
+
* credential was paired to. The paired room is last because it is the weakest
|
|
157
|
+
* signal — it is where the agent was told to deliver, not necessarily where
|
|
158
|
+
* this invocation means to.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveRoom(args) {
|
|
161
|
+
return args.room
|
|
162
|
+
|| process.env.PINGROOM_ROOM
|
|
163
|
+
|| readConfigFile().default_room
|
|
164
|
+
|| readStoredCredential()?.room?.invite_code
|
|
165
|
+
|| undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Resolve the credential + endpoint a token-only command needs. When nothing is
|
|
169
|
+
// available this is a usage error pointing at PINGROOM_TOKEN — never a prompt,
|
|
170
|
+
// so a CI job fails in a second instead of hanging on an invisible question.
|
|
171
|
+
export function agentContext(args, { needRoom = false } = {}) {
|
|
172
|
+
const token = resolveToken(args);
|
|
173
|
+
if (!token) {
|
|
174
|
+
fail(
|
|
175
|
+
'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
|
|
176
|
+
EXIT.USAGE,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const apiBase = resolveApiBase(args);
|
|
180
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
181
|
+
requireSafeUrl('--api', apiBase);
|
|
182
|
+
const room = resolveRoom(args);
|
|
183
|
+
if (needRoom && !room) {
|
|
184
|
+
fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
|
|
185
|
+
}
|
|
186
|
+
return { token, apiBase, room };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Persist the active credential plus the bits the status line prints. */
|
|
190
|
+
export function saveCredential({ token, handle, room, rooms, roomAccess, account, scopes, apiBase }) {
|
|
191
|
+
writeJsonFile(credentialsPath(), {
|
|
192
|
+
version: 1,
|
|
193
|
+
token,
|
|
194
|
+
handle: handle || null,
|
|
195
|
+
// `room` is the delivery room — where handoffs and questions land. `rooms`
|
|
196
|
+
// is the whole grant, which can be wider; `room_access: "all"` means the
|
|
197
|
+
// human granted every room they are in, listing none.
|
|
198
|
+
room: room || null,
|
|
199
|
+
rooms: Array.isArray(rooms) ? rooms : [],
|
|
200
|
+
room_access: roomAccess || null,
|
|
201
|
+
account: account || null,
|
|
202
|
+
scopes: scopes || [],
|
|
203
|
+
api_url: apiBase,
|
|
204
|
+
created_at: new Date().toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Endpoints and exit codes shared by every command surface.
|
|
2
|
+
|
|
3
|
+
export const BUILTIN_API = 'https://api.pingroom.io';
|
|
4
|
+
export const MCP_ENDPOINT = `${BUILTIN_API}/api/agent/mcp`;
|
|
5
|
+
export const DEFAULT_API = process.env.PINGROOM_API_URL || BUILTIN_API;
|
|
6
|
+
|
|
7
|
+
export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
|
|
8
|
+
|
|
9
|
+
// A caller holding only a room code or webhook URL cannot know the room's
|
|
10
|
+
// visibility without another request. Keep the CLI's local ceiling at the
|
|
11
|
+
// public-room limit; Laravel applies the tighter private-room limit.
|
|
12
|
+
export const PING_TITLE_MAX_LENGTH = 40;
|
|
13
|
+
export const PRIVATE_PING_MESSAGE_MAX_LENGTH = 120;
|
|
14
|
+
export const PUBLIC_PING_MESSAGE_MAX_LENGTH = 160;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The GitHub Actions output file. The CLI owns $GITHUB_OUTPUT end to end: the
|
|
2
|
+
// shell in action.yml never parses stdout, never redirects into the file, and
|
|
3
|
+
// never gets to name a key.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { appendFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
import { EXIT } from './constants.js';
|
|
9
|
+
import { fail } from './util.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Append the composite Action's declared outputs without interpreting stdout.
|
|
13
|
+
* Values use GitHub's multiline protocol with a fresh random delimiter. The
|
|
14
|
+
* `fields` a caller passes are a FIXED allowlist built from constants — never
|
|
15
|
+
* from server data — so untrusted answer text can never create a key.
|
|
16
|
+
*/
|
|
17
|
+
export function writeGitHubOutputs(path, fields) {
|
|
18
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
19
|
+
fail('--github-output must be a non-empty path', EXIT.USAGE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const blocks = fields.map(([name, rawValue]) => {
|
|
23
|
+
const value = String(rawValue ?? '');
|
|
24
|
+
let delimiter;
|
|
25
|
+
do {
|
|
26
|
+
delimiter = `pingroom_${randomBytes(24).toString('hex')}`;
|
|
27
|
+
} while (value.includes(delimiter));
|
|
28
|
+
// Keep the collision check next to serialization: a delimiter must never
|
|
29
|
+
// occur in an untrusted value, even though a 192-bit collision is remote.
|
|
30
|
+
if (value.includes(delimiter)) {
|
|
31
|
+
fail('could not create a safe GitHub output delimiter');
|
|
32
|
+
}
|
|
33
|
+
return `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
appendFileSync(path, blocks.join(''), { encoding: 'utf8' });
|
|
38
|
+
} catch {
|
|
39
|
+
fail('could not write GitHub outputs');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The handoff half of the Action's output contract. */
|
|
44
|
+
export function writeGitHubHandoffOutputs(path, h) {
|
|
45
|
+
const ackerId = h.acked_by && typeof h.acked_by === 'object'
|
|
46
|
+
? h.acked_by.id
|
|
47
|
+
: h.acked_by;
|
|
48
|
+
const fields = [
|
|
49
|
+
['handoff-id', h.id ?? ''],
|
|
50
|
+
['state', h.state ?? ''],
|
|
51
|
+
];
|
|
52
|
+
if (h.delivery_state != null) fields.push(['delivery-state', h.delivery_state]);
|
|
53
|
+
if (h.state === 'answered') {
|
|
54
|
+
fields.push(['answer', h.answer && (h.answer.value ?? h.answer.text) || '']);
|
|
55
|
+
}
|
|
56
|
+
if (h.state === 'acked') fields.push(['acknowledged-by', ackerId ?? '']);
|
|
57
|
+
|
|
58
|
+
writeGitHubOutputs(path, fields);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `ask` half. Deliberately narrower than the handoff mapper: a question has
|
|
63
|
+
* no delivery_state and no acker, so the allowlist is exactly question-id,
|
|
64
|
+
* state, and — only once answered — answer.
|
|
65
|
+
*/
|
|
66
|
+
export function writeGitHubQuestionOutputs(path, q) {
|
|
67
|
+
const fields = [
|
|
68
|
+
['question-id', q.id ?? ''],
|
|
69
|
+
['state', q.state ?? ''],
|
|
70
|
+
];
|
|
71
|
+
if (q.state === 'answered') {
|
|
72
|
+
fields.push(['answer', q.answer && (q.answer.value ?? q.answer.text) || '']);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
writeGitHubOutputs(path, fields);
|
|
76
|
+
}
|