@xano/cli 1.0.6-beta.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +46 -62
  2. package/dist/base-command.d.ts +0 -2
  3. package/dist/base-command.js +4 -10
  4. package/dist/commands/release/pull/index.d.ts +6 -0
  5. package/dist/commands/release/pull/index.js +95 -10
  6. package/dist/commands/sandbox/pull/index.d.ts +1 -0
  7. package/dist/commands/sandbox/pull/index.js +77 -10
  8. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  9. package/dist/commands/sandbox/unit_test/run_all/index.js +9 -1
  10. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  11. package/dist/commands/sandbox/workflow_test/run_all/index.js +9 -1
  12. package/dist/commands/tenant/pull/index.d.ts +6 -0
  13. package/dist/commands/tenant/pull/index.js +95 -10
  14. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  15. package/dist/commands/tenant/unit_test/run_all/index.js +13 -1
  16. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  17. package/dist/commands/tenant/workflow_test/run_all/index.js +13 -1
  18. package/dist/commands/unit_test/run/index.js +19 -11
  19. package/dist/commands/unit_test/run_all/index.js +13 -1
  20. package/dist/commands/workflow_test/run/index.js +15 -10
  21. package/dist/commands/workflow_test/run_all/index.js +15 -3
  22. package/dist/commands/workspace/git/pull/index.js +3 -53
  23. package/dist/commands/workspace/pull/index.d.ts +6 -0
  24. package/dist/commands/workspace/pull/index.js +95 -10
  25. package/dist/utils/document-parser.d.ts +0 -85
  26. package/dist/utils/document-parser.js +1 -214
  27. package/dist/utils/knowledge-sync.d.ts +0 -1
  28. package/dist/utils/knowledge-sync.js +5 -11
  29. package/dist/utils/paging.d.ts +1 -2
  30. package/dist/utils/reference-checker.js +0 -13
  31. package/oclif.manifest.json +2796 -4820
  32. package/package.json +1 -10
  33. package/dist/commands/ephemeral/create/index.d.ts +0 -18
  34. package/dist/commands/ephemeral/create/index.js +0 -103
  35. package/dist/commands/ephemeral/delete/index.d.ts +0 -18
  36. package/dist/commands/ephemeral/delete/index.js +0 -99
  37. package/dist/commands/ephemeral/edit/index.d.ts +0 -18
  38. package/dist/commands/ephemeral/edit/index.js +0 -109
  39. package/dist/commands/ephemeral/get/index.d.ts +0 -21
  40. package/dist/commands/ephemeral/get/index.js +0 -125
  41. package/dist/commands/ephemeral/impersonate/index.d.ts +0 -20
  42. package/dist/commands/ephemeral/impersonate/index.js +0 -120
  43. package/dist/commands/ephemeral/list/index.d.ts +0 -19
  44. package/dist/commands/ephemeral/list/index.js +0 -123
  45. package/dist/commands/ephemeral/pull/index.d.ts +0 -19
  46. package/dist/commands/ephemeral/pull/index.js +0 -164
  47. package/dist/commands/ephemeral/push/index.d.ts +0 -35
  48. package/dist/commands/ephemeral/push/index.js +0 -221
  49. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +0 -20
  50. package/dist/commands/ephemeral/static_host/build/delete/index.js +0 -121
  51. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +0 -18
  52. package/dist/commands/ephemeral/static_host/build/get/index.js +0 -137
  53. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +0 -18
  54. package/dist/commands/ephemeral/static_host/build/list/index.js +0 -137
  55. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +0 -53
  56. package/dist/commands/ephemeral/static_host/build/pull/index.js +0 -308
  57. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +0 -24
  58. package/dist/commands/ephemeral/static_host/build/push/index.js +0 -250
  59. package/dist/commands/ephemeral/static_host/create/index.d.ts +0 -18
  60. package/dist/commands/ephemeral/static_host/create/index.js +0 -92
  61. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +0 -19
  62. package/dist/commands/ephemeral/static_host/deploy/index.js +0 -112
  63. package/dist/commands/ephemeral/static_host/edit/index.d.ts +0 -24
  64. package/dist/commands/ephemeral/static_host/edit/index.js +0 -158
  65. package/dist/commands/ephemeral/static_host/get/index.d.ts +0 -19
  66. package/dist/commands/ephemeral/static_host/get/index.js +0 -101
  67. package/dist/commands/ephemeral/static_host/list/index.d.ts +0 -17
  68. package/dist/commands/ephemeral/static_host/list/index.js +0 -132
  69. package/dist/utils/microservice-wait.d.ts +0 -53
  70. package/dist/utils/microservice-wait.js +0 -83
package/README.md CHANGED
@@ -46,16 +46,16 @@ The Metadata API pages unevenly, so `list` commands differ in what they expose.
46
46
  | Commands | Flags | Footer |
47
47
  |---|---|---|
48
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` |
49
+ | `static_host list`, `static_host build list` | `--page` only | `Page 2 · 100 shown · 340 total` |
50
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 |
51
+ | `tenant list`, `release list`, `platform list`, `tenant cluster list` | none | none |
52
52
  | `tenant backup list` | `--page` only (pre-existing) | none |
53
53
 
54
54
  Notes:
55
55
 
56
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
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.
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, 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
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
60
 
61
61
  ```jsonc
@@ -487,7 +487,7 @@ Every `run_all` command takes `--concurrency` (default `1`):
487
487
 
488
488
  ```bash
489
489
  xano unit_test run_all --concurrency 4
490
- xano tenant unit_test run_all my-tenant --concurrency 8
490
+ xano tenant unit_test run_all --tenant my-tenant --concurrency 8
491
491
  ```
492
492
 
493
493
  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.
@@ -496,6 +496,48 @@ Output stays in test order regardless of concurrency, so a parallel run is still
496
496
 
497
497
  **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.
498
498
 
499
+ #### Exit codes
500
+
501
+ Every `run` and `run_all` command — on all four surfaces (`unit_test`, `workflow_test`, `sandbox`, `tenant`) — uses the same exit codes:
502
+
503
+ | Code | Meaning |
504
+ |------|---------|
505
+ | `0` | All tests passed, **or** there were no tests to run |
506
+ | `1` | At least one test failed |
507
+ | `2` | The command could not run the tests at all — bad workspace, auth failure, unreadable profile, or the test-list request failed |
508
+
509
+ **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.
510
+
511
+ ```bash
512
+ xano workflow_test run_all -o json || echo "tests failed or the command errored"
513
+ ```
514
+
515
+ **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.
516
+
517
+ 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:
518
+
519
+ ```bash
520
+ xano workflow_test run_all -o json | jq '[.results[] | select(.message // "" | startswith("API error"))] | length'
521
+ ```
522
+
523
+ #### JSON output
524
+
525
+ `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:
526
+
527
+ ```json
528
+ { "status": "exception", "timing": 0.02, "message": "Precondition failed." }
529
+ ```
530
+
531
+ `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:
532
+
533
+ ```json
534
+ { "passed": 0, "failed": 0, "results": [], "total_timing": 0 }
535
+ ```
536
+
537
+ 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.
538
+
539
+ > **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.
540
+
499
541
  ### Tenants
500
542
 
501
543
  Manage tenants, their environment variables, backups, deployments, and clusters.
@@ -652,64 +694,6 @@ xano tenant cluster license set <cluster_id>
652
694
  xano tenant cluster license set <cluster_id> --file ./kubeconfig.yaml
653
695
  ```
654
696
 
655
- ### Ephemeral Tenants
656
-
657
- 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.
658
-
659
- ```bash
660
- # List ephemeral tenants in the current workspace
661
- xano ephemeral list
662
- xano ephemeral list -w 5
663
-
664
- # List ephemeral tenants across every workspace you can access
665
- xano ephemeral list --global
666
-
667
- # Create an ephemeral tenant (workspace required)
668
- xano ephemeral create "PR preview"
669
- xano ephemeral create "Demo" --expires-hours 24 -w 5
670
- xano ephemeral create "Load test" -d "48h soak" --expires-hours 48
671
-
672
- # Get / edit / delete
673
- xano ephemeral get <tenant_name>
674
- xano ephemeral edit <tenant_name> --display "New Name" -d "New description"
675
- xano ephemeral delete <tenant_name> --force
676
-
677
- # Pull to / push from local files (multidoc)
678
- xano ephemeral pull <tenant_name> -d ./my-ephemeral
679
- xano ephemeral push <tenant_name> -d ./my-ephemeral --dry-run # preview first
680
- xano ephemeral push <tenant_name> -d ./my-ephemeral
681
-
682
- # Open in the browser (or print the URL)
683
- xano ephemeral impersonate <tenant_name>
684
- xano ephemeral impersonate <tenant_name> --url-only
685
- xano ephemeral impersonate <tenant_name> --guest # read-only session (browse only)
686
- ```
687
-
688
- #### Static hosting for an ephemeral tenant
689
-
690
- An ephemeral tenant can host static sites, scoped to that tenant. These commands mirror
691
- `xano static_host *` but take the tenant name as the first argument (the tenant's static
692
- hosting is isolated inside the tenant's own database).
693
-
694
- ```bash
695
- # List / create / inspect a tenant's static hosts
696
- xano ephemeral static_host list <tenant_name>
697
- xano ephemeral static_host create <tenant_name> --name marketing --description "Marketing site"
698
- xano ephemeral static_host get <tenant_name> -H marketing
699
- xano ephemeral static_host edit <tenant_name> -H marketing --description "Updated"
700
-
701
- # Builds: push a directory, list, inspect, deploy to an env, pull, delete
702
- xano ephemeral static_host build push <tenant_name> -H default -f ./site
703
- xano ephemeral static_host build list <tenant_name> -H default
704
- xano ephemeral static_host build get <tenant_name> -H default --build_id 52
705
- xano ephemeral static_host deploy <tenant_name> -H default --build_id 52 --env prod
706
- xano ephemeral static_host build pull <tenant_name> -H default --latest
707
- xano ephemeral static_host build delete <tenant_name> -H default --build_id 52
708
- ```
709
-
710
- > Static hosting is currently available for **local** tenants. Remote (tier2/tier3)
711
- > tenants are not yet supported and will return an error.
712
-
713
697
  ### Sandbox
714
698
 
715
699
  Manage your sandbox tenant. Each user has a single sandbox tenant that is auto-provisioned on first use.
@@ -117,7 +117,6 @@ export default abstract class BaseCommand extends Command {
117
117
  protected logStaticHostUrls(opts: {
118
118
  profile: ProfileConfig;
119
119
  staticHost: string;
120
- tenantName?: string;
121
120
  verbose: boolean;
122
121
  workspaceId: string;
123
122
  }): Promise<void>;
@@ -127,7 +126,6 @@ export default abstract class BaseCommand extends Command {
127
126
  profile: ProfileConfig;
128
127
  quiet?: boolean;
129
128
  staticHost: string;
130
- tenantName?: string;
131
129
  timeoutMs?: number;
132
130
  verbose: boolean;
133
131
  workspaceId: string;
@@ -370,11 +370,8 @@ export default class BaseCommand extends Command {
370
370
  * Returns the final status. Resolves to the last-seen status on timeout.
371
371
  */
372
372
  async logStaticHostUrls(opts) {
373
- const { profile, staticHost, tenantName, verbose, workspaceId } = opts;
374
- // When a tenant is scoped, target the tenant's static hosting; otherwise the
375
- // workspace-level static host (default, unchanged behavior).
376
- const scope = tenantName ? `/tenant/${tenantName}` : '';
377
- const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}`;
373
+ const { profile, staticHost, verbose, workspaceId } = opts;
374
+ const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}`;
378
375
  try {
379
376
  const response = await this.verboseFetch(url, {
380
377
  headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` },
@@ -397,14 +394,11 @@ export default class BaseCommand extends Command {
397
394
  }
398
395
  }
399
396
  async waitForBuild(opts) {
400
- const { buildId, profile, quiet, staticHost, tenantName, verbose, workspaceId } = opts;
397
+ const { buildId, profile, quiet, staticHost, verbose, workspaceId } = opts;
401
398
  const intervalMs = opts.intervalMs ?? 2000;
402
399
  const timeoutMs = opts.timeoutMs ?? 600_000; // 10 min
403
400
  const terminal = new Set(['error', 'ok']);
404
- // When a tenant is scoped, target the tenant's static hosting; otherwise the
405
- // workspace-level static host (default, unchanged behavior).
406
- const scope = tenantName ? `/tenant/${tenantName}` : '';
407
- const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}${scope}/static_host/${staticHost}/build/${buildId}`;
401
+ const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/static_host/${staticHost}/build/${buildId}`;
408
402
  // Spinner only on an interactive TTY and when not emitting JSON. Verbose mode
409
403
  // also disables it (the spinner would interleave with request/response logs).
410
404
  const animate = Boolean(process.stdout.isTTY) && !quiet && !verbose;
@@ -14,4 +14,10 @@ export default class ReleasePull extends BaseCommand {
14
14
  };
15
15
  run(): Promise<void>;
16
16
  private resolveReleaseName;
17
+ /**
18
+ * Sanitize a document name for use as a filename.
19
+ * Strips quotes, replaces spaces with underscores, and removes
20
+ * characters that are unsafe in filenames.
21
+ */
22
+ private sanitizeFilename;
17
23
  }
@@ -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, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
7
7
  export default class ReleasePull extends BaseCommand {
8
8
  static description = 'Pull a release multidoc from the Xano Metadata API and split into individual files';
9
9
  static examples = [
@@ -123,19 +123,96 @@ Pulled 58 documents from release 'v1.0'
123
123
  fs.mkdirSync(outputDir, { recursive: true });
124
124
  // Resolve api_group names to unique folder names, disambiguating collisions
125
125
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
126
- // Resolve a realtime v2 channel path -> owning realtime_server name, so
127
- // messages can nest under their channel's server (see resolver docs).
128
- const getChannelServer = buildChannelServerResolver(documents);
129
126
  // Track filenames per type to handle duplicates
130
127
  const filenameCounters = new Map();
131
128
  let writtenCount = 0;
132
129
  for (const doc of documents) {
133
- const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
134
- getApiGroupFolder,
135
- getChannelServer,
136
- join: path.join,
137
- snakeCase,
138
- });
130
+ let typeDir;
131
+ let baseName;
132
+ if (doc.type === 'workspace') {
133
+ // workspace → workspace/{name}.xs
134
+ typeDir = path.join(outputDir, 'workspace');
135
+ baseName = this.sanitizeFilename(doc.name);
136
+ }
137
+ else if (doc.type === 'workspace_trigger') {
138
+ // workspace_trigger → workspace/trigger/{name}.xs
139
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
140
+ baseName = this.sanitizeFilename(doc.name);
141
+ }
142
+ else if (doc.type === 'error_trigger') {
143
+ // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
144
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
145
+ baseName = this.sanitizeFilename(doc.name);
146
+ }
147
+ else if (doc.type === 'agent') {
148
+ // agent → ai/agent/{name}.xs
149
+ typeDir = path.join(outputDir, 'ai', 'agent');
150
+ baseName = this.sanitizeFilename(doc.name);
151
+ }
152
+ else if (doc.type === 'mcp_server') {
153
+ // mcp_server → ai/mcp_server/{name}.xs
154
+ typeDir = path.join(outputDir, 'ai', 'mcp_server');
155
+ baseName = this.sanitizeFilename(doc.name);
156
+ }
157
+ else if (doc.type === 'tool') {
158
+ // tool → ai/tool/{name}.xs
159
+ typeDir = path.join(outputDir, 'ai', 'tool');
160
+ baseName = this.sanitizeFilename(doc.name);
161
+ }
162
+ else if (doc.type === 'agent_trigger') {
163
+ // agent_trigger → ai/agent/trigger/{name}.xs
164
+ typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
165
+ baseName = this.sanitizeFilename(doc.name);
166
+ }
167
+ else if (doc.type === 'mcp_server_trigger') {
168
+ // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
169
+ typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
170
+ baseName = this.sanitizeFilename(doc.name);
171
+ }
172
+ else if (doc.type === 'table_trigger') {
173
+ // table_trigger → table/trigger/{name}.xs
174
+ typeDir = path.join(outputDir, 'table', 'trigger');
175
+ baseName = this.sanitizeFilename(doc.name);
176
+ }
177
+ else if (doc.type === 'realtime_channel') {
178
+ // realtime_channel → realtime/channel/{name}.xs
179
+ typeDir = path.join(outputDir, 'realtime', 'channel');
180
+ baseName = this.sanitizeFilename(doc.name);
181
+ }
182
+ else if (doc.type === 'realtime_trigger') {
183
+ // realtime_trigger → realtime/trigger/{name}.xs
184
+ typeDir = path.join(outputDir, 'realtime', 'trigger');
185
+ baseName = this.sanitizeFilename(doc.name);
186
+ }
187
+ else if (doc.type === 'api_group') {
188
+ // api_group "test" → api/{resolved_folder}/{name}.xs
189
+ const groupFolder = getApiGroupFolder(doc.name);
190
+ typeDir = path.join(outputDir, 'api', groupFolder);
191
+ baseName = this.sanitizeFilename(doc.name);
192
+ }
193
+ else if (doc.type === 'query' && doc.apiGroup) {
194
+ // query in group "test" → api/{resolved_folder}/{query_name}.xs
195
+ const groupFolder = getApiGroupFolder(doc.apiGroup);
196
+ const nameParts = doc.name.split('/');
197
+ const leafName = nameParts.pop();
198
+ const folderParts = nameParts.map((part) => snakeCase(part));
199
+ typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
200
+ baseName = this.sanitizeFilename(leafName);
201
+ if (doc.verb) {
202
+ baseName = `${baseName}_${doc.verb}`;
203
+ }
204
+ }
205
+ else {
206
+ // Default: split folder path from name
207
+ const nameParts = doc.name.split('/');
208
+ const leafName = nameParts.pop();
209
+ const folderParts = nameParts.map((part) => snakeCase(part));
210
+ typeDir = path.join(outputDir, doc.type, ...folderParts);
211
+ baseName = this.sanitizeFilename(leafName);
212
+ if (doc.verb) {
213
+ baseName = `${baseName}_${doc.verb}`;
214
+ }
215
+ }
139
216
  fs.mkdirSync(typeDir, { recursive: true });
140
217
  // Track duplicates per directory
141
218
  const dirKey = path.relative(outputDir, typeDir);
@@ -180,4 +257,12 @@ Pulled 58 documents from release 'v1.0'
180
257
  }
181
258
  return match.id;
182
259
  }
260
+ /**
261
+ * Sanitize a document name for use as a filename.
262
+ * Strips quotes, replaces spaces with underscores, and removes
263
+ * characters that are unsafe in filenames.
264
+ */
265
+ sanitizeFilename(name) {
266
+ return snakeCase(name.replaceAll('"', ''));
267
+ }
183
268
  }
@@ -12,4 +12,5 @@ export default class SandboxPull extends BaseCommand {
12
12
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
13
  };
14
14
  run(): Promise<void>;
15
+ private sanitizeFilename;
15
16
  }
@@ -1,7 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import snakeCase from 'lodash.snakecase';
3
3
  import BaseCommand from '../../../base-command.js';
4
- import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
4
+ import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
5
5
  import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
@@ -92,18 +92,82 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
92
92
  const outputDir = path.resolve(flags.directory);
93
93
  fs.mkdirSync(outputDir, { recursive: true });
94
94
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
95
- // Resolve a realtime v2 channel path -> owning realtime_server name, so
96
- // messages can nest under their channel's server (see resolver docs).
97
- const getChannelServer = buildChannelServerResolver(documents);
98
95
  const filenameCounters = new Map();
99
96
  let writtenCount = 0;
100
97
  for (const doc of documents) {
101
- const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
102
- getApiGroupFolder,
103
- getChannelServer,
104
- join: path.join,
105
- snakeCase,
106
- });
98
+ let typeDir;
99
+ let baseName;
100
+ if (doc.type === 'workspace') {
101
+ typeDir = path.join(outputDir, 'workspace');
102
+ baseName = this.sanitizeFilename(doc.name);
103
+ }
104
+ else if (doc.type === 'workspace_trigger') {
105
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
106
+ baseName = this.sanitizeFilename(doc.name);
107
+ }
108
+ else if (doc.type === 'error_trigger') {
109
+ // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
110
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
111
+ baseName = this.sanitizeFilename(doc.name);
112
+ }
113
+ else if (doc.type === 'agent') {
114
+ typeDir = path.join(outputDir, 'ai', 'agent');
115
+ baseName = this.sanitizeFilename(doc.name);
116
+ }
117
+ else if (doc.type === 'mcp_server') {
118
+ typeDir = path.join(outputDir, 'ai', 'mcp_server');
119
+ baseName = this.sanitizeFilename(doc.name);
120
+ }
121
+ else if (doc.type === 'tool') {
122
+ typeDir = path.join(outputDir, 'ai', 'tool');
123
+ baseName = this.sanitizeFilename(doc.name);
124
+ }
125
+ else if (doc.type === 'agent_trigger') {
126
+ typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
127
+ baseName = this.sanitizeFilename(doc.name);
128
+ }
129
+ else if (doc.type === 'mcp_server_trigger') {
130
+ typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
131
+ baseName = this.sanitizeFilename(doc.name);
132
+ }
133
+ else if (doc.type === 'table_trigger') {
134
+ typeDir = path.join(outputDir, 'table', 'trigger');
135
+ baseName = this.sanitizeFilename(doc.name);
136
+ }
137
+ else if (doc.type === 'realtime_channel') {
138
+ typeDir = path.join(outputDir, 'realtime', 'channel');
139
+ baseName = this.sanitizeFilename(doc.name);
140
+ }
141
+ else if (doc.type === 'realtime_trigger') {
142
+ typeDir = path.join(outputDir, 'realtime', 'trigger');
143
+ baseName = this.sanitizeFilename(doc.name);
144
+ }
145
+ else if (doc.type === 'api_group') {
146
+ const groupFolder = getApiGroupFolder(doc.name);
147
+ typeDir = path.join(outputDir, 'api', groupFolder);
148
+ baseName = this.sanitizeFilename(doc.name);
149
+ }
150
+ else if (doc.type === 'query' && doc.apiGroup) {
151
+ const groupFolder = getApiGroupFolder(doc.apiGroup);
152
+ const nameParts = doc.name.split('/');
153
+ const leafName = nameParts.pop();
154
+ const folderParts = nameParts.map((part) => snakeCase(part));
155
+ typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
156
+ baseName = this.sanitizeFilename(leafName);
157
+ if (doc.verb) {
158
+ baseName = `${baseName}_${doc.verb}`;
159
+ }
160
+ }
161
+ else {
162
+ const nameParts = doc.name.split('/');
163
+ const leafName = nameParts.pop();
164
+ const folderParts = nameParts.map((part) => snakeCase(part));
165
+ typeDir = path.join(outputDir, doc.type, ...folderParts);
166
+ baseName = this.sanitizeFilename(leafName);
167
+ if (doc.verb) {
168
+ baseName = `${baseName}_${doc.verb}`;
169
+ }
170
+ }
107
171
  fs.mkdirSync(typeDir, { recursive: true });
108
172
  const dirKey = path.relative(outputDir, typeDir);
109
173
  if (!filenameCounters.has(dirKey)) {
@@ -130,4 +194,7 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
130
194
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
131
195
  this.log(`Pulled ${parts.join(' + ')} from sandbox environment to ${flags.directory}`);
132
196
  }
197
+ sanitizeFilename(name) {
198
+ return snakeCase(name.replaceAll('"', ''));
199
+ }
133
200
  }
@@ -46,24 +46,30 @@ Result: PASS
46
46
  this.error(message);
47
47
  }
48
48
  const result = (await response.json());
49
+ // Anything other than 'ok' is a failure -- the API has been observed
50
+ // returning several distinct non-ok statuses, so never enumerate them.
51
+ const passed = result.status === 'ok';
49
52
  if (flags.output === 'json') {
50
53
  this.log(JSON.stringify(result, null, 2));
51
54
  }
55
+ else if (passed) {
56
+ this.log('Result: PASS');
57
+ }
52
58
  else {
53
- if (result.status === 'ok') {
54
- this.log('Result: PASS');
55
- }
56
- else {
57
- this.log('Result: FAIL');
58
- const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
59
- for (const expect of failedExpects) {
60
- if (expect.message) {
61
- this.log(` Error: ${expect.message}`);
62
- }
59
+ this.log('Result: FAIL');
60
+ const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
61
+ for (const expect of failedExpects) {
62
+ if (expect.message) {
63
+ this.log(` Error: ${expect.message}`);
63
64
  }
64
- this.exit(1);
65
65
  }
66
66
  }
67
+ // Set the code rather than throwing via this.exit(): the exit must apply
68
+ // in both output modes, and process.exitCode cannot be swallowed by the
69
+ // surrounding catch. Mirrors what run_all already does.
70
+ if (!passed) {
71
+ process.exitCode = 1;
72
+ }
67
73
  }
68
74
  catch (error) {
69
75
  if (error instanceof Error && 'oclif' in error)
@@ -78,7 +78,15 @@ Results: 4 passed, 1 failed
78
78
  },
79
79
  });
80
80
  if (tests.length === 0) {
81
- this.log('No unit tests found');
81
+ // An empty suite is a normal state (fresh branches have no tests) and
82
+ // is a success, not a failure. Under -o json it must still be parseable,
83
+ // with the same keys the populated path emits below.
84
+ if (flags.output === 'json') {
85
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
86
+ }
87
+ else {
88
+ this.log('No unit tests found');
89
+ }
82
90
  return;
83
91
  }
84
92
  if (flags.output === 'summary') {
@@ -46,22 +46,25 @@ Result: PASS (0.25s)
46
46
  this.error(message);
47
47
  }
48
48
  const result = (await response.json());
49
+ // Anything other than 'ok' is a failure -- the API has been observed
50
+ // returning several distinct non-ok statuses, so never enumerate them.
51
+ const passed = result.status === 'ok';
49
52
  if (flags.output === 'json') {
50
53
  this.log(JSON.stringify(result, null, 2));
51
54
  }
52
55
  else {
53
56
  const timing = result.timing ? ` (${result.timing}s)` : '';
54
- if (result.status === 'ok') {
55
- this.log(`Result: PASS${timing}`);
56
- }
57
- else {
58
- this.log(`Result: FAIL${timing}`);
59
- if (result.message) {
60
- this.log(` Error: ${result.message}`);
61
- }
62
- this.exit(1);
57
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
58
+ if (!passed && result.message) {
59
+ this.log(` Error: ${result.message}`);
63
60
  }
64
61
  }
62
+ // Set the code rather than throwing via this.exit(): the exit must apply
63
+ // in both output modes, and process.exitCode cannot be swallowed by the
64
+ // surrounding catch. Mirrors what run_all already does.
65
+ if (!passed) {
66
+ process.exitCode = 1;
67
+ }
65
68
  }
66
69
  catch (error) {
67
70
  if (error instanceof Error && 'oclif' in error)
@@ -71,7 +71,15 @@ Results: 2 passed, 1 failed
71
71
  },
72
72
  });
73
73
  if (tests.length === 0) {
74
- this.log('No workflow tests found');
74
+ // An empty suite is a normal state (fresh branches have no tests) and
75
+ // is a success, not a failure. Under -o json it must still be parseable,
76
+ // with the same keys the populated path emits below.
77
+ if (flags.output === 'json') {
78
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
79
+ }
80
+ else {
81
+ this.log('No workflow tests found');
82
+ }
75
83
  return;
76
84
  }
77
85
  if (flags.output === 'summary') {
@@ -14,4 +14,10 @@ export default class Pull extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
+ /**
18
+ * Sanitize a document name for use as a filename.
19
+ * Strips quotes, replaces spaces with underscores, and removes
20
+ * characters that are unsafe in filenames.
21
+ */
22
+ private sanitizeFilename;
17
23
  }