@xano/cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +112 -3
  2. package/dist/base-command.d.ts +43 -36
  3. package/dist/base-command.js +223 -160
  4. package/dist/commands/auth/index.d.ts +1 -1
  5. package/dist/commands/auth/index.js +7 -7
  6. package/dist/commands/branch/create/index.d.ts +1 -1
  7. package/dist/commands/branch/create/index.js +1 -1
  8. package/dist/commands/ephemeral/create/index.d.ts +18 -0
  9. package/dist/commands/ephemeral/create/index.js +103 -0
  10. package/dist/commands/ephemeral/delete/index.d.ts +18 -0
  11. package/dist/commands/ephemeral/delete/index.js +99 -0
  12. package/dist/commands/ephemeral/edit/index.d.ts +18 -0
  13. package/dist/commands/ephemeral/edit/index.js +109 -0
  14. package/dist/commands/ephemeral/get/index.d.ts +21 -0
  15. package/dist/commands/ephemeral/get/index.js +125 -0
  16. package/dist/commands/ephemeral/impersonate/index.d.ts +20 -0
  17. package/dist/commands/ephemeral/impersonate/index.js +120 -0
  18. package/dist/commands/ephemeral/list/index.d.ts +19 -0
  19. package/dist/commands/ephemeral/list/index.js +123 -0
  20. package/dist/commands/ephemeral/pull/index.d.ts +19 -0
  21. package/dist/commands/ephemeral/pull/index.js +163 -0
  22. package/dist/commands/ephemeral/push/index.d.ts +35 -0
  23. package/dist/commands/ephemeral/push/index.js +223 -0
  24. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
  25. package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
  26. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
  27. package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
  28. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
  29. package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
  30. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
  31. package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
  32. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
  33. package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
  34. package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
  35. package/dist/commands/ephemeral/static_host/create/index.js +92 -0
  36. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
  37. package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
  38. package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
  39. package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
  40. package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
  41. package/dist/commands/ephemeral/static_host/get/index.js +101 -0
  42. package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
  43. package/dist/commands/ephemeral/static_host/list/index.js +132 -0
  44. package/dist/commands/flatten/index.d.ts +34 -0
  45. package/dist/commands/flatten/index.js +140 -0
  46. package/dist/commands/function/create/index.js +1 -1
  47. package/dist/commands/function/edit/index.js +26 -32
  48. package/dist/commands/function/get/index.js +2 -3
  49. package/dist/commands/function/run/index.d.ts +1 -0
  50. package/dist/commands/function/run/index.js +19 -6
  51. package/dist/commands/profile/create/index.d.ts +1 -1
  52. package/dist/commands/profile/create/index.js +7 -7
  53. package/dist/commands/profile/me/index.js +1 -1
  54. package/dist/commands/profile/use/index.js +0 -4
  55. package/dist/commands/profile/wizard/index.d.ts +1 -1
  56. package/dist/commands/profile/wizard/index.js +12 -10
  57. package/dist/commands/profile/workspace/set/index.js +1 -1
  58. package/dist/commands/release/edit/index.js +2 -2
  59. package/dist/commands/release/export/index.js +1 -2
  60. package/dist/commands/release/import/index.js +1 -1
  61. package/dist/commands/release/pull/index.d.ts +0 -6
  62. package/dist/commands/release/pull/index.js +13 -99
  63. package/dist/commands/release/push/index.js +2 -2
  64. package/dist/commands/sandbox/env/get_all/index.js +1 -1
  65. package/dist/commands/sandbox/env/set_all/index.js +1 -1
  66. package/dist/commands/sandbox/get/index.js +1 -1
  67. package/dist/commands/sandbox/license/get/index.js +1 -1
  68. package/dist/commands/sandbox/license/set/index.js +1 -1
  69. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  70. package/dist/commands/sandbox/pull/index.js +13 -81
  71. package/dist/commands/sandbox/push/index.d.ts +1 -1
  72. package/dist/commands/sandbox/push/index.js +5 -5
  73. package/dist/commands/static_host/build/create/index.d.ts +1 -1
  74. package/dist/commands/static_host/build/create/index.js +2 -2
  75. package/dist/commands/static_host/build/pull/index.js +1 -1
  76. package/dist/commands/static_host/build/push/index.js +1 -1
  77. package/dist/commands/static_host/edit/index.js +1 -1
  78. package/dist/commands/tenant/backup/export/index.js +1 -2
  79. package/dist/commands/tenant/backup/import/index.js +1 -1
  80. package/dist/commands/tenant/cluster/create/index.js +1 -1
  81. package/dist/commands/tenant/cluster/license/get/index.js +1 -1
  82. package/dist/commands/tenant/cluster/license/set/index.js +1 -1
  83. package/dist/commands/tenant/create/index.d.ts +1 -1
  84. package/dist/commands/tenant/create/index.js +6 -6
  85. package/dist/commands/tenant/deploy_platform/index.js +1 -1
  86. package/dist/commands/tenant/edit/index.js +7 -7
  87. package/dist/commands/tenant/env/get_all/index.js +1 -1
  88. package/dist/commands/tenant/env/set_all/index.js +1 -1
  89. package/dist/commands/tenant/impersonate/index.d.ts +1 -1
  90. package/dist/commands/tenant/impersonate/index.js +14 -14
  91. package/dist/commands/tenant/license/get/index.js +1 -1
  92. package/dist/commands/tenant/license/set/index.js +1 -1
  93. package/dist/commands/tenant/pull/index.d.ts +0 -6
  94. package/dist/commands/tenant/pull/index.js +14 -100
  95. package/dist/commands/workspace/git/pull/index.d.ts +4 -4
  96. package/dist/commands/workspace/git/pull/index.js +158 -97
  97. package/dist/commands/workspace/pull/index.d.ts +1 -7
  98. package/dist/commands/workspace/pull/index.js +17 -103
  99. package/dist/help.js +4 -2
  100. package/dist/update-check.d.ts +1 -1
  101. package/dist/update-check.js +1 -1
  102. package/dist/utils/document-parser.d.ts +116 -0
  103. package/dist/utils/document-parser.js +311 -4
  104. package/dist/utils/flatten.d.ts +36 -0
  105. package/dist/utils/flatten.js +68 -0
  106. package/dist/utils/knowledge-sync.d.ts +1 -0
  107. package/dist/utils/knowledge-sync.js +11 -6
  108. package/dist/utils/microservice-wait.d.ts +53 -0
  109. package/dist/utils/microservice-wait.js +84 -0
  110. package/dist/utils/multidoc-push.d.ts +88 -0
  111. package/dist/utils/multidoc-push.js +378 -15
  112. package/dist/utils/paging.d.ts +2 -1
  113. package/dist/utils/paging.js +0 -1
  114. package/dist/utils/reference-checker.js +14 -3
  115. package/oclif.manifest.json +5264 -3170
  116. package/package.json +10 -1
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as yaml from 'js-yaml';
3
3
  import * as fs from 'node:fs';
4
- import * as path from 'node:path';
4
+ import path from 'node:path';
5
5
  import BaseCommand from '../../../../base-command.js';
6
6
  export default class TenantEnvGetAll extends BaseCommand {
7
7
  static args = {
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as yaml from 'js-yaml';
3
3
  import * as fs from 'node:fs';
4
- import * as path from 'node:path';
4
+ import path from 'node:path';
5
5
  import BaseCommand from '../../../../base-command.js';
6
6
  export default class TenantEnvSetAll extends BaseCommand {
7
7
  static args = {
@@ -14,6 +14,6 @@ export default class TenantImpersonate extends BaseCommand {
14
14
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  run(): Promise<void>;
17
- private getImpersonateResponse;
18
17
  private getFrontendUrl;
18
+ private getImpersonateResponse;
19
19
  }
@@ -76,13 +76,26 @@ Impersonation successful!
76
76
  }
77
77
  }
78
78
  }
79
+ getFrontendUrl(instanceOrigin) {
80
+ try {
81
+ const url = new URL(instanceOrigin);
82
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
83
+ url.port = '4200';
84
+ return url.origin;
85
+ }
86
+ }
87
+ catch {
88
+ // fall through
89
+ }
90
+ return instanceOrigin;
91
+ }
79
92
  async getImpersonateResponse(profile, workspaceId, tenantName) {
80
93
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${encodeURIComponent(tenantName)}/impersonate`;
81
94
  const { verbose } = await this.parse(TenantImpersonate).then((r) => r.flags);
82
95
  const response = await this.verboseFetch(apiUrl, {
83
96
  headers: {
84
- Authorization: `Bearer ${profile.access_token}`,
85
97
  accept: 'application/json',
98
+ Authorization: `Bearer ${profile.access_token}`,
86
99
  },
87
100
  method: 'GET',
88
101
  }, verbose, profile.access_token);
@@ -96,17 +109,4 @@ Impersonation successful!
96
109
  }
97
110
  return result;
98
111
  }
99
- getFrontendUrl(instanceOrigin) {
100
- try {
101
- const url = new URL(instanceOrigin);
102
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
103
- url.port = '4200';
104
- return url.origin;
105
- }
106
- }
107
- catch {
108
- // fall through
109
- }
110
- return instanceOrigin;
111
- }
112
112
  }
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import * as path from 'node:path';
3
+ import path from 'node:path';
4
4
  import BaseCommand from '../../../../base-command.js';
5
5
  export default class TenantLicenseGet extends BaseCommand {
6
6
  static args = {
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import * as path from 'node:path';
3
+ import path from 'node:path';
4
4
  import BaseCommand from '../../../../base-command.js';
5
5
  export default class TenantLicenseSet extends BaseCommand {
6
6
  static args = {
@@ -14,10 +14,4 @@ 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;
23
17
  }
@@ -1,9 +1,9 @@
1
1
  import { Flags } from '@oclif/core';
2
- import * as fs from 'node:fs';
3
- import * as path from 'node:path';
4
2
  import snakeCase from 'lodash.snakecase';
3
+ import * as fs from 'node:fs';
4
+ import path from 'node:path';
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 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 = [
@@ -57,7 +57,7 @@ Pulled 58 documents from tenant my-tenant
57
57
  };
58
58
  async run() {
59
59
  const { flags } = await this.parse(Pull);
60
- const { profileName, profile } = this.resolveProfile(flags);
60
+ const { profile, profileName } = this.resolveProfile(flags);
61
61
  // Determine workspace_id from flag or profile
62
62
  let workspaceId;
63
63
  if (flags.workspace) {
@@ -129,96 +129,19 @@ 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);
132
135
  // Track filenames per type to handle duplicates
133
136
  const filenameCounters = new Map();
134
137
  let writtenCount = 0;
135
138
  for (const doc of documents) {
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
- }
139
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
140
+ getApiGroupFolder,
141
+ getChannelServer,
142
+ join: path.join,
143
+ snakeCase,
144
+ });
222
145
  fs.mkdirSync(typeDir, { recursive: true });
223
146
  // Track duplicates per directory
224
147
  const dirKey = path.relative(outputDir, typeDir);
@@ -229,20 +152,11 @@ Pulled 58 documents from tenant my-tenant
229
152
  const count = typeCounters.get(baseName) || 0;
230
153
  typeCounters.set(baseName, count + 1);
231
154
  // Append numeric suffix for duplicates
232
- let filename;
233
- filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
155
+ const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
234
156
  const filePath = path.join(typeDir, filename);
235
157
  fs.writeFileSync(filePath, doc.content, 'utf8');
236
158
  writtenCount++;
237
159
  }
238
160
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
239
161
  }
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
- }
248
162
  }
@@ -13,14 +13,14 @@ export default class GitPull extends BaseCommand {
13
13
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
14
  };
15
15
  run(): Promise<void>;
16
- /**
17
- * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
18
- */
19
- private collectFiles;
20
16
  /**
21
17
  * Clone a git repository using shallow clone.
22
18
  */
23
19
  private cloneRepo;
20
+ /**
21
+ * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
22
+ */
23
+ private collectFiles;
24
24
  /**
25
25
  * Fetch a GitHub repository via the tarball API for fast download without requiring git.
26
26
  */
@@ -1,11 +1,11 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import snakeCase from 'lodash.snakecase';
2
3
  import { execSync } from 'node:child_process';
3
4
  import * as fs from 'node:fs';
4
5
  import * as os from 'node:os';
5
- import * as path from 'node:path';
6
- import snakeCase from 'lodash.snakecase';
6
+ import path from 'node:path';
7
7
  import BaseCommand, { buildUserAgent } from '../../../../base-command.js';
8
- import { buildApiGroupFolderResolver, parseDocument } from '../../../../utils/document-parser.js';
8
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, channelPathSegments, 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 = [
@@ -60,13 +60,7 @@ export default class GitPull extends BaseCommand {
60
60
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xano-git-pull-'));
61
61
  try {
62
62
  // Fetch repository contents
63
- let repoRoot;
64
- if (repoInfo.host === 'github') {
65
- repoRoot = await this.fetchGitHubTarball(repoInfo.owner, repoInfo.repo, ref, token, tempDir, flags.verbose);
66
- }
67
- else {
68
- repoRoot = this.cloneRepo(repoInfo.url, ref, token, tempDir, flags.verbose);
69
- }
63
+ const repoRoot = repoInfo.host === 'github' ? (await this.fetchGitHubTarball(repoInfo.owner, repoInfo.repo, ref, token, tempDir, flags.verbose)) : this.cloneRepo(repoInfo.url, ref, token, tempDir, flags.verbose);
70
64
  // Determine source directory (optionally scoped to --path or URL path)
71
65
  const sourceDir = subPath ? path.join(repoRoot, subPath) : repoRoot;
72
66
  if (!fs.existsSync(sourceDir)) {
@@ -97,10 +91,13 @@ export default class GitPull extends BaseCommand {
97
91
  // Write documents to output directory using the same file tree logic as workspace pull
98
92
  fs.mkdirSync(outputDir, { recursive: true });
99
93
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
94
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
95
+ // messages can nest under their channel's server (see resolver docs).
96
+ const getChannelServer = buildChannelServerResolver(documents);
100
97
  const filenameCounters = new Map();
101
98
  let writtenCount = 0;
102
99
  for (const doc of documents) {
103
- const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
100
+ const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer);
104
101
  fs.mkdirSync(typeDir, { recursive: true });
105
102
  // Track duplicates per directory
106
103
  const dirKey = path.relative(outputDir, typeDir);
@@ -123,23 +120,6 @@ export default class GitPull extends BaseCommand {
123
120
  fs.rmSync(tempDir, { force: true, recursive: true });
124
121
  }
125
122
  }
126
- /**
127
- * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
128
- */
129
- collectFiles(dir) {
130
- const files = [];
131
- const entries = fs.readdirSync(dir, { withFileTypes: true });
132
- for (const entry of entries) {
133
- const fullPath = path.join(dir, entry.name);
134
- if (entry.isDirectory()) {
135
- files.push(...this.collectFiles(fullPath));
136
- }
137
- else if (entry.isFile() && entry.name.endsWith('.xs')) {
138
- files.push(fullPath);
139
- }
140
- }
141
- return files.sort();
142
- }
143
123
  /**
144
124
  * Clone a git repository using shallow clone.
145
125
  */
@@ -168,6 +148,23 @@ export default class GitPull extends BaseCommand {
168
148
  }
169
149
  return cloneTarget;
170
150
  }
151
+ /**
152
+ * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
153
+ */
154
+ collectFiles(dir) {
155
+ const files = [];
156
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ const fullPath = path.join(dir, entry.name);
159
+ if (entry.isDirectory()) {
160
+ files.push(...this.collectFiles(fullPath));
161
+ }
162
+ else if (entry.isFile() && entry.name.endsWith('.xs')) {
163
+ files.push(fullPath);
164
+ }
165
+ }
166
+ return files.sort();
167
+ }
171
168
  /**
172
169
  * Fetch a GitHub repository via the tarball API for fast download without requiring git.
173
170
  */
@@ -325,78 +322,142 @@ export default class GitPull extends BaseCommand {
325
322
  * Resolve the output directory and base filename for a parsed document.
326
323
  * Uses the same type-to-directory mapping as workspace pull.
327
324
  */
328
- resolveOutputPath(outputDir, doc, getApiGroupFolder) {
325
+ resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer) {
329
326
  let typeDir;
330
327
  let baseName;
331
- if (doc.type === 'workspace') {
332
- typeDir = path.join(outputDir, 'workspace');
333
- baseName = this.sanitizeFilename(doc.name);
334
- }
335
- else if (doc.type === 'workspace_trigger') {
336
- typeDir = path.join(outputDir, 'workspace', 'trigger');
337
- baseName = this.sanitizeFilename(doc.name);
338
- }
339
- else if (doc.type === 'error_trigger') {
340
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
341
- typeDir = path.join(outputDir, 'workspace', 'trigger');
342
- baseName = this.sanitizeFilename(doc.name);
343
- }
344
- else if (doc.type === 'agent') {
345
- typeDir = path.join(outputDir, 'ai', 'agent');
346
- baseName = this.sanitizeFilename(doc.name);
347
- }
348
- else if (doc.type === 'mcp_server') {
349
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
350
- baseName = this.sanitizeFilename(doc.name);
351
- }
352
- else if (doc.type === 'tool') {
353
- typeDir = path.join(outputDir, 'ai', 'tool');
354
- baseName = this.sanitizeFilename(doc.name);
355
- }
356
- else if (doc.type === 'agent_trigger') {
357
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
358
- baseName = this.sanitizeFilename(doc.name);
359
- }
360
- else if (doc.type === 'mcp_server_trigger') {
361
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
362
- baseName = this.sanitizeFilename(doc.name);
363
- }
364
- else if (doc.type === 'table_trigger') {
365
- typeDir = path.join(outputDir, 'table', 'trigger');
366
- baseName = this.sanitizeFilename(doc.name);
367
- }
368
- else if (doc.type === 'realtime_channel') {
369
- typeDir = path.join(outputDir, 'realtime', 'channel');
370
- baseName = this.sanitizeFilename(doc.name);
371
- }
372
- else if (doc.type === 'realtime_trigger') {
373
- typeDir = path.join(outputDir, 'realtime', 'trigger');
374
- baseName = this.sanitizeFilename(doc.name);
375
- }
376
- else if (doc.type === 'api_group') {
377
- const groupFolder = getApiGroupFolder(doc.name);
378
- typeDir = path.join(outputDir, 'api', groupFolder);
379
- baseName = this.sanitizeFilename(doc.name);
380
- }
381
- else if (doc.type === 'query' && doc.apiGroup) {
382
- const groupFolder = getApiGroupFolder(doc.apiGroup);
383
- const nameParts = doc.name.split('/');
384
- const leafName = nameParts.pop();
385
- const folderParts = nameParts.map((part) => snakeCase(part));
386
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
387
- baseName = this.sanitizeFilename(leafName);
388
- if (doc.verb) {
389
- baseName = `${baseName}_${doc.verb}`;
328
+ switch (doc.type) {
329
+ case 'agent': {
330
+ typeDir = path.join(outputDir, 'ai', 'agent');
331
+ baseName = this.sanitizeFilename(doc.name);
332
+ break;
390
333
  }
391
- }
392
- else {
393
- const nameParts = doc.name.split('/');
394
- const leafName = nameParts.pop();
395
- const folderParts = nameParts.map((part) => snakeCase(part));
396
- typeDir = path.join(outputDir, doc.type, ...folderParts);
397
- baseName = this.sanitizeFilename(leafName);
398
- if (doc.verb) {
399
- baseName = `${baseName}_${doc.verb}`;
334
+ case 'agent_trigger': {
335
+ typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
336
+ baseName = this.sanitizeFilename(doc.name);
337
+ break;
338
+ }
339
+ case 'channel': {
340
+ // Realtime v2. The channel owns a directory named after itself (the full
341
+ // channel name, snake_cased into a single flat segment — like every other
342
+ // object), and its messages live in a message/ subfolder inside it. It
343
+ // nests under its owning realtime_server (from `server = "..."`):
344
+ // channel "rooms/{room_id}" (server "chat")
345
+ // → realtime/server/chat/channel/rooms_room_id/rooms_room_id.xs
346
+ //
347
+ // The channel name is snake_cased as a whole (rooms/{room_id} →
348
+ // rooms_room_id), so there is no path nesting and no bracket escaping —
349
+ // the on-disk name is a display form; the .xs content keeps the real
350
+ // name. A channel with no resolvable server (e.g. a pre-server export)
351
+ // falls back to the legacy flat channel/<path>/_channel.xs layout.
352
+ if (doc.server) {
353
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.server), 'channel', snakeCase(doc.name));
354
+ baseName = snakeCase(doc.name);
355
+ }
356
+ else {
357
+ typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
358
+ baseName = '_channel';
359
+ }
360
+ break;
361
+ }
362
+ case 'error_trigger': {
363
+ // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
364
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
365
+ baseName = this.sanitizeFilename(doc.name);
366
+ break;
367
+ }
368
+ case 'mcp_server': {
369
+ typeDir = path.join(outputDir, 'ai', 'mcp_server');
370
+ baseName = this.sanitizeFilename(doc.name);
371
+ break;
372
+ }
373
+ case 'mcp_server_trigger': {
374
+ typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
375
+ baseName = this.sanitizeFilename(doc.name);
376
+ break;
377
+ }
378
+ case 'realtime_channel': {
379
+ typeDir = path.join(outputDir, 'realtime', 'channel');
380
+ baseName = this.sanitizeFilename(doc.name);
381
+ break;
382
+ }
383
+ case 'realtime_server': {
384
+ // Realtime v2 — see workspace/pull for the full rationale. Its own
385
+ // document is named after itself, mirroring api_group.
386
+ // realtime_server "chat" → realtime/server/chat/chat.xs
387
+ typeDir = path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(doc.name));
388
+ baseName = this.sanitizeFilename(doc.name);
389
+ break;
390
+ }
391
+ case 'realtime_trigger': {
392
+ typeDir = path.join(outputDir, 'realtime', 'trigger');
393
+ baseName = this.sanitizeFilename(doc.name);
394
+ break;
395
+ }
396
+ case 'table_trigger': {
397
+ typeDir = path.join(outputDir, 'table', 'trigger');
398
+ baseName = this.sanitizeFilename(doc.name);
399
+ break;
400
+ }
401
+ case 'tool': {
402
+ typeDir = path.join(outputDir, 'ai', 'tool');
403
+ baseName = this.sanitizeFilename(doc.name);
404
+ break;
405
+ }
406
+ case 'workspace': {
407
+ typeDir = path.join(outputDir, 'workspace');
408
+ baseName = this.sanitizeFilename(doc.name);
409
+ break;
410
+ }
411
+ case 'workspace_trigger': {
412
+ typeDir = path.join(outputDir, 'workspace', 'trigger');
413
+ baseName = this.sanitizeFilename(doc.name);
414
+ break;
415
+ }
416
+ default: {
417
+ if (doc.type === 'message' && doc.channel) {
418
+ // Realtime v2 message → nests in a message/ subfolder under its channel,
419
+ // under that channel's server. The message names only its channel; the
420
+ // server is resolved by looking that channel up in the same multidoc
421
+ // (two-pass resolve).
422
+ // message "post" on channel "rooms/{room_id}" (server "chat")
423
+ // → realtime/server/chat/channel/rooms_room_id/message/post.xs
424
+ //
425
+ // Nesting matters beyond tidiness: message names are unique only WITHIN
426
+ // a channel, so a flat message/ directory would collide when two
427
+ // channels both define e.g. "say". A channel whose server can't be
428
+ // resolved falls back to the legacy flat channel/ layout.
429
+ const messageServer = getChannelServer(doc.channel);
430
+ typeDir = messageServer
431
+ ? path.join(outputDir, 'realtime', 'server', this.sanitizeFilename(messageServer), 'channel', snakeCase(doc.channel), 'message')
432
+ : path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
433
+ baseName = this.sanitizeFilename(doc.name);
434
+ }
435
+ else if (doc.type === 'api_group') {
436
+ const groupFolder = getApiGroupFolder(doc.name);
437
+ typeDir = path.join(outputDir, 'api', groupFolder);
438
+ baseName = this.sanitizeFilename(doc.name);
439
+ }
440
+ else if (doc.type === 'query' && doc.apiGroup) {
441
+ const groupFolder = getApiGroupFolder(doc.apiGroup);
442
+ const nameParts = doc.name.split('/');
443
+ const leafName = nameParts.pop();
444
+ const folderParts = nameParts.map((part) => snakeCase(part));
445
+ typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
446
+ baseName = this.sanitizeFilename(leafName);
447
+ if (doc.verb) {
448
+ baseName = `${baseName}_${doc.verb}`;
449
+ }
450
+ }
451
+ else {
452
+ const nameParts = doc.name.split('/');
453
+ const leafName = nameParts.pop();
454
+ const folderParts = nameParts.map((part) => snakeCase(part));
455
+ typeDir = path.join(outputDir, doc.type, ...folderParts);
456
+ baseName = this.sanitizeFilename(leafName);
457
+ if (doc.verb) {
458
+ baseName = `${baseName}_${doc.verb}`;
459
+ }
460
+ }
400
461
  }
401
462
  }
402
463
  return { baseName, typeDir };
@@ -5,8 +5,8 @@ export default class Pull extends BaseCommand {
5
5
  static flags: {
6
6
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  directory: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
- env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
8
  draft: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -14,10 +14,4 @@ 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;
23
17
  }