not-manage 0.2.0 → 0.2.1

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
@@ -48,6 +48,7 @@ For local development:
48
48
  git clone https://github.com/Not-Operations/not-manage.git
49
49
  cd not-manage
50
50
  npm install
51
+ npm run hooks:install
51
52
  node bin/not-manage.js --help
52
53
  ```
53
54
 
@@ -95,6 +96,11 @@ During setup, the CLI asks you to acknowledge that:
95
96
  - [SECURITY.md](SECURITY.md)
96
97
  - [TERMS.md](TERMS.md)
97
98
  - [OPERATIONS.md](OPERATIONS.md)
99
+ - [SUPPORT.md](SUPPORT.md)
100
+ - [CUSTOMER-TESTING.md](CUSTOMER-TESTING.md)
101
+ - [SUBPROCESSORS.md](SUBPROCESSORS.md)
102
+ - [MVSP-SELF-ASSESSMENT.md](MVSP-SELF-ASSESSMENT.md)
103
+ - [CLIO-APP-DIRECTORY-CHECKLIST.md](CLIO-APP-DIRECTORY-CHECKLIST.md)
98
104
 
99
105
  ## Core commands
100
106
 
@@ -103,34 +109,63 @@ not-manage setup
103
109
  not-manage auth setup
104
110
  not-manage auth login
105
111
  not-manage auth status
106
- not-manage activities list
107
- not-manage activity get 123
108
- not-manage tasks list
109
- not-manage task get 789
110
- not-manage contacts list
111
- not-manage contact get 12345
112
- not-manage time-entries list
113
- not-manage billable-clients list
114
- not-manage billable-matters list
115
- not-manage bills list
116
- not-manage bill get 987
117
- not-manage invoices list
118
- not-manage matters list
119
- not-manage matter get 456
120
- not-manage users list
121
- not-manage user get 123
122
- not-manage practice-areas list
123
- not-manage practice-area get 45
124
- not-manage matters list --status open --limit 50
125
- not-manage matters list --all --json
126
112
  not-manage whoami
127
113
  not-manage auth revoke
128
114
  ```
129
115
 
116
+ ## Resource command reference
117
+
118
+ <!-- GENERATED:CLI_REFERENCE:start -->
119
+ This table is generated from resource metadata. Global auth/setup commands stay hand-written.
120
+
121
+ | Command | Operations | Aliases | Required list filters |
122
+ | --- | --- | --- | --- |
123
+ | `activities` | `list`, `get` | `activity` | - |
124
+ | `calendar-entries` | `list`, `get` | `calendar-entry` | - |
125
+ | `reminders` | `list`, `get` | `reminder` | - |
126
+ | `tasks` | `list`, `get` | `task` | - |
127
+ | `contacts` | `list`, `get` | `contact` | - |
128
+ | `communications` | `list`, `get` | `communication` | - |
129
+ | `conversations` | `list`, `get` | `conversation` | - |
130
+ | `conversation-messages` | `list`, `get` | `conversation-message` | `--conversation-id` |
131
+ | `notes` | `list`, `get` | `note` | `--type` |
132
+ | `custom-fields` | `list`, `get` | `custom-field` | - |
133
+ | `time-entries` | `list`, `get` | `time-entry` | - |
134
+ | `billable-clients` | `list` | `billable-client` | - |
135
+ | `billable-matters` | `list` | `billable-matter` | - |
136
+ | `bills` | `list`, `get` | `bill` | - |
137
+ | `invoices` | `list`, `get` | `invoice` | - |
138
+ | `outstanding-client-balances` | `list` | `outstanding-client-balance` | - |
139
+ | `matters` | `list`, `get` | `matter` | - |
140
+ | `matter-dockets` | `list`, `get` | `matter-docket` | - |
141
+ | `users` | `list`, `get` | `user` | - |
142
+ | `practice-areas` | `list`, `get` | `practice-area` | - |
143
+ | `my-events` | `list` | `my-event` | - |
144
+
145
+ Required list filters are enforced by the CLI before it calls Clio.
146
+ <!-- GENERATED:CLI_REFERENCE:end -->
147
+
130
148
  Plural commands still work. Singular aliases are accepted for the single-record flows so `contact get 12345` and `contacts get 12345` both work.
131
149
 
132
150
  Every data command also accepts `--fields <comma-separated-list>` to override the default response shape. If you pass `--fields` with no value, the CLI prints the current default field list for that command.
133
151
 
152
+ ## Live smoke checks
153
+
154
+ In a local checkout, if you already have an authenticated test account, you can run a safe read-only smoke pass:
155
+
156
+ ```bash
157
+ npm run smoke:live
158
+ ```
159
+
160
+ This exercises a small set of real CLI reads against the currently authenticated Clio account:
161
+
162
+ - `auth status`
163
+ - `whoami`
164
+ - `users list`
165
+ - `notes list --type Matter`
166
+ - `outstanding-client-balances list`
167
+ - `calendar-entries list`
168
+
134
169
  ## Read-only examples
135
170
 
136
171
  ```bash
@@ -165,6 +200,14 @@ not-manage practice-areas list --name "Family"
165
200
  not-manage practice-areas list --matter-id 456
166
201
  not-manage practice-area get 45
167
202
 
203
+ not-manage calendar-entries list --from 2026-03-01T00:00:00Z --to 2026-03-31T23:59:59Z
204
+ not-manage reminders list --state pending
205
+ not-manage notes list --type Matter --limit 25
206
+ not-manage conversation-messages list --conversation-id 123
207
+ not-manage outstanding-client-balances list --limit 25
208
+ not-manage matter-dockets list --matter-id 456
209
+ not-manage my-events list --limit 25
210
+
168
211
  not-manage billable-matters list --client-id 999
169
212
  not-manage billable-clients list --start-date 2026-03-01
170
213
  not-manage matter get 456 --redacted
@@ -201,6 +244,13 @@ not-manage matter get 456 --redacted
201
244
  - Dependabot is configured for both npm dependencies and GitHub Actions.
202
245
  - npm release publishing is set up for provenance-backed trusted publishing from GitHub Actions using the `npm` environment.
203
246
 
247
+ ## Support
248
+
249
+ - Public support page: [SUPPORT.md](SUPPORT.md)
250
+ - Public support contact: `hello@notoperations.com`
251
+ - Bug reports and feature requests: `https://github.com/Not-Operations/not-manage/issues`
252
+ - Security reports: see [SECURITY.md](SECURITY.md)
253
+
204
254
  ## Troubleshooting
205
255
 
206
256
  - `OS keychain ... failed`: re-enable your OS keychain service or run the CLI in a supported desktop session with keychain access.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "not-manage",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Unofficial command-line tool for Clio Manage integrations",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -31,14 +31,18 @@
31
31
  "cli"
32
32
  ],
33
33
  "scripts": {
34
- "check": "npm run check:syntax && npm test",
34
+ "check": "npm run check:syntax && npm test && npm run docs:check",
35
35
  "check:security": "npm audit --audit-level=high",
36
- "check:syntax": "node --check bin/not-manage.js && node --check src/*.js && node --check test/*.test.js && node --check test/helpers/*.js",
36
+ "check:syntax": "node --check bin/not-manage.js && node --check src/*.js && node --check scripts/*.js && node --check test/*.test.js && node --check test/helpers/*.js",
37
37
  "dev": "node bin/not-manage.js",
38
+ "docs:check": "node scripts/generate-readme-cli-reference.js --check",
39
+ "docs:generate": "node scripts/generate-readme-cli-reference.js",
40
+ "hooks:install": "node scripts/install-git-hooks.js",
38
41
  "pack:check": "npm pack --dry-run",
39
42
  "postinstall": "node src/postinstall.js",
40
43
  "seed:handbook-imports": "node scripts/generate-handbook-import-csvs.js",
41
44
  "seed:historical": "node scripts/seed-historical-data.js",
45
+ "smoke:live": "node scripts/smoke-read-only-live.js",
42
46
  "start": "node bin/not-manage.js",
43
47
  "test": "node --test test/*.test.js"
44
48
  },
@@ -2,6 +2,10 @@ function hasOwn(object, key) {
2
2
  return Object.prototype.hasOwnProperty.call(object, key);
3
3
  }
4
4
 
5
+ function isPlainObject(value) {
6
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+
5
9
  function normalizeOptionName(name) {
6
10
  return String(name).replace(/^--/, "");
7
11
  }
@@ -23,6 +27,20 @@ function hasFlag(args, ...flags) {
23
27
  return flags.some((flag) => args.includes(flag));
24
28
  }
25
29
 
30
+ function appendParsedValue(parsed, key, value) {
31
+ if (!hasOwn(parsed, key)) {
32
+ parsed[key] = value;
33
+ return;
34
+ }
35
+
36
+ if (Array.isArray(parsed[key])) {
37
+ parsed[key].push(value);
38
+ return;
39
+ }
40
+
41
+ parsed[key] = [parsed[key], value];
42
+ }
43
+
26
44
  function parseOptions(args) {
27
45
  const parsed = {};
28
46
  const positional = [];
@@ -39,35 +57,61 @@ function parseOptions(args) {
39
57
  const inlineValue = rest.length > 0 ? rest.join("=") : null;
40
58
 
41
59
  if (inlineValue !== null) {
42
- parsed[rawKey] = inlineValue;
60
+ appendParsedValue(parsed, rawKey, inlineValue);
43
61
  continue;
44
62
  }
45
63
 
46
64
  const next = args[index + 1];
47
65
  if (next && !next.startsWith("--")) {
48
- parsed[rawKey] = next;
66
+ appendParsedValue(parsed, rawKey, next);
49
67
  index += 1;
50
68
  continue;
51
69
  }
52
70
 
53
- parsed[rawKey] = true;
71
+ appendParsedValue(parsed, rawKey, true);
54
72
  }
55
73
 
56
74
  return { parsed, positional };
57
75
  }
58
76
 
59
- function readOption(parsedOptions, name) {
60
- for (const candidate of optionKeyCandidates(name)) {
61
- if (hasOwn(parsedOptions, candidate)) {
62
- return parsedOptions[candidate];
77
+ function normalizeOptionValues(value) {
78
+ if (value === undefined) {
79
+ return [];
80
+ }
81
+
82
+ return Array.isArray(value) ? value : [value];
83
+ }
84
+
85
+ function readOptionValues(parsedOptions, name) {
86
+ return optionKeyCandidates(name).reduce((values, candidate) => {
87
+ if (!hasOwn(parsedOptions, candidate)) {
88
+ return values;
63
89
  }
90
+
91
+ return values.concat(normalizeOptionValues(parsedOptions[candidate]));
92
+ }, []);
93
+ }
94
+
95
+ function readOption(parsedOptions, name) {
96
+ const values = readOptionValues(parsedOptions, name);
97
+ if (values.length === 0) {
98
+ return undefined;
99
+ }
100
+
101
+ return values.length === 1 ? values[0] : values;
102
+ }
103
+
104
+ function readLastOptionValue(parsedOptions, name) {
105
+ const values = readOptionValues(parsedOptions, name);
106
+ if (values.length === 0) {
107
+ return undefined;
64
108
  }
65
109
 
66
- return undefined;
110
+ return values[values.length - 1];
67
111
  }
68
112
 
69
113
  function readStringOption(parsedOptions, name) {
70
- const value = readOption(parsedOptions, name);
114
+ const value = readLastOptionValue(parsedOptions, name);
71
115
  if (value === undefined) {
72
116
  return undefined;
73
117
  }
@@ -79,6 +123,34 @@ function readStringOption(parsedOptions, name) {
79
123
  return String(value);
80
124
  }
81
125
 
126
+ function splitCommaSeparatedValues(text) {
127
+ return String(text)
128
+ .split(",")
129
+ .map((value) => value.trim())
130
+ .filter(Boolean);
131
+ }
132
+
133
+ function readStringArrayOption(parsedOptions, name) {
134
+ const values = readOptionValues(parsedOptions, name);
135
+ if (values.length === 0) {
136
+ return undefined;
137
+ }
138
+
139
+ const output = [];
140
+
141
+ values.forEach((value) => {
142
+ if (value === true) {
143
+ throw new Error(`\`--${toKebabCase(name)}\` requires a value.`);
144
+ }
145
+
146
+ splitCommaSeparatedValues(value).forEach((item) => {
147
+ output.push(item);
148
+ });
149
+ });
150
+
151
+ return output.length > 0 ? output : undefined;
152
+ }
153
+
82
154
  function parseBooleanValue(value, name) {
83
155
  if (value === true || value === false) {
84
156
  return value;
@@ -98,7 +170,7 @@ function parseBooleanValue(value, name) {
98
170
  }
99
171
 
100
172
  function readBooleanOption(parsedOptions, name) {
101
- const value = readOption(parsedOptions, name);
173
+ const value = readLastOptionValue(parsedOptions, name);
102
174
  if (value === undefined) {
103
175
  return undefined;
104
176
  }
@@ -156,6 +228,185 @@ function readIsoDateTimeOption(parsedOptions, name) {
156
228
  return validateIsoDateTime(value, name);
157
229
  }
158
230
 
231
+ function readIsoDateArrayOption(parsedOptions, name) {
232
+ const values = readStringArrayOption(parsedOptions, name);
233
+ if (values === undefined) {
234
+ return undefined;
235
+ }
236
+
237
+ return values.map((value) => validateIsoDate(value, name));
238
+ }
239
+
240
+ function readIsoDateTimeArrayOption(parsedOptions, name) {
241
+ const values = readStringArrayOption(parsedOptions, name);
242
+ if (values === undefined) {
243
+ return undefined;
244
+ }
245
+
246
+ return values.map((value) => validateIsoDateTime(value, name));
247
+ }
248
+
249
+ function splitTopLevel(text, delimiter = ",") {
250
+ const segments = [];
251
+ let current = "";
252
+ let depth = 0;
253
+ let quote = null;
254
+ let escapeNext = false;
255
+
256
+ for (const char of String(text)) {
257
+ if (escapeNext) {
258
+ current += char;
259
+ escapeNext = false;
260
+ continue;
261
+ }
262
+
263
+ if (quote) {
264
+ current += char;
265
+ if (char === "\\") {
266
+ escapeNext = true;
267
+ } else if (char === quote) {
268
+ quote = null;
269
+ }
270
+ continue;
271
+ }
272
+
273
+ if (char === "'" || char === '"') {
274
+ quote = char;
275
+ current += char;
276
+ continue;
277
+ }
278
+
279
+ if (char === "[" || char === "{") {
280
+ depth += 1;
281
+ current += char;
282
+ continue;
283
+ }
284
+
285
+ if (char === "]" || char === "}") {
286
+ depth = Math.max(0, depth - 1);
287
+ current += char;
288
+ continue;
289
+ }
290
+
291
+ if (char === delimiter && depth === 0) {
292
+ if (current.trim()) {
293
+ segments.push(current.trim());
294
+ }
295
+ current = "";
296
+ continue;
297
+ }
298
+
299
+ current += char;
300
+ }
301
+
302
+ if (current.trim()) {
303
+ segments.push(current.trim());
304
+ }
305
+
306
+ return segments;
307
+ }
308
+
309
+ function unwrapQuotedValue(value) {
310
+ const text = String(value).trim();
311
+ if (
312
+ (text.startsWith('"') && text.endsWith('"')) ||
313
+ (text.startsWith("'") && text.endsWith("'"))
314
+ ) {
315
+ return text.slice(1, -1);
316
+ }
317
+ return text;
318
+ }
319
+
320
+ function parseStructuredValue(rawValue) {
321
+ const text = String(rawValue).trim();
322
+ if (!text) {
323
+ return "";
324
+ }
325
+
326
+ if (
327
+ (text.startsWith("{") && text.endsWith("}")) ||
328
+ (text.startsWith("[") && text.endsWith("]")) ||
329
+ (text.startsWith('"') && text.endsWith('"'))
330
+ ) {
331
+ try {
332
+ return JSON.parse(text);
333
+ } catch (_error) {
334
+ if (text.startsWith("[") && text.endsWith("]")) {
335
+ return splitTopLevel(text.slice(1, -1)).map((item) => unwrapQuotedValue(item));
336
+ }
337
+ }
338
+ }
339
+
340
+ return unwrapQuotedValue(text);
341
+ }
342
+
343
+ function mergeStructuredValues(existingValue, nextValue) {
344
+ if (existingValue === undefined) {
345
+ return nextValue;
346
+ }
347
+
348
+ if (isPlainObject(existingValue) && isPlainObject(nextValue)) {
349
+ return Object.entries(nextValue).reduce((merged, [key, value]) => {
350
+ merged[key] = mergeStructuredValues(merged[key], value);
351
+ return merged;
352
+ }, { ...existingValue });
353
+ }
354
+
355
+ const existingItems = Array.isArray(existingValue) ? existingValue : [existingValue];
356
+ const nextItems = Array.isArray(nextValue) ? nextValue : [nextValue];
357
+ return existingItems.concat(nextItems);
358
+ }
359
+
360
+ function parseObjectEntries(rawValue, name) {
361
+ const text = String(rawValue).trim();
362
+ if (!text) {
363
+ throw new Error(`\`--${toKebabCase(name)}\` requires a value.`);
364
+ }
365
+
366
+ if (text.startsWith("{")) {
367
+ const parsed = parseStructuredValue(text);
368
+ if (!isPlainObject(parsed)) {
369
+ throw new Error(`\`--${toKebabCase(name)}\` must be an object or key=value pairs.`);
370
+ }
371
+ return parsed;
372
+ }
373
+
374
+ return splitTopLevel(text).reduce((output, entry) => {
375
+ const separatorIndex = entry.search(/[:=]/);
376
+ if (separatorIndex <= 0) {
377
+ throw new Error(
378
+ `\`--${toKebabCase(name)}\` entries must look like \`field=value\` or JSON.`
379
+ );
380
+ }
381
+
382
+ const key = entry.slice(0, separatorIndex).trim();
383
+ const value = entry.slice(separatorIndex + 1).trim();
384
+ if (!key) {
385
+ throw new Error(
386
+ `\`--${toKebabCase(name)}\` entries must include a field identifier before \`=\` or \`:\`.`
387
+ );
388
+ }
389
+
390
+ output[key] = mergeStructuredValues(output[key], parseStructuredValue(value));
391
+ return output;
392
+ }, {});
393
+ }
394
+
395
+ function readObjectOption(parsedOptions, name) {
396
+ const values = readOptionValues(parsedOptions, name);
397
+ if (values.length === 0) {
398
+ return undefined;
399
+ }
400
+
401
+ return values.reduce((output, value) => {
402
+ if (value === true) {
403
+ throw new Error(`\`--${toKebabCase(name)}\` requires a value.`);
404
+ }
405
+
406
+ return mergeStructuredValues(output, parseObjectEntries(value, name));
407
+ }, undefined);
408
+ }
409
+
159
410
  function readCommandOptions(parsedOptions, schema, positional = [], baseOptions = {}, fixed = {}) {
160
411
  const options = { ...baseOptions };
161
412
 
@@ -175,9 +426,21 @@ function readCommandOptions(parsedOptions, schema, positional = [], baseOptions
175
426
  case "iso-date":
176
427
  options[propertyName] = readIsoDateOption(parsedOptions, optionDef.option);
177
428
  return;
429
+ case "iso-date-array":
430
+ options[propertyName] = readIsoDateArrayOption(parsedOptions, optionDef.option);
431
+ return;
178
432
  case "iso-datetime":
179
433
  options[propertyName] = readIsoDateTimeOption(parsedOptions, optionDef.option);
180
434
  return;
435
+ case "iso-datetime-array":
436
+ options[propertyName] = readIsoDateTimeArrayOption(parsedOptions, optionDef.option);
437
+ return;
438
+ case "object":
439
+ options[propertyName] = readObjectOption(parsedOptions, optionDef.option);
440
+ return;
441
+ case "string-array":
442
+ options[propertyName] = readStringArrayOption(parsedOptions, optionDef.option);
443
+ return;
181
444
  case "string":
182
445
  default:
183
446
  options[propertyName] = readStringOption(parsedOptions, optionDef.option);
@@ -197,8 +460,13 @@ module.exports = {
197
460
  readCommandOptions,
198
461
  readFlagOption,
199
462
  readIsoDateOption,
463
+ readIsoDateArrayOption,
200
464
  readIsoDateTimeOption,
465
+ readIsoDateTimeArrayOption,
466
+ readObjectOption,
201
467
  readOption,
468
+ readOptionValues,
202
469
  readStringOption,
470
+ readStringArrayOption,
203
471
  toKebabCase,
204
472
  };
package/src/cli.js CHANGED
@@ -7,60 +7,16 @@ const {
7
7
  setupWizard,
8
8
  whoAmI,
9
9
  } = require("./commands-auth");
10
- const { activitiesGet, activitiesList } = require("./commands-activities");
11
- const { billableClientsList } = require("./commands-billable-clients");
12
- const { billableMattersList } = require("./commands-billable-matters");
13
- const { billsGet, billsList } = require("./commands-bills");
14
- const { contactsGet, contactsList } = require("./commands-contacts");
15
- const { mattersGet, mattersList } = require("./commands-matters");
16
- const { practiceAreasGet, practiceAreasList } = require("./commands-practice-areas");
17
- const { tasksGet, tasksList } = require("./commands-tasks");
18
- const { usersGet, usersList } = require("./commands-users");
19
10
  const { hasFlag, parseOptions, readBooleanOption, readCommandOptions } = require("./cli-options");
11
+ const { getResourceHandler } = require("./resource-handlers");
20
12
  const {
21
13
  RESOURCE_ORDER,
22
14
  getResourceMetadata,
15
+ listRequiredOptionFlags,
23
16
  normalizeResourceCommand,
24
17
  } = require("./resource-metadata");
25
18
  const { version } = require("../package.json");
26
19
 
27
- const RESOURCE_HANDLERS = {
28
- activities: {
29
- get: activitiesGet,
30
- list: activitiesList,
31
- },
32
- "billable-clients": {
33
- list: billableClientsList,
34
- },
35
- "billable-matters": {
36
- list: billableMattersList,
37
- },
38
- bills: {
39
- get: billsGet,
40
- list: billsList,
41
- },
42
- contacts: {
43
- get: contactsGet,
44
- list: contactsList,
45
- },
46
- matters: {
47
- get: mattersGet,
48
- list: mattersList,
49
- },
50
- "practice-areas": {
51
- get: practiceAreasGet,
52
- list: practiceAreasList,
53
- },
54
- tasks: {
55
- get: tasksGet,
56
- list: tasksList,
57
- },
58
- users: {
59
- get: usersGet,
60
- list: usersList,
61
- },
62
- };
63
-
64
20
  function maybePrintDefaultFields(command, sub, optionValues) {
65
21
  if (optionValues.fields !== true) {
66
22
  return false;
@@ -134,12 +90,15 @@ function printHelp() {
134
90
  RESOURCE_ORDER.forEach((command) => {
135
91
  const resourceMetadata = getResourceMetadata(command);
136
92
  ["list", "get"].forEach((sub) => {
137
- if (!resourceMetadata.supports[sub]) {
93
+ if (!resourceMetadata.capabilities[sub].enabled) {
138
94
  return;
139
95
  }
140
96
 
141
97
  const usage = `${command} ${sub}`.padEnd(18, " ");
142
- console.log(` ${usage} ${resourceMetadata.help[sub]}`);
98
+ const requiredFlags = listRequiredOptionFlags(resourceMetadata, sub);
99
+ const requirementNote =
100
+ requiredFlags.length > 0 ? ` (requires ${requiredFlags.join(", ")})` : "";
101
+ console.log(` ${usage} ${resourceMetadata.help[sub]}${requirementNote}`);
143
102
  });
144
103
  });
145
104
 
@@ -231,9 +190,7 @@ async function run(args) {
231
190
  }
232
191
 
233
192
  const resourceMetadata = getResourceMetadata(command);
234
- const handler = resourceMetadata
235
- ? RESOURCE_HANDLERS[resourceMetadata.handlerKey]?.[sub]
236
- : null;
193
+ const handler = getResourceHandler(resourceMetadata, sub);
237
194
 
238
195
  if (resourceMetadata && handler) {
239
196
  warnAboutRedaction(resourceMetadata, sub, optionValues, redacted);
package/src/clio-api.js CHANGED
@@ -5,6 +5,10 @@ function createError(message, responseText) {
5
5
  return new Error(`${message}.${suffix}`.trim());
6
6
  }
7
7
 
8
+ function isPlainObject(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
8
12
  async function postForm(url, formFields, headers = {}) {
9
13
  const response = await fetch(url, {
10
14
  method: "POST",
@@ -136,21 +140,45 @@ function parseTrustedApiUrl(config, url, expectedPathPrefix = "/api/v4/") {
136
140
  function buildUrlWithQuery(baseUrl, query = {}) {
137
141
  const url = new URL(baseUrl);
138
142
 
139
- Object.entries(query).forEach(([key, value]) => {
143
+ function appendQueryValue(key, value) {
140
144
  if (value === undefined || value === null || value === "") {
141
145
  return;
142
146
  }
143
147
 
144
148
  if (Array.isArray(value)) {
145
149
  value.forEach((item) => {
146
- if (item !== undefined && item !== null && item !== "") {
147
- url.searchParams.append(key, String(item));
150
+ appendQueryValue(key, item);
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (isPlainObject(value)) {
156
+ Object.entries(value).forEach(([nestedKey, nestedValue]) => {
157
+ if (nestedValue === undefined || nestedValue === null || nestedValue === "") {
158
+ return;
148
159
  }
160
+
161
+ const compositeKey = `${key}[${nestedKey}]`;
162
+ if (Array.isArray(nestedValue)) {
163
+ const serialized = nestedValue
164
+ .filter((item) => item !== undefined && item !== null && item !== "")
165
+ .map((item) => String(item));
166
+ if (serialized.length > 0) {
167
+ url.searchParams.append(compositeKey, `[${serialized.join(", ")}]`);
168
+ }
169
+ return;
170
+ }
171
+
172
+ appendQueryValue(compositeKey, nestedValue);
149
173
  });
150
174
  return;
151
175
  }
152
176
 
153
- url.searchParams.set(key, String(value));
177
+ url.searchParams.append(key, String(value));
178
+ }
179
+
180
+ Object.entries(query).forEach(([key, value]) => {
181
+ appendQueryValue(key, value);
154
182
  });
155
183
 
156
184
  return url.toString();
@@ -382,6 +410,7 @@ module.exports = {
382
410
  fetchWhoAmI,
383
411
  getValidAccessToken,
384
412
  __private: {
413
+ buildUrlWithQuery,
385
414
  parseTrustedApiUrl,
386
415
  },
387
416
  };