ubuyfirst 0.1.0 → 0.2.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/README.md +22 -13
- package/dist/cli.js +1 -1
- package/dist/{program-YTm6g9Ut.js → program-BFXGjkBy.js} +697 -53
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ubuyfirst
|
|
2
2
|
|
|
3
3
|
Command-line interface for the [uBuyFirst public API](https://app.ubuyfirst.com/api/docs) —
|
|
4
|
-
manage your saved searches, folders, blocklists and notification channels from a
|
|
5
|
-
script, and back them up to a file.
|
|
4
|
+
manage your saved searches, item filters, folders, blocklists and notification channels from a
|
|
5
|
+
terminal or a script, and back them up to a file.
|
|
6
6
|
|
|
7
7
|
Requires Node.js 22.12 or newer.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ immediately invalidates the old one.
|
|
|
22
22
|
Store it — the CLI reads the key from stdin so it never lands in your shell history:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
ubuyfirst config set-key # paste the key, then
|
|
25
|
+
ubuyfirst config set-key # paste the key, then Enter and Ctrl-D (Ctrl-Z on Windows)
|
|
26
26
|
ubuyfirst config show # config path, whether a key is stored, effective base URL
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -32,23 +32,26 @@ precedence over the stored key. `ubuyfirst config clear` removes what is stored.
|
|
|
32
32
|
## Commands
|
|
33
33
|
|
|
34
34
|
```
|
|
35
|
-
searches
|
|
36
|
-
folders
|
|
37
|
-
blocklist
|
|
38
|
-
notifications
|
|
39
|
-
filters
|
|
40
|
-
|
|
35
|
+
searches list, create, update, pause, resume, delete, export, import
|
|
36
|
+
folders list, create, rename, delete, move
|
|
37
|
+
blocklist list, add, remove
|
|
38
|
+
notifications list, toggle, settings
|
|
39
|
+
filters list, get, create, update, delete, move, export, import
|
|
40
|
+
filter-folders list, create, rename, reorder, move, delete, attach, detach, attachments
|
|
41
|
+
config set-key, show, clear
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
Run `ubuyfirst <group> --help` for the flags of any group.
|
|
44
|
+
Run `ubuyfirst <group> --help` for the flags of any group. `blocklist` works on three separate
|
|
45
|
+
lists — sellers, countries and items — chosen with `--type`.
|
|
44
46
|
|
|
45
47
|
```bash
|
|
46
48
|
ubuyfirst searches list --limit 20
|
|
47
49
|
ubuyfirst searches create --name "Leica M6" --keywords "leica m6" --price-max 2500
|
|
48
50
|
ubuyfirst searches pause abc123
|
|
49
51
|
ubuyfirst blocklist add --type sellers badseller99 anotherseller
|
|
50
|
-
ubuyfirst searches export > backup.json
|
|
52
|
+
ubuyfirst --json searches export > backup.json
|
|
51
53
|
ubuyfirst searches import backup.json --preview
|
|
54
|
+
ubuyfirst --json filters export > filters.json
|
|
52
55
|
```
|
|
53
56
|
|
|
54
57
|
Bulk imports accept `--preview` to report what *would* change without writing anything.
|
|
@@ -56,12 +59,17 @@ Bulk imports accept `--preview` to report what *would* change without writing an
|
|
|
56
59
|
## Scripting
|
|
57
60
|
|
|
58
61
|
`--json` makes any command — including a failure — emit exactly one parseable JSON document
|
|
59
|
-
on stdout
|
|
62
|
+
on stdout. It works before or after the group name. `--help` and `--version` are the two
|
|
63
|
+
exceptions: they always print plain text.
|
|
60
64
|
|
|
61
65
|
```bash
|
|
62
|
-
ubuyfirst --json searches list | jq '.
|
|
66
|
+
ubuyfirst --json searches list | jq '.savedSearches[].name'
|
|
63
67
|
```
|
|
64
68
|
|
|
69
|
+
Without it commands print human-readable text — for the exports, a table — so a backup
|
|
70
|
+
written as `ubuyfirst searches export > backup.json` is not a document `searches import`
|
|
71
|
+
can read.
|
|
72
|
+
|
|
65
73
|
Exit codes are stable, so a script can branch on the failure without parsing text:
|
|
66
74
|
|
|
67
75
|
| Code | Meaning | Code | Meaning |
|
|
@@ -72,6 +80,7 @@ Exit codes are stable, so a script can branch on the failure without parsing tex
|
|
|
72
80
|
| 3 | validation error | 9 | write failed |
|
|
73
81
|
| 4 | missing or invalid API key | 10 | server error — retriable |
|
|
74
82
|
| 5 | access denied | 11 | partly applied; see the output |
|
|
83
|
+
| | | 12 | request body too large — send fewer rows |
|
|
75
84
|
|
|
76
85
|
Codes 8 and 10 are the retriable ones. Client-side refusals also carry a `cli.`-prefixed error
|
|
77
86
|
code in `--json` output (`cli.usage`, `cli.no_key`, `cli.unexpected`) so you can tell them from
|
package/dist/cli.js
CHANGED
|
@@ -56,7 +56,7 @@ if (!floorCheck.ok) {
|
|
|
56
56
|
process.stderr.write(`${floorCheck.message}\n`);
|
|
57
57
|
process.exitCode = 1;
|
|
58
58
|
} else {
|
|
59
|
-
const { runProgram } = await import("./program-
|
|
59
|
+
const { runProgram } = await import("./program-BFXGjkBy.js");
|
|
60
60
|
await runProgram(process.argv.slice(2));
|
|
61
61
|
}
|
|
62
62
|
//#endregion
|
|
@@ -7,7 +7,7 @@ import { styleText } from "node:util";
|
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import { text } from "node:stream/consumers";
|
|
9
9
|
//#region package.json
|
|
10
|
-
var version = "0.
|
|
10
|
+
var version = "0.2.0";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/errors.ts
|
|
13
13
|
/**
|
|
@@ -71,7 +71,8 @@ const EXIT_BY_ERROR_CODE = {
|
|
|
71
71
|
CAP_EXCEEDED: 7,
|
|
72
72
|
HTTP_429: 8,
|
|
73
73
|
WRITE_FAILED: 9,
|
|
74
|
-
HTTP_5XX: 10
|
|
74
|
+
HTTP_5XX: 10,
|
|
75
|
+
PAYLOAD_TOO_LARGE: 12
|
|
75
76
|
};
|
|
76
77
|
/**
|
|
77
78
|
* Codes for a failure the CLI decided ITSELF, for the `--json` document it emits
|
|
@@ -540,15 +541,16 @@ async function callAction(client, path, body) {
|
|
|
540
541
|
* settings` spreads it with `id` LAST so the positional argument always wins.
|
|
541
542
|
* Folding those into one shape would change what each command sends.
|
|
542
543
|
*/
|
|
544
|
+
const DEFAULT_FLAG = "--input";
|
|
543
545
|
/** `-` is stdin, `@path` is a file, anything else is the literal itself. */
|
|
544
|
-
async function readSource(raw, readStdin) {
|
|
546
|
+
async function readSource(raw, readStdin, flag) {
|
|
545
547
|
if (raw === "-") return readStdin();
|
|
546
548
|
if (!raw.startsWith("@")) return raw;
|
|
547
549
|
const path = raw.slice(1);
|
|
548
550
|
try {
|
|
549
551
|
return await readFile(path, "utf8");
|
|
550
552
|
} catch (thrown) {
|
|
551
|
-
throw new CliError(2, `Cannot read
|
|
553
|
+
throw new CliError(2, `Cannot read ${flag} file ${path}: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
552
554
|
}
|
|
553
555
|
}
|
|
554
556
|
/**
|
|
@@ -560,14 +562,15 @@ async function readSource(raw, readStdin) {
|
|
|
560
562
|
* the only one the CLI performs on an `--input` document.
|
|
561
563
|
*/
|
|
562
564
|
async function readInputObject(request) {
|
|
563
|
-
const
|
|
565
|
+
const flag = request.flag ?? DEFAULT_FLAG;
|
|
566
|
+
const text = await readSource(request.raw, request.readStdin, flag);
|
|
564
567
|
let parsed;
|
|
565
568
|
try {
|
|
566
569
|
parsed = JSON.parse(text);
|
|
567
570
|
} catch {
|
|
568
|
-
throw new CliError(2,
|
|
571
|
+
throw new CliError(2, `${flag} is not valid JSON.`);
|
|
569
572
|
}
|
|
570
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CliError(2,
|
|
573
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CliError(2, `${flag} must be a JSON object, not an array or a scalar.${request.expects === void 0 ? "" : ` ${request.expects}`}`);
|
|
571
574
|
return parsed;
|
|
572
575
|
}
|
|
573
576
|
//#endregion
|
|
@@ -795,7 +798,7 @@ async function runCommand(options) {
|
|
|
795
798
|
* - **The CLI owns per-type entry construction.** MCP needs an object root,
|
|
796
799
|
* so one `entries` array serves all three lists and the generated type
|
|
797
800
|
* leaves its ITEM shape open. The server stays the only validator of what
|
|
798
|
-
* goes in it.
|
|
801
|
+
* goes in it — including whether a country code is one it knows.
|
|
799
802
|
*/
|
|
800
803
|
/**
|
|
801
804
|
* TOTAL over the type union the generated document publishes: a fourth list
|
|
@@ -822,7 +825,7 @@ function parseType(raw) {
|
|
|
822
825
|
return raw;
|
|
823
826
|
}
|
|
824
827
|
/** The 1–200 range is the server's to enforce; this only refuses a non-number. */
|
|
825
|
-
function parseLimit$
|
|
828
|
+
function parseLimit$4(raw) {
|
|
826
829
|
if (raw === void 0) return void 0;
|
|
827
830
|
if (!/^\d+$/.test(raw)) throw new CliError(2, `--limit takes a whole number of entries, not "${raw}".`);
|
|
828
831
|
return Number(raw);
|
|
@@ -834,22 +837,12 @@ function addEntriesFromKeys(type, keys, reason) {
|
|
|
834
837
|
reason
|
|
835
838
|
}));
|
|
836
839
|
case "items": return keys.map((ebayItemId) => ({ ebayItemId }));
|
|
837
|
-
case "countries":
|
|
840
|
+
case "countries": return keys.map((countryCode) => ({ countryCode }));
|
|
838
841
|
}
|
|
839
842
|
}
|
|
840
|
-
/**
|
|
841
|
-
|
|
842
|
-
|
|
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];
|
|
843
|
+
/** BARE string keys for all three types: seller names, ISO country codes, listing ids. */
|
|
844
|
+
function removeKeysFromArguments(_type, keys) {
|
|
845
|
+
return [...keys];
|
|
853
846
|
}
|
|
854
847
|
/**
|
|
855
848
|
* What the object is expected to carry, for the shared parser's refusal. It is
|
|
@@ -901,15 +894,7 @@ function renderList(data, line) {
|
|
|
901
894
|
renderTable(["SELLER", "REASON"], data.entries.map((entry) => [entry.sellerName, entry.reason ?? ""]), line);
|
|
902
895
|
return;
|
|
903
896
|
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);
|
|
897
|
+
renderTable(["CODE", "NAME"], data.entries.map((entry) => [entry.countryCode, entry.countryName]), line);
|
|
913
898
|
return;
|
|
914
899
|
case "items":
|
|
915
900
|
renderTable([
|
|
@@ -940,7 +925,7 @@ async function readAllStdin$3() {
|
|
|
940
925
|
for await (const chunk of process.stdin) text += String(chunk);
|
|
941
926
|
return text;
|
|
942
927
|
}
|
|
943
|
-
function resolveDeps$
|
|
928
|
+
function resolveDeps$3(overrides) {
|
|
944
929
|
return {
|
|
945
930
|
resolveClient: overrides.resolveClient ?? ((baseUrlFlag) => resolveApiClient({ baseUrlFlag })),
|
|
946
931
|
createOutput: overrides.createOutput ?? processOutput,
|
|
@@ -956,7 +941,7 @@ function buildListCommand(deps) {
|
|
|
956
941
|
out: deps.createOutput(options.json ?? false),
|
|
957
942
|
run: async () => {
|
|
958
943
|
const type = parseType(options.type);
|
|
959
|
-
const limit = parseLimit$
|
|
944
|
+
const limit = parseLimit$4(options.limit);
|
|
960
945
|
const all = options.all ?? false;
|
|
961
946
|
assertPagingFlags({
|
|
962
947
|
all,
|
|
@@ -979,7 +964,7 @@ function buildListCommand(deps) {
|
|
|
979
964
|
});
|
|
980
965
|
}
|
|
981
966
|
function buildAddCommand(deps) {
|
|
982
|
-
return new Command("add").description("Block sellers, countries or items").argument("[keys...]", "seller names, or eBay listing ids
|
|
967
|
+
return new Command("add").description("Block sellers, countries or items").argument("[keys...]", "seller names, two-letter ISO country codes, or eBay listing ids").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
968
|
const options = command.optsWithGlobals();
|
|
984
969
|
await runCommand({
|
|
985
970
|
out: deps.createOutput(options.json ?? false),
|
|
@@ -1006,7 +991,7 @@ function buildAddCommand(deps) {
|
|
|
1006
991
|
});
|
|
1007
992
|
}
|
|
1008
993
|
function buildRemoveCommand(deps) {
|
|
1009
|
-
return new Command("remove").description("Unblock sellers, countries or items").argument("[keys...]", "the stored keys: seller names, country
|
|
994
|
+
return new Command("remove").description("Unblock sellers, countries or items").argument("[keys...]", "the stored keys: seller names, ISO country codes, or eBay listing ids").option("--type <type>", TYPE_FLAG_DESCRIPTION).option("--input <source>", INPUT_FLAG_DESCRIPTION).action(async (keys, _options, command) => {
|
|
1010
995
|
const options = command.optsWithGlobals();
|
|
1011
996
|
await runCommand({
|
|
1012
997
|
out: deps.createOutput(options.json ?? false),
|
|
@@ -1036,7 +1021,7 @@ function buildRemoveCommand(deps) {
|
|
|
1036
1021
|
* is reachable on the two write verbs by putting `"scope":"team"` in `--input`.
|
|
1037
1022
|
*/
|
|
1038
1023
|
function buildBlocklistCommand(overrides = {}) {
|
|
1039
|
-
const deps = resolveDeps$
|
|
1024
|
+
const deps = resolveDeps$3(overrides);
|
|
1040
1025
|
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
1026
|
}
|
|
1042
1027
|
//#endregion
|
|
@@ -1171,6 +1156,315 @@ function buildConfigCommand(deps = {}) {
|
|
|
1171
1156
|
return group;
|
|
1172
1157
|
}
|
|
1173
1158
|
//#endregion
|
|
1159
|
+
//#region src/commands/filter-folders.ts
|
|
1160
|
+
/**
|
|
1161
|
+
* The `filter-folders` command group (spec 302).
|
|
1162
|
+
*
|
|
1163
|
+
* `list`, `create`, `rename`, `move`, `reorder` and `delete` over the FILTER
|
|
1164
|
+
* folder tree, plus `attachments`, `attach` and `detach` over the links between
|
|
1165
|
+
* a folder and the saved searches that run it.
|
|
1166
|
+
*
|
|
1167
|
+
* THIS IS NOT THE `folders` GROUP. Two separate trees with two separate id
|
|
1168
|
+
* namespaces: an id from `folders list` is refused here exactly like a folder
|
|
1169
|
+
* belonging to someone else, and the reverse. Nothing carries an id between
|
|
1170
|
+
* them, and the flag names deliberately do not invite it.
|
|
1171
|
+
*
|
|
1172
|
+
* ONE VERB, ONE THING. `create` and `move` and `rename` each refuse the fields
|
|
1173
|
+
* that belong to another — `sortOrder` is `reorder`'s alone, `parentId` is
|
|
1174
|
+
* `move`'s — and every one of those bodies is closed server-side, so a flag for
|
|
1175
|
+
* a field its action rejects would answer VALIDATION on every run. That is why
|
|
1176
|
+
* `rename` and `delete` declare no options at all (D5, D7).
|
|
1177
|
+
*
|
|
1178
|
+
* FOLDERS ARE PERSONAL ON THIS SURFACE (D3). A team's shared folders are never
|
|
1179
|
+
* listed, and an account whose folders all belong to a team reads an EMPTY list
|
|
1180
|
+
* rather than a refusal — the server deliberately cannot distinguish that from
|
|
1181
|
+
* an account with no folders, so neither may this command.
|
|
1182
|
+
*
|
|
1183
|
+
* Argument mapping and rendering ONLY, like every other group — the transport,
|
|
1184
|
+
* the exit discipline and the page walk all live in the shared core.
|
|
1185
|
+
*
|
|
1186
|
+
* Global options are read with `optsWithGlobals()`. Commander's `.opts()`
|
|
1187
|
+
* returns LOCAL options only, so a subcommand reading `--json` through it gets
|
|
1188
|
+
* `undefined` and silently writes human text into a pipe with a zero exit code.
|
|
1189
|
+
* `__tests__/global-options.test.ts` refuses that accessor in this directory.
|
|
1190
|
+
*/
|
|
1191
|
+
function resolveDeps$2(deps) {
|
|
1192
|
+
return {
|
|
1193
|
+
resolveClient: deps.resolveClient ?? resolveApiClient,
|
|
1194
|
+
createOutput: deps.createOutput ?? processOutput
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const FOLDER_COLUMNS$1 = [
|
|
1198
|
+
"ID",
|
|
1199
|
+
"NAME",
|
|
1200
|
+
"PARENT",
|
|
1201
|
+
"SORT"
|
|
1202
|
+
];
|
|
1203
|
+
/** What a null parent renders as: the folder is at the root, not missing a value. */
|
|
1204
|
+
const NONE$2 = "-";
|
|
1205
|
+
function usage$2(message) {
|
|
1206
|
+
return new CliError(2, message);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* `sortOrder` is the folder's own position among its SIBLINGS in the folder
|
|
1210
|
+
* tree. On `attachments` it is still that number and NOT the folder's position
|
|
1211
|
+
* among a search's attachments — the attachment's own position is deliberately
|
|
1212
|
+
* not emitted, because every attach writes the same value.
|
|
1213
|
+
*/
|
|
1214
|
+
function folderRow$1(folder) {
|
|
1215
|
+
return [
|
|
1216
|
+
folder.id,
|
|
1217
|
+
folder.name,
|
|
1218
|
+
folder.parentId ?? NONE$2,
|
|
1219
|
+
String(folder.sortOrder)
|
|
1220
|
+
];
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Rejects a `--limit` that is not a whole number and stops there. The RANGE is
|
|
1224
|
+
* the server's (1–200): it is the only schema validator, and duplicating its
|
|
1225
|
+
* bounds here would go stale the day they move. The SHAPE is not the server's
|
|
1226
|
+
* problem to report — `abc` would travel as a string and come back as a
|
|
1227
|
+
* VALIDATION the caller cannot read as their own typo.
|
|
1228
|
+
*/
|
|
1229
|
+
function parseLimit$3(raw) {
|
|
1230
|
+
if (raw === void 0) return void 0;
|
|
1231
|
+
if (!/^\d+$/.test(raw)) throw usage$2(`--limit must be a whole number, not "${raw}".`);
|
|
1232
|
+
return Number(raw);
|
|
1233
|
+
}
|
|
1234
|
+
function listCommand$2(deps) {
|
|
1235
|
+
return new Command("list").description("List filter folders. A PARENT of - means the folder is at the root. Personal folders only — a team’s shared folders are never listed, and an account whose folders all belong to a team reads an empty list. They are still there.").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 function runList() {
|
|
1236
|
+
const options = this.optsWithGlobals();
|
|
1237
|
+
await runCommand({
|
|
1238
|
+
out: deps.createOutput(options.json ?? false),
|
|
1239
|
+
run: async () => {
|
|
1240
|
+
const all = options.all ?? false;
|
|
1241
|
+
assertPagingFlags({
|
|
1242
|
+
all,
|
|
1243
|
+
cursor: options.cursor
|
|
1244
|
+
});
|
|
1245
|
+
const limit = parseLimit$3(options.limit);
|
|
1246
|
+
const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
|
|
1247
|
+
if (all) return walkAllPages((cursor) => callAction(client, "list-filter-folders", {
|
|
1248
|
+
limit,
|
|
1249
|
+
cursor: cursor ?? void 0
|
|
1250
|
+
}), "folders");
|
|
1251
|
+
return callAction(client, "list-filter-folders", {
|
|
1252
|
+
limit,
|
|
1253
|
+
cursor: options.cursor
|
|
1254
|
+
});
|
|
1255
|
+
},
|
|
1256
|
+
renderHuman: (data, line) => renderTable(FOLDER_COLUMNS$1, data.folders.map(folderRow$1), line)
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
function createCommand$2(deps) {
|
|
1261
|
+
return new Command("create").description("Create a filter folder. It starts EMPTY — \"filters move\" files filters into it.").argument("<name>", "Folder name").option("--parent <id>", "Nest inside this folder id, from \"filter-folders list\" (default: root)").action(async function runCreate(name) {
|
|
1262
|
+
const options = this.optsWithGlobals();
|
|
1263
|
+
await runCommand({
|
|
1264
|
+
out: deps.createOutput(options.json ?? false),
|
|
1265
|
+
run: async () => {
|
|
1266
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "create-filter-folder", {
|
|
1267
|
+
name,
|
|
1268
|
+
parentId: options.parent ?? null
|
|
1269
|
+
});
|
|
1270
|
+
},
|
|
1271
|
+
renderHuman: (data, line) => renderTable(FOLDER_COLUMNS$1, [folderRow$1(data.folder)], line)
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* NO OPTIONS AT ALL, and that is the contract rather than an omission.
|
|
1277
|
+
* `parentId` and `sortOrder` are not fields of `update-filter-folder`'s body and
|
|
1278
|
+
* the body is closed, so a flag for either would answer VALIDATION on every run
|
|
1279
|
+
* — while looking like a rename that also moved the folder. Reparenting is
|
|
1280
|
+
* `move`; ordering is `reorder`.
|
|
1281
|
+
*/
|
|
1282
|
+
function renameCommand(deps) {
|
|
1283
|
+
return new Command("rename").description("Rename a filter folder. Rename ONLY — nothing inside it changes, and re-parenting is \"move\".").argument("<id>", "Folder id, from \"filter-folders list\"").argument("<name>", "The new name").action(async function runRename(id, name) {
|
|
1284
|
+
const options = this.optsWithGlobals();
|
|
1285
|
+
await runCommand({
|
|
1286
|
+
out: deps.createOutput(options.json ?? false),
|
|
1287
|
+
run: async () => {
|
|
1288
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "update-filter-folder", {
|
|
1289
|
+
id,
|
|
1290
|
+
name
|
|
1291
|
+
});
|
|
1292
|
+
},
|
|
1293
|
+
renderHuman: (data, line) => renderTable(["ID", "NAME"], [[data.id, data.name]], line)
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* `--parent <id>` XOR `--root`, exactly one, decided locally before any HTTP
|
|
1299
|
+
* call. `parentId` is nullable rather than optional precisely so that "move it
|
|
1300
|
+
* to the top level" is something the caller SAID — an omitted key would be a
|
|
1301
|
+
* silent move to the root.
|
|
1302
|
+
*
|
|
1303
|
+
* NO `--index`. The body is closed and carries none, so the folder KEEPS the
|
|
1304
|
+
* position number it already had and where it lands among its new siblings is
|
|
1305
|
+
* not chosen here. Follow the move with `reorder` when the position matters.
|
|
1306
|
+
*/
|
|
1307
|
+
function moveCommand$1(deps) {
|
|
1308
|
+
return new Command("move").description("Move a filter folder under a different parent, or to the root. The whole subtree travels with it and nothing is deleted.").argument("<id>", "Folder id, from \"filter-folders list\"").option("--parent <id>", "New parent folder id, from \"filter-folders list\"").option("--root", "Move it to the top level — sends an explicit null").action(async function runMove(id) {
|
|
1309
|
+
const options = this.optsWithGlobals();
|
|
1310
|
+
await runCommand({
|
|
1311
|
+
out: deps.createOutput(options.json ?? false),
|
|
1312
|
+
run: async () => {
|
|
1313
|
+
const root = options.root ?? false;
|
|
1314
|
+
if (options.parent !== void 0 && root) throw usage$2("Use --parent <id> or --root, not both. A folder sits under one parent, or at the top level.");
|
|
1315
|
+
if (options.parent === void 0 && !root) throw usage$2("A move needs --parent <id> or --root. There is no default destination, so the CLI will not choose one for you.");
|
|
1316
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "move-filter-folder", {
|
|
1317
|
+
id,
|
|
1318
|
+
parentId: options.parent ?? null
|
|
1319
|
+
});
|
|
1320
|
+
},
|
|
1321
|
+
renderHuman: (data, line) => renderTable(["ID", "PARENT"], [[data.id, data.parentId ?? NONE$2]], line)
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* The only command that writes folder order — `create`, `rename` and `move` all
|
|
1327
|
+
* refuse `sortOrder` outright (D5).
|
|
1328
|
+
*
|
|
1329
|
+
* ITS TWO REFUSALS ARE THE SERVER'S AND REACH THE USER VERBATIM. A duplicated
|
|
1330
|
+
* id and a list that is not the parent's complete sibling set need DIFFERENT
|
|
1331
|
+
* fixes from the caller, and the service names its own rule in each message, so
|
|
1332
|
+
* nothing here collapses them into one wording. `runCommand` prints that
|
|
1333
|
+
* message on stderr and emits the envelope unchanged under `--json`.
|
|
1334
|
+
*/
|
|
1335
|
+
function reorderCommand(deps) {
|
|
1336
|
+
return new Command("reorder").description("Set the order of one parent’s filter folders. Pass the COMPLETE sibling set in its new order — each folder’s position is its place in the list, not a delta. A list that repeats an id, or that is missing a sibling, is refused and NOTHING is written.").argument("<folderId...>", "Every folder id under the parent, in the order they should take").option("--parent <id>", "The parent whose children these are (default: the root set)").action(async function runReorder(folderIds) {
|
|
1337
|
+
const options = this.optsWithGlobals();
|
|
1338
|
+
await runCommand({
|
|
1339
|
+
out: deps.createOutput(options.json ?? false),
|
|
1340
|
+
run: async () => {
|
|
1341
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "reorder-filter-folders", {
|
|
1342
|
+
parentId: options.parent ?? null,
|
|
1343
|
+
folderIds
|
|
1344
|
+
});
|
|
1345
|
+
},
|
|
1346
|
+
renderHuman: (data, line) => renderTable([
|
|
1347
|
+
"PARENT",
|
|
1348
|
+
"POSITION",
|
|
1349
|
+
"ID"
|
|
1350
|
+
], data.folderIds.map((id, index) => [
|
|
1351
|
+
data.parentId ?? NONE$2,
|
|
1352
|
+
String(index),
|
|
1353
|
+
id
|
|
1354
|
+
]), line)
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* NO `--delete-contents`, and no `--input` either.
|
|
1360
|
+
*
|
|
1361
|
+
* `deleteContents` is a real service parameter that destroys every child folder
|
|
1362
|
+
* and every filter inside them, and it is NOT a field of this action's body.
|
|
1363
|
+
* The body is closed, so sending it is a VALIDATION error — a CLI flag for it
|
|
1364
|
+
* would therefore fail on every single run while promising a cascade the API
|
|
1365
|
+
* does not offer (D7). Deleting the filters too means calling
|
|
1366
|
+
* `filters delete` for each one first.
|
|
1367
|
+
*
|
|
1368
|
+
* The id is required and there is no body a flag could carry beyond it.
|
|
1369
|
+
*/
|
|
1370
|
+
function deleteCommand$2(deps) {
|
|
1371
|
+
return new Command("delete").description("Delete a filter folder. Nothing inside is deleted: its item filters become unfiled and keep matching, and any folder nested inside moves up to this folder’s own parent. Your LAST remaining folder cannot be deleted — that one is refused.").argument("<id>", "Folder id, from \"filter-folders list\"").action(async function runDelete(id) {
|
|
1372
|
+
const options = this.optsWithGlobals();
|
|
1373
|
+
await runCommand({
|
|
1374
|
+
out: deps.createOutput(options.json ?? false),
|
|
1375
|
+
run: async () => {
|
|
1376
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "delete-filter-folder", { id });
|
|
1377
|
+
},
|
|
1378
|
+
renderHuman: (data, line) => renderTable(["ID"], [[data.id]], line)
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
function attachmentsCommand(deps) {
|
|
1383
|
+
return new Command("attachments").description("List the filter folders attached to ONE saved search. An empty list means no PERSONAL folders are attached; a team’s are never shown.").argument("<searchId>", "Saved-search id, from \"searches list\"").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 function runAttachments(searchId) {
|
|
1384
|
+
const options = this.optsWithGlobals();
|
|
1385
|
+
await runCommand({
|
|
1386
|
+
out: deps.createOutput(options.json ?? false),
|
|
1387
|
+
run: async () => {
|
|
1388
|
+
const all = options.all ?? false;
|
|
1389
|
+
assertPagingFlags({
|
|
1390
|
+
all,
|
|
1391
|
+
cursor: options.cursor
|
|
1392
|
+
});
|
|
1393
|
+
const limit = parseLimit$3(options.limit);
|
|
1394
|
+
const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
|
|
1395
|
+
if (all) return walkAllPages((cursor) => callAction(client, "list-search-filter-folders", {
|
|
1396
|
+
searchId,
|
|
1397
|
+
limit,
|
|
1398
|
+
cursor: cursor ?? void 0
|
|
1399
|
+
}), "folders");
|
|
1400
|
+
return callAction(client, "list-search-filter-folders", {
|
|
1401
|
+
searchId,
|
|
1402
|
+
limit,
|
|
1403
|
+
cursor: options.cursor
|
|
1404
|
+
});
|
|
1405
|
+
},
|
|
1406
|
+
renderHuman: (data, line) => renderTable(FOLDER_COLUMNS$1, data.folders.map(folderRow$1), line)
|
|
1407
|
+
});
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* BOTH COUNTS ARE RENDERED, because a 200 here does not mean the batch landed.
|
|
1412
|
+
* The folder is the subject of the call and an unreachable one refuses it
|
|
1413
|
+
* outright; a SEARCH is one of many, so an unreachable one is silently skipped
|
|
1414
|
+
* and the call still succeeds — otherwise the batch would answer "does this
|
|
1415
|
+
* search id exist and is it yours?" one id at a time (G4). A body whose search
|
|
1416
|
+
* ids are all unreachable therefore answers `attached: 0`, and only the counts
|
|
1417
|
+
* say so.
|
|
1418
|
+
*/
|
|
1419
|
+
function attachCommand(deps) {
|
|
1420
|
+
return new Command("attach").description("Attach a filter folder to saved searches, so they run the item filters in it. Read the counts: an unreachable search is SKIPPED, not refused, so the call can succeed having attached nothing.").argument("<folderId>", "Filter folder id, from \"filter-folders list\"").argument("<searchId...>", "One or more saved-search ids, from \"searches list\"").action(async function runAttach(folderId, searchIds) {
|
|
1421
|
+
const options = this.optsWithGlobals();
|
|
1422
|
+
await runCommand({
|
|
1423
|
+
out: deps.createOutput(options.json ?? false),
|
|
1424
|
+
run: async () => {
|
|
1425
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "attach-filter-folder-to-searches", {
|
|
1426
|
+
folderId,
|
|
1427
|
+
searchIds
|
|
1428
|
+
});
|
|
1429
|
+
},
|
|
1430
|
+
renderHuman: (data, line) => renderTable([
|
|
1431
|
+
"FOLDER",
|
|
1432
|
+
"ATTACHED",
|
|
1433
|
+
"SKIPPED"
|
|
1434
|
+
], [[
|
|
1435
|
+
data.folderId,
|
|
1436
|
+
String(data.attached),
|
|
1437
|
+
String(data.skipped)
|
|
1438
|
+
]], line)
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* `detached` IS THE COUNT ACTUALLY REMOVED, never the size of the list sent. A
|
|
1444
|
+
* pair that was not attached removes nothing and an unreachable id removes
|
|
1445
|
+
* nothing, and the number does not separate the two — so `detached: 0` means
|
|
1446
|
+
* nothing changed, not that the folder was never attached.
|
|
1447
|
+
*/
|
|
1448
|
+
function detachCommand(deps) {
|
|
1449
|
+
return new Command("detach").description("Detach a filter folder from saved searches, so they stop running the item filters in it. Nothing is deleted, and DETACHED counts what was actually removed.").argument("<folderId>", "Filter folder id, from \"filter-folders list\"").argument("<searchId...>", "One or more saved-search ids, from \"searches list\"").action(async function runDetach(folderId, searchIds) {
|
|
1450
|
+
const options = this.optsWithGlobals();
|
|
1451
|
+
await runCommand({
|
|
1452
|
+
out: deps.createOutput(options.json ?? false),
|
|
1453
|
+
run: async () => {
|
|
1454
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "detach-filter-folder-from-searches", {
|
|
1455
|
+
folderId,
|
|
1456
|
+
searchIds
|
|
1457
|
+
});
|
|
1458
|
+
},
|
|
1459
|
+
renderHuman: (data, line) => renderTable(["FOLDER", "DETACHED"], [[data.folderId, String(data.detached)]], line)
|
|
1460
|
+
});
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
function buildFilterFoldersCommand(deps = {}) {
|
|
1464
|
+
const resolved = resolveDeps$2(deps);
|
|
1465
|
+
return new Command("filter-folders").description("Manage the filter-folder tree and its attachments to saved searches").addCommand(attachCommand(resolved)).addCommand(attachmentsCommand(resolved)).addCommand(createCommand$2(resolved)).addCommand(deleteCommand$2(resolved)).addCommand(detachCommand(resolved)).addCommand(listCommand$2(resolved)).addCommand(moveCommand$1(resolved)).addCommand(renameCommand(resolved)).addCommand(reorderCommand(resolved));
|
|
1466
|
+
}
|
|
1467
|
+
//#endregion
|
|
1174
1468
|
//#region src/bulk.ts
|
|
1175
1469
|
/**
|
|
1176
1470
|
* Bulk export/import mechanism, shared by `searches` and `filters`
|
|
@@ -1430,6 +1724,8 @@ function foldResults(results, frames) {
|
|
|
1430
1724
|
toCreate: previews.reduce((sum, result) => sum + result.toCreate, 0),
|
|
1431
1725
|
toUpdate: previews.reduce((sum, result) => sum + result.toUpdate, 0),
|
|
1432
1726
|
existingCount: first.existingCount,
|
|
1727
|
+
replaceAll: first.replaceAll,
|
|
1728
|
+
toDelete: first.toDelete,
|
|
1433
1729
|
...diagnostics
|
|
1434
1730
|
};
|
|
1435
1731
|
}
|
|
@@ -1528,6 +1824,7 @@ function renderImportResult(result, line) {
|
|
|
1528
1824
|
}
|
|
1529
1825
|
if (result.mode === "preview") {
|
|
1530
1826
|
line(`Preview: ${String(result.toCreate)} to create, ${String(result.toUpdate)} to update, ${String(result.existingCount)} already in the account. Nothing was written.`);
|
|
1827
|
+
if (result.replaceAll) line(`REPLACE-ALL: ${String(result.toDelete)} existing row(s) would be DELETED first.`);
|
|
1531
1828
|
if (!result.valid) line("This file cannot be imported as it stands.");
|
|
1532
1829
|
return;
|
|
1533
1830
|
}
|
|
@@ -1564,20 +1861,30 @@ function exitCodeForExport(skipped) {
|
|
|
1564
1861
|
//#endregion
|
|
1565
1862
|
//#region src/commands/filters.ts
|
|
1566
1863
|
/**
|
|
1567
|
-
* The `filters` command group
|
|
1864
|
+
* The `filters` command group.
|
|
1865
|
+
*
|
|
1866
|
+
* `list`, `get`, `create`, `update`, `delete` and `move` over the item-filter
|
|
1867
|
+
* actions, plus `export` and `import` over the two bulk verbs. One subcommand
|
|
1868
|
+
* per endpoint the API serves, which is the rule that decides what may be added
|
|
1869
|
+
* here: a command ahead of its endpoint would be a command with nothing behind
|
|
1870
|
+
* it. The filter-FOLDER actions are a separate tree with a separate id
|
|
1871
|
+
* namespace and live in `filter-folders.ts`.
|
|
1872
|
+
*
|
|
1873
|
+
* WHY THE GROUP EXISTED BEFORE THE CRUD, since spec 301's command surface says
|
|
1874
|
+
* the two bulk filter verbs ship with no wave-1 CLI command: a saved-search row
|
|
1875
|
+
* references its filter folders by PATH, so restoring an account imports item
|
|
1876
|
+
* filters FIRST — and a CLI that could export and import searches but not
|
|
1877
|
+
* filters cannot perform the restore its own export document implies.
|
|
1568
1878
|
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1879
|
+
* FILING A FILTER IS ONE CALL, NEVER TWO. `create` and `update` both refuse
|
|
1880
|
+
* `folderId` and `sortOrder` server-side, so `move` is the only command that
|
|
1881
|
+
* files a filter and it sets parent and position together (D5). There is
|
|
1882
|
+
* deliberately no `reorder` command for filters.
|
|
1573
1883
|
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
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.
|
|
1884
|
+
* FILTERS ARE PERSONAL ON THIS SURFACE (spec 302 D3). A team's shared filters are
|
|
1885
|
+
* never listed, and an account whose filters all belong to a team reads an EMPTY
|
|
1886
|
+
* list rather than a refusal — the server deliberately cannot distinguish that
|
|
1887
|
+
* from an account with no filters, so neither may this command.
|
|
1581
1888
|
*
|
|
1582
1889
|
* Argument mapping and rendering ONLY, like every other group — the transport,
|
|
1583
1890
|
* the exit discipline, the page walk and the bulk mechanism all live in the
|
|
@@ -1598,6 +1905,218 @@ function resolveDeps$1(deps) {
|
|
|
1598
1905
|
confirm: deps.confirm ?? confirmOnTerminal
|
|
1599
1906
|
};
|
|
1600
1907
|
}
|
|
1908
|
+
const FILTER_COLUMNS = [
|
|
1909
|
+
"ID",
|
|
1910
|
+
"NAME",
|
|
1911
|
+
"FOLDER",
|
|
1912
|
+
"ACTION",
|
|
1913
|
+
"ENABLED"
|
|
1914
|
+
];
|
|
1915
|
+
/** What a null folder renders as: the filter is unfiled, not missing a value. */
|
|
1916
|
+
const NONE$1 = "-";
|
|
1917
|
+
function filterRow(filter) {
|
|
1918
|
+
return [
|
|
1919
|
+
filter.id,
|
|
1920
|
+
filter.name,
|
|
1921
|
+
filter.folderId ?? NONE$1,
|
|
1922
|
+
filter.action,
|
|
1923
|
+
filter.isEnabled ? "yes" : "no"
|
|
1924
|
+
];
|
|
1925
|
+
}
|
|
1926
|
+
function usage$1(message) {
|
|
1927
|
+
return new CliError(2, message);
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* The three closed vocabularies the write flags accept, DERIVED from the
|
|
1931
|
+
* generated document rather than copied from the server's zod enums.
|
|
1932
|
+
*
|
|
1933
|
+
* `satisfies` is what binds them: a value the server DROPS stops this
|
|
1934
|
+
* compiling, so a flag cannot outlive the contract. The reverse — a value the
|
|
1935
|
+
* server ADDS — is not caught here and would make the flag refuse something the
|
|
1936
|
+
* API accepts; the refusal below names every accepted value, so a caller learns
|
|
1937
|
+
* it in one run rather than from a `VALIDATION` round trip.
|
|
1938
|
+
*
|
|
1939
|
+
* They are checked at all only because the mistake is visible LOCALLY. The
|
|
1940
|
+
* server stays the only schema validator for everything it alone can know —
|
|
1941
|
+
* `--tag-color`'s pattern and `--description`'s length are deliberately not
|
|
1942
|
+
* checked here.
|
|
1943
|
+
*/
|
|
1944
|
+
const FILTER_ACTIONS = [
|
|
1945
|
+
"add_tag",
|
|
1946
|
+
"hide",
|
|
1947
|
+
"block_countries",
|
|
1948
|
+
"allow_countries",
|
|
1949
|
+
"notify_include",
|
|
1950
|
+
"notify_exclude"
|
|
1951
|
+
];
|
|
1952
|
+
const FILTER_SCOPES = ["GLOBAL", "SEARCHES"];
|
|
1953
|
+
const FILTER_TYPES = ["general", "country"];
|
|
1954
|
+
/**
|
|
1955
|
+
* Narrows a flag's raw string to one of a closed set, WITHOUT an assertion:
|
|
1956
|
+
* `.find` over a `readonly T[]` answers `T | undefined`, so the return type is
|
|
1957
|
+
* established by the search rather than claimed over it.
|
|
1958
|
+
*/
|
|
1959
|
+
function closedValue(values, raw, flag) {
|
|
1960
|
+
const found = values.find((value) => value === raw);
|
|
1961
|
+
if (found === void 0) throw usage$1(`${flag} must be one of: ${values.join(", ")}. Got "${raw}".`);
|
|
1962
|
+
return found;
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* The rule tree, read through the SHARED `--input` reader — a literal, `@file`
|
|
1966
|
+
* or `-` for stdin — because a rule tree is not something anyone types on a
|
|
1967
|
+
* command line. `filters import` reads its document the same way; this is that
|
|
1968
|
+
* one mechanism pointed at one field, not a second input path.
|
|
1969
|
+
*
|
|
1970
|
+
* It carries ONE field, which is why it is `--rules` and not `--input`. A
|
|
1971
|
+
* whole-body flag here could set `name` or `action` behind the command line's
|
|
1972
|
+
* back; this one cannot say anything the command does not already name.
|
|
1973
|
+
*/
|
|
1974
|
+
function readRuleTree(raw, readStdin) {
|
|
1975
|
+
return readInputObject({
|
|
1976
|
+
raw,
|
|
1977
|
+
readStdin,
|
|
1978
|
+
flag: "--rules",
|
|
1979
|
+
expects: "It should be the rule GROUP — take one from a \"filters get\" or \"filters export\" answer."
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
/** The optional write fields, mapped once for both `create` and `update`. */
|
|
1983
|
+
function optionalWriteFields(options) {
|
|
1984
|
+
return {
|
|
1985
|
+
description: options.description,
|
|
1986
|
+
tagColor: options.tagColor,
|
|
1987
|
+
scope: options.scope === void 0 ? void 0 : closedValue(FILTER_SCOPES, options.scope, "--scope"),
|
|
1988
|
+
filterType: options.filterType === void 0 ? void 0 : closedValue(FILTER_TYPES, options.filterType, "--filter-type")
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
async function createBody$1(name, options, readStdin) {
|
|
1992
|
+
if (options.action === void 0) throw usage$1(`--action is required, one of: ${FILTER_ACTIONS.join(", ")}.`);
|
|
1993
|
+
if (options.rules === void 0) throw usage$1("--rules is required and carries the rule group: a JSON literal, @file, or - for stdin.");
|
|
1994
|
+
return {
|
|
1995
|
+
name,
|
|
1996
|
+
action: closedValue(FILTER_ACTIONS, options.action, "--action"),
|
|
1997
|
+
rules: await readRuleTree(options.rules, readStdin),
|
|
1998
|
+
...optionalWriteFields(options)
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* `--enable` XOR `--disable`, and NEITHER is legal — unlike `notifications
|
|
2003
|
+
* toggle`, where the field is required. `isEnabled` is one optional field of a
|
|
2004
|
+
* partial update, so "not mentioned" is a real answer meaning leave it alone.
|
|
2005
|
+
*/
|
|
2006
|
+
function enabledSwitch(options) {
|
|
2007
|
+
const enable = options.enable ?? false;
|
|
2008
|
+
const disable = options.disable ?? false;
|
|
2009
|
+
if (enable && disable) throw usage$1("Use --enable or --disable, not both. \"isEnabled\" is a SET, not a flip, so the CLI will not choose one for you.");
|
|
2010
|
+
if (!enable && !disable) return void 0;
|
|
2011
|
+
return enable;
|
|
2012
|
+
}
|
|
2013
|
+
async function updateBody(filterId, options, readStdin) {
|
|
2014
|
+
const changes = {
|
|
2015
|
+
name: options.name,
|
|
2016
|
+
action: options.action === void 0 ? void 0 : closedValue(FILTER_ACTIONS, options.action, "--action"),
|
|
2017
|
+
rules: options.rules === void 0 ? void 0 : await readRuleTree(options.rules, readStdin),
|
|
2018
|
+
isEnabled: enabledSwitch(options),
|
|
2019
|
+
...optionalWriteFields(options)
|
|
2020
|
+
};
|
|
2021
|
+
if (Object.values(changes).every((value) => value === void 0)) throw usage$1("Name at least one field to change: --name, --action, --rules, --description, --tag-color, --enable, --disable, --scope or --filter-type.");
|
|
2022
|
+
return {
|
|
2023
|
+
filterId,
|
|
2024
|
+
...changes
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* The confirmation on the one command that destroys something no other endpoint
|
|
2029
|
+
* can rebuild: the rule tree goes with the row, and there is no undelete.
|
|
2030
|
+
*
|
|
2031
|
+
* Deliberately NOT `folders delete`'s shape, which asks nothing — deleting a
|
|
2032
|
+
* SEARCH folder keeps its searches and its children. It is also not
|
|
2033
|
+
* `assertReplaceAllAllowed`, whose row-cap and short-document refusals describe
|
|
2034
|
+
* a bulk document this command does not have. What is shared is the ORDER:
|
|
2035
|
+
* `--yes` acknowledges; `--json` means a machine is reading, so prompting would
|
|
2036
|
+
* corrupt the one document that mode promises; a non-terminal has nobody to ask.
|
|
2037
|
+
* Declining is the same outcome as never acknowledging — the CLI was asked to do
|
|
2038
|
+
* something it did not do.
|
|
2039
|
+
*
|
|
2040
|
+
* No `bodyFromStdin` case: this command has no `--input`, so nothing has spent
|
|
2041
|
+
* the stream a prompt would read the answer from.
|
|
2042
|
+
*/
|
|
2043
|
+
async function assertDeleteAllowed(request) {
|
|
2044
|
+
if (request.yes) return;
|
|
2045
|
+
const cost = `Deleting filter ${request.filterId} is PERMANENT: its rule tree goes with it, and no endpoint can rebuild it.`;
|
|
2046
|
+
if (request.json || !request.deps.isInteractive()) throw usage$1(`${cost} Re-run with --yes to confirm it.`);
|
|
2047
|
+
if (!await request.deps.confirm(cost)) throw usage$1("Aborted. Nothing was deleted.");
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* `--index` is OPTIONAL and an absent one is NOT SENT: the action's documented
|
|
2051
|
+
* default for a missing index is "last", so the CLI leaves the choice to the
|
|
2052
|
+
* server rather than restating its rule as a number. The server CLAMPS a value
|
|
2053
|
+
* past the end; a negative one it refuses, and the shape check below refuses
|
|
2054
|
+
* it here first.
|
|
2055
|
+
*/
|
|
2056
|
+
function parseIndex(raw) {
|
|
2057
|
+
if (raw === void 0) return void 0;
|
|
2058
|
+
if (!/^\d+$/.test(raw)) throw usage$1(`--index must be a whole number of 0 or more, not "${raw}".`);
|
|
2059
|
+
return Number(raw);
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* `--folder <id>` XOR `--unfile`, exactly one, decided locally before any HTTP
|
|
2063
|
+
* call — the same split `folders move` makes, and for the same reason:
|
|
2064
|
+
* `targetFolderId` is nullable with NO default, so "neither flag" is a guess
|
|
2065
|
+
* about which the caller meant. `--unfile` sends an explicit `null`.
|
|
2066
|
+
*/
|
|
2067
|
+
function moveBody$1(filterId, options) {
|
|
2068
|
+
const unfile = options.unfile ?? false;
|
|
2069
|
+
if (options.folder !== void 0 && unfile) throw usage$1("Use --folder <id> or --unfile, not both. A filter sits in one folder, or in none.");
|
|
2070
|
+
if (options.folder === void 0 && !unfile) throw usage$1("A move needs --folder <id> or --unfile. There is no default target folder, so the CLI will not choose one for you.");
|
|
2071
|
+
return {
|
|
2072
|
+
filterId,
|
|
2073
|
+
targetFolderId: options.folder ?? null,
|
|
2074
|
+
index: parseIndex(options.index)
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Rejects a `--limit` that is not a whole number and stops there — the same
|
|
2079
|
+
* split `folders list` makes. The RANGE is the server's (1–200): it is the only
|
|
2080
|
+
* schema validator, and a copy of its bounds here would go stale the day they
|
|
2081
|
+
* move. The SHAPE is not the server's problem to report: `abc` would travel as a
|
|
2082
|
+
* string and come back as a VALIDATION the caller cannot read as their own typo.
|
|
2083
|
+
*/
|
|
2084
|
+
function parseLimit$2(raw) {
|
|
2085
|
+
if (raw === void 0) return void 0;
|
|
2086
|
+
if (!/^\d+$/.test(raw)) throw new CliError(2, `--limit must be a whole number, not "${raw}".`);
|
|
2087
|
+
return Number(raw);
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* NO RULE TREE IS LISTED HERE, and that is the endpoint's contract rather than a
|
|
2091
|
+
* rendering choice: a summary row carries none of the conditions a filter
|
|
2092
|
+
* matches on. `filters export` is where the full definition lives.
|
|
2093
|
+
*/
|
|
2094
|
+
function listCommand$1(deps) {
|
|
2095
|
+
return new Command("list").description("List item filters. A FOLDER of - means the filter is unfiled. Personal filters only — a team’s shared filters are never listed, and an account whose filters all belong to a team reads an empty list. They are still there.").option("--limit <n>", "Filters 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 function runList() {
|
|
2096
|
+
const options = this.optsWithGlobals();
|
|
2097
|
+
await runCommand({
|
|
2098
|
+
out: deps.createOutput(options.json ?? false),
|
|
2099
|
+
run: async () => {
|
|
2100
|
+
const all = options.all ?? false;
|
|
2101
|
+
assertPagingFlags({
|
|
2102
|
+
all,
|
|
2103
|
+
cursor: options.cursor
|
|
2104
|
+
});
|
|
2105
|
+
const limit = parseLimit$2(options.limit);
|
|
2106
|
+
const client = await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null });
|
|
2107
|
+
if (all) return walkAllPages((cursor) => callAction(client, "list-item-filters", {
|
|
2108
|
+
limit,
|
|
2109
|
+
cursor: cursor ?? void 0
|
|
2110
|
+
}), "filters");
|
|
2111
|
+
return callAction(client, "list-item-filters", {
|
|
2112
|
+
limit,
|
|
2113
|
+
cursor: options.cursor
|
|
2114
|
+
});
|
|
2115
|
+
},
|
|
2116
|
+
renderHuman: (data, line) => renderTable(FILTER_COLUMNS, data.filters.map(filterRow), line)
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
1601
2120
|
/**
|
|
1602
2121
|
* `export` IMPLIES `--all`, on the same reasoning as `searches export`: a
|
|
1603
2122
|
* partial export is a corrupt backup, so neither `--limit` nor `--cursor` is
|
|
@@ -1715,9 +2234,113 @@ function importCommand$1(deps) {
|
|
|
1715
2234
|
});
|
|
1716
2235
|
});
|
|
1717
2236
|
}
|
|
2237
|
+
const RULES_DESCRIPTION = "The rule GROUP as JSON: a literal, @file, or - for stdin. Take one from \"filters get\".";
|
|
2238
|
+
/**
|
|
2239
|
+
* The only command that carries a rule tree back to the caller.
|
|
2240
|
+
*
|
|
2241
|
+
* The tree is rendered as JSON below the summary row rather than squeezed into
|
|
2242
|
+
* a column: it is a recursive structure with no tabular form, and abbreviating
|
|
2243
|
+
* it would describe what a filter matches from an answer that no longer says
|
|
2244
|
+
* so. `filters list` carries no tree at all, which is why this command exists.
|
|
2245
|
+
*/
|
|
2246
|
+
function getCommand(deps) {
|
|
2247
|
+
return new Command("get").description("Read ONE item filter, rule tree included").argument("<id>", "Filter id, from \"filters list\"").action(async function runGet(id) {
|
|
2248
|
+
const options = this.optsWithGlobals();
|
|
2249
|
+
await runCommand({
|
|
2250
|
+
out: deps.createOutput(options.json ?? false),
|
|
2251
|
+
run: async () => {
|
|
2252
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "get-item-filter", { filterId: id });
|
|
2253
|
+
},
|
|
2254
|
+
renderHuman: (data, line) => {
|
|
2255
|
+
const filter = data.filter;
|
|
2256
|
+
renderTable([
|
|
2257
|
+
...FILTER_COLUMNS,
|
|
2258
|
+
"SCOPE",
|
|
2259
|
+
"TYPE"
|
|
2260
|
+
], [[
|
|
2261
|
+
...filterRow(filter),
|
|
2262
|
+
filter.scope,
|
|
2263
|
+
filter.filterType
|
|
2264
|
+
]], line);
|
|
2265
|
+
if (filter.description !== null) line(`description: ${filter.description}`);
|
|
2266
|
+
if (filter.tagColor !== null) line(`tagColor: ${filter.tagColor}`);
|
|
2267
|
+
line(JSON.stringify(filter.rules ?? null, null, 2) ?? "null");
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
function createCommand$1(deps) {
|
|
2273
|
+
return new Command("create").description("Create an item filter. It is appended UNFILED — \"filters move\" files it.").argument("<name>", "Filter name").option("--action <action>", `What the filter does: ${FILTER_ACTIONS.join(", ")}`).option("--rules <json|@file|->", RULES_DESCRIPTION).option("--description <text>", "Free-text note on the filter").option("--tag-color <color>", "Tag colour, for the add_tag action").option("--scope <scope>", `Where it applies: ${FILTER_SCOPES.join(", ")} (default SEARCHES)`).option("--filter-type <type>", `Filter kind: ${FILTER_TYPES.join(", ")} (default general)`).action(async function runCreate(name) {
|
|
2274
|
+
const options = this.optsWithGlobals();
|
|
2275
|
+
await runCommand({
|
|
2276
|
+
out: deps.createOutput(options.json ?? false),
|
|
2277
|
+
run: async () => {
|
|
2278
|
+
const body = await createBody$1(name, options, deps.readStdin);
|
|
2279
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "create-item-filter", body);
|
|
2280
|
+
},
|
|
2281
|
+
renderHuman: (data, line) => renderTable(FILTER_COLUMNS, [filterRow(data.filter)], line)
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
function updateCommand$1(deps) {
|
|
2286
|
+
return new Command("update").description("Change an item filter. A PARTIAL update — only the fields you name are written.").argument("<id>", "Filter id, from \"filters list\"").option("--name <name>", "New filter name").option("--action <action>", `What the filter does: ${FILTER_ACTIONS.join(", ")}`).option("--rules <json|@file|->", `${RULES_DESCRIPTION} REPLACES the whole tree.`).option("--description <text>", "Free-text note. It can be changed but not cleared.").option("--tag-color <color>", "Tag colour. It can be changed but not cleared.").option("--enable", "Turn the filter on").option("--disable", "Turn the filter off").option("--scope <scope>", `Where it applies: ${FILTER_SCOPES.join(", ")}`).option("--filter-type <type>", `Filter kind: ${FILTER_TYPES.join(", ")}`).action(async function runUpdate(id) {
|
|
2287
|
+
const options = this.optsWithGlobals();
|
|
2288
|
+
await runCommand({
|
|
2289
|
+
out: deps.createOutput(options.json ?? false),
|
|
2290
|
+
run: async () => {
|
|
2291
|
+
const body = await updateBody(id, options, deps.readStdin);
|
|
2292
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "update-item-filter", body);
|
|
2293
|
+
},
|
|
2294
|
+
renderHuman: (data, line) => renderTable(FILTER_COLUMNS, [filterRow(data.filter)], line)
|
|
2295
|
+
});
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* No `--input`, and the id is REQUIRED — the whole body is `{ filterId }`, so a
|
|
2300
|
+
* body could only restate or OVERRIDE the filter the command already names.
|
|
2301
|
+
* `folders delete` and `searches delete` are the same shape.
|
|
2302
|
+
*/
|
|
2303
|
+
function deleteCommand$1(deps) {
|
|
2304
|
+
return new Command("delete").description("Delete an item filter. PERMANENT — the rule tree goes with it and no endpoint can rebuild it.").argument("<id>", "Filter id, from \"filters list\"").option("--yes", "Confirm the deletion without an interactive prompt").action(async function runDelete(id) {
|
|
2305
|
+
const options = this.optsWithGlobals();
|
|
2306
|
+
const json = options.json ?? false;
|
|
2307
|
+
await runCommand({
|
|
2308
|
+
out: deps.createOutput(json),
|
|
2309
|
+
run: async () => {
|
|
2310
|
+
await assertDeleteAllowed({
|
|
2311
|
+
filterId: id,
|
|
2312
|
+
yes: options.yes ?? false,
|
|
2313
|
+
json,
|
|
2314
|
+
deps
|
|
2315
|
+
});
|
|
2316
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "delete-item-filter", { filterId: id });
|
|
2317
|
+
},
|
|
2318
|
+
renderHuman: (data, line) => renderTable(["ID"], [[data.id]], line)
|
|
2319
|
+
});
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* THE ONLY COMMAND THAT FILES A FILTER, and it sets the position in the same
|
|
2324
|
+
* call. `create` and `update` both refuse `folderId` and `sortOrder`
|
|
2325
|
+
* server-side, so there is one checked mechanism rather than two that can
|
|
2326
|
+
* disagree (D5) — and no separate `reorder` command for filters.
|
|
2327
|
+
*/
|
|
2328
|
+
function moveCommand(deps) {
|
|
2329
|
+
return new Command("move").description("File an item filter under a filter folder, or under none, optionally at a position").argument("<id>", "Filter id, from \"filters list\"").option("--folder <id>", "Target FILTER folder id, from \"filter-folders list\"").option("--unfile", "File the filter under no folder — sends an explicit null").option("--index <n>", "0-based position among the target’s filters; past the end means last. Omitted: last").action(async function runMove(id) {
|
|
2330
|
+
const options = this.optsWithGlobals();
|
|
2331
|
+
await runCommand({
|
|
2332
|
+
out: deps.createOutput(options.json ?? false),
|
|
2333
|
+
run: async () => {
|
|
2334
|
+
const body = moveBody$1(id, options);
|
|
2335
|
+
return callAction(await deps.resolveClient({ baseUrlFlag: options.baseUrl ?? null }), "move-item-filter-to-folder", body);
|
|
2336
|
+
},
|
|
2337
|
+
renderHuman: (data, line) => renderTable(["FILTER", "FOLDER"], [[data.id, data.folderId ?? NONE$1]], line)
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
1718
2341
|
function buildFiltersCommand(deps = {}) {
|
|
1719
2342
|
const resolved = resolveDeps$1(deps);
|
|
1720
|
-
return new Command("filters").description("
|
|
2343
|
+
return new Command("filters").description("Manage item filters: list, read, create, change, delete, file, export and import").addCommand(createCommand$1(resolved)).addCommand(deleteCommand$1(resolved)).addCommand(exportCommand$1(resolved)).addCommand(getCommand(resolved)).addCommand(importCommand$1(resolved)).addCommand(listCommand$1(resolved)).addCommand(moveCommand(resolved)).addCommand(updateCommand$1(resolved));
|
|
1721
2344
|
}
|
|
1722
2345
|
//#endregion
|
|
1723
2346
|
//#region src/commands/folders.ts
|
|
@@ -2017,6 +2640,23 @@ function readAllStdin$1() {
|
|
|
2017
2640
|
* check — nothing here enumerates the fields.
|
|
2018
2641
|
*/
|
|
2019
2642
|
const INPUT_EXPECTS = "The object carries the aspects to write; the server validates their contents.";
|
|
2643
|
+
const TEMPLATE_DROPPED = "warning: \"template\" is read-only and was dropped from the write; the other aspects were sent.";
|
|
2644
|
+
/**
|
|
2645
|
+
* The ONE field the CLI removes from `--input`, against the rule that the server
|
|
2646
|
+
* is the only validator of it. The read document is the write body — every
|
|
2647
|
+
* aspect comes back in the shape the write takes, and the read-only echoes
|
|
2648
|
+
* inside them (`lastBatchAt`, `filterName`) are the server's to strip — except
|
|
2649
|
+
* `template`, which the server refuses outright as not writable. Dropping it
|
|
2650
|
+
* here, with a warning on stderr, is what lets `settings X > doc.json`, edit,
|
|
2651
|
+
* `settings X --input @doc.json` work; refusing would make every round trip
|
|
2652
|
+
* start with deleting a key by hand. Null if there was nothing to drop.
|
|
2653
|
+
*/
|
|
2654
|
+
function withoutTemplate(aspects) {
|
|
2655
|
+
if (!Object.hasOwn(aspects, "template")) return null;
|
|
2656
|
+
const writable = { ...aspects };
|
|
2657
|
+
Reflect.deleteProperty(writable, "template");
|
|
2658
|
+
return writable;
|
|
2659
|
+
}
|
|
2020
2660
|
function describeSchedule(schedule) {
|
|
2021
2661
|
if (schedule === null) return NOT_CONFIGURED;
|
|
2022
2662
|
if (schedule.scheduleType === "ALWAYS") return "ALWAYS";
|
|
@@ -2108,7 +2748,7 @@ function buildNotificationsCommand(deps = {}) {
|
|
|
2108
2748
|
renderHuman: (result, line) => renderTable(["ID", "ENABLED"], [[result.id, yesNo(result.isEnabled)]], line)
|
|
2109
2749
|
});
|
|
2110
2750
|
});
|
|
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
|
|
2751
|
+
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 — each in the shape the read prints, so an edited read document is a valid input. At least one is required and each REPLACES that aspect wholesale. \"template\" is not writable and is dropped with a warning. Without this flag the command reads instead.").action(async (id, _options, command) => {
|
|
2112
2752
|
const options = command.optsWithGlobals();
|
|
2113
2753
|
const out = createOutput(options.json ?? false);
|
|
2114
2754
|
const input = options.input;
|
|
@@ -2125,11 +2765,14 @@ function buildNotificationsCommand(deps = {}) {
|
|
|
2125
2765
|
await runCommand({
|
|
2126
2766
|
out,
|
|
2127
2767
|
run: async () => {
|
|
2128
|
-
const
|
|
2768
|
+
const document = await readInputObject({
|
|
2129
2769
|
raw: input,
|
|
2130
2770
|
readStdin,
|
|
2131
2771
|
expects: INPUT_EXPECTS
|
|
2132
2772
|
});
|
|
2773
|
+
const writable = withoutTemplate(document);
|
|
2774
|
+
if (writable !== null) out.stderr.write(`${TEMPLATE_DROPPED}\n`);
|
|
2775
|
+
const aspects = writable ?? document;
|
|
2133
2776
|
return callAction(await resolveClient(options.baseUrl ?? null), "update-notification-channel-settings", {
|
|
2134
2777
|
...aspects,
|
|
2135
2778
|
id
|
|
@@ -2273,7 +2916,7 @@ function listCommand(deps) {
|
|
|
2273
2916
|
});
|
|
2274
2917
|
}
|
|
2275
2918
|
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() {
|
|
2919
|
+
return new Command("create").description("Create a saved search. It lands in the first root folder, as on the website; use \"folders move\" to file it elsewhere").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
2920
|
const options = this.optsWithGlobals();
|
|
2278
2921
|
await runCommand({
|
|
2279
2922
|
out: deps.createOutput(options.json ?? false),
|
|
@@ -2467,6 +3110,7 @@ function buildProgram() {
|
|
|
2467
3110
|
program.addCommand(buildBlocklistCommand());
|
|
2468
3111
|
program.addCommand(buildNotificationsCommand());
|
|
2469
3112
|
program.addCommand(buildFiltersCommand());
|
|
3113
|
+
program.addCommand(buildFilterFoldersCommand());
|
|
2470
3114
|
program.addCommand(buildConfigCommand());
|
|
2471
3115
|
return program;
|
|
2472
3116
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ubuyfirst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Command-line interface for the uBuyFirst public API",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://app.ubuyfirst.com/api/docs",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"provenance": false
|
|
21
|
+
},
|
|
19
22
|
"engines": {
|
|
20
23
|
"node": ">=22.12.0"
|
|
21
24
|
},
|