@xano/cli 1.0.6-beta.2 → 1.0.6-beta.20

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 (112) hide show
  1. package/README.md +217 -6
  2. package/dist/base-command.d.ts +5 -0
  3. package/dist/base-command.js +61 -4
  4. package/dist/commands/branch/list/index.js +5 -1
  5. package/dist/commands/ephemeral/create/index.js +3 -3
  6. package/dist/commands/ephemeral/list/index.js +2 -1
  7. package/dist/commands/ephemeral/pull/index.d.ts +0 -6
  8. package/dist/commands/ephemeral/pull/index.js +10 -95
  9. package/dist/commands/ephemeral/push/index.d.ts +10 -0
  10. package/dist/commands/ephemeral/push/index.js +77 -0
  11. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  12. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  13. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  14. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  15. package/dist/commands/flatten/index.d.ts +34 -0
  16. package/dist/commands/flatten/index.js +140 -0
  17. package/dist/commands/function/list/index.d.ts +4 -4
  18. package/dist/commands/function/list/index.js +30 -38
  19. package/dist/commands/function/run/index.d.ts +1 -0
  20. package/dist/commands/function/run/index.js +19 -5
  21. package/dist/commands/knowledge/list/index.js +2 -1
  22. package/dist/commands/platform/list/index.js +13 -14
  23. package/dist/commands/release/list/index.js +13 -14
  24. package/dist/commands/release/pull/index.d.ts +0 -6
  25. package/dist/commands/release/pull/index.js +10 -95
  26. package/dist/commands/sandbox/env/list/index.js +4 -0
  27. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  28. package/dist/commands/sandbox/pull/index.js +10 -77
  29. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  30. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  31. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  32. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  33. package/dist/commands/sandbox/unit_test/run_all/index.js +76 -38
  34. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  35. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  36. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  37. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  38. package/dist/commands/sandbox/workflow_test/run_all/index.js +74 -36
  39. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  40. package/dist/commands/static_host/build/list/index.js +19 -40
  41. package/dist/commands/static_host/build/pull/index.js +12 -9
  42. package/dist/commands/static_host/list/index.d.ts +2 -3
  43. package/dist/commands/static_host/list/index.js +20 -40
  44. package/dist/commands/static_host/migrate/index.js +14 -10
  45. package/dist/commands/tenant/backup/list/index.js +13 -14
  46. package/dist/commands/tenant/cluster/list/index.js +9 -10
  47. package/dist/commands/tenant/create/index.d.ts +3 -0
  48. package/dist/commands/tenant/create/index.js +21 -0
  49. package/dist/commands/tenant/deploy_release/index.d.ts +21 -0
  50. package/dist/commands/tenant/deploy_release/index.js +94 -2
  51. package/dist/commands/tenant/edit/index.d.ts +2 -0
  52. package/dist/commands/tenant/edit/index.js +39 -4
  53. package/dist/commands/tenant/env/list/index.js +4 -0
  54. package/dist/commands/tenant/list/index.js +19 -20
  55. package/dist/commands/tenant/pull/index.d.ts +0 -6
  56. package/dist/commands/tenant/pull/index.js +10 -106
  57. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  58. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  59. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  60. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  61. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  62. package/dist/commands/tenant/unit_test/run_all/index.js +80 -38
  63. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  64. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  65. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  66. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  67. package/dist/commands/tenant/workflow_test/run_all/index.js +78 -36
  68. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +20 -0
  69. package/dist/commands/tenant_deploy_request/bypass/index.js +79 -0
  70. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  71. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  72. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  73. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  74. package/dist/commands/tenant_deploy_request/get/index.d.ts +19 -0
  75. package/dist/commands/tenant_deploy_request/get/index.js +75 -0
  76. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  77. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  78. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  79. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  80. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +21 -0
  81. package/dist/commands/tenant_deploy_request/set_status/index.js +112 -0
  82. package/dist/commands/unit_test/list/index.d.ts +3 -1
  83. package/dist/commands/unit_test/list/index.js +9 -14
  84. package/dist/commands/unit_test/run/index.js +19 -11
  85. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  86. package/dist/commands/unit_test/run_all/index.js +81 -41
  87. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  88. package/dist/commands/workflow_test/list/index.js +10 -13
  89. package/dist/commands/workflow_test/run/index.js +15 -10
  90. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  91. package/dist/commands/workflow_test/run_all/index.js +82 -39
  92. package/dist/commands/workspace/git/pull/index.js +48 -7
  93. package/dist/commands/workspace/list/index.js +7 -13
  94. package/dist/commands/workspace/pull/index.d.ts +0 -6
  95. package/dist/commands/workspace/pull/index.js +10 -115
  96. package/dist/utils/concurrency.d.ts +29 -0
  97. package/dist/utils/concurrency.js +54 -0
  98. package/dist/utils/document-parser.d.ts +90 -0
  99. package/dist/utils/document-parser.js +273 -1
  100. package/dist/utils/flatten.d.ts +36 -0
  101. package/dist/utils/flatten.js +68 -0
  102. package/dist/utils/knowledge-sync.d.ts +1 -0
  103. package/dist/utils/knowledge-sync.js +11 -5
  104. package/dist/utils/microservice-wait.d.ts +53 -0
  105. package/dist/utils/microservice-wait.js +83 -0
  106. package/dist/utils/multidoc-push.d.ts +88 -0
  107. package/dist/utils/multidoc-push.js +377 -8
  108. package/dist/utils/paging.d.ts +145 -0
  109. package/dist/utils/paging.js +199 -0
  110. package/dist/utils/reference-checker.js +13 -0
  111. package/oclif.manifest.json +4999 -3973
  112. package/package.json +1 -1
package/README.md CHANGED
@@ -39,6 +39,38 @@ Destructive commands include imperative safety prefixes in their help text and f
39
39
 
40
40
  These warnings are layer 1 of broader push-safety work; ephemeral sandbox environments and push preview remain the structural safeguards.
41
41
 
42
+ ### Paging on list commands
43
+
44
+ The Metadata API pages unevenly, so `list` commands differ in what they expose. Every command reports only what its endpoint actually returns — the CLI never guesses whether more results exist.
45
+
46
+ | Commands | Flags | Footer |
47
+ |---|---|---|
48
+ | `function list`, `unit_test list`, `workflow_test list` (plus `sandbox` / `tenant` variants) | `--page`, `--per_page` | `Page 2 · 50 shown · next: --page 3` |
49
+ | `static_host list`, `static_host build list` (plus `ephemeral` variants) | `--page` only | `Page 2 · 100 shown · 340 total` |
50
+ | `branch list`, `workspace list`, `tenant snapshot list`, `sandbox env list`, `tenant env list` | none | `12 branches` |
51
+ | `tenant list`, `release list`, `platform list`, `tenant cluster list`, `ephemeral list` | none | none |
52
+ | `tenant backup list` | `--page` only (pre-existing) | none |
53
+
54
+ Notes:
55
+
56
+ - **`--per_page` is only offered where the endpoint accepts it.** Static-host endpoints hardcode 100 items per page server-side, so those commands take `--page` alone. `xano static_host list --per_page 10` previously parsed but did nothing; it is now rejected rather than silently ignored.
57
+ - **All paged commands default `--per_page` to 50.** The test-list commands previously requested 10000 internally so nothing was ever cut off; now that the footer and the JSON envelope both report position, a page that stops at 50 is visible rather than silent. Raise it (up to 10000) when you want everything in one call.
58
+ - **The last group has no CLI paging.** Those endpoints page server-side but return a plain array with no page or total metadata, so the CLI cannot tell you where you are. Rather than show a page number with no context — or infer "more available" from a full page, which is wrong exactly when the result count is a multiple of the page size — these commands are left as-is. **They return at most 25–50 items** (50 for tenants and ephemerals, 25 for releases, platforms, and tenant backups). If you have more than that, query the Metadata API directly until those endpoints return paging metadata.
59
+ - **`--output json` returns an envelope, not a bare array.** Every list command emits `{items, ...}` using the Metadata API's own field names — `curPage`, `nextPage`, `prevPage`, `itemsTotal` — each included only when the endpoint actually reports it. `perPage` is the one field the CLI adds, since the API never echoes it back and it is otherwise invisible at its default. This is a **breaking change** for scripts that parsed the previous bare array — read `.items` instead.
60
+
61
+ ```jsonc
62
+ // xano function list -o json
63
+ {
64
+ "curPage": 2,
65
+ "perPage": 50, // injected by the CLI; everything else mirrors the API
66
+ "nextPage": 3, // absent on the last page — never inferred
67
+ "prevPage": 1,
68
+ "items": [ /* ... */ ]
69
+ }
70
+ ```
71
+
72
+ The point of the envelope is that a script gets the same honest stop condition a human gets from the footer, in the same shape the raw Metadata API returns. Without it the only signal available is `items.length < perPage`, which is wrong exactly when the result count is a multiple of the page size. Absence of `nextPage` means the server said there is no next page; it is never omitted as a guess.
73
+
42
74
  ### Authentication
43
75
 
44
76
  ```bash
@@ -197,6 +229,50 @@ xano workspace git pull -r https://github.com/owner/private-repo -t ghp_xxx
197
229
  xano workspace git pull -r https://github.com/owner/repo --path subdir
198
230
  ```
199
231
 
232
+ **One workspace document per tree.** A push directory must contain at most one
233
+ `workspace/*.xs` document. The server applies the first workspace document it
234
+ receives to the workspace being pushed into and silently discards the rest,
235
+ without checking that it describes that workspace — so a second one renames the
236
+ destination to a foreign name while leaving its content untouched. `workspace push`
237
+ and `sandbox push` refuse a tree carrying more than one, naming the offending
238
+ files; there is no `--force` override, because only you know which workspace the
239
+ tree is meant to be.
240
+
241
+ Trees pick up a second one easily: `pull` names the file after the workspace
242
+ itself (`workspace/{name}.xs`), so pulling a different workspace into the same
243
+ directory *adds* a file rather than overwriting, and renaming a workspace leaves
244
+ the old-name file behind. Delete the stale ones, keeping the single document that
245
+ matches your target workspace.
246
+
247
+ ### Knowledge
248
+
249
+ Knowledge items are user-authored docs and skills (e.g. `CLAUDE.md`, `AGENTS.md`, runbooks)
250
+ attached to a workspace. Each knowledge item's **name is a path** (e.g. `some/thing/CLAUDE.md`):
251
+ `pull` writes its content to that path under the output directory, and `push` turns each local
252
+ file into a knowledge item named by its relative path.
253
+
254
+ Push matches local files to remote items by name. Existing items are updated (content only —
255
+ description, mode, tags, and other metadata are preserved); new files are created. The
256
+ knowledge type for new items is inferred from the filename: `AGENTS.md` → `agents.md`,
257
+ `SKILL.md` → `skill`, everything else → `doc`. Hidden files (dotfiles) and `node_modules`
258
+ are skipped.
259
+
260
+ ```bash
261
+ # Pull knowledge files to local paths (defaults to current directory)
262
+ xano knowledge pull
263
+ xano knowledge pull -d ./knowledge # Specify output directory
264
+ xano knowledge pull -b dev # Specific branch
265
+
266
+ # Push local files as knowledge (defaults to current directory, only changed files)
267
+ xano knowledge push
268
+ xano knowledge push -d ./knowledge # Push from a specific directory
269
+ xano knowledge push --dry-run # Preview changes without pushing
270
+ xano knowledge push --sync --delete # Full push + delete remote knowledge not included
271
+ xano knowledge push --force # Skip preview and confirmation (for CI/CD)
272
+ xano knowledge push -i "guides/*" # Push only matching files
273
+ xano knowledge push -e "**/README.md" # Push all files except READMEs
274
+ ```
275
+
200
276
  ### Branches
201
277
 
202
278
  All branch commands use **branch labels** (e.g., `v1`, `dev`), not IDs.
@@ -262,6 +338,7 @@ xano function run <name> --data email=jo@x.com --data age:=30 --data active:=tru
262
338
  xano function run <name> --json @payload.json --data env=staging # base payload + override
263
339
  echo '{"email":"jo@x.com"}' | xano function run <name> --stdin -o json | jq .result
264
340
  xano function run <name> --branch dev --logs # run on a branch, show execution logs
341
+ xano function run <name> --datasource test # run against the 'test' data source
265
342
  ```
266
343
 
267
344
  Input flexibility for `function run` (assembled into one JSON `input` object):
@@ -274,6 +351,11 @@ Input flexibility for `function run` (assembled into one JSON `input` object):
274
351
  | `--json '<inline>'` / `--json @file.json` / `--json -` | a base JSON object (stdin with `-`) |
275
352
  | `--stdin` | read the JSON object from stdin (same as `--json -`) |
276
353
 
354
+ `--datasource <label>` runs the function against a non-live data source by sending the
355
+ `X-Data-Source` header, so table reads and writes hit that data source's tables. Omit it
356
+ to run against `live`; an unknown label is rejected by the server with
357
+ `Invalid data source.`
358
+
277
359
  Merge order is JSON base first, then `--data` overrides. Missing required inputs are
278
360
  prompted for on an interactive terminal; in a non-TTY (CI) context the command fails
279
361
  listing them. Output defaults to the raw `result` (`-o json`); the exit code is non-zero
@@ -372,9 +454,10 @@ xano platform get <platform_id>
372
454
  #### Unit Tests
373
455
 
374
456
  ```bash
375
- # List unit tests
457
+ # List unit tests (returns all tests by default; --per_page narrows the page)
376
458
  xano unit_test list
377
459
  xano unit_test list --branch dev --obj-type function
460
+ xano unit_test list --page 2 --per_page 25
378
461
 
379
462
  # Run a single unit test
380
463
  xano unit_test run <unit_test_id>
@@ -388,9 +471,10 @@ xano unit_test run_all --branch dev --obj-type function
388
471
  #### Workflow Tests
389
472
 
390
473
  ```bash
391
- # List workflow tests
474
+ # List workflow tests (returns all tests by default; --per_page narrows the page)
392
475
  xano workflow_test list
393
476
  xano workflow_test list --branch dev
477
+ xano workflow_test list --page 2 --per_page 25
394
478
 
395
479
  # Get workflow test details
396
480
  xano workflow_test get <workflow_test_id>
@@ -404,10 +488,77 @@ xano workflow_test run <workflow_test_id>
404
488
  xano workflow_test run_all
405
489
  xano workflow_test run_all --branch dev
406
490
 
491
+ # Run tests in parallel (default is 1 — sequential)
492
+ xano unit_test run_all --concurrency 4
493
+ xano workflow_test run_all --concurrency 4
494
+
495
+ # Sandbox and tenant variants take the same paging flags
496
+ xano sandbox unit_test list --page 2 --per_page 25
497
+ xano sandbox workflow_test list --page 2 --per_page 25
498
+ xano tenant unit_test list my-tenant --page 2 --per_page 25
499
+ xano tenant workflow_test list my-tenant --page 2 --per_page 25
500
+
407
501
  # Delete a workflow test
408
502
  xano workflow_test delete <workflow_test_id>
409
503
  ```
410
504
 
505
+ #### Running tests in parallel
506
+
507
+ Every `run_all` command takes `--concurrency` (default `1`):
508
+
509
+ ```bash
510
+ xano unit_test run_all --concurrency 4
511
+ xano tenant unit_test run_all --tenant my-tenant --concurrency 8
512
+ ```
513
+
514
+ Output stays in test order regardless of concurrency, so a parallel run is still diffable against a sequential one, and the JSON `results` array keeps a stable order.
515
+
516
+ `run_all` discovers the test list by paging at 100 per request rather than asking for the whole suite in one response, and stops when the API reports no next page.
517
+
518
+ **The default is 1 on purpose.** These tests execute against a shared workspace database, so tests that touch the same tables or records can interfere with each other when run at the same time — producing failures that look like flakiness rather than a real regression. Raise `--concurrency` once you know your tests are independent.
519
+
520
+ #### Exit codes
521
+
522
+ Every `run` and `run_all` command — on all four surfaces (`unit_test`, `workflow_test`, `sandbox`, `tenant`) — uses the same exit codes:
523
+
524
+ | Code | Meaning |
525
+ |------|---------|
526
+ | `0` | All tests passed, **or** there were no tests to run |
527
+ | `1` | At least one test failed |
528
+ | `2` | The command could not run the tests at all — bad workspace, auth failure, unreadable profile, or the test-list request failed |
529
+
530
+ **This is identical for `-o json` and summary output.** Adding `-o json` to get machine-readable output does not change the exit code, so a CI job can rely on it in either mode.
531
+
532
+ ```bash
533
+ xano workflow_test run_all -o json || echo "tests failed or the command errored"
534
+ ```
535
+
536
+ **One asymmetry worth knowing about.** In `run`, a non-2xx response from the run endpoint is a CLI error and exits `2`. In `run_all`, a non-2xx response for an *individual* test is recorded as a failed test result with an `API error <status>` message and contributes to exit `1` — because `run_all`'s job is to finish the batch and report a roll-up rather than abort on one test's transport error. Exit `2` in `run_all` is reserved for failures that stop the batch from starting at all.
537
+
538
+ If you need to tell a backend outage apart from a genuine assertion failure, the exit code alone will not do it — inspect `results[].message` for the `API error` prefix:
539
+
540
+ ```bash
541
+ xano workflow_test run_all -o json | jq '[.results[] | select(.message // "" | startswith("API error"))] | length'
542
+ ```
543
+
544
+ #### JSON output
545
+
546
+ `run` returns the API's result object unchanged. Treat **any** `status` other than `"ok"` as a failure — several distinct non-`ok` values are returned in practice, so match on "not `ok`" rather than enumerating known failure values:
547
+
548
+ ```json
549
+ { "status": "exception", "timing": 0.02, "message": "Precondition failed." }
550
+ ```
551
+
552
+ `run_all` returns a roll-up. A branch with no tests is a normal state — freshly created branches routinely have none — and still emits valid JSON rather than plain text, so `JSON.parse` is always safe:
553
+
554
+ ```json
555
+ { "passed": 0, "failed": 0, "results": [], "total_timing": 0 }
556
+ ```
557
+
558
+ The `total_timing` field is currently emitted by `workflow_test run_all` only; the `unit_test`, `tenant`, and `sandbox` variants omit it. The empty-suite envelope always carries the same keys as that command's populated envelope.
559
+
560
+ > **Changed in this release:** a failing `run` in summary mode previously exited `2` and printed a spurious `EEXIT: 1` line; it now exits `1` with clean output. A failing `run` with `-o json` previously exited `0`, which silently hid failures from CI. If you match on exit code `2` specifically to detect test failures, switch to a non-zero check.
561
+
411
562
  ### Tenants
412
563
 
413
564
  Manage tenants, their environment variables, backups, deployments, and clusters.
@@ -425,9 +576,12 @@ xano tenant get <tenant_name>
425
576
  xano tenant create "My Tenant"
426
577
  xano tenant create "My Tenant" -d "Description" --type tier2 --cluster_id 1 --platform_id 5
427
578
  xano tenant create "My Tenant" --type tier2 --cluster_id 1 --license ./license.yaml
579
+ xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass
428
580
 
429
581
  # Edit a tenant
430
582
  xano tenant edit <tenant_name> --display "New Name" -d "New description"
583
+ xano tenant edit <tenant_name> --required_reviewers 1
584
+ xano tenant edit <tenant_name> --required_reviewers 0
431
585
 
432
586
  # Delete a tenant (confirmation required)
433
587
  xano tenant delete <tenant_name>
@@ -478,6 +632,60 @@ xano tenant deploy_release <tenant_name> --release v1.0
478
632
  xano tenant deploy_platform <tenant_name> --platform_id 5 --license ./license.yaml
479
633
  ```
480
634
 
635
+ If a tenant has `required_reviewers` set above 0, `tenant deploy_release` fails with an
636
+ error naming the `tenant_deploy_request` command to run instead — see below.
637
+
638
+ #### Tenant Deploy Requests
639
+
640
+ When a tenant has `required_reviewers` set above 0 (see `tenant create`/`tenant edit`
641
+ above), a release may only be deployed to it through an **approved deploy request**,
642
+ requiring that many distinct reviewer approvals. An author with no deploy permission
643
+ opens a request; a named reviewer who does hold deploy permission on the tenant votes to
644
+ approve it, and once enough reviewers have approved, it deploys as part of that action.
645
+
646
+ ```bash
647
+ # List deploy requests in a workspace (results show the target tenant and release)
648
+ xano tenant_deploy_request list
649
+ xano tenant_deploy_request list --tenant prod --status pending
650
+ xano tenant_deploy_request list --tenant prod --release v1.2
651
+ xano tenant_deploy_request list --to-review
652
+
653
+ # Get details of a specific deploy request
654
+ xano tenant_deploy_request get <id>
655
+
656
+ # Open a deploy request (submits for review immediately unless --draft is passed)
657
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --reviewers 12,45
658
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --draft
659
+
660
+ # Edit a deploy request's title, description or reviewers
661
+ xano tenant_deploy_request edit <id> --title "Deploy v1.2.1 to prod"
662
+ xano tenant_deploy_request edit <id> --reviewers 12,45,67
663
+
664
+ # Change status: draft, submit, approve, request_changes, close, or reopen
665
+ # (approve records one reviewer's vote; once enough votes are in it deploys the
666
+ # release as part of that same call)
667
+ xano tenant_deploy_request set_status <id> --status submit
668
+ xano tenant_deploy_request set_status <id> --status approve
669
+ xano tenant_deploy_request set_status <id> --status request_changes --reason "Needs a migration note"
670
+ xano tenant_deploy_request set_status <id> --status close --reason "Superseded by a newer request"
671
+ xano tenant_deploy_request set_status <id> --status reopen
672
+
673
+ # Link a newer release to a request after changes were requested (author only)
674
+ xano tenant_deploy_request revision <id> --release v1.2.1 --note "Added the missing migration"
675
+
676
+ # Deploy past the approval gate under the deploy-bypass permission (requires the
677
+ # tenant's allow_deploy_bypass to be enabled, and a separately-grantable RBAC
678
+ # permission). Every use is audited.
679
+ xano tenant_deploy_request bypass <id> --reason "Prod incident, reviewer unavailable"
680
+ ```
681
+
682
+ CI example (e.g. a GitHub Action step):
683
+
684
+ ```bash
685
+ xano tenant_deploy_request create "$PR_TITLE" --tenant prod --release v1.2 \
686
+ --description "$PR_BODY" --reviewers 12,45 -o json
687
+ ```
688
+
481
689
  #### Tenant License
482
690
 
483
691
  ```bash
@@ -566,7 +774,7 @@ xano tenant cluster license set <cluster_id> --file ./kubeconfig.yaml
566
774
 
567
775
  ### Ephemeral Tenants
568
776
 
569
- Manage ephemeral tenants — short-lived, auto-expiring tenants scoped to a workspace (default TTL 1h, max 72h). Unlike a sandbox, an ephemeral tenant requires a workspace.
777
+ Manage ephemeral tenants — short-lived, auto-expiring tenants scoped to a workspace (default TTL 1h, max 24h). Unlike a sandbox, an ephemeral tenant requires a workspace.
570
778
 
571
779
  ```bash
572
780
  # List ephemeral tenants in the current workspace
@@ -579,7 +787,7 @@ xano ephemeral list --global
579
787
  # Create an ephemeral tenant (workspace required)
580
788
  xano ephemeral create "PR preview"
581
789
  xano ephemeral create "Demo" --expires-hours 24 -w 5
582
- xano ephemeral create "Load test" -d "48h soak" --expires-hours 48
790
+ xano ephemeral create "Load test" -d "overnight soak" --expires-hours 24
583
791
 
584
792
  # Get / edit / delete
585
793
  xano ephemeral get <tenant_name>
@@ -664,16 +872,19 @@ xano sandbox reset --force
664
872
  ### Static Hosts
665
873
 
666
874
  ```bash
667
- # List static hosts
875
+ # List static hosts. Page size is fixed at 100 by the API, so there is no
876
+ # --per_page flag; the footer reports the true total.
668
877
  xano static_host list
878
+ xano static_host list --page 2
669
879
 
670
880
  # Create / get / edit a static host
671
881
  xano static_host create marketing --description "Marketing site"
672
882
  xano static_host get marketing
673
883
  xano static_host edit marketing --name marketing-v2 --description "Updated"
674
884
 
675
- # List builds
885
+ # List builds (--page only; page size is fixed at 100 by the API)
676
886
  xano static_host build list default
887
+ xano static_host build list default --page 2
677
888
 
678
889
  # Get build details
679
890
  xano static_host build get default --build_id 52
@@ -15,6 +15,11 @@ export interface CredentialsFile {
15
15
  };
16
16
  }
17
17
  export declare function buildUserAgent(version: string): string;
18
+ /**
19
+ * True when `error` is the "foreign dispatcher rejected by built-in fetch"
20
+ * failure (Node 26+). The real code lives on `error.cause.code`.
21
+ */
22
+ export declare function isInvalidDispatcherError(error: unknown): boolean;
18
23
  export interface SandboxTenant {
19
24
  created_at?: string;
20
25
  description?: string;
@@ -29,13 +29,46 @@ function resolveRequestTimeoutMs(env = process.env) {
29
29
  return DEFAULT_REQUEST_TIMEOUT_MS;
30
30
  return parsed;
31
31
  }
32
+ /**
33
+ * Whether the built-in `fetch` on this runtime accepts a dispatcher built from
34
+ * our bundled userland `undici`. Node <= 24 accepts it; Node 26 rejects a
35
+ * foreign dispatcher with UND_ERR_INVALID_ARG, which `fetch` surfaces only as
36
+ * the generic "fetch failed" — breaking *every* command (DEV-7773).
37
+ *
38
+ * Starts optimistic (`true`); flipped to `false` the first time a request fails
39
+ * with UND_ERR_INVALID_ARG (see `verboseFetch`, which then retries without the
40
+ * dispatcher). Once flipped, every later request skips the dispatcher outright.
41
+ */
42
+ let dispatcherSupported = true;
43
+ /**
44
+ * True when `error` is the "foreign dispatcher rejected by built-in fetch"
45
+ * failure (Node 26+). The real code lives on `error.cause.code`.
46
+ */
47
+ export function isInvalidDispatcherError(error) {
48
+ if (!(error instanceof Error))
49
+ return false;
50
+ const { cause } = error;
51
+ const code = cause && typeof cause === 'object' && 'code' in cause
52
+ ? cause.code
53
+ : error.code;
54
+ return code === 'UND_ERR_INVALID_ARG';
55
+ }
32
56
  /**
33
57
  * Lazily-built undici dispatcher whose header/body inactivity timeouts match our
34
58
  * request timeout, so undici's internal 300s default never fires first. Built
35
59
  * once and reused across requests. `timeout === 0` means "no bound".
60
+ *
61
+ * Returns `undefined` when this runtime has been found to reject a foreign
62
+ * dispatcher (Node 26+, DEV-7773). Dropping the dispatcher does NOT regress the
63
+ * large-push timeout: the per-request `AbortSignal.timeout(timeoutMs)` in
64
+ * `verboseFetch` already bounds the whole request (headers included) to the same
65
+ * 15-min ceiling the dispatcher enforced, so undici's hidden 300s
66
+ * `headersTimeout` is subsumed by it.
36
67
  */
37
68
  let sharedDispatcher;
38
69
  function getRequestDispatcher(timeoutMs) {
70
+ if (!dispatcherSupported)
71
+ return undefined;
39
72
  if (timeoutMs === 0) {
40
73
  sharedDispatcher ??= new Agent({ bodyTimeout: 0, headersTimeout: 0 });
41
74
  return sharedDispatcher;
@@ -324,15 +357,20 @@ export default class BaseCommand extends Command {
324
357
  ...options.headers,
325
358
  };
326
359
  const timeoutMs = resolveRequestTimeoutMs();
327
- const fetchOptions = {
360
+ // Build the fetch options fresh each attempt: a retry needs a new
361
+ // AbortSignal (the first attempt's may already be aborted/consumed) and,
362
+ // when the dispatcher is the culprit, no dispatcher at all.
363
+ const buildFetchOptions = (withDispatcher) => ({
328
364
  ...options,
329
365
  // undici dispatcher: lifts the hidden 300s headers/body timeout to our bound
330
- dispatcher: getRequestDispatcher(timeoutMs),
366
+ ...(withDispatcher ? { dispatcher: getRequestDispatcher(timeoutMs) } : {}),
331
367
  headers,
332
368
  // belt-and-suspenders hard ceiling that also covers DNS/connect stalls;
333
369
  // surfaces as a clean AbortError (see describeNetworkError). 0 = no bound.
370
+ // This alone bounds the whole request, so dropping the dispatcher (Node 26)
371
+ // does not lose the large-push timeout protection.
334
372
  ...(timeoutMs > 0 && !options.signal ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
335
- };
373
+ });
336
374
  const contentType = headers['Content-Type'] || 'application/json';
337
375
  if (verbose) {
338
376
  this.log('');
@@ -349,7 +387,26 @@ export default class BaseCommand extends Command {
349
387
  }
350
388
  }
351
389
  const startTime = Date.now();
352
- const response = await fetch(url, fetchOptions);
390
+ let response;
391
+ try {
392
+ response = await fetch(url, buildFetchOptions(dispatcherSupported));
393
+ }
394
+ catch (error) {
395
+ // Node 26+ rejects our userland-undici dispatcher with UND_ERR_INVALID_ARG,
396
+ // surfaced only as "fetch failed" (DEV-7773). Disable it for the rest of the
397
+ // process and retry once without it; the AbortSignal.timeout ceiling above
398
+ // still bounds the request, so the large-push protection is preserved.
399
+ if (dispatcherSupported && isInvalidDispatcherError(error)) {
400
+ dispatcherSupported = false;
401
+ if (verbose) {
402
+ this.log(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
403
+ }
404
+ response = await fetch(url, buildFetchOptions(false));
405
+ }
406
+ else {
407
+ throw error;
408
+ }
409
+ }
353
410
  const elapsed = Date.now() - startTime;
354
411
  if (verbose) {
355
412
  this.log(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson, formatPagingFooter } from '../../../utils/paging.js';
3
4
  export function filterBackups(branches, includeBackups) {
4
5
  return includeBackups ? branches : branches.filter((b) => !b.backup);
5
6
  }
@@ -82,7 +83,7 @@ Available branches:
82
83
  const branches = filterBackups(allBranches, flags.backups);
83
84
  // Output results
84
85
  if (flags.output === 'json') {
85
- this.log(JSON.stringify(branches, null, 2));
86
+ this.log(JSON.stringify(buildPagingJson({ items: branches }, { tier: 'none' }), null, 2));
86
87
  }
87
88
  else {
88
89
  // summary format
@@ -97,6 +98,9 @@ Available branches:
97
98
  this.log(` - ${branch.label}${liveIndicator}${backupIndicator}`);
98
99
  }
99
100
  }
101
+ const footer = formatPagingFooter({ items: branches }, { noun: 'branch', nounPlural: 'branches', tier: 'none' });
102
+ if (footer)
103
+ this.log(footer);
100
104
  }
101
105
  }
102
106
  catch (error) {
@@ -14,7 +14,7 @@ Created ephemeral tenant: PR preview (e4f2-9ab1-...) - ID: 42
14
14
  Expires: in 1 hour
15
15
  `,
16
16
  `$ xano ephemeral create "Demo" --expires-hours 24 -w 114`,
17
- `$ xano ephemeral create "Load test" --description "48h soak" --expires-hours 48 -o json`,
17
+ `$ xano ephemeral create "Load test" --description "overnight soak" --expires-hours 24 -o json`,
18
18
  ];
19
19
  static flags = {
20
20
  ...BaseCommand.baseFlags,
@@ -25,8 +25,8 @@ Created ephemeral tenant: PR preview (e4f2-9ab1-...) - ID: 42
25
25
  }),
26
26
  'expires-hours': Flags.integer({
27
27
  default: 1,
28
- description: 'Hours until the tenant auto-expires (1-72)',
29
- max: 72,
28
+ description: 'Hours until the tenant auto-expires (1-24)',
29
+ max: 24,
30
30
  min: 1,
31
31
  required: false,
32
32
  }),
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson } from '../../../utils/paging.js';
3
4
  export default class EphemeralList extends BaseCommand {
4
5
  static description = 'List ephemeral tenants in a workspace, or across the whole instance with --global';
5
6
  static examples = [
@@ -73,7 +74,7 @@ Ephemeral tenants (all workspaces):
73
74
  this.error('Unexpected API response format');
74
75
  }
75
76
  if (flags.output === 'json') {
76
- this.log(JSON.stringify(tenants, null, 2));
77
+ this.log(JSON.stringify(buildPagingJson({ items: tenants }, { tier: 'none' }), null, 2));
77
78
  }
78
79
  else if (tenants.length === 0) {
79
80
  this.log('No ephemeral tenants found');
@@ -16,10 +16,4 @@ export default class EphemeralPull extends BaseCommand {
16
16
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  };
18
18
  run(): Promise<void>;
19
- /**
20
- * Sanitize a document name for use as a filename.
21
- * Strips quotes, replaces spaces with underscores, and removes
22
- * characters that are unsafe in filenames.
23
- */
24
- private sanitizeFilename;
25
19
  }
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import snakeCase from 'lodash.snakecase';
5
5
  import BaseCommand from '../../../base-command.js';
6
- import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  export default class EphemeralPull extends BaseCommand {
8
8
  static args = {
9
9
  tenant_name: Args.string({
@@ -130,96 +130,19 @@ Pulled 58 documents from tenant e4f2-9ab1-xyz1
130
130
  fs.mkdirSync(outputDir, { recursive: true });
131
131
  // Resolve api_group names to unique folder names, disambiguating collisions
132
132
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
133
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
134
+ // messages can nest under their channel's server (see resolver docs).
135
+ const getChannelServer = buildChannelServerResolver(documents);
133
136
  // Track filenames per type to handle duplicates
134
137
  const filenameCounters = new Map();
135
138
  let writtenCount = 0;
136
139
  for (const doc of documents) {
137
- let typeDir;
138
- let baseName;
139
- if (doc.type === 'workspace') {
140
- // workspace → workspace/{name}.xs
141
- typeDir = path.join(outputDir, 'workspace');
142
- baseName = this.sanitizeFilename(doc.name);
143
- }
144
- else if (doc.type === 'workspace_trigger') {
145
- // workspace_trigger → workspace/trigger/{name}.xs
146
- typeDir = path.join(outputDir, 'workspace', 'trigger');
147
- baseName = this.sanitizeFilename(doc.name);
148
- }
149
- else if (doc.type === 'error_trigger') {
150
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
151
- typeDir = path.join(outputDir, 'workspace', 'trigger');
152
- baseName = this.sanitizeFilename(doc.name);
153
- }
154
- else if (doc.type === 'agent') {
155
- // agent → ai/agent/{name}.xs
156
- typeDir = path.join(outputDir, 'ai', 'agent');
157
- baseName = this.sanitizeFilename(doc.name);
158
- }
159
- else if (doc.type === 'mcp_server') {
160
- // mcp_server → ai/mcp_server/{name}.xs
161
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
162
- baseName = this.sanitizeFilename(doc.name);
163
- }
164
- else if (doc.type === 'tool') {
165
- // tool → ai/tool/{name}.xs
166
- typeDir = path.join(outputDir, 'ai', 'tool');
167
- baseName = this.sanitizeFilename(doc.name);
168
- }
169
- else if (doc.type === 'agent_trigger') {
170
- // agent_trigger → ai/agent/trigger/{name}.xs
171
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
172
- baseName = this.sanitizeFilename(doc.name);
173
- }
174
- else if (doc.type === 'mcp_server_trigger') {
175
- // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
176
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
177
- baseName = this.sanitizeFilename(doc.name);
178
- }
179
- else if (doc.type === 'table_trigger') {
180
- // table_trigger → table/trigger/{name}.xs
181
- typeDir = path.join(outputDir, 'table', 'trigger');
182
- baseName = this.sanitizeFilename(doc.name);
183
- }
184
- else if (doc.type === 'realtime_channel') {
185
- // realtime_channel → realtime/channel/{name}.xs
186
- typeDir = path.join(outputDir, 'realtime', 'channel');
187
- baseName = this.sanitizeFilename(doc.name);
188
- }
189
- else if (doc.type === 'realtime_trigger') {
190
- // realtime_trigger → realtime/trigger/{name}.xs
191
- typeDir = path.join(outputDir, 'realtime', 'trigger');
192
- baseName = this.sanitizeFilename(doc.name);
193
- }
194
- else if (doc.type === 'api_group') {
195
- // api_group "test" → api/{resolved_folder}/{name}.xs
196
- const groupFolder = getApiGroupFolder(doc.name);
197
- typeDir = path.join(outputDir, 'api', groupFolder);
198
- baseName = this.sanitizeFilename(doc.name);
199
- }
200
- else if (doc.type === 'query' && doc.apiGroup) {
201
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
202
- const groupFolder = getApiGroupFolder(doc.apiGroup);
203
- const nameParts = doc.name.split('/');
204
- const leafName = nameParts.pop();
205
- const folderParts = nameParts.map((part) => snakeCase(part));
206
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
207
- baseName = this.sanitizeFilename(leafName);
208
- if (doc.verb) {
209
- baseName = `${baseName}_${doc.verb}`;
210
- }
211
- }
212
- else {
213
- // Default: split folder path from name
214
- const nameParts = doc.name.split('/');
215
- const leafName = nameParts.pop();
216
- const folderParts = nameParts.map((part) => snakeCase(part));
217
- typeDir = path.join(outputDir, doc.type, ...folderParts);
218
- baseName = this.sanitizeFilename(leafName);
219
- if (doc.verb) {
220
- baseName = `${baseName}_${doc.verb}`;
221
- }
222
- }
140
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
141
+ getApiGroupFolder,
142
+ getChannelServer,
143
+ join: path.join,
144
+ snakeCase,
145
+ });
223
146
  fs.mkdirSync(typeDir, { recursive: true });
224
147
  // Track duplicates per directory
225
148
  const dirKey = path.relative(outputDir, typeDir);
@@ -238,12 +161,4 @@ Pulled 58 documents from tenant e4f2-9ab1-xyz1
238
161
  }
239
162
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
240
163
  }
241
- /**
242
- * Sanitize a document name for use as a filename.
243
- * Strips quotes, replaces spaces with underscores, and removes
244
- * characters that are unsafe in filenames.
245
- */
246
- sanitizeFilename(name) {
247
- return snakeCase(name.replaceAll('"', ''));
248
- }
249
164
  }
@@ -16,10 +16,20 @@ export default class EphemeralPush extends BaseCommand {
16
16
  sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  transaction: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ 'wait-timeout': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
19
21
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
22
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
23
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
24
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
25
  };
24
26
  run(): Promise<void>;
27
+ /**
28
+ * Poll GET .../tenant/{name}/microservice until auto microservices settle.
29
+ * Renders a one-line-per-microservice progress view that updates in place, then
30
+ * exits non-zero if any auto microservice failed or the wait timed out.
31
+ */
32
+ private waitForDeploy;
33
+ /** Number of lines the last progress render wrote (for in-place TTY updates). */
34
+ private lastRenderLines;
25
35
  }