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

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 (112) hide show
  1. package/README.md +217 -6
  2. package/dist/base-command.d.ts +5 -0
  3. package/dist/base-command.js +61 -4
  4. package/dist/commands/branch/list/index.js +5 -1
  5. package/dist/commands/ephemeral/create/index.js +3 -3
  6. package/dist/commands/ephemeral/list/index.js +2 -1
  7. package/dist/commands/ephemeral/pull/index.d.ts +0 -6
  8. package/dist/commands/ephemeral/pull/index.js +10 -95
  9. package/dist/commands/ephemeral/push/index.d.ts +10 -0
  10. package/dist/commands/ephemeral/push/index.js +77 -0
  11. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +2 -3
  12. package/dist/commands/ephemeral/static_host/build/list/index.js +19 -40
  13. package/dist/commands/ephemeral/static_host/list/index.d.ts +2 -3
  14. package/dist/commands/ephemeral/static_host/list/index.js +20 -40
  15. package/dist/commands/flatten/index.d.ts +34 -0
  16. package/dist/commands/flatten/index.js +140 -0
  17. package/dist/commands/function/list/index.d.ts +4 -4
  18. package/dist/commands/function/list/index.js +30 -38
  19. package/dist/commands/function/run/index.d.ts +1 -0
  20. package/dist/commands/function/run/index.js +19 -5
  21. package/dist/commands/knowledge/list/index.js +2 -1
  22. package/dist/commands/platform/list/index.js +13 -14
  23. package/dist/commands/release/list/index.js +13 -14
  24. package/dist/commands/release/pull/index.d.ts +0 -6
  25. package/dist/commands/release/pull/index.js +10 -95
  26. package/dist/commands/sandbox/env/list/index.js +4 -0
  27. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  28. package/dist/commands/sandbox/pull/index.js +10 -77
  29. package/dist/commands/sandbox/unit_test/list/index.d.ts +2 -0
  30. package/dist/commands/sandbox/unit_test/list/index.js +9 -14
  31. package/dist/commands/sandbox/unit_test/run/index.js +17 -11
  32. package/dist/commands/sandbox/unit_test/run_all/index.d.ts +1 -0
  33. package/dist/commands/sandbox/unit_test/run_all/index.js +76 -38
  34. package/dist/commands/sandbox/workflow_test/list/index.d.ts +2 -0
  35. package/dist/commands/sandbox/workflow_test/list/index.js +9 -14
  36. package/dist/commands/sandbox/workflow_test/run/index.js +12 -9
  37. package/dist/commands/sandbox/workflow_test/run_all/index.d.ts +1 -0
  38. package/dist/commands/sandbox/workflow_test/run_all/index.js +74 -36
  39. package/dist/commands/static_host/build/list/index.d.ts +2 -3
  40. package/dist/commands/static_host/build/list/index.js +19 -40
  41. package/dist/commands/static_host/build/pull/index.js +12 -9
  42. package/dist/commands/static_host/list/index.d.ts +2 -3
  43. package/dist/commands/static_host/list/index.js +20 -40
  44. package/dist/commands/static_host/migrate/index.js +14 -10
  45. package/dist/commands/tenant/backup/list/index.js +13 -14
  46. package/dist/commands/tenant/cluster/list/index.js +9 -10
  47. package/dist/commands/tenant/create/index.d.ts +3 -0
  48. package/dist/commands/tenant/create/index.js +21 -0
  49. package/dist/commands/tenant/deploy_release/index.d.ts +21 -0
  50. package/dist/commands/tenant/deploy_release/index.js +94 -2
  51. package/dist/commands/tenant/edit/index.d.ts +2 -0
  52. package/dist/commands/tenant/edit/index.js +39 -4
  53. package/dist/commands/tenant/env/list/index.js +4 -0
  54. package/dist/commands/tenant/list/index.js +19 -20
  55. package/dist/commands/tenant/pull/index.d.ts +0 -6
  56. package/dist/commands/tenant/pull/index.js +10 -106
  57. package/dist/commands/tenant/snapshot/list/index.js +5 -1
  58. package/dist/commands/tenant/unit_test/list/index.d.ts +4 -2
  59. package/dist/commands/tenant/unit_test/list/index.js +9 -14
  60. package/dist/commands/tenant/unit_test/run/index.js +19 -11
  61. package/dist/commands/tenant/unit_test/run_all/index.d.ts +1 -0
  62. package/dist/commands/tenant/unit_test/run_all/index.js +80 -38
  63. package/dist/commands/tenant/workflow_test/list/index.d.ts +4 -2
  64. package/dist/commands/tenant/workflow_test/list/index.js +9 -14
  65. package/dist/commands/tenant/workflow_test/run/index.js +14 -9
  66. package/dist/commands/tenant/workflow_test/run_all/index.d.ts +1 -0
  67. package/dist/commands/tenant/workflow_test/run_all/index.js +78 -36
  68. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +20 -0
  69. package/dist/commands/tenant_deploy_request/bypass/index.js +79 -0
  70. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  71. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  72. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  73. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  74. package/dist/commands/tenant_deploy_request/get/index.d.ts +19 -0
  75. package/dist/commands/tenant_deploy_request/get/index.js +75 -0
  76. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  77. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  78. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  79. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  80. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +21 -0
  81. package/dist/commands/tenant_deploy_request/set_status/index.js +112 -0
  82. package/dist/commands/unit_test/list/index.d.ts +3 -1
  83. package/dist/commands/unit_test/list/index.js +9 -14
  84. package/dist/commands/unit_test/run/index.js +19 -11
  85. package/dist/commands/unit_test/run_all/index.d.ts +1 -0
  86. package/dist/commands/unit_test/run_all/index.js +81 -41
  87. package/dist/commands/workflow_test/list/index.d.ts +3 -1
  88. package/dist/commands/workflow_test/list/index.js +10 -13
  89. package/dist/commands/workflow_test/run/index.js +15 -10
  90. package/dist/commands/workflow_test/run_all/index.d.ts +1 -0
  91. package/dist/commands/workflow_test/run_all/index.js +82 -39
  92. package/dist/commands/workspace/git/pull/index.js +48 -7
  93. package/dist/commands/workspace/list/index.js +7 -13
  94. package/dist/commands/workspace/pull/index.d.ts +0 -6
  95. package/dist/commands/workspace/pull/index.js +10 -115
  96. package/dist/utils/concurrency.d.ts +29 -0
  97. package/dist/utils/concurrency.js +54 -0
  98. package/dist/utils/document-parser.d.ts +90 -0
  99. package/dist/utils/document-parser.js +273 -1
  100. package/dist/utils/flatten.d.ts +36 -0
  101. package/dist/utils/flatten.js +68 -0
  102. package/dist/utils/knowledge-sync.d.ts +1 -0
  103. package/dist/utils/knowledge-sync.js +11 -5
  104. package/dist/utils/microservice-wait.d.ts +53 -0
  105. package/dist/utils/microservice-wait.js +83 -0
  106. package/dist/utils/multidoc-push.d.ts +88 -0
  107. package/dist/utils/multidoc-push.js +377 -8
  108. package/dist/utils/paging.d.ts +145 -0
  109. package/dist/utils/paging.js +199 -0
  110. package/dist/utils/reference-checker.js +13 -0
  111. package/oclif.manifest.json +4999 -3973
  112. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { minimatch } from 'minimatch';
3
3
  import * as fs from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
5
  import { buildDocumentKey, findFilesWithGuid, parseDocument } from './document-parser.js';
6
+ import { flattenBundleFile } from './flatten.js';
6
7
  import { collectKnowledgeObjects, fetchKnowledge, knowledgePreview, pushKnowledge, syncGuidToFrontmatter, toPushItems, } from './knowledge-sync.js';
7
8
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
8
9
  /**
@@ -65,30 +66,151 @@ export function collectFiles(dir) {
65
66
  * Apply include/exclude glob filters to a file list. Logs filter results.
66
67
  * Returns the filtered file list.
67
68
  */
69
+ /**
70
+ * Normalize a filter pattern into one minimatch can actually match against a
71
+ * repo-relative FILE path.
72
+ *
73
+ * minimatch has no notion of "this pattern names a directory", and the two
74
+ * spellings a person reaches for first both silently match NOTHING:
75
+ *
76
+ * "table/" a trailing slash is not directory syntax; it matches nothing
77
+ * "table" no slash, so `matchBase` compares it to the file's BASENAME,
78
+ * i.e. it asks "is this file called `table`", not "is it under
79
+ * `table/`"
80
+ *
81
+ * Silently matching nothing is the dangerous half of this: combined with
82
+ * `--delete`, a filter that fails open pushes more than intended, and a filter
83
+ * that succeeds deletes what it was meant to protect. So a pattern that names a
84
+ * real directory is expanded to `<dir>/**` rather than left to match nothing.
85
+ */
86
+ export function normalizeFilterPattern(pattern, inputDir) {
87
+ const trimmed = pattern.replace(/[/\\]+$/, '');
88
+ if (trimmed === '')
89
+ return pattern;
90
+ // Anything with a glob character is taken exactly as written.
91
+ if (trimmed.includes('*') || trimmed.includes('?') || trimmed.includes('[')) {
92
+ return trimmed === pattern ? pattern : trimmed;
93
+ }
94
+ // A trailing slash is an unambiguous "this is a directory".
95
+ if (trimmed !== pattern)
96
+ return `${trimmed}/**`;
97
+ try {
98
+ if (fs.statSync(join(inputDir, trimmed)).isDirectory())
99
+ return `${trimmed}/**`;
100
+ }
101
+ catch {
102
+ // Not a path in this tree - leave it alone (it may be a bare filename,
103
+ // which matchBase handles).
104
+ }
105
+ return pattern;
106
+ }
68
107
  export function applyFilters(files, inputDir, include, exclude, log) {
69
108
  let filtered = files;
70
109
  const totalCount = files.length;
110
+ // Report each pattern's own match count. The aggregate line alone hides a dead
111
+ // pattern among live ones: a run with three -e patterns where one is a typo
112
+ // still prints a plausible number and says nothing about which one matched
113
+ // nothing.
114
+ const describe = (patterns, rels) => patterns
115
+ .map((raw) => {
116
+ const pattern = normalizeFilterPattern(raw, inputDir);
117
+ const hits = rels.filter((rel) => minimatch(rel, pattern, { matchBase: true })).length;
118
+ const shown = pattern === raw ? raw : `${raw} -> ${pattern}`;
119
+ return hits === 0
120
+ ? `${ux.colorize('yellow', shown)} ${ux.colorize('yellow', '(matched 0 files)')}`
121
+ : `${ux.colorize('cyan', shown)} ${ux.colorize('dim', `(${hits})`)}`;
122
+ })
123
+ .join(', ');
71
124
  if (include && include.length > 0) {
125
+ const rels = filtered.map((f) => relative(inputDir, f));
126
+ const patterns = include.map((p) => normalizeFilterPattern(p, inputDir));
127
+ log('');
128
+ log(` ${ux.colorize('dim', 'Include:')} ${describe(include, rels)}`);
72
129
  filtered = filtered.filter((f) => {
73
130
  const rel = relative(inputDir, f);
74
- return include.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
131
+ return patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
75
132
  });
76
- log('');
77
- log(` ${ux.colorize('dim', 'Include:')} ${include.map((p) => ux.colorize('cyan', p)).join(', ')}`);
78
133
  log(` ${ux.colorize('dim', 'Matched:')} ${ux.colorize('bold', String(filtered.length))} of ${totalCount} files`);
79
134
  }
80
135
  if (exclude && exclude.length > 0) {
81
136
  const beforeCount = filtered.length;
137
+ const rels = filtered.map((f) => relative(inputDir, f));
138
+ const patterns = exclude.map((p) => normalizeFilterPattern(p, inputDir));
139
+ log('');
140
+ log(` ${ux.colorize('dim', 'Exclude:')} ${describe(exclude, rels)}`);
82
141
  filtered = filtered.filter((f) => {
83
142
  const rel = relative(inputDir, f);
84
- return !exclude.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
143
+ return !patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
85
144
  });
86
- log('');
87
- log(` ${ux.colorize('dim', 'Exclude:')} ${exclude.map((p) => ux.colorize('cyan', p)).join(', ')}`);
88
145
  log(` ${ux.colorize('dim', 'Kept:')} ${ux.colorize('bold', String(filtered.length))} of ${beforeCount} files (excluded ${beforeCount - filtered.length})`);
89
146
  }
90
147
  return filtered;
91
148
  }
149
+ /**
150
+ * Describe the documents a --include/--exclude filter removed.
151
+ *
152
+ * These are the objects the user asked NOT to touch, and they are exactly the
153
+ * ones a `--delete` sweep would remove: a filtered-out document never reaches
154
+ * the payload, so the server reads it as deleted from the tree. Their GUIDs are
155
+ * sent as `protect_guids` so the server spares them, and their keys are used to
156
+ * VERIFY the server actually did.
157
+ */
158
+ export function describeFilteredOut(allFiles, keptFiles, inputDir) {
159
+ const kept = new Set(keptFiles);
160
+ const out = [];
161
+ for (const filePath of allFiles) {
162
+ if (kept.has(filePath))
163
+ continue;
164
+ let content;
165
+ try {
166
+ content = fs.readFileSync(filePath, 'utf8').trim();
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ if (!content)
172
+ continue;
173
+ const parsed = parseDocument(content);
174
+ if (!parsed)
175
+ continue;
176
+ const opName = parsed.verb ? `${parsed.name} ${parsed.verb}` : parsed.name;
177
+ out.push({
178
+ guid: parsed.guid,
179
+ // The preview buckets every trigger subtype under the generic `trigger`
180
+ // type, so key triggers that way to match (DEV-7084, same as
181
+ // filterChangedEntries).
182
+ key: `${parsed.type.endsWith('_trigger') ? 'trigger' : parsed.type}:${opName}`,
183
+ name: opName,
184
+ relPath: relative(inputDir, filePath),
185
+ type: parsed.type,
186
+ });
187
+ }
188
+ return out;
189
+ }
190
+ /**
191
+ * Delete operations that would remove something the filter excluded.
192
+ *
193
+ * This is the guard's actual test, and it is deliberately performed against the
194
+ * dry-run the SERVER returned rather than trusting that `protect_guids` was
195
+ * honoured: an older instance ignores the parameter entirely, and a document
196
+ * with no GUID cannot be protected at all. Either way the answer here is the
197
+ * truth about what the push is about to do.
198
+ */
199
+ export function deletesHittingFilteredOut(operations, filteredOut) {
200
+ if (filteredOut.length === 0)
201
+ return [];
202
+ const byKey = new Map(filteredOut.map((d) => [d.key, d]));
203
+ const hits = [];
204
+ for (const op of operations) {
205
+ if (op.action !== 'delete' && op.action !== 'cascade_delete')
206
+ continue;
207
+ const match = byKey.get(`${op.type}:${op.name}`);
208
+ if (match) {
209
+ hits.push({ name: op.name, relPath: match.relPath, type: op.type });
210
+ }
211
+ }
212
+ return hits;
213
+ }
92
214
  /**
93
215
  * Read .xs files into document entries, skipping empty files.
94
216
  */
@@ -102,6 +224,58 @@ export function readDocuments(files) {
102
224
  }
103
225
  return entries;
104
226
  }
227
+ /**
228
+ * A `.xs` document separator: a line that is exactly `---`. The whole push
229
+ * pipeline (partial-diff filtering, the document→file map, and GUID writeback)
230
+ * assumes ONE document per file — it only ever parses the first document in a
231
+ * file and rewrites the first `guid =` line. A multi-document file therefore
232
+ * silently drops on a partial push and corrupts GUIDs on a full one, so it is
233
+ * refused up front (with a pointer to `xano flatten`).
234
+ */
235
+ const DOC_SEPARATOR = /^---$/m;
236
+ /**
237
+ * Return the entries whose file holds more than one document (a `---`
238
+ * separator). Used to refuse multi-doc files before the push pipeline's
239
+ * single-doc-per-file assumptions can drop or corrupt them.
240
+ */
241
+ export function findMultiDocEntries(entries) {
242
+ const offenders = [];
243
+ for (const entry of entries) {
244
+ if (DOC_SEPARATOR.test(entry.content)) {
245
+ // Count documents = separators + 1 (trailing/empty fragments don't change the "multi" verdict).
246
+ const count = entry.content.split('\n').filter((l) => l.trim() === '---').length + 1;
247
+ offenders.push({ count, filePath: entry.filePath });
248
+ }
249
+ }
250
+ return offenders;
251
+ }
252
+ /**
253
+ * Return every `workspace` document in the push set, with the name it declares.
254
+ *
255
+ * A tree must carry at most ONE. The server applies the first workspace
256
+ * document it encounters to the workspace being pushed into and silently
257
+ * discards the rest, with no check that the document actually describes that
258
+ * workspace — so a second one renames the destination to a foreign name while
259
+ * leaving its content untouched, and which name wins flips with the contents of
260
+ * the pushed set.
261
+ *
262
+ * Trees accumulate a second one easily, because `pull` names the file after the
263
+ * workspace's own name (`workspace/{name}.xs`): pulling a different workspace
264
+ * into the same directory ADDS a file rather than overwriting, and renaming a
265
+ * workspace leaves the old-name file behind.
266
+ */
267
+ export function findWorkspaceEntries(entries) {
268
+ const found = [];
269
+ for (const entry of entries) {
270
+ const parsed = parseDocument(entry.content);
271
+ // Exact match: `workspace_trigger` is a different document type that also
272
+ // lives under workspace/ and must not be counted here.
273
+ if (parsed && parsed.type === 'workspace') {
274
+ found.push({ filePath: entry.filePath, name: parsed.name });
275
+ }
276
+ }
277
+ return found;
278
+ }
105
279
  // ── Validation Rendering ────────────────────────────────────────────────────
106
280
  export function renderBadReferences(badRefs, log) {
107
281
  log(ux.colorize('yellow', ux.colorize('bold', '=== Unresolved References ===')));
@@ -148,7 +322,7 @@ const TYPE_LABELS = {
148
322
  workflow_test: 'Workflow Tests',
149
323
  workspace: 'Workspace Settings',
150
324
  };
151
- function renderPreview(result, willDelete, target, verbose, partial, log) {
325
+ function renderPreview(result, willDelete, target, verbose, partial, log, filteredOutCount = 0) {
152
326
  log('');
153
327
  log(ux.colorize('bold', `=== Push Preview: ${target.label} ===`));
154
328
  let instanceHost = target.instanceOrigin;
@@ -212,6 +386,17 @@ function renderPreview(result, willDelete, target, verbose, partial, log) {
212
386
  // Split destructive ops by category
213
387
  const deleteOps = destructive.filter((op) => op.action === 'delete' || op.action === 'cascade_delete');
214
388
  const alwaysDestructive = destructive.filter((op) => op.action === 'truncate' || op.action === 'drop_field' || op.action === 'alter_field');
389
+ // --include/--exclude and --delete interact in a way the preview cannot show
390
+ // on its own: a filtered-out document is absent from the payload, so without
391
+ // the protection the push declares, the sweep would read it as removed. The
392
+ // objects it defines are spared (and the push is refused outright if they
393
+ // cannot be), but anything ELSE missing from the tree is still deleted below,
394
+ // which is worth saying while a filter is active.
395
+ if (willDelete && filteredOutCount > 0 && deleteOps.length > 0) {
396
+ log('');
397
+ log(ux.colorize('yellow', ` Note: ${filteredOutCount} file(s) were filtered out by --include/--exclude, and --delete is on.`));
398
+ log(ux.colorize('yellow', ' What they define is protected from the sweep; the deletes below are objects missing from the tree entirely.'));
399
+ }
215
400
  // Show destructive operations (deletes only when --delete, truncates/drop_field always)
216
401
  const shownDestructive = [...(willDelete ? deleteOps : []), ...alwaysDestructive];
217
402
  if (shownDestructive.length > 0) {
@@ -274,6 +459,12 @@ export async function confirm(message) {
274
459
  });
275
460
  }
276
461
  // ── GUID Sync ───────────────────────────────────────────────────────────────
462
+ /**
463
+ * Ceiling for the `protect_guids` query parameter. The whole request LINE must
464
+ * fit in one nginx buffer (8k by default), and the URL already carries the rest
465
+ * of the push parameters, so stay well inside it.
466
+ */
467
+ const PROTECT_GUIDS_MAX_CHARS = 4000;
277
468
  const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
278
469
  /**
279
470
  * Sync a GUID into a local .xs file. Returns true if the file was modified.
@@ -315,6 +506,32 @@ function syncGuidToFile(filePath, guid) {
315
506
  fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
316
507
  return true;
317
508
  }
509
+ const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
510
+ /**
511
+ * Sync a server-assigned `canonical` into a local .xs file. Returns true if the
512
+ * file was modified.
513
+ *
514
+ * Like the GUID, a canonical is minted by the instance and the document can only
515
+ * ASK for one: it is unique across every workspace on the instance, so a tree
516
+ * cloned from another workspace names canonicals that are already taken and the
517
+ * import keeps the existing value instead. Without this writeback the local file
518
+ * kept asking for a value the server kept refusing, so every push reported the
519
+ * same objects as changed forever.
520
+ *
521
+ * Unlike the GUID this NEVER inserts a missing line: only the container kinds
522
+ * (api_group, realtime_server, mcp_server/agent/toolset) have the field, and the
523
+ * server omits the key for everything else, so an absent line means "this kind
524
+ * has no canonical" rather than "not yet assigned".
525
+ */
526
+ function syncCanonicalToFile(filePath, canonical) {
527
+ const content = fs.readFileSync(filePath, 'utf8');
528
+ const existingMatch = content.match(CANONICAL_REGEX);
529
+ if (!existingMatch || existingMatch[2] === canonical) {
530
+ return false;
531
+ }
532
+ fs.writeFileSync(filePath, content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`), 'utf8');
533
+ return true;
534
+ }
318
535
  // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
319
536
  /**
320
537
  * Compute a knowledge change preview: prefer the server's `dry_run` response,
@@ -372,6 +589,8 @@ export async function executePush(ctx, target, flags) {
372
589
  // ── Collect and filter .xs files ──────────────────────────────────────
373
590
  const allFiles = collectFiles(inputDir);
374
591
  const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, log);
592
+ const filteredOut = describeFilteredOut(allFiles, files, inputDir);
593
+ const filteredOutCount = allFiles.length - files.length;
375
594
  const knowledgeOnly = files.length === 0 && (knowledgeObjects.length > 0 || ctx.knowledge !== undefined);
376
595
  if (files.length === 0 && !knowledgeOnly) {
377
596
  command.error(flags.include || flags.exclude
@@ -387,6 +606,91 @@ export async function executePush(ctx, target, flags) {
387
606
  if (documentEntries.length === 0) {
388
607
  command.error(`All .xs files in ${inputDir} are empty`);
389
608
  }
609
+ // ── Handle multi-document files ───────────────────────────────────────
610
+ // The push pipeline assumes one document per file: the partial-diff filter
611
+ // and GUID-writeback both parse only the first document in a file, so a
612
+ // bundle like a single hand-authored multidoc.xs silently pushes nothing
613
+ // (partial) or clobbers a single guid line across every document (full).
614
+ // Rather than fail, offer to flatten each bundle in place into the
615
+ // per-document layout `pull` produces, then re-read and continue the push.
616
+ const multiDocFiles = findMultiDocEntries(documentEntries);
617
+ if (multiDocFiles.length > 0) {
618
+ const list = multiDocFiles
619
+ .map((f) => ` ${relative(inputDir, f.filePath) || f.filePath} (${f.count} documents)`)
620
+ .join('\n');
621
+ log('');
622
+ log(ux.colorize('yellow', ux.colorize('bold', '=== Multi-document file(s) detected ===')));
623
+ log('');
624
+ log(ux.colorize('yellow', 'These .xs files hold more than one document:'));
625
+ log(list);
626
+ log('');
627
+ log(ux.colorize('dim', 'Push needs one document per file (partial diff + GUID writeback operate per file),\n' +
628
+ 'so a multi-doc bundle silently pushes nothing or corrupts GUIDs. I can split each\n' +
629
+ 'into the standard per-document layout (the same tree `pull` produces) and continue.'));
630
+ log('');
631
+ // Flattening DELETES the source bundle, so never do it unattended without an
632
+ // explicit opt-in. --force means "just do it"; an interactive TTY gets a
633
+ // prompt; a bare non-interactive run refuses (rather than silently rewriting
634
+ // and deleting the user's files in CI).
635
+ if (flags.force) {
636
+ log(ux.colorize('dim', 'Flattening (--force)…'));
637
+ }
638
+ else if (process.stdin.isTTY) {
639
+ const doFlatten = await confirm('Flatten these file(s) in place and continue the push?');
640
+ if (!doFlatten) {
641
+ log('Push cancelled. Run `xano flatten <file>` yourself, or re-run to be prompted again.');
642
+ return;
643
+ }
644
+ }
645
+ else {
646
+ command.error('Multi-document .xs file(s) cannot be pushed. Run `xano flatten <file>` to split them ' +
647
+ 'into the standard per-document layout first, or re-run with --force to flatten automatically.');
648
+ }
649
+ for (const offender of multiDocFiles) {
650
+ try {
651
+ const result = flattenBundleFile(offender.filePath, { log: (m) => flags.verbose && log(ux.colorize('dim', m)) });
652
+ log(ux.colorize('dim', ` flattened ${relative(inputDir, offender.filePath) || offender.filePath} → ${result.written.length} files`));
653
+ }
654
+ catch (error) {
655
+ command.error(`Failed to flatten ${offender.filePath}: ${error.message}`);
656
+ }
657
+ }
658
+ log('');
659
+ // Re-collect and re-read: the bundle files are gone, replaced by the split
660
+ // per-document tree, so the rest of the push sees a clean one-doc-per-file set.
661
+ const refreshed = readDocuments(applyFilters(collectFiles(inputDir), inputDir, flags.include, flags.exclude, () => { }));
662
+ documentEntries = refreshed;
663
+ if (documentEntries.length === 0) {
664
+ command.error('After flattening, no .xs documents remain to push.');
665
+ }
666
+ }
667
+ // ── Refuse a tree carrying more than one workspace document ───────────
668
+ // The server applies the FIRST workspace document it sees to the workspace
669
+ // being pushed into and silently drops the rest, with no check that the
670
+ // document describes that workspace. A second one therefore renames the
671
+ // destination to a foreign name while leaving all of its content intact,
672
+ // and which name wins flips with whatever happens to be in the pushed set.
673
+ //
674
+ // Checked against the WHOLE tree rather than the partial changed-set, because
675
+ // the flip-flop case is exactly the one where only the foreign document is
676
+ // "changed" and would be the sole workspace document actually sent.
677
+ //
678
+ // No --force escape and no auto-fix: unlike the multi-doc bundle above there
679
+ // is no safe repair, since only the user knows which workspace this tree is
680
+ // meant to be. Picking one is the bug.
681
+ const workspaceDocs = findWorkspaceEntries(documentEntries);
682
+ if (workspaceDocs.length > 1) {
683
+ const list = workspaceDocs
684
+ .map((w) => ` ${relative(inputDir, w.filePath) || w.filePath} → workspace "${w.name}"`)
685
+ .join('\n');
686
+ command.error(`${inputDir} holds ${workspaceDocs.length} workspace documents:\n${list}\n\n` +
687
+ 'A push targets one workspace, and the server applies only the first of these and ' +
688
+ 'silently discards the rest — renaming the destination to a foreign name while leaving ' +
689
+ 'its content untouched.\n\n' +
690
+ 'Delete the ones that do not describe the workspace you are pushing to, keeping a single ' +
691
+ 'workspace/*.xs. (`pull` names this file after the workspace, so pulling a different ' +
692
+ 'workspace into this tree, or renaming one, leaves the old file behind.)');
693
+ }
390
694
  multidoc = documentEntries.map((d) => d.content).join('\n---\n');
391
695
  // ── Build document key → file path map (for GUID writeback) ─────────
392
696
  for (const entry of documentEntries) {
@@ -420,6 +724,17 @@ export async function executePush(ctx, target, flags) {
420
724
  if (target.sourceWorkspaceId) {
421
725
  queryParams.set('source_workspace_id', target.sourceWorkspaceId);
422
726
  }
727
+ // Tell the server which objects the filter put out of scope, so its delete
728
+ // sweep spares them. Only meaningful with --delete; without it nothing is
729
+ // swept. The parameter travels in the query string (the body is the multidoc
730
+ // itself), so it has a length ceiling: past PROTECT_GUIDS_MAX_CHARS we send
731
+ // nothing and let the verification below refuse the push rather than issue a
732
+ // request the server may truncate or reject.
733
+ const protectableGuids = filteredOut.map((d) => d.guid).filter((g) => typeof g === 'string' && g.length > 0);
734
+ const protectGuidsParam = protectableGuids.join(',');
735
+ if (shouldDelete && protectGuidsParam && protectGuidsParam.length <= PROTECT_GUIDS_MAX_CHARS) {
736
+ queryParams.set('protect_guids', protectGuidsParam);
737
+ }
423
738
  // ── Request headers ───────────────────────────────────────────────────
424
739
  const requestHeaders = {
425
740
  accept: 'application/json',
@@ -451,7 +766,46 @@ export async function executePush(ctx, target, flags) {
451
766
  const knowledgeDryRun = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
452
767
  mergeKnowledgePreview(preview, knowledgeDryRun);
453
768
  }
454
- renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log);
769
+ renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log, filteredOutCount);
770
+ // GUARD: --include/--exclude must never cost you the objects it
771
+ // filtered out.
772
+ //
773
+ // A filtered-out document is absent from the payload, so a --delete
774
+ // sweep reads it as removed from the tree and deletes it - the exact
775
+ // opposite of what -e means to the person typing it. The server is
776
+ // told to spare them (protect_guids above), but this checks the
777
+ // SERVER'S OWN preview rather than assuming that worked: an older
778
+ // instance ignores the parameter, a document that has never been
779
+ // pushed has no GUID to protect, and a very large filter exceeds what
780
+ // the query string can carry. In every one of those cases the push is
781
+ // refused rather than allowed to delete.
782
+ if (shouldDelete) {
783
+ const endangered = deletesHittingFilteredOut(preview.operations, filteredOut);
784
+ if (endangered.length > 0) {
785
+ log('');
786
+ log(ux.colorize('red', '--- Refusing to push ---'));
787
+ log('');
788
+ log(` --delete would remove ${endangered.length} object(s) that --include/--exclude filtered out:`);
789
+ log('');
790
+ for (const hit of endangered.slice(0, 10)) {
791
+ log(` ${ux.colorize('red', 'DELETE'.padEnd(8))} ${hit.type.padEnd(18)} ${hit.name} ${ux.colorize('dim', `(${hit.relPath})`)}`);
792
+ }
793
+ if (endangered.length > 10) {
794
+ log(ux.colorize('dim', ` ... and ${endangered.length - 10} more`));
795
+ }
796
+ log('');
797
+ const unprotectable = filteredOut.filter((d) => !d.guid).length;
798
+ if (unprotectable > 0) {
799
+ log(ux.colorize('dim', ` ${unprotectable} filtered-out document(s) have no guid yet, so the server cannot be told to spare them.`));
800
+ }
801
+ if (protectGuidsParam.length > PROTECT_GUIDS_MAX_CHARS) {
802
+ log(ux.colorize('dim', ' Too many filtered-out documents to declare in one request.'));
803
+ }
804
+ log(ux.colorize('dim', ' This instance may also predate protected pushes.'));
805
+ log('');
806
+ command.error('Re-run without --delete, or without the --include/--exclude filter.');
807
+ }
808
+ }
455
809
  // Check for bad cross-references using dry-run operations to avoid false positives
456
810
  const badRefs = checkReferences(documentEntries, preview.operations);
457
811
  if (badRefs.length > 0) {
@@ -689,6 +1043,7 @@ export async function executePush(ctx, target, flags) {
689
1043
  }
690
1044
  }
691
1045
  let updatedCount = 0;
1046
+ let canonicalCount = 0;
692
1047
  for (const entry of guidMap) {
693
1048
  if (!entry.guid)
694
1049
  continue;
@@ -715,10 +1070,23 @@ export async function executePush(ctx, target, flags) {
715
1070
  catch (error) {
716
1071
  command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
717
1072
  }
1073
+ if (entry.canonical) {
1074
+ try {
1075
+ const updated = syncCanonicalToFile(filePath, entry.canonical);
1076
+ if (updated)
1077
+ canonicalCount++;
1078
+ }
1079
+ catch (error) {
1080
+ command.warn(`Failed to sync canonical to ${filePath}: ${error.message}`);
1081
+ }
1082
+ }
718
1083
  }
719
1084
  if (updatedCount > 0) {
720
1085
  log(`Synced ${updatedCount} GUIDs to local files`);
721
1086
  }
1087
+ if (canonicalCount > 0) {
1088
+ log(`Synced ${canonicalCount} canonicals to local files (the instance assigned its own)`);
1089
+ }
722
1090
  }
723
1091
  pushedDocCount = multidoc.split('\n---\n').length;
724
1092
  }
@@ -820,6 +1188,7 @@ export function describeNetworkError(error, url, elapsedMs) {
820
1188
  ETIMEDOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
821
1189
  UND_ERR_CONNECT_TIMEOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
822
1190
  UND_ERR_HEADERS_TIMEOUT: `${host} accepted the connection but did not respond in time. The push may be too large; try splitting it or retrying.`,
1191
+ UND_ERR_INVALID_ARG: `Your Node runtime rejected the CLI's HTTP dispatcher. Update the CLI, or run it under Node 24 (see DEV-7773).`,
823
1192
  };
824
1193
  let base;
825
1194
  if (code && hints[code]) {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * How much paging information the backing Metadata API endpoint gives us.
3
+ *
4
+ * The Metadata API pages unevenly, and what the CLI can honestly tell the user
5
+ * depends entirely on what the response carries:
6
+ *
7
+ * - `envelope` accepts page + per_page and returns
8
+ * {curPage, nextPage, prevPage, items[]}. `nextPage` is
9
+ * server-computed and null on the last page, so it is the
10
+ * one place a "there is more" hint is guaranteed correct.
11
+ * - `page-only-envelope` accepts page only (per_page is hardcoded server-side)
12
+ * and returns an object carrying `itemsTotal`. A true
13
+ * total, so we can say where the user sits — but we offer
14
+ * no next-page affordance.
15
+ * - `none` no server-side paging at all. The full set comes back,
16
+ * so the item count IS the total.
17
+ *
18
+ * There is deliberately no tier for endpoints that page but return a bare array
19
+ * with no metadata (tenants, releases, platforms, clusters, ephemerals, tenant
20
+ * backups). Those commands are left alone: the only has-more signal available
21
+ * would be "the page came back full", which is a guaranteed false positive when
22
+ * the result set is an exact multiple of the page size — sending the user to an
23
+ * empty page. Better to offer nothing than to offer a lie.
24
+ */
25
+ export type PagingTier = 'envelope' | 'none' | 'page-only-envelope';
26
+ export interface PagingFlagValues {
27
+ page?: number;
28
+ per_page?: number;
29
+ }
30
+ export interface NormalizedList<T> {
31
+ curPage?: number;
32
+ items: T[];
33
+ itemsTotal?: number;
34
+ nextPage?: number;
35
+ prevPage?: number;
36
+ }
37
+ export interface PagingFlagOptions {
38
+ /** Page size the endpoint hardcodes server-side; surfaced in the page flag description. */
39
+ fixedPerPage?: number;
40
+ /** Maximum per_page the endpoint accepts; surfaced in the per_page flag description. */
41
+ maxPerPage?: number;
42
+ }
43
+ declare function makePageFlag(description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
44
+ declare function makePerPageFlag(defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
45
+ type PageFlag = ReturnType<typeof makePageFlag>;
46
+ type PerPageFlag = ReturnType<typeof makePerPageFlag>;
47
+ /**
48
+ * Build the oclif flag definitions appropriate to an endpoint's paging tier.
49
+ * Spread the result into a command's `static flags`.
50
+ *
51
+ * A flag the server ignores is worse than no flag, so `per_page` is only
52
+ * produced for tiers whose endpoint actually accepts it. The overloads keep
53
+ * oclif's flag-type inference intact so `flags.page` stays typed at call sites.
54
+ */
55
+ export declare function pagingFlags(tier: 'envelope', options?: PagingFlagOptions): {
56
+ page: PageFlag;
57
+ per_page: PerPageFlag;
58
+ };
59
+ export declare function pagingFlags(tier: 'page-only-envelope', options?: PagingFlagOptions): {
60
+ page: PageFlag;
61
+ };
62
+ export declare function pagingFlags(tier: 'none', options?: PagingFlagOptions): Record<string, never>;
63
+ export declare function pagingFlags(tier: PagingTier, options?: PagingFlagOptions): Record<string, unknown>;
64
+ /**
65
+ * Translate parsed paging flags into query-string entries, emitting only the
66
+ * params the endpoint actually declares.
67
+ */
68
+ export declare function buildPagingParams(flags: PagingFlagValues | Record<string, unknown>, tier: PagingTier): Array<[string, string]>;
69
+ /**
70
+ * Absorb the response shapes the Metadata API returns for list endpoints:
71
+ * a bare array, a paging envelope keyed on `items`, or an object keyed on the
72
+ * resource name (e.g. `{workspaces: [...]}`).
73
+ *
74
+ * `resourceKeys` names any resource-keyed fallbacks to check, in order.
75
+ */
76
+ export declare function normalizeListResponse<T>(data: unknown, resourceKeys?: string[]): NormalizedList<T>;
77
+ /** Page size used when the CLI pages an endpoint on the user's behalf. */
78
+ export declare const INTERNAL_PAGE_SIZE = 100;
79
+ /**
80
+ * Walk an envelope-tier endpoint page by page and collect every item.
81
+ *
82
+ * Termination is the server's own `nextPage`, never "the page came back full" —
83
+ * the same rule the footer follows. An empty page also stops the walk, which
84
+ * covers an endpoint that omits `nextPage` entirely rather than looping forever.
85
+ *
86
+ * `maxPages` is a runaway backstop, not a limit users are expected to hit; when
87
+ * it trips, `onTruncate` fires so the caller can say so out loud instead of
88
+ * silently processing a subset.
89
+ */
90
+ export declare function collectAllPages<T>(fetchPage: (page: number) => Promise<NormalizedList<T>>, options?: {
91
+ maxPages?: number;
92
+ onTruncate?: (collected: number) => void;
93
+ }): Promise<T[]>;
94
+ export interface PagingJson<T> {
95
+ curPage?: number;
96
+ items: T[];
97
+ itemsTotal?: number;
98
+ nextPage?: number;
99
+ perPage?: number;
100
+ prevPage?: number;
101
+ }
102
+ /**
103
+ * Build the `--output json` payload for a list command.
104
+ *
105
+ * Field names mirror the Metadata API envelope exactly — `curPage`, `nextPage`,
106
+ * `prevPage`, `itemsTotal`, `items` — so a script can move between the CLI and
107
+ * the raw API without a translation layer. `perPage` is the one field the CLI
108
+ * injects: the API never echoes it back, and it is otherwise invisible when the
109
+ * user leaves the flag at its default.
110
+ *
111
+ * Emits only fields the response actually proves, mirroring the footer's
112
+ * honesty rule: `itemsTotal` appears only when the server sent it, `nextPage`
113
+ * only when the server computed it.
114
+ *
115
+ * Without this envelope, a script driving `--output json` sees a bare array and
116
+ * its only available stop condition is `length < perPage` — precisely the
117
+ * page-fullness inference this module refuses to make in the footer.
118
+ */
119
+ export declare function buildPagingJson<T>(list: NormalizedList<T>, options: {
120
+ page?: number;
121
+ perPage?: number;
122
+ tier: PagingTier;
123
+ }): PagingJson<T>;
124
+ export interface PagingFooterOptions {
125
+ /** Singular noun for the listed resource, used by the `none` tier. */
126
+ noun: string;
127
+ /** Plural form; defaults to `noun + 's'`. */
128
+ nounPlural?: string;
129
+ /** Page the user requested, used when the response omits `curPage`. */
130
+ page?: number;
131
+ /** Page size the user requested. Recorded for context; never used to infer a next page. */
132
+ perPage?: number;
133
+ tier: PagingTier;
134
+ }
135
+ /**
136
+ * Render the paging footer for a list command, reporting only facts the response
137
+ * actually proves. Returns null when there is nothing worth saying — an empty
138
+ * result set already prints its own "No X found" line.
139
+ *
140
+ * Deliberately asymmetric across tiers: `envelope` shows a next-page hint but no
141
+ * total, `page-only-envelope` shows a total but no next-page hint, and `none`
142
+ * shows a bare count. See PagingTier for why.
143
+ */
144
+ export declare function formatPagingFooter<T>(list: NormalizedList<T>, options: PagingFooterOptions): null | string;
145
+ export {};