@zeyos/client 0.1.1 → 0.3.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/agents/README.md +18 -6
  3. package/agents/shared/zeyos-agent-operating-guide.md +112 -0
  4. package/agents/shared/zeyos-query-patterns.md +12 -1
  5. package/agents/zeyos/SKILL.md +95 -0
  6. package/agents/zeyos-account-intelligence/SKILL.md +1 -1
  7. package/agents/zeyos-account-intelligence/references/workflows.md +7 -0
  8. package/agents/zeyos-billing-insights/SKILL.md +6 -1
  9. package/agents/zeyos-billing-insights/references/workflows.md +32 -3
  10. package/agents/zeyos-campaign-and-outreach/SKILL.md +1 -1
  11. package/agents/zeyos-campaign-and-outreach/references/workflows.md +8 -0
  12. package/agents/zeyos-collaboration-and-activity/SKILL.md +1 -1
  13. package/agents/zeyos-collaboration-and-activity/references/workflows.md +9 -0
  14. package/agents/zeyos-collections-and-dunning/SKILL.md +1 -1
  15. package/agents/zeyos-commerce-and-inventory/SKILL.md +1 -1
  16. package/agents/zeyos-commerce-and-inventory/references/workflows.md +7 -0
  17. package/agents/zeyos-mail-operations/SKILL.md +8 -2
  18. package/agents/zeyos-mail-operations/references/workflows.md +19 -2
  19. package/agents/zeyos-notes-and-sops/SKILL.md +1 -1
  20. package/agents/zeyos-platform-and-schema/SKILL.md +2 -2
  21. package/agents/zeyos-platform-and-schema/references/workflows.md +24 -0
  22. package/agents/zeyos-time-tracking/SKILL.md +48 -0
  23. package/agents/zeyos-time-tracking/references/workflows.md +230 -0
  24. package/agents/zeyos-work-management/SKILL.md +6 -3
  25. package/agents/zeyos-work-management/references/workflows.md +54 -4
  26. package/docs/02-javascript-client/03-making-requests.md +46 -1
  27. package/docs/03-cli/01-getting-started.md +7 -0
  28. package/docs/03-cli/02-commands.md +63 -1
  29. package/docs/03-cli/03-configuration.md +37 -5
  30. package/docs/04-agent-workflows/01-agent-quickstart.md +24 -0
  31. package/docs/04-agent-workflows/03-cli-coverage-and-escalation.md +3 -2
  32. package/package.json +6 -3
  33. package/src/runtime/client.js +226 -18
@@ -14,6 +14,7 @@ These options work with any command:
14
14
  |--------|-------------|
15
15
  | `--json` | Output as formatted JSON |
16
16
  | `--yaml` | Output as YAML |
17
+ | `--profile <name>` | Use a named credential profile for this command |
17
18
  | `--no-color` | Disable ANSI color output |
18
19
  | `-h`, `--help` | Show help for a command |
19
20
 
@@ -101,6 +102,36 @@ zeyos whoami --show-token --json # explicitly include the current access token
101
102
 
102
103
  ---
103
104
 
105
+ ## profile
106
+
107
+ Manage named credential profiles and switch between ZeyOS instances. See [Configuration → Profiles](./03-configuration.md#profiles) for the full model.
108
+
109
+ ```
110
+ zeyos profile <list|current|use|add|remove> [options]
111
+ ```
112
+
113
+ | Command | Description |
114
+ |---------|-------------|
115
+ | `profile list` | List all profiles; the active one is marked `*`, with token status |
116
+ | `profile current` | Show which profile resolves right now, and why (flag/env/pin/active) |
117
+ | `profile use <name>` | Make `<name>` the active profile (global) |
118
+ | `profile use <name> --local` | Pin `<name>` to the current project (`.zeyos/profile`) |
119
+ | `profile add <name> [opts]` | Create/update a profile (`--base-url`, `--client-id`, `--secret`, or `--from-current`) |
120
+ | `profile remove <name>` | Delete a profile |
121
+
122
+ **Examples:**
123
+
124
+ ```bash
125
+ zeyos profile add dev --base-url https://zeyos.cms-it.de/dev
126
+ zeyos profile add prod --from-current # snapshot current credentials
127
+ zeyos login --profile prod # authenticate into & activate a profile
128
+ zeyos profile use dev # switch active profile
129
+ zeyos profile use prod --local # pin to this project
130
+ zeyos list tickets --profile dev # one-off override on any command
131
+ ```
132
+
133
+ ---
134
+
104
135
  ## list
105
136
 
106
137
  Query and list records for a resource with filtering, sorting, and pagination.
@@ -113,6 +144,7 @@ zeyos list <resource> [options]
113
144
  |--------|-------------|
114
145
  | `--fields <fields>` | Field selection — comma-separated, JSON object, or JSON array (see below) |
115
146
  | `--filter <json>` | Filter criteria — JSON object |
147
+ | `--filter-file <path>` | Read filter criteria from a JSON file |
116
148
  | `--sort <fields>` | Sort fields, comma-separated (prefix `+` asc, `-` desc) |
117
149
  | `--limit <n>` | Maximum records to return (default: `50`) |
118
150
  | `--offset <n>` | Skip the first n records |
@@ -140,6 +172,9 @@ zeyos list tickets
140
172
  # Custom filters
141
173
  zeyos list tickets --filter '{"status":1,"priority":3}'
142
174
 
175
+ # Custom filters from a file
176
+ zeyos list tickets --filter-file ./filters/open-tickets.json
177
+
143
178
  # Specify fields with aliases
144
179
  zeyos list accounts --fields '{"Name":"lastname","City":"contact.city"}'
145
180
 
@@ -179,6 +214,7 @@ zeyos count <resource> [options]
179
214
  | Option | Description |
180
215
  |--------|-------------|
181
216
  | `--filter <json>` | Filter criteria — JSON object |
217
+ | `--filter-file <path>` | Read filter criteria from a JSON file |
182
218
  | `--json` | Output as `{"count": N}` |
183
219
  | `--yaml` | YAML output |
184
220
 
@@ -193,6 +229,9 @@ zeyos count tickets
193
229
  zeyos count tickets --filter '{"status":1}'
194
230
  # → 12
195
231
 
232
+ # Filtered count using a JSON file
233
+ zeyos count tickets --filter-file ./filters/open-tickets.json
234
+
196
235
  # JSON output for scripting
197
236
  zeyos count accounts --json
198
237
  # → {"count": 156}
@@ -261,6 +300,7 @@ zeyos create <resource> [--data <json>] [--field value ...]
261
300
  | Option | Description |
262
301
  |--------|-------------|
263
302
  | `--data <json>` | Complete record as a JSON string |
303
+ | `--data-file <path>` | Read the complete record from a JSON file |
264
304
  | `--<field> <value>` | Set individual field (any unknown flag becomes a field) |
265
305
 
266
306
  **Examples:**
@@ -269,6 +309,9 @@ zeyos create <resource> [--data <json>] [--field value ...]
269
309
  # Using --data JSON
270
310
  zeyos create ticket --data '{"name":"Fix login bug","status":0,"priority":3}'
271
311
 
312
+ # Using a JSON file
313
+ zeyos create ticket --data-file ./ticket.json
314
+
272
315
  # Using individual field flags
273
316
  zeyos create ticket --name "Fix login bug" --status 0 --priority 3
274
317
 
@@ -301,6 +344,9 @@ zeyos update <resource> <id> [--data <json>] [--field value ...]
301
344
  # Using --data JSON
302
345
  zeyos update ticket 42 --data '{"status":4}'
303
346
 
347
+ # Using a JSON file
348
+ zeyos update ticket 42 --data-file ./ticket-update.json
349
+
304
350
  # Using field flags
305
351
  zeyos update ticket 42 --status 4 --priority 2
306
352
 
@@ -348,7 +394,10 @@ List all curated CLI resources and their operations. This is the authoritative b
348
394
  zeyos resources
349
395
  ```
350
396
 
351
- Shows a table of all CLI-supported resource types and available operations.
397
+ Shows a table of all CLI-supported resource types and available operations. Operational
398
+ workflows can use `actionstep` / `actionsteps` / `time-entries` for follow-ups and
399
+ effort records. Read-only platform schema definitions are available as `customfield` /
400
+ `customfields` with `list`, `get`, and therefore `count` support.
352
401
 
353
402
  ---
354
403
 
@@ -368,6 +417,19 @@ Foreign keys are shown as `→ <table>`, and enum fields list their valid values
368
417
 
369
418
  ---
370
419
 
420
+ ## doctor
421
+
422
+ Check local CLI readiness for coding agents. This runs offline and never prints tokens or client secrets.
423
+
424
+ ```bash
425
+ zeyos doctor agent
426
+ zeyos doctor agent --json
427
+ ```
428
+
429
+ The report includes the CLI version, configured base URL and instance, whether auth values are present through environment/local/global config, and whether the curated resource registry can be loaded.
430
+
431
+ ---
432
+
371
433
  ## skills
372
434
 
373
435
  Discover and install the bundled ZeyOS agent skill packs into any coding agent, so the agent (Claude Code, Codex, opencode, Factory Droid, pi, …) operates against ZeyOS with the right conventions out of the box.
@@ -12,15 +12,47 @@ These settings apply to the CLI's curated resource registry. If you need a resou
12
12
 
13
13
  ## Credential Cascade
14
14
 
15
- Credentials are resolved from three sources in priority order:
15
+ The CLI first decides **which credential set** is the base, then lets environment credential variables field-override on top. The base is chosen by the first match in this order:
16
16
 
17
17
  | Priority | Source | Location |
18
18
  |----------|--------|----------|
19
- | 1 (highest) | Environment variables | `ZEYOS_BASE_URL`, `ZEYOS_TOKEN`, etc. |
20
- | 2 | Local config file | `.zeyos/auth.json` (walks up from CWD) |
21
- | 3 (lowest) | Global config file | `~/.config/zeyos/credentials.json` |
19
+ | 1 (highest) | `--profile <name>` flag | named profile (per command) |
20
+ | 2 | `ZEYOS_PROFILE` env var | named profile |
21
+ | 3 | Project pin | `.zeyos/profile` (walks up from CWD) |
22
+ | 4 | Local config file | `.zeyos/auth.json` (walks up from CWD) |
23
+ | 5 | Global active profile | `~/.config/zeyos/profiles.json` (`active`) |
24
+ | 6 (lowest) | Global config file | `~/.config/zeyos/credentials.json` |
22
25
 
23
- Configuration is merged from global, then local, then environment variables. Higher-priority sources override individual fields from lower-priority sources. For example, setting `ZEYOS_TOKEN` as an environment variable overrides the stored token while still allowing `baseUrl` and client credentials to come from a config file.
26
+ On top of the chosen base, the credential **environment variables** (`ZEYOS_BASE_URL`, `ZEYOS_TOKEN`, …) always override individual fields so you can keep `baseUrl` and client credentials in a profile while overriding just the token via `ZEYOS_TOKEN` in CI.
27
+
28
+ If you have never created a profile, nothing changes: the CLI falls through to the legacy local `.zeyos/auth.json` and global `credentials.json` exactly as before.
29
+
30
+ ## Profiles
31
+
32
+ Profiles let you store several ZeyOS instances (e.g. `dev`, `prod`, `client-x`) and switch between them without re-running `login`. Each profile is a full credential set (URL, OAuth app, tokens) kept in `~/.config/zeyos/profiles.json`, with one marked **active**.
33
+
34
+ ```bash
35
+ # Create profiles (connection params now; tokens via login)
36
+ zeyos profile add dev --base-url https://zeyos.cms-it.de/dev
37
+ zeyos profile add prod --base-url https://cloud.zeyos.com/acme --client-id app --secret "$SECRET"
38
+
39
+ # Authenticate into a profile (also makes it active)
40
+ zeyos login --profile prod
41
+
42
+ # See and switch profiles
43
+ zeyos profile list # active marked with *, shows token status
44
+ zeyos profile use dev # switch the global active profile
45
+ zeyos profile current # what resolves right now, and why
46
+
47
+ # Per-project: pin a profile so cd-ing into a repo selects its instance
48
+ cd ~/work/acme && zeyos profile use prod --local # writes ./.zeyos/profile
49
+
50
+ # One-off override on any command (beats env, pin, and active)
51
+ zeyos whoami --profile dev
52
+ zeyos list tickets --profile prod
53
+ ```
54
+
55
+ `zeyos profile add <name> --from-current` snapshots whatever credentials are in effect (including tokens) into a new profile — handy for adopting an existing `.zeyos/auth.json` setup. Add `.zeyos/profile` to `.gitignore` alongside `.zeyos/auth.json`.
24
56
 
25
57
  ## Environment Variables
26
58
 
@@ -107,6 +107,28 @@ Count matching records:
107
107
 
108
108
  ```bash
109
109
  zeyos count tickets --filter '{"visibility":0,"status":4}' --json
110
+ zeyos count customfields --json
111
+ zeyos count actionsteps --filter '{"status":0}' --json
112
+ ```
113
+
114
+ Summarize actionstep effort/time-entry evidence:
115
+
116
+ ```bash
117
+ zeyos list actionsteps \
118
+ --fields ID,name,status,date,duedate,effort,ticket,task,account \
119
+ --filter '{"status":3}' \
120
+ --limit 100 \
121
+ --json
122
+ ```
123
+
124
+ Inspect ticket-linked mail without sending anything:
125
+
126
+ ```bash
127
+ zeyos list messages \
128
+ --fields ID,date,mailbox,subject,sender_email,to_email,ticket,reference,messageid \
129
+ --filter '{"ticket":42}' \
130
+ --sort +date \
131
+ --json
110
132
  ```
111
133
 
112
134
  ## Write Data
@@ -145,4 +167,6 @@ zeyos delete ticket 42 --force
145
167
  - Include `visibility: 0` in filters for normal business views.
146
168
  - Prefer `--data '<json>'` over many separate flags in automation.
147
169
  - Run `zeyos resources` before assuming a resource is CLI-supported.
170
+ - Use `actionsteps.effort` for time-entry totals; do not infer booked time from task assignment.
171
+ - Draft e-mail text in the response unless the user explicitly asks for a real ZeyOS draft record; never send mail from an agent workflow.
148
172
  - Escalate to [`@zeyos/client`](./03-cli-coverage-and-escalation.md) when the CLI resource registry is not enough.
@@ -13,10 +13,11 @@ The command `zeyos resources` is the source of truth for CLI-supported resource
13
13
 
14
14
  | Resource | Operations |
15
15
  |----------|------------|
16
- | `account`, `appointment`, `campaign`, `contact`, `document`, `event`, `file`, `invitation`, `item`, `message`, `note`, `opportunity`, `payment`, `project`, `storage`, `task`, `ticket`, `transaction` | `list`, `get`, `create`, `update`, `delete` |
16
+ | `account`, `actionstep`, `appointment`, `campaign`, `contact`, `document`, `event`, `file`, `invitation`, `item`, `message`, `note`, `opportunity`, `payment`, `project`, `storage`, `task`, `ticket`, `transaction` | `list`, `get`, `create`, `update`, `delete` |
17
+ | `customfield` / `customfields` | `list`, `get` |
17
18
  | `group`, `user` | `list`, `get` |
18
19
 
19
- Plural names and common aliases such as `tickets`, `docs`, `invoice`, and `crm` are resolved by the CLI, but the underlying coverage boundary is still the registry above.
20
+ Plural names and common aliases such as `tickets`, `actionsteps`, `time-entries`, `docs`, `invoice`, and `crm` are resolved by the CLI, but the underlying coverage boundary is still the registry above.
20
21
 
21
22
  ## What the CLI Does Not Try to Cover
22
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/client",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Dependency-light JavaScript client for ZeyOS OpenAPI services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,9 @@
31
31
  "scripts/generate-client.mjs",
32
32
  "openapi",
33
33
  "docs",
34
- "samples",
34
+ "samples/crm",
35
+ "samples/dashboard",
36
+ "samples/kanban",
35
37
  "agents",
36
38
  "README.md",
37
39
  "CHANGELOG.md",
@@ -44,6 +46,7 @@
44
46
  "generate": "node scripts/generate-client.mjs",
45
47
  "test": "node scripts/test.mjs",
46
48
  "test:cli-integration": "node --test cli/test/integration.mjs",
47
- "test:agent-protocol": "node test/agent-protocol/harness/run.mjs"
49
+ "test:agent-protocol": "node test/agent-protocol/harness/run.mjs",
50
+ "test:agent-loop": "node test/agent-protocol/harness/loop.mjs"
48
51
  }
49
52
  }
@@ -56,8 +56,14 @@ function abortableDelay(ms, signal) {
56
56
  });
57
57
  }
58
58
 
59
- // Honor a Retry-After header (seconds or HTTP-date), else exponential backoff
60
- // with jitter, capped at maxDelayMs.
59
+ // Exponential backoff with jitter, capped at maxDelayMs.
60
+ function backoffDelay(attempt, retryConfig) {
61
+ const exp = retryConfig.baseDelayMs * Math.pow(2, attempt);
62
+ const jitter = Math.random() * retryConfig.baseDelayMs;
63
+ return Math.min(retryConfig.maxDelayMs, exp + jitter);
64
+ }
65
+
66
+ // Honor a Retry-After header (seconds or HTTP-date), else exponential backoff.
61
67
  function computeRetryDelay(response, attempt, retryConfig) {
62
68
  const header = response.headers?.['retry-after'];
63
69
  // An empty or whitespace-only header carries no delay directive — `Number('')`
@@ -74,9 +80,88 @@ function computeRetryDelay(response, attempt, retryConfig) {
74
80
  return Math.min(retryConfig.maxDelayMs, Math.max(0, dateMs - Date.now()));
75
81
  }
76
82
  }
77
- const exp = retryConfig.baseDelayMs * Math.pow(2, attempt);
78
- const jitter = Math.random() * retryConfig.baseDelayMs;
79
- return Math.min(retryConfig.maxDelayMs, exp + jitter);
83
+ return backoffDelay(attempt, retryConfig);
84
+ }
85
+
86
+ // Operations that are safe to transparently retry on a network error / timeout:
87
+ // HTTP GET/HEAD, plus ZeyOS read queries (list/count/search) which are POST but
88
+ // side-effect-free. Writes (create/update/delete) are never auto-retried, so a
89
+ // dropped connection can't cause a duplicate mutation.
90
+ function isReadOperation(operation) {
91
+ const method = operation?.method;
92
+ if (method === 'GET' || method === 'HEAD') {
93
+ return true;
94
+ }
95
+ return /^(list|count|search|exists|get)/i.test(operation?.operationId || '');
96
+ }
97
+
98
+ // Build a one-line, human-readable summary from a server error body so the thrown
99
+ // error message says *why* (e.g. an unknown-filter 400), not just the status code.
100
+ // The full body remains available on error.body.
101
+ function summarizeErrorBody(body, maxLength = 200) {
102
+ let text = '';
103
+ if (typeof body === 'string') {
104
+ text = body.trim();
105
+ } else if (body && typeof body === 'object') {
106
+ const candidate =
107
+ body.message ?? body.error_description ?? body.error ?? body.detail ?? body.title;
108
+ if (typeof candidate === 'string' && candidate.trim()) {
109
+ text = candidate.trim();
110
+ } else {
111
+ try {
112
+ text = JSON.stringify(body);
113
+ } catch {
114
+ text = '';
115
+ }
116
+ }
117
+ }
118
+ if (!text) {
119
+ return '';
120
+ }
121
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
122
+ }
123
+
124
+ // Run fetch with an optional per-attempt timeout, composed with the caller's
125
+ // AbortSignal (Node 18 compatible — no AbortSignal.any). A timeout aborts the
126
+ // internal controller only, so `externalSignal.aborted` stays false and the
127
+ // caller can distinguish a timeout (retryable for reads) from a user abort.
128
+ async function fetchWithTimeout(httpRequestImpl, requestArgs, externalSignal, timeoutMs) {
129
+ if (!(timeoutMs > 0)) {
130
+ return httpRequestImpl({ ...requestArgs, signal: externalSignal });
131
+ }
132
+
133
+ const controller = new AbortController();
134
+ let timedOut = false;
135
+ const onExternalAbort = () => controller.abort(externalSignal.reason);
136
+
137
+ if (externalSignal) {
138
+ if (externalSignal.aborted) {
139
+ controller.abort(externalSignal.reason);
140
+ } else {
141
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
142
+ }
143
+ }
144
+
145
+ const timer = setTimeout(() => {
146
+ timedOut = true;
147
+ controller.abort();
148
+ }, timeoutMs);
149
+
150
+ try {
151
+ return await httpRequestImpl({ ...requestArgs, signal: controller.signal });
152
+ } catch (error) {
153
+ if (timedOut) {
154
+ const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
155
+ timeoutError.name = 'TimeoutError';
156
+ timeoutError.code = 'ETIMEDOUT';
157
+ timeoutError.isTimeout = true;
158
+ throw timeoutError;
159
+ }
160
+ throw error;
161
+ } finally {
162
+ clearTimeout(timer);
163
+ externalSignal?.removeEventListener?.('abort', onExternalAbort);
164
+ }
80
165
  }
81
166
 
82
167
  const AUTH_SCHEME_MAP = Object.freeze({
@@ -390,7 +475,8 @@ function chooseBodyType(serviceKey, operation, prepared, fallbackBodyType) {
390
475
 
391
476
  function createApiError(response, { serviceKey, operation, method, url }) {
392
477
  const operationDescription = operation.operationId ? `${serviceKey}.${operation.operationId}` : `${serviceKey} request`;
393
- const message = `${operationDescription} failed with HTTP ${response.status}`;
478
+ const detail = summarizeErrorBody(response.data);
479
+ const message = `${operationDescription} failed with HTTP ${response.status}${detail ? `: ${detail}` : ''}`;
394
480
 
395
481
  return new ZeyosApiError(message, {
396
482
  status: response.status,
@@ -606,6 +692,10 @@ export function createZeyosClient(rawConfig = {}) {
606
692
 
607
693
  const defaultHeaders = isObject(config.headers) ? config.headers : {};
608
694
  const retryConfig = normalizeRetry(config.retry);
695
+ const defaultTimeoutMs = Number(config.timeoutMs) > 0 ? Number(config.timeoutMs) : 0;
696
+ // Whether to transparently retry network errors / timeouts. `undefined` (default)
697
+ // means "auto": retry only read operations. `true`/`false` force the behavior.
698
+ const defaultRetryOnNetworkError = typeof config.retryOnNetworkError === 'boolean' ? config.retryOnNetworkError : undefined;
609
699
  const schemaApi = createSchema({ services: SERVICES, schema: SCHEMA });
610
700
  const validateByDefault = config.validate === true;
611
701
  const operationLookup = new Map();
@@ -616,6 +706,10 @@ export function createZeyosClient(rawConfig = {}) {
616
706
  }
617
707
  }
618
708
 
709
+ // Single-flight token refresh: shared across all concurrent operations so an
710
+ // expired token triggers exactly one getToken call (see refreshAccessTokenOnce).
711
+ let refreshInFlight = null;
712
+
619
713
  async function getTokenSet() {
620
714
  return normalizeTokenSet(await tokenStore.get());
621
715
  }
@@ -644,6 +738,15 @@ export function createZeyosClient(rawConfig = {}) {
644
738
  return `ZEYOSID=${cookieValue}`;
645
739
  }
646
740
 
741
+ // Resolve whether a network error / timeout may be retried for this call.
742
+ // Explicit per-request / client config wins; otherwise auto = read ops only.
743
+ function resolveNetworkRetry(operation, requestOptions) {
744
+ const explicit = requestOptions?.retryOnNetworkError ?? defaultRetryOnNetworkError;
745
+ if (explicit === true) return true;
746
+ if (explicit === false) return false;
747
+ return isReadOperation(operation);
748
+ }
749
+
647
750
  async function sendRequestOnce({ serviceKey, operation, prepared, requestAuth, tokenSet, candidate, requestOptions }) {
648
751
  const body = cloneValue(prepared.body);
649
752
  const authHeaders = {};
@@ -698,19 +801,27 @@ export function createZeyosClient(rawConfig = {}) {
698
801
  );
699
802
 
700
803
  const signal = prepared.signal ?? requestOptions?.signal;
804
+ const timeoutMs = Number(requestOptions?.timeoutMs ?? defaultTimeoutMs) || 0;
805
+ const networkRetryAllowed = resolveNetworkRetry(operation, requestOptions);
806
+ const requestArgs = { fetchImpl, url, method: operation.method, headers, body, bodyType, credentials };
701
807
 
702
808
  let response;
703
809
  for (let attempt = 0; ; attempt++) {
704
- response = await httpRequest({
705
- fetchImpl,
706
- url,
707
- method: operation.method,
708
- headers,
709
- body,
710
- bodyType,
711
- signal,
712
- credentials
713
- });
810
+ try {
811
+ response = await fetchWithTimeout(httpRequest, requestArgs, signal, timeoutMs);
812
+ } catch (error) {
813
+ // A caller-initiated abort must never be retried — propagate immediately.
814
+ // (A timeout aborts only the internal controller, so signal.aborted is false.)
815
+ if (signal?.aborted) {
816
+ throw error;
817
+ }
818
+ // Network error or timeout: retry only safe (read) operations, within budget.
819
+ if (!networkRetryAllowed || attempt >= retryConfig.maxRetries) {
820
+ throw error;
821
+ }
822
+ await abortableDelay(backoffDelay(attempt, retryConfig), signal);
823
+ continue;
824
+ }
714
825
 
715
826
  if (attempt >= retryConfig.maxRetries || !retryConfig.retryOn.has(response.status)) {
716
827
  break;
@@ -787,7 +898,50 @@ export function createZeyosClient(rawConfig = {}) {
787
898
  return nextTokenSet;
788
899
  }
789
900
 
901
+ // Single-flight wrapper: when several operations notice an expired token at the
902
+ // same time (e.g. `Promise.all([...])`), they must share ONE refresh. Without
903
+ // this each fires its own getToken — redundant load, and a hard failure when the
904
+ // server rotates refresh tokens (all but the first present a stale refresh token).
905
+ function refreshAccessTokenOnce(currentTokenSet, requestAuth, requestOptions) {
906
+ if (refreshInFlight) {
907
+ return refreshInFlight;
908
+ }
909
+ refreshInFlight = Promise.resolve()
910
+ .then(() => refreshAccessToken(currentTokenSet, requestAuth, requestOptions))
911
+ .finally(() => {
912
+ refreshInFlight = null;
913
+ });
914
+ return refreshInFlight;
915
+ }
916
+
790
917
  async function executeOperation({ serviceKey, operation, prepared, requestOptions = {} }) {
918
+ // Dry run: resolve the route + payload exactly as they would be sent, but
919
+ // return that descriptor instead of performing any network request or token
920
+ // work. Powers `zeyos … --query` and is handy for debugging/testing.
921
+ if (requestOptions.dryRun || prepared.dryRun) {
922
+ const baseUrl = resolveBaseUrl({
923
+ services: SERVICES,
924
+ serviceKey,
925
+ config,
926
+ explicitBaseUrl: prepared.baseUrl ?? requestOptions.baseUrl
927
+ });
928
+ const url = buildUrl(baseUrl, operation.path, prepared.pathParams, prepared.query);
929
+ const bodyType = chooseBodyType(serviceKey, operation, prepared, requestOptions?.bodyType);
930
+ return {
931
+ dryRun: true,
932
+ service: serviceKey,
933
+ operationId: operation.operationId,
934
+ method: operation.method,
935
+ url,
936
+ path: operation.path,
937
+ pathParams: prepared.pathParams,
938
+ query: prepared.query,
939
+ headers: prepared.headers,
940
+ body: prepared.body,
941
+ bodyType
942
+ };
943
+ }
944
+
791
945
  const requestAuth = normalizeRequestAuth(prepared.auth ?? requestOptions.auth);
792
946
  const mode = normalizeAuthMode(requestAuth.mode, defaultMode);
793
947
  const schemes = securitySchemesFromOperation(operation);
@@ -799,7 +953,7 @@ export function createZeyosClient(rawConfig = {}) {
799
953
  canRefreshAccessToken({ mode, operation, tokenSet, oauthConfig })
800
954
  ) {
801
955
  try {
802
- const refreshed = await refreshAccessToken(tokenSet, requestAuth, requestOptions);
956
+ const refreshed = await refreshAccessTokenOnce(tokenSet, requestAuth, requestOptions);
803
957
  if (refreshed?.accessToken) {
804
958
  tokenSet = refreshed;
805
959
  }
@@ -838,7 +992,7 @@ export function createZeyosClient(rawConfig = {}) {
838
992
 
839
993
  if (candidate.type === 'bearer' && canRefreshAccessToken({ mode, operation, tokenSet, oauthConfig })) {
840
994
  try {
841
- const refreshed = await refreshAccessToken(tokenSet, requestAuth, requestOptions);
995
+ const refreshed = await refreshAccessTokenOnce(tokenSet, requestAuth, requestOptions);
842
996
  if (refreshed?.accessToken) {
843
997
  tokenSet = refreshed;
844
998
  const retryResponse = await sendRequestOnce({
@@ -990,6 +1144,58 @@ export function createZeyosClient(rawConfig = {}) {
990
1144
  const oauth2Operations = bindService('oauth2');
991
1145
  const legacyAuth = bindService('legacyAuth');
992
1146
 
1147
+ // Async-iterate every record from a list operation, paging by `offset` until a
1148
+ // short/empty page (or `opts.max`) is reached — removing the manual offset
1149
+ // bookkeeping the 1000/10000 list caps otherwise force on callers.
1150
+ //
1151
+ // for await (const ticket of client.paginate('listTickets', { filters: { visibility: 0 } })) { … }
1152
+ //
1153
+ async function* paginate(operationId, input = {}, opts = {}) {
1154
+ const op = operationLookup.get(`api.${operationId}`);
1155
+ if (!op) {
1156
+ const candidates = (SERVICES.api?.operations ?? []).map((entry) => entry.operationId);
1157
+ const suggestion = suggestClosest(operationId, candidates);
1158
+ throw new ZeyosApiError(
1159
+ `Unknown list operation: api.${operationId}.` + (suggestion ? ` Did you mean '${suggestion}'?` : ''),
1160
+ { operationId, service: 'api' }
1161
+ );
1162
+ }
1163
+
1164
+ const requested = Number(opts.pageSize) > 0 ? Number(opts.pageSize)
1165
+ : Number(input.limit) > 0 ? Number(input.limit) : 1000;
1166
+ // Clamp to the server's max page size: a larger request would be silently
1167
+ // capped server-side, making the short-page terminator stop early and drop rows.
1168
+ const pageSize = Math.min(requested, 10000);
1169
+ const max = Number(opts.max) > 0 ? Number(opts.max) : Infinity;
1170
+ let offset = Number(input.offset) > 0 ? Number(input.offset) : 0;
1171
+ let yielded = 0;
1172
+
1173
+ for (;;) {
1174
+ const page = await api[operationId]({ ...input, limit: pageSize, offset }, opts.requestOptions);
1175
+ const rows = Array.isArray(page) ? page : Array.isArray(page?.data) ? page.data : [];
1176
+ for (const row of rows) {
1177
+ yield row;
1178
+ yielded += 1;
1179
+ if (yielded >= max) {
1180
+ return;
1181
+ }
1182
+ }
1183
+ if (rows.length < pageSize) {
1184
+ return; // last (short or empty) page
1185
+ }
1186
+ offset += pageSize;
1187
+ }
1188
+ }
1189
+
1190
+ // Eager convenience: collect up to `opts.max` records into an array.
1191
+ async function collect(operationId, input = {}, opts = {}) {
1192
+ const out = [];
1193
+ for await (const row of paginate(operationId, input, opts)) {
1194
+ out.push(row);
1195
+ }
1196
+ return out;
1197
+ }
1198
+
993
1199
  function buildAuthorizationUrl(options = {}) {
994
1200
  const clientId = options.clientId ?? options.client_id ?? oauthConfig.clientId;
995
1201
  const redirectUri = options.redirectUri ?? options.redirect_uri;
@@ -1192,6 +1398,8 @@ export function createZeyosClient(rawConfig = {}) {
1192
1398
  oauth2,
1193
1399
  legacyAuth,
1194
1400
  request,
1401
+ paginate,
1402
+ collect,
1195
1403
  schema: schemaApi,
1196
1404
  auth: {
1197
1405
  getTokenSet,