@zeyos/cli 0.5.0 → 0.6.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
@@ -80,15 +80,25 @@ Inspect dynamic schema definitions:
80
80
  ```bash
81
81
  zeyos count customfields --json
82
82
  zeyos list customfields --fields ID,name,identifier,context,type --json
83
+ zeyos count dunning --json
83
84
  ```
84
85
 
85
86
  Inspect actionsteps/time-entry evidence and ticket mail:
86
87
 
87
88
  ```bash
88
89
  zeyos list actionsteps --fields ID,name,status,date,duedate,effort,ticket,account --json
90
+ zeyos sum actionsteps effort --filter '{"status":[1,3]}' --json
89
91
  zeyos list messages --fields ID,date,mailbox,subject,sender_email,to_email,ticket,reference --filter '{"ticket":42}' --json
90
92
  ```
91
93
 
94
+ Filters accept common agent-generated shapes and normalize them to the native ZeyOS
95
+ request form. Use `--query --json` to inspect the request without sending it:
96
+
97
+ ```bash
98
+ zeyos list tickets --filter '{"status":{"$nin":[8,9,10]},"priority":[3,4]}' --query --json
99
+ zeyos list accounts --filter '{"name__like":"Acme%","ID__in":[1,2,3]}' --query --json
100
+ ```
101
+
92
102
  Create, update, and delete:
93
103
 
94
104
  ```bash
package/bin/zeyos.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  * whoami Show current user info
11
11
  * list <resource> List records
12
12
  * count <resource> Count records
13
+ * sum <resource> Sum a numeric field across matching records
13
14
  * get <resource> <id> Fetch a single record
14
15
  * show <resource> <id> Alias for get
15
16
  * create <resource> Create a new record
@@ -42,6 +43,7 @@ ${_c.bold('Commands:')}
42
43
  ${_c.cyan('whoami')} Show currently authenticated user
43
44
  ${_c.cyan('list')} <resource> List / query records
44
45
  ${_c.cyan('count')} <resource> Count records (with optional filter)
46
+ ${_c.cyan('sum')} <resource> <field> Sum a numeric field
45
47
  ${_c.cyan('get')} <resource> <id> Fetch a single record by ID
46
48
  ${_c.cyan('show')} <resource> <id> Alias for get
47
49
  ${_c.cyan('create')} <resource> Create a new record
@@ -68,6 +70,7 @@ ${_c.bold('Examples:')}
68
70
  ${_z} list tickets --filter '{"status":1}' --sort -lastmodified
69
71
  ${_z} list tickets --filter-file ./filters/open-tickets.json
70
72
  ${_z} count tickets --filter '{"status":1}'
73
+ ${_z} sum actionsteps effort --filter '{"status":[1,3]}'
71
74
  ${_z} get ticket 42
72
75
  ${_z} get ticket 42 --all
73
76
  ${_z} create ticket --name "Fix login bug" --priority 3
@@ -105,6 +108,7 @@ const OPTIONS = {
105
108
  'sort': { type: 'string' },
106
109
  'limit': { type: 'string' },
107
110
  'offset': { type: 'string' },
111
+ 'page-size': { type: 'string' },
108
112
  'expand': { type: 'string' },
109
113
  'extdata': { type: 'boolean' },
110
114
  'tags': { type: 'boolean' },
@@ -127,6 +131,12 @@ const OPTIONS = {
127
131
  'from-current': { type: 'boolean' },
128
132
  };
129
133
 
134
+ const COMMON_COMMAND_HELP = `\
135
+ Global options:
136
+ --profile <name> Use a named credential profile for this command
137
+ --no-color Disable ANSI colors
138
+ `;
139
+
130
140
  // ── Command registry ──────────────────────────────────────────────────────────
131
141
  // Maps every command and alias to the module that implements it.
132
142
 
@@ -136,6 +146,7 @@ const COMMANDS = {
136
146
  whoami: '../commands/whoami.mjs',
137
147
  list: '../commands/list.mjs',
138
148
  count: '../commands/count.mjs',
149
+ sum: '../commands/sum.mjs',
139
150
  get: '../commands/get.mjs',
140
151
  show: '../commands/get.mjs',
141
152
  create: '../commands/create.mjs',
@@ -161,6 +172,7 @@ const COMMANDS = {
161
172
  // exception: they accept arbitrary `--<field>` flags, marked with `null` below.
162
173
 
163
174
  const ALWAYS_FLAGS = ['help', 'json', 'yaml', 'no-color', 'profile'];
175
+ const LEADING_FLAGS = [...ALWAYS_FLAGS, 'version', 'query'];
164
176
  const SKILLS_FLAGS = ['target', 'dir', 'global', 'local', 'force', 'yes', 'no-logo'];
165
177
  const OKF_FLAGS = ['dir', 'out', 'force', 'no-logo'];
166
178
  const PROFILE_FLAGS = ['base-url', 'client-id', 'secret', 'local', 'from-current'];
@@ -173,6 +185,7 @@ const COMMAND_FLAGS = {
173
185
  whoami: ['show-token'],
174
186
  list: ['fields', 'filter', 'filter-file', 'sort', 'limit', 'offset', 'extdata', 'expand', 'query'],
175
187
  count: ['filter', 'filter-file', 'query'],
188
+ sum: ['filter', 'filter-file', 'limit', 'offset', 'page-size', 'query'],
176
189
  get: GET_FLAGS,
177
190
  show: GET_FLAGS,
178
191
  create: null,
@@ -192,6 +205,8 @@ const COMMAND_FLAGS = {
192
205
  profiles: PROFILE_FLAGS,
193
206
  };
194
207
 
208
+ const CREDENTIAL_MUTATION_COMMANDS = new Set(['login', 'logout', 'profile', 'profiles']);
209
+
195
210
  // ── Main ──────────────────────────────────────────────────────────────────────
196
211
 
197
212
  async function main() {
@@ -208,20 +223,37 @@ async function main() {
208
223
  process.exit(0);
209
224
  }
210
225
 
211
- const command = argv[0];
226
+ const lead = _splitLeadingFlags(argv);
227
+ if (lead.values.help && lead.argv.length === 0) {
228
+ process.stdout.write(HELP);
229
+ process.exit(0);
230
+ }
231
+ if (lead.values.version && lead.argv.length === 0) {
232
+ process.stdout.write(_VERSION + '\n');
233
+ process.exit(0);
234
+ }
235
+ if (lead.argv.length === 0) {
236
+ process.stdout.write(HELP);
237
+ process.exit(0);
238
+ }
212
239
 
213
- // A leading flag (e.g. `zeyos --invalid`) is not a command — surface it as a
214
- // bad option rather than letting it masquerade as one.
240
+ const command = lead.argv[0];
215
241
  if (command.startsWith('-')) {
216
- process.stderr.write(`Unknown option: "${command}". Run 'zeyos --help' for usage.\n`);
217
- process.exit(1);
242
+ _failLeadingOption(command);
218
243
  }
219
244
 
220
- const rest = argv.slice(1);
245
+ const rest = lead.argv.slice(1);
246
+
247
+ if (process.env.ZEYOS_CREDENTIALS_READONLY && CREDENTIAL_MUTATION_COMMANDS.has(command)) {
248
+ process.stderr.write(`Credential command "${command}" is disabled because ZEYOS_CREDENTIALS_READONLY is set.\n`);
249
+ process.exit(1);
250
+ }
221
251
 
222
252
  // Parse remaining args permissively: known options are parsed normally and
223
253
  // unknown --key value flags are captured too (so create/update accept fields).
224
- const { values, positional } = _parsePermissive(rest, OPTIONS);
254
+ const parsed = _parsePermissive(rest, OPTIONS);
255
+ const values = { ...lead.values, ...parsed.values };
256
+ const positional = parsed.positional;
225
257
 
226
258
  const modulePath = COMMANDS[command];
227
259
  if (!modulePath) {
@@ -232,7 +264,7 @@ async function main() {
232
264
  const mod = await import(modulePath);
233
265
 
234
266
  if (values.help) {
235
- process.stdout.write(mod.USAGE ?? HELP);
267
+ process.stdout.write(_formatCommandHelp(mod.USAGE ?? HELP));
236
268
  process.exit(0);
237
269
  }
238
270
 
@@ -254,11 +286,121 @@ async function main() {
254
286
  }
255
287
  }
256
288
 
289
+ _validateKnownStringValues(values);
290
+
257
291
  await mod.run(values, positional);
258
292
  }
259
293
 
260
294
  // ── Helpers ───────────────────────────────────────────────────────────────────
261
295
 
296
+ /**
297
+ * Parse documented global flags that appear before the command name, e.g.
298
+ * `zeyos --profile dev whoami`. Command-specific flags remain after the
299
+ * command so the per-command allow-list can validate them.
300
+ */
301
+ function _splitLeadingFlags(argv) {
302
+ const values = {};
303
+ let i = 0;
304
+
305
+ while (i < argv.length) {
306
+ const arg = argv[i];
307
+
308
+ if (arg === '--') {
309
+ return { values, argv: argv.slice(i + 1) };
310
+ }
311
+
312
+ if (arg.startsWith('--')) {
313
+ const eqIdx = arg.indexOf('=');
314
+ const key = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
315
+ const inlineVal = eqIdx === -1 ? undefined : arg.slice(eqIdx + 1);
316
+ const opt = OPTIONS[key];
317
+
318
+ if (!LEADING_FLAGS.includes(key)) {
319
+ _failLeadingOption(arg);
320
+ }
321
+
322
+ if (opt?.type === 'boolean') {
323
+ values[key] = true;
324
+ i++;
325
+ continue;
326
+ }
327
+
328
+ if (opt?.type === 'string') {
329
+ if (inlineVal !== undefined) {
330
+ values[key] = inlineVal;
331
+ i++;
332
+ } else {
333
+ const next = argv[i + 1];
334
+ if (next !== undefined && next.startsWith('--')) {
335
+ values[key] = '';
336
+ i++;
337
+ } else {
338
+ values[key] = next ?? '';
339
+ i += 2;
340
+ }
341
+ }
342
+ continue;
343
+ }
344
+ }
345
+
346
+ if (arg.startsWith('-') && arg.length === 2) {
347
+ const short = arg[1];
348
+ const match = Object.entries(OPTIONS).find(([, o]) => o.short === short);
349
+ if (!match || !LEADING_FLAGS.includes(match[0])) {
350
+ _failLeadingOption(arg);
351
+ }
352
+
353
+ const [key, opt] = match;
354
+ if (opt.type === 'boolean') {
355
+ values[key] = true;
356
+ i++;
357
+ } else {
358
+ const next = argv[i + 1];
359
+ if (next !== undefined && next.startsWith('--')) {
360
+ values[key] = '';
361
+ i++;
362
+ } else {
363
+ values[key] = next ?? '';
364
+ i += 2;
365
+ }
366
+ }
367
+ continue;
368
+ }
369
+
370
+ break;
371
+ }
372
+
373
+ return { values, argv: argv.slice(i) };
374
+ }
375
+
376
+ function _failLeadingOption(flag) {
377
+ process.stderr.write(`Unknown option: "${flag}". Run 'zeyos --help' for usage.\n`);
378
+ process.exit(1);
379
+ }
380
+
381
+ function _formatCommandHelp(usage) {
382
+ if (usage === HELP || /--profile\s+<name>/.test(usage)) {
383
+ return usage;
384
+ }
385
+ if (/Global options:\n/.test(usage)) {
386
+ return usage.replace(
387
+ /Global options:\n/,
388
+ `Global options:\n --profile <name> Use a named credential profile for this command\n --no-color Disable ANSI colors\n`
389
+ );
390
+ }
391
+ const trimmed = usage.endsWith('\n') ? usage : `${usage}\n`;
392
+ return `${trimmed}\n${COMMON_COMMAND_HELP}`;
393
+ }
394
+
395
+ function _validateKnownStringValues(values) {
396
+ for (const [key, value] of Object.entries(values)) {
397
+ if (OPTIONS[key]?.type === 'string' && value === '') {
398
+ process.stderr.write(`Option --${key} requires a value.\n`);
399
+ process.exit(1);
400
+ }
401
+ }
402
+ }
403
+
262
404
  /**
263
405
  * Parse argv with known options; capture unknown --key value pairs too.
264
406
  * This lets create/update accept arbitrary --fieldName value flags.
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { normalizeCountResult } from '@zeyos/client';
14
- import { buildCliClient, callApi, maybeDryRun, parseJsonOptionOrFile, requireResource } from '../lib/command.mjs';
14
+ import { buildCliClient, callApi, maybeDryRun, normalizeFilterOperators, parseJsonOptionOrFile, requireResource } from '../lib/command.mjs';
15
15
  import { outputMode, printJson, printYaml } from '../lib/output.mjs';
16
16
 
17
17
  export const USAGE = `\
@@ -24,6 +24,8 @@ Arguments:
24
24
 
25
25
  Options:
26
26
  --filter <json> JSON filter object e.g. '{"status":1}'
27
+ Arrays normalize to IN; $lt/$lte/$gt/$gte/$ne/$in/$nin and suffix
28
+ keys like field__startswith/field__gt normalize to native operators
27
29
  --filter-file <path>
28
30
  Read JSON filter object from a file
29
31
  --json Output as JSON ({ "count": N })
@@ -47,7 +49,7 @@ export async function run(values, positional) {
47
49
 
48
50
  const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
49
51
  if (filters !== undefined) {
50
- body.filters = filters;
52
+ body.filters = normalizeFilterOperators(filters, { fieldAliases: res.filterAliases });
51
53
  }
52
54
 
53
55
  // ── Call API ───────────────────────────────────────────────────────────────
@@ -21,6 +21,8 @@ const ENV_KEYS = {
21
21
  ZEYOS_CLIENT_SECRET: 'clientSecret',
22
22
  ZEYOS_TOKEN: 'accessToken',
23
23
  ZEYOS_REFRESH_TOKEN: 'refreshToken',
24
+ ZEYOS_NO_REFRESH: 'tokenOnly',
25
+ ZEYOS_CREDENTIALS_READONLY: 'credentialsReadonly',
24
26
  };
25
27
 
26
28
  export const USAGE = `\
@@ -82,8 +84,9 @@ function buildAgentReport() {
82
84
  clientSecret: Boolean(config.clientSecret),
83
85
  accessToken: Boolean(config.accessToken),
84
86
  refreshToken: Boolean(config.refreshToken),
87
+ tokenOnly: Boolean(process.env.ZEYOS_TOKEN) || isTruthyEnv(process.env.ZEYOS_NO_REFRESH),
85
88
  };
86
- const ready = Boolean(effective.baseUrl && effective.clientId && effective.clientSecret && effective.accessToken);
89
+ const ready = Boolean(effective.baseUrl && effective.accessToken && (effective.tokenOnly || (effective.clientId && effective.clientSecret)));
87
90
  const resources = inspectResources();
88
91
 
89
92
  return {
@@ -117,6 +120,10 @@ function buildAgentReport() {
117
120
  };
118
121
  }
119
122
 
123
+ function isTruthyEnv(value) {
124
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
125
+ }
126
+
120
127
  function inspectResources() {
121
128
  const names = listResources();
122
129
  const missing = [];
package/commands/list.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  callApi,
27
27
  fail,
28
28
  maybeDryRun,
29
+ normalizeFilterOperators,
29
30
  parseJsonOptionOrFile,
30
31
  requireApiMethod,
31
32
  requireResource
@@ -42,6 +43,8 @@ Arguments:
42
43
  Options:
43
44
  --fields <list> Field selection (see formats below)
44
45
  --filter <json> JSON filter object e.g. '{"status":1}'
46
+ Arrays normalize to IN; $lt/$lte/$gt/$gte/$ne/$in/$nin and suffix
47
+ keys like field__startswith/field__gt normalize to native operators
45
48
  --filter-file <path>
46
49
  Read JSON filter object from a file
47
50
  --sort <fields> Sort expression e.g. '-lastmodified'
@@ -86,7 +89,7 @@ export async function run(values, positional) {
86
89
 
87
90
  const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
88
91
  if (filters !== undefined) {
89
- body.filters = filters;
92
+ body.filters = normalizeFilterOperators(filters, { fieldAliases: res.filterAliases });
90
93
  }
91
94
 
92
95
  if (values.sort) body.sort = values.sort.split(',').map(s => s.trim()).filter(Boolean);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * zeyos sum <resource> <field>
3
+ *
4
+ * Page through records and sum one numeric field client-side.
5
+ */
6
+
7
+ import { normalizeListResult } from '@zeyos/client';
8
+ import {
9
+ buildCliClient,
10
+ callApi,
11
+ fail,
12
+ maybeDryRun,
13
+ normalizeFilterOperators,
14
+ parseJsonOptionOrFile,
15
+ requireResource
16
+ } from '../lib/command.mjs';
17
+ import { outputMode, printJson, printYaml } from '../lib/output.mjs';
18
+
19
+ export const USAGE = `\
20
+ Usage: zeyos sum <resource> <field> [options]
21
+
22
+ Sum a numeric field across all records matching an optional filter.
23
+ The CLI pages internally so agents do not need to list rows and write ad hoc scripts.
24
+
25
+ Arguments:
26
+ resource Resource name (e.g. actionsteps, transactions, payments)
27
+ field Numeric field to sum (e.g. effort, amount, netamount)
28
+
29
+ Options:
30
+ --filter <json> JSON filter object e.g. '{"status":[1,3]}'
31
+ Arrays normalize to IN; $lt/$lte/$gt/$gte/$ne/$in/$nin and suffix
32
+ keys like field__startswith/field__gt normalize to native operators
33
+ --filter-file <path>
34
+ Read JSON filter object from a file
35
+ --page-size <n> Records per API page (default: 50)
36
+ --limit <n> Maximum records to inspect
37
+ --offset <n> Initial offset (default: 0)
38
+ --json Output as JSON ({ "sum": N, "count": N })
39
+ --yaml Output as YAML
40
+ --query Print the first page request without sending it
41
+ -h, --help Show this help
42
+
43
+ Examples:
44
+ zeyos sum actionsteps effort --filter '{"status":[1,3]}'
45
+ zeyos sum transactions netamount --filter '{"type":3}' --json
46
+ `;
47
+
48
+ export async function run(values, positional) {
49
+ const resourceName = positional[0];
50
+ const field = positional[1];
51
+ const res = requireResource(resourceName, 'zeyos sum <resource> <field>');
52
+ if (!field) fail('Missing field name. Usage: zeyos sum <resource> <field>');
53
+
54
+ const pageSize = parsePositiveInt(values['page-size'] ?? '50', '--page-size');
55
+ const maxRows = values.limit == null ? Infinity : parsePositiveInt(values.limit, '--limit');
56
+ let offset = values.offset == null ? 0 : parseNonNegativeInt(values.offset, '--offset');
57
+
58
+ const body = { fields: [field], limit: Math.min(pageSize, maxRows), offset };
59
+ const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
60
+ if (filters !== undefined) body.filters = normalizeFilterOperators(filters, { fieldAliases: res.filterAliases });
61
+
62
+ const clientState = buildCliClient(values);
63
+ if (await maybeDryRun(clientState, res.list, body, values)) return;
64
+
65
+ let sum = 0;
66
+ let count = 0;
67
+
68
+ while (count < maxRows) {
69
+ const remaining = maxRows - count;
70
+ const limit = Math.min(pageSize, remaining);
71
+ const pageBody = { ...body, limit, offset };
72
+ const result = await callApi(clientState, res.list, pageBody);
73
+ const rows = normalizeListResult(result).data;
74
+
75
+ for (const row of rows) {
76
+ sum += numericValue(row[field], field);
77
+ count += 1;
78
+ if (count >= maxRows) break;
79
+ }
80
+
81
+ if (rows.length < limit || rows.length === 0) break;
82
+ offset += rows.length;
83
+ }
84
+
85
+ const mode = outputMode(values);
86
+ if (mode === 'json') {
87
+ printJson({ sum, count, field });
88
+ } else if (mode === 'yaml') {
89
+ printYaml({ sum, count, field });
90
+ } else {
91
+ process.stdout.write(`${sum}\n`);
92
+ }
93
+ }
94
+
95
+ function numericValue(value, field) {
96
+ if (value == null || value === '') return 0;
97
+ const n = Number(value);
98
+ if (!Number.isFinite(n)) fail(`Field "${field}" contains a non-numeric value: ${JSON.stringify(value)}`);
99
+ return n;
100
+ }
101
+
102
+ function parsePositiveInt(value, flag) {
103
+ const n = Number.parseInt(String(value), 10);
104
+ if (!Number.isInteger(n) || n <= 0) fail(`${flag} must be a positive integer.`);
105
+ return n;
106
+ }
107
+
108
+ function parseNonNegativeInt(value, flag) {
109
+ const n = Number.parseInt(String(value), 10);
110
+ if (!Number.isInteger(n) || n < 0) fail(`${flag} must be a non-negative integer.`);
111
+ return n;
112
+ }
package/lib/client.mjs CHANGED
@@ -23,29 +23,47 @@ export function buildClient(overrides = {}, opts = {}) {
23
23
  throw new Error(`Profile "${loaded.profile.name}" not found (selected via ${loaded.profile.origin}). ${known}`);
24
24
  }
25
25
  const config = { ...loaded.config, ...overrides };
26
- requireConfig(['baseUrl', 'clientId', 'clientSecret', 'accessToken'], config);
26
+ const tokenOnly = isTokenOnlyMode(config);
27
+ requireConfig(tokenOnly ? ['baseUrl', 'accessToken'] : ['baseUrl', 'clientId', 'clientSecret', 'accessToken'], config);
27
28
 
28
- const tokenStore = new MemoryTokenStore({
29
- accessToken: config.accessToken,
30
- refreshToken: config.refreshToken,
31
- expiresAt: config.expiresAt,
32
- refreshTokenExpiresAt: config.refreshTokenExpiresAt,
33
- });
29
+ const tokenStore = new MemoryTokenStore(tokenOnly
30
+ ? { accessToken: config.accessToken }
31
+ : {
32
+ accessToken: config.accessToken,
33
+ refreshToken: config.refreshToken,
34
+ expiresAt: config.expiresAt,
35
+ refreshTokenExpiresAt: config.refreshTokenExpiresAt,
36
+ });
34
37
 
35
- const client = createZeyosClient({
36
- platform: config.baseUrl,
37
- auth: {
38
- mode: 'oauth',
39
- oauth: {
38
+ const oauth = tokenOnly
39
+ ? {
40
+ tokenStore,
41
+ autoRefresh: false,
42
+ }
43
+ : {
40
44
  clientId: config.clientId,
41
45
  clientSecret: config.clientSecret,
42
46
  tokenStore,
43
47
  autoRefresh: true,
44
- },
48
+ };
49
+
50
+ const client = createZeyosClient({
51
+ platform: config.baseUrl,
52
+ auth: {
53
+ mode: 'oauth',
54
+ oauth,
45
55
  },
46
56
  });
47
57
 
48
- return { client, config, tokenStore, configSource: loaded.source };
58
+ return { client, config, tokenStore, configSource: tokenOnly ? null : loaded.source, tokenOnly };
59
+ }
60
+
61
+ function isTruthyEnv(value) {
62
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
63
+ }
64
+
65
+ function isTokenOnlyMode(config) {
66
+ return Boolean(process.env.ZEYOS_TOKEN) || (isTruthyEnv(process.env.ZEYOS_NO_REFRESH) && Boolean(config.accessToken));
49
67
  }
50
68
 
51
69
  /**
package/lib/command.mjs CHANGED
@@ -99,6 +99,134 @@ export function parseJsonOptionOrFile(values, flagName, fileFlagName = `${flagNa
99
99
  return undefined;
100
100
  }
101
101
 
102
+ const FILTER_OPERATOR_ALIASES = {
103
+ $lt: '<',
104
+ $lte: '<=',
105
+ $gt: '>',
106
+ $gte: '>=',
107
+ $ne: '!=',
108
+ $in: 'IN',
109
+ $nin: '!IN',
110
+ $notIn: '!IN',
111
+ lt: '<',
112
+ lte: '<=',
113
+ gt: '>',
114
+ gte: '>=',
115
+ ne: '!=',
116
+ in: 'IN',
117
+ nin: '!IN',
118
+ notIn: '!IN',
119
+ notin: '!IN'
120
+ };
121
+
122
+ const FILTER_SUFFIX_OPERATOR_ALIASES = {
123
+ lt: '<',
124
+ lte: '<=',
125
+ gt: '>',
126
+ gte: '>=',
127
+ ne: '!=',
128
+ in: 'IN',
129
+ nin: '!IN',
130
+ notin: '!IN'
131
+ };
132
+
133
+ const FILTER_PATTERN_SUFFIXES = new Set([
134
+ 'startswith',
135
+ 'istartswith',
136
+ 'like',
137
+ 'ilike',
138
+ 'contains',
139
+ 'icontains',
140
+ 'regex',
141
+ 'iregex'
142
+ ]);
143
+
144
+ export function normalizeFilterOperators(value, options = {}) {
145
+ if (Array.isArray(value)) {
146
+ return value.map((item) => normalizeFilterOperators(item, options));
147
+ }
148
+ if (!value || typeof value !== 'object') return value;
149
+
150
+ const out = {};
151
+ for (const [key, child] of Object.entries(value)) {
152
+ const suffixFilter = parseFilterSuffix(key, child, options);
153
+ if (suffixFilter) {
154
+ mergeFieldFilter(out, suffixFilter.field, suffixFilter.operator, suffixFilter.value);
155
+ continue;
156
+ }
157
+
158
+ const normalizedKey = FILTER_OPERATOR_ALIASES[key] || key;
159
+ const outputKey = isFilterOperatorKey(normalizedKey)
160
+ ? normalizedKey
161
+ : normalizeFieldAlias(normalizedKey, options);
162
+ const normalizedChild = normalizeFilterOperators(child, options);
163
+ out[outputKey] = Array.isArray(normalizedChild) && !isFilterOperatorKey(outputKey)
164
+ ? { IN: normalizedChild }
165
+ : normalizedChild;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function isFilterOperatorKey(key) {
171
+ return ['<', '<=', '>', '>=', '!=', 'IN', '!IN', '~~*'].includes(key);
172
+ }
173
+
174
+ function normalizeFieldAlias(field, options = {}) {
175
+ return options.fieldAliases?.[field] || field;
176
+ }
177
+
178
+ function parseFilterSuffix(key, child, options) {
179
+ const separator = key.lastIndexOf('__');
180
+ if (separator <= 0) return null;
181
+
182
+ const rawField = key.slice(0, separator);
183
+ const suffix = key.slice(separator + 2).toLowerCase();
184
+ const field = normalizeFieldAlias(rawField, options);
185
+
186
+ if (Object.prototype.hasOwnProperty.call(FILTER_SUFFIX_OPERATOR_ALIASES, suffix)) {
187
+ return {
188
+ field,
189
+ operator: FILTER_SUFFIX_OPERATOR_ALIASES[suffix],
190
+ value: normalizeFilterOperators(child, options)
191
+ };
192
+ }
193
+
194
+ if (FILTER_PATTERN_SUFFIXES.has(suffix)) {
195
+ return {
196
+ field,
197
+ operator: '~~*',
198
+ value: patternValueForSuffix(suffix, child)
199
+ };
200
+ }
201
+
202
+ return null;
203
+ }
204
+
205
+ function patternValueForSuffix(suffix, child) {
206
+ const value = String(child ?? '');
207
+ if (suffix === 'startswith' || suffix === 'istartswith') return `${value}%`;
208
+ if (suffix === 'contains' || suffix === 'icontains') return `%${value}%`;
209
+ if (suffix === 'regex' || suffix === 'iregex') return regexLikeToSqlLike(value);
210
+ return value;
211
+ }
212
+
213
+ function regexLikeToSqlLike(value) {
214
+ return String(value)
215
+ .replace(/^\^/, '')
216
+ .replace(/\$$/, '')
217
+ .replace(/\\.\\*/g, '%')
218
+ .replace(/\.\*/g, '%');
219
+ }
220
+
221
+ function mergeFieldFilter(out, field, operator, value) {
222
+ const existing = out[field];
223
+ if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
224
+ out[field] = { ...existing, [operator]: value };
225
+ return;
226
+ }
227
+ out[field] = { [operator]: value };
228
+ }
229
+
102
230
  /** Cheap structural check: does this string look like an intended JSON object? */
103
231
  function looksLikeJsonObject(value) {
104
232
  return typeof value === 'string' && value.trim().startsWith('{');
package/lib/config.mjs CHANGED
@@ -60,6 +60,9 @@ export function loadConfig(opts = {}) {
60
60
  */
61
61
  export function loadConfigWithSource(opts = {}) {
62
62
  const env = _fromEnv();
63
+ if (env.accessToken) {
64
+ return { config: env, source: null, profile: null };
65
+ }
63
66
  const selection = resolveProfileSelection({ profileFlag: opts.profile });
64
67
 
65
68
  let base = {};
@@ -73,7 +73,7 @@ export function loadResourceConfig(name) {
73
73
  export function getListFields(res, name, override) {
74
74
  // 1. CLI override
75
75
  if (override) {
76
- return _parseFieldsOverride(override);
76
+ return _parseFieldsOverride(override, res?.fieldAliases);
77
77
  }
78
78
 
79
79
  // 2. Config file
@@ -181,7 +181,7 @@ export function getGetParams(name) {
181
181
  * Parse a --fields override string.
182
182
  * Supports: comma-separated, JSON object, JSON array.
183
183
  */
184
- function _parseFieldsOverride(raw) {
184
+ function _parseFieldsOverride(raw, fieldAliases = {}) {
185
185
  const trimmed = raw.trim();
186
186
 
187
187
  // JSON object: {"Alias": "path", ...}
@@ -189,7 +189,7 @@ function _parseFieldsOverride(raw) {
189
189
  try {
190
190
  const obj = JSON.parse(trimmed);
191
191
  if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
192
- const apiFields = _toFieldAliasMap(obj);
192
+ const apiFields = _toFieldAliasMap(obj, fieldAliases);
193
193
  return { apiFields, displayColumns: Object.keys(apiFields) };
194
194
  }
195
195
  } catch (e) {
@@ -205,7 +205,7 @@ function _parseFieldsOverride(raw) {
205
205
  if (Array.isArray(arr)) {
206
206
  const paths = arr.map(String);
207
207
  const apiFields = {};
208
- for (const p of paths) apiFields[p] = p;
208
+ for (const p of paths) apiFields[p] = normalizeFieldAlias(p, fieldAliases);
209
209
  return { apiFields, displayColumns: paths };
210
210
  }
211
211
  } catch (e) {
@@ -217,7 +217,7 @@ function _parseFieldsOverride(raw) {
217
217
  // Comma-separated: "ID,name,status"
218
218
  const paths = trimmed.split(',').map(s => s.trim()).filter(Boolean);
219
219
  const apiFields = {};
220
- for (const p of paths) apiFields[p] = p;
220
+ for (const p of paths) apiFields[p] = normalizeFieldAlias(p, fieldAliases);
221
221
  return { apiFields, displayColumns: paths };
222
222
  }
223
223
 
@@ -227,14 +227,18 @@ function _parseFieldsOverride(raw) {
227
227
  * @param {Record<string, JsonValue>} value
228
228
  * @returns {Record<string,string>}
229
229
  */
230
- function _toFieldAliasMap(value) {
230
+ function _toFieldAliasMap(value, fieldAliases = {}) {
231
231
  const fields = {};
232
232
  for (const [alias, field] of Object.entries(value)) {
233
- fields[String(alias)] = String(field);
233
+ fields[String(alias)] = normalizeFieldAlias(String(field), fieldAliases);
234
234
  }
235
235
  return fields;
236
236
  }
237
237
 
238
+ function normalizeFieldAlias(field, fieldAliases = {}) {
239
+ return fieldAliases[field] || field;
240
+ }
241
+
238
242
  // ── Internals ────────────────────────────────────────────────────────────────
239
243
 
240
244
  /**
package/lib/resources.mjs CHANGED
@@ -45,6 +45,8 @@ const REGISTRY = {
45
45
  update: 'updateAccount',
46
46
  delete: 'deleteAccount',
47
47
  fields: ['ID', 'customernum', 'lastname', 'firstname', 'type', 'assigneduser', 'lastmodified'],
48
+ fieldAliases: { name: 'lastname' },
49
+ filterAliases: { name: 'lastname' },
48
50
  },
49
51
  contact: {
50
52
  list: 'listContacts',
@@ -54,6 +56,14 @@ const REGISTRY = {
54
56
  delete: 'deleteContact',
55
57
  fields: ['ID', 'firstname', 'lastname', 'email', 'phone', 'account'],
56
58
  },
59
+ address: {
60
+ list: 'listAddresses',
61
+ get: 'getAddress',
62
+ create: 'createAddress',
63
+ update: 'updateAddress',
64
+ delete: 'deleteAddress',
65
+ fields: ['ID', 'account', 'contact', 'type', 'default'],
66
+ },
57
67
  project: {
58
68
  list: 'listProjects',
59
69
  get: 'getProject',
@@ -112,6 +122,11 @@ const REGISTRY = {
112
122
  get: 'getGroup',
113
123
  fields: ['ID', 'name', 'description'],
114
124
  },
125
+ groupuser: {
126
+ list: 'listGroupsToUsers',
127
+ get: 'getGroupToUser',
128
+ fields: ['ID', 'group', 'user'],
129
+ },
115
130
  event: {
116
131
  list: 'listEvents',
117
132
  get: 'getEvent',
@@ -152,6 +167,62 @@ const REGISTRY = {
152
167
  delete: 'deleteCampaign',
153
168
  fields: ['ID', 'name', 'status', 'startdate', 'enddate'],
154
169
  },
170
+ mailinglist: {
171
+ list: 'listMailingLists',
172
+ get: 'getMailingList',
173
+ create: 'createMailingList',
174
+ update: 'updateMailingList',
175
+ delete: 'deleteMailingList',
176
+ fields: ['ID', 'name', 'description', 'status', 'lastmodified'],
177
+ },
178
+ mailingrecipient: {
179
+ list: 'listMailingRecipients',
180
+ get: 'getMailingRecipient',
181
+ create: 'createMailingRecipient',
182
+ update: 'updateMailingRecipient',
183
+ delete: 'deleteMailingRecipient',
184
+ fields: ['ID', 'message', 'mailinglist', 'campaign', 'email', 'recipientuser', 'recipientgroup'],
185
+ },
186
+ dunning: {
187
+ list: 'listDunningNotices',
188
+ get: 'getDunningNotice',
189
+ create: 'createDunningNotice',
190
+ update: 'updateDunningNotice',
191
+ delete: 'deleteDunningNotice',
192
+ fields: ['ID', 'dunningnum', 'type', 'status', 'date', 'duedate', 'account', 'recipient', 'fee'],
193
+ },
194
+ dunningtransaction: {
195
+ list: 'listDunningToTransactions',
196
+ get: 'getDunningToTransaction',
197
+ create: 'createDunningToTransaction',
198
+ update: 'updateDunningToTransaction',
199
+ delete: 'deleteDunningToTransaction',
200
+ fields: ['ID', 'dunning', 'transaction'],
201
+ },
202
+ pricelist: {
203
+ list: 'listPriceLists',
204
+ get: 'getPriceList',
205
+ create: 'createPriceList',
206
+ update: 'updatePriceList',
207
+ delete: 'deletePriceList',
208
+ fields: ['ID', 'name', 'type', 'discount', 'allaccounts'],
209
+ },
210
+ pricelistaccount: {
211
+ list: 'listPriceListsToAccounts',
212
+ get: 'getPriceListToAccount',
213
+ create: 'createPriceListToAccount',
214
+ update: 'updatePriceListToAccount',
215
+ delete: 'deletePriceListToAccount',
216
+ fields: ['ID', 'pricelist', 'account'],
217
+ },
218
+ price: {
219
+ list: 'listPrices',
220
+ get: 'getPrice',
221
+ create: 'createPrice',
222
+ update: 'updatePrice',
223
+ delete: 'deletePrice',
224
+ fields: ['ID', 'pricelist', 'item', 'price', 'rebate', 'discount'],
225
+ },
155
226
  customfield: {
156
227
  list: 'listCustomFields',
157
228
  get: 'getCustomField',
@@ -198,6 +269,7 @@ const ALIASES = {
198
269
  tasks: 'task',
199
270
  accounts: 'account',
200
271
  contacts: 'contact',
272
+ addresses: 'address',
201
273
  projects: 'project',
202
274
  appointments: 'appointment',
203
275
  documents: 'document',
@@ -208,11 +280,46 @@ const ALIASES = {
208
280
  items: 'item',
209
281
  users: 'user',
210
282
  groups: 'group',
283
+ groupuser: 'groupuser',
284
+ groupusers: 'groupuser',
285
+ groups2user: 'groupuser',
286
+ groups2users: 'groupuser',
287
+ 'group-user': 'groupuser',
288
+ 'group-users': 'groupuser',
289
+ 'groups-to-user': 'groupuser',
290
+ 'groups-to-users': 'groupuser',
211
291
  events: 'event',
212
292
  transactions: 'transaction',
213
293
  payments: 'payment',
214
294
  opportunities:'opportunity',
215
295
  campaigns: 'campaign',
296
+ mailinglists: 'mailinglist',
297
+ 'mailing-list': 'mailinglist',
298
+ 'mailing-lists': 'mailinglist',
299
+ mailingrecipients: 'mailingrecipient',
300
+ 'mailing-recipient': 'mailingrecipient',
301
+ 'mailing-recipients': 'mailingrecipient',
302
+ dunnings: 'dunning',
303
+ 'dunning-notice': 'dunning',
304
+ 'dunning-notices': 'dunning',
305
+ dunningnotice: 'dunning',
306
+ dunningnotices: 'dunning',
307
+ dunning2transaction: 'dunningtransaction',
308
+ dunning2transactions: 'dunningtransaction',
309
+ 'dunning-transaction': 'dunningtransaction',
310
+ 'dunning-transactions': 'dunningtransaction',
311
+ 'dunning-to-transaction': 'dunningtransaction',
312
+ 'dunning-to-transactions': 'dunningtransaction',
313
+ pricelists: 'pricelist',
314
+ 'price-list': 'pricelist',
315
+ 'price-lists': 'pricelist',
316
+ pricelistaccount: 'pricelistaccount',
317
+ pricelistaccounts: 'pricelistaccount',
318
+ pricelists2account: 'pricelistaccount',
319
+ pricelists2accounts: 'pricelistaccount',
320
+ 'price-list-account': 'pricelistaccount',
321
+ 'price-list-accounts': 'pricelistaccount',
322
+ prices: 'price',
216
323
  customfields: 'customfield',
217
324
  custom_fields: 'customfield',
218
325
  'custom-fields': 'customfield',
package/lib/types.mjs CHANGED
@@ -18,7 +18,9 @@
18
18
  * update?: string,
19
19
  * delete?: string,
20
20
  * fields: string[],
21
- * idField?: string
21
+ * idField?: string,
22
+ * fieldAliases?: Record<string,string>,
23
+ * filterAliases?: Record<string,string>
22
24
  * }} ResourceDef
23
25
  */
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Command-line interface for the ZeyOS API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18.3"
40
40
  },
41
41
  "dependencies": {
42
- "@zeyos/client": "^0.5.0"
42
+ "@zeyos/client": "^0.6.0"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test test/offline.mjs"