@xano/cli 1.0.6-beta.1 → 1.0.6-beta.11

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 (89) hide show
  1. package/README.md +138 -4
  2. package/dist/commands/branch/list/index.js +5 -1
  3. package/dist/commands/ephemeral/impersonate/index.d.ts +1 -0
  4. package/dist/commands/ephemeral/impersonate/index.js +11 -3
  5. package/dist/commands/ephemeral/list/index.js +2 -1
  6. package/dist/commands/ephemeral/pull/index.d.ts +0 -6
  7. package/dist/commands/ephemeral/pull/index.js +10 -95
  8. package/dist/commands/ephemeral/push/index.d.ts +10 -0
  9. package/dist/commands/ephemeral/push/index.js +77 -0
  10. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  11. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  12. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  13. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  14. package/dist/commands/flatten/index.d.ts +34 -0
  15. package/dist/commands/flatten/index.js +140 -0
  16. package/dist/commands/function/list/index.d.ts +4 -4
  17. package/dist/commands/function/list/index.js +30 -38
  18. package/dist/commands/knowledge/list/index.js +2 -1
  19. package/dist/commands/platform/list/index.js +13 -14
  20. package/dist/commands/release/list/index.js +13 -14
  21. package/dist/commands/release/pull/index.d.ts +0 -6
  22. package/dist/commands/release/pull/index.js +10 -95
  23. package/dist/commands/sandbox/env/list/index.js +4 -0
  24. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  25. package/dist/commands/sandbox/pull/index.js +10 -77
  26. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  27. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  28. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  29. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  30. package/dist/commands/sandbox/unit_test/run_all/index.js +76 -38
  31. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  32. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  33. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  34. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  35. package/dist/commands/sandbox/workflow_test/run_all/index.js +74 -36
  36. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  37. package/dist/commands/static_host/build/list/index.js +19 -40
  38. package/dist/commands/static_host/build/pull/index.js +12 -9
  39. package/dist/commands/static_host/list/index.d.ts +2 -3
  40. package/dist/commands/static_host/list/index.js +20 -40
  41. package/dist/commands/static_host/migrate/index.js +14 -10
  42. package/dist/commands/tenant/backup/list/index.js +13 -14
  43. package/dist/commands/tenant/cluster/list/index.js +9 -10
  44. package/dist/commands/tenant/env/list/index.js +4 -0
  45. package/dist/commands/tenant/list/index.js +19 -20
  46. package/dist/commands/tenant/pull/index.d.ts +0 -6
  47. package/dist/commands/tenant/pull/index.js +10 -95
  48. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  49. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  50. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  51. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  52. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  53. package/dist/commands/tenant/unit_test/run_all/index.js +80 -38
  54. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  55. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  56. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  57. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  58. package/dist/commands/tenant/workflow_test/run_all/index.js +78 -36
  59. package/dist/commands/unit_test/list/index.d.ts +3 -1
  60. package/dist/commands/unit_test/list/index.js +9 -14
  61. package/dist/commands/unit_test/run/index.js +19 -11
  62. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  63. package/dist/commands/unit_test/run_all/index.js +81 -41
  64. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  65. package/dist/commands/workflow_test/list/index.js +10 -13
  66. package/dist/commands/workflow_test/run/index.js +15 -10
  67. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  68. package/dist/commands/workflow_test/run_all/index.js +82 -39
  69. package/dist/commands/workspace/git/pull/index.js +53 -3
  70. package/dist/commands/workspace/list/index.js +7 -13
  71. package/dist/commands/workspace/pull/index.d.ts +0 -6
  72. package/dist/commands/workspace/pull/index.js +10 -95
  73. package/dist/utils/concurrency.d.ts +29 -0
  74. package/dist/utils/concurrency.js +54 -0
  75. package/dist/utils/document-parser.d.ts +116 -0
  76. package/dist/utils/document-parser.js +274 -1
  77. package/dist/utils/flatten.d.ts +36 -0
  78. package/dist/utils/flatten.js +68 -0
  79. package/dist/utils/knowledge-sync.d.ts +1 -0
  80. package/dist/utils/knowledge-sync.js +11 -5
  81. package/dist/utils/microservice-wait.d.ts +53 -0
  82. package/dist/utils/microservice-wait.js +83 -0
  83. package/dist/utils/multidoc-push.d.ts +12 -0
  84. package/dist/utils/multidoc-push.js +84 -0
  85. package/dist/utils/paging.d.ts +145 -0
  86. package/dist/utils/paging.js +199 -0
  87. package/dist/utils/reference-checker.js +13 -0
  88. package/oclif.manifest.json +3420 -3208
  89. package/package.json +1 -1
@@ -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
  }
@@ -4,6 +4,7 @@ export default class WorkflowTestRunAll extends BaseCommand {
4
4
  static examples: string[];
5
5
  static flags: {
6
6
  branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
7
8
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
9
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,5 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { createOrderedEmitter, mapWithConcurrency } from '../../../utils/concurrency.js';
4
+ import { collectAllPages, INTERNAL_PAGE_SIZE, normalizeListResponse } from '../../../utils/paging.js';
3
5
  export default class WorkflowTestRunAll extends BaseCommand {
4
6
  static description = 'Run all workflow tests in a workspace';
5
7
  static examples = [
@@ -22,6 +24,11 @@ Results: 2 passed, 1 failed (2.691s total)
22
24
  description: 'Filter by branch name',
23
25
  required: false,
24
26
  }),
27
+ concurrency: Flags.integer({
28
+ default: 1,
29
+ description: 'Run this many tests in parallel. Tests share the workspace database, so raise this only when your tests do not depend on shared state.',
30
+ required: false,
31
+ }),
25
32
  output: Flags.string({
26
33
  char: 'o',
27
34
  default: 'summary',
@@ -45,34 +52,45 @@ Results: 2 passed, 1 failed (2.691s total)
45
52
  const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/workflow_test`;
46
53
  try {
47
54
  // Step 1: List all workflow tests
48
- const listParams = new URLSearchParams({ include_xanoscript: 'false' });
49
- if (flags.branch) {
50
- listParams.set('branch', flags.branch);
51
- }
52
- const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
53
- headers: {
54
- accept: 'application/json',
55
- Authorization: `Bearer ${profile.access_token}`,
55
+ // Page the list rather than asking for everything at once: 100 per
56
+ // request is plenty for typical suites and does not make the instance
57
+ // build one enormous response. Termination is the server's own nextPage,
58
+ // never "the page came back full".
59
+ const branchValue = flags.branch;
60
+ const tests = await collectAllPages(async (page) => {
61
+ const listParams = new URLSearchParams();
62
+ listParams.set('page', String(page));
63
+ listParams.set('per_page', String(INTERNAL_PAGE_SIZE));
64
+ if (branchValue)
65
+ listParams.set('branch', branchValue);
66
+ listParams.set('include_xanoscript', 'false');
67
+ const listResponse = await this.verboseFetch(`${baseUrl}?${listParams}`, {
68
+ headers: {
69
+ accept: 'application/json',
70
+ Authorization: `Bearer ${profile.access_token}`,
71
+ },
72
+ method: 'GET',
73
+ }, flags.verbose, profile.access_token);
74
+ if (!listResponse.ok) {
75
+ const errorText = await listResponse.text();
76
+ this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
77
+ }
78
+ return normalizeListResponse(await listResponse.json());
79
+ }, {
80
+ onTruncate: (collected) => {
81
+ this.warn(`Stopped after ${collected} workflow tests — the list did not terminate. Some tests may not have run.`);
56
82
  },
57
- method: 'GET',
58
- }, flags.verbose, profile.access_token);
59
- if (!listResponse.ok) {
60
- const errorText = await listResponse.text();
61
- this.error(`Failed to list workflow tests: ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
62
- }
63
- const data = (await listResponse.json());
64
- let tests;
65
- if (Array.isArray(data)) {
66
- tests = data;
67
- }
68
- else if (data && typeof data === 'object' && 'items' in data && Array.isArray(data.items)) {
69
- tests = data.items;
70
- }
71
- else {
72
- this.error('Unexpected API response format');
73
- }
83
+ });
74
84
  if (tests.length === 0) {
75
- 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
+ }
76
94
  return;
77
95
  }
78
96
  if (flags.output === 'summary') {
@@ -81,7 +99,15 @@ Results: 2 passed, 1 failed (2.691s total)
81
99
  // Step 2: Run each test
82
100
  const results = [];
83
101
  let totalTiming = 0;
84
- for (const test of tests) {
102
+ const runOne = async (test) => {
103
+ // Shadowed accumulators: the body below is the original sequential
104
+ // logic, but it records output instead of printing it so a concurrent
105
+ // run can still emit in input order.
106
+ const results = [];
107
+ const lines = [];
108
+ const log = (message) => {
109
+ lines.push(message);
110
+ };
85
111
  const runUrl = `${baseUrl}/${test.id}/run`;
86
112
  try {
87
113
  const runResponse = await this.verboseFetch(runUrl, {
@@ -102,10 +128,10 @@ Results: 2 passed, 1 failed (2.691s total)
102
128
  };
103
129
  results.push(result);
104
130
  if (flags.output === 'summary') {
105
- this.log(`FAIL ${test.name} (0.000s)`);
106
- this.log(` Error: API error ${runResponse.status}`);
131
+ log(`FAIL ${test.name} (0.000s)`);
132
+ log(` Error: API error ${runResponse.status}`);
107
133
  }
108
- continue;
134
+ return { lines, results };
109
135
  }
110
136
  const runResult = (await runResponse.json());
111
137
  const passed = runResult.status === 'ok';
@@ -116,21 +142,23 @@ Results: 2 passed, 1 failed (2.691s total)
116
142
  timing: runResult.timing,
117
143
  };
118
144
  results.push(result);
119
- totalTiming += runResult.timing;
145
+ totalTiming += runResult.timing ?? 0;
120
146
  if (flags.output === 'summary') {
121
- const timing = `(${runResult.timing.toFixed(3)}s)`;
147
+ const timing = `(${(runResult.timing ?? 0).toFixed(3)}s)`;
122
148
  if (passed) {
123
- this.log(`PASS ${test.name} ${timing}`);
149
+ log(`PASS ${test.name} ${timing}`);
124
150
  }
125
151
  else {
126
- this.log(`FAIL ${test.name} ${timing}`);
152
+ log(`FAIL ${test.name} ${timing}`);
127
153
  if (runResult.message) {
128
- this.log(` Error: ${runResult.message}`);
154
+ log(` Error: ${runResult.message}`);
129
155
  }
130
156
  }
131
157
  }
132
158
  }
133
159
  catch (error) {
160
+ if (error instanceof Error && 'oclif' in error)
161
+ throw error;
134
162
  const message = error instanceof Error ? error.message : String(error);
135
163
  results.push({
136
164
  message,
@@ -139,16 +167,29 @@ Results: 2 passed, 1 failed (2.691s total)
139
167
  timing: 0,
140
168
  });
141
169
  if (flags.output === 'summary') {
142
- this.log(`FAIL ${test.name} (0.000s)`);
143
- this.log(` Error: ${message}`);
170
+ log(`FAIL ${test.name} (0.000s)`);
171
+ log(` Error: ${message}`);
144
172
  }
145
173
  }
146
- }
174
+ return { lines, results };
175
+ };
176
+ // Print in input order as each test settles, so concurrent output stays
177
+ // diffable against a sequential run.
178
+ const emitter = createOrderedEmitter((settled) => {
179
+ for (const line of settled.lines)
180
+ this.log(line);
181
+ });
182
+ const perTest = await mapWithConcurrency(tests, flags.concurrency, async (test, index) => {
183
+ const settled = await runOne(test);
184
+ emitter.settle(index, settled);
185
+ return settled;
186
+ });
187
+ results.push(...perTest.flatMap((r) => r.results));
147
188
  // Step 3: Summary
148
189
  const passed = results.filter((r) => r.status === 'pass').length;
149
190
  const failed = results.filter((r) => r.status === 'fail').length;
150
191
  if (flags.output === 'json') {
151
- this.log(JSON.stringify({ passed, failed, total_timing: totalTiming, results }, null, 2));
192
+ this.log(JSON.stringify({ failed, passed, results, total_timing: totalTiming }, null, 2));
152
193
  }
153
194
  else {
154
195
  this.log(`\nResults: ${passed} passed, ${failed} failed (${totalTiming.toFixed(3)}s total)`);
@@ -158,6 +199,8 @@ Results: 2 passed, 1 failed (2.691s total)
158
199
  }
159
200
  }
160
201
  catch (error) {
202
+ if (error instanceof Error && 'oclif' in error)
203
+ throw error;
161
204
  if (error instanceof Error) {
162
205
  this.error(`Failed to run workflow tests: ${error.message}`);
163
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, 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,6 +376,53 @@ 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
+ }
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
+ }
376
426
  else if (doc.type === 'api_group') {
377
427
  const groupFolder = getApiGroupFolder(doc.name);
378
428
  typeDir = path.join(outputDir, 'api', groupFolder);
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { buildPagingJson, formatPagingFooter, normalizeListResponse } from '../../../utils/paging.js';
3
4
  export default class WorkspaceList extends BaseCommand {
4
5
  static description = 'List all workspaces from the Xano Metadata API';
5
6
  static examples = [
@@ -71,24 +72,14 @@ Available workspaces:
71
72
  const errorText = await response.text();
72
73
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
73
74
  }
74
- const data = (await response.json());
75
- // Handle different response formats
76
- let workspaces;
77
- if (Array.isArray(data)) {
78
- workspaces = data;
79
- }
80
- else if (data && typeof data === 'object' && 'workspaces' in data && Array.isArray(data.workspaces)) {
81
- workspaces = data.workspaces;
82
- }
83
- else {
84
- this.error('Unexpected API response format');
85
- }
75
+ const list = normalizeListResponse(await response.json(), ['workspaces']);
76
+ const workspaces = list.items;
86
77
  if (flags.latest) {
87
78
  workspaces.sort((a, b) => b.id - a.id);
88
79
  }
89
80
  // Output results
90
81
  if (flags.output === 'json') {
91
- this.log(JSON.stringify(workspaces, null, 2));
82
+ this.log(JSON.stringify(buildPagingJson(list, { tier: 'none' }), null, 2));
92
83
  }
93
84
  else {
94
85
  // summary format
@@ -107,6 +98,9 @@ Available workspaces:
107
98
  }
108
99
  }
109
100
  }
101
+ const footer = formatPagingFooter({ items: workspaces }, { noun: 'workspace', tier: 'none' });
102
+ if (footer)
103
+ this.log(footer);
110
104
  }
111
105
  }
112
106
  catch (error) {
@@ -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, parseDocument } from '../../../utils/document-parser.js';
6
+ import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
8
8
  export default class Pull extends BaseCommand {
9
9
  static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
@@ -134,96 +134,19 @@ Pulled 58 documents
134
134
  // Resolve api_group names to unique folder names, disambiguating collisions
135
135
  // where different names produce the same snakeCase (e.g., "Authentication" vs "authentication")
136
136
  const getApiGroupFolder = buildApiGroupFolderResolver(documents, snakeCase);
137
+ // Resolve a realtime v2 channel path -> owning realtime_server name, so
138
+ // messages can nest under their channel's server (see resolver docs).
139
+ const getChannelServer = buildChannelServerResolver(documents);
137
140
  // Track filenames per type to handle duplicates
138
141
  const filenameCounters = new Map();
139
142
  let writtenCount = 0;
140
143
  for (const doc of documents) {
141
- let typeDir;
142
- let baseName;
143
- if (doc.type === 'workspace') {
144
- // workspace → workspace/{name}.xs
145
- typeDir = path.join(outputDir, 'workspace');
146
- baseName = this.sanitizeFilename(doc.name);
147
- }
148
- else if (doc.type === 'workspace_trigger') {
149
- // workspace_trigger → workspace/trigger/{name}.xs
150
- typeDir = path.join(outputDir, 'workspace', 'trigger');
151
- baseName = this.sanitizeFilename(doc.name);
152
- }
153
- else if (doc.type === 'error_trigger') {
154
- // error_trigger → workspace/trigger/{name}.xs (singleton, colocated with workspace triggers)
155
- typeDir = path.join(outputDir, 'workspace', 'trigger');
156
- baseName = this.sanitizeFilename(doc.name);
157
- }
158
- else if (doc.type === 'agent') {
159
- // agent → ai/agent/{name}.xs
160
- typeDir = path.join(outputDir, 'ai', 'agent');
161
- baseName = this.sanitizeFilename(doc.name);
162
- }
163
- else if (doc.type === 'mcp_server') {
164
- // mcp_server → ai/mcp_server/{name}.xs
165
- typeDir = path.join(outputDir, 'ai', 'mcp_server');
166
- baseName = this.sanitizeFilename(doc.name);
167
- }
168
- else if (doc.type === 'tool') {
169
- // tool → ai/tool/{name}.xs
170
- typeDir = path.join(outputDir, 'ai', 'tool');
171
- baseName = this.sanitizeFilename(doc.name);
172
- }
173
- else if (doc.type === 'agent_trigger') {
174
- // agent_trigger → ai/agent/trigger/{name}.xs
175
- typeDir = path.join(outputDir, 'ai', 'agent', 'trigger');
176
- baseName = this.sanitizeFilename(doc.name);
177
- }
178
- else if (doc.type === 'mcp_server_trigger') {
179
- // mcp_server_trigger → ai/mcp_server/trigger/{name}.xs
180
- typeDir = path.join(outputDir, 'ai', 'mcp_server', 'trigger');
181
- baseName = this.sanitizeFilename(doc.name);
182
- }
183
- else if (doc.type === 'table_trigger') {
184
- // table_trigger → table/trigger/{name}.xs
185
- typeDir = path.join(outputDir, 'table', 'trigger');
186
- baseName = this.sanitizeFilename(doc.name);
187
- }
188
- else if (doc.type === 'realtime_channel') {
189
- // realtime_channel → realtime/channel/{name}.xs
190
- typeDir = path.join(outputDir, 'realtime', 'channel');
191
- baseName = this.sanitizeFilename(doc.name);
192
- }
193
- else if (doc.type === 'realtime_trigger') {
194
- // realtime_trigger → realtime/trigger/{name}.xs
195
- typeDir = path.join(outputDir, 'realtime', 'trigger');
196
- baseName = this.sanitizeFilename(doc.name);
197
- }
198
- else if (doc.type === 'api_group') {
199
- // api_group "test" → api/{resolved_folder}/{name}.xs
200
- const groupFolder = getApiGroupFolder(doc.name);
201
- typeDir = path.join(outputDir, 'api', groupFolder);
202
- baseName = this.sanitizeFilename(doc.name);
203
- }
204
- else if (doc.type === 'query' && doc.apiGroup) {
205
- // query in group "test" → api/{resolved_folder}/{query_name}.xs
206
- const groupFolder = getApiGroupFolder(doc.apiGroup);
207
- const nameParts = doc.name.split('/');
208
- const leafName = nameParts.pop();
209
- const folderParts = nameParts.map((part) => snakeCase(part));
210
- typeDir = path.join(outputDir, 'api', groupFolder, ...folderParts);
211
- baseName = this.sanitizeFilename(leafName);
212
- if (doc.verb) {
213
- baseName = `${baseName}_${doc.verb}`;
214
- }
215
- }
216
- else {
217
- // Default: split folder path from name
218
- const nameParts = doc.name.split('/');
219
- const leafName = nameParts.pop();
220
- const folderParts = nameParts.map((part) => snakeCase(part));
221
- typeDir = path.join(outputDir, doc.type, ...folderParts);
222
- baseName = this.sanitizeFilename(leafName);
223
- if (doc.verb) {
224
- baseName = `${baseName}_${doc.verb}`;
225
- }
226
- }
144
+ const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
145
+ getApiGroupFolder,
146
+ getChannelServer,
147
+ join: path.join,
148
+ snakeCase,
149
+ });
227
150
  fs.mkdirSync(typeDir, { recursive: true });
228
151
  // Track duplicates per directory
229
152
  const dirKey = path.relative(outputDir, typeDir);
@@ -252,12 +175,4 @@ Pulled 58 documents
252
175
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
253
176
  this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
254
177
  }
255
- /**
256
- * Sanitize a document name for use as a filename.
257
- * Strips quotes, replaces spaces with underscores, and removes
258
- * characters that are unsafe in filenames.
259
- */
260
- sanitizeFilename(name) {
261
- return snakeCase(name.replaceAll('"', ''));
262
- }
263
178
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run `worker` over `items` with at most `limit` in flight at once.
3
+ *
4
+ * Results come back in **input order**, not completion order. That matters for
5
+ * test runners: a concurrent run must still produce output a human can diff
6
+ * against a sequential one, and a JSON results array whose order does not shift
7
+ * between runs.
8
+ *
9
+ * `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
10
+ * execution and callers do not need a separate code path for it.
11
+ *
12
+ * The worker is expected to capture its own failures — one rejected worker
13
+ * rejects the whole call. Callers that want per-item error reporting (as the
14
+ * test runners do) should resolve a failure result instead of throwing.
15
+ */
16
+ export declare function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
17
+ /**
18
+ * Emit results in input order as soon as each becomes available, without
19
+ * waiting for the whole batch.
20
+ *
21
+ * Progress lines are the reason this exists: with concurrency the third test may
22
+ * finish before the first, but printing them out of order makes the output hard
23
+ * to follow and non-deterministic between runs. This buffers out-of-order
24
+ * completions and flushes the moment the next expected index arrives, so the
25
+ * user sees an ordered stream that still starts before the run ends.
26
+ */
27
+ export declare function createOrderedEmitter<R>(onReady: (result: R, index: number) => void): {
28
+ settle(index: number, result: R): void;
29
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Run `worker` over `items` with at most `limit` in flight at once.
3
+ *
4
+ * Results come back in **input order**, not completion order. That matters for
5
+ * test runners: a concurrent run must still produce output a human can diff
6
+ * against a sequential one, and a JSON results array whose order does not shift
7
+ * between runs.
8
+ *
9
+ * `limit` is clamped to at least 1, so a limit of 1 is exactly sequential
10
+ * execution and callers do not need a separate code path for it.
11
+ *
12
+ * The worker is expected to capture its own failures — one rejected worker
13
+ * rejects the whole call. Callers that want per-item error reporting (as the
14
+ * test runners do) should resolve a failure result instead of throwing.
15
+ */
16
+ export async function mapWithConcurrency(items, limit, worker) {
17
+ const effectiveLimit = Math.max(1, Math.floor(limit));
18
+ const results = Array.from({ length: items.length });
19
+ let cursor = 0;
20
+ const runners = Array.from({ length: Math.min(effectiveLimit, items.length) }, async () => {
21
+ while (cursor < items.length) {
22
+ const index = cursor;
23
+ cursor++;
24
+ // eslint-disable-next-line no-await-in-loop
25
+ results[index] = await worker(items[index], index);
26
+ }
27
+ });
28
+ await Promise.all(runners);
29
+ return results;
30
+ }
31
+ /**
32
+ * Emit results in input order as soon as each becomes available, without
33
+ * waiting for the whole batch.
34
+ *
35
+ * Progress lines are the reason this exists: with concurrency the third test may
36
+ * finish before the first, but printing them out of order makes the output hard
37
+ * to follow and non-deterministic between runs. This buffers out-of-order
38
+ * completions and flushes the moment the next expected index arrives, so the
39
+ * user sees an ordered stream that still starts before the run ends.
40
+ */
41
+ export function createOrderedEmitter(onReady) {
42
+ const pending = new Map();
43
+ let next = 0;
44
+ return {
45
+ settle(index, result) {
46
+ pending.set(index, result);
47
+ while (pending.has(next)) {
48
+ onReady(pending.get(next), next);
49
+ pending.delete(next);
50
+ next++;
51
+ }
52
+ },
53
+ };
54
+ }