@xano/cli 1.0.6-beta.6 → 1.1.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 (57) hide show
  1. package/README.md +9 -67
  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/tenant/pull/index.d.ts +6 -0
  9. package/dist/commands/tenant/pull/index.js +95 -10
  10. package/dist/commands/workspace/git/pull/index.js +3 -53
  11. package/dist/commands/workspace/pull/index.d.ts +6 -0
  12. package/dist/commands/workspace/pull/index.js +95 -10
  13. package/dist/utils/document-parser.d.ts +0 -85
  14. package/dist/utils/document-parser.js +1 -214
  15. package/dist/utils/knowledge-sync.d.ts +0 -1
  16. package/dist/utils/knowledge-sync.js +5 -11
  17. package/dist/utils/paging.d.ts +17 -14
  18. package/dist/utils/paging.js +19 -14
  19. package/dist/utils/reference-checker.js +0 -13
  20. package/oclif.manifest.json +3578 -5586
  21. package/package.json +1 -10
  22. package/dist/commands/ephemeral/create/index.d.ts +0 -18
  23. package/dist/commands/ephemeral/create/index.js +0 -103
  24. package/dist/commands/ephemeral/delete/index.d.ts +0 -18
  25. package/dist/commands/ephemeral/delete/index.js +0 -99
  26. package/dist/commands/ephemeral/edit/index.d.ts +0 -18
  27. package/dist/commands/ephemeral/edit/index.js +0 -109
  28. package/dist/commands/ephemeral/get/index.d.ts +0 -21
  29. package/dist/commands/ephemeral/get/index.js +0 -125
  30. package/dist/commands/ephemeral/impersonate/index.d.ts +0 -20
  31. package/dist/commands/ephemeral/impersonate/index.js +0 -120
  32. package/dist/commands/ephemeral/list/index.d.ts +0 -19
  33. package/dist/commands/ephemeral/list/index.js +0 -123
  34. package/dist/commands/ephemeral/pull/index.d.ts +0 -19
  35. package/dist/commands/ephemeral/pull/index.js +0 -164
  36. package/dist/commands/ephemeral/push/index.d.ts +0 -25
  37. package/dist/commands/ephemeral/push/index.js +0 -144
  38. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +0 -20
  39. package/dist/commands/ephemeral/static_host/build/delete/index.js +0 -121
  40. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +0 -18
  41. package/dist/commands/ephemeral/static_host/build/get/index.js +0 -137
  42. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +0 -18
  43. package/dist/commands/ephemeral/static_host/build/list/index.js +0 -137
  44. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +0 -53
  45. package/dist/commands/ephemeral/static_host/build/pull/index.js +0 -308
  46. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +0 -24
  47. package/dist/commands/ephemeral/static_host/build/push/index.js +0 -250
  48. package/dist/commands/ephemeral/static_host/create/index.d.ts +0 -18
  49. package/dist/commands/ephemeral/static_host/create/index.js +0 -92
  50. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +0 -19
  51. package/dist/commands/ephemeral/static_host/deploy/index.js +0 -112
  52. package/dist/commands/ephemeral/static_host/edit/index.d.ts +0 -24
  53. package/dist/commands/ephemeral/static_host/edit/index.js +0 -158
  54. package/dist/commands/ephemeral/static_host/get/index.d.ts +0 -19
  55. package/dist/commands/ephemeral/static_host/get/index.js +0 -101
  56. package/dist/commands/ephemeral/static_host/list/index.d.ts +0 -17
  57. package/dist/commands/ephemeral/static_host/list/index.js +0 -132
package/README.md CHANGED
@@ -46,30 +46,30 @@ 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.
59
- - **`--output json` returns an envelope, not a bare array.** Every list command emits `{count, items, ...}`, with `page`, `per_page`, `next_page`, `prev_page`, and `total` included only when the endpoint actually reports them. This is a **breaking change** for scripts that parsed the previous bare array — read `.items` instead.
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
+ - **`--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
62
62
  // xano function list -o json
63
63
  {
64
- "count": 50,
65
- "page": 2,
66
- "per_page": 50,
67
- "next_page": 3, // absent on the last page — never inferred
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
68
  "items": [ /* ... */ ]
69
69
  }
70
70
  ```
71
71
 
72
- The point of the envelope is that a script gets the same honest stop condition a human gets from the footer. Without it the only signal available is `items.length < per_page`, which is wrong exactly when the result count is a multiple of the page size. Absence of `next_page` means the server said there is no next page; it is never omitted as a guess.
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
73
 
74
74
  ### Authentication
75
75
 
@@ -652,64 +652,6 @@ xano tenant cluster license set <cluster_id>
652
652
  xano tenant cluster license set <cluster_id> --file ./kubeconfig.yaml
653
653
  ```
654
654
 
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
655
  ### Sandbox
714
656
 
715
657
  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
  }
@@ -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
  }
@@ -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 Pull extends BaseCommand {
8
8
  static description = 'Pull a tenant multidoc from the Xano Metadata API and split into individual files';
9
9
  static examples = [
@@ -129,19 +129,96 @@ Pulled 58 documents from tenant my-tenant
129
129
  fs.mkdirSync(outputDir, { recursive: true });
130
130
  // Resolve api_group names to unique folder names, disambiguating collisions
131
131
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
132
- // Resolve a realtime v2 channel path -> owning realtime_server name, so
133
- // messages can nest under their channel's server (see resolver docs).
134
- const getChannelServer = buildChannelServerResolver(documents);
135
132
  // Track filenames per type to handle duplicates
136
133
  const filenameCounters = new Map();
137
134
  let writtenCount = 0;
138
135
  for (const doc of documents) {
139
- const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
140
- getApiGroupFolder,
141
- getChannelServer,
142
- join: path.join,
143
- snakeCase,
144
- });
136
+ let typeDir;
137
+ let baseName;
138
+ if (doc.type === 'workspace') {
139
+ // workspace → workspace/{name}.xs
140
+ typeDir = path.join(outputDir, 'workspace');
141
+ baseName = this.sanitizeFilename(doc.name);
142
+ }
143
+ else if (doc.type === 'workspace_trigger') {
144
+ // workspace_trigger → workspace/trigger/{name}.xs
145
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
146
+ baseName = this.sanitizeFilename(doc.name);
147
+ }
148
+ else if (doc.type === 'error_trigger') {
149
+ // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
150
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
151
+ baseName = this.sanitizeFilename(doc.name);
152
+ }
153
+ else if (doc.type === 'agent') {
154
+ // agent → ai/agent/{name}.xs
155
+ typeDir = path.join(outputDir, 'ai', 'agent');
156
+ baseName = this.sanitizeFilename(doc.name);
157
+ }
158
+ else if (doc.type === 'mcp_server') {
159
+ // mcp_server → ai/mcp_server/{name}.xs
160
+ typeDir = path.join(outputDir, 'ai', 'mcp_server');
161
+ baseName = this.sanitizeFilename(doc.name);
162
+ }
163
+ else if (doc.type === 'tool') {
164
+ // tool → ai/tool/{name}.xs
165
+ typeDir = path.join(outputDir, 'ai', 'tool');
166
+ baseName = this.sanitizeFilename(doc.name);
167
+ }
168
+ else if (doc.type === 'agent_trigger') {
169
+ // agent_trigger → ai/agent/trigger/{name}.xs
170
+ typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
171
+ baseName = this.sanitizeFilename(doc.name);
172
+ }
173
+ else if (doc.type === 'mcp_server_trigger') {
174
+ // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
175
+ typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
176
+ baseName = this.sanitizeFilename(doc.name);
177
+ }
178
+ else if (doc.type === 'table_trigger') {
179
+ // table_trigger → table/trigger/{name}.xs
180
+ typeDir = path.join(outputDir, 'table', 'trigger');
181
+ baseName = this.sanitizeFilename(doc.name);
182
+ }
183
+ else if (doc.type === 'realtime_channel') {
184
+ // realtime_channel → realtime/channel/{name}.xs
185
+ typeDir = path.join(outputDir, 'realtime', 'channel');
186
+ baseName = this.sanitizeFilename(doc.name);
187
+ }
188
+ else if (doc.type === 'realtime_trigger') {
189
+ // realtime_trigger → realtime/trigger/{name}.xs
190
+ typeDir = path.join(outputDir, 'realtime', 'trigger');
191
+ baseName = this.sanitizeFilename(doc.name);
192
+ }
193
+ else if (doc.type === 'api_group') {
194
+ // api_group "test" → api/{resolved_folder}/{name}.xs
195
+ const groupFolder = getApiGroupFolder(doc.name);
196
+ typeDir = path.join(outputDir, 'api', groupFolder);
197
+ baseName = this.sanitizeFilename(doc.name);
198
+ }
199
+ else if (doc.type === 'query' && doc.apiGroup) {
200
+ // query in group "test" → api/{resolved_folder}/{query_name}.xs
201
+ const groupFolder = getApiGroupFolder(doc.apiGroup);
202
+ const nameParts = doc.name.split('/');
203
+ const leafName = nameParts.pop();
204
+ const folderParts = nameParts.map((part) => snakeCase(part));
205
+ typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
206
+ baseName = this.sanitizeFilename(leafName);
207
+ if (doc.verb) {
208
+ baseName = `${baseName}_${doc.verb}`;
209
+ }
210
+ }
211
+ else {
212
+ // Default: split folder path from name
213
+ const nameParts = doc.name.split('/');
214
+ const leafName = nameParts.pop();
215
+ const folderParts = nameParts.map((part) => snakeCase(part));
216
+ typeDir = path.join(outputDir, doc.type, ...folderParts);
217
+ baseName = this.sanitizeFilename(leafName);
218
+ if (doc.verb) {
219
+ baseName = `${baseName}_${doc.verb}`;
220
+ }
221
+ }
145
222
  fs.mkdirSync(typeDir, { recursive: true });
146
223
  // Track duplicates per directory
147
224
  const dirKey = path.relative(outputDir, typeDir);
@@ -160,4 +237,12 @@ Pulled 58 documents from tenant my-tenant
160
237
  }
161
238
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
162
239
  }
240
+ /**
241
+ * Sanitize a document name for use as a filename.
242
+ * Strips quotes, replaces spaces with underscores, and removes
243
+ * characters that are unsafe in filenames.
244
+ */
245
+ sanitizeFilename(name) {
246
+ return snakeCase(name.replaceAll('"', ''));
247
+ }
163
248
  }
@@ -5,7 +5,7 @@ import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import snakeCase from 'lodash.snakecase';
7
7
  import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
8
- import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, parseDocument, } from '../../../../utils/document-parser.js';
8
+ import { buildApiGroupFolderResolver, parseDocument } from '../../../../utils/document-parser.js';
9
9
  export default class GitPull extends BaseCommand {
10
10
  static description = 'Pull XanoScript files from a git repository into a local directory';
11
11
  static examples = [
@@ -97,13 +97,10 @@ export default class GitPull extends BaseCommand {
97
97
  // Write documents to output directory using the same file tree logic as workspace pull
98
98
  fs.mkdirSync(outputDir, { recursive: true });
99
99
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
100
- // Resolve a realtime v2 channel path -> owning realtime_server name, so
101
- // messages can nest under their channel's server (see resolver docs).
102
- const getChannelServer = buildChannelServerResolver(documents);
103
100
  const filenameCounters = new Map();
104
101
  let writtenCount = 0;
105
102
  for (const doc of documents) {
106
- const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer);
103
+ const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
107
104
  fs.mkdirSync(typeDir, { recursive: true });
108
105
  // Track duplicates per directory
109
106
  const dirKey = path.relative(outputDir, typeDir);
@@ -328,7 +325,7 @@ export default class GitPull extends BaseCommand {
328
325
  * Resolve the output directory and base filename for a parsed document.
329
326
  * Uses the same type-to-directory mapping as workspace pull.
330
327
  */
331
- resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer) {
328
+ resolveOutputPath(outputDir, doc, getApiGroupFolder) {
332
329
  let typeDir;
333
330
  let baseName;
334
331
  if (doc.type === 'workspace') {
@@ -376,53 +373,6 @@ export default class GitPull extends BaseCommand {
376
373
  typeDir = path.join(outputDir, 'realtime', 'trigger');
377
374
  baseName = this.sanitizeFilename(doc.name);
378
375
  }
379
- else if (doc.type === 'realtime_server') {
380
- // Realtime v2 — see workspace/pull for the full rationale. Its own
381
- // document is named after itself, mirroring api_group.
382
- // realtime_server "chat" → realtime/server/chat/chat.xs
383
- typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
384
- baseName = this.sanitizeFilename(doc.name);
385
- }
386
- else if (doc.type === 'channel') {
387
- // Realtime v2. The channel owns a directory named after itself (the full
388
- // channel name, snake_cased into a single flat segment — like every other
389
- // object), and its messages live in a message/ subfolder inside it. It
390
- // nests under its owning realtime_server (from `server = "..."`):
391
- // channel "rooms/{room_id}" (server "chat")
392
- // → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
393
- //
394
- // The channel name is snake_cased as a whole (rooms/{room_id} →
395
- // rooms_room_id), so there is no path nesting and no bracket escaping —
396
- // the on-disk name is a display form; the .xs content keeps the real
397
- // name. A channel with no resolvable server (e.g. a pre-server export)
398
- // falls back to the legacy flat channel/<path>/_channel.xs layout.
399
- if (doc.server) {
400
- typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
401
- baseName = snakeCase(doc.name);
402
- }
403
- else {
404
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
405
- baseName = '_channel';
406
- }
407
- }
408
- else if (doc.type === 'message' && doc.channel) {
409
- // Realtime v2 message → nests in a message/ subfolder under its channel,
410
- // under that channel's server. The message names only its channel; the
411
- // server is resolved by looking that channel up in the same multidoc
412
- // (two-pass resolve).
413
- // message "post" on channel "rooms/{room_id}" (server "chat")
414
- // → realtime/server/chat/channel/rooms_room_id/message/post.xs
415
- //
416
- // Nesting matters beyond tidiness: message names are unique only WITHIN
417
- // a channel, so a flat message/ directory would collide when two
418
- // channels both define e.g. "say". A channel whose server can't be
419
- // resolved falls back to the legacy flat channel/ layout.
420
- const messageServer = getChannelServer(doc.channel);
421
- typeDir = messageServer
422
- ? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
423
- : path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
424
- baseName = this.sanitizeFilename(doc.name);
425
- }
426
376
  else if (doc.type === 'api_group') {
427
377
  const groupFolder = getApiGroupFolder(doc.name);
428
378
  typeDir = path.join(outputDir, 'api', groupFolder);
@@ -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
  }