@pingroom/cli 0.7.0 → 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.
@@ -0,0 +1,165 @@
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
+ // 256, not the 500 a ping body gets: this is the card's one live line.
55
+ requireMaxLength(args.message, 256, '--message');
56
+ requireMaxLength(args.title, 40, '--title');
57
+ requireMaxLength(args.prompt, 256, '--prompt');
58
+ requireMaxLength(args.center, 40, '--center');
59
+ if (args.message !== undefined) liveStatus.message = args.message;
60
+ if (args.prompt !== undefined) liveStatus.prompt = args.prompt;
61
+
62
+ const progress = numberOption(args.progress, '--progress', { min: 0, max: 1 });
63
+ if (progress !== undefined) liveStatus.progress = progress;
64
+
65
+ const step = numberOption(args.step, '--step', { min: 0, max: 8, integer: true });
66
+ if (step !== undefined) liveStatus.current_step = step;
67
+
68
+ const deadlineAt = numberOption(args.deadline_at, '--deadline-at', { min: 0, integer: true });
69
+ if (deadlineAt !== undefined) liveStatus.deadline_at = deadlineAt;
70
+
71
+ const etaAt = numberOption(args.eta_at, '--eta-at', { min: 0, integer: true });
72
+ if (etaAt !== undefined) liveStatus.eta_at = etaAt;
73
+
74
+ const metrics = buildMetrics(args.metric);
75
+ if (metrics) liveStatus.metrics = metrics;
76
+
77
+ const options = buildLiveOptions(args.option);
78
+ if (options) {
79
+ if (options.length > 4) fail('--option accepts at most 4 choices', EXIT.USAGE);
80
+ liveStatus.options = options;
81
+ }
82
+
83
+ const left = buildSide(args.left, '--left');
84
+ if (left) liveStatus.left = left;
85
+ const right = buildSide(args.right, '--right');
86
+ if (right) liveStatus.right = right;
87
+ if (args.center !== undefined) liveStatus.center = args.center;
88
+
89
+ const accent = normalizeAccent(args.accent_override);
90
+ if (accent) liveStatus.accent_override = accent;
91
+
92
+ // Template, category and step labels are fixed when the stream is created;
93
+ // sending them on an update is a no-op server-side, so only `start` takes them.
94
+ if (sub === 'start') {
95
+ // Validated locally for the same reason --category is: a typo'd name is a
96
+ // usage error, and letting it reach the server turns it into a 422 round
97
+ // trip that reads like an outage.
98
+ if (args.template) {
99
+ const template = canonicalTemplate(args.template);
100
+ if (!LIVE_TEMPLATES.includes(template)) {
101
+ fail(`--template must be one of: ${LIVE_TEMPLATE_NAMES.join(', ')}`, EXIT.USAGE);
102
+ }
103
+ liveStatus.template = template;
104
+ }
105
+ // `alert` has no template equivalent and is the only way to start a stream
106
+ // time-sensitive (breaking through Focus) without also demanding an ack.
107
+ if (args.category) {
108
+ if (!['status', 'steps', 'alert'].includes(args.category)) {
109
+ fail('--category must be status, steps or alert', EXIT.USAGE);
110
+ }
111
+ liveStatus.category = args.category;
112
+ }
113
+ if (args.steps) {
114
+ const labels = args.steps.split(',').map((s) => s.trim()).filter(Boolean);
115
+ if (labels.length < 2 || labels.length > 8) {
116
+ fail('--steps needs between 2 and 8 comma-separated labels', EXIT.USAGE);
117
+ }
118
+ liveStatus.steps = labels;
119
+ }
120
+ } else if (args.template || args.steps || args.category) {
121
+ fail('--template, --category and --steps are fixed at stream creation; pass them to "live start"', EXIT.USAGE);
122
+ }
123
+
124
+ const body = { correlation_id: correlationId, live_status: liveStatus };
125
+ if (args.title) body.title = args.title;
126
+ if (args.action !== undefined) body.action = Number(args.action);
127
+ // Same object-shape guard ping/ask/handoff use. A bare JSON.parse also accepts
128
+ // an array, which the server then rejects — a wasted round trip for what is a
129
+ // local usage error.
130
+ // `!== undefined`, not truthiness: `-d ''` is a malformed value, and a
131
+ // truthiness test drops it on the floor and ships the ping without the data
132
+ // the caller believed they attached. ping/ask/handoff all reject it loudly.
133
+ if (args.data !== undefined) body.data = parseDataObject(args.data);
134
+ if (args.require_ack) body.requires_ack = true;
135
+ const ackTimeout = numberOption(args.ack_timeout, '--ack-timeout', { min: 1, max: 86_400, integer: true });
136
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
137
+
138
+ let result;
139
+ if (webhook) {
140
+ requireSafeUrl('--webhook', webhook);
141
+ result = await httpJson('POST', webhook, { body });
142
+ } else if (token) {
143
+ requireStoredCredentialOrigin(args, apiBase);
144
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
145
+ requireSafeUrl('--api', apiBase);
146
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/live`;
147
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
148
+ } else {
149
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
150
+ }
151
+
152
+ const { res, text, json } = result;
153
+ if (args.json) process.stdout.write(`${text || '{}'}\n`);
154
+
155
+ if (!res.ok || (json && json.success === false)) {
156
+ const detail = apiDetail(res, json);
157
+ fail(`live ${sub} failed: ${detail}`);
158
+ }
159
+
160
+ if (!args.json) {
161
+ const state = (json && (json.state || (json.live_status && json.live_status.state))) || sub;
162
+ process.stdout.write(`live ${sub} → ${state} ✅\n`);
163
+ }
164
+ return EXIT.OK;
165
+ }
@@ -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,123 @@
1
+ import { EXIT } 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
+ requireMaxLength(message, 500, '--message');
13
+ requireMaxLength(args.title, 40, '--title');
14
+
15
+ if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
16
+ fail('--action must be an integer 1–4', EXIT.USAGE);
17
+ }
18
+
19
+ let ackTimeout;
20
+ if (args.ack_timeout !== undefined) {
21
+ if (!args.require_ack) {
22
+ fail('--ack-timeout requires --require-ack', EXIT.USAGE);
23
+ }
24
+ if (!/^\d+$/.test(String(args.ack_timeout))) {
25
+ fail('--ack-timeout must be an integer number of seconds', EXIT.USAGE);
26
+ }
27
+ ackTimeout = Number(args.ack_timeout);
28
+ }
29
+
30
+ let data;
31
+ if (args.data !== undefined) {
32
+ data = parseDataObject(args.data);
33
+ }
34
+
35
+ // Link ping: --url/--button-label fold into the structured data object
36
+ // (server contract: data.url = absolute http(s) <= 2048, data.button_label <= 26).
37
+ if (args.button_label !== undefined && args.url === undefined) {
38
+ fail('--button-label requires --url', EXIT.USAGE);
39
+ }
40
+ if (args.url !== undefined) {
41
+ let linkUrl;
42
+ try {
43
+ linkUrl = new URL(args.url);
44
+ } catch {
45
+ fail('--url is not a valid URL', EXIT.USAGE);
46
+ }
47
+ if (linkUrl.protocol !== 'https:' && linkUrl.protocol !== 'http:') {
48
+ fail('--url must be an absolute http(s) URL', EXIT.USAGE);
49
+ }
50
+ if (args.url.length > 2048) {
51
+ fail('--url must be at most 2048 characters', EXIT.USAGE);
52
+ }
53
+ if (args.button_label !== undefined && args.button_label.length > 26) {
54
+ fail('--button-label must be at most 26 characters', EXIT.USAGE);
55
+ }
56
+ data = { ...(data || {}), url: args.url };
57
+ if (args.button_label !== undefined) data.button_label = args.button_label;
58
+ }
59
+
60
+ const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
61
+ const token = resolveToken(args);
62
+ const apiBase = resolveApiBase(args);
63
+ const room = resolveRoom(args);
64
+
65
+ let result;
66
+
67
+ // Attachments exist only on the agent-token path: an incoming webhook has no
68
+ // uploader identity to bind private files to, so the API takes no ids there.
69
+ const attachPaths = args.attach ?? [];
70
+ if (attachPaths.length && (webhook || !token)) {
71
+ fail('--attach requires an agent token (--token / PINGROOM_TOKEN), not a webhook ping', EXIT.USAGE);
72
+ }
73
+
74
+ if (webhook) {
75
+ if (ackTimeout !== undefined && (ackTimeout < 1 || ackTimeout > 86_400)) {
76
+ fail('--ack-timeout must be between 1 and 86400 seconds for a webhook ping', EXIT.USAGE);
77
+ }
78
+ requireSafeUrl('--webhook', webhook);
79
+ const body = { message };
80
+ if (args.title) body.title = args.title;
81
+ if (args.action !== undefined) body.action = Number(args.action);
82
+ if (data) body.data = data;
83
+ if (args.require_ack) body.requires_ack = true;
84
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
85
+ result = await httpJson('POST', webhook, { body });
86
+ } else if (token) {
87
+ requireStoredCredentialOrigin(args, apiBase);
88
+ if (!room) fail('--room is required when using --token (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
89
+ if (ackTimeout !== undefined && (ackTimeout < 60 || ackTimeout > 86_400)) {
90
+ fail('--ack-timeout must be between 60 and 86400 seconds for an agent room ping', EXIT.USAGE);
91
+ }
92
+ requireSafeUrl('--api', apiBase);
93
+ const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`;
94
+ const body = { message };
95
+ if (args.title) body.title = args.title;
96
+ if (args.action !== undefined) body.action_number = Number(args.action);
97
+ if (data) body.data = data;
98
+ if (args.require_ack) body.requires_ack = true;
99
+ if (ackTimeout !== undefined) body.ack_timeout_seconds = ackTimeout;
100
+ if (attachPaths.length) {
101
+ body.attachment_ids = await uploadAttachments(attachPaths, apiBase, token);
102
+ }
103
+ result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
104
+ } else {
105
+ fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN, or run "pingroom" to connect)', EXIT.USAGE);
106
+ }
107
+
108
+ const { res, text, json } = result;
109
+
110
+ if (args.json) {
111
+ process.stdout.write(`${text || '{}'}\n`);
112
+ }
113
+
114
+ const ok = res.ok && !(json && json.success === false);
115
+
116
+ if (!ok) {
117
+ const detail = apiDetail(res, json);
118
+ fail(`delivery failed: ${detail}`);
119
+ }
120
+
121
+ if (!args.json) process.stdout.write('ping sent ✅\n');
122
+ return EXIT.OK;
123
+ }
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
+ }
@@ -0,0 +1,7 @@
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 };
@@ -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
+ }