@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/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.2",
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"