ubuyfirst 0.1.0

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,2522 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError } from "commander";
3
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import envPaths from "env-paths";
6
+ import { styleText } from "node:util";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { text } from "node:stream/consumers";
9
+ //#region package.json
10
+ var version = "0.1.0";
11
+ //#endregion
12
+ //#region src/errors.ts
13
+ /**
14
+ * The IO gate (Castle Rule). Returns the payload for a well-formed envelope and
15
+ * `null` for anything else — an HTML 404 page from a wrong `--base-url`, an
16
+ * un-enveloped body, a bare string. The caller turns `null` into exit 1 rather
17
+ * than absorbing it into an error code (spec 301: "no status fallback").
18
+ */
19
+ function readErrorEnvelope(value) {
20
+ if (typeof value !== "object" || value === null) return null;
21
+ if (!("error" in value)) return null;
22
+ const error = value.error;
23
+ if (typeof error !== "object" || error === null) return null;
24
+ if (!("code" in error) || !("retriable" in error) || !("message" in error)) return null;
25
+ const { code, retriable, message } = error;
26
+ if (typeof code !== "string") return null;
27
+ if (typeof retriable !== "boolean") return null;
28
+ if (typeof message !== "string") return null;
29
+ return {
30
+ code,
31
+ retriable,
32
+ message
33
+ };
34
+ }
35
+ /**
36
+ * A failure the SERVER reported in its own envelope.
37
+ *
38
+ * `envelope` is the parsed body exactly as received, kept because `--json` must
39
+ * emit it verbatim — re-serializing `payload` would silently drop any field the
40
+ * server added and this build does not know about.
41
+ */
42
+ var ApiCallError = class extends Error {
43
+ payload;
44
+ envelope;
45
+ constructor(payload, envelope) {
46
+ super(payload.message);
47
+ this.name = "ApiCallError";
48
+ this.payload = payload;
49
+ this.envelope = envelope;
50
+ }
51
+ };
52
+ /**
53
+ * A failure the CLI decided locally, carrying the exit code it means: a usage
54
+ * error (2), no resolvable API key (4), or a response that is not an envelope
55
+ * at all (1). Callers pass the constant from `exit-codes.ts`.
56
+ */
57
+ var CliError = class extends Error {
58
+ exitCode;
59
+ constructor(exitCode, message) {
60
+ super(message);
61
+ this.name = "CliError";
62
+ this.exitCode = exitCode;
63
+ }
64
+ };
65
+ const EXIT_BY_ERROR_CODE = {
66
+ VALIDATION: 3,
67
+ HTTP_401: 4,
68
+ ACCESS_DENIED: 5,
69
+ HTTP_403: 5,
70
+ AMBIGUOUS: 6,
71
+ CAP_EXCEEDED: 7,
72
+ HTTP_429: 8,
73
+ WRITE_FAILED: 9,
74
+ HTTP_5XX: 10
75
+ };
76
+ /**
77
+ * Codes for a failure the CLI decided ITSELF, for the `--json` document it emits
78
+ * when there is no server envelope to echo (spec 301 → CLI → Output,
79
+ * acceptance 5: `--json` on ANY command emits exactly one parseable document).
80
+ *
81
+ * Every value is namespaced `cli.`, and that prefix is the whole design. The
82
+ * server's error vocabulary is frozen and public; minting `USAGE` alongside it
83
+ * would put a value in that vocabulary no server ever sends. A consumer tells a
84
+ * client-side refusal from a server one by the prefix alone, and
85
+ * `exitCodeForErrorCode` above stays a table over the server's codes only.
86
+ *
87
+ * `runCommand` emits this automatically — a command module never builds one.
88
+ */
89
+ const CLI_ERROR_CODE = {
90
+ /** Bad, missing or mutually-exclusive flags. */
91
+ usage: "cli.usage",
92
+ /** No API key resolvable from either source. */
93
+ noKey: "cli.no_key",
94
+ /** Anything else the CLI decided: a body that is no envelope, a dead host. */
95
+ unexpected: "cli.unexpected"
96
+ };
97
+ /**
98
+ * The local code for an exit this CLI chose.
99
+ *
100
+ * Derived from the exit code rather than carried on each `CliError`, so the
101
+ * dozen existing throw sites keep working and there is one place where a new
102
+ * local exit picks up a name.
103
+ */
104
+ function cliErrorCodeForExit(exitCode) {
105
+ if (exitCode === 2) return CLI_ERROR_CODE.usage;
106
+ if (exitCode === 4) return CLI_ERROR_CODE.noKey;
107
+ return CLI_ERROR_CODE.unexpected;
108
+ }
109
+ /**
110
+ * Looks a key up in a table WITHOUT inheriting Object.prototype.
111
+ *
112
+ * `table[code]` alone answers a FUNCTION for `toString`, `constructor`,
113
+ * `valueOf` and `hasOwnProperty` — every one of them a string a server is free
114
+ * to send — and `?? fallback` never fires on a function. The value then reaches
115
+ * `process.exitCode`, which throws `ERR_INVALID_ARG_TYPE` from inside
116
+ * `runCommand`'s own catch block and escapes as an unhandled rejection instead
117
+ * of exiting. `Object.hasOwn` is what makes these tables closed.
118
+ */
119
+ function ownValue(table, key) {
120
+ return Object.hasOwn(table, key) ? table[key] ?? null : null;
121
+ }
122
+ /**
123
+ * Takes the code a live server actually sent, which is why the parameter is
124
+ * `string`: a server one deploy ahead of the committed types can name a code
125
+ * this build has no row for. That is exit 1 — unexpected — not a guess.
126
+ */
127
+ function exitCodeForErrorCode(code) {
128
+ return ownValue(EXIT_BY_ERROR_CODE, code) ?? 1;
129
+ }
130
+ /**
131
+ * The three aspects this exit code is derived from, TOTAL over the response's
132
+ * own keys: an aspect added or removed fails typecheck here rather than being
133
+ * read as an outcome, or quietly not read at all.
134
+ */
135
+ const IS_ASPECT = {
136
+ schedule: true,
137
+ interval: true,
138
+ filterRules: true
139
+ };
140
+ /**
141
+ * Which outcomes mean nothing is outstanding. TOTAL over the generated enum, so
142
+ * a fifth outcome value fails typecheck instead of defaulting to "fine".
143
+ *
144
+ * Stated as SETTLED rather than as PARTIAL so the default runs the safe way: an
145
+ * outcome missing from this table is not settled, and a server one deploy ahead
146
+ * naming a fifth value exits 11 rather than reporting a write it never
147
+ * confirmed. `skipped` IS settled — it means the caller never asked for that
148
+ * aspect.
149
+ */
150
+ const SETTLED_BY_ASPECT_OUTCOME = {
151
+ updated: true,
152
+ skipped: true,
153
+ failed: false,
154
+ not_attempted: false
155
+ };
156
+ /**
157
+ * The one action whose 200 can still mean failure:
158
+ * `update-notification-channel-settings` writes three aspects against three
159
+ * tables with no transaction across them and returns a per-aspect outcome map.
160
+ * A client that reads that 200 as success reports writes that never occurred.
161
+ *
162
+ * The blocklist per-entry outcomes are deliberately NOT routed here:
163
+ * `already_present` and `not_found` are documented normal successes — the
164
+ * requested state holds — so they exit 0.
165
+ */
166
+ function exitCodeForChannelSettings(result) {
167
+ return Object.keys(IS_ASPECT).every((aspect) => {
168
+ const outcome = Reflect.get(result, aspect);
169
+ if (typeof outcome !== "string") return false;
170
+ return ownValue(SETTLED_BY_ASPECT_OUTCOME, outcome) ?? false;
171
+ }) ? 0 : 11;
172
+ }
173
+ //#endregion
174
+ //#region src/config.ts
175
+ /**
176
+ * The CLI's config file — its location, its two fields, and the modes it is
177
+ * created with (spec 301 → CLI → Auth and configuration).
178
+ *
179
+ * This module is the SINGLE owner of that file. The `config` command group
180
+ * renders and prompts; it never re-implements the path, the parse or the modes,
181
+ * so there is one place where a permission mistake could be made and one place
182
+ * to audit.
183
+ */
184
+ const CONFIG_FILE_NAME = "config.json";
185
+ /**
186
+ * `suffix: ''` drops env-paths' default `-nodejs`: the directory belongs to a
187
+ * user-facing binary called `ubuyfirst`, and the runtime it happens to be
188
+ * written in is not part of that name.
189
+ */
190
+ function defaultConfigDir() {
191
+ return envPaths("ubuyfirst", { suffix: "" }).config;
192
+ }
193
+ function configFilePath(dir = defaultConfigDir()) {
194
+ return join(dir, CONFIG_FILE_NAME);
195
+ }
196
+ function malformed(path, detail) {
197
+ return new CliError(1, `${path} is not a usable ubuyfirst config file: ${detail}`);
198
+ }
199
+ /** The `code` off a Node system error, as `unknown` until proven a string. */
200
+ function errorCode(thrown) {
201
+ if (typeof thrown !== "object" || thrown === null) return null;
202
+ const code = Reflect.get(thrown, "code");
203
+ return typeof code === "string" ? code : null;
204
+ }
205
+ /** Blank is not a value — a botched `set-key` must not become an empty Bearer. */
206
+ function readStringField(source, field, path) {
207
+ if (!(field in source)) return null;
208
+ const value = Reflect.get(source, field);
209
+ if (value === null) return null;
210
+ if (typeof value !== "string") throw malformed(path, `"${field}" is not a string.`);
211
+ const trimmed = value.trim();
212
+ return trimmed === "" ? null : trimmed;
213
+ }
214
+ /**
215
+ * Reads the config file. An ABSENT file is an empty config, not a failure — the
216
+ * CLI is usable with `UBUYFIRST_API_KEY` alone and never requires a write.
217
+ *
218
+ * A file that EXISTS but cannot be read is a failure, and deliberately a fatal
219
+ * one for every command rather than only for `config show`. Two reasons, and the
220
+ * second is the load-bearing one:
221
+ * - swallowing the error makes `config show` report "no key" about a file that
222
+ * holds one, sending the user to set a key they already set;
223
+ * - the file also carries `baseUrl`. Continuing would silently fall back to
224
+ * the default host, so a walk configured against a self-hosted target would
225
+ * send its key somewhere the user did not choose. Refusing names the path
226
+ * and the OS error; guessing does not.
227
+ *
228
+ * So the env var alone serves a MISSING config file, not a broken one. Unknown
229
+ * FIELDS are still ignored on purpose, so a file written by a newer CLI does not
230
+ * brick an older one.
231
+ */
232
+ async function readConfig(dir = defaultConfigDir()) {
233
+ const path = configFilePath(dir);
234
+ let raw;
235
+ try {
236
+ raw = await readFile(path, "utf8");
237
+ } catch (thrown) {
238
+ const code = errorCode(thrown);
239
+ if (code === "ENOENT") return {
240
+ apiKey: null,
241
+ baseUrl: null
242
+ };
243
+ throw malformed(path, `it could not be read (${code ?? "unknown error"}).`);
244
+ }
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(raw);
248
+ } catch {
249
+ throw malformed(path, "it is not valid JSON.");
250
+ }
251
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw malformed(path, "its top level is not a JSON object.");
252
+ return {
253
+ apiKey: readStringField(parsed, "apiKey", path),
254
+ baseUrl: readStringField(parsed, "baseUrl", path)
255
+ };
256
+ }
257
+ const CONFIG_DIR_MODE = 448;
258
+ const CONFIG_FILE_MODE = 384;
259
+ /**
260
+ * Whether the config file is already there — the one question that decides which of the
261
+ * two write paths below applies.
262
+ *
263
+ * A `stat` that fails for any reason OTHER than "no such file" is re-thrown rather than
264
+ * read as absence: answering "absent" to an EACCES would take the creation path, whose
265
+ * `mode` a pre-existing file ignores, and the tightening would be skipped on exactly the
266
+ * file whose permissions could not be inspected.
267
+ */
268
+ async function configFileExists(path) {
269
+ try {
270
+ await stat(path);
271
+ return true;
272
+ } catch (thrown) {
273
+ if (errorCode(thrown) === "ENOENT") return false;
274
+ throw thrown;
275
+ }
276
+ }
277
+ /**
278
+ * Narrows a config file that already exists to `0600`.
279
+ *
280
+ * ENOENT is the ONE code that degrades rather than aborting: the file was there a
281
+ * moment ago and is not now — a concurrent `config clear`, a temp-directory reaper — and
282
+ * the write that follows then CREATES it `0600`, which is the state this call was
283
+ * reaching for. Refusing would abort a `set-key` that was about to succeed, naming a path
284
+ * that no longer exists.
285
+ *
286
+ * Every other code still refuses. An EPERM says the file IS there and could not be
287
+ * narrowed, so continuing would write the key into it under whatever mode it already had.
288
+ */
289
+ async function tighten(path) {
290
+ try {
291
+ await chmod(path, CONFIG_FILE_MODE);
292
+ } catch (thrown) {
293
+ if (errorCode(thrown) !== "ENOENT") throw thrown;
294
+ }
295
+ }
296
+ /**
297
+ * Writes the config file, creating the directory `0700` and the file `0600` IN
298
+ * THE SAME CALL that creates each. A file this function CREATES is never
299
+ * chmod'd afterwards: a create-then-chmod leaves a window in which the key is
300
+ * world-readable.
301
+ *
302
+ * AN ALREADY-EXISTING FILE IS THE OTHER HALF, and `mode` cannot serve it: the
303
+ * flag applies only at creation, so a config left `0644` by a shell redirect or
304
+ * by a build of this CLI from before the modes existed keeps `0644` through
305
+ * every `set-key`, with the plaintext key readable by every other account on the
306
+ * host. It is chmod'd BEFORE the new contents are written, never after — the
307
+ * file is narrowed while it still holds the OLD document, so there is no moment
308
+ * at which the key is present under the looser mode. The two paths are exclusive:
309
+ * a created file takes no chmod at all, and only a pre-existing one takes one.
310
+ *
311
+ * Two honest limits. `mode` is masked by the process umask, so a hostile umask
312
+ * can only make these TIGHTER, never looser. And on Windows both modes are
313
+ * advisory — Node maps a POSIX mode onto the read-only attribute there; it is
314
+ * not an ACL and grants no protection. `--help` says so rather than implying a
315
+ * guarantee the platform does not make.
316
+ *
317
+ * Null fields are omitted rather than stored as JSON nulls, so `config clear`
318
+ * leaves `{}` instead of a document that still names the key it dropped.
319
+ */
320
+ async function writeConfig(config, dir = defaultConfigDir()) {
321
+ const document = {};
322
+ if (config.apiKey !== null) document["apiKey"] = config.apiKey;
323
+ if (config.baseUrl !== null) document["baseUrl"] = config.baseUrl;
324
+ const path = configFilePath(dir);
325
+ await mkdir(dir, {
326
+ recursive: true,
327
+ mode: CONFIG_DIR_MODE
328
+ });
329
+ if (await configFileExists(path)) await tighten(path);
330
+ await writeFile(path, `${JSON.stringify(document, null, 2)}\n`, { mode: CONFIG_FILE_MODE });
331
+ }
332
+ /**
333
+ * What `config show` is allowed to know. The key is reduced to a boolean HERE,
334
+ * so "never print the key" is a property of the type rather than something each
335
+ * renderer has to remember (spec 301: not masked, absent).
336
+ */
337
+ function summarizeConfig(config, path, effectiveBaseUrl) {
338
+ return {
339
+ path,
340
+ keyIsSet: config.apiKey !== null,
341
+ baseUrl: effectiveBaseUrl
342
+ };
343
+ }
344
+ //#endregion
345
+ //#region src/auth.ts
346
+ const API_KEY_ENV_VAR = "UBUYFIRST_API_KEY";
347
+ /** Blank is unset. An empty `Bearer ` would be a 401 the CLI could have refused locally. */
348
+ function present(value) {
349
+ const trimmed = value?.trim() ?? "";
350
+ return trimmed === "" ? null : trimmed;
351
+ }
352
+ /**
353
+ * C0 controls plus DEL. CR and LF are the ones that matter: Node's fetch refuses
354
+ * an Authorization value containing them by throwing `Headers.append: "Bearer
355
+ * <THE WHOLE KEY>" is an invalid header value.` — and `runCommand` prints a
356
+ * thrown error's message, so a key pasted with a line break in it would land in
357
+ * the user's terminal and in whatever captured that output.
358
+ */
359
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/;
360
+ /**
361
+ * Refuses a key that cannot become a header, naming WHERE it came from and
362
+ * never WHAT it was.
363
+ *
364
+ * Checked here because this is where a key ENTERS the CLI, and because the
365
+ * refusal has to happen before any request exists (acceptance 10). That covers
366
+ * both real sources — the env var and the config file — and so every key
367
+ * `resolveApiClient` produces. It is not a guarantee about `ApiClient` itself:
368
+ * that type takes any `apiKey: string`, and a caller assembling one by hand
369
+ * bypasses this. `resolveApiClient` is the only sanctioned way to build one.
370
+ */
371
+ function usableKey(key, source) {
372
+ if (CONTROL_CHARACTERS.test(key)) throw new CliError(4, `The API key from ${source} contains a control character and cannot be sent as an Authorization header. Re-copy it as a single line. Its value is deliberately not shown.`);
373
+ return key;
374
+ }
375
+ /**
376
+ * Two sources, one order: `UBUYFIRST_API_KEY`, then the config file.
377
+ *
378
+ * There is deliberately NO `--api-key` flag, which is why argv is not an input
379
+ * here — a key passed on the command line lands in the shell history and in the
380
+ * process table.
381
+ *
382
+ * The refusal names both sources and the config path, and nothing else. It
383
+ * never echoes a value it did find.
384
+ */
385
+ function resolveApiKey(input) {
386
+ const fromEnv = present(input.env[API_KEY_ENV_VAR]);
387
+ if (fromEnv !== null) return usableKey(fromEnv, API_KEY_ENV_VAR);
388
+ const fromConfig = present(input.config.apiKey);
389
+ if (fromConfig !== null) return usableKey(fromConfig, input.configPath);
390
+ throw new CliError(4, `No API key. Set ${API_KEY_ENV_VAR}, or store one with "ubuyfirst config set-key" (${input.configPath}).`);
391
+ }
392
+ /** `--base-url` > `UBUYFIRST_API_URL` > config `baseUrl` > the documented default. */
393
+ function resolveBaseUrl(input) {
394
+ return present(input.flag) ?? present(input.env["UBUYFIRST_API_URL"]) ?? present(input.config.baseUrl) ?? "https://app.ubuyfirst.com";
395
+ }
396
+ /**
397
+ * The composition every command calls. Resolution happens BEFORE any request
398
+ * exists, so a missing key exits 4 with no HTTP call made (acceptance 10) — the
399
+ * fetch it was handed is never touched.
400
+ */
401
+ async function resolveApiClient(options = {}) {
402
+ const env = options.env ?? process.env;
403
+ const configDir = options.configDir ?? defaultConfigDir();
404
+ const config = await readConfig(configDir);
405
+ return {
406
+ fetch: options.fetch ?? globalThis.fetch,
407
+ apiKey: resolveApiKey({
408
+ env,
409
+ config,
410
+ configPath: configFilePath(configDir)
411
+ }),
412
+ baseUrl: resolveBaseUrl({
413
+ flag: options.baseUrlFlag ?? null,
414
+ env,
415
+ config
416
+ })
417
+ };
418
+ }
419
+ //#endregion
420
+ //#region src/call-action.ts
421
+ /**
422
+ * The CLI's whole transport (spec 301 → CLI → Transport).
423
+ *
424
+ * One helper, not seventeen generated wrappers: it owns the URL, the two
425
+ * headers, the envelope decode and the typed answer, so every command module is
426
+ * argument mapping and rendering only.
427
+ *
428
+ * It is also the ONE typed seam. Request and response types are read off the
429
+ * committed generated `paths` map; nothing else in `cli/` touches generated
430
+ * types.
431
+ */
432
+ const PUBLIC_API_PREFIX = "/api/public/v1";
433
+ /**
434
+ * The deadline for ONE action call, covering the response body as well as the request.
435
+ *
436
+ * Node's own defaults are not this, and the difference is the reason the constant exists.
437
+ * Undici gives `fetch` a 300s `headersTimeout` and a 300s `bodyTimeout`, so a dead peer
438
+ * does eventually fail — but each bounds an IDLE period separately, and a peer trickling
439
+ * one byte at a time resets both forever. `AbortSignal` bounds the TOTAL, and it is
440
+ * created before the fetch so it still governs `response.json()`.
441
+ *
442
+ * Two minutes, not a tighter number. This has to sit above the slowest action the API
443
+ * serves — a 1,000-row import, executed inside one database transaction — because a
444
+ * deadline that fires on work about to succeed turns a slow import into a call whose
445
+ * answer never arrived, which is the one outcome this API's own documentation tells a
446
+ * client it cannot safely retry. It is a fixed constant rather than a `--timeout` flag:
447
+ * the override would have to be threaded from the program's global options through
448
+ * `resolveApiClient` and every command's context, which is a lot of surface for a number
449
+ * nobody has yet needed to change.
450
+ */
451
+ const REQUEST_TIMEOUT_MS = 12e4;
452
+ async function parseJsonBody(response) {
453
+ try {
454
+ return {
455
+ ok: true,
456
+ value: await response.json()
457
+ };
458
+ } catch {
459
+ return { ok: false };
460
+ }
461
+ }
462
+ /**
463
+ * Deliberately names the URL and the status and NOTHING else. The API key is
464
+ * never part of anything the CLI prints.
465
+ */
466
+ function notThisApi(url, status) {
467
+ return new CliError(1, `${url} answered HTTP ${status} with a body that is not this API's JSON envelope. Check --base-url.`);
468
+ }
469
+ /**
470
+ * The one refusal `REQUEST_TIMEOUT_MS` produces, worded the same wherever the deadline
471
+ * fires — and it can fire in two places that fail very differently.
472
+ *
473
+ * Before the headers, `fetch` itself rejects and the raw `TimeoutError` says only "The
474
+ * operation was aborted due to timeout", naming neither the target nor the deadline.
475
+ * DURING THE BODY it is worse, and it is the case the deadline was added for: a trickling
476
+ * peer's stream errors inside `parseJsonBody`, whose catch cannot tell that from an HTML
477
+ * 404 page, so the answer was `notThisApi` — "Check --base-url" about a base URL that was
478
+ * correct. One message for one condition, so neither phase sends the user somewhere else.
479
+ *
480
+ * It names the retry hazard rather than inviting a retry: an abandoned request is
481
+ * indistinguishable from one that never ran, which for a write is the one case this API's
482
+ * own documentation says not to blind-retry.
483
+ */
484
+ function timedOut(url) {
485
+ return new CliError(1, `${url} did not answer within ${REQUEST_TIMEOUT_MS / 1e3} seconds, so the request was abandoned. It may still have been applied — if it was a write, read the account back before sending it again.`);
486
+ }
487
+ /**
488
+ * Sends one action call and returns its 200 body.
489
+ *
490
+ * Failure modes, kept distinct on purpose (spec 301: "ONE envelope, one table,
491
+ * no status fallback"):
492
+ * - the server's own error envelope becomes `ApiCallError`, and the exit code
493
+ * comes from its `code` alone;
494
+ * - anything else — an HTML 404 page from a wrong `--base-url`, an
495
+ * un-enveloped JSON body, a non-object 200 — becomes `CliError` at exit 1.
496
+ * Absorbing those into an error code is exactly what the removed status
497
+ * fallback used to do.
498
+ */
499
+ async function callAction(client, path, body) {
500
+ const url = `${client.baseUrl.replace(/\/+$/, "")}${PUBLIC_API_PREFIX}/${path}`;
501
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
502
+ let response;
503
+ try {
504
+ response = await client.fetch(url, {
505
+ method: "POST",
506
+ headers: {
507
+ Authorization: `Bearer ${client.apiKey}`,
508
+ "Content-Type": "application/json"
509
+ },
510
+ body: JSON.stringify(body),
511
+ signal
512
+ });
513
+ } catch (thrown) {
514
+ if (signal.aborted) throw timedOut(url);
515
+ throw thrown;
516
+ }
517
+ const parsed = await parseJsonBody(response);
518
+ if (!parsed.ok) throw signal.aborted ? timedOut(url) : notThisApi(url, response.status);
519
+ const payload = readErrorEnvelope(parsed.value);
520
+ if (payload !== null) throw new ApiCallError(payload, parsed.value);
521
+ if (!response.ok) throw notThisApi(url, response.status);
522
+ if (typeof parsed.value !== "object" || parsed.value === null || Array.isArray(parsed.value)) throw notThisApi(url, response.status);
523
+ return parsed.value;
524
+ }
525
+ //#endregion
526
+ //#region src/input.ts
527
+ /**
528
+ * `--input <json | @file | ->`, the general body escape hatch
529
+ * (spec 301 → CLI → Argument rules).
530
+ *
531
+ * Four command groups offered the flag and four implemented the same three
532
+ * steps — resolve the source, parse it, refuse anything that is not a JSON
533
+ * object — with four different wordings for the same three refusals. AGENTS.md
534
+ * says extract at three copies; this is that extraction.
535
+ *
536
+ * What is shared is the MECHANISM only. Where the parsed object then goes is
537
+ * deliberately NOT here, because the commands genuinely disagree about it:
538
+ * `searches update` wraps it as `{ id, changes }`, `blocklist add` reads
539
+ * `entries` out of it while `type` comes from the flag, and `notifications
540
+ * settings` spreads it with `id` LAST so the positional argument always wins.
541
+ * Folding those into one shape would change what each command sends.
542
+ */
543
+ /** `-` is stdin, `@path` is a file, anything else is the literal itself. */
544
+ async function readSource(raw, readStdin) {
545
+ if (raw === "-") return readStdin();
546
+ if (!raw.startsWith("@")) return raw;
547
+ const path = raw.slice(1);
548
+ try {
549
+ return await readFile(path, "utf8");
550
+ } catch (thrown) {
551
+ throw new CliError(2, `Cannot read --input file ${path}: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
552
+ }
553
+ }
554
+ /**
555
+ * Reads `--input` and checks the ONE thing the CLI checks about it: that it is a
556
+ * JSON object. Nothing here inspects a field name or a value.
557
+ *
558
+ * The assertion on the last line is over a PROVEN non-null, non-array object —
559
+ * the request-side twin of the one `call-action.ts` makes on a 200 body, and now
560
+ * the only one the CLI performs on an `--input` document.
561
+ */
562
+ async function readInputObject(request) {
563
+ const text = await readSource(request.raw, request.readStdin);
564
+ let parsed;
565
+ try {
566
+ parsed = JSON.parse(text);
567
+ } catch {
568
+ throw new CliError(2, "--input is not valid JSON.");
569
+ }
570
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CliError(2, `--input must be a JSON object, not an array or a scalar.${request.expects === void 0 ? "" : ` ${request.expects}`}`);
571
+ return parsed;
572
+ }
573
+ //#endregion
574
+ //#region src/paging.ts
575
+ /**
576
+ * The `--all` walker (spec 301 → CLI → Paging).
577
+ *
578
+ * Six reads are cursor-paginated. Default is ONE page; `--limit` and `--cursor`
579
+ * pass through unchanged. `--all` is the only thing in this module.
580
+ */
581
+ /**
582
+ * `--all` and `--cursor` together are a usage error: `--all` starts from the
583
+ * beginning by definition, so the two state different intentions and merging
584
+ * them would silently honour one.
585
+ */
586
+ function assertPagingFlags(flags) {
587
+ if (flags.all && flags.cursor !== null && flags.cursor !== void 0) throw new CliError(2, "Use --all or --cursor, not both. --all starts from the first page.");
588
+ }
589
+ /**
590
+ * Walks every page and returns ONE synthesized document: the LAST page's
591
+ * envelope with the row array concatenated across pages, `hasMore: false` and
592
+ * `nextCursor: null`.
593
+ *
594
+ * That synthesized document is the single deliberate exception to "`--json`
595
+ * emits the raw envelope" — and because the result is still a page of the same
596
+ * shape, it needs no special case downstream: `runCommand` emits it verbatim
597
+ * under `--json`, the human renderer reads its rows, and `continuationLine`
598
+ * correctly falls silent on `hasMore: false`.
599
+ *
600
+ * `fetchPage` receives ONLY the cursor, which is what forces the caller to
601
+ * repeat every other input field itself. A cursor carries none of them: for
602
+ * `blocklist list` that means `type` AND `scope`, and the three lists number
603
+ * their rows independently, so a cursor reused across types is not refused — it
604
+ * resumes at a meaningless position.
605
+ *
606
+ * Two server states RAISE rather than end the walk: `hasMore: true` with no
607
+ * cursor, and a page that hands back the very cursor it was given. Both mean
608
+ * the walk cannot complete — the first has no position to resume from, the
609
+ * second makes no progress and would re-fetch one page until the heap is gone —
610
+ * and both used to be survivable by returning what had been collected so far.
611
+ * That is the worse answer: `--all` exists because a partial answer is
612
+ * indistinguishable from a complete one, and `searches export` implies `--all`
613
+ * precisely because a truncated export is a corrupt backup.
614
+ *
615
+ * NON-PROGRESS is the test, deliberately, and not "a cursor seen before". The
616
+ * server pages with Prisma `cursor: { id }, skip: 1`, which locates the cursor
617
+ * row in the order that holds AT READ TIME — and folders order by the MUTABLE
618
+ * `sortOrder`. So another client moving a row mid-walk can legitimately bring an
619
+ * earlier cursor round again on a walk that still terminates, and rejecting any
620
+ * repeat would abort it. Only the server returning the cursor it was just handed
621
+ * proves the walk is not advancing.
622
+ *
623
+ * `rowsKey` is a plain `string`, deliberately. Tying it to `keyof Page` LOOKS
624
+ * stronger and is a trap: an arrow with an unannotated `cursor` is
625
+ * context-sensitive, so TypeScript resolves `rowsKey`'s constraint in an earlier
626
+ * inference round, while `Page` is still the bare `PageEnvelope` — and then
627
+ * refuses `'entries'` on a call that is entirely correct. The runtime guard
628
+ * below is what actually holds, and it holds against the case a type never
629
+ * could: a live server whose shape differs from the committed document.
630
+ *
631
+ * SEVERAL KEYS may be named, because one paged answer can carry more than one
632
+ * per-page array. `export_item_filters` returns `filters` AND `skipped` — the
633
+ * export's machine-readable admission that a row was too large or too deep to
634
+ * carry — and `skipped` accumulates page by page exactly as the rows do.
635
+ * Concatenating only the rows would keep the LAST page's `skipped` and discard
636
+ * every earlier one, which is the same silent truncation this walker exists to
637
+ * prevent, in the one field whose whole job is to report it.
638
+ *
639
+ * A key NOT named keeps its LAST-PAGE value, and that is load-bearing for
640
+ * `customFieldDefinitions`: it is whole-account and repeats verbatim on every
641
+ * page, so concatenating it would emit each definition once per page walked.
642
+ * Per-page arrays are named; whole-account ones are not.
643
+ */
644
+ async function walkAllPages(fetchPage, rowsKey) {
645
+ const keys = typeof rowsKey === "string" ? [rowsKey] : rowsKey;
646
+ const collected = new Map(keys.map((key) => [key, []]));
647
+ let cursor = null;
648
+ let page;
649
+ for (;;) {
650
+ page = await fetchPage(cursor);
651
+ for (const key of keys) {
652
+ const pageRows = Reflect.get(page, key);
653
+ if (!Array.isArray(pageRows)) throw new CliError(1, `A page of results carried no "${key}" array. The server answered a shape this build does not know.`);
654
+ const rowValues = pageRows;
655
+ collected.get(key)?.push(...rowValues);
656
+ }
657
+ if (!page.hasMore) break;
658
+ if (page.nextCursor === null) throw new CliError(1, `The server reported more results but sent no cursor to resume from, so "--all" cannot complete. Re-run without --all to page manually.`);
659
+ if (page.nextCursor === cursor) throw new CliError(1, `The server returned the same paging cursor it was given, so "--all" would fetch the same page forever. Re-run without --all to page manually.`);
660
+ cursor = page.nextCursor;
661
+ }
662
+ return Object.assign({}, page, {
663
+ hasMore: false,
664
+ nextCursor: null
665
+ }, Object.fromEntries(collected));
666
+ }
667
+ //#endregion
668
+ //#region src/render.ts
669
+ /**
670
+ * Output and exit discipline (spec 301 → CLI → Output, Exit codes).
671
+ *
672
+ * Every command runs through `runCommand`, so envelope handling, the exit-code
673
+ * derivation and the two output modes exist once. A command module supplies the
674
+ * call and its column set and nothing else.
675
+ */
676
+ /**
677
+ * The production wiring.
678
+ *
679
+ * `process.exitCode = n` and RETURN — never `process.exit()` after writing.
680
+ * `process.exit()` tears the process down before a piped stdout has drained,
681
+ * and the large `--json` payload is exactly the case that loses data
682
+ * (acceptance 7).
683
+ */
684
+ function processOutput(json) {
685
+ return {
686
+ stdout: { write: (chunk) => void process.stdout.write(chunk) },
687
+ stderr: { write: (chunk) => void process.stderr.write(chunk) },
688
+ json,
689
+ setExitCode: (code) => {
690
+ process.exitCode = code;
691
+ }
692
+ };
693
+ }
694
+ /**
695
+ * The line human mode prints whenever another page remains. Without it a
696
+ * truncated page reads as the whole list — the failure every paged endpoint's
697
+ * own documentation warns about — so it names BOTH ways forward.
698
+ *
699
+ * `hasMore: true` with no cursor yields nothing: there is no position to resume
700
+ * from, and inviting a caller to re-run with an absent cursor is worse advice
701
+ * than silence.
702
+ */
703
+ function continuationLine(data) {
704
+ if (typeof data !== "object" || data === null) return null;
705
+ if (!("hasMore" in data) || !("nextCursor" in data)) return null;
706
+ const { hasMore, nextCursor } = data;
707
+ if (hasMore !== true) return null;
708
+ if (typeof nextCursor !== "string") return null;
709
+ return `More results remain. Re-run with --all for every page, or --cursor ${nextCursor} for the next one.`;
710
+ }
711
+ /**
712
+ * A fixed column set, one row per record. Emits NOTHING for an empty result:
713
+ * a lone header row reads as a table whose rows failed to render, where no
714
+ * output plainly means no records.
715
+ */
716
+ function renderTable(headers, rows, line) {
717
+ if (rows.length === 0) return;
718
+ const widths = headers.map((header, column) => rows.reduce((widest, row) => Math.max(widest, (row[column] ?? "").length), header.length));
719
+ const format = (cells) => cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
720
+ line(format(headers));
721
+ for (const row of rows) line(format(row));
722
+ }
723
+ /**
724
+ * Node's own `styleText` — no chalk, no picocolors. It honors `NO_COLOR` and
725
+ * strips color on a non-TTY by itself, so the CLI adds no detection of its own.
726
+ * Bound to stderr because that is the stream this text goes to; `--json` output
727
+ * never passes through here, so it is never colored.
728
+ */
729
+ function errorPrefix() {
730
+ return styleText("red", "error:", { stream: process.stderr });
731
+ }
732
+ /**
733
+ * Runs one command and owns everything that happens to its answer.
734
+ *
735
+ * `--json` emits exactly one newline-terminated document on stdout: the action's
736
+ * `data` body on success, the server's error envelope VERBATIM on failure, with
737
+ * stderr empty either way (acceptance 5).
738
+ *
739
+ * A LOCALLY-decided failure — a usage error, no resolvable key, a response that
740
+ * is no envelope — has no server envelope to echo. Under `--json` it emits an
741
+ * equivalent document carrying a `cli.`-namespaced code, because acceptance 5
742
+ * says ANY command under `--json` writes exactly one parseable document and
743
+ * empty stdout breaks that for every consumer. The namespace is what keeps the
744
+ * server's frozen vocabulary untouched: see `CLI_ERROR_CODE`.
745
+ *
746
+ * Without `--json` the same failure stays prose on stderr, and the exit code is
747
+ * unaffected in both modes — the spec's table still governs.
748
+ */
749
+ async function runCommand(options) {
750
+ const { out } = options;
751
+ const line = (text) => out.stdout.write(`${text}\n`);
752
+ try {
753
+ const data = await options.run();
754
+ if (out.json) out.stdout.write(`${JSON.stringify(data)}\n`);
755
+ else {
756
+ options.renderHuman(data, line);
757
+ const more = continuationLine(data);
758
+ if (more !== null) line(more);
759
+ }
760
+ out.setExitCode(options.exitCodeFor?.(data) ?? 0);
761
+ } catch (thrown) {
762
+ if (thrown instanceof ApiCallError) {
763
+ if (out.json) out.stdout.write(`${JSON.stringify(thrown.envelope)}\n`);
764
+ else out.stderr.write(`${errorPrefix()} ${thrown.payload.message}\n`);
765
+ out.setExitCode(exitCodeForErrorCode(thrown.payload.code));
766
+ return;
767
+ }
768
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
769
+ const exitCode = thrown instanceof CliError ? thrown.exitCode : 1;
770
+ if (out.json) {
771
+ const document = { error: {
772
+ code: cliErrorCodeForExit(exitCode),
773
+ retriable: false,
774
+ message
775
+ } };
776
+ out.stdout.write(`${JSON.stringify(document)}\n`);
777
+ } else out.stderr.write(`${errorPrefix()} ${message}\n`);
778
+ out.setExitCode(exitCode);
779
+ }
780
+ }
781
+ //#endregion
782
+ //#region src/commands/blocklist.ts
783
+ /**
784
+ * `blocklist list | add | remove` (spec 301 → CLI → Command surface).
785
+ *
786
+ * Argument mapping and rendering over `callAction` — no HTTP, no envelope
787
+ * handling and no exit-code logic of its own.
788
+ *
789
+ * Two decisions this module exists to hold:
790
+ *
791
+ * - **`list` pages.** `list-blocklist` is cursor-paginated and a long
792
+ * blocklist IS truncated. The recorded incident behind this spec was an
793
+ * unpaginated read of a ~10,000-entry list, so `--all` walks to completion
794
+ * through `walkAllPages` and `--json` emits the whole walk as ONE document.
795
+ * - **The CLI owns per-type entry construction.** MCP needs an object root,
796
+ * so one `entries` array serves all three lists and the generated type
797
+ * leaves its ITEM shape open. The server stays the only validator of what
798
+ * goes in it.
799
+ */
800
+ /**
801
+ * TOTAL over the type union the generated document publishes: a fourth list
802
+ * added server-side fails `npm run typecheck` here rather than being reachable
803
+ * by no command at all.
804
+ */
805
+ const IS_BLOCKLIST_TYPE = {
806
+ sellers: true,
807
+ countries: true,
808
+ items: true
809
+ };
810
+ const BLOCKLIST_TYPES = Object.keys(IS_BLOCKLIST_TYPE).join(" | ");
811
+ function isBlocklistType(value) {
812
+ return Object.hasOwn(IS_BLOCKLIST_TYPE, value);
813
+ }
814
+ /**
815
+ * `--type` is required on all three subcommands: these are three separate lists
816
+ * with three different row shapes, one call reaches exactly one of them, and the
817
+ * server refuses a body without it.
818
+ */
819
+ function parseType(raw) {
820
+ if (raw === void 0) throw new CliError(2, `--type is required (${BLOCKLIST_TYPES}). The three blocklists are separate and one call reaches exactly one of them.`);
821
+ if (!isBlocklistType(raw)) throw new CliError(2, `--type "${raw}" is not a blocklist. Use one of: ${BLOCKLIST_TYPES}.`);
822
+ return raw;
823
+ }
824
+ /** The 1–200 range is the server's to enforce; this only refuses a non-number. */
825
+ function parseLimit$2(raw) {
826
+ if (raw === void 0) return void 0;
827
+ if (!/^\d+$/.test(raw)) throw new CliError(2, `--limit takes a whole number of entries, not "${raw}".`);
828
+ return Number(raw);
829
+ }
830
+ function addEntriesFromKeys(type, keys, reason) {
831
+ switch (type) {
832
+ case "sellers": return keys.map((sellerName) => ({
833
+ sellerName,
834
+ reason
835
+ }));
836
+ case "items": return keys.map((ebayItemId) => ({ ebayItemId }));
837
+ case "countries": throw new CliError(2, "blocklist add --type countries takes --input, not positional arguments: each entry needs countryId, countryCode and countryName, all three, because the columns are NOT NULL and nothing is looked up server-side.");
838
+ }
839
+ }
840
+ /**
841
+ * A country id, as a NUMBER — that is what `remove` takes for this list, and the
842
+ * only entry shape on either verb that is not a string.
843
+ */
844
+ function parseCountryId(raw) {
845
+ if (!/^\d+$/.test(raw)) throw new CliError(2, `"${raw}" is not a country id. blocklist remove --type countries takes the numeric ids that "blocklist list --type countries" prints under ID.`);
846
+ const id = Number(raw);
847
+ if (!Number.isSafeInteger(id)) throw new CliError(2, `"${raw}" is too large to send as a JSON number.`);
848
+ return id;
849
+ }
850
+ /** BARE keys for all three types: strings for sellers and items, numbers for countries. */
851
+ function removeKeysFromArguments(type, keys) {
852
+ return type === "countries" ? keys.map(parseCountryId) : [...keys];
853
+ }
854
+ /**
855
+ * What the object is expected to carry, for the shared parser's refusal. It is
856
+ * guidance only — `entriesFromInput` below is the check, and `--type` stays on
857
+ * the command line either way.
858
+ */
859
+ const INPUT_EXPECTS$1 = "The object carries the entries, e.g. {\"entries\":[...]}.";
860
+ function entriesFromInput(input) {
861
+ const entries = Reflect.get(input, "entries");
862
+ if (!Array.isArray(entries)) throw new CliError(2, "--input carries no \"entries\" array. The object holds the entries, e.g. {\"entries\":[...]}; --type stays on the command line.");
863
+ return entries;
864
+ }
865
+ /**
866
+ * `--type` and a `type` inside `--input` are two stated intentions. Honouring
867
+ * one silently would write to a list the command line does not name.
868
+ */
869
+ function assertInputTypeAgrees(input, type) {
870
+ const declared = Reflect.get(input, "type");
871
+ if (declared !== void 0 && declared !== type) throw new CliError(2, `--type ${type} and the "type" inside --input name different blocklists. Send one of them.`);
872
+ }
873
+ /**
874
+ * `--input` and the field arguments of the same command are MUTUALLY EXCLUSIVE.
875
+ * Merging them would make the body actually sent unreadable from the command
876
+ * line, so supplying both is a usage error decided before any HTTP call.
877
+ */
878
+ async function resolveEntries(args) {
879
+ if (args.input !== void 0) {
880
+ if (args.keys.length > 0 || args.hasFieldFlags) throw new CliError(2, "--input and the entry arguments of the same command are mutually exclusive. Send one.");
881
+ const input = await readInputObject({
882
+ raw: args.input,
883
+ readStdin: args.readStdin,
884
+ expects: INPUT_EXPECTS$1
885
+ });
886
+ assertInputTypeAgrees(input, args.type);
887
+ return {
888
+ entries: entriesFromInput(input),
889
+ extra: input
890
+ };
891
+ }
892
+ if (args.keys.length === 0) throw new CliError(2, "Name at least one entry, or pass --input.");
893
+ return {
894
+ entries: args.fromKeys(args.type, args.keys),
895
+ extra: {}
896
+ };
897
+ }
898
+ function renderList(data, line) {
899
+ switch (data.type) {
900
+ case "sellers":
901
+ renderTable(["SELLER", "REASON"], data.entries.map((entry) => [entry.sellerName, entry.reason ?? ""]), line);
902
+ return;
903
+ case "countries":
904
+ renderTable([
905
+ "ID",
906
+ "CODE",
907
+ "NAME"
908
+ ], data.entries.map((entry) => [
909
+ String(entry.countryId),
910
+ entry.countryCode,
911
+ entry.countryName
912
+ ]), line);
913
+ return;
914
+ case "items":
915
+ renderTable([
916
+ "ITEM ID",
917
+ "TITLE",
918
+ "IMAGE URL"
919
+ ], data.entries.map((entry) => [
920
+ entry.ebayItemId,
921
+ entry.title ?? "",
922
+ entry.imageUrl ?? ""
923
+ ]), line);
924
+ return;
925
+ }
926
+ }
927
+ /**
928
+ * The per-entry rows of a write. `already_present` and `not_found` are
929
+ * documented normal successes — the requested state holds — so they are rendered
930
+ * like any other outcome and exit 0. Only the envelope decides otherwise.
931
+ */
932
+ function renderOutcomes(results, summary, line) {
933
+ renderTable(["KEY", "OUTCOME"], results.map((result) => [result.key, result.outcome]), line);
934
+ line(summary);
935
+ }
936
+ /** `--input -`. Read whole, because the body is one JSON document. */
937
+ async function readAllStdin$3() {
938
+ process.stdin.setEncoding("utf8");
939
+ let text = "";
940
+ for await (const chunk of process.stdin) text += String(chunk);
941
+ return text;
942
+ }
943
+ function resolveDeps$2(overrides) {
944
+ return {
945
+ resolveClient: overrides.resolveClient ?? ((baseUrlFlag) => resolveApiClient({ baseUrlFlag })),
946
+ createOutput: overrides.createOutput ?? processOutput,
947
+ readStdin: overrides.readStdin ?? readAllStdin$3
948
+ };
949
+ }
950
+ const TYPE_FLAG_DESCRIPTION = "which blocklist: sellers | countries | items (required)";
951
+ const INPUT_FLAG_DESCRIPTION = "JSON object carrying the entries: a literal, @file, or - for stdin. Mutually exclusive with the entry arguments";
952
+ function buildListCommand(deps) {
953
+ return new Command("list").description("List blocked sellers, countries or items").option("--type <type>", TYPE_FLAG_DESCRIPTION).option("--limit <count>", "entries per page (1-200, server default 200)").option("--cursor <cursor>", "resume from a previous answer’s nextCursor").option("--all", "walk every page and emit one complete result").action(async (_options, command) => {
954
+ const options = command.optsWithGlobals();
955
+ await runCommand({
956
+ out: deps.createOutput(options.json ?? false),
957
+ run: async () => {
958
+ const type = parseType(options.type);
959
+ const limit = parseLimit$2(options.limit);
960
+ const all = options.all ?? false;
961
+ assertPagingFlags({
962
+ all,
963
+ cursor: options.cursor
964
+ });
965
+ const client = await deps.resolveClient(options.baseUrl ?? null);
966
+ if (all) return await walkAllPages((cursor) => callAction(client, "list-blocklist", {
967
+ type,
968
+ limit,
969
+ cursor
970
+ }), "entries");
971
+ return await callAction(client, "list-blocklist", {
972
+ type,
973
+ limit,
974
+ cursor: options.cursor
975
+ });
976
+ },
977
+ renderHuman: renderList
978
+ });
979
+ });
980
+ }
981
+ function buildAddCommand(deps) {
982
+ return new Command("add").description("Block sellers, countries or items").argument("[keys...]", "seller names, or eBay listing ids. Countries take --input instead").option("--type <type>", TYPE_FLAG_DESCRIPTION).option("--reason <text>", "applies to every seller entry in the call").option("--input <source>", INPUT_FLAG_DESCRIPTION).action(async (keys, _options, command) => {
983
+ const options = command.optsWithGlobals();
984
+ await runCommand({
985
+ out: deps.createOutput(options.json ?? false),
986
+ run: async () => {
987
+ const type = parseType(options.type);
988
+ const reason = options.reason;
989
+ if (reason !== void 0 && type !== "sellers") throw new CliError(2, "--reason applies only to --type sellers; country and item entries carry no reason field.");
990
+ const { entries, extra } = await resolveEntries({
991
+ type,
992
+ keys,
993
+ input: options.input,
994
+ hasFieldFlags: reason !== void 0,
995
+ readStdin: deps.readStdin,
996
+ fromKeys: (entryType, entryKeys) => addEntriesFromKeys(entryType, entryKeys, reason)
997
+ });
998
+ return await callAction(await deps.resolveClient(options.baseUrl ?? null), "add-blocklist-entries", {
999
+ ...extra,
1000
+ type,
1001
+ entries
1002
+ });
1003
+ },
1004
+ renderHuman: (data, line) => renderOutcomes(data.results, `${data.addedCount} added.`, line)
1005
+ });
1006
+ });
1007
+ }
1008
+ function buildRemoveCommand(deps) {
1009
+ return new Command("remove").description("Unblock sellers, countries or items").argument("[keys...]", "the stored keys: seller names, country ids, or eBay listing ids").option("--type <type>", TYPE_FLAG_DESCRIPTION).option("--input <source>", INPUT_FLAG_DESCRIPTION).action(async (keys, _options, command) => {
1010
+ const options = command.optsWithGlobals();
1011
+ await runCommand({
1012
+ out: deps.createOutput(options.json ?? false),
1013
+ run: async () => {
1014
+ const type = parseType(options.type);
1015
+ const { entries, extra } = await resolveEntries({
1016
+ type,
1017
+ keys,
1018
+ input: options.input,
1019
+ hasFieldFlags: false,
1020
+ readStdin: deps.readStdin,
1021
+ fromKeys: removeKeysFromArguments
1022
+ });
1023
+ return await callAction(await deps.resolveClient(options.baseUrl ?? null), "remove-blocklist-entries", {
1024
+ ...extra,
1025
+ type,
1026
+ entries
1027
+ });
1028
+ },
1029
+ renderHuman: (data, line) => renderOutcomes(data.results, `${data.removedCount} removed.`, line)
1030
+ });
1031
+ });
1032
+ }
1033
+ /**
1034
+ * `scope` is deliberately not a flag: it defaults to `personal` server-side and
1035
+ * the CLI supplies nothing, so the server's default is the only one. A team list
1036
+ * is reachable on the two write verbs by putting `"scope":"team"` in `--input`.
1037
+ */
1038
+ function buildBlocklistCommand(overrides = {}) {
1039
+ const deps = resolveDeps$2(overrides);
1040
+ return new Command("blocklist").description("Manage the blocked sellers, countries and items of the key holder").addCommand(buildListCommand(deps)).addCommand(buildAddCommand(deps)).addCommand(buildRemoveCommand(deps));
1041
+ }
1042
+ //#endregion
1043
+ //#region src/commands/config.ts
1044
+ /**
1045
+ * The `config` command group — `set-key`, `show`, `clear`
1046
+ * (spec 301 → CLI → Auth and configuration).
1047
+ *
1048
+ * This module is stdin, precedence reporting and rendering ONLY. The config
1049
+ * file's path, its parse and the `0700`/`0600` modes belong to `../config.ts`,
1050
+ * which is their single owner; a second implementation of a permission-sensitive
1051
+ * write is how two of them drift apart.
1052
+ */
1053
+ const KEY_SOURCE_LABEL = {
1054
+ env: API_KEY_ENV_VAR,
1055
+ config: "config file",
1056
+ none: "none"
1057
+ };
1058
+ const SET_KEY_HELP = `
1059
+ The key is read from stdin — never from an argument or a flag, because argv
1060
+ lands in the shell history and in the process table. There is no --api-key.
1061
+
1062
+ printf %s "$MY_KEY" | ubuyfirst config set-key
1063
+
1064
+ The config directory is created 0700 and the file 0600, in the same call that
1065
+ creates each. On Windows both modes are advisory: Node maps a POSIX mode onto
1066
+ the read-only attribute, which is not an ACL and grants no protection there.`;
1067
+ /**
1068
+ * Which source wins, WITHOUT resolving the key itself — this module never holds
1069
+ * the value it must not print.
1070
+ *
1071
+ * The order mirrors `resolveApiKey`: env var, then the config file, blank
1072
+ * meaning unset in both. It is reported at all because an env var silently
1073
+ * overriding a stored key is otherwise invisible: `show` would say a key is set,
1074
+ * the user would re-check the file, and the call would still carry the other one.
1075
+ */
1076
+ function keySourceOf(env, config) {
1077
+ if ((env["UBUYFIRST_API_KEY"] ?? "").trim() !== "") return "env";
1078
+ return config.apiKey === null ? "none" : "config";
1079
+ }
1080
+ function viewOf(config, dir, env, baseUrlFlag) {
1081
+ const summary = summarizeConfig(config, configFilePath(dir), resolveBaseUrl({
1082
+ flag: baseUrlFlag,
1083
+ env,
1084
+ config
1085
+ }));
1086
+ return {
1087
+ path: summary.path,
1088
+ keyIsSet: summary.keyIsSet,
1089
+ keySource: keySourceOf(env, config),
1090
+ baseUrl: summary.baseUrl
1091
+ };
1092
+ }
1093
+ function renderView(view, line) {
1094
+ line(`path: ${view.path}`);
1095
+ line(`key: ${view.keyIsSet ? "set" : "not set"}`);
1096
+ line(`key source: ${KEY_SOURCE_LABEL[view.keySource]}`);
1097
+ line(`base url: ${view.baseUrl}`);
1098
+ }
1099
+ /**
1100
+ * Reads the whole of stdin as the key.
1101
+ *
1102
+ * The value is trimmed and never echoed — not in the refusal, not in a
1103
+ * confirmation. It is deliberately NOT re-validated for control characters
1104
+ * here: `auth.ts` owns that rule at the point a key becomes an Authorization
1105
+ * header, and a second copy of it in this module is a rule that can drift.
1106
+ */
1107
+ async function readKeyFromStdin(stdin, stderr) {
1108
+ if (stdin.isTTY ?? false) stderr.write("Paste the API key, then press Enter followed by Ctrl-D (Ctrl-Z on Windows).\n");
1109
+ const decoder = new TextDecoder();
1110
+ let text = "";
1111
+ for await (const chunk of stdin) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
1112
+ text += decoder.decode();
1113
+ const key = text.trim();
1114
+ if (key === "") throw new CliError(2, "No API key on stdin. Pipe one in, for example: printf %s \"$MY_KEY\" | ubuyfirst config set-key");
1115
+ return key;
1116
+ }
1117
+ function buildConfigCommand(deps = {}) {
1118
+ const makeOutput = deps.output ?? processOutput;
1119
+ const context = (command) => {
1120
+ const globals = command.optsWithGlobals();
1121
+ return {
1122
+ out: makeOutput(globals.json ?? false),
1123
+ dir: deps.configDir ?? defaultConfigDir(),
1124
+ env: deps.env ?? process.env,
1125
+ baseUrlFlag: globals.baseUrl ?? null
1126
+ };
1127
+ };
1128
+ const group = new Command("config").description("Manage the CLI configuration file");
1129
+ const setKey = group.command("set-key");
1130
+ setKey.description("Store the API key, read from stdin").addHelpText("after", SET_KEY_HELP).action(async () => {
1131
+ const { out, dir, env, baseUrlFlag } = context(setKey);
1132
+ await runCommand({
1133
+ out,
1134
+ run: async () => {
1135
+ const existing = await readConfig(dir);
1136
+ const stored = {
1137
+ apiKey: await readKeyFromStdin(deps.stdin ?? process.stdin, out.stderr),
1138
+ baseUrl: existing.baseUrl
1139
+ };
1140
+ await writeConfig(stored, dir);
1141
+ return viewOf(stored, dir, env, baseUrlFlag);
1142
+ },
1143
+ renderHuman: renderView
1144
+ });
1145
+ });
1146
+ const show = group.command("show");
1147
+ show.description("Print the config path, whether a key is stored, and the effective base URL").action(async () => {
1148
+ const { out, dir, env, baseUrlFlag } = context(show);
1149
+ await runCommand({
1150
+ out,
1151
+ run: async () => viewOf(await readConfig(dir), dir, env, baseUrlFlag),
1152
+ renderHuman: renderView
1153
+ });
1154
+ });
1155
+ const clear = group.command("clear");
1156
+ clear.description("Remove the stored API key and base URL").action(async () => {
1157
+ const { out, dir, env, baseUrlFlag } = context(clear);
1158
+ await runCommand({
1159
+ out,
1160
+ run: async () => {
1161
+ const cleared = {
1162
+ apiKey: null,
1163
+ baseUrl: null
1164
+ };
1165
+ await writeConfig(cleared, dir);
1166
+ return viewOf(cleared, dir, env, baseUrlFlag);
1167
+ },
1168
+ renderHuman: renderView
1169
+ });
1170
+ });
1171
+ return group;
1172
+ }
1173
+ //#endregion
1174
+ //#region src/bulk.ts
1175
+ /**
1176
+ * Bulk export/import mechanism, shared by `searches` and `filters`
1177
+ * (spec 301 → Bulk export/import, CLI → Command surface).
1178
+ *
1179
+ * The two groups run the SAME four steps — walk every page, project a document
1180
+ * into a strict body, guard a destructive flag, chunk and aggregate — and differ
1181
+ * only in which action they call and what a row is called. AGENTS.md says
1182
+ * extract at three copies; writing this twice would be two copies of a
1183
+ * destructive-flag guard, which is the last thing that should exist twice.
1184
+ *
1185
+ * What is shared is the MECHANISM only. The action paths, the row key and the
1186
+ * columns stay in the command module that owns them.
1187
+ */
1188
+ /**
1189
+ * The most rows ONE import call carries.
1190
+ *
1191
+ * DERIVED, not chosen: the generated OpenAPI document publishes
1192
+ * `maxItems: 1000` on `import-saved-searches.searches` AND on
1193
+ * `import-item-filters.filters`, which is `SEARCH_IMPORT_ROWS_MAX` /
1194
+ * `FILTER_IMPORT_ROWS_MAX` server-side, each pinned there against the row
1195
+ * conversion path's own cap (`jsonImportBodySchema`,
1196
+ * `filterJsonImportBodySchema`).
1197
+ *
1198
+ * It is DECLARED here rather than imported because `cli/` is a standalone
1199
+ * package with no path alias to the app (spec 301 → CLI → Packaging), and a
1200
+ * `maxItems` keyword does not survive into the TypeScript the generator emits —
1201
+ * so there is nothing in `cli/` to read it off at compile time. That makes this
1202
+ * the one number in the CLI that can silently drift from the server's. It is a
1203
+ * MIRROR, and if the two ever disagree the symptom is a `VALIDATION` refusal on
1204
+ * the first oversized chunk, not silent data loss.
1205
+ *
1206
+ * ONE constant for both verbs because the server declares one number for both.
1207
+ * Splitting it would invent a difference the API does not have.
1208
+ */
1209
+ const BULK_IMPORT_ROWS_MAX = 1e3;
1210
+ /**
1211
+ * The real terminal check: ALL THREE streams, because any one of them redirected
1212
+ * means there is nobody who can be asked. `isTTY` is `boolean | undefined` on a
1213
+ * stream Node never opened, and an absent value means NOT a terminal.
1214
+ *
1215
+ * STDERR IS IN THE SET BECAUSE THAT IS WHERE THE QUESTION IS PRINTED.
1216
+ * `confirmOnTerminal` writes the prompt to `process.stderr` and reads the answer
1217
+ * from `process.stdin`, so `... --replace-all 2>/dev/null` on a terminal used to
1218
+ * pass this check, print an INVISIBLE question and then block on stdin forever.
1219
+ *
1220
+ * STDOUT STAYS IN THE SET even though nothing is prompted through it. It is not
1221
+ * the prompt's stream, it is the last refusal in `assertReplaceAllAllowed`: a
1222
+ * piped stdout means the caller is a script, and a destructive default must be
1223
+ * refused there rather than prompted. Dropping it would start prompting on
1224
+ * `ubuyfirst ... | less`, which loosens the guard this function feeds.
1225
+ */
1226
+ function processIsInteractive() {
1227
+ return (process.stdin.isTTY ?? false) && (process.stdout.isTTY ?? false) && (process.stderr.isTTY ?? false);
1228
+ }
1229
+ /**
1230
+ * The real prompt. Anything but an explicit `y`/`yes` is a refusal.
1231
+ *
1232
+ * Only ever called with stdin UNREAD: `assertReplaceAllAllowed` refuses a
1233
+ * destructive call whose body came from stdin before reaching here, because
1234
+ * asking a stream already at EOF produces a question that is never answered
1235
+ * rather than a refusal.
1236
+ */
1237
+ async function confirmOnTerminal(question) {
1238
+ const rl = createInterface({
1239
+ input: process.stdin,
1240
+ output: process.stderr
1241
+ });
1242
+ try {
1243
+ const answer = await rl.question(`${question} [y/N] `);
1244
+ return /^y(es)?$/i.test(answer.trim());
1245
+ } finally {
1246
+ rl.close();
1247
+ }
1248
+ }
1249
+ /** Splits rows into batches of at most `max`, in order, with no row in two batches. */
1250
+ function chunkRows(rows, max) {
1251
+ const batches = [];
1252
+ for (let start = 0; start < rows.length; start += max) batches.push(rows.slice(start, start + max));
1253
+ return batches;
1254
+ }
1255
+ /**
1256
+ * Pulls the row array out of an `--input` document and refuses the two shapes
1257
+ * that cannot become a request.
1258
+ *
1259
+ * The document is whatever the user handed in — commonly a page `export` wrote,
1260
+ * envelope and all. Only the rows are read; see `projectImportBody` in the
1261
+ * command modules for why nothing else may be forwarded.
1262
+ */
1263
+ function readRows(document, rowsKey, what) {
1264
+ const rows = document[rowsKey];
1265
+ if (!Array.isArray(rows)) throw new CliError(2, `--input must carry a "${rowsKey}" array. An export document written by "${what} export" already does.`);
1266
+ if (rows.length === 0) throw new CliError(2, `There are no ${what} to import — "${rowsKey}" is empty.`);
1267
+ return rows;
1268
+ }
1269
+ /**
1270
+ * How many rows an `--input` document admits are MISSING from it.
1271
+ *
1272
+ * An export page carries `skipped[]` — the rows it could not serialize. The document is
1273
+ * untrusted, so anything that is not an array counts as ZERO: a missing or malformed
1274
+ * `skipped` must not be read as a claim that rows are absent.
1275
+ */
1276
+ function documentSkippedCount(document) {
1277
+ const skipped = document["skipped"];
1278
+ return Array.isArray(skipped) ? skipped.length : 0;
1279
+ }
1280
+ /**
1281
+ * The whole guard on the one flag that DELETES DATA.
1282
+ *
1283
+ * Six refusals, in the order that makes each reachable:
1284
+ *
1285
+ * 1. `--replace-all` over the per-call cap is refused OUTRIGHT, before any
1286
+ * acknowledgement can excuse it. Chunking is incompatible with it: the
1287
+ * second chunk deletes the first chunk's work, the action cannot detect
1288
+ * that because it holds no session across calls, and the CLI is the only
1289
+ * place the whole file is visible (spec 301, acceptance 13).
1290
+ * 2. A document that REPORTS ITS OWN SHORTFALL is refused OUTRIGHT too. An
1291
+ * export writes `skipped[]` for the rows it could not carry, so restoring
1292
+ * such a file with `--replace-all` deletes rows the file itself names and
1293
+ * then cannot put them back. `--yes` acknowledges the deletion the caller
1294
+ * ASKED for; it cannot acknowledge one nobody has been told about, so this
1295
+ * sits above it.
1296
+ * 3. `--yes` is the acknowledgement, and it works in every mode.
1297
+ * 4. `--json` means a machine is reading. Prompting would corrupt the single
1298
+ * document the mode promises, so an unacknowledged destructive call is
1299
+ * refused rather than run.
1300
+ * 5. A body read from STDIN has already spent the stream a prompt would read
1301
+ * the answer from. `ubuyfirst searches import - --replace-all` typed at a
1302
+ * terminal is still `isTTY` on both streams, so the terminal check alone
1303
+ * says "ask" — and the question is then put to a stream at EOF, which
1304
+ * answers nothing and leaves the process waiting instead of refusing.
1305
+ * 6. A non-terminal is the same situation without the flag: nobody can answer,
1306
+ * so proceeding would let a destructive default fire because stdout
1307
+ * happened to be piped.
1308
+ *
1309
+ * Only then is there a prompt, and declining it is the same refusal as never
1310
+ * acknowledging — exit 2, decided locally, no HTTP call. Exit 2 rather than 0
1311
+ * because "you did not confirm" and "you refused to confirm" are one outcome:
1312
+ * the CLI was asked to do something it did not do.
1313
+ */
1314
+ async function assertReplaceAllAllowed(request) {
1315
+ if (!request.replaceAll) return;
1316
+ if (request.chunkCount > 1) throw new CliError(2, `--replace-all cannot be used with more than ${String(BULK_IMPORT_ROWS_MAX)} rows: the file is sent in ${String(request.chunkCount)} chunks and each one would delete the previous chunk's work. Import without --replace-all, or split the file yourself.`);
1317
+ if (request.documentSkippedCount > 0) throw new CliError(2, `--replace-all cannot restore this document: it reports that ${String(request.documentSkippedCount)} of your ${request.entity} could NOT be exported and are missing from it. Restoring it would delete them permanently. Import without --replace-all, or export again once those rows are exportable.`);
1318
+ const cost = `--replace-all DELETES every one of your existing ${request.entity} before importing this file.`;
1319
+ if (request.yes) return;
1320
+ if (request.json || request.bodyFromStdin || !request.deps.isInteractive()) throw new CliError(2, `${cost} Re-run with --yes to confirm it.`);
1321
+ if (!await request.deps.confirm(cost)) throw new CliError(2, `Aborted. Nothing was deleted and nothing was imported.`);
1322
+ }
1323
+ /** Sums two nullable counts, staying null only when NEITHER side reported one. */
1324
+ function addNullable(left, right) {
1325
+ if (left === null) return right;
1326
+ if (right === null) return left;
1327
+ return left + right;
1328
+ }
1329
+ function chunkFrames(rowsPerChunk) {
1330
+ const frames = [];
1331
+ let offset = 0;
1332
+ for (const size of rowsPerChunk) {
1333
+ frames.push({
1334
+ offset,
1335
+ firstRow: offset + 1,
1336
+ lastRow: offset + size
1337
+ });
1338
+ offset += size;
1339
+ }
1340
+ return frames;
1341
+ }
1342
+ /**
1343
+ * Shifts one row number from its CHUNK's frame into the FILE's.
1344
+ *
1345
+ * `row: 0` is left alone. The import services use it as the BATCH-level
1346
+ * sentinel — "Import would create 40 new folders, exceeding the limit" is about
1347
+ * the request, not about a line — while per-row numbers are 1-based. Shifting
1348
+ * the sentinel turns a whole-file complaint into a pointer at row 1000, which is
1349
+ * a real row, and one belonging to the PREVIOUS chunk.
1350
+ */
1351
+ function offsetRow(row, offset) {
1352
+ return row === 0 ? 0 : row + offset;
1353
+ }
1354
+ /**
1355
+ * Merges one chunk's diagnostics into the running total, moving every row number
1356
+ * into the file's frame.
1357
+ *
1358
+ * The server numbers rows within the BATCH it received, so chunk 2's "row 3" is
1359
+ * the file's row 1003. Concatenating the arrays without the offset produces a
1360
+ * report whose row numbers all point into the first thousand lines — the caller
1361
+ * then looks at the wrong row, or at a row that is fine.
1362
+ *
1363
+ * WARNINGS are prose, and they carry their own row number inside the sentence
1364
+ * ("Row 1: Unknown custom field ..."). Rewriting a number out of prose is
1365
+ * guesswork, so the frame is stated AROUND the sentence instead and only when
1366
+ * there is more than one frame to tell apart — a single-chunk import returns the
1367
+ * server's warnings byte-identical.
1368
+ *
1369
+ * The arrays are NOT re-capped. Each response is already truncated to the
1370
+ * server's own diagnostic cap while its `*Count` reports the true total, and
1371
+ * that relationship is preserved: counts sum to the truth, arrays hold what
1372
+ * arrived.
1373
+ */
1374
+ function mergeDiagnostics(total, chunk, frame, labelFrame) {
1375
+ for (const row of chunk.skipped) total.skipped.push({
1376
+ ...row,
1377
+ row: offsetRow(row.row, frame.offset)
1378
+ });
1379
+ for (const row of chunk.errors) total.errors.push({
1380
+ ...row,
1381
+ row: offsetRow(row.row, frame.offset)
1382
+ });
1383
+ for (const warning of chunk.warnings) total.warnings.push(labelFrame ? `rows ${String(frame.firstRow)}-${String(frame.lastRow)}: ${warning}` : warning);
1384
+ total.skippedCount += chunk.skippedCount;
1385
+ total.errorCount += chunk.errorCount;
1386
+ total.warningCount += chunk.warningCount;
1387
+ }
1388
+ function emptyDiagnostics() {
1389
+ return {
1390
+ skipped: [],
1391
+ skippedCount: 0,
1392
+ errors: [],
1393
+ errorCount: 0,
1394
+ warnings: [],
1395
+ warningCount: 0
1396
+ };
1397
+ }
1398
+ /**
1399
+ * Folds every chunk's answer into ONE document OF THE SERVER'S OWN SHAPE.
1400
+ *
1401
+ * Deliberately not a new "chunk report" type. For a single chunk the fold is the
1402
+ * identity, so the overwhelmingly common case still emits the server's body
1403
+ * verbatim and a consumer parses one shape whether the file was chunked or not.
1404
+ * Inventing a wrapper would make every caller branch on a detail the CLI chose.
1405
+ *
1406
+ * A chunk answering a DIFFERENT mode than the first is a server inconsistency —
1407
+ * `preview` is one flag sent identically on every chunk — and it is exit 1
1408
+ * rather than a silent pick.
1409
+ */
1410
+ function foldResults(results, frames) {
1411
+ const first = results[0];
1412
+ if (first === void 0) throw new Error("unreachable: no chunk ran");
1413
+ const labelFrame = results.length > 1;
1414
+ const diagnostics = emptyDiagnostics();
1415
+ results.forEach((result, index) => {
1416
+ const frame = frames[index];
1417
+ if (frame === void 0) throw new Error("unreachable: a chunk answered with no frame");
1418
+ if (result.mode !== first.mode) throw new CliError(1, `Chunk ${String(index + 1)} answered "${result.mode}" where chunk 1 answered "${first.mode}". The server changed mode mid-import.`);
1419
+ mergeDiagnostics(diagnostics, result, frame, labelFrame);
1420
+ });
1421
+ if (first.mode === "preview") {
1422
+ const previews = results;
1423
+ if (previews.length > 1) {
1424
+ diagnostics.warnings.unshift(`This preview validated ${String(previews.length)} chunks INDEPENDENTLY, each against the same unchanged account. Conflicts that SPAN chunks — one row key (an alias, a filter name) appearing in two different chunks, or any whole-file total — are NOT detected here, so a valid preview does not prove the whole file imports.`);
1425
+ diagnostics.warningCount += 1;
1426
+ }
1427
+ return {
1428
+ mode: "preview",
1429
+ valid: previews.every((result) => result.valid),
1430
+ toCreate: previews.reduce((sum, result) => sum + result.toCreate, 0),
1431
+ toUpdate: previews.reduce((sum, result) => sum + result.toUpdate, 0),
1432
+ existingCount: first.existingCount,
1433
+ ...diagnostics
1434
+ };
1435
+ }
1436
+ const executed = results;
1437
+ const messages = executed.map((result) => result.message).filter((message) => message !== null);
1438
+ return {
1439
+ mode: "executed",
1440
+ success: executed.every((result) => result.success),
1441
+ created: executed.reduce((sum, result) => sum + result.created, 0),
1442
+ updated: executed.reduce((sum, result) => sum + result.updated, 0),
1443
+ foldersCreated: executed.reduce((sum, result) => addNullable(sum, result.foldersCreated), null),
1444
+ message: messages.length === 0 ? null : messages.join("; "),
1445
+ ...diagnostics
1446
+ };
1447
+ }
1448
+ /**
1449
+ * Runs the chunks in order and tells the truth about where it stopped.
1450
+ *
1451
+ * THERE IS NO TRANSACTION ACROSS CHUNKS. Chunk 1 is committed the moment it
1452
+ * answers 200, so a failure on chunk 3 of 7 leaves the account with the first
1453
+ * two chunks applied. The two outcomes are therefore genuinely different and are
1454
+ * reported differently:
1455
+ *
1456
+ * - NOTHING succeeded yet → the failure is rethrown UNCHANGED, so `--json`
1457
+ * emits the server's own envelope verbatim and the exit code is the one its
1458
+ * `code` maps to. There is no partial state to describe, and flattening a
1459
+ * `CAP_EXCEEDED` into a generic partial would cost a script the one field it
1460
+ * branches on.
1461
+ * - SOMETHING succeeded → the answer is a partial import, which the server's
1462
+ * own shape can already say: `success: false` with a `message` naming the
1463
+ * chunk that failed and the reason it gave. Echoing only the error envelope
1464
+ * there would tell the caller nothing was written, which is false.
1465
+ *
1466
+ * A PREVIEW is exempt from the second case: it writes nothing, so a failed
1467
+ * preview chunk leaves no partial state and the failure passes straight through.
1468
+ */
1469
+ async function runChunkedImport(request) {
1470
+ const batches = chunkRows(request.rows, BULK_IMPORT_ROWS_MAX);
1471
+ const frames = chunkFrames(batches.map((batch) => batch.length));
1472
+ const results = [];
1473
+ for (const [index, batch] of batches.entries()) try {
1474
+ results.push(await request.sendChunk(batch));
1475
+ } catch (thrown) {
1476
+ if (results.filter((result) => result.mode === "executed").length === 0) throw thrown;
1477
+ const reason = thrown instanceof Error ? thrown.message : String(thrown);
1478
+ const folded = foldResults(results, frames);
1479
+ if (folded.mode !== "executed") throw thrown;
1480
+ const unattempted = batches.length - (index + 1);
1481
+ return {
1482
+ ...folded,
1483
+ success: false,
1484
+ message: `Chunk ${String(index + 1)} of ${String(batches.length)} failed: ${reason}. Earlier chunks left ${String(folded.created)} created and ${String(folded.updated)} updated in place — there is no rollback. Whether chunk ${String(index + 1)} itself wrote anything is unknown.${unattempted === 0 ? "" : ` The remaining ${String(unattempted)} chunk(s) were never sent.`}`
1485
+ };
1486
+ }
1487
+ return foldResults(results, frames);
1488
+ }
1489
+ /**
1490
+ * The success exit code for an import.
1491
+ *
1492
+ * Exit 11 is "a 200 whose body reports work that did not happen" (spec 301 →
1493
+ * Exit codes), and an import that could not apply a row is exactly that: a
1494
+ * restore script reading exit 0 would report a backup fully applied when rows
1495
+ * were rejected.
1496
+ *
1497
+ * `skippedCount` ALONE does not reach 11. A skipped row is the import path's
1498
+ * documented normal outcome for a row that needed no change, the same reading
1499
+ * the spec gives the blocklist's `already_present` / `not_found` — the requested
1500
+ * state holds. It is still printed prominently; it just is not a failure.
1501
+ */
1502
+ function exitCodeForImport(result) {
1503
+ if (result.errorCount > 0) return 11;
1504
+ if (result.mode === "preview") return result.valid ? 0 : 11;
1505
+ return result.success ? 0 : 11;
1506
+ }
1507
+ /**
1508
+ * The import's human answer, with the diagnostics ABOVE the summary.
1509
+ *
1510
+ * Order is the point. A caller who reads the first line and stops must see that
1511
+ * something was rejected, so the counts that mean "incomplete" print first and
1512
+ * the totals print last.
1513
+ */
1514
+ function renderImportResult(result, line) {
1515
+ if (result.errorCount > 0) {
1516
+ line(`${String(result.errorCount)} row(s) were REJECTED and did not import:`);
1517
+ for (const row of result.errors) line(` row ${String(row.row)} ${row.field}: ${row.reason}`);
1518
+ if (result.errors.length < result.errorCount) line(` ... and ${String(result.errorCount - result.errors.length)} more not listed.`);
1519
+ }
1520
+ if (result.skippedCount > 0) {
1521
+ line(`${String(result.skippedCount)} row(s) were skipped:`);
1522
+ for (const row of result.skipped) line(` row ${String(row.row)} ${row.reason}`);
1523
+ if (result.skipped.length < result.skippedCount) line(` ... and ${String(result.skippedCount - result.skipped.length)} more not listed.`);
1524
+ }
1525
+ if (result.warningCount > 0) {
1526
+ for (const warning of result.warnings) line(`warning: ${warning}`);
1527
+ if (result.warnings.length < result.warningCount) line(`warning: ... and ${String(result.warningCount - result.warnings.length)} more.`);
1528
+ }
1529
+ if (result.mode === "preview") {
1530
+ line(`Preview: ${String(result.toCreate)} to create, ${String(result.toUpdate)} to update, ${String(result.existingCount)} already in the account. Nothing was written.`);
1531
+ if (!result.valid) line("This file cannot be imported as it stands.");
1532
+ return;
1533
+ }
1534
+ line(`Imported: ${String(result.created)} created, ${String(result.updated)} updated${result.foldersCreated === null ? "" : `, ${String(result.foldersCreated)} folder(s) created`}.`);
1535
+ if (result.message !== null) line(result.message);
1536
+ if (!result.success) line("The import did NOT complete successfully.");
1537
+ }
1538
+ /**
1539
+ * The export's own admission that the document is short.
1540
+ *
1541
+ * Printed as a WARNING BLOCK before anything else, never as a trailing note: the
1542
+ * caller of an export is writing a backup, and a row missing from it is not
1543
+ * discovered until a restore that silently comes back incomplete. This is the
1544
+ * one caller that would otherwise ignore `skipped[]`.
1545
+ */
1546
+ function renderExportSkipped(skipped, entity, line) {
1547
+ if (skipped.length === 0) return;
1548
+ line(`WARNING: ${String(skipped.length)} ${entity} could NOT be exported and are MISSING from this document:`);
1549
+ for (const row of skipped) line(` ${row.publicId ?? "(no id)"} ${row.name} — too ${row.reason === "depth" ? "deeply nested" : "large"}`);
1550
+ line("This backup will NOT restore them. Simplify them and export again.");
1551
+ }
1552
+ /**
1553
+ * Exit 11 when the export left rows behind.
1554
+ *
1555
+ * A partial backup that exits 0 is the failure mode that matters here: every
1556
+ * wrapper script treats 0 as "the backup is good", and the omission surfaces at
1557
+ * restore time instead. Exit 11 is the spec's own "a 200 whose body reports work
1558
+ * that did not happen" and needs no new code — the spec's note that ONE action
1559
+ * produces it dates from before these verbs had a CLI command.
1560
+ */
1561
+ function exitCodeForExport(skipped) {
1562
+ return skipped.length > 0 ? 11 : 0;
1563
+ }
1564
+ //#endregion
1565
+ //#region src/commands/filters.ts
1566
+ /**
1567
+ * The `filters` command group (spec 301 → Bulk export/import).
1568
+ *
1569
+ * Export and import ONLY. Item filters are not a first-class concept in wave 1:
1570
+ * the API has no `list_item_filters`, no CRUD and no filter-folder action, so
1571
+ * there is nothing else for this group to expose and a third subcommand here
1572
+ * would be a command with no endpoint behind it.
1573
+ *
1574
+ * WHY THE GROUP EXISTS AT ALL, since spec 301's command surface says the two
1575
+ * filter verbs ship with no wave-1 CLI command: a saved-search row references
1576
+ * its filter folders by PATH, so restoring an account imports item filters
1577
+ * FIRST — and a CLI that could export and import searches but not filters cannot
1578
+ * perform the restore its own export document implies. That is a scope decision
1579
+ * above this module; the contradiction with the spec's "20 commands over 21
1580
+ * actions" is reported rather than resolved here.
1581
+ *
1582
+ * Argument mapping and rendering ONLY, like every other group — the transport,
1583
+ * the exit discipline, the page walk and the bulk mechanism all live in the
1584
+ * shared core.
1585
+ */
1586
+ async function readAllStdin$2() {
1587
+ const chunks = [];
1588
+ process.stdin.setEncoding("utf8");
1589
+ for await (const chunk of process.stdin) chunks.push(String(chunk));
1590
+ return chunks.join("");
1591
+ }
1592
+ function resolveDeps$1(deps) {
1593
+ return {
1594
+ resolveClient: deps.resolveClient ?? resolveApiClient,
1595
+ createOutput: deps.createOutput ?? processOutput,
1596
+ readStdin: deps.readStdin ?? readAllStdin$2,
1597
+ isInteractive: deps.isInteractive ?? processIsInteractive,
1598
+ confirm: deps.confirm ?? confirmOnTerminal
1599
+ };
1600
+ }
1601
+ /**
1602
+ * `export` IMPLIES `--all`, on the same reasoning as `searches export`: a
1603
+ * partial export is a corrupt backup, so neither `--limit` nor `--cursor` is
1604
+ * offered.
1605
+ *
1606
+ * BOTH per-page arrays are named in the walk. `filters` carries the rows;
1607
+ * `skipped` carries the rows the export could NOT carry — too large or too
1608
+ * deeply nested for the response scanner — and it accumulates page by page
1609
+ * exactly as the rows do. Walking only `filters` would keep the last page's
1610
+ * `skipped` and silently discard every earlier one, which is the same truncation
1611
+ * the field exists to report.
1612
+ */
1613
+ function exportCommand$1(deps) {
1614
+ return new Command("export").description("Write every item filter as a backup document — always walks every page").action(async function runExport() {
1615
+ const options = this.optsWithGlobals();
1616
+ await runCommand({
1617
+ out: deps.createOutput(options.json ?? false),
1618
+ run: async () => {
1619
+ const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
1620
+ return walkAllPages((cursor) => callAction(client, "export-item-filters", { cursor: cursor ?? void 0 }), ["filters", "skipped"]);
1621
+ },
1622
+ renderHuman: (data, line) => {
1623
+ renderExportSkipped(data.skipped, "item filter(s)", line);
1624
+ line(`Exported ${String(data.filters.length)} item filter(s).`);
1625
+ renderTable([
1626
+ "ID",
1627
+ "NAME",
1628
+ "FOLDER",
1629
+ "ACTION"
1630
+ ], data.filters.map((row) => [
1631
+ row.Id ?? "",
1632
+ row.Name,
1633
+ row["Folder Path"] ?? "",
1634
+ row.Action
1635
+ ]), line);
1636
+ },
1637
+ exitCodeFor: (data) => exitCodeForExport(data.skipped)
1638
+ });
1639
+ });
1640
+ }
1641
+ /**
1642
+ * The one seam where a document the CLI did not type-check becomes a typed
1643
+ * request — narrow and named, because spec 301 makes the server the only schema
1644
+ * validator and `--input` is by definition a document this build has not
1645
+ * inspected.
1646
+ */
1647
+ function asImportRows$1(rows) {
1648
+ return rows;
1649
+ }
1650
+ /**
1651
+ * The BODY, assembled field by field — never spread from the caller's document.
1652
+ *
1653
+ * Same write-retargeting rule as `searches import`, and the same worst case: a
1654
+ * `"replaceAll": true` in the file would DELETE every item filter on an import
1655
+ * the command line never flagged as destructive.
1656
+ *
1657
+ * There is deliberately NO `customFieldDefinitions` here. That field belongs to
1658
+ * the saved-search import; this body is `.strict()`, so copying it across would
1659
+ * refuse the whole call rather than be ignored — as would `skipped`, which an
1660
+ * export page carries.
1661
+ *
1662
+ * OMITTING `skipped` FROM THE BODY IS NOT DISMISSING IT. It has no meaning to
1663
+ * the write — it names rows the file does NOT hold — but it is the document's
1664
+ * own admission that it is incomplete, and that is a decision the CALLER's
1665
+ * flags turn on: `assertReplaceAllAllowed` reads it through
1666
+ * `documentSkippedCount` and refuses `--replace-all` over a short document,
1667
+ * which would delete the very rows the field names. Not sent, and not ignored.
1668
+ */
1669
+ function importBody$1(rows, options) {
1670
+ return {
1671
+ filters: asImportRows$1(rows),
1672
+ preview: options.preview ?? false,
1673
+ replaceAll: options.replaceAll ?? false,
1674
+ targetFolderId: options.targetFolder ?? null
1675
+ };
1676
+ }
1677
+ /** `-` stays stdin; anything else is a path, which `--input` spells `@path`. */
1678
+ function fileSource$1(file) {
1679
+ return file === "-" ? "-" : `@${file}`;
1680
+ }
1681
+ function importCommand$1(deps) {
1682
+ return new Command("import").description("Restore item filters from a backup document").argument("[file]", "Path to the export document, or \"-\" for stdin").option("--input <json|@file|->", "The document as JSON, instead of the positional").option("--preview", "Validate and report without writing anything").option("--replace-all", "DELETE every existing item filter first — destructive").option("--target-folder <id>", "File every imported filter into this folder").option("--yes", "Acknowledge --replace-all without an interactive prompt").action(async function runImport(file) {
1683
+ const options = this.optsWithGlobals();
1684
+ const json = options.json ?? false;
1685
+ await runCommand({
1686
+ out: deps.createOutput(json),
1687
+ run: async () => {
1688
+ if (file !== void 0 && options.input !== void 0) throw new CliError(2, "Use the file argument or --input, not both. They are two ways to supply one body.");
1689
+ const raw = options.input ?? (file === void 0 ? void 0 : fileSource$1(file));
1690
+ if (raw === void 0) throw new CliError(2, "filters import needs a file argument, or --input carrying the export document.");
1691
+ const document = await readInputObject({
1692
+ raw,
1693
+ readStdin: deps.readStdin,
1694
+ expects: "It should carry the \"filters\" array an export document holds."
1695
+ });
1696
+ const rows = readRows(document, "filters", "item filters");
1697
+ await assertReplaceAllAllowed({
1698
+ replaceAll: options.replaceAll ?? false,
1699
+ yes: options.yes ?? false,
1700
+ json,
1701
+ bodyFromStdin: raw === "-",
1702
+ chunkCount: chunkRows(rows, BULK_IMPORT_ROWS_MAX).length,
1703
+ documentSkippedCount: documentSkippedCount(document),
1704
+ entity: "item filters",
1705
+ deps
1706
+ });
1707
+ const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
1708
+ return runChunkedImport({
1709
+ rows,
1710
+ sendChunk: (batch) => callAction(client, "import-item-filters", importBody$1(batch, options))
1711
+ });
1712
+ },
1713
+ renderHuman: renderImportResult,
1714
+ exitCodeFor: exitCodeForImport
1715
+ });
1716
+ });
1717
+ }
1718
+ function buildFiltersCommand(deps = {}) {
1719
+ const resolved = resolveDeps$1(deps);
1720
+ return new Command("filters").description("Export and import item filters").addCommand(exportCommand$1(resolved)).addCommand(importCommand$1(resolved));
1721
+ }
1722
+ //#endregion
1723
+ //#region src/commands/folders.ts
1724
+ /**
1725
+ * The `folders` command group (spec 301 → CLI → Command surface).
1726
+ *
1727
+ * `list`, `create`, `rename`, `delete`, `move` over `list-folders`,
1728
+ * `create-folder`, `update-folder`, `delete-folder` and `move-search-to-folder`.
1729
+ *
1730
+ * Argument mapping and rendering ONLY. The transport, the envelope, the exit
1731
+ * codes, the two output modes and the `--all` walk all live in the shared core,
1732
+ * so nothing here re-implements them.
1733
+ *
1734
+ * Global options are read with `optsWithGlobals()`. Commander's `.opts()`
1735
+ * returns LOCAL options only, so a subcommand reading `--json` through it gets
1736
+ * `undefined` and silently writes human text into a pipe with a zero exit code.
1737
+ * `__tests__/global-options.test.ts` refuses a `.opts()` call in this file; it
1738
+ * strips comments before scanning, so naming the accessor here is free.
1739
+ */
1740
+ const FOLDER_COLUMNS = [
1741
+ "ID",
1742
+ "NAME",
1743
+ "PARENT",
1744
+ "SORT"
1745
+ ];
1746
+ /** What a null id renders as: no parent (top level), or no folder (unfiled). */
1747
+ const NONE = "-";
1748
+ const INPUT_DESCRIPTION = "Send this JSON object as the whole request body: a literal, @file, or - for stdin";
1749
+ function usage(message) {
1750
+ return new CliError(2, message);
1751
+ }
1752
+ function folderRow(folder) {
1753
+ return [
1754
+ folder.id,
1755
+ folder.name,
1756
+ folder.parentId ?? NONE,
1757
+ String(folder.sortOrder)
1758
+ ];
1759
+ }
1760
+ function requiredArgument(value, what) {
1761
+ if (value === void 0) throw usage(`${what} is required. Pass it, or pass --input with the whole request body.`);
1762
+ return value;
1763
+ }
1764
+ /**
1765
+ * Rejects a `--limit` that is not a whole number and stops there. The RANGE is
1766
+ * the server's (1–200): it is the only schema validator, and duplicating its
1767
+ * bounds here would go stale the day they move.
1768
+ */
1769
+ function parseLimit$1(raw) {
1770
+ if (raw === void 0) return void 0;
1771
+ if (!/^\d+$/.test(raw)) throw usage(`--limit must be a whole number, not "${raw}".`);
1772
+ return Number(raw);
1773
+ }
1774
+ /**
1775
+ * `--input` IS the request body, so it never merges with the command's own
1776
+ * arguments (spec 301 → CLI → Argument rules). Supplying both is a usage error
1777
+ * rather than a merge, because a merged body cannot be read back off the command
1778
+ * line — the caller would be guessing which half won.
1779
+ *
1780
+ * Positional arguments are checked alongside the flags, which is why every
1781
+ * argument in this group is declared optional: `--input` and the arguments are
1782
+ * alternative ways to say the same thing, and the requirement is enforced here
1783
+ * so a missing one is exit 2 with a message rather than commander's own error.
1784
+ */
1785
+ function assertInputAlone(input, named) {
1786
+ if (input === void 0) return;
1787
+ const supplied = Object.keys(named).filter((name) => named[name] !== void 0);
1788
+ if (supplied.length === 0) return;
1789
+ throw usage(`--input carries the whole request body, so it cannot be combined with ${supplied.join(" or ")}. Use one or the other.`);
1790
+ }
1791
+ /**
1792
+ * `--input` IS the whole request body for this group — unlike `searches update`,
1793
+ * which wraps it, and `notifications settings`, which spreads it under a
1794
+ * positional id. `assertInputAlone` above is what keeps that unambiguous: the
1795
+ * flag and the command's own arguments are never merged, so there is nothing for
1796
+ * a body to silently override.
1797
+ */
1798
+ function inputBody(raw, readStdin) {
1799
+ return readInputObject({
1800
+ raw,
1801
+ readStdin
1802
+ });
1803
+ }
1804
+ async function createBody(name, options, readStdin) {
1805
+ assertInputAlone(options.input, {
1806
+ "a name argument": name,
1807
+ "--parent": options.parent
1808
+ });
1809
+ if (options.input !== void 0) return inputBody(options.input, readStdin);
1810
+ return {
1811
+ name: requiredArgument(name, "A folder name"),
1812
+ parentId: options.parent ?? null
1813
+ };
1814
+ }
1815
+ async function renameBody(id, name, options, readStdin) {
1816
+ assertInputAlone(options.input, {
1817
+ "an id argument": id,
1818
+ "a name argument": name
1819
+ });
1820
+ if (options.input !== void 0) return inputBody(options.input, readStdin);
1821
+ return {
1822
+ id: requiredArgument(id, "A folder id"),
1823
+ name: requiredArgument(name, "A new folder name")
1824
+ };
1825
+ }
1826
+ /**
1827
+ * `--to <id>` XOR `--unfile`, exactly one, decided locally before any HTTP call
1828
+ * (acceptance 8).
1829
+ *
1830
+ * `targetFolderId` is nullable with NO default, so there is no body the CLI can
1831
+ * send for "neither flag" that is not a guess about which the caller meant —
1832
+ * and `--unfile` sends an explicit `null` rather than omitting the field.
1833
+ */
1834
+ async function moveBody(searchId, options, readStdin) {
1835
+ assertInputAlone(options.input, {
1836
+ "a searchId argument": searchId,
1837
+ "--to": options.to,
1838
+ "--unfile": options.unfile
1839
+ });
1840
+ if (options.input !== void 0) return inputBody(options.input, readStdin);
1841
+ const unfile = options.unfile ?? false;
1842
+ if (options.to !== void 0 && unfile) throw usage("Use --to <id> or --unfile, not both. A search is filed under one folder, or none.");
1843
+ if (options.to === void 0 && !unfile) throw usage("A move needs --to <id> or --unfile. There is no default target folder, so the CLI will not choose one for you.");
1844
+ return {
1845
+ searchId: requiredArgument(searchId, "A saved-search id"),
1846
+ targetFolderId: options.to ?? null
1847
+ };
1848
+ }
1849
+ function buildFoldersCommand(deps = {}) {
1850
+ const output = deps.output ?? processOutput;
1851
+ const readStdin = deps.readStdin ?? (() => text(process.stdin));
1852
+ const client = (globals) => resolveApiClient({
1853
+ baseUrlFlag: globals.baseUrl ?? null,
1854
+ env: deps.env,
1855
+ fetch: deps.fetch,
1856
+ configDir: deps.configDir
1857
+ });
1858
+ const folders = new Command("folders").description("Manage saved-search folders");
1859
+ folders.command("list").description("List folders. A PARENT of - means the folder is top level.").option("--limit <n>", "Folders per page (the server allows 1-200 and defaults to 200)").option("--cursor <cursor>", "Resume from a previous answer's nextCursor").option("--all", "Walk every page and emit one combined result").action(async (_options, command) => {
1860
+ const options = command.optsWithGlobals();
1861
+ await runCommand({
1862
+ out: output(options.json ?? false),
1863
+ run: async () => {
1864
+ const all = options.all ?? false;
1865
+ assertPagingFlags({
1866
+ all,
1867
+ cursor: options.cursor
1868
+ });
1869
+ const limit = parseLimit$1(options.limit);
1870
+ const api = await client(options);
1871
+ if (all) return walkAllPages((cursor) => callAction(api, "list-folders", {
1872
+ limit,
1873
+ cursor: cursor ?? void 0
1874
+ }), "folders");
1875
+ return callAction(api, "list-folders", {
1876
+ limit,
1877
+ cursor: options.cursor
1878
+ });
1879
+ },
1880
+ renderHuman: (data, line) => renderTable(FOLDER_COLUMNS, data.folders.map(folderRow), line)
1881
+ });
1882
+ });
1883
+ folders.command("create").description("Create a folder").argument("[name]", "Folder name. Omit it only when passing --input.").option("--parent <id>", "Nest inside this folder id (default: top level)").option("--input <json|@file|->", INPUT_DESCRIPTION).action(async (name, _options, command) => {
1884
+ const options = command.optsWithGlobals();
1885
+ await runCommand({
1886
+ out: output(options.json ?? false),
1887
+ run: async () => {
1888
+ const body = await createBody(name, options, readStdin);
1889
+ return callAction(await client(options), "create-folder", body);
1890
+ },
1891
+ renderHuman: (data, line) => renderTable(FOLDER_COLUMNS, [folderRow(data.folder)], line)
1892
+ });
1893
+ });
1894
+ folders.command("rename").description("Rename a folder. Rename only — re-parenting and reordering are not exposed.").argument("[id]", "Folder id, from folders list. Omit it only when passing --input.").argument("[name]", "The new name").option("--input <json|@file|->", INPUT_DESCRIPTION).action(async (id, name, _options, command) => {
1895
+ const options = command.optsWithGlobals();
1896
+ await runCommand({
1897
+ out: output(options.json ?? false),
1898
+ run: async () => {
1899
+ const body = await renameBody(id, name, options, readStdin);
1900
+ return callAction(await client(options), "update-folder", body);
1901
+ },
1902
+ renderHuman: (data, line) => renderTable(["ID", "NAME"], [[data.id, data.name]], line)
1903
+ });
1904
+ });
1905
+ folders.command("delete").description("Delete a folder. The saved searches inside it survive, unfiled, and child folders are flattened to the top level.").argument("<id>", "Folder id, from folders list").action(async (id, _options, command) => {
1906
+ const options = command.optsWithGlobals();
1907
+ await runCommand({
1908
+ out: output(options.json ?? false),
1909
+ run: async () => callAction(await client(options), "delete-folder", { id }),
1910
+ renderHuman: (data, line) => renderTable(["ID"], [[data.id]], line)
1911
+ });
1912
+ });
1913
+ folders.command("move").description("File a saved search under a folder, or under none").argument("[searchId]", "Saved-search id, from searches list. Omit it only with --input.").option("--to <id>", "Target folder id, from folders list").option("--unfile", "File the search under no folder — sends an explicit null").option("--input <json|@file|->", INPUT_DESCRIPTION).action(async (searchId, _options, command) => {
1914
+ const options = command.optsWithGlobals();
1915
+ await runCommand({
1916
+ out: output(options.json ?? false),
1917
+ run: async () => {
1918
+ const body = await moveBody(searchId, options, readStdin);
1919
+ return callAction(await client(options), "move-search-to-folder", body);
1920
+ },
1921
+ renderHuman: (data, line) => renderTable(["SEARCH", "FOLDER"], [[data.id, data.folderId ?? NONE]], line)
1922
+ });
1923
+ });
1924
+ return folders;
1925
+ }
1926
+ //#endregion
1927
+ //#region src/commands/notifications.ts
1928
+ /**
1929
+ * The `notifications` command group (spec 301 → CLI → Command surface, wave 1).
1930
+ *
1931
+ * Three subcommands over FOUR actions: `settings` reads by default and writes
1932
+ * when `--input` is present, so `--input` IS the read/write switch. There is no
1933
+ * fourth subcommand — notification CREDENTIAL management is a stated non-goal,
1934
+ * and no endpoint exposes it.
1935
+ *
1936
+ * Like every command module this is argument mapping and rendering only. The
1937
+ * transport, the error envelope, the exit-code table and the `--all` walk all
1938
+ * live in the shared core; nothing here re-implements any of them.
1939
+ */
1940
+ /** 0=Sunday through 6=Saturday, as the endpoint documents `activeDays`. */
1941
+ const DAY_NAMES = [
1942
+ "Sun",
1943
+ "Mon",
1944
+ "Tue",
1945
+ "Wed",
1946
+ "Thu",
1947
+ "Fri",
1948
+ "Sat"
1949
+ ];
1950
+ /**
1951
+ * NULL IS NOT "OFF". `schedule`, `interval` and `template` are null exactly when
1952
+ * the channel has never been CONFIRMED — the three rows are created together on
1953
+ * first confirmation — so null means "not configured yet", never "unrestricted"
1954
+ * or "unlimited". Rendering it as "off" or "none" inverts the meaning.
1955
+ */
1956
+ const NOT_CONFIGURED = "not configured (this channel has never been confirmed)";
1957
+ const CHANNEL_COLUMNS = [
1958
+ "ID",
1959
+ "TYPE",
1960
+ "STATUS",
1961
+ "ENABLED",
1962
+ "CONFIRMED",
1963
+ "FAILURES",
1964
+ "LAST SENT"
1965
+ ];
1966
+ function yesNo(value) {
1967
+ return value ? "yes" : "no";
1968
+ }
1969
+ /**
1970
+ * `isEnabled` and `status` are INDEPENDENT and both are shown: the first is the
1971
+ * account holder's own switch, the second is delivery health they do not set, so
1972
+ * an enabled channel can still be failing. `confirmedAt` is shown for the same
1973
+ * reason — an unconfirmed channel delivers nothing whatever `isEnabled` says.
1974
+ * Everything else the row carries is one `--json` away.
1975
+ */
1976
+ function channelRow(channel) {
1977
+ return [
1978
+ channel.id,
1979
+ channel.platformType,
1980
+ channel.status,
1981
+ yesNo(channel.isEnabled),
1982
+ channel.confirmedAt ?? "never",
1983
+ String(channel.consecutiveFailures),
1984
+ channel.lastSentAt ?? "never"
1985
+ ];
1986
+ }
1987
+ /**
1988
+ * Refuses a `--limit` that is not a whole number, locally.
1989
+ *
1990
+ * The API's own 1–200 range is deliberately NOT re-declared here — the server is
1991
+ * the validator of it. What this catches is different: `Number('twenty')` is
1992
+ * `NaN`, `JSON.stringify({ limit: NaN })` is `{"limit":null}`, and the user then
1993
+ * reads a VALIDATION message about a null they never typed.
1994
+ */
1995
+ function parseLimit(raw) {
1996
+ if (raw === void 0) return void 0;
1997
+ if (!/^\d+$/.test(raw)) throw new CliError(2, `--limit must be a whole number, not "${raw}".`);
1998
+ return Number(raw);
1999
+ }
2000
+ /**
2001
+ * `--on` XOR `--off`. `enabled` is REQUIRED with no default and it is a SET, not
2002
+ * a flip, so the CLI cannot pick one: neither flag and both flags are the same
2003
+ * failure — the caller has not said which state they want.
2004
+ */
2005
+ function requiredSwitch(options) {
2006
+ const on = options.on ?? false;
2007
+ if (on === (options.off ?? false)) throw new CliError(2, "Use --on or --off, exactly one. \"enabled\" is a SET, not a flip, and the API declares no default.");
2008
+ return on;
2009
+ }
2010
+ /** Whole-stream read, so `--input -` works on Windows too (no `/dev/stdin`). */
2011
+ function readAllStdin$1() {
2012
+ return text(process.stdin);
2013
+ }
2014
+ /**
2015
+ * What the object is expected to carry, for the shared parser's refusal. Spec
2016
+ * 301 puts the whole schema with the server, so this is guidance and never a
2017
+ * check — nothing here enumerates the fields.
2018
+ */
2019
+ const INPUT_EXPECTS = "The object carries the aspects to write; the server validates their contents.";
2020
+ function describeSchedule(schedule) {
2021
+ if (schedule === null) return NOT_CONFIGURED;
2022
+ if (schedule.scheduleType === "ALWAYS") return "ALWAYS";
2023
+ const days = schedule.activeDays.map((day) => DAY_NAMES[day] ?? String(day));
2024
+ const window = `${schedule.startTime ?? "unset"}-${schedule.endTime ?? "unset"}`;
2025
+ return `CUSTOM ${days.length === 0 ? "no days" : days.join(",")} ${window}`;
2026
+ }
2027
+ function describeInterval(interval) {
2028
+ if (interval === null) return NOT_CONFIGURED;
2029
+ return `${interval.interval} last batch ${interval.lastBatchAt ?? "never"}`;
2030
+ }
2031
+ function describeTemplate(template) {
2032
+ if (template === null) return NOT_CONFIGURED;
2033
+ const images = template.includeImages ? "with images" : "without images";
2034
+ return `${template.selectedFields.length} fields, ${images}`;
2035
+ }
2036
+ function renderSettings(settings, line) {
2037
+ renderTable(["FIELD", "VALUE"], [
2038
+ ["ID", settings.id],
2039
+ ["SCHEDULE", describeSchedule(settings.schedule)],
2040
+ ["INTERVAL", describeInterval(settings.interval)],
2041
+ ["TEMPLATE", `${describeTemplate(settings.template)} (read-only)`]
2042
+ ], line);
2043
+ line("");
2044
+ if (settings.filterRules.length === 0) {
2045
+ line("Filter rules: none attached.");
2046
+ return;
2047
+ }
2048
+ renderTable([
2049
+ "FILTER ID",
2050
+ "ACTION",
2051
+ "NAME"
2052
+ ], settings.filterRules.map((rule) => [
2053
+ rule.filterId,
2054
+ rule.action,
2055
+ rule.filterName ?? "(unnamed)"
2056
+ ]), line);
2057
+ }
2058
+ /**
2059
+ * The 200 body is a PER-ASPECT OUTCOME MAP, not a success flag, so the render
2060
+ * shows all three outcomes verbatim and `exitCodeForChannelSettings` turns an
2061
+ * unapplied one into exit 11.
2062
+ */
2063
+ function renderAspectOutcomes(result, line) {
2064
+ renderTable(["ASPECT", "OUTCOME"], [
2065
+ ["schedule", result.schedule],
2066
+ ["interval", result.interval],
2067
+ ["filterRules", result.filterRules]
2068
+ ], line);
2069
+ }
2070
+ function buildNotificationsCommand(deps = {}) {
2071
+ const resolveClient = deps.resolveClient ?? ((baseUrlFlag) => resolveApiClient({ baseUrlFlag }));
2072
+ const createOutput = deps.createOutput ?? processOutput;
2073
+ const readStdin = deps.readStdin ?? readAllStdin$1;
2074
+ const group = new Command("notifications").description("List notification channels, switch them on or off, and read or write their delivery settings");
2075
+ group.command("list").description("List the delivery channels this key owns").option("--limit <n>", "Channels per page (the API allows 1-200, default 200)").option("--cursor <cursor>", "Resume from a previous answer’s nextCursor").option("--all", "Walk every page and emit one combined result").action(async (_options, command) => {
2076
+ const options = command.optsWithGlobals();
2077
+ await runCommand({
2078
+ out: createOutput(options.json ?? false),
2079
+ run: async () => {
2080
+ const all = options.all ?? false;
2081
+ const cursor = options.cursor ?? null;
2082
+ assertPagingFlags({
2083
+ all,
2084
+ cursor
2085
+ });
2086
+ const limit = parseLimit(options.limit);
2087
+ const client = await resolveClient(options.baseUrl ?? null);
2088
+ const fetchPage = (pageCursor) => callAction(client, "list-notification-channels", {
2089
+ limit,
2090
+ cursor: pageCursor ?? void 0
2091
+ });
2092
+ return all ? walkAllPages(fetchPage, "channels") : fetchPage(cursor);
2093
+ },
2094
+ renderHuman: (page, line) => renderTable(CHANNEL_COLUMNS, page.channels.map(channelRow), line)
2095
+ });
2096
+ });
2097
+ group.command("toggle").description("Turn one channel on or off").argument("<id>", "Channel id, from \"notifications list\"").option("--on", "Deliver on this channel").option("--off", "Stop delivering on this channel").action(async (id, _options, command) => {
2098
+ const options = command.optsWithGlobals();
2099
+ await runCommand({
2100
+ out: createOutput(options.json ?? false),
2101
+ run: async () => {
2102
+ const enabled = requiredSwitch(options);
2103
+ return callAction(await resolveClient(options.baseUrl ?? null), "toggle-notification-channel", {
2104
+ id,
2105
+ enabled
2106
+ });
2107
+ },
2108
+ renderHuman: (result, line) => renderTable(["ID", "ENABLED"], [[result.id, yesNo(result.isEnabled)]], line)
2109
+ });
2110
+ });
2111
+ group.command("settings").description("Read one channel’s delivery settings, or write them with --input").argument("<id>", "Channel id, from \"notifications list\"").option("--input <json|@file|->", "Aspects to WRITE, as a JSON object: schedule, interval, filterRules. At least one is required, each REPLACES that aspect wholesale, and \"template\" is not writable. Without this flag the command reads instead.").action(async (id, _options, command) => {
2112
+ const options = command.optsWithGlobals();
2113
+ const out = createOutput(options.json ?? false);
2114
+ const input = options.input;
2115
+ if (input === void 0) {
2116
+ await runCommand({
2117
+ out,
2118
+ run: async () => {
2119
+ return callAction(await resolveClient(options.baseUrl ?? null), "get-notification-channel-settings", { id });
2120
+ },
2121
+ renderHuman: renderSettings
2122
+ });
2123
+ return;
2124
+ }
2125
+ await runCommand({
2126
+ out,
2127
+ run: async () => {
2128
+ const aspects = await readInputObject({
2129
+ raw: input,
2130
+ readStdin,
2131
+ expects: INPUT_EXPECTS
2132
+ });
2133
+ return callAction(await resolveClient(options.baseUrl ?? null), "update-notification-channel-settings", {
2134
+ ...aspects,
2135
+ id
2136
+ });
2137
+ },
2138
+ renderHuman: renderAspectOutcomes,
2139
+ exitCodeFor: exitCodeForChannelSettings
2140
+ });
2141
+ });
2142
+ return group;
2143
+ }
2144
+ //#endregion
2145
+ //#region src/commands/searches.ts
2146
+ /**
2147
+ * The `searches` command group (spec 301 → CLI → Command surface).
2148
+ *
2149
+ * `list`, `create`, `update`, `pause`, `resume`, `delete`, `export`, `import`.
2150
+ *
2151
+ * Argument mapping and rendering ONLY. The transport (`callAction`), the
2152
+ * envelope and exit discipline (`runCommand`), the `--all` walk
2153
+ * (`walkAllPages`) and the bulk mechanism (`bulk.ts`) all live in the shared
2154
+ * core; nothing in this file speaks HTTP, formats an error or sets an exit code
2155
+ * of its own.
2156
+ */
2157
+ async function readAllStdin() {
2158
+ const chunks = [];
2159
+ process.stdin.setEncoding("utf8");
2160
+ for await (const chunk of process.stdin) chunks.push(String(chunk));
2161
+ return chunks.join("");
2162
+ }
2163
+ function resolveDeps(deps) {
2164
+ return {
2165
+ resolveClient: deps.resolveClient ?? resolveApiClient,
2166
+ createOutput: deps.createOutput ?? processOutput,
2167
+ readStdin: deps.readStdin ?? readAllStdin,
2168
+ isInteractive: deps.isInteractive ?? processIsInteractive,
2169
+ confirm: deps.confirm ?? confirmOnTerminal
2170
+ };
2171
+ }
2172
+ const SEARCH_COLUMNS = [
2173
+ "ID",
2174
+ "NAME",
2175
+ "KEYWORDS",
2176
+ "SITE",
2177
+ "ENABLED"
2178
+ ];
2179
+ function searchCells(search) {
2180
+ return [
2181
+ search.id,
2182
+ search.name,
2183
+ search.keywords,
2184
+ search.site,
2185
+ search.isEnabled ? "yes" : "no"
2186
+ ];
2187
+ }
2188
+ function decimalFlag(raw, flag) {
2189
+ if (raw === void 0) return void 0;
2190
+ const value = Number(raw);
2191
+ if (raw.trim() === "" || !Number.isFinite(value)) throw new CliError(2, `${flag} must be a number.`);
2192
+ return value;
2193
+ }
2194
+ function wholeNumberFlag(raw, flag) {
2195
+ const value = decimalFlag(raw, flag);
2196
+ if (value !== void 0 && !Number.isInteger(value)) throw new CliError(2, `${flag} must be a whole number.`);
2197
+ return value;
2198
+ }
2199
+ function asCreateBody(fields) {
2200
+ return fields;
2201
+ }
2202
+ function asUpdateChanges(fields) {
2203
+ return fields;
2204
+ }
2205
+ /**
2206
+ * Commander's option KEY beside the flag a user actually typed. The refusal
2207
+ * below names the flag, not the key: `priceMin` is not something anyone can drop
2208
+ * from a command line.
2209
+ */
2210
+ const CREATE_FIELD_FLAGS = [
2211
+ ["name", "--name"],
2212
+ ["keywords", "--keywords"],
2213
+ ["site", "--site"],
2214
+ ["priceMin", "--price-min"],
2215
+ ["priceMax", "--price-max"],
2216
+ ["condition", "--condition"]
2217
+ ];
2218
+ /**
2219
+ * `--input` and the same command's field flags are mutually exclusive —
2220
+ * supplying both is a usage error, never a merge, because a merged body is
2221
+ * unreadable from the command line.
2222
+ *
2223
+ * The check runs BEFORE the branch, deliberately: putting it inside the
2224
+ * field-flag branch is unreachable the moment `--input` is present, which is the
2225
+ * only case it exists to catch.
2226
+ */
2227
+ async function resolveCreateFields(options, readStdin) {
2228
+ const supplied = CREATE_FIELD_FLAGS.filter(([key]) => Reflect.get(options, key) !== void 0).map(([, flag]) => flag);
2229
+ if (options.input !== void 0) {
2230
+ if (supplied.length > 0) throw new CliError(2, `Use --input or the field flags, not both. Drop --input, or drop: ${supplied.join(", ")}.`);
2231
+ return readInputObject({
2232
+ raw: options.input,
2233
+ readStdin
2234
+ });
2235
+ }
2236
+ return buildCreateFields(options);
2237
+ }
2238
+ function buildCreateFields(options) {
2239
+ if (options.name === void 0 || options.keywords === void 0) throw new CliError(2, "searches create needs --name and --keywords, or an --input body carrying them.");
2240
+ return {
2241
+ name: options.name,
2242
+ keywords: options.keywords,
2243
+ site: options.site,
2244
+ priceMin: decimalFlag(options.priceMin, "--price-min"),
2245
+ priceMax: decimalFlag(options.priceMax, "--price-max"),
2246
+ conditions: options.condition
2247
+ };
2248
+ }
2249
+ function listCommand(deps) {
2250
+ return new Command("list").description("List saved searches").option("--query <text>", "Filter by name or keywords").option("--limit <count>", "Rows per page (1-50, default 20)").option("--cursor <cursor>", "Resume from a previous page").option("--all", "Walk every page and emit one result").action(async function runList() {
2251
+ const options = this.optsWithGlobals();
2252
+ await runCommand({
2253
+ out: deps.createOutput(options.json ?? false),
2254
+ run: async () => {
2255
+ const all = options.all ?? false;
2256
+ assertPagingFlags({
2257
+ all,
2258
+ cursor: options.cursor
2259
+ });
2260
+ const limit = wholeNumberFlag(options.limit, "--limit");
2261
+ const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
2262
+ const page = (cursor) => callAction(client, "list-saved-searches", {
2263
+ query: options.query,
2264
+ limit,
2265
+ cursor: cursor ?? void 0
2266
+ });
2267
+ return all ? walkAllPages(page, "savedSearches") : page(options.cursor);
2268
+ },
2269
+ renderHuman: (data, line) => {
2270
+ renderTable(SEARCH_COLUMNS, data.savedSearches.map(searchCells), line);
2271
+ }
2272
+ });
2273
+ });
2274
+ }
2275
+ function createCommand(deps) {
2276
+ return new Command("create").description("Create a saved search").option("--input <json|@file|->", "The whole request body as JSON").option("--name <name>", "Saved search name").option("--keywords <keywords>", "eBay keyword query").option("--site <site>", "eBay site, e.g. EBAY_US").option("--price-min <amount>", "Minimum price").option("--price-max <amount>", "Maximum price").option("--condition <condition...>", "Item condition; repeat for several").action(async function runCreate() {
2277
+ const options = this.optsWithGlobals();
2278
+ await runCommand({
2279
+ out: deps.createOutput(options.json ?? false),
2280
+ run: async () => {
2281
+ const fields = await resolveCreateFields(options, deps.readStdin);
2282
+ return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "create-saved-search", asCreateBody(fields));
2283
+ },
2284
+ renderHuman: (data, line) => {
2285
+ renderTable(SEARCH_COLUMNS, [searchCells(data.savedSearch)], line);
2286
+ }
2287
+ });
2288
+ });
2289
+ }
2290
+ function updateCommand(deps) {
2291
+ return new Command("update").description("Apply a partial change set to one saved search").argument("<id>", "Saved search id, from \"searches list\"").option("--input <json|@file|->", "The CHANGES object as JSON — the CLI wraps it").action(async function runUpdate(id) {
2292
+ const options = this.optsWithGlobals();
2293
+ await runCommand({
2294
+ out: deps.createOutput(options.json ?? false),
2295
+ run: async () => {
2296
+ const input = options.input;
2297
+ if (input === void 0) throw new CliError(2, "searches update needs --input carrying the fields to change.");
2298
+ const changes = asUpdateChanges(await readInputObject({
2299
+ raw: input,
2300
+ readStdin: deps.readStdin
2301
+ }));
2302
+ return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "update-saved-search", {
2303
+ id,
2304
+ changes
2305
+ });
2306
+ },
2307
+ renderHuman: (data, line) => {
2308
+ renderTable(SEARCH_COLUMNS, [searchCells(data.savedSearch)], line);
2309
+ }
2310
+ });
2311
+ });
2312
+ }
2313
+ /**
2314
+ * `pause` and `resume` are one action with `enabled` flipped, and it is a SET,
2315
+ * not a toggle: the command name is what states which the caller meant.
2316
+ */
2317
+ function enabledCommand(deps, name, enabled) {
2318
+ return new Command(name).description(enabled ? "Resume a saved search" : "Pause a saved search").argument("<id>", "Saved search id, from \"searches list\"").action(async function runEnabled(id) {
2319
+ const options = this.optsWithGlobals();
2320
+ await runCommand({
2321
+ out: deps.createOutput(options.json ?? false),
2322
+ run: async () => {
2323
+ return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "pause-or-resume-saved-search", {
2324
+ id,
2325
+ enabled
2326
+ });
2327
+ },
2328
+ renderHuman: (data, line) => {
2329
+ renderTable(["ID", "ENABLED"], [[data.id, data.isEnabled ? "yes" : "no"]], line);
2330
+ }
2331
+ });
2332
+ });
2333
+ }
2334
+ function deleteCommand(deps) {
2335
+ return new Command("delete").description("Delete a saved search — there is no undelete").argument("<id>", "Saved search id, from \"searches list\"").action(async function runDelete(id) {
2336
+ const options = this.optsWithGlobals();
2337
+ await runCommand({
2338
+ out: deps.createOutput(options.json ?? false),
2339
+ run: async () => {
2340
+ return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "delete-saved-search", { id });
2341
+ },
2342
+ renderHuman: (data, line) => {
2343
+ line(`Deleted saved search ${data.id}`);
2344
+ }
2345
+ });
2346
+ });
2347
+ }
2348
+ /**
2349
+ * `export` IMPLIES `--all` and offers neither `--limit` nor `--cursor`.
2350
+ *
2351
+ * A partial export is a corrupt backup, so this is the one read that walks every
2352
+ * page by default rather than on request (spec 301 → Paging). Offering the two
2353
+ * paging flags would let a caller write a one-page file that looks exactly like
2354
+ * a whole-account one.
2355
+ *
2356
+ * `customFieldDefinitions` is NOT concatenated across pages: it is whole-account
2357
+ * and repeats verbatim on every page, so the walker keeps the last page's copy.
2358
+ * The rows are the only per-page array this export carries.
2359
+ */
2360
+ function exportCommand(deps) {
2361
+ return new Command("export").description("Write every saved search as a backup document — always walks every page").action(async function runExport() {
2362
+ const options = this.optsWithGlobals();
2363
+ await runCommand({
2364
+ out: deps.createOutput(options.json ?? false),
2365
+ run: async () => {
2366
+ const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
2367
+ return walkAllPages((cursor) => callAction(client, "export-saved-searches", { cursor: cursor ?? void 0 }), "searches");
2368
+ },
2369
+ renderHuman: (data, line) => {
2370
+ line(`Exported ${String(data.searches.length)} saved search(es).`);
2371
+ renderTable([
2372
+ "ALIAS",
2373
+ "FOLDER",
2374
+ "SITE"
2375
+ ], data.searches.map((row) => [
2376
+ row["eBay Search Alias"],
2377
+ row["Folder Path"] ?? "",
2378
+ row.Site ?? ""
2379
+ ]), line);
2380
+ }
2381
+ });
2382
+ });
2383
+ }
2384
+ /**
2385
+ * The third seam where a body the CLI did not type-check becomes a typed
2386
+ * request, alongside `asCreateBody` and `asUpdateChanges` above — narrow, named
2387
+ * and greppable rather than one general helper.
2388
+ *
2389
+ * It exists because spec 301 makes the server the only schema validator and
2390
+ * `--input` is by definition a document this build has not inspected.
2391
+ */
2392
+ function asImportRows(rows) {
2393
+ return rows;
2394
+ }
2395
+ function asDefinitions(value) {
2396
+ return Array.isArray(value) ? value : [];
2397
+ }
2398
+ function importBody(rows, document, options) {
2399
+ return {
2400
+ searches: asImportRows(rows),
2401
+ customFieldDefinitions: asDefinitions(document["customFieldDefinitions"]),
2402
+ preview: options.preview ?? false,
2403
+ replaceAll: options.replaceAll ?? false,
2404
+ targetFolderId: options.targetFolder ?? null
2405
+ };
2406
+ }
2407
+ function importCommand(deps) {
2408
+ return new Command("import").description("Restore saved searches from a backup document").argument("[file]", "Path to the export document, or \"-\" for stdin").option("--input <json|@file|->", "The document as JSON, instead of the positional").option("--preview", "Validate and report without writing anything").option("--replace-all", "DELETE every existing saved search first — destructive").option("--target-folder <id>", "File every imported search into this folder").option("--yes", "Acknowledge --replace-all without an interactive prompt").action(async function runImport(file) {
2409
+ const options = this.optsWithGlobals();
2410
+ const json = options.json ?? false;
2411
+ await runCommand({
2412
+ out: deps.createOutput(json),
2413
+ run: async () => {
2414
+ if (file !== void 0 && options.input !== void 0) throw new CliError(2, "Use the file argument or --input, not both. They are two ways to supply one body.");
2415
+ const raw = options.input ?? (file === void 0 ? void 0 : fileSource(file));
2416
+ if (raw === void 0) throw new CliError(2, "searches import needs a file argument, or --input carrying the export document.");
2417
+ const document = await readInputObject({
2418
+ raw,
2419
+ readStdin: deps.readStdin,
2420
+ expects: "It should carry the \"searches\" array an export document holds."
2421
+ });
2422
+ const rows = readRows(document, "searches", "saved searches");
2423
+ await assertReplaceAllAllowed({
2424
+ replaceAll: options.replaceAll ?? false,
2425
+ yes: options.yes ?? false,
2426
+ json,
2427
+ bodyFromStdin: raw === "-",
2428
+ chunkCount: chunkRows(rows, BULK_IMPORT_ROWS_MAX).length,
2429
+ documentSkippedCount: documentSkippedCount(document),
2430
+ entity: "saved searches",
2431
+ deps
2432
+ });
2433
+ const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
2434
+ return runChunkedImport({
2435
+ rows,
2436
+ sendChunk: (batch) => callAction(client, "import-saved-searches", importBody(batch, document, options))
2437
+ });
2438
+ },
2439
+ renderHuman: renderImportResult,
2440
+ exitCodeFor: exitCodeForImport
2441
+ });
2442
+ });
2443
+ }
2444
+ /** `-` stays stdin; anything else is a path, which `--input` spells `@path`. */
2445
+ function fileSource(file) {
2446
+ return file === "-" ? "-" : `@${file}`;
2447
+ }
2448
+ function buildSearchesCommand(deps = {}) {
2449
+ const resolved = resolveDeps(deps);
2450
+ return new Command("searches").description("Manage saved searches").addCommand(listCommand(resolved)).addCommand(createCommand(resolved)).addCommand(updateCommand(resolved)).addCommand(enabledCommand(resolved, "pause", false)).addCommand(enabledCommand(resolved, "resume", true)).addCommand(deleteCommand(resolved)).addCommand(exportCommand(resolved)).addCommand(importCommand(resolved));
2451
+ }
2452
+ //#endregion
2453
+ //#region src/program.ts
2454
+ /**
2455
+ * The program tree, and the one place commander's OWN parse failures are turned
2456
+ * into an exit code (spec 301 → CLI → Exit codes).
2457
+ *
2458
+ * Split out of `cli.ts` so it can be driven by a test: `cli.ts` is the bin
2459
+ * entry and runs on import, which is exactly what a test cannot do. `cli.ts`
2460
+ * keeps the Node floor check and nothing else.
2461
+ */
2462
+ function buildProgram() {
2463
+ const program = new Command();
2464
+ program.name("ubuyfirst").description("Command-line interface for the uBuyFirst public API").version(version).option("--json", "Emit machine-readable JSON output").option("--base-url <url>", "Override the API base URL");
2465
+ program.addCommand(buildSearchesCommand());
2466
+ program.addCommand(buildFoldersCommand());
2467
+ program.addCommand(buildBlocklistCommand());
2468
+ program.addCommand(buildNotificationsCommand());
2469
+ program.addCommand(buildFiltersCommand());
2470
+ program.addCommand(buildConfigCommand());
2471
+ return program;
2472
+ }
2473
+ /**
2474
+ * Makes commander THROW its parse failures instead of exiting, and routes the
2475
+ * text it writes about them to `sink`.
2476
+ *
2477
+ * Both settings are per-command and are NOT inherited by a command joined with
2478
+ * `addCommand` (verified against commander 15), so both are applied down the
2479
+ * whole tree — otherwise every subcommand, which is every command in the
2480
+ * surface, keeps exiting on its own.
2481
+ *
2482
+ * `writeErr` alone: `writeOut` stays commander's, so `--help` and `--version`
2483
+ * still print to stdout.
2484
+ */
2485
+ function captureParseFailures(command, sink) {
2486
+ command.exitOverride();
2487
+ command.configureOutput({ writeErr: sink });
2488
+ for (const child of command.commands) captureParseFailures(child, sink);
2489
+ }
2490
+ async function runProgram(argv, createOutput = processOutput) {
2491
+ const program = buildProgram();
2492
+ let buffered = "";
2493
+ captureParseFailures(program, (text) => {
2494
+ buffered += text;
2495
+ });
2496
+ try {
2497
+ await program.parseAsync([...argv], { from: "user" });
2498
+ return;
2499
+ } catch (thrown) {
2500
+ if (!(thrown instanceof CommanderError)) throw thrown;
2501
+ const out = createOutput(program.opts().json ?? false);
2502
+ if (thrown.exitCode === 0) {
2503
+ out.setExitCode(0);
2504
+ return;
2505
+ }
2506
+ const written = buffered === "" ? `error: ${thrown.message}\n` : buffered;
2507
+ if (out.json) {
2508
+ const trimmed = written.trimEnd();
2509
+ const message = trimmed.startsWith("error: ") ? trimmed.slice(7) : trimmed;
2510
+ await runCommand({
2511
+ out,
2512
+ run: () => Promise.reject(new CliError(2, message)),
2513
+ renderHuman: () => void 0
2514
+ });
2515
+ return;
2516
+ }
2517
+ out.stderr.write(written);
2518
+ out.setExitCode(2);
2519
+ }
2520
+ }
2521
+ //#endregion
2522
+ export { runProgram };