@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
package/lib/parser.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// One argv parser per command surface, all built from the same table-driven
|
|
2
|
+
// factory. Splitting them out keeps the flag vocabulary in one file.
|
|
3
|
+
|
|
4
|
+
import { EXIT } from './constants.js';
|
|
5
|
+
import { fail } from './util.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build an argv parser from a flag table. Every command parser runs the same
|
|
9
|
+
* loop; only the tables differ:
|
|
10
|
+
* aliases flag or alias -> canonical args key
|
|
11
|
+
* booleans keys that take no value
|
|
12
|
+
* repeatable keys collected into an array (the flag may repeat)
|
|
13
|
+
* bareDashIsPositional whether a lone `-` collects into `_` (the question-
|
|
14
|
+
* style parsers) or fails as an unknown option (ping,
|
|
15
|
+
* live)
|
|
16
|
+
* Unknown flags always fail as a usage error; bare words collect into `_`.
|
|
17
|
+
*/
|
|
18
|
+
export function makeParser({ aliases, booleans, repeatable = [], bareDashIsPositional = false }) {
|
|
19
|
+
const booleanKeys = new Set(booleans);
|
|
20
|
+
const repeatableKeys = new Set(repeatable);
|
|
21
|
+
function parse(argv) {
|
|
22
|
+
const args = { _: [] };
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const token = argv[i];
|
|
25
|
+
// Object.hasOwn, not aliases[token]: a bare lookup walks the prototype
|
|
26
|
+
// chain, so `constructor` / `toString` / `__proto__` in flag position
|
|
27
|
+
// resolve to a truthy inherited value, get treated as an option, and
|
|
28
|
+
// swallow the next argument instead of failing as an unknown flag.
|
|
29
|
+
const key = Object.hasOwn(aliases, token) ? aliases[token] : undefined;
|
|
30
|
+
if (key && booleanKeys.has(key)) {
|
|
31
|
+
args[key] = true;
|
|
32
|
+
} else if (key) {
|
|
33
|
+
const value = argv[++i];
|
|
34
|
+
if (value === undefined) {
|
|
35
|
+
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
36
|
+
}
|
|
37
|
+
if (repeatableKeys.has(key)) (args[key] ||= []).push(value);
|
|
38
|
+
else args[key] = value;
|
|
39
|
+
} else if (token.startsWith('-') && !(bareDashIsPositional && token === '-')) {
|
|
40
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
41
|
+
} else {
|
|
42
|
+
args._.push(token);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
// The accepted flag vocabulary, published on the parser itself. The release
|
|
48
|
+
// gate reads it to assert that every flag `action.yml` hands the CLI is one
|
|
49
|
+
// the matching parser accepts — the check that would have caught `ask
|
|
50
|
+
// --github-output` being wired into the handoff parser only.
|
|
51
|
+
parse.flags = new Set(Object.keys(aliases));
|
|
52
|
+
return parse;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const parseArgs = makeParser({
|
|
56
|
+
aliases: {
|
|
57
|
+
'-m': 'message', '--message': 'message',
|
|
58
|
+
'-t': 'title', '--title': 'title',
|
|
59
|
+
'-a': 'action', '--action': 'action',
|
|
60
|
+
'-d': 'data', '--data': 'data',
|
|
61
|
+
'-w': 'webhook', '--webhook': 'webhook',
|
|
62
|
+
'--url': 'url',
|
|
63
|
+
'--button-label': 'button_label',
|
|
64
|
+
'--require-ack': 'require_ack',
|
|
65
|
+
'--urgent': 'urgent',
|
|
66
|
+
'--ack-timeout': 'ack_timeout',
|
|
67
|
+
'--attach': 'attach',
|
|
68
|
+
'--token': 'token',
|
|
69
|
+
'--room': 'room',
|
|
70
|
+
'--api': 'api',
|
|
71
|
+
'--json': 'json',
|
|
72
|
+
'-h': 'help', '--help': 'help',
|
|
73
|
+
},
|
|
74
|
+
booleans: ['require_ack', 'urgent', 'json', 'help'],
|
|
75
|
+
repeatable: ['attach'],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Parser for the question commands: supports repeatable --option and a trailing
|
|
79
|
+
// positional (a question id). Unknown flags fail like the ping parser.
|
|
80
|
+
export const parseQArgs = makeParser({
|
|
81
|
+
aliases: {
|
|
82
|
+
'-p': 'prompt', '--prompt': 'prompt',
|
|
83
|
+
'-o': 'option', '--option': 'option',
|
|
84
|
+
'-c': 'context', '--context': 'context',
|
|
85
|
+
'--scope': 'scope',
|
|
86
|
+
'--target': 'target',
|
|
87
|
+
'--ttl': 'ttl',
|
|
88
|
+
'-d': 'data', '--data': 'data',
|
|
89
|
+
'--correlation-id': 'correlation_id',
|
|
90
|
+
'--reply-to': 'reply_to',
|
|
91
|
+
'--text-input': 'text_input',
|
|
92
|
+
'--text-max': 'text_max',
|
|
93
|
+
'--timeout': 'timeout',
|
|
94
|
+
'--state': 'state',
|
|
95
|
+
'--limit': 'limit',
|
|
96
|
+
'--from': 'from',
|
|
97
|
+
'--once': 'once',
|
|
98
|
+
'--github-output': 'github_output',
|
|
99
|
+
'--token': 'token',
|
|
100
|
+
'--room': 'room',
|
|
101
|
+
'--api': 'api',
|
|
102
|
+
'--wait': 'wait',
|
|
103
|
+
'--json': 'json',
|
|
104
|
+
'-h': 'help', '--help': 'help',
|
|
105
|
+
},
|
|
106
|
+
booleans: ['wait', 'json', 'help', 'once'],
|
|
107
|
+
repeatable: ['option'],
|
|
108
|
+
bareDashIsPositional: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Parser for `handoff`: --message plus repeatable --option, boolean --question,
|
|
112
|
+
// and the handoff-specific flags. Unknown flags fail like the other parsers.
|
|
113
|
+
export const parseHandoffArgs = makeParser({
|
|
114
|
+
aliases: {
|
|
115
|
+
'-m': 'message', '--message': 'message',
|
|
116
|
+
'--question': 'question',
|
|
117
|
+
'-o': 'option', '--option': 'option',
|
|
118
|
+
'--target': 'target',
|
|
119
|
+
'--expires-in': 'expires_in',
|
|
120
|
+
'--urgency': 'urgency',
|
|
121
|
+
'--idempotency-key': 'idempotency_key',
|
|
122
|
+
'--correlation-id': 'correlation_id',
|
|
123
|
+
'--reply-to': 'reply_to',
|
|
124
|
+
'-d': 'data', '--data': 'data',
|
|
125
|
+
'--timeout': 'timeout',
|
|
126
|
+
'--github-output': 'github_output',
|
|
127
|
+
'--token': 'token',
|
|
128
|
+
'--api': 'api',
|
|
129
|
+
'--wait': 'wait',
|
|
130
|
+
'--json': 'json',
|
|
131
|
+
'-h': 'help', '--help': 'help',
|
|
132
|
+
},
|
|
133
|
+
booleans: ['question', 'wait', 'json', 'help'],
|
|
134
|
+
repeatable: ['option'],
|
|
135
|
+
bareDashIsPositional: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Parser for `live`: a leading subcommand (start|update|end|get) plus the
|
|
139
|
+
// live-status flags. Unknown flags fail like the other parsers.
|
|
140
|
+
export const parseLiveArgs = makeParser({
|
|
141
|
+
aliases: {
|
|
142
|
+
'-c': 'correlation_id', '--correlation-id': 'correlation_id',
|
|
143
|
+
'-t': 'title', '--title': 'title',
|
|
144
|
+
'-m': 'message', '--message': 'message',
|
|
145
|
+
'--template': 'template',
|
|
146
|
+
'--category': 'category',
|
|
147
|
+
'--progress': 'progress',
|
|
148
|
+
'--step': 'step',
|
|
149
|
+
'--steps': 'steps',
|
|
150
|
+
'--metric': 'metric',
|
|
151
|
+
'--deadline-at': 'deadline_at',
|
|
152
|
+
'--eta-at': 'eta_at',
|
|
153
|
+
'--prompt': 'prompt',
|
|
154
|
+
'--option': 'option',
|
|
155
|
+
'--left': 'left',
|
|
156
|
+
'--right': 'right',
|
|
157
|
+
'--center': 'center',
|
|
158
|
+
'--accent-override': 'accent_override',
|
|
159
|
+
'--failed': 'failed',
|
|
160
|
+
'-a': 'action', '--action': 'action',
|
|
161
|
+
'-d': 'data', '--data': 'data',
|
|
162
|
+
'--require-ack': 'require_ack',
|
|
163
|
+
// No --urgent here on purpose. A STREAM starts time-sensitive via
|
|
164
|
+
// `--category alert` (fixed at creation); the live-status endpoint does not
|
|
165
|
+
// accept `is_urgent`, so the flag would parse and then be silently dropped.
|
|
166
|
+
'--ack-timeout': 'ack_timeout',
|
|
167
|
+
'-w': 'webhook', '--webhook': 'webhook',
|
|
168
|
+
'--token': 'token',
|
|
169
|
+
'--room': 'room',
|
|
170
|
+
'--api': 'api',
|
|
171
|
+
'--json': 'json',
|
|
172
|
+
'-h': 'help', '--help': 'help',
|
|
173
|
+
},
|
|
174
|
+
booleans: ['require_ack', 'json', 'help', 'failed'],
|
|
175
|
+
repeatable: ['metric', 'option'],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export const parseHookArgs = makeParser({
|
|
179
|
+
aliases: {
|
|
180
|
+
'--room': 'room',
|
|
181
|
+
'--ttl': 'ttl',
|
|
182
|
+
'--quiet': 'quiet',
|
|
183
|
+
'--print-config': 'print_config',
|
|
184
|
+
'--token': 'token',
|
|
185
|
+
'--api': 'api',
|
|
186
|
+
'--json': 'json',
|
|
187
|
+
'-h': 'help', '--help': 'help',
|
|
188
|
+
},
|
|
189
|
+
booleans: ['quiet', 'print_config', 'json', 'help'],
|
|
190
|
+
bareDashIsPositional: true,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// config/logout/handoffs used to share parseQArgs, which silently accepted and
|
|
194
|
+
// ignored flags those commands never read (`logout --wait --prompt x`). Minimal
|
|
195
|
+
// tables instead, so an irrelevant flag is a usage error like everywhere else.
|
|
196
|
+
export const parseConfigArgs = makeParser({
|
|
197
|
+
aliases: { '--json': 'json', '-h': 'help', '--help': 'help' },
|
|
198
|
+
booleans: ['json', 'help'],
|
|
199
|
+
bareDashIsPositional: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
export const parseLogoutArgs = makeParser({
|
|
203
|
+
aliases: { '-h': 'help', '--help': 'help' },
|
|
204
|
+
booleans: ['help'],
|
|
205
|
+
bareDashIsPositional: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export const parseHandoffsArgs = makeParser({
|
|
209
|
+
aliases: {
|
|
210
|
+
'--state': 'state',
|
|
211
|
+
'--token': 'token',
|
|
212
|
+
'--api': 'api',
|
|
213
|
+
'--json': 'json',
|
|
214
|
+
'-h': 'help', '--help': 'help',
|
|
215
|
+
},
|
|
216
|
+
booleans: ['json', 'help'],
|
|
217
|
+
bareDashIsPositional: true,
|
|
218
|
+
});
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Turning flag strings into wire objects, and wire objects back into the exact
|
|
2
|
+
// lines this CLI prints. Pure functions with no I/O beyond stdout/stderr, which
|
|
3
|
+
// is what makes them unit-testable without a subprocess (see test/render.test.mjs).
|
|
4
|
+
|
|
5
|
+
import { EXIT } from './constants.js';
|
|
6
|
+
import { fail, stripControlChars } from './util.js';
|
|
7
|
+
|
|
8
|
+
// The templates the server accepts on `live start`. Mirrored here so a typo is
|
|
9
|
+
// a local usage error instead of a 422 from the API. Keep in lockstep with the
|
|
10
|
+
// --template line in HELP and with LIVE_ACTIVITY_TEMPLATES.md.
|
|
11
|
+
export const LIVE_TEMPLATES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'question', 'matchup'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Names the API does not take, folded onto the wire id it does.
|
|
15
|
+
*
|
|
16
|
+
* The `question` template is labelled **Decision** everywhere a person sees it,
|
|
17
|
+
* so it is never confused with PingRoom's first-class Question protocol — that
|
|
18
|
+
* one is answered through `pingroom ask`, carries a real Question id, and this
|
|
19
|
+
* template does not. The wire id stayed `question`, so someone who reads
|
|
20
|
+
* "Decision" in the app and types it would otherwise get a usage error for
|
|
21
|
+
* using the only name they have been shown.
|
|
22
|
+
*/
|
|
23
|
+
export const LIVE_TEMPLATE_ALIASES = { decision: 'question' };
|
|
24
|
+
|
|
25
|
+
/** The wire id for a template name a human typed, or the name unchanged. */
|
|
26
|
+
export function canonicalTemplate(name) {
|
|
27
|
+
return LIVE_TEMPLATE_ALIASES[name] ?? name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What we offer in help and errors: the alias leads, since it is what the app shows. */
|
|
31
|
+
export const LIVE_TEMPLATE_NAMES = ['status', 'steps', 'progress', 'metrics', 'countdown', 'decision', 'matchup'];
|
|
32
|
+
|
|
33
|
+
// "label:value" -> {label, value}. Only the first colon splits.
|
|
34
|
+
export function buildMetrics(list) {
|
|
35
|
+
if (!list || list.length === 0) return undefined;
|
|
36
|
+
return list.map((spec) => {
|
|
37
|
+
const idx = spec.indexOf(':');
|
|
38
|
+
if (idx <= 0) fail(`--metric must be "label:value" (got "${spec}")`, EXIT.USAGE);
|
|
39
|
+
return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// "value:label" -> {value, label}; a bare token is both. Matches the `ask`
|
|
44
|
+
// command's option syntax minus `style`, which live_status options don't carry.
|
|
45
|
+
export function buildLiveOptions(list) {
|
|
46
|
+
if (!list || list.length === 0) return undefined;
|
|
47
|
+
return list.map((spec) => {
|
|
48
|
+
const idx = spec.indexOf(':');
|
|
49
|
+
if (idx < 0) return { value: spec, label: spec };
|
|
50
|
+
if (idx === 0) fail(`--option needs a value before the colon (got "${spec}")`, EXIT.USAGE);
|
|
51
|
+
return { value: spec.slice(0, idx), label: spec.slice(idx + 1) };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// "label:value" -> {label, value}, for --left / --right on the matchup template.
|
|
56
|
+
export function buildSide(spec, flag) {
|
|
57
|
+
if (spec === undefined) return undefined;
|
|
58
|
+
const idx = spec.indexOf(':');
|
|
59
|
+
if (idx <= 0) fail(`${flag} must be "label:value" (got "${spec}")`, EXIT.USAGE);
|
|
60
|
+
return { label: spec.slice(0, idx), value: spec.slice(idx + 1) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The server accepts #rrggbb with or without the leading #; normalize to one
|
|
64
|
+
// form so a shell that ate the # (unquoted) still produces a valid payload.
|
|
65
|
+
export function normalizeAccent(raw) {
|
|
66
|
+
if (raw === undefined) return undefined;
|
|
67
|
+
const hex = raw.trim().replace(/^#/, '');
|
|
68
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
|
|
69
|
+
fail(`--accent-override must be a 6-digit hex color (got "${raw}")`, EXIT.USAGE);
|
|
70
|
+
}
|
|
71
|
+
return `#${hex.toLowerCase()}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// value:label -> {value, label}. Labels may contain colons (only the first
|
|
75
|
+
// splits). A bare token is both value and label. Omit all for Approve/Deny.
|
|
76
|
+
export function buildOptions(list) {
|
|
77
|
+
if (!list || list.length === 0) return undefined;
|
|
78
|
+
return list.map((spec) => {
|
|
79
|
+
const idx = spec.indexOf(':');
|
|
80
|
+
const value = idx === -1 ? spec : spec.slice(0, idx);
|
|
81
|
+
let label = idx === -1 ? spec : spec.slice(idx + 1);
|
|
82
|
+
if (!value) fail(`--option must be "value", "value:label" or "value:label:style" (got "${spec}")`, EXIT.USAGE);
|
|
83
|
+
// A trailing :primary|:danger|:default segment styles the button; any other
|
|
84
|
+
// trailing segment stays part of the label (labels may contain colons).
|
|
85
|
+
let style;
|
|
86
|
+
const lastColon = label.lastIndexOf(':');
|
|
87
|
+
if (lastColon !== -1) {
|
|
88
|
+
const candidate = label.slice(lastColon + 1);
|
|
89
|
+
if (candidate === 'primary' || candidate === 'danger' || candidate === 'default') {
|
|
90
|
+
style = candidate;
|
|
91
|
+
label = label.slice(0, lastColon);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return style ? { value, label, style } : { value, label };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function exitForState(state) {
|
|
99
|
+
switch (state) {
|
|
100
|
+
case 'answered': return EXIT.OK;
|
|
101
|
+
case 'expired': return EXIT.EXPIRED;
|
|
102
|
+
case 'cancelled': return EXIT.CANCELLED;
|
|
103
|
+
default: return EXIT.ERROR;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Print the outcome. On `answered`, the chosen value (or typed text) goes to
|
|
108
|
+
// stdout so `$(pingroom ask --wait ...)` captures it; other outcomes report to
|
|
109
|
+
// stderr and leave stdout empty.
|
|
110
|
+
export function printResolution(q) {
|
|
111
|
+
if (q.state === 'answered') {
|
|
112
|
+
const out = q.answer && (q.answer.text || q.answer.value) || '';
|
|
113
|
+
process.stdout.write(`${out}\n`);
|
|
114
|
+
} else {
|
|
115
|
+
process.stderr.write(`pingroom: question ${q.state}\n`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One readable line per incoming ping. */
|
|
120
|
+
export function formatIncoming(item) {
|
|
121
|
+
const room = item?.room?.name || item?.room?.code || '?';
|
|
122
|
+
const body = stripControlChars(item?.message ?? '');
|
|
123
|
+
const marks = [];
|
|
124
|
+
if (item?.correlation_id) marks.push(`corr=${stripControlChars(item.correlation_id)}`);
|
|
125
|
+
if (item?.reply_to) marks.push(`reply_to=${stripControlChars(item.reply_to)}`);
|
|
126
|
+
if (item?.question) marks.push('question');
|
|
127
|
+
if (Array.isArray(item?.attachments) && item.attachments.length) {
|
|
128
|
+
marks.push(`${item.attachments.length} attachment${item.attachments.length === 1 ? '' : 's'}`);
|
|
129
|
+
}
|
|
130
|
+
const suffix = marks.length ? ` (${marks.join(' · ')})` : '';
|
|
131
|
+
return `[${stripControlChars(room)}] ${body}${suffix}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Terminal wire states across both kinds. ack: open→acked|expired.
|
|
135
|
+
// question: pending→answered|expired|cancelled. `open`/`pending` are the only
|
|
136
|
+
// non-terminal states, so a wait loop against these always terminates.
|
|
137
|
+
export const HANDOFF_PENDING = new Set(['open', 'pending']);
|
|
138
|
+
|
|
139
|
+
// Map a terminal handoff state to an exit code. A `question` answered with ANY
|
|
140
|
+
// value is a success (0) — a negative human decision ('hold'/'deny') is NOT an
|
|
141
|
+
// infra failure. `acked` is likewise 0. `expired` is a distinct 3 so CI can
|
|
142
|
+
// branch; `cancelled` shares 4 with recipient_not_ready.
|
|
143
|
+
export function exitForHandoffState(state) {
|
|
144
|
+
switch (state) {
|
|
145
|
+
case 'acked': return EXIT.OK;
|
|
146
|
+
case 'answered': return EXIT.OK;
|
|
147
|
+
case 'expired': return EXIT.EXPIRED;
|
|
148
|
+
case 'cancelled': return EXIT.CANCELLED;
|
|
149
|
+
default: return EXIT.ERROR;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Print a machine-readable summary of a handoff: id, state, delivery-state, and
|
|
154
|
+
// the answer value / acked-by when present, one `key=value` per line to stdout.
|
|
155
|
+
export function printHandoff(h) {
|
|
156
|
+
const lines = [`id=${h.id ?? ''}`, `state=${h.state ?? ''}`];
|
|
157
|
+
if (h.delivery_state != null) lines.push(`delivery-state=${h.delivery_state}`);
|
|
158
|
+
if (h.correlation_id) lines.push(`correlation-id=${h.correlation_id}`);
|
|
159
|
+
if (h.state === 'answered') {
|
|
160
|
+
const value = h.answer && (h.answer.value ?? h.answer.text) || '';
|
|
161
|
+
lines.push(`answer=${value}`);
|
|
162
|
+
}
|
|
163
|
+
if (h.state === 'acked') {
|
|
164
|
+
// The Handoff API returns a privacy-aware actor object. Only expose its id
|
|
165
|
+
// in the machine-readable CLI/GitHub Action output; a redacted actor yields
|
|
166
|
+
// an empty value instead of the unhelpful "[object Object]" string.
|
|
167
|
+
const ackerId = h.acked_by && typeof h.acked_by === 'object'
|
|
168
|
+
? h.acked_by.id
|
|
169
|
+
: h.acked_by;
|
|
170
|
+
lines.push(`acked-by=${ackerId ?? ''}`);
|
|
171
|
+
if (h.acked_at) lines.push(`acked-at=${h.acked_at}`);
|
|
172
|
+
}
|
|
173
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
174
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Small primitives every other module leans on: exiting, sleeping, sanitizing
|
|
2
|
+
// untrusted text, and the local validations that turn a would-be 422 into a
|
|
3
|
+
// usage error.
|
|
4
|
+
|
|
5
|
+
import { EXIT } from './constants.js';
|
|
6
|
+
|
|
7
|
+
export function fail(message, code = EXIT.ERROR) {
|
|
8
|
+
process.stderr.write(`pingroom: ${message}\n`);
|
|
9
|
+
process.exit(code);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when it is safe to prompt / draw a QR. Both streams must be a TTY: a
|
|
14
|
+
* piped stdin cannot answer a prompt and a piped stdout would capture the QR as
|
|
15
|
+
* garbage.
|
|
16
|
+
*
|
|
17
|
+
* The override is deliberately double-locked (internal-looking name AND
|
|
18
|
+
* NODE_ENV=test) and not documented in --help. A single well-known env var
|
|
19
|
+
* shipping in the published binary is one stray `export` away from making a CI
|
|
20
|
+
* job prompt into the void and poll for the full 15-minute pairing window
|
|
21
|
+
* instead of failing in a second.
|
|
22
|
+
*/
|
|
23
|
+
export function isInteractive() {
|
|
24
|
+
if (process.env.PINGROOM_INTERNAL_TEST_TTY === '1' && process.env.NODE_ENV === 'test') return true;
|
|
25
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sleep(ms) {
|
|
29
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Drop C0/C1 control characters before echoing server-supplied text to the
|
|
33
|
+
// terminal. Without this an attacker-controlled API base can smuggle ANSI
|
|
34
|
+
// escapes into the output and repaint, erase or overwrite the lines around them.
|
|
35
|
+
export function stripControlChars(value) {
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
return String(value).replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function truncate(value, max) {
|
|
41
|
+
const str = String(value ?? '');
|
|
42
|
+
const characters = Array.from(str);
|
|
43
|
+
return characters.length <= max ? str : `${characters.slice(0, max - 1).join('')}…`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reject an over-long field here rather than letting it become a 422.
|
|
48
|
+
*
|
|
49
|
+
* Every bound mirrors a Laravel rule (StoreNotificationRequest,
|
|
50
|
+
* StoreQuestionRequest, LiveStatusRules) and is documented in --help, so a value
|
|
51
|
+
* past it was always going to be refused — locally it reads as the usage error
|
|
52
|
+
* it is, with the limit and the actual length named.
|
|
53
|
+
*/
|
|
54
|
+
export function requireMaxLength(value, max, flag) {
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
const length = Array.from(value).length;
|
|
57
|
+
if (length > max) {
|
|
58
|
+
fail(`${flag} must be at most ${max} characters (got ${length})`, EXIT.USAGE);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate --timeout and resolve the per-poll hold. Called by ask/handoff
|
|
65
|
+
* BEFORE the create POST: the old in-wait check ran only after the question or
|
|
66
|
+
* handoff already existed, so `--timeout -5` put a live question on someone's
|
|
67
|
+
* phone and then exited 2, orphaning it until its TTL.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveWaitHold(args, { def, cap }) {
|
|
70
|
+
if (args.timeout === undefined) return Math.min(def, cap);
|
|
71
|
+
const hold = Number(args.timeout);
|
|
72
|
+
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
73
|
+
return Math.min(hold, cap);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function numberOption(raw, flag, { min, max, integer = false } = {}) {
|
|
77
|
+
if (raw === undefined) return undefined;
|
|
78
|
+
const value = Number(raw);
|
|
79
|
+
if (!Number.isFinite(value)) fail(`${flag} must be a number`, EXIT.USAGE);
|
|
80
|
+
if (integer && !Number.isInteger(value)) fail(`${flag} must be an integer`, EXIT.USAGE);
|
|
81
|
+
if (min !== undefined && value < min) fail(`${flag} must be at least ${min}`, EXIT.USAGE);
|
|
82
|
+
if (max !== undefined && value > max) fail(`${flag} must be at most ${max}`, EXIT.USAGE);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseDataObject(raw) {
|
|
87
|
+
let data;
|
|
88
|
+
try {
|
|
89
|
+
data = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
fail('--data must be valid JSON', EXIT.USAGE);
|
|
92
|
+
}
|
|
93
|
+
if (typeof data !== 'object' || Array.isArray(data) || data === null) {
|
|
94
|
+
fail('--data must be a JSON object', EXIT.USAGE);
|
|
95
|
+
}
|
|
96
|
+
return data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isJsonObject(value) {
|
|
100
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isNonEmptyString(value) {
|
|
104
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isNullableString(value) {
|
|
108
|
+
return value === null || typeof value === 'string';
|
|
109
|
+
}
|
package/lib/version.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Single-sourced from package.json, which npm always ships inside the tarball,
|
|
2
|
+
// so the version can never drift from the package it was published as. The
|
|
3
|
+
// GitHub Action pins the same version in action.yml and a test keeps the two
|
|
4
|
+
// equal. `hook --print-config` emits this version.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
export const VERSION = JSON.parse(
|
|
9
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
10
|
+
).version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pingroom/cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"test": "node --test \"test/*.test.mjs\""
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"bin"
|
|
13
|
+
"bin",
|
|
14
|
+
"lib"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=20"
|