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

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.
@@ -16,10 +16,4 @@ export default class EphemeralPull extends BaseCommand {
16
16
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  };
18
18
  run(): Promise<void>;
19
- /**
20
- * Sanitize a document name for use as a filename.
21
- * Strips quotes, replaces spaces with underscores, and removes
22
- * characters that are unsafe in filenames.
23
- */
24
- private sanitizeFilename;
25
19
  }
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import snakeCase from 'lodash.snakecase';
5
5
  import BaseCommand from '../../../base-command.js';
6
- import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  export default class EphemeralPull extends BaseCommand {
8
8
  static args = {
9
9
  tenant_name: Args.string({
@@ -130,96 +130,19 @@ Pulled 58 documents from tenant e4f2-9ab1-xyz1
130
130
  fs.mkdirSync(outputDir, { recursive: true });
131
131
  // Resolve api_group names to unique folder names, disambiguating collisions
132
132
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
133
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
134
+ // messages can nest under their channel's server (see resolver docs).
135
+ const getChannelServer = buildChannelServerResolver(documents);
133
136
  // Track filenames per type to handle duplicates
134
137
  const filenameCounters = new Map();
135
138
  let writtenCount = 0;
136
139
  for (const doc of documents) {
137
- let typeDir;
138
- let baseName;
139
- if (doc.type === 'workspace') {
140
- // workspace → workspace/{name}.xs
141
- typeDir = path.join(outputDir, 'workspace');
142
- baseName = this.sanitizeFilename(doc.name);
143
- }
144
- else if (doc.type === 'workspace_trigger') {
145
- // workspace_trigger → workspace/trigger/{name}.xs
146
- typeDir = path.join(outputDir, 'workspace', 'trigger');
147
- baseName = this.sanitizeFilename(doc.name);
148
- }
149
- else if (doc.type === 'error_trigger') {
150
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
151
- typeDir = path.join(outputDir, 'workspace', 'trigger');
152
- baseName = this.sanitizeFilename(doc.name);
153
- }
154
- else if (doc.type === 'agent') {
155
- // agent → ai/agent/{name}.xs
156
- typeDir = path.join(outputDir, 'ai', 'agent');
157
- baseName = this.sanitizeFilename(doc.name);
158
- }
159
- else if (doc.type === 'mcp_server') {
160
- // mcp_server → ai/mcp_server/{name}.xs
161
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
162
- baseName = this.sanitizeFilename(doc.name);
163
- }
164
- else if (doc.type === 'tool') {
165
- // tool → ai/tool/{name}.xs
166
- typeDir = path.join(outputDir, 'ai', 'tool');
167
- baseName = this.sanitizeFilename(doc.name);
168
- }
169
- else if (doc.type === 'agent_trigger') {
170
- // agent_trigger → ai/agent/trigger/{name}.xs
171
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
172
- baseName = this.sanitizeFilename(doc.name);
173
- }
174
- else if (doc.type === 'mcp_server_trigger') {
175
- // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
176
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
177
- baseName = this.sanitizeFilename(doc.name);
178
- }
179
- else if (doc.type === 'table_trigger') {
180
- // table_trigger → table/trigger/{name}.xs
181
- typeDir = path.join(outputDir, 'table', 'trigger');
182
- baseName = this.sanitizeFilename(doc.name);
183
- }
184
- else if (doc.type === 'realtime_channel') {
185
- // realtime_channel → realtime/channel/{name}.xs
186
- typeDir = path.join(outputDir, 'realtime', 'channel');
187
- baseName = this.sanitizeFilename(doc.name);
188
- }
189
- else if (doc.type === 'realtime_trigger') {
190
- // realtime_trigger → realtime/trigger/{name}.xs
191
- typeDir = path.join(outputDir, 'realtime', 'trigger');
192
- baseName = this.sanitizeFilename(doc.name);
193
- }
194
- else if (doc.type === 'api_group') {
195
- // api_group "test" → api/{resolved_folder}/{name}.xs
196
- const groupFolder = getApiGroupFolder(doc.name);
197
- typeDir = path.join(outputDir, 'api', groupFolder);
198
- baseName = this.sanitizeFilename(doc.name);
199
- }
200
- else if (doc.type === 'query' && doc.apiGroup) {
201
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
202
- const groupFolder = getApiGroupFolder(doc.apiGroup);
203
- const nameParts = doc.name.split('/');
204
- const leafName = nameParts.pop();
205
- const folderParts = nameParts.map((part) => snakeCase(part));
206
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
207
- baseName = this.sanitizeFilename(leafName);
208
- if (doc.verb) {
209
- baseName = `${baseName}_${doc.verb}`;
210
- }
211
- }
212
- else {
213
- // Default: split folder path from name
214
- const nameParts = doc.name.split('/');
215
- const leafName = nameParts.pop();
216
- const folderParts = nameParts.map((part) => snakeCase(part));
217
- typeDir = path.join(outputDir, doc.type, ...folderParts);
218
- baseName = this.sanitizeFilename(leafName);
219
- if (doc.verb) {
220
- baseName = `${baseName}_${doc.verb}`;
221
- }
222
- }
140
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
141
+ getApiGroupFolder,
142
+ getChannelServer,
143
+ join: path.join,
144
+ snakeCase,
145
+ });
223
146
  fs.mkdirSync(typeDir, { recursive: true });
224
147
  // Track duplicates per directory
225
148
  const dirKey = path.relative(outputDir, typeDir);
@@ -238,12 +161,4 @@ Pulled 58 documents from tenant e4f2-9ab1-xyz1
238
161
  }
239
162
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
240
163
  }
241
- /**
242
- * Sanitize a document name for use as a filename.
243
- * Strips quotes, replaces spaces with underscores, and removes
244
- * characters that are unsafe in filenames.
245
- */
246
- sanitizeFilename(name) {
247
- return snakeCase(name.replaceAll('"', ''));
248
- }
249
164
  }
@@ -14,10 +14,4 @@ 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;
23
17
  }
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import snakeCase from 'lodash.snakecase';
5
5
  import BaseCommand from '../../../base-command.js';
6
- import { buildApiGroupFolderResolver, parseDocument } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  export default class 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,96 +123,19 @@ 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);
126
129
  // Track filenames per type to handle duplicates
127
130
  const filenameCounters = new Map();
128
131
  let writtenCount = 0;
129
132
  for (const doc of documents) {
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
- }
133
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
134
+ getApiGroupFolder,
135
+ getChannelServer,
136
+ join: path.join,
137
+ snakeCase,
138
+ });
216
139
  fs.mkdirSync(typeDir, { recursive: true });
217
140
  // Track duplicates per directory
218
141
  const dirKey = path.relative(outputDir, typeDir);
@@ -257,12 +180,4 @@ Pulled 58 documents from release 'v1.0'
257
180
  }
258
181
  return match.id;
259
182
  }
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
- }
268
183
  }
@@ -12,5 +12,4 @@ 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;
16
15
  }
@@ -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, parseDocument } from '../../../utils/document-parser.js';
4
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } 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,82 +92,18 @@ 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);
95
98
  const filenameCounters = new Map();
96
99
  let writtenCount = 0;
97
100
  for (const doc of documents) {
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
- }
101
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
102
+ getApiGroupFolder,
103
+ getChannelServer,
104
+ join: path.join,
105
+ snakeCase,
106
+ });
171
107
  fs.mkdirSync(typeDir, { recursive: true });
172
108
  const dirKey = path.relative(outputDir, typeDir);
173
109
  if (!filenameCounters.has(dirKey)) {
@@ -194,7 +130,4 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
194
130
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
195
131
  this.log(`Pulled ${parts.join(' + ')} from sandbox environment to ${flags.directory}`);
196
132
  }
197
- sanitizeFilename(name) {
198
- return snakeCase(name.replaceAll('"', ''));
199
- }
200
133
  }
@@ -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
  }
@@ -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, channelPathSegments, 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 = [
@@ -129,107 +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 === 'channel') {
194
- // Realtime v2 — see workspace/pull for the full rationale.
195
- // channel "rooms/{room_id}" → channel/rooms/[room_id]/_channel.xs
196
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
197
- baseName = '_channel';
198
- }
199
- else if (doc.type === 'message' && doc.channel) {
200
- // Realtime v2 message → channel/{channel_path}/{message_name}.xs
201
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
202
- baseName = this.sanitizeFilename(doc.name);
203
- }
204
- else if (doc.type === 'api_group') {
205
- // api_group "test" → api/{resolved_folder}/{name}.xs
206
- const groupFolder = getApiGroupFolder(doc.name);
207
- typeDir = path.join(outputDir, 'api', groupFolder);
208
- baseName = this.sanitizeFilename(doc.name);
209
- }
210
- else if (doc.type === 'query' && doc.apiGroup) {
211
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
212
- const groupFolder = getApiGroupFolder(doc.apiGroup);
213
- const nameParts = doc.name.split('/');
214
- const leafName = nameParts.pop();
215
- const folderParts = nameParts.map((part) => snakeCase(part));
216
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
217
- baseName = this.sanitizeFilename(leafName);
218
- if (doc.verb) {
219
- baseName = `${baseName}_${doc.verb}`;
220
- }
221
- }
222
- else {
223
- // Default: split folder path from name
224
- const nameParts = doc.name.split('/');
225
- const leafName = nameParts.pop();
226
- const folderParts = nameParts.map((part) => snakeCase(part));
227
- typeDir = path.join(outputDir, doc.type, ...folderParts);
228
- baseName = this.sanitizeFilename(leafName);
229
- if (doc.verb) {
230
- baseName = `${baseName}_${doc.verb}`;
231
- }
232
- }
139
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
140
+ getApiGroupFolder,
141
+ getChannelServer,
142
+ join: path.join,
143
+ snakeCase,
144
+ });
233
145
  fs.mkdirSync(typeDir, { recursive: true });
234
146
  // Track duplicates per directory
235
147
  const dirKey = path.relative(outputDir, typeDir);
@@ -248,12 +160,4 @@ Pulled 58 documents from tenant my-tenant
248
160
  }
249
161
  this.log(`Pulled ${writtenCount} documents from tenant ${tenantName} to ${flags.directory}`);
250
162
  }
251
- /**
252
- * Sanitize a document name for use as a filename.
253
- * Strips quotes, replaces spaces with underscores, and removes
254
- * characters that are unsafe in filenames.
255
- */
256
- sanitizeFilename(name) {
257
- return snakeCase(name.replaceAll('"', ''));
258
- }
259
163
  }
@@ -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, channelPathSegments, 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 = [
@@ -97,10 +97,13 @@ 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);
100
103
  const filenameCounters = new Map();
101
104
  let writtenCount = 0;
102
105
  for (const doc of documents) {
103
- const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder);
106
+ const { baseName, typeDir } = this.resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer);
104
107
  fs.mkdirSync(typeDir, { recursive: true });
105
108
  // Track duplicates per directory
106
109
  const dirKey = path.relative(outputDir, typeDir);
@@ -325,7 +328,7 @@ export default class GitPull extends BaseCommand {
325
328
  * Resolve the output directory and base filename for a parsed document.
326
329
  * Uses the same type-to-directory mapping as workspace pull.
327
330
  */
328
- resolveOutputPath(outputDir, doc, getApiGroupFolder) {
331
+ resolveOutputPath(outputDir, doc, getApiGroupFolder, getChannelServer) {
329
332
  let typeDir;
330
333
  let baseName;
331
334
  if (doc.type === 'workspace') {
@@ -373,13 +376,51 @@ export default class GitPull extends BaseCommand {
373
376
  typeDir = path.join(outputDir, 'realtime', 'trigger');
374
377
  baseName = this.sanitizeFilename(doc.name);
375
378
  }
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
+ }
376
386
  else if (doc.type === 'channel') {
377
- // Realtime v2 — see workspace/pull for the full rationale.
378
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.name, snakeCase));
379
- baseName = '_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
+ }
380
407
  }
381
408
  else if (doc.type === 'message' && doc.channel) {
382
- typeDir = path.join(outputDir, 'channel', ...channelPathSegments(doc.channel, snakeCase));
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));
383
424
  baseName = this.sanitizeFilename(doc.name);
384
425
  }
385
426
  else if (doc.type === 'api_group') {
@@ -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
  }