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 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 terminal or 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 press Enter
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 list, create, update, pause, resume, delete, export, import
36
- folders list, create, rename, delete, move
37
- blocklist list, add, remove (sellers, countries, items)
38
- notifications list, toggle, settings
39
- filters export, import
40
- config set-key, show, clear
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 '.searches[].name'
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-YTm6g9Ut.js");
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.1.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 --input file ${path}: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
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 text = await readSource(request.raw, request.readStdin);
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, "--input is not valid JSON.");
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, `--input must be a JSON object, not an array or a scalar.${request.expects === void 0 ? "" : ` ${request.expects}`}`);
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$2(raw) {
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": 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.");
840
+ case "countries": return keys.map((countryCode) => ({ countryCode }));
838
841
  }
839
842
  }
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];
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$2(overrides) {
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$2(options.limit);
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. 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) => {
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 ids, or eBay listing ids").option("--type <type>", TYPE_FLAG_DESCRIPTION).option("--input <source>", INPUT_FLAG_DESCRIPTION).action(async (keys, _options, command) => {
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$2(overrides);
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 (spec 301 → Bulk export/import).
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
- * 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.
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
- * 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.
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("Export and import item filters").addCommand(exportCommand$1(resolved)).addCommand(importCommand$1(resolved));
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, each REPLACES that aspect wholesale, and \"template\" is not writable. Without this flag the command reads instead.").action(async (id, _options, command) => {
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 aspects = await readInputObject({
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.1.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
  },