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.
- package/LICENSE +15 -0
- package/README.md +90 -0
- package/dist/cli.js +63 -0
- package/dist/program-YTm6g9Ut.js +2522 -0
- package/package.json +47 -0
|
@@ -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 };
|