@xano/cli 1.0.6-beta.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +46 -62
  2. package/dist/base-command.d.ts +0 -2
  3. package/dist/base-command.js +4 -10
  4. package/dist/commands/release/pull/index.d.ts +6 -0
  5. package/dist/commands/release/pull/index.js +95 -10
  6. package/dist/commands/sandbox/pull/index.d.ts +1 -0
  7. package/dist/commands/sandbox/pull/index.js +77 -10
  8. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  9. package/dist/commands/sandbox/unit_test/run_all/index.js +9 -1
  10. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  11. package/dist/commands/sandbox/workflow_test/run_all/index.js +9 -1
  12. package/dist/commands/tenant/pull/index.d.ts +6 -0
  13. package/dist/commands/tenant/pull/index.js +95 -10
  14. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  15. package/dist/commands/tenant/unit_test/run_all/index.js +13 -1
  16. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  17. package/dist/commands/tenant/workflow_test/run_all/index.js +13 -1
  18. package/dist/commands/unit_test/run/index.js +19 -11
  19. package/dist/commands/unit_test/run_all/index.js +13 -1
  20. package/dist/commands/workflow_test/run/index.js +15 -10
  21. package/dist/commands/workflow_test/run_all/index.js +15 -3
  22. package/dist/commands/workspace/git/pull/index.js +3 -53
  23. package/dist/commands/workspace/pull/index.d.ts +6 -0
  24. package/dist/commands/workspace/pull/index.js +95 -10
  25. package/dist/utils/document-parser.d.ts +0 -85
  26. package/dist/utils/document-parser.js +1 -214
  27. package/dist/utils/knowledge-sync.d.ts +0 -1
  28. package/dist/utils/knowledge-sync.js +5 -11
  29. package/dist/utils/paging.d.ts +1 -2
  30. package/dist/utils/reference-checker.js +0 -13
  31. package/oclif.manifest.json +2796 -4820
  32. package/package.json +1 -10
  33. package/dist/commands/ephemeral/create/index.d.ts +0 -18
  34. package/dist/commands/ephemeral/create/index.js +0 -103
  35. package/dist/commands/ephemeral/delete/index.d.ts +0 -18
  36. package/dist/commands/ephemeral/delete/index.js +0 -99
  37. package/dist/commands/ephemeral/edit/index.d.ts +0 -18
  38. package/dist/commands/ephemeral/edit/index.js +0 -109
  39. package/dist/commands/ephemeral/get/index.d.ts +0 -21
  40. package/dist/commands/ephemeral/get/index.js +0 -125
  41. package/dist/commands/ephemeral/impersonate/index.d.ts +0 -20
  42. package/dist/commands/ephemeral/impersonate/index.js +0 -120
  43. package/dist/commands/ephemeral/list/index.d.ts +0 -19
  44. package/dist/commands/ephemeral/list/index.js +0 -123
  45. package/dist/commands/ephemeral/pull/index.d.ts +0 -19
  46. package/dist/commands/ephemeral/pull/index.js +0 -164
  47. package/dist/commands/ephemeral/push/index.d.ts +0 -35
  48. package/dist/commands/ephemeral/push/index.js +0 -221
  49. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +0 -20
  50. package/dist/commands/ephemeral/static_host/build/delete/index.js +0 -121
  51. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +0 -18
  52. package/dist/commands/ephemeral/static_host/build/get/index.js +0 -137
  53. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +0 -18
  54. package/dist/commands/ephemeral/static_host/build/list/index.js +0 -137
  55. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +0 -53
  56. package/dist/commands/ephemeral/static_host/build/pull/index.js +0 -308
  57. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +0 -24
  58. package/dist/commands/ephemeral/static_host/build/push/index.js +0 -250
  59. package/dist/commands/ephemeral/static_host/create/index.d.ts +0 -18
  60. package/dist/commands/ephemeral/static_host/create/index.js +0 -92
  61. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +0 -19
  62. package/dist/commands/ephemeral/static_host/deploy/index.js +0 -112
  63. package/dist/commands/ephemeral/static_host/edit/index.d.ts +0 -24
  64. package/dist/commands/ephemeral/static_host/edit/index.js +0 -158
  65. package/dist/commands/ephemeral/static_host/get/index.d.ts +0 -19
  66. package/dist/commands/ephemeral/static_host/get/index.js +0 -101
  67. package/dist/commands/ephemeral/static_host/list/index.d.ts +0 -17
  68. package/dist/commands/ephemeral/static_host/list/index.js +0 -132
  69. package/dist/utils/microservice-wait.d.ts +0 -53
  70. package/dist/utils/microservice-wait.js +0 -83
@@ -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
  }
@@ -68,26 +68,34 @@ Result: PASS
68
68
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
69
69
  }
70
70
  const result = (await response.json());
71
+ // Anything other than 'ok' is a failure -- the API has been observed
72
+ // returning several distinct non-ok statuses, so never enumerate them.
73
+ const passed = result.status === 'ok';
71
74
  if (flags.output === 'json') {
72
75
  this.log(JSON.stringify(result, null, 2));
73
76
  }
77
+ else if (passed) {
78
+ this.log('Result: PASS');
79
+ }
74
80
  else {
75
- if (result.status === 'ok') {
76
- this.log('Result: PASS');
77
- }
78
- else {
79
- this.log('Result: FAIL');
80
- const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
81
- for (const expect of failedExpects) {
82
- if (expect.message) {
83
- this.log(` Error: ${expect.message}`);
84
- }
81
+ this.log('Result: FAIL');
82
+ const failedExpects = result.results?.filter((r) => r.status === 'fail') ?? [];
83
+ for (const expect of failedExpects) {
84
+ if (expect.message) {
85
+ this.log(` Error: ${expect.message}`);
85
86
  }
86
- this.exit(1);
87
87
  }
88
88
  }
89
+ // Set the code rather than throwing via this.exit(): the exit must apply
90
+ // in both output modes, and process.exitCode cannot be swallowed by the
91
+ // surrounding catch. Mirrors what run_all already does.
92
+ if (!passed) {
93
+ process.exitCode = 1;
94
+ }
89
95
  }
90
96
  catch (error) {
97
+ if (error instanceof Error && 'oclif' in error)
98
+ throw error;
91
99
  if (error instanceof Error) {
92
100
  this.error(`Failed to run unit test: ${error.message}`);
93
101
  }
@@ -95,7 +95,15 @@ Results: 4 passed, 1 failed
95
95
  },
96
96
  });
97
97
  if (tests.length === 0) {
98
- this.log('No unit tests found');
98
+ // An empty suite is a normal state (fresh branches have no tests) and
99
+ // is a success, not a failure. Under -o json it must still be parseable,
100
+ // with the same keys the populated path emits below.
101
+ if (flags.output === 'json') {
102
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
103
+ }
104
+ else {
105
+ this.log('No unit tests found');
106
+ }
99
107
  return;
100
108
  }
101
109
  if (flags.output === 'summary') {
@@ -162,6 +170,8 @@ Results: 4 passed, 1 failed
162
170
  }
163
171
  }
164
172
  catch (error) {
173
+ if (error instanceof Error && 'oclif' in error)
174
+ throw error;
165
175
  const message = error instanceof Error ? error.message : String(error);
166
176
  results.push({
167
177
  message,
@@ -202,6 +212,8 @@ Results: 4 passed, 1 failed
202
212
  }
203
213
  }
204
214
  catch (error) {
215
+ if (error instanceof Error && 'oclif' in error)
216
+ throw error;
205
217
  if (error instanceof Error) {
206
218
  this.error(`Failed to run unit tests: ${error.message}`);
207
219
  }
@@ -61,24 +61,29 @@ Result: PASS (0.25s)
61
61
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
62
62
  }
63
63
  const result = (await response.json());
64
+ // Anything other than 'ok' is a failure -- the API has been observed
65
+ // returning several distinct non-ok statuses, so never enumerate them.
66
+ const passed = result.status === 'ok';
64
67
  if (flags.output === 'json') {
65
68
  this.log(JSON.stringify(result, null, 2));
66
69
  }
67
70
  else {
68
71
  const timing = result.timing ? ` (${result.timing}s)` : '';
69
- if (result.status === 'ok') {
70
- this.log(`Result: PASS${timing}`);
71
- }
72
- else {
73
- this.log(`Result: FAIL${timing}`);
74
- if (result.message) {
75
- this.log(` Error: ${result.message}`);
76
- }
77
- this.exit(1);
72
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
73
+ if (!passed && result.message) {
74
+ this.log(` Error: ${result.message}`);
78
75
  }
79
76
  }
77
+ // Set the code rather than throwing via this.exit(): the exit must apply
78
+ // in both output modes, and process.exitCode cannot be swallowed by the
79
+ // surrounding catch. Mirrors what run_all already does.
80
+ if (!passed) {
81
+ process.exitCode = 1;
82
+ }
80
83
  }
81
84
  catch (error) {
85
+ if (error instanceof Error && 'oclif' in error)
86
+ throw error;
82
87
  if (error instanceof Error) {
83
88
  this.error(`Failed to run workflow test: ${error.message}`);
84
89
  }
@@ -85,7 +85,15 @@ Results: 2 passed, 1 failed
85
85
  },
86
86
  });
87
87
  if (tests.length === 0) {
88
- this.log('No workflow tests found');
88
+ // An empty suite is a normal state (fresh branches have no tests) and
89
+ // is a success, not a failure. Under -o json it must still be parseable,
90
+ // with the same keys the populated path emits below.
91
+ if (flags.output === 'json') {
92
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
93
+ }
94
+ else {
95
+ this.log('No workflow tests found');
96
+ }
89
97
  return;
90
98
  }
91
99
  if (flags.output === 'summary') {
@@ -146,6 +154,8 @@ Results: 2 passed, 1 failed
146
154
  }
147
155
  }
148
156
  catch (error) {
157
+ if (error instanceof Error && 'oclif' in error)
158
+ throw error;
149
159
  const message = error instanceof Error ? error.message : String(error);
150
160
  results.push({
151
161
  message,
@@ -184,6 +194,8 @@ Results: 2 passed, 1 failed
184
194
  }
185
195
  }
186
196
  catch (error) {
197
+ if (error instanceof Error && 'oclif' in error)
198
+ throw error;
187
199
  if (error instanceof Error) {
188
200
  this.error(`Failed to run workflow tests: ${error.message}`);
189
201
  }
@@ -63,26 +63,34 @@ Result: PASS
63
63
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
64
64
  }
65
65
  const result = await response.json();
66
+ // Anything other than 'ok' is a failure -- the API has been observed
67
+ // returning several distinct non-ok statuses, so never enumerate them.
68
+ const passed = result.status === 'ok';
66
69
  if (flags.output === 'json') {
67
70
  this.log(JSON.stringify(result, null, 2));
68
71
  }
72
+ else if (passed) {
73
+ this.log('Result: PASS');
74
+ }
69
75
  else {
70
- if (result.status === 'ok') {
71
- this.log('Result: PASS');
72
- }
73
- else {
74
- this.log('Result: FAIL');
75
- const failedExpects = result.results?.filter(r => r.status === 'fail') ?? [];
76
- for (const expect of failedExpects) {
77
- if (expect.message) {
78
- this.log(` Error: ${expect.message}`);
79
- }
76
+ this.log('Result: FAIL');
77
+ const failedExpects = result.results?.filter(r => r.status === 'fail') ?? [];
78
+ for (const expect of failedExpects) {
79
+ if (expect.message) {
80
+ this.log(` Error: ${expect.message}`);
80
81
  }
81
- this.exit(1);
82
82
  }
83
83
  }
84
+ // Set the code rather than throwing via this.exit(): the exit must apply
85
+ // in both output modes, and process.exitCode cannot be swallowed by the
86
+ // surrounding catch. Mirrors what run_all already does.
87
+ if (!passed) {
88
+ process.exitCode = 1;
89
+ }
84
90
  }
85
91
  catch (error) {
92
+ if (error instanceof Error && 'oclif' in error)
93
+ throw error;
86
94
  if (error instanceof Error) {
87
95
  this.error(`Failed to run unit test: ${error.message}`);
88
96
  }
@@ -91,7 +91,15 @@ Results: 2 passed, 1 failed
91
91
  },
92
92
  });
93
93
  if (tests.length === 0) {
94
- this.log('No unit tests found');
94
+ // An empty suite is a normal state (fresh branches have no tests) and
95
+ // is a success, not a failure. Under -o json it must still be parseable,
96
+ // with the same keys the populated path emits below.
97
+ if (flags.output === 'json') {
98
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [] }, null, 2));
99
+ }
100
+ else {
101
+ this.log('No unit tests found');
102
+ }
95
103
  return;
96
104
  }
97
105
  if (flags.output === 'summary') {
@@ -161,6 +169,8 @@ Results: 2 passed, 1 failed
161
169
  }
162
170
  }
163
171
  catch (error) {
172
+ if (error instanceof Error && 'oclif' in error)
173
+ throw error;
164
174
  const message = error instanceof Error ? error.message : String(error);
165
175
  results.push({
166
176
  message,
@@ -202,6 +212,8 @@ Results: 2 passed, 1 failed
202
212
  }
203
213
  }
204
214
  catch (error) {
215
+ if (error instanceof Error && 'oclif' in error)
216
+ throw error;
205
217
  if (error instanceof Error) {
206
218
  this.error(`Failed to run unit tests: ${error.message}`);
207
219
  }
@@ -55,24 +55,29 @@ Result: PASS (1.234s)
55
55
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
56
56
  }
57
57
  const result = await response.json();
58
+ // Anything other than 'ok' is a failure -- the API has been observed
59
+ // returning several distinct non-ok statuses, so never enumerate them.
60
+ const passed = result.status === 'ok';
58
61
  if (flags.output === 'json') {
59
62
  this.log(JSON.stringify(result, null, 2));
60
63
  }
61
64
  else {
62
- const timing = `(${result.timing.toFixed(3)}s)`;
63
- if (result.status === 'ok') {
64
- this.log(`Result: PASS ${timing}`);
65
- }
66
- else {
67
- this.log(`Result: FAIL ${timing}`);
68
- if (result.message) {
69
- this.log(` Error: ${result.message}`);
70
- }
71
- this.exit(1);
65
+ const timing = typeof result.timing === 'number' ? ` (${result.timing.toFixed(3)}s)` : '';
66
+ this.log(`Result: ${passed ? 'PASS' : 'FAIL'}${timing}`);
67
+ if (!passed && result.message) {
68
+ this.log(` Error: ${result.message}`);
72
69
  }
73
70
  }
71
+ // Set the code rather than throwing via this.exit(): the exit must apply
72
+ // in both output modes, and process.exitCode cannot be swallowed by the
73
+ // surrounding catch. Mirrors what run_all already does.
74
+ if (!passed) {
75
+ process.exitCode = 1;
76
+ }
74
77
  }
75
78
  catch (error) {
79
+ if (error instanceof Error && 'oclif' in error)
80
+ throw error;
76
81
  if (error instanceof Error) {
77
82
  this.error(`Failed to run workflow test: ${error.message}`);
78
83
  }
@@ -82,7 +82,15 @@ Results: 2 passed, 1 failed (2.691s total)
82
82
  },
83
83
  });
84
84
  if (tests.length === 0) {
85
- this.log('No workflow tests found');
85
+ // An empty suite is a normal state (fresh branches have no tests) and
86
+ // is a success, not a failure. Under -o json it must still be parseable,
87
+ // with the same keys the populated path emits below.
88
+ if (flags.output === 'json') {
89
+ this.log(JSON.stringify({ failed: 0, passed: 0, results: [], total_timing: 0 }, null, 2));
90
+ }
91
+ else {
92
+ this.log('No workflow tests found');
93
+ }
86
94
  return;
87
95
  }
88
96
  if (flags.output === 'summary') {
@@ -134,9 +142,9 @@ Results: 2 passed, 1 failed (2.691s total)
134
142
  timing: runResult.timing,
135
143
  };
136
144
  results.push(result);
137
- totalTiming += runResult.timing;
145
+ totalTiming += runResult.timing ?? 0;
138
146
  if (flags.output === 'summary') {
139
- const timing = `(${runResult.timing.toFixed(3)}s)`;
147
+ const timing = `(${(runResult.timing ?? 0).toFixed(3)}s)`;
140
148
  if (passed) {
141
149
  log(`PASS ${test.name} ${timing}`);
142
150
  }
@@ -149,6 +157,8 @@ Results: 2 passed, 1 failed (2.691s total)
149
157
  }
150
158
  }
151
159
  catch (error) {
160
+ if (error instanceof Error && 'oclif' in error)
161
+ throw error;
152
162
  const message = error instanceof Error ? error.message : String(error);
153
163
  results.push({
154
164
  message,
@@ -189,6 +199,8 @@ Results: 2 passed, 1 failed (2.691s total)
189
199
  }
190
200
  }
191
201
  catch (error) {
202
+ if (error instanceof Error && 'oclif' in error)
203
+ throw error;
192
204
  if (error instanceof Error) {
193
205
  this.error(`Failed to run workflow tests: ${error.message}`);
194
206
  }
@@ -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
  }