@xano/cli 1.2.0 → 1.3.1-beta.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 (192) hide show
  1. package/README.md +381 -4
  2. package/dist/base-command.d.ts +54 -36
  3. package/dist/base-command.js +246 -169
  4. package/dist/commands/auth/index.d.ts +1 -1
  5. package/dist/commands/auth/index.js +7 -7
  6. package/dist/commands/branch/create/index.d.ts +1 -1
  7. package/dist/commands/branch/create/index.js +1 -1
  8. package/dist/commands/branch/set_live/index.d.ts +22 -0
  9. package/dist/commands/branch/set_live/index.js +60 -1
  10. package/dist/commands/ephemeral/create/index.d.ts +18 -0
  11. package/dist/commands/ephemeral/create/index.js +103 -0
  12. package/dist/commands/ephemeral/delete/index.d.ts +18 -0
  13. package/dist/commands/ephemeral/delete/index.js +99 -0
  14. package/dist/commands/ephemeral/edit/index.d.ts +18 -0
  15. package/dist/commands/ephemeral/edit/index.js +109 -0
  16. package/dist/commands/ephemeral/get/index.d.ts +21 -0
  17. package/dist/commands/ephemeral/get/index.js +125 -0
  18. package/dist/commands/ephemeral/impersonate/index.d.ts +20 -0
  19. package/dist/commands/ephemeral/impersonate/index.js +120 -0
  20. package/dist/commands/ephemeral/list/index.d.ts +19 -0
  21. package/dist/commands/ephemeral/list/index.js +123 -0
  22. package/dist/commands/ephemeral/pull/index.d.ts +19 -0
  23. package/dist/commands/ephemeral/pull/index.js +163 -0
  24. package/dist/commands/ephemeral/push/index.d.ts +35 -0
  25. package/dist/commands/ephemeral/push/index.js +228 -0
  26. package/dist/commands/ephemeral/static_host/build/delete/index.d.ts +20 -0
  27. package/dist/commands/ephemeral/static_host/build/delete/index.js +121 -0
  28. package/dist/commands/ephemeral/static_host/build/get/index.d.ts +18 -0
  29. package/dist/commands/ephemeral/static_host/build/get/index.js +137 -0
  30. package/dist/commands/ephemeral/static_host/build/list/index.d.ts +18 -0
  31. package/dist/commands/ephemeral/static_host/build/list/index.js +137 -0
  32. package/dist/commands/ephemeral/static_host/build/pull/index.d.ts +53 -0
  33. package/dist/commands/ephemeral/static_host/build/pull/index.js +308 -0
  34. package/dist/commands/ephemeral/static_host/build/push/index.d.ts +24 -0
  35. package/dist/commands/ephemeral/static_host/build/push/index.js +250 -0
  36. package/dist/commands/ephemeral/static_host/create/index.d.ts +18 -0
  37. package/dist/commands/ephemeral/static_host/create/index.js +92 -0
  38. package/dist/commands/ephemeral/static_host/deploy/index.d.ts +19 -0
  39. package/dist/commands/ephemeral/static_host/deploy/index.js +112 -0
  40. package/dist/commands/ephemeral/static_host/edit/index.d.ts +24 -0
  41. package/dist/commands/ephemeral/static_host/edit/index.js +158 -0
  42. package/dist/commands/ephemeral/static_host/get/index.d.ts +19 -0
  43. package/dist/commands/ephemeral/static_host/get/index.js +101 -0
  44. package/dist/commands/ephemeral/static_host/list/index.d.ts +17 -0
  45. package/dist/commands/ephemeral/static_host/list/index.js +132 -0
  46. package/dist/commands/flatten/index.d.ts +34 -0
  47. package/dist/commands/flatten/index.js +140 -0
  48. package/dist/commands/function/create/index.d.ts +15 -0
  49. package/dist/commands/function/create/index.js +41 -1
  50. package/dist/commands/function/edit/index.d.ts +15 -0
  51. package/dist/commands/function/edit/index.js +66 -32
  52. package/dist/commands/function/get/index.js +2 -3
  53. package/dist/commands/function/run/index.d.ts +1 -0
  54. package/dist/commands/function/run/index.js +19 -6
  55. package/dist/commands/policy/catalogue/index.d.ts +15 -0
  56. package/dist/commands/policy/catalogue/index.js +29 -0
  57. package/dist/commands/policy/create/index.d.ts +18 -0
  58. package/dist/commands/policy/create/index.js +39 -0
  59. package/dist/commands/policy/delete/index.d.ts +19 -0
  60. package/dist/commands/policy/delete/index.js +61 -0
  61. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  62. package/dist/commands/policy/evaluate/index.js +71 -0
  63. package/dist/commands/policy/list/index.d.ts +14 -0
  64. package/dist/commands/policy/list/index.js +21 -0
  65. package/dist/commands/policy/parse/index.d.ts +19 -0
  66. package/dist/commands/policy/parse/index.js +18 -0
  67. package/dist/commands/policy/publish/index.d.ts +20 -0
  68. package/dist/commands/policy/publish/index.js +38 -0
  69. package/dist/commands/policy/runs/index.d.ts +54 -0
  70. package/dist/commands/policy/runs/index.js +273 -0
  71. package/dist/commands/policy/status/index.d.ts +21 -0
  72. package/dist/commands/policy/status/index.js +54 -0
  73. package/dist/commands/profile/create/index.d.ts +1 -1
  74. package/dist/commands/profile/create/index.js +7 -7
  75. package/dist/commands/profile/me/index.js +1 -1
  76. package/dist/commands/profile/use/index.js +0 -4
  77. package/dist/commands/profile/wizard/index.d.ts +1 -1
  78. package/dist/commands/profile/wizard/index.js +12 -10
  79. package/dist/commands/profile/workspace/set/index.js +1 -1
  80. package/dist/commands/release/create/index.js +7 -0
  81. package/dist/commands/release/deploy/index.d.ts +28 -0
  82. package/dist/commands/release/deploy/index.js +90 -6
  83. package/dist/commands/release/edit/index.js +2 -2
  84. package/dist/commands/release/export/index.js +1 -2
  85. package/dist/commands/release/import/index.js +6 -2
  86. package/dist/commands/release/list/index.d.ts +7 -0
  87. package/dist/commands/release/list/index.js +54 -10
  88. package/dist/commands/release/pull/index.d.ts +0 -6
  89. package/dist/commands/release/pull/index.js +13 -99
  90. package/dist/commands/release/push/index.js +21 -3
  91. package/dist/commands/sandbox/env/get_all/index.js +1 -1
  92. package/dist/commands/sandbox/env/set_all/index.js +1 -1
  93. package/dist/commands/sandbox/get/index.js +1 -1
  94. package/dist/commands/sandbox/license/get/index.js +1 -1
  95. package/dist/commands/sandbox/license/set/index.js +1 -1
  96. package/dist/commands/sandbox/pull/index.d.ts +0 -1
  97. package/dist/commands/sandbox/pull/index.js +13 -81
  98. package/dist/commands/sandbox/push/index.d.ts +1 -1
  99. package/dist/commands/sandbox/push/index.js +11 -6
  100. package/dist/commands/skills/pull/index.d.ts +30 -0
  101. package/dist/commands/skills/pull/index.js +118 -0
  102. package/dist/commands/static_host/build/create/index.d.ts +1 -1
  103. package/dist/commands/static_host/build/create/index.js +2 -2
  104. package/dist/commands/static_host/build/pull/index.js +1 -1
  105. package/dist/commands/static_host/build/push/index.js +1 -1
  106. package/dist/commands/static_host/edit/index.js +1 -1
  107. package/dist/commands/tenant/backup/export/index.js +1 -2
  108. package/dist/commands/tenant/backup/import/index.js +1 -1
  109. package/dist/commands/tenant/cluster/create/index.js +1 -1
  110. package/dist/commands/tenant/cluster/license/get/index.js +1 -1
  111. package/dist/commands/tenant/cluster/license/set/index.js +1 -1
  112. package/dist/commands/tenant/create/index.d.ts +4 -1
  113. package/dist/commands/tenant/create/index.js +26 -5
  114. package/dist/commands/tenant/deploy_platform/index.js +1 -1
  115. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  116. package/dist/commands/tenant/deploy_release/index.js +186 -4
  117. package/dist/commands/tenant/edit/index.d.ts +2 -0
  118. package/dist/commands/tenant/edit/index.js +46 -11
  119. package/dist/commands/tenant/env/get_all/index.js +1 -1
  120. package/dist/commands/tenant/env/set_all/index.js +1 -1
  121. package/dist/commands/tenant/impersonate/index.d.ts +1 -1
  122. package/dist/commands/tenant/impersonate/index.js +14 -14
  123. package/dist/commands/tenant/license/get/index.js +1 -1
  124. package/dist/commands/tenant/license/set/index.js +1 -1
  125. package/dist/commands/tenant/pull/index.d.ts +0 -6
  126. package/dist/commands/tenant/pull/index.js +14 -100
  127. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  128. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  129. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  130. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  131. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  132. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  133. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  134. package/dist/commands/tenant_deploy_request/get/index.js +116 -0
  135. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  136. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  137. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  138. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  139. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  140. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  141. package/dist/commands/workspace/git/pull/index.d.ts +4 -4
  142. package/dist/commands/workspace/git/pull/index.js +158 -97
  143. package/dist/commands/workspace/pull/index.d.ts +9 -5
  144. package/dist/commands/workspace/pull/index.js +83 -106
  145. package/dist/commands/workspace/push/index.d.ts +19 -0
  146. package/dist/commands/workspace/push/index.js +124 -5
  147. package/dist/help.js +4 -2
  148. package/dist/policy-command.d.ts +90 -0
  149. package/dist/policy-command.js +94 -0
  150. package/dist/update-check.d.ts +1 -1
  151. package/dist/update-check.js +1 -1
  152. package/dist/utils/document-parser.d.ts +126 -0
  153. package/dist/utils/document-parser.js +332 -4
  154. package/dist/utils/flatten.d.ts +36 -0
  155. package/dist/utils/flatten.js +68 -0
  156. package/dist/utils/knowledge-sync.d.ts +1 -0
  157. package/dist/utils/knowledge-sync.js +11 -6
  158. package/dist/utils/microservice-wait.d.ts +53 -0
  159. package/dist/utils/microservice-wait.js +84 -0
  160. package/dist/utils/multidoc-push.d.ts +143 -2
  161. package/dist/utils/multidoc-push.js +479 -40
  162. package/dist/utils/paging.d.ts +2 -1
  163. package/dist/utils/paging.js +0 -1
  164. package/dist/utils/policy/catalogue.d.ts +7 -0
  165. package/dist/utils/policy/catalogue.js +72 -0
  166. package/dist/utils/policy/errors.d.ts +12 -0
  167. package/dist/utils/policy/errors.js +61 -0
  168. package/dist/utils/policy/feedback.d.ts +68 -0
  169. package/dist/utils/policy/feedback.js +165 -0
  170. package/dist/utils/policy/findings.d.ts +23 -0
  171. package/dist/utils/policy/findings.js +65 -0
  172. package/dist/utils/policy/gate.d.ts +161 -0
  173. package/dist/utils/policy/gate.js +201 -0
  174. package/dist/utils/policy/goal.d.ts +46 -0
  175. package/dist/utils/policy/goal.js +95 -0
  176. package/dist/utils/policy/permission.d.ts +23 -0
  177. package/dist/utils/policy/permission.js +84 -0
  178. package/dist/utils/policy/push-gate.d.ts +5 -0
  179. package/dist/utils/policy/push-gate.js +29 -0
  180. package/dist/utils/policy/release.d.ts +85 -0
  181. package/dist/utils/policy/release.js +115 -0
  182. package/dist/utils/policy/request.d.ts +55 -0
  183. package/dist/utils/policy/request.js +115 -0
  184. package/dist/utils/policy/runs.d.ts +30 -0
  185. package/dist/utils/policy/runs.js +101 -0
  186. package/dist/utils/policy/status.d.ts +47 -0
  187. package/dist/utils/policy/status.js +117 -0
  188. package/dist/utils/policy/types.d.ts +185 -0
  189. package/dist/utils/policy/types.js +1 -0
  190. package/dist/utils/reference-checker.js +14 -3
  191. package/oclif.manifest.json +8538 -3445
  192. package/package.json +16 -1
@@ -1,10 +1,24 @@
1
- import { ux } from '@oclif/core';
1
+ import { Errors, ux } from '@oclif/core';
2
2
  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';
8
+ import { policyWeakeningGuidance, WEAKENING_REFUSAL } from './policy/permission.js';
9
+ import { PushPolicyGateError } from './policy/push-gate.js';
7
10
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
11
+ /**
12
+ * A push that failed after its multidoc import landed. The import is not rolled back, so the error
13
+ * carries what it did for the calling command to report beside the failure.
14
+ */
15
+ export class FailedAfterImportError extends Errors.CLIError {
16
+ imported;
17
+ constructor(message, imported) {
18
+ super(message);
19
+ this.imported = imported;
20
+ }
21
+ }
8
22
  /**
9
23
  * Filter document entries down to the ones the dry-run preview reports as changed,
10
24
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -65,30 +79,151 @@ export function collectFiles(dir) {
65
79
  * Apply include/exclude glob filters to a file list. Logs filter results.
66
80
  * Returns the filtered file list.
67
81
  */
82
+ /**
83
+ * Normalize a filter pattern into one minimatch can actually match against a
84
+ * repo-relative FILE path.
85
+ *
86
+ * minimatch has no notion of "this pattern names a directory", and the two
87
+ * spellings a person reaches for first both silently match NOTHING:
88
+ *
89
+ * "table/" a trailing slash is not directory syntax; it matches nothing
90
+ * "table" no slash, so `matchBase` compares it to the file's BASENAME,
91
+ * i.e. it asks "is this file called `table`", not "is it under
92
+ * `table/`"
93
+ *
94
+ * Silently matching nothing is the dangerous half of this: combined with
95
+ * `--delete`, a filter that fails open pushes more than intended, and a filter
96
+ * that succeeds deletes what it was meant to protect. So a pattern that names a
97
+ * real directory is expanded to `<dir>/**` rather than left to match nothing.
98
+ */
99
+ export function normalizeFilterPattern(pattern, inputDir) {
100
+ const trimmed = pattern.replace(/[/\\]+$/, '');
101
+ if (trimmed === '')
102
+ return pattern;
103
+ // Anything with a glob character is taken exactly as written.
104
+ if (trimmed.includes('*') || trimmed.includes('?') || trimmed.includes('[')) {
105
+ return trimmed === pattern ? pattern : trimmed;
106
+ }
107
+ // A trailing slash is an unambiguous "this is a directory".
108
+ if (trimmed !== pattern)
109
+ return `${trimmed}/**`;
110
+ try {
111
+ if (fs.statSync(join(inputDir, trimmed)).isDirectory())
112
+ return `${trimmed}/**`;
113
+ }
114
+ catch {
115
+ // Not a path in this tree - leave it alone (it may be a bare filename,
116
+ // which matchBase handles).
117
+ }
118
+ return pattern;
119
+ }
68
120
  export function applyFilters(files, inputDir, include, exclude, log) {
69
121
  let filtered = files;
70
122
  const totalCount = files.length;
123
+ // Report each pattern's own match count. The aggregate line alone hides a dead
124
+ // pattern among live ones: a run with three -e patterns where one is a typo
125
+ // still prints a plausible number and says nothing about which one matched
126
+ // nothing.
127
+ const describe = (patterns, rels) => patterns
128
+ .map((raw) => {
129
+ const pattern = normalizeFilterPattern(raw, inputDir);
130
+ const hits = rels.filter((rel) => minimatch(rel, pattern, { matchBase: true })).length;
131
+ const shown = pattern === raw ? raw : `${raw} -> ${pattern}`;
132
+ return hits === 0
133
+ ? `${ux.colorize('yellow', shown)} ${ux.colorize('yellow', '(matched 0 files)')}`
134
+ : `${ux.colorize('cyan', shown)} ${ux.colorize('dim', `(${hits})`)}`;
135
+ })
136
+ .join(', ');
71
137
  if (include && include.length > 0) {
138
+ const rels = filtered.map((f) => relative(inputDir, f));
139
+ const patterns = include.map((p) => normalizeFilterPattern(p, inputDir));
140
+ log('');
141
+ log(` ${ux.colorize('dim', 'Include:')} ${describe(include, rels)}`);
72
142
  filtered = filtered.filter((f) => {
73
143
  const rel = relative(inputDir, f);
74
- return include.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
144
+ return patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
75
145
  });
76
- log('');
77
- log(` ${ux.colorize('dim', 'Include:')} ${include.map((p) => ux.colorize('cyan', p)).join(', ')}`);
78
146
  log(` ${ux.colorize('dim', 'Matched:')} ${ux.colorize('bold', String(filtered.length))} of ${totalCount} files`);
79
147
  }
80
148
  if (exclude && exclude.length > 0) {
81
149
  const beforeCount = filtered.length;
150
+ const rels = filtered.map((f) => relative(inputDir, f));
151
+ const patterns = exclude.map((p) => normalizeFilterPattern(p, inputDir));
152
+ log('');
153
+ log(` ${ux.colorize('dim', 'Exclude:')} ${describe(exclude, rels)}`);
82
154
  filtered = filtered.filter((f) => {
83
155
  const rel = relative(inputDir, f);
84
- return !exclude.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
156
+ return !patterns.some((pattern) => minimatch(rel, pattern, { matchBase: true }));
85
157
  });
86
- log('');
87
- log(` ${ux.colorize('dim', 'Exclude:')} ${exclude.map((p) => ux.colorize('cyan', p)).join(', ')}`);
88
158
  log(` ${ux.colorize('dim', 'Kept:')} ${ux.colorize('bold', String(filtered.length))} of ${beforeCount} files (excluded ${beforeCount - filtered.length})`);
89
159
  }
90
160
  return filtered;
91
161
  }
162
+ /**
163
+ * Describe the documents a --include/--exclude filter removed.
164
+ *
165
+ * These are the objects the user asked NOT to touch, and they are exactly the
166
+ * ones a `--delete` sweep would remove: a filtered-out document never reaches
167
+ * the payload, so the server reads it as deleted from the tree. Their GUIDs are
168
+ * sent as `protect_guids` so the server spares them, and their keys are used to
169
+ * VERIFY the server actually did.
170
+ */
171
+ export function describeFilteredOut(allFiles, keptFiles, inputDir) {
172
+ const kept = new Set(keptFiles);
173
+ const out = [];
174
+ for (const filePath of allFiles) {
175
+ if (kept.has(filePath))
176
+ continue;
177
+ let content;
178
+ try {
179
+ content = fs.readFileSync(filePath, 'utf8').trim();
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ if (!content)
185
+ continue;
186
+ const parsed = parseDocument(content);
187
+ if (!parsed)
188
+ continue;
189
+ const opName = parsed.verb ? `${parsed.name} ${parsed.verb}` : parsed.name;
190
+ out.push({
191
+ guid: parsed.guid,
192
+ // The preview buckets every trigger subtype under the generic `trigger`
193
+ // type, so key triggers that way to match (DEV-7084, same as
194
+ // filterChangedEntries).
195
+ key: `${parsed.type.endsWith('_trigger') ? 'trigger' : parsed.type}:${opName}`,
196
+ name: opName,
197
+ relPath: relative(inputDir, filePath),
198
+ type: parsed.type,
199
+ });
200
+ }
201
+ return out;
202
+ }
203
+ /**
204
+ * Delete operations that would remove something the filter excluded.
205
+ *
206
+ * This is the guard's actual test, and it is deliberately performed against the
207
+ * dry-run the SERVER returned rather than trusting that `protect_guids` was
208
+ * honoured: an older instance ignores the parameter entirely, and a document
209
+ * with no GUID cannot be protected at all. Either way the answer here is the
210
+ * truth about what the push is about to do.
211
+ */
212
+ export function deletesHittingFilteredOut(operations, filteredOut) {
213
+ if (filteredOut.length === 0)
214
+ return [];
215
+ const byKey = new Map(filteredOut.map((d) => [d.key, d]));
216
+ const hits = [];
217
+ for (const op of operations) {
218
+ if (op.action !== 'delete' && op.action !== 'cascade_delete')
219
+ continue;
220
+ const match = byKey.get(`${op.type}:${op.name}`);
221
+ if (match) {
222
+ hits.push({ name: op.name, relPath: match.relPath, type: op.type });
223
+ }
224
+ }
225
+ return hits;
226
+ }
92
227
  /**
93
228
  * Read .xs files into document entries, skipping empty files.
94
229
  */
@@ -102,6 +237,58 @@ export function readDocuments(files) {
102
237
  }
103
238
  return entries;
104
239
  }
240
+ /**
241
+ * A `.xs` document separator: a line that is exactly `---`. The whole push
242
+ * pipeline (partial-diff filtering, the document→file map, and GUID writeback)
243
+ * assumes ONE document per file — it only ever parses the first document in a
244
+ * file and rewrites the first `guid =` line. A multi-document file therefore
245
+ * silently drops on a partial push and corrupts GUIDs on a full one, so it is
246
+ * refused up front (with a pointer to `xano flatten`).
247
+ */
248
+ const DOC_SEPARATOR = /^---$/m;
249
+ /**
250
+ * Return the entries whose file holds more than one document (a `---`
251
+ * separator). Used to refuse multi-doc files before the push pipeline's
252
+ * single-doc-per-file assumptions can drop or corrupt them.
253
+ */
254
+ export function findMultiDocEntries(entries) {
255
+ const offenders = [];
256
+ for (const entry of entries) {
257
+ if (DOC_SEPARATOR.test(entry.content)) {
258
+ // Count documents = separators + 1 (trailing/empty fragments don't change the "multi" verdict).
259
+ const count = entry.content.split('\n').filter((l) => l.trim() === '---').length + 1;
260
+ offenders.push({ count, filePath: entry.filePath });
261
+ }
262
+ }
263
+ return offenders;
264
+ }
265
+ /**
266
+ * Return every `workspace` document in the push set, with the name it declares.
267
+ *
268
+ * A tree must carry at most ONE. The server applies the first workspace
269
+ * document it encounters to the workspace being pushed into and silently
270
+ * discards the rest, with no check that the document actually describes that
271
+ * workspace — so a second one renames the destination to a foreign name while
272
+ * leaving its content untouched, and which name wins flips with the contents of
273
+ * the pushed set.
274
+ *
275
+ * Trees accumulate a second one easily, because `pull` names the file after the
276
+ * workspace's own name (`workspace/{name}.xs`): pulling a different workspace
277
+ * into the same directory ADDS a file rather than overwriting, and renaming a
278
+ * workspace leaves the old-name file behind.
279
+ */
280
+ export function findWorkspaceEntries(entries) {
281
+ const found = [];
282
+ for (const entry of entries) {
283
+ const parsed = parseDocument(entry.content);
284
+ // Exact match: `workspace_trigger` is a different document type that also
285
+ // lives under workspace/ and must not be counted here.
286
+ if (parsed && parsed.type === 'workspace') {
287
+ found.push({ filePath: entry.filePath, name: parsed.name });
288
+ }
289
+ }
290
+ return found;
291
+ }
105
292
  // ── Validation Rendering ────────────────────────────────────────────────────
106
293
  export function renderBadReferences(badRefs, log) {
107
294
  log(ux.colorize('yellow', ux.colorize('bold', '=== Unresolved References ===')));
@@ -148,7 +335,7 @@ const TYPE_LABELS = {
148
335
  workflow_test: 'Workflow Tests',
149
336
  workspace: 'Workspace Settings',
150
337
  };
151
- function renderPreview(result, willDelete, target, verbose, partial, log) {
338
+ function renderPreview(result, willDelete, target, verbose, partial, log, filteredOutCount = 0) {
152
339
  log('');
153
340
  log(ux.colorize('bold', `=== Push Preview: ${target.label} ===`));
154
341
  let instanceHost = target.instanceOrigin;
@@ -212,6 +399,17 @@ function renderPreview(result, willDelete, target, verbose, partial, log) {
212
399
  // Split destructive ops by category
213
400
  const deleteOps = destructive.filter((op) => op.action === 'delete' || op.action === 'cascade_delete');
214
401
  const alwaysDestructive = destructive.filter((op) => op.action === 'truncate' || op.action === 'drop_field' || op.action === 'alter_field');
402
+ // --include/--exclude and --delete interact in a way the preview cannot show
403
+ // on its own: a filtered-out document is absent from the payload, so without
404
+ // the protection the push declares, the sweep would read it as removed. The
405
+ // objects it defines are spared (and the push is refused outright if they
406
+ // cannot be), but anything ELSE missing from the tree is still deleted below,
407
+ // which is worth saying while a filter is active.
408
+ if (willDelete && filteredOutCount > 0 && deleteOps.length > 0) {
409
+ log('');
410
+ log(ux.colorize('yellow', ` Note: ${filteredOutCount} file(s) were filtered out by --include/--exclude, and --delete is on.`));
411
+ log(ux.colorize('yellow', ' What they define is protected from the sweep; the deletes below are objects missing from the tree entirely.'));
412
+ }
215
413
  // Show destructive operations (deletes only when --delete, truncates/drop_field always)
216
414
  const shownDestructive = [...(willDelete ? deleteOps : []), ...alwaysDestructive];
217
415
  if (shownDestructive.length > 0) {
@@ -274,6 +472,12 @@ export async function confirm(message) {
274
472
  });
275
473
  }
276
474
  // ── GUID Sync ───────────────────────────────────────────────────────────────
475
+ /**
476
+ * Ceiling for the `protect_guids` query parameter. The whole request LINE must
477
+ * fit in one nginx buffer (8k by default), and the URL already carries the rest
478
+ * of the push parameters, so stay well inside it.
479
+ */
480
+ const PROTECT_GUIDS_MAX_CHARS = 4000;
277
481
  const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
278
482
  /**
279
483
  * Sync a GUID into a local .xs file. Returns true if the file was modified.
@@ -315,6 +519,32 @@ function syncGuidToFile(filePath, guid) {
315
519
  fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
316
520
  return true;
317
521
  }
522
+ const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
523
+ /**
524
+ * Sync a server-assigned `canonical` into a local .xs file. Returns true if the
525
+ * file was modified.
526
+ *
527
+ * Like the GUID, a canonical is minted by the instance and the document can only
528
+ * ASK for one: it is unique across every workspace on the instance, so a tree
529
+ * cloned from another workspace names canonicals that are already taken and the
530
+ * import keeps the existing value instead. Without this writeback the local file
531
+ * kept asking for a value the server kept refusing, so every push reported the
532
+ * same objects as changed forever.
533
+ *
534
+ * Unlike the GUID this NEVER inserts a missing line: only the container kinds
535
+ * (api_group, realtime_server, mcp_server/agent/toolset) have the field, and the
536
+ * server omits the key for everything else, so an absent line means "this kind
537
+ * has no canonical" rather than "not yet assigned".
538
+ */
539
+ function syncCanonicalToFile(filePath, canonical) {
540
+ const content = fs.readFileSync(filePath, 'utf8');
541
+ const existingMatch = content.match(CANONICAL_REGEX);
542
+ if (!existingMatch || existingMatch[2] === canonical) {
543
+ return false;
544
+ }
545
+ fs.writeFileSync(filePath, content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`), 'utf8');
546
+ return true;
547
+ }
318
548
  // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
319
549
  /**
320
550
  * Compute a knowledge change preview: prefer the server's `dry_run` response,
@@ -327,7 +557,6 @@ async function computeKnowledgePreview(listUrl, objects, branch, shouldDelete, a
327
557
  const serverResult = await pushKnowledge(listUrl, accessToken, verboseFetch, verbose, {
328
558
  branch,
329
559
  delete: shouldDelete,
330
- // eslint-disable-next-line camelcase -- external Metadata API field name
331
560
  dry_run: true,
332
561
  items,
333
562
  });
@@ -363,7 +592,9 @@ function mergeKnowledgePreview(preview, knowledge) {
363
592
  */
364
593
  export async function executePush(ctx, target, flags) {
365
594
  const { accessToken, command, inputDir, verboseFetch } = ctx;
366
- const log = command.log.bind(command);
595
+ const log = ctx.log ?? command.log.bind(command);
596
+ let dryRunPreview = null;
597
+ const stop = (stopped) => ({ knowledge: { deleted: 0, imported: 0 }, preview: dryRunPreview, sent: [], stopped });
367
598
  // ── Collect knowledge entries (before file check so knowledge-only push works) ─
368
599
  let knowledgeObjects = [];
369
600
  if (ctx.knowledge) {
@@ -372,6 +603,8 @@ export async function executePush(ctx, target, flags) {
372
603
  // ── Collect and filter .xs files ──────────────────────────────────────
373
604
  const allFiles = collectFiles(inputDir);
374
605
  const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, log);
606
+ const filteredOut = describeFilteredOut(allFiles, files, inputDir);
607
+ const filteredOutCount = allFiles.length - files.length;
375
608
  const knowledgeOnly = files.length === 0 && (knowledgeObjects.length > 0 || ctx.knowledge !== undefined);
376
609
  if (files.length === 0 && !knowledgeOnly) {
377
610
  command.error(flags.include || flags.exclude
@@ -387,6 +620,91 @@ export async function executePush(ctx, target, flags) {
387
620
  if (documentEntries.length === 0) {
388
621
  command.error(`All .xs files in ${inputDir} are empty`);
389
622
  }
623
+ // ── Handle multi-document files ───────────────────────────────────────
624
+ // The push pipeline assumes one document per file: the partial-diff filter
625
+ // and GUID-writeback both parse only the first document in a file, so a
626
+ // bundle like a single hand-authored multidoc.xs silently pushes nothing
627
+ // (partial) or clobbers a single guid line across every document (full).
628
+ // Rather than fail, offer to flatten each bundle in place into the
629
+ // per-document layout `pull` produces, then re-read and continue the push.
630
+ const multiDocFiles = findMultiDocEntries(documentEntries);
631
+ if (multiDocFiles.length > 0) {
632
+ const list = multiDocFiles
633
+ .map((f) => ` ${relative(inputDir, f.filePath) || f.filePath} (${f.count} documents)`)
634
+ .join('\n');
635
+ log('');
636
+ log(ux.colorize('yellow', ux.colorize('bold', '=== Multi-document file(s) detected ===')));
637
+ log('');
638
+ log(ux.colorize('yellow', 'These .xs files hold more than one document:'));
639
+ log(list);
640
+ log('');
641
+ log(ux.colorize('dim', 'Push needs one document per file (partial diff + GUID writeback operate per file),\n' +
642
+ 'so a multi-doc bundle silently pushes nothing or corrupts GUIDs. I can split each\n' +
643
+ 'into the standard per-document layout (the same tree `pull` produces) and continue.'));
644
+ log('');
645
+ // Flattening DELETES the source bundle, so never do it unattended without an
646
+ // explicit opt-in. --force means "just do it"; an interactive TTY gets a
647
+ // prompt; a bare non-interactive run refuses (rather than silently rewriting
648
+ // and deleting the user's files in CI).
649
+ if (flags.force) {
650
+ log(ux.colorize('dim', 'Flattening (--force)…'));
651
+ }
652
+ else if (process.stdin.isTTY) {
653
+ const doFlatten = await confirm('Flatten these file(s) in place and continue the push?');
654
+ if (!doFlatten) {
655
+ log('Push cancelled. Run `xano flatten <file>` yourself, or re-run to be prompted again.');
656
+ return stop('cancelled');
657
+ }
658
+ }
659
+ else {
660
+ command.error('Multi-document .xs file(s) cannot be pushed. Run `xano flatten <file>` to split them ' +
661
+ 'into the standard per-document layout first, or re-run with --force to flatten automatically.');
662
+ }
663
+ for (const offender of multiDocFiles) {
664
+ try {
665
+ const result = flattenBundleFile(offender.filePath, { log: (m) => flags.verbose && log(ux.colorize('dim', m)) });
666
+ log(ux.colorize('dim', ` flattened ${relative(inputDir, offender.filePath) || offender.filePath} → ${result.written.length} files`));
667
+ }
668
+ catch (error) {
669
+ command.error(`Failed to flatten ${offender.filePath}: ${error.message}`);
670
+ }
671
+ }
672
+ log('');
673
+ // Re-collect and re-read: the bundle files are gone, replaced by the split
674
+ // per-document tree, so the rest of the push sees a clean one-doc-per-file set.
675
+ const refreshed = readDocuments(applyFilters(collectFiles(inputDir), inputDir, flags.include, flags.exclude, () => { }));
676
+ documentEntries = refreshed;
677
+ if (documentEntries.length === 0) {
678
+ command.error('After flattening, no .xs documents remain to push.');
679
+ }
680
+ }
681
+ // ── Refuse a tree carrying more than one workspace document ───────────
682
+ // The server applies the FIRST workspace document it sees to the workspace
683
+ // being pushed into and silently drops the rest, with no check that the
684
+ // document describes that workspace. A second one therefore renames the
685
+ // destination to a foreign name while leaving all of its content intact,
686
+ // and which name wins flips with whatever happens to be in the pushed set.
687
+ //
688
+ // Checked against the WHOLE tree rather than the partial changed-set, because
689
+ // the flip-flop case is exactly the one where only the foreign document is
690
+ // "changed" and would be the sole workspace document actually sent.
691
+ //
692
+ // No --force escape and no auto-fix: unlike the multi-doc bundle above there
693
+ // is no safe repair, since only the user knows which workspace this tree is
694
+ // meant to be. Picking one is the bug.
695
+ const workspaceDocs = findWorkspaceEntries(documentEntries);
696
+ if (workspaceDocs.length > 1) {
697
+ const list = workspaceDocs
698
+ .map((w) => ` ${relative(inputDir, w.filePath) || w.filePath} → workspace "${w.name}"`)
699
+ .join('\n');
700
+ command.error(`${inputDir} holds ${workspaceDocs.length} workspace documents:\n${list}\n\n` +
701
+ 'A push targets one workspace, and the server applies only the first of these and ' +
702
+ 'silently discards the rest — renaming the destination to a foreign name while leaving ' +
703
+ 'its content untouched.\n\n' +
704
+ 'Delete the ones that do not describe the workspace you are pushing to, keeping a single ' +
705
+ 'workspace/*.xs. (`pull` names this file after the workspace, so pulling a different ' +
706
+ 'workspace into this tree, or renaming one, leaves the old file behind.)');
707
+ }
390
708
  multidoc = documentEntries.map((d) => d.content).join('\n---\n');
391
709
  // ── Build document key → file path map (for GUID writeback) ─────────
392
710
  for (const entry of documentEntries) {
@@ -420,6 +738,17 @@ export async function executePush(ctx, target, flags) {
420
738
  if (target.sourceWorkspaceId) {
421
739
  queryParams.set('source_workspace_id', target.sourceWorkspaceId);
422
740
  }
741
+ // Tell the server which objects the filter put out of scope, so its delete
742
+ // sweep spares them. Only meaningful with --delete; without it nothing is
743
+ // swept. The parameter travels in the query string (the body is the multidoc
744
+ // itself), so it has a length ceiling: past PROTECT_GUIDS_MAX_CHARS we send
745
+ // nothing and let the verification below refuse the push rather than issue a
746
+ // request the server may truncate or reject.
747
+ const protectableGuids = filteredOut.map((d) => d.guid).filter((g) => typeof g === 'string' && g.length > 0);
748
+ const protectGuidsParam = protectableGuids.join(',');
749
+ if (shouldDelete && protectGuidsParam && protectGuidsParam.length <= PROTECT_GUIDS_MAX_CHARS) {
750
+ queryParams.set('protect_guids', protectGuidsParam);
751
+ }
423
752
  // ── Request headers ───────────────────────────────────────────────────
424
753
  const requestHeaders = {
425
754
  accept: 'application/json',
@@ -427,7 +756,6 @@ export async function executePush(ctx, target, flags) {
427
756
  'Content-Type': 'text/x-xanoscript',
428
757
  };
429
758
  // ── Dry-run / Preview ─────────────────────────────────────────────────
430
- let dryRunPreview = null;
431
759
  const dryRunUrl = knowledgeOnly ? null : target.buildDryRunUrl(queryParams);
432
760
  if (dryRunUrl && (flags['dry-run'] || !flags.force)) {
433
761
  const dryRunParams = new URLSearchParams(queryParams);
@@ -451,7 +779,46 @@ export async function executePush(ctx, target, flags) {
451
779
  const knowledgeDryRun = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
452
780
  mergeKnowledgePreview(preview, knowledgeDryRun);
453
781
  }
454
- renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log);
782
+ renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log, filteredOutCount);
783
+ // GUARD: --include/--exclude must never cost you the objects it
784
+ // filtered out.
785
+ //
786
+ // A filtered-out document is absent from the payload, so a --delete
787
+ // sweep reads it as removed from the tree and deletes it - the exact
788
+ // opposite of what -e means to the person typing it. The server is
789
+ // told to spare them (protect_guids above), but this checks the
790
+ // SERVER'S OWN preview rather than assuming that worked: an older
791
+ // instance ignores the parameter, a document that has never been
792
+ // pushed has no GUID to protect, and a very large filter exceeds what
793
+ // the query string can carry. In every one of those cases the push is
794
+ // refused rather than allowed to delete.
795
+ if (shouldDelete) {
796
+ const endangered = deletesHittingFilteredOut(preview.operations, filteredOut);
797
+ if (endangered.length > 0) {
798
+ log('');
799
+ log(ux.colorize('red', '--- Refusing to push ---'));
800
+ log('');
801
+ log(` --delete would remove ${endangered.length} object(s) that --include/--exclude filtered out:`);
802
+ log('');
803
+ for (const hit of endangered.slice(0, 10)) {
804
+ log(` ${ux.colorize('red', 'DELETE'.padEnd(8))} ${hit.type.padEnd(18)} ${hit.name} ${ux.colorize('dim', `(${hit.relPath})`)}`);
805
+ }
806
+ if (endangered.length > 10) {
807
+ log(ux.colorize('dim', ` ... and ${endangered.length - 10} more`));
808
+ }
809
+ log('');
810
+ const unprotectable = filteredOut.filter((d) => !d.guid).length;
811
+ if (unprotectable > 0) {
812
+ log(ux.colorize('dim', ` ${unprotectable} filtered-out document(s) have no guid yet, so the server cannot be told to spare them.`));
813
+ }
814
+ if (protectGuidsParam.length > PROTECT_GUIDS_MAX_CHARS) {
815
+ log(ux.colorize('dim', ' Too many filtered-out documents to declare in one request.'));
816
+ }
817
+ log(ux.colorize('dim', ' This instance may also predate protected pushes.'));
818
+ log('');
819
+ command.error('Re-run without --delete, or without the --include/--exclude filter.');
820
+ }
821
+ }
455
822
  // Check for bad cross-references using dry-run operations to avoid false positives
456
823
  const badRefs = checkReferences(documentEntries, preview.operations);
457
824
  if (badRefs.length > 0) {
@@ -480,7 +847,7 @@ export async function executePush(ctx, target, flags) {
480
847
  log('');
481
848
  log(ux.colorize('red', `Push blocked: ${criticalOps.length} critical error(s) found.`));
482
849
  if (!flags.force) {
483
- return;
850
+ return stop('blocked');
484
851
  }
485
852
  log(ux.colorize('yellow', 'Proceeding anyway due to --force flag.'));
486
853
  }
@@ -510,10 +877,10 @@ export async function executePush(ctx, target, flags) {
510
877
  if (!hasChanges && !hasLocalRecords) {
511
878
  log('');
512
879
  log('No changes to push.');
513
- return;
880
+ return stop('no-changes');
514
881
  }
515
882
  if (flags['dry-run']) {
516
- return;
883
+ return stop('dry-run');
517
884
  }
518
885
  // Warn when the sandbox currently holds a different source workspace than the one being
519
886
  // pushed. The backend compares the source workspace id we sent against the id stored on
@@ -531,7 +898,7 @@ export async function executePush(ctx, target, flags) {
531
898
  const proceed = await confirm('Continue with push anyway?');
532
899
  if (!proceed) {
533
900
  log('Push cancelled. Run `xano sandbox reset` then retry.');
534
- return;
901
+ return stop('cancelled');
535
902
  }
536
903
  mismatchConfirmed = true;
537
904
  }
@@ -552,7 +919,7 @@ export async function executePush(ctx, target, flags) {
552
919
  const confirmed = await confirm(message);
553
920
  if (!confirmed) {
554
921
  log('Push cancelled.');
555
- return;
922
+ return stop('cancelled');
556
923
  }
557
924
  }
558
925
  else {
@@ -569,15 +936,17 @@ export async function executePush(ctx, target, flags) {
569
936
  }
570
937
  }
571
938
  else {
572
- await handleDryRunError(dryRunResponse, command, flags, target);
939
+ await handleDryRunError(dryRunResponse, command, target, { log, verbose: flags.verbose });
573
940
  // If we get here, the user confirmed to proceed without preview
574
941
  }
575
942
  }
576
943
  catch (error) {
577
944
  // Ctrl+C or SIGINT
945
+ if (error instanceof PushPolicyGateError)
946
+ throw error;
578
947
  if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') {
579
948
  log('\nPush cancelled.');
580
- return;
949
+ return stop('cancelled');
581
950
  }
582
951
  // Re-throw oclif errors
583
952
  if (error instanceof Error && 'oclif' in error) {
@@ -608,16 +977,16 @@ export async function executePush(ctx, target, flags) {
608
977
  if (!hasChanges) {
609
978
  log('');
610
979
  log('No changes to push.');
611
- return;
980
+ return stop('no-changes');
612
981
  }
613
982
  if (flags['dry-run']) {
614
- return;
983
+ return stop('dry-run');
615
984
  }
616
985
  if (process.stdin.isTTY) {
617
986
  const confirmed = await confirm('Proceed with push?');
618
987
  if (!confirmed) {
619
988
  log('Push cancelled.');
620
- return;
989
+ return stop('cancelled');
621
990
  }
622
991
  }
623
992
  else {
@@ -633,22 +1002,19 @@ export async function executePush(ctx, target, flags) {
633
1002
  }
634
1003
  }
635
1004
  // ── Partial push: filter to changed documents only ────────────────────
1005
+ let sent = documentEntries;
636
1006
  if (!knowledgeOnly && isPartial && dryRunPreview) {
637
- const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
638
- if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
1007
+ sent = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
1008
+ if (sent.length === 0 && knowledgeObjects.length === 0) {
639
1009
  log('No changes to push.');
640
- return;
641
- }
642
- if (filteredEntries.length > 0) {
643
- multidoc = filteredEntries.map((d) => d.content).join('\n---\n');
644
- }
645
- else {
646
- multidoc = '';
1010
+ return stop('no-changes');
647
1011
  }
1012
+ multidoc = sent.map((d) => d.content).join('\n---\n');
648
1013
  }
649
1014
  // ── Execute the actual push ───────────────────────────────────────────
650
1015
  const startTime = Date.now();
651
1016
  let pushedDocCount = 0;
1017
+ let pushResponse;
652
1018
  if (!knowledgeOnly && multidoc) {
653
1019
  const apiUrl = target.buildPushUrl(queryParams);
654
1020
  try {
@@ -658,14 +1024,19 @@ export async function executePush(ctx, target, flags) {
658
1024
  method: 'POST',
659
1025
  }, flags.verbose, accessToken);
660
1026
  if (!response.ok) {
661
- handlePushError(response, await response.text(), documentEntries, inputDir, command);
1027
+ const errorText = await response.text();
1028
+ refuseIfExplained(command, target, response.status, errorText);
1029
+ handlePushError(response, errorText, documentEntries, inputDir, command);
662
1030
  }
663
1031
  // Parse response for GUID map
664
1032
  const responseText = await response.text();
665
1033
  let guidMap = [];
1034
+ pushResponse = {};
666
1035
  if (responseText && responseText !== 'null') {
667
1036
  try {
668
1037
  const responseJson = JSON.parse(responseText);
1038
+ if (responseJson && typeof responseJson === 'object')
1039
+ pushResponse = responseJson;
669
1040
  if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
670
1041
  guidMap = responseJson.guid_map;
671
1042
  }
@@ -689,8 +1060,9 @@ export async function executePush(ctx, target, flags) {
689
1060
  }
690
1061
  }
691
1062
  let updatedCount = 0;
1063
+ let canonicalCount = 0;
692
1064
  for (const entry of guidMap) {
693
- if (!entry.guid)
1065
+ if (!entry.guid || entry.type === 'policy')
694
1066
  continue;
695
1067
  const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
696
1068
  let filePath = documentFileMap.get(key);
@@ -715,14 +1087,29 @@ export async function executePush(ctx, target, flags) {
715
1087
  catch (error) {
716
1088
  command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
717
1089
  }
1090
+ if (entry.canonical) {
1091
+ try {
1092
+ const updated = syncCanonicalToFile(filePath, entry.canonical);
1093
+ if (updated)
1094
+ canonicalCount++;
1095
+ }
1096
+ catch (error) {
1097
+ command.warn(`Failed to sync canonical to ${filePath}: ${error.message}`);
1098
+ }
1099
+ }
718
1100
  }
719
1101
  if (updatedCount > 0) {
720
1102
  log(`Synced ${updatedCount} GUIDs to local files`);
721
1103
  }
1104
+ if (canonicalCount > 0) {
1105
+ log(`Synced ${canonicalCount} canonicals to local files (the instance assigned its own)`);
1106
+ }
722
1107
  }
723
1108
  pushedDocCount = multidoc.split('\n---\n').length;
724
1109
  }
725
1110
  catch (error) {
1111
+ if (error instanceof PushPolicyGateError)
1112
+ throw error;
726
1113
  if (error instanceof Error && 'oclif' in error)
727
1114
  throw error;
728
1115
  const elapsedMs = Date.now() - startTime;
@@ -732,6 +1119,12 @@ export async function executePush(ctx, target, flags) {
732
1119
  // ── Push knowledge ────────────────────────────────────────────────────
733
1120
  let knowledgeImported = 0;
734
1121
  let knowledgeDeleted = 0;
1122
+ const pushed = () => ({
1123
+ knowledge: { deleted: knowledgeDeleted, imported: knowledgeImported },
1124
+ preview: dryRunPreview,
1125
+ response: pushResponse,
1126
+ sent: pushResponse ? sent : [],
1127
+ });
735
1128
  if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
736
1129
  const listUrl = ctx.knowledge.listUrl();
737
1130
  try {
@@ -769,7 +1162,10 @@ export async function executePush(ctx, target, flags) {
769
1162
  if (error instanceof Error && 'oclif' in error)
770
1163
  throw error;
771
1164
  const elapsedMs = Date.now() - startTime;
772
- command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
1165
+ const message = `Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`;
1166
+ if (pushResponse)
1167
+ throw new FailedAfterImportError(message, pushed());
1168
+ command.error(message);
773
1169
  }
774
1170
  }
775
1171
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -783,6 +1179,7 @@ export async function executePush(ctx, target, flags) {
783
1179
  parts.push(kParts.join(', '));
784
1180
  }
785
1181
  log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
1182
+ return pushed();
786
1183
  }
787
1184
  // ── Error Handlers ──────────────────────────────────────────────────────────
788
1185
  /**
@@ -820,6 +1217,7 @@ export function describeNetworkError(error, url, elapsedMs) {
820
1217
  ETIMEDOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
821
1218
  UND_ERR_CONNECT_TIMEOUT: `Connection to ${host} timed out. Check your network or VPN, then retry.`,
822
1219
  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.`,
1220
+ 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
1221
  };
824
1222
  let base;
825
1223
  if (code && hints[code]) {
@@ -860,10 +1258,43 @@ function formatFailureDuration(elapsedMs) {
860
1258
  ? ` (failed after ~${human}, near a common ${boundaries.find((b) => Math.abs(totalSeconds - b) <= b * 0.05)}s timeout — likely a server or proxy cutoff)`
861
1259
  : ` (failed after ${human})`;
862
1260
  }
863
- async function handleDryRunError(response, command, flags, target) {
864
- const log = command.log.bind(command);
1261
+ /** Stop with the target's own guidance when it recognises this refusal. */
1262
+ function refuseIfExplained(command, target, status, body) {
1263
+ let message = body;
1264
+ let payload;
1265
+ try {
1266
+ const parsed = JSON.parse(body);
1267
+ if (typeof parsed?.message === 'string')
1268
+ message = parsed.message;
1269
+ payload = parsed?.payload;
1270
+ }
1271
+ catch {
1272
+ // Not JSON
1273
+ }
1274
+ const refusal = payload;
1275
+ if (status === 403 && refusal?.code === 'policy_gate')
1276
+ throw new PushPolicyGateError(refusal);
1277
+ // Policy files that weaken a mandatory policy of the live branch, from a credential without
1278
+ // workspace:policy update: a permission refusal (exit 1), not a blocking finding. The platform's
1279
+ // sentence already reads "Push refused: … Nothing was imported.", so it is printed as it is.
1280
+ if (status === 403 && refusal?.code === WEAKENING_REFUSAL) {
1281
+ command.error(`${message}${target.explainRefusal?.(status, payload) ?? policyWeakeningGuidance(payload, [], true)}`);
1282
+ }
1283
+ // The plan's policy cap: the platform's message names the plan, its cap and the remedy, and the
1284
+ // push itself would be refused the same way, so the preview stops here rather than skipping.
1285
+ if (status === 403 && refusal?.code === 'policy_plan_limit')
1286
+ command.error(`Push refused (${status}): ${message}`);
1287
+ if (refusal?.code === 'policy_gate_transaction_required') {
1288
+ command.error('Push refused: the live branch policy gate requires a transaction. Remove --no-transaction and retry. Nothing was imported.');
1289
+ }
1290
+ const guidance = target.explainRefusal?.(status, payload);
1291
+ if (guidance)
1292
+ command.error(`Push refused (${status}): ${message}${guidance}`);
1293
+ }
1294
+ async function handleDryRunError(response, command, target, { log, verbose }) {
1295
+ const errorText = await response.text();
1296
+ refuseIfExplained(command, target, response.status, errorText);
865
1297
  if (response.status === 404) {
866
- const errorText = await response.text();
867
1298
  try {
868
1299
  const errorJson = JSON.parse(errorText);
869
1300
  if (errorJson.message) {
@@ -881,7 +1312,6 @@ async function handleDryRunError(response, command, flags, target) {
881
1312
  log('');
882
1313
  }
883
1314
  else {
884
- const errorText = await response.text();
885
1315
  // Check if push is disabled
886
1316
  try {
887
1317
  const errorJson = JSON.parse(errorText);
@@ -904,8 +1334,17 @@ async function handleDryRunError(response, command, flags, target) {
904
1334
  catch {
905
1335
  // Not JSON, fall through
906
1336
  }
907
- command.warn(`Push preview failed (${response.status}). Skipping preview.`);
908
- if (flags.verbose) {
1337
+ let reason = '';
1338
+ try {
1339
+ const errorJson = JSON.parse(errorText);
1340
+ if (typeof errorJson?.message === 'string' && errorJson.message.trim())
1341
+ reason = `: ${errorJson.message.trim().replace(/\.$/, '')}`;
1342
+ }
1343
+ catch {
1344
+ // Not JSON
1345
+ }
1346
+ command.warn(`Push preview failed (${response.status})${reason}. Skipping preview.`);
1347
+ if (verbose) {
909
1348
  log(ux.colorize('dim', errorText));
910
1349
  }
911
1350
  }