@xano/cli 1.3.3 → 1.3.5-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 (118) hide show
  1. package/README.md +137 -3
  2. package/dist/base-command.d.ts +12 -1
  3. package/dist/base-command.js +36 -22
  4. package/dist/commands/branch/set_live/index.d.ts +14 -0
  5. package/dist/commands/branch/set_live/index.js +38 -0
  6. package/dist/commands/ephemeral/push/index.js +6 -1
  7. package/dist/commands/function/create/index.d.ts +15 -0
  8. package/dist/commands/function/create/index.js +39 -0
  9. package/dist/commands/function/edit/index.d.ts +15 -0
  10. package/dist/commands/function/edit/index.js +39 -0
  11. package/dist/commands/function/run/index.js +5 -11
  12. package/dist/commands/policy/catalogue/index.d.ts +16 -0
  13. package/dist/commands/policy/catalogue/index.js +60 -0
  14. package/dist/commands/policy/coverage/index.d.ts +17 -0
  15. package/dist/commands/policy/coverage/index.js +33 -0
  16. package/dist/commands/policy/create/index.d.ts +18 -0
  17. package/dist/commands/policy/create/index.js +44 -0
  18. package/dist/commands/policy/delete/index.d.ts +19 -0
  19. package/dist/commands/policy/delete/index.js +61 -0
  20. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  21. package/dist/commands/policy/evaluate/index.js +71 -0
  22. package/dist/commands/policy/list/index.d.ts +14 -0
  23. package/dist/commands/policy/list/index.js +21 -0
  24. package/dist/commands/policy/parse/index.d.ts +19 -0
  25. package/dist/commands/policy/parse/index.js +18 -0
  26. package/dist/commands/policy/publish/index.d.ts +20 -0
  27. package/dist/commands/policy/publish/index.js +38 -0
  28. package/dist/commands/policy/runs/index.d.ts +54 -0
  29. package/dist/commands/policy/runs/index.js +273 -0
  30. package/dist/commands/policy/status/index.d.ts +21 -0
  31. package/dist/commands/policy/status/index.js +54 -0
  32. package/dist/commands/release/create/index.js +4 -0
  33. package/dist/commands/release/deploy/index.d.ts +19 -0
  34. package/dist/commands/release/deploy/index.js +68 -6
  35. package/dist/commands/release/import/index.js +5 -1
  36. package/dist/commands/release/list/index.d.ts +7 -0
  37. package/dist/commands/release/list/index.js +46 -9
  38. package/dist/commands/release/push/index.js +19 -1
  39. package/dist/commands/sandbox/delete/index.js +8 -0
  40. package/dist/commands/sandbox/pull/index.d.ts +7 -0
  41. package/dist/commands/sandbox/pull/index.js +49 -0
  42. package/dist/commands/sandbox/push/index.d.ts +4 -0
  43. package/dist/commands/sandbox/push/index.js +59 -2
  44. package/dist/commands/sandbox/reset/index.js +8 -0
  45. package/dist/commands/skills/pull/index.d.ts +30 -0
  46. package/dist/commands/skills/pull/index.js +118 -0
  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 +40 -0
  50. package/dist/commands/tenant/deploy_release/index.js +182 -4
  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_deploy_request/bypass/index.d.ts +31 -0
  54. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  55. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  56. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  57. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  58. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  59. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  60. package/dist/commands/tenant_deploy_request/get/index.js +113 -0
  61. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  62. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  63. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  64. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  65. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  66. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  67. package/dist/commands/workspace/pull/index.d.ts +17 -0
  68. package/dist/commands/workspace/pull/index.js +124 -7
  69. package/dist/commands/workspace/push/index.d.ts +23 -0
  70. package/dist/commands/workspace/push/index.js +188 -6
  71. package/dist/policy-command.d.ts +91 -0
  72. package/dist/policy-command.js +95 -0
  73. package/dist/utils/document-parser.d.ts +10 -0
  74. package/dist/utils/document-parser.js +21 -0
  75. package/dist/utils/export-compare.d.ts +52 -0
  76. package/dist/utils/export-compare.js +134 -0
  77. package/dist/utils/local-config.d.ts +23 -0
  78. package/dist/utils/local-config.js +62 -2
  79. package/dist/utils/multidoc-push.d.ts +124 -4
  80. package/dist/utils/multidoc-push.js +670 -49
  81. package/dist/utils/object-fetch.d.ts +86 -0
  82. package/dist/utils/object-fetch.js +265 -0
  83. package/dist/utils/policy/catalogue.d.ts +85 -0
  84. package/dist/utils/policy/catalogue.js +196 -0
  85. package/dist/utils/policy/coverage.d.ts +19 -0
  86. package/dist/utils/policy/coverage.js +93 -0
  87. package/dist/utils/policy/errors.d.ts +20 -0
  88. package/dist/utils/policy/errors.js +93 -0
  89. package/dist/utils/policy/feedback.d.ts +90 -0
  90. package/dist/utils/policy/feedback.js +210 -0
  91. package/dist/utils/policy/findings.d.ts +29 -0
  92. package/dist/utils/policy/findings.js +82 -0
  93. package/dist/utils/policy/gate.d.ts +138 -0
  94. package/dist/utils/policy/gate.js +193 -0
  95. package/dist/utils/policy/permission.d.ts +7 -0
  96. package/dist/utils/policy/permission.js +55 -0
  97. package/dist/utils/policy/push-gate.d.ts +5 -0
  98. package/dist/utils/policy/push-gate.js +43 -0
  99. package/dist/utils/policy/release.d.ts +85 -0
  100. package/dist/utils/policy/release.js +117 -0
  101. package/dist/utils/policy/request.d.ts +55 -0
  102. package/dist/utils/policy/request.js +119 -0
  103. package/dist/utils/policy/runs.d.ts +30 -0
  104. package/dist/utils/policy/runs.js +101 -0
  105. package/dist/utils/policy/status.d.ts +55 -0
  106. package/dist/utils/policy/status.js +127 -0
  107. package/dist/utils/policy/template.d.ts +66 -0
  108. package/dist/utils/policy/template.js +152 -0
  109. package/dist/utils/policy/types.d.ts +295 -0
  110. package/dist/utils/policy/types.js +1 -0
  111. package/dist/utils/reference-checker.d.ts +3 -0
  112. package/dist/utils/reference-checker.js +3 -2
  113. package/dist/utils/sandbox-marker.d.ts +9 -0
  114. package/dist/utils/sandbox-marker.js +119 -0
  115. package/dist/utils/sync-state.d.ts +71 -0
  116. package/dist/utils/sync-state.js +353 -0
  117. package/oclif.manifest.json +6472 -3181
  118. package/package.json +9 -1
@@ -1,11 +1,27 @@
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 { compareLocalToExport, indexLocalDocuments, parseExportDocuments, resolveMatchedPath, selectWritebacks, } from './export-compare.js';
6
7
  import { flattenBundleFile } from './flatten.js';
7
8
  import { collectKnowledgeObjects, fetchKnowledge, knowledgePreview, pushKnowledge, syncGuidToFrontmatter, toPushItems, } from './knowledge-sync.js';
9
+ import { fetchFormattedObjects, } from './object-fetch.js';
10
+ import { PushPolicyGateError } from './policy/push-gate.js';
8
11
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
12
+ import { invalidateSandboxMarkerForOrigin, readMarkerToken, sandboxMarkerExists, writeMarkerToken, } from './sandbox-marker.js';
13
+ import { hashBytes, hashFile, instanceHost, loadSyncState, recordEntriesForContents, resolveTarget, sandboxTargetIdentity, saveSyncState, syncStateExists, toRelPath, } from './sync-state.js';
14
+ /**
15
+ * A push that failed after its multidoc import landed. The import is not rolled back, so the error
16
+ * carries what it did for the calling command to report beside the failure.
17
+ */
18
+ export class FailedAfterImportError extends Errors.CLIError {
19
+ imported;
20
+ constructor(message, imported) {
21
+ super(message);
22
+ this.imported = imported;
23
+ }
24
+ }
9
25
  /**
10
26
  * Filter document entries down to the ones the dry-run preview reports as changed,
11
27
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -44,6 +60,10 @@ export function filterChangedEntries(entries, operations, includeRecords) {
44
60
  return false;
45
61
  });
46
62
  }
63
+ /** True for a table document carrying an `items = [...]` block (records). */
64
+ function isTableWithRecords(content) {
65
+ return /^table\s+/m.test(content) && /\bitems\s*=\s*\[/m.test(content);
66
+ }
47
67
  // ── File Collection ─────────────────────────────────────────────────────────
48
68
  /**
49
69
  * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
@@ -211,15 +231,117 @@ export function deletesHittingFilteredOut(operations, filteredOut) {
211
231
  }
212
232
  return hits;
213
233
  }
234
+ export function pathMatchesFilters(relPath, inputDir, include, exclude) {
235
+ if (include && include.length > 0) {
236
+ const patterns = include.map((pattern) => normalizeFilterPattern(pattern, inputDir));
237
+ if (!patterns.some((pattern) => minimatch(relPath, pattern, { matchBase: true }))) {
238
+ return false;
239
+ }
240
+ }
241
+ if (exclude && exclude.length > 0) {
242
+ const patterns = exclude.map((pattern) => normalizeFilterPattern(pattern, inputDir));
243
+ if (patterns.some((pattern) => minimatch(relPath, pattern, { matchBase: true }))) {
244
+ return false;
245
+ }
246
+ }
247
+ return true;
248
+ }
249
+ function commitSyncRecordWithSandbox(ctx, inputDir, target, entries, options, warn) {
250
+ if (!target) {
251
+ warn('Skipped writing sync record; could not resolve sandbox/workspace identity');
252
+ return;
253
+ }
254
+ let sandboxToken;
255
+ if (ctx.syncTarget?.kind === 'sandbox') {
256
+ try {
257
+ sandboxToken = writeMarkerToken(ctx.syncTarget.markerDir, target.instance_host, target.branch_created_at);
258
+ }
259
+ catch (error) {
260
+ warn(`Skipped writing sandbox sync marker: ${error.message}`);
261
+ }
262
+ }
263
+ trySaveSyncState(inputDir, target, entries, { ...options, sandboxToken }, warn);
264
+ }
265
+ function trySaveSyncState(inputDir, target, entries, options, warn) {
266
+ try {
267
+ saveSyncState(inputDir, target, entries, options);
268
+ }
269
+ catch (error) {
270
+ warn(`Skipped writing .xano/sync_state.json: ${error.message}`);
271
+ }
272
+ }
273
+ async function resolveSyncTarget(ctx, flags) {
274
+ if (!ctx.syncTarget) {
275
+ return { isLiveBranch: !ctx.branch, liveBranch: ctx.branch, target: null };
276
+ }
277
+ const origin = ctx.syncTarget.instanceOrigin.replace(/\/$/, '');
278
+ const fetchJson = async (pathname) => {
279
+ const response = await ctx.verboseFetch(`${origin}/api:meta${pathname}`, {
280
+ headers: {
281
+ accept: 'application/json',
282
+ Authorization: `Bearer ${ctx.accessToken}`,
283
+ },
284
+ method: 'GET',
285
+ }, flags.verbose, ctx.accessToken);
286
+ if (!response.ok) {
287
+ throw new Error(`HTTP ${response.status}`);
288
+ }
289
+ return response.json();
290
+ };
291
+ if (ctx.syncTarget.kind === 'sandbox') {
292
+ try {
293
+ const me = await fetchJson('/sandbox/me');
294
+ if (!me ||
295
+ typeof me !== 'object' ||
296
+ Array.isArray(me) ||
297
+ typeof me.created_at !== 'string' ||
298
+ typeof me.id !== 'number') {
299
+ return { isLiveBranch: true, liveBranch: '', target: null };
300
+ }
301
+ return {
302
+ isLiveBranch: true,
303
+ liveBranch: '',
304
+ target: sandboxTargetIdentity(instanceHost(ctx.syncTarget.instanceOrigin), {
305
+ created_at: me.created_at,
306
+ id: me.id,
307
+ }),
308
+ };
309
+ }
310
+ catch {
311
+ return { isLiveBranch: true, liveBranch: '', target: null };
312
+ }
313
+ }
314
+ const target = await resolveTarget(ctx.syncTarget.instanceOrigin, ctx.syncTarget.workspace, ctx.branch, fetchJson);
315
+ return {
316
+ isLiveBranch: Boolean(target && target.branch === target.live_branch),
317
+ liveBranch: target?.live_branch ?? ctx.branch,
318
+ target,
319
+ };
320
+ }
321
+ function maybeInvalidateSandboxMarker(ctx, persistRecord, warn) {
322
+ if (ctx.syncTarget?.kind !== 'sandbox')
323
+ return;
324
+ if (!persistRecord && !sandboxMarkerExists(ctx.syncTarget.markerDir))
325
+ return;
326
+ try {
327
+ invalidateSandboxMarkerForOrigin(ctx.syncTarget.markerDir, ctx.syncTarget.instanceOrigin);
328
+ }
329
+ catch (error) {
330
+ warn(`Skipped invalidating sandbox sync marker: ${error.message}`);
331
+ }
332
+ }
214
333
  /**
215
- * Read .xs files into document entries, skipping empty files.
334
+ * Read .xs files into document entries, skipping empty files. When `raw` is given, the
335
+ * untrimmed bytes of each returned file are stored in it, keyed by file path.
216
336
  */
217
- export function readDocuments(files) {
337
+ export function readDocuments(files, raw) {
218
338
  const entries = [];
219
339
  for (const filePath of files) {
220
- const content = fs.readFileSync(filePath, 'utf8').trim();
340
+ const bytes = fs.readFileSync(filePath, 'utf8');
341
+ const content = bytes.trim();
221
342
  if (content) {
222
343
  entries.push({ content, filePath });
344
+ raw?.set(filePath, bytes);
223
345
  }
224
346
  }
225
347
  return entries;
@@ -470,15 +592,22 @@ const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
470
592
  * Sync a GUID into a local .xs file. Returns true if the file was modified.
471
593
  */
472
594
  function syncGuidToFile(filePath, guid) {
473
- const content = fs.readFileSync(filePath, 'utf8');
595
+ const updated = withGuid(fs.readFileSync(filePath, 'utf8'), guid);
596
+ if (updated === null)
597
+ return false;
598
+ fs.writeFileSync(filePath, updated, 'utf8');
599
+ return true;
600
+ }
601
+ /**
602
+ * Return `content` with its GUID set to `guid`, or null when nothing changes.
603
+ */
604
+ function withGuid(content, guid) {
474
605
  const existingMatch = content.match(GUID_REGEX);
475
606
  if (existingMatch) {
476
607
  if (existingMatch[2] === guid) {
477
- return false;
608
+ return null;
478
609
  }
479
- const updated = content.replace(GUID_REGEX, `guid = "${guid}"`);
480
- fs.writeFileSync(filePath, updated, 'utf8');
481
- return true;
610
+ return content.replace(GUID_REGEX, `guid = "${guid}"`);
482
611
  }
483
612
  // No GUID line exists — insert before the final closing brace
484
613
  const lines = content.split('\n');
@@ -490,7 +619,7 @@ function syncGuidToFile(filePath, guid) {
490
619
  }
491
620
  }
492
621
  if (insertIndex === -1) {
493
- return false;
622
+ return null;
494
623
  }
495
624
  let indent = ' ';
496
625
  for (let i = insertIndex - 1; i >= 0; i--) {
@@ -503,8 +632,7 @@ function syncGuidToFile(filePath, guid) {
503
632
  }
504
633
  }
505
634
  lines.splice(insertIndex, 0, `${indent}guid = "${guid}"`);
506
- fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
507
- return true;
635
+ return lines.join('\n');
508
636
  }
509
637
  const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
510
638
  /**
@@ -524,13 +652,30 @@ const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
524
652
  * has no canonical" rather than "not yet assigned".
525
653
  */
526
654
  function syncCanonicalToFile(filePath, canonical) {
527
- const content = fs.readFileSync(filePath, 'utf8');
655
+ const updated = withCanonical(fs.readFileSync(filePath, 'utf8'), canonical);
656
+ if (updated === null)
657
+ return false;
658
+ fs.writeFileSync(filePath, updated, 'utf8');
659
+ return true;
660
+ }
661
+ /**
662
+ * Return `content` with its canonical set to `canonical`, or null when nothing changes.
663
+ */
664
+ function withCanonical(content, canonical) {
528
665
  const existingMatch = content.match(CANONICAL_REGEX);
529
666
  if (!existingMatch || existingMatch[2] === canonical) {
530
- return false;
667
+ return null;
531
668
  }
532
- fs.writeFileSync(filePath, content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`), 'utf8');
533
- return true;
669
+ return content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`);
670
+ }
671
+ /** Apply a GUID/canonical rewrite to the in-memory copy of what was pushed. */
672
+ function updateSnapshot(snapshots, filePath, rewrite) {
673
+ const content = snapshots.get(filePath);
674
+ if (content === undefined)
675
+ return;
676
+ const updated = rewrite(content);
677
+ if (updated !== null)
678
+ snapshots.set(filePath, updated);
534
679
  }
535
680
  // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
536
681
  /**
@@ -579,7 +724,15 @@ function mergeKnowledgePreview(preview, knowledge) {
579
724
  */
580
725
  export async function executePush(ctx, target, flags) {
581
726
  const { accessToken, command, inputDir, verboseFetch } = ctx;
582
- const log = command.log.bind(command);
727
+ const log = ctx.log ?? command.log.bind(command);
728
+ let dryRunPreview = null;
729
+ const stop = (stopped) => ({ knowledge: { deleted: 0, imported: 0 }, preview: dryRunPreview, sent: [], stopped });
730
+ if (flags.fast && flags.sync) {
731
+ command.error('--fast cannot be used with --sync');
732
+ }
733
+ if (flags.refresh && !flags.fast) {
734
+ command.error('--refresh requires --fast');
735
+ }
583
736
  // ── Collect knowledge entries (before file check so knowledge-only push works) ─
584
737
  let knowledgeObjects = [];
585
738
  if (ctx.knowledge) {
@@ -600,8 +753,12 @@ export async function executePush(ctx, target, flags) {
600
753
  let documentEntries = [];
601
754
  let multidoc = '';
602
755
  const documentFileMap = new Map();
756
+ // The exact bytes of each document as read for this push (then as rewritten by GUID sync
757
+ // and writeback). The sync record hashes these, never a later re-read of the file, so an
758
+ // edit made while the push runs is not recorded as synced.
759
+ const pushedBytes = new Map();
603
760
  if (!knowledgeOnly) {
604
- documentEntries = readDocuments(files);
761
+ documentEntries = readDocuments(files, pushedBytes);
605
762
  if (documentEntries.length === 0) {
606
763
  command.error(`All .xs files in ${inputDir} are empty`);
607
764
  }
@@ -638,7 +795,7 @@ export async function executePush(ctx, target, flags) {
638
795
  const doFlatten = await confirm('Flatten these file(s) in place and continue the push?');
639
796
  if (!doFlatten) {
640
797
  log('Push cancelled. Run `xano flatten <file>` yourself, or re-run to be prompted again.');
641
- return;
798
+ return stop('cancelled');
642
799
  }
643
800
  }
644
801
  else {
@@ -657,7 +814,8 @@ export async function executePush(ctx, target, flags) {
657
814
  log('');
658
815
  // Re-collect and re-read: the bundle files are gone, replaced by the split
659
816
  // per-document tree, so the rest of the push sees a clean one-doc-per-file set.
660
- const refreshed = readDocuments(applyFilters(collectFiles(inputDir), inputDir, flags.include, flags.exclude, () => { }));
817
+ pushedBytes.clear();
818
+ const refreshed = readDocuments(applyFilters(collectFiles(inputDir), inputDir, flags.include, flags.exclude, () => { }), pushedBytes);
661
819
  documentEntries = refreshed;
662
820
  if (documentEntries.length === 0) {
663
821
  command.error('After flattening, no .xs documents remain to push.');
@@ -700,12 +858,175 @@ export async function executePush(ctx, target, flags) {
700
858
  }
701
859
  }
702
860
  }
861
+ const allDocumentEntries = [...documentEntries];
862
+ let recordSourceEntries = [...documentEntries];
863
+ let exportRebuild = false;
864
+ const pendingRecordEntries = {};
865
+ // Filled after writeback; the second persist() picks up the ids through recordEntriesFor.
866
+ const writebackIds = {};
867
+ const recordEntriesFor = (filePaths) => {
868
+ const snapshots = filePaths.flatMap((filePath) => {
869
+ const content = pushedBytes.get(filePath);
870
+ return content === undefined ? [] : [{ content, filePath }];
871
+ });
872
+ const entries = { ...pendingRecordEntries, ...recordEntriesForContents(inputDir, snapshots) };
873
+ for (const [relPath, found] of Object.entries(writebackIds)) {
874
+ const entry = entries[relPath];
875
+ if (entry && entry.key === found.key && found.id !== undefined) {
876
+ entries[relPath] = { ...entry, id: found.id };
877
+ }
878
+ }
879
+ return entries;
880
+ };
881
+ // An export rebuild re-derives the whole record, unless -i/-e limited it to part of the tree.
882
+ // An untrusted sandbox record is always replaced: entries outside the filter describe a
883
+ // sandbox that may since have been reset, and the new token would vouch for them.
884
+ const replaceRecord = () => exportRebuild && (sandboxUntrusted || (!flags.include && !flags.exclude));
885
+ // Under --records a table with items is always sent, as filterChangedEntries does without
886
+ // --fast: the record does not say whether its rows were imported.
887
+ const alwaysSend = (content) => flags.records && isTableWithRecords(content);
888
+ const persistRecord = !flags['dry-run'] && Boolean(ctx.syncTarget) && (Boolean(flags.fast) || syncStateExists(inputDir));
889
+ const resolved = flags.fast || flags.writeback || persistRecord
890
+ ? await resolveSyncTarget(ctx, flags)
891
+ : { isLiveBranch: !ctx.branch, liveBranch: ctx.branch, target: null };
892
+ const resolvedTarget = resolved.target;
893
+ let loadedState = null;
894
+ if (resolvedTarget) {
895
+ loadedState = loadSyncState(inputDir, resolvedTarget, (message) => command.warn(message));
896
+ }
897
+ else if (ctx.syncTarget && flags.fast) {
898
+ command.warn('Could not resolve sync target fingerprints; comparing against the server export');
899
+ }
900
+ let sandboxUntrusted = false;
901
+ if (ctx.syncTarget?.kind === 'sandbox' && resolvedTarget && loadedState) {
902
+ const markerToken = readMarkerToken(ctx.syncTarget.markerDir, resolvedTarget.instance_host, resolvedTarget.branch_created_at);
903
+ if (!loadedState.sandbox_token || loadedState.sandbox_token !== markerToken) {
904
+ sandboxUntrusted = true;
905
+ loadedState = null;
906
+ }
907
+ }
908
+ if (flags.fast && flags.env && ctx.syncTarget && !knowledgeOnly) {
909
+ log(ux.colorize('dim', '--env: the sync record does not track environment variables; comparing every file with the dry-run'));
910
+ }
911
+ if (flags.fast && !flags.env && ctx.syncTarget && !knowledgeOnly) {
912
+ const useExport = Boolean(flags.refresh) || !resolvedTarget || !loadedState;
913
+ if (useExport) {
914
+ let exportReason = 'No sync record for this workspace/branch; comparing against the server export';
915
+ if (flags.refresh) {
916
+ exportReason = 'Refreshing sync record from the server export';
917
+ }
918
+ else if (sandboxUntrusted) {
919
+ exportReason =
920
+ 'Sandbox changed since this directory\'s last push (reset, delete, or another directory); comparing against the sandbox export';
921
+ }
922
+ log(exportReason);
923
+ const exportParams = new URLSearchParams({
924
+ env: 'false',
925
+ include_draft: 'false',
926
+ records: flags.records.toString(),
927
+ });
928
+ if (target.supportsBranches) {
929
+ exportParams.set('branch', ctx.branch);
930
+ }
931
+ try {
932
+ const exportResponse = await verboseFetch(target.buildPushUrl(exportParams), {
933
+ headers: {
934
+ accept: 'application/json',
935
+ Authorization: `Bearer ${accessToken}`,
936
+ },
937
+ method: 'GET',
938
+ }, flags.verbose, accessToken);
939
+ if (!exportResponse.ok) {
940
+ throw new Error(`HTTP ${exportResponse.status}`);
941
+ }
942
+ const exported = parseExportDocuments(await exportResponse.text());
943
+ const compared = compareLocalToExport(allDocumentEntries.map((entry) => ({
944
+ ...entry,
945
+ relPath: toRelPath(inputDir, entry.filePath),
946
+ })), exported);
947
+ if (compared.serverOnly.length > 0) {
948
+ log(ux.colorize('dim', `${compared.serverOnly.length} object${compared.serverOnly.length === 1 ? '' : 's'} exist on the server but not locally; pull to get them, or --sync --delete to remove them`));
949
+ }
950
+ exportRebuild = true;
951
+ const candidates = [...compared.candidates];
952
+ const identical = [];
953
+ for (const entry of compared.identical) {
954
+ if (alwaysSend(entry.content))
955
+ candidates.push(entry);
956
+ else
957
+ identical.push(entry);
958
+ }
959
+ Object.assign(pendingRecordEntries, recordEntriesFor(identical.map((entry) => entry.filePath)));
960
+ if (candidates.length === 0 && knowledgeObjects.length === 0) {
961
+ if (resolvedTarget && !flags['dry-run']) {
962
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, pendingRecordEntries, { replace: replaceRecord() }, (message) => command.warn(message));
963
+ }
964
+ log('');
965
+ log('No changes to push.');
966
+ return stop('no-changes');
967
+ }
968
+ documentEntries = candidates;
969
+ recordSourceEntries = [...documentEntries];
970
+ multidoc = documentEntries.map((entry) => entry.content).join('\n---\n');
971
+ documentFileMap.clear();
972
+ for (const entry of documentEntries) {
973
+ const parsed = parseDocument(entry.content);
974
+ if (parsed) {
975
+ documentFileMap.set(buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup), entry.filePath);
976
+ }
977
+ }
978
+ }
979
+ catch (error) {
980
+ command.warn(`Failed to download the server export for --fast (${error.message}); falling back to a full dry-run`);
981
+ exportRebuild = false;
982
+ }
983
+ }
984
+ else if (resolvedTarget && loadedState) {
985
+ const allHashes = collectFiles(inputDir).map((filePath) => {
986
+ const content = pushedBytes.get(filePath);
987
+ return {
988
+ alwaysSend: content !== undefined && alwaysSend(content),
989
+ relPath: toRelPath(inputDir, filePath),
990
+ sha256: content === undefined ? hashFile(filePath) : hashBytes(content),
991
+ };
992
+ });
993
+ const { candidates, deletedLocally } = {
994
+ candidates: allHashes.filter((file) => {
995
+ const recorded = loadedState.files[file.relPath];
996
+ return file.alwaysSend || !recorded || recorded.sha256 !== file.sha256;
997
+ }),
998
+ deletedLocally: Object.entries(loadedState.files)
999
+ .filter(([relPath]) => !allHashes.some((file) => file.relPath === relPath))
1000
+ .filter(([relPath]) => pathMatchesFilters(relPath, inputDir, flags.include, flags.exclude))
1001
+ .map(([relPath, entry]) => ({ key: entry.key, relPath })),
1002
+ };
1003
+ if (deletedLocally.length > 0) {
1004
+ log(ux.colorize('dim', `${deletedLocally.length} file${deletedLocally.length === 1 ? '' : 's'} deleted locally since last sync; use --sync --delete to remove them remotely`));
1005
+ }
1006
+ const candidatePaths = new Set(candidates.map((file) => file.relPath));
1007
+ documentEntries = allDocumentEntries.filter((entry) => candidatePaths.has(toRelPath(inputDir, entry.filePath)));
1008
+ recordSourceEntries = [...documentEntries];
1009
+ if (documentEntries.length === 0 && knowledgeObjects.length === 0) {
1010
+ log('No local changes since last sync');
1011
+ return stop('no-changes');
1012
+ }
1013
+ multidoc = documentEntries.map((entry) => entry.content).join('\n---\n');
1014
+ documentFileMap.clear();
1015
+ for (const entry of documentEntries) {
1016
+ const parsed = parseDocument(entry.content);
1017
+ if (parsed) {
1018
+ documentFileMap.set(buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup), entry.filePath);
1019
+ }
1020
+ }
1021
+ }
1022
+ }
703
1023
  // ── Resolve push mode ─────────────────────────────────────────────────
704
1024
  const isPartial = !flags.sync;
705
1025
  if (flags.delete && isPartial) {
706
1026
  command.error('Cannot use --delete without --sync');
707
1027
  }
708
1028
  const shouldDelete = isPartial ? false : flags.delete;
1029
+ const pruneMissing = shouldDelete && !knowledgeOnly;
709
1030
  // ── Build query params ────────────────────────────────────────────────
710
1031
  const queryParams = new URLSearchParams({
711
1032
  delete: shouldDelete.toString(),
@@ -741,7 +1062,6 @@ export async function executePush(ctx, target, flags) {
741
1062
  'Content-Type': 'text/x-xanoscript',
742
1063
  };
743
1064
  // ── Dry-run / Preview ─────────────────────────────────────────────────
744
- let dryRunPreview = null;
745
1065
  const dryRunUrl = knowledgeOnly ? null : target.buildDryRunUrl(queryParams);
746
1066
  if (dryRunUrl && (flags['dry-run'] || !flags.force)) {
747
1067
  const dryRunParams = new URLSearchParams(queryParams);
@@ -806,7 +1126,7 @@ export async function executePush(ctx, target, flags) {
806
1126
  }
807
1127
  }
808
1128
  // Check for bad cross-references using dry-run operations to avoid false positives
809
- const badRefs = checkReferences(documentEntries, preview.operations);
1129
+ const badRefs = checkReferences(allDocumentEntries, preview.operations, documentEntries);
810
1130
  if (badRefs.length > 0) {
811
1131
  renderBadReferences(badRefs, log);
812
1132
  }
@@ -833,7 +1153,7 @@ export async function executePush(ctx, target, flags) {
833
1153
  log('');
834
1154
  log(ux.colorize('red', `Push blocked: ${criticalOps.length} critical error(s) found.`));
835
1155
  if (!flags.force) {
836
- return;
1156
+ return stop('blocked');
837
1157
  }
838
1158
  log(ux.colorize('yellow', 'Proceeding anyway due to --force flag.'));
839
1159
  }
@@ -842,7 +1162,7 @@ export async function executePush(ctx, target, flags) {
842
1162
  // Detect local records
843
1163
  const tablesWithRecords = flags.records
844
1164
  ? documentEntries
845
- .filter((d) => /^table\s+/m.test(d.content) && /\bitems\s*=\s*\[/m.test(d.content))
1165
+ .filter((d) => isTableWithRecords(d.content))
846
1166
  .map((d) => {
847
1167
  const nameMatch = d.content.match(/^table\s+(\S+)/m);
848
1168
  const itemsMatch = d.content.match(/\bitems\s*=\s*\[([\s\S]*?)\n\s*\]/);
@@ -863,10 +1183,13 @@ export async function executePush(ctx, target, flags) {
863
1183
  if (!hasChanges && !hasLocalRecords) {
864
1184
  log('');
865
1185
  log('No changes to push.');
866
- return;
1186
+ if (persistRecord) {
1187
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(recordSourceEntries.map((entry) => entry.filePath)), { pruneMissing, replace: replaceRecord() }, (message) => command.warn(message));
1188
+ }
1189
+ return stop('no-changes');
867
1190
  }
868
1191
  if (flags['dry-run']) {
869
- return;
1192
+ return stop('dry-run');
870
1193
  }
871
1194
  // Warn when the sandbox currently holds a different source workspace than the one being
872
1195
  // pushed. The backend compares the source workspace id we sent against the id stored on
@@ -884,7 +1207,7 @@ export async function executePush(ctx, target, flags) {
884
1207
  const proceed = await confirm('Continue with push anyway?');
885
1208
  if (!proceed) {
886
1209
  log('Push cancelled. Run `xano sandbox reset` then retry.');
887
- return;
1210
+ return stop('cancelled');
888
1211
  }
889
1212
  mismatchConfirmed = true;
890
1213
  }
@@ -905,7 +1228,7 @@ export async function executePush(ctx, target, flags) {
905
1228
  const confirmed = await confirm(message);
906
1229
  if (!confirmed) {
907
1230
  log('Push cancelled.');
908
- return;
1231
+ return stop('cancelled');
909
1232
  }
910
1233
  }
911
1234
  else {
@@ -922,15 +1245,17 @@ export async function executePush(ctx, target, flags) {
922
1245
  }
923
1246
  }
924
1247
  else {
925
- await handleDryRunError(dryRunResponse, command, flags, target);
1248
+ await handleDryRunError(dryRunResponse, command, target, { log, verbose: flags.verbose });
926
1249
  // If we get here, the user confirmed to proceed without preview
927
1250
  }
928
1251
  }
929
1252
  catch (error) {
930
1253
  // Ctrl+C or SIGINT
1254
+ if (error instanceof PushPolicyGateError)
1255
+ throw error;
931
1256
  if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') {
932
1257
  log('\nPush cancelled.');
933
- return;
1258
+ return stop('cancelled');
934
1259
  }
935
1260
  // Re-throw oclif errors
936
1261
  if (error instanceof Error && 'oclif' in error) {
@@ -961,16 +1286,16 @@ export async function executePush(ctx, target, flags) {
961
1286
  if (!hasChanges) {
962
1287
  log('');
963
1288
  log('No changes to push.');
964
- return;
1289
+ return stop('no-changes');
965
1290
  }
966
1291
  if (flags['dry-run']) {
967
- return;
1292
+ return stop('dry-run');
968
1293
  }
969
1294
  if (process.stdin.isTTY) {
970
1295
  const confirmed = await confirm('Proceed with push?');
971
1296
  if (!confirmed) {
972
1297
  log('Push cancelled.');
973
- return;
1298
+ return stop('cancelled');
974
1299
  }
975
1300
  }
976
1301
  else {
@@ -979,24 +1304,43 @@ export async function executePush(ctx, target, flags) {
979
1304
  }
980
1305
  // ── Show bad references in force mode (preview mode shows them inline) ─
981
1306
  if (flags.force && !knowledgeOnly) {
982
- const badRefs = checkReferences(documentEntries);
1307
+ const badRefs = checkReferences(allDocumentEntries, undefined, documentEntries);
983
1308
  if (badRefs.length > 0) {
984
1309
  log('');
985
1310
  renderBadReferences(badRefs, log);
986
1311
  }
987
1312
  }
988
1313
  // ── Partial push: filter to changed documents only ────────────────────
1314
+ let sent = documentEntries;
989
1315
  if (!knowledgeOnly && isPartial && dryRunPreview) {
990
- const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
991
- if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
1316
+ sent = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
1317
+ if (sent.length === 0 && knowledgeObjects.length === 0) {
992
1318
  log('No changes to push.');
993
- return;
1319
+ if (persistRecord) {
1320
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(recordSourceEntries.map((entry) => entry.filePath)), { pruneMissing: false, replace: replaceRecord() }, (message) => command.warn(message));
1321
+ }
1322
+ return stop('no-changes');
994
1323
  }
995
- multidoc = filteredEntries.length > 0 ? filteredEntries.map((d) => d.content).join('\n---\n') : '';
1324
+ multidoc = sent.map((d) => d.content).join('\n---\n');
996
1325
  }
1326
+ const persist = () => {
1327
+ if (!persistRecord || !ctx.syncTarget)
1328
+ return;
1329
+ const paths = [
1330
+ ...recordSourceEntries.map((entry) => entry.filePath),
1331
+ ...sent.map((entry) => entry.filePath),
1332
+ ];
1333
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(paths), { pruneMissing, replace: replaceRecord() }, (message) => command.warn(message));
1334
+ };
997
1335
  // ── Execute the actual push ───────────────────────────────────────────
1336
+ const willMutate = (!knowledgeOnly && Boolean(multidoc)) ||
1337
+ Boolean(ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete));
1338
+ if (willMutate && !flags['dry-run']) {
1339
+ maybeInvalidateSandboxMarker(ctx, persistRecord, (message) => command.warn(message));
1340
+ }
998
1341
  const startTime = Date.now();
999
1342
  let pushedDocCount = 0;
1343
+ let pushResponse;
1000
1344
  if (!knowledgeOnly && multidoc) {
1001
1345
  const apiUrl = target.buildPushUrl(queryParams);
1002
1346
  try {
@@ -1006,14 +1350,19 @@ export async function executePush(ctx, target, flags) {
1006
1350
  method: 'POST',
1007
1351
  }, flags.verbose, accessToken);
1008
1352
  if (!response.ok) {
1009
- handlePushError(response, await response.text(), documentEntries, inputDir, command);
1353
+ const errorText = await response.text();
1354
+ refuseIfExplained(command, target, response.status, errorText);
1355
+ handlePushError(response, errorText, documentEntries, inputDir, command);
1010
1356
  }
1011
1357
  // Parse response for GUID map
1012
1358
  const responseText = await response.text();
1013
1359
  let guidMap = [];
1360
+ pushResponse = {};
1014
1361
  if (responseText && responseText !== 'null') {
1015
1362
  try {
1016
1363
  const responseJson = JSON.parse(responseText);
1364
+ if (responseJson && typeof responseJson === 'object')
1365
+ pushResponse = responseJson;
1017
1366
  if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
1018
1367
  guidMap = responseJson.guid_map;
1019
1368
  }
@@ -1039,7 +1388,7 @@ export async function executePush(ctx, target, flags) {
1039
1388
  let updatedCount = 0;
1040
1389
  let canonicalCount = 0;
1041
1390
  for (const entry of guidMap) {
1042
- if (!entry.guid)
1391
+ if (!entry.guid || entry.type === 'policy')
1043
1392
  continue;
1044
1393
  const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
1045
1394
  let filePath = documentFileMap.get(key);
@@ -1060,15 +1409,18 @@ export async function executePush(ctx, target, flags) {
1060
1409
  const updated = syncGuidToFile(filePath, entry.guid);
1061
1410
  if (updated)
1062
1411
  updatedCount++;
1412
+ updateSnapshot(pushedBytes, filePath, (content) => withGuid(content, entry.guid));
1063
1413
  }
1064
1414
  catch (error) {
1065
1415
  command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
1066
1416
  }
1067
1417
  if (entry.canonical) {
1418
+ const { canonical } = entry;
1068
1419
  try {
1069
- const updated = syncCanonicalToFile(filePath, entry.canonical);
1420
+ const updated = syncCanonicalToFile(filePath, canonical);
1070
1421
  if (updated)
1071
1422
  canonicalCount++;
1423
+ updateSnapshot(pushedBytes, filePath, (content) => withCanonical(content, canonical));
1072
1424
  }
1073
1425
  catch (error) {
1074
1426
  command.warn(`Failed to sync canonical to ${filePath}: ${error.message}`);
@@ -1085,15 +1437,54 @@ export async function executePush(ctx, target, flags) {
1085
1437
  pushedDocCount = multidoc.split('\n---\n').length;
1086
1438
  }
1087
1439
  catch (error) {
1440
+ if (error instanceof PushPolicyGateError)
1441
+ throw error;
1088
1442
  if (error instanceof Error && 'oclif' in error)
1089
1443
  throw error;
1090
1444
  const elapsedMs = Date.now() - startTime;
1091
1445
  command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
1092
1446
  }
1093
1447
  }
1448
+ // Write the marker/record before writeback. Writeback is another full export, and
1449
+ // a failure or Ctrl+C there must not drop --fast state from an already-successful push.
1450
+ persist();
1451
+ let writebackMs = 0;
1452
+ if (flags.writeback && pushedDocCount > 0) {
1453
+ const writeback = await writeBackFormattedDocuments({
1454
+ accessToken,
1455
+ branch: ctx.branch,
1456
+ command,
1457
+ includeEnv: flags.env,
1458
+ includeRecords: flags.records,
1459
+ inputDir,
1460
+ isLiveBranch: resolved.isLiveBranch,
1461
+ log,
1462
+ pushedBytes,
1463
+ pushedEntries: sent,
1464
+ resolvedTarget,
1465
+ syncState: loadedState,
1466
+ target,
1467
+ verbose: flags.verbose,
1468
+ verboseFetch,
1469
+ workspaceId: ctx.syncTarget?.kind === 'workspace' ? ctx.syncTarget.workspace : undefined,
1470
+ writebackDiff: flags.writebackDiff,
1471
+ writebackObjectLimit: flags.writebackObjectLimit,
1472
+ });
1473
+ writebackMs = writeback.elapsedMs;
1474
+ Object.assign(writebackIds, writeback.ids);
1475
+ for (const { content, filePath } of writeback.written) {
1476
+ pushedBytes.set(filePath, content);
1477
+ }
1478
+ }
1094
1479
  // ── Push knowledge ────────────────────────────────────────────────────
1095
1480
  let knowledgeImported = 0;
1096
1481
  let knowledgeDeleted = 0;
1482
+ const pushed = () => ({
1483
+ knowledge: { deleted: knowledgeDeleted, imported: knowledgeImported },
1484
+ preview: dryRunPreview,
1485
+ response: pushResponse,
1486
+ sent: pushResponse ? sent : [],
1487
+ });
1097
1488
  if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
1098
1489
  const listUrl = ctx.knowledge.listUrl();
1099
1490
  try {
@@ -1131,10 +1522,13 @@ export async function executePush(ctx, target, flags) {
1131
1522
  if (error instanceof Error && 'oclif' in error)
1132
1523
  throw error;
1133
1524
  const elapsedMs = Date.now() - startTime;
1134
- command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
1525
+ const message = `Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`;
1526
+ if (pushResponse)
1527
+ throw new FailedAfterImportError(message, pushed());
1528
+ command.error(message);
1135
1529
  }
1136
1530
  }
1137
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1531
+ const elapsed = (Math.max(0, Date.now() - startTime - writebackMs) / 1000).toFixed(1);
1138
1532
  const parts = [];
1139
1533
  if (!knowledgeOnly)
1140
1534
  parts.push(`${pushedDocCount} documents`);
@@ -1144,7 +1538,198 @@ export async function executePush(ctx, target, flags) {
1144
1538
  kParts.push(`${knowledgeDeleted} deleted`);
1145
1539
  parts.push(kParts.join(', '));
1146
1540
  }
1541
+ persist();
1147
1542
  log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
1543
+ return pushed();
1544
+ }
1545
+ export async function writeBackFormattedDocuments(args) {
1546
+ const started = Date.now();
1547
+ const { accessToken, branch, command, includeEnv, includeRecords, inputDir, isLiveBranch = !branch, pushedBytes, resolvedTarget, syncState, target, verbose, verboseFetch, workspaceId, writebackDiff, writebackObjectLimit, } = args;
1548
+ const log = args.log ?? command.log.bind(command);
1549
+ const elapsedLabel = () => ` (${((Date.now() - started) / 1000).toFixed(1)}s)`;
1550
+ const ids = {};
1551
+ const written = [];
1552
+ const done = () => ({ elapsedMs: Date.now() - started, ids, written });
1553
+ const relPathOf = (filePath) => (inputDir ? toRelPath(inputDir, filePath) : filePath);
1554
+ const localContents = new Map();
1555
+ const pushedFiles = new Map();
1556
+ const unchangedEntries = [];
1557
+ for (const entry of args.pushedEntries) {
1558
+ const parsed = parseDocument(entry.content);
1559
+ if (!parsed)
1560
+ continue;
1561
+ let local;
1562
+ try {
1563
+ local = fs.readFileSync(entry.filePath, 'utf8');
1564
+ }
1565
+ catch {
1566
+ local = entry.content;
1567
+ }
1568
+ const sent = pushedBytes?.get(entry.filePath);
1569
+ if (sent !== undefined && local !== sent) {
1570
+ command.warn(`Writeback: skipped ${relPathOf(entry.filePath)}; it changed after it was read for the push`);
1571
+ continue;
1572
+ }
1573
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
1574
+ pushedFiles.set(key, entry.filePath);
1575
+ localContents.set(entry.filePath, local);
1576
+ unchangedEntries.push(entry);
1577
+ }
1578
+ if (unchangedEntries.length === 0)
1579
+ return done();
1580
+ let exportedDocs = [];
1581
+ let pathLabel = 'export';
1582
+ if (resolvedTarget && workspaceId && writebackObjectLimit !== 0) {
1583
+ const get = async (pathname, query = {}) => {
1584
+ const params = new URLSearchParams(query);
1585
+ const origin = target.instanceOrigin.replace(/\/$/, '');
1586
+ const url = `${origin}/api:meta${pathname}${params.toString() ? `?${params.toString()}` : ''}`;
1587
+ const response = await verboseFetch(url, {
1588
+ headers: {
1589
+ accept: 'application/json',
1590
+ Authorization: `Bearer ${accessToken}`,
1591
+ },
1592
+ method: 'GET',
1593
+ }, verbose, accessToken);
1594
+ const text = await response.text();
1595
+ let json = text;
1596
+ try {
1597
+ json = JSON.parse(text);
1598
+ }
1599
+ catch {
1600
+ // keep text
1601
+ }
1602
+ return { json, ok: response.ok, status: response.status };
1603
+ };
1604
+ let fetched;
1605
+ try {
1606
+ fetched = await fetchFormattedObjects({
1607
+ get,
1608
+ isLiveBranch,
1609
+ limit: writebackObjectLimit,
1610
+ pushed: unchangedEntries.map((entry) => ({
1611
+ content: localContents.get(entry.filePath) ?? entry.content,
1612
+ filePath: entry.filePath,
1613
+ relPath: relPathOf(entry.filePath),
1614
+ })),
1615
+ state: syncState ?? null,
1616
+ target: resolvedTarget,
1617
+ workspaceId,
1618
+ });
1619
+ }
1620
+ catch (error) {
1621
+ fetched = { ok: false, reason: error.message };
1622
+ }
1623
+ if (fetched.ok) {
1624
+ Object.assign(ids, fetched.ids);
1625
+ exportedDocs = [];
1626
+ for (const result of fetched.results) {
1627
+ const parsed = parseDocument(result.content);
1628
+ if (!parsed) {
1629
+ command.warn(`Writeback: skipped ${result.filePath}; fetched document did not parse`);
1630
+ continue;
1631
+ }
1632
+ exportedDocs.push({ ...parsed, content: result.content });
1633
+ }
1634
+ pathLabel = 'per-object';
1635
+ }
1636
+ else {
1637
+ command.warn(`Per-object writeback unavailable (${fetched.reason}); using full export`);
1638
+ }
1639
+ }
1640
+ if (exportedDocs.length === 0) {
1641
+ const exportParams = new URLSearchParams({
1642
+ env: includeEnv.toString(),
1643
+ records: includeRecords.toString(),
1644
+ });
1645
+ exportParams.set('include_draft', 'false');
1646
+ if (target.supportsBranches) {
1647
+ exportParams.set('branch', branch);
1648
+ }
1649
+ const exportUrl = target.buildPushUrl(exportParams);
1650
+ let response;
1651
+ try {
1652
+ response = await verboseFetch(exportUrl, {
1653
+ headers: {
1654
+ accept: 'application/json',
1655
+ Authorization: `Bearer ${accessToken}`,
1656
+ },
1657
+ method: 'GET',
1658
+ }, verbose, accessToken);
1659
+ }
1660
+ catch (error) {
1661
+ command.warn(`Failed to fetch server-formatted documents for writeback: ${error.message}`);
1662
+ return done();
1663
+ }
1664
+ if (!response.ok) {
1665
+ command.warn(`Failed to fetch server-formatted documents for writeback (${response.status})`);
1666
+ return done();
1667
+ }
1668
+ const responseText = await response.text();
1669
+ exportedDocs = parseExportDocuments(responseText);
1670
+ pathLabel = 'export';
1671
+ if (exportedDocs.length === 0) {
1672
+ const preview = responseText.trim().slice(0, 200).replaceAll('\n', String.raw `\n`);
1673
+ command.warn(`Writeback: export contained no XanoScript documents (${pushedFiles.size} pushed). Response starts with: ${preview || '(empty)'}`);
1674
+ return done();
1675
+ }
1676
+ }
1677
+ const matcher = indexLocalDocuments(pushedFiles, localContents);
1678
+ const matched = new Set();
1679
+ for (const doc of exportedDocs) {
1680
+ const filePath = resolveMatchedPath(doc, matcher);
1681
+ if (filePath)
1682
+ matched.add(filePath);
1683
+ }
1684
+ const writebacks = selectWritebacks(exportedDocs, pushedFiles, localContents, { includeRecords });
1685
+ if (writebackDiff) {
1686
+ for (const { content, filePath } of writebacks) {
1687
+ const local = localContents.get(filePath) ?? '';
1688
+ const rel = inputDir ? relPathOf(filePath) : filePath.split(/[\\/]/).pop() || 'file.xs';
1689
+ const destBase = join(writebackDiff, rel);
1690
+ try {
1691
+ fs.mkdirSync(join(destBase, '..'), { recursive: true });
1692
+ fs.writeFileSync(`${destBase}.local.xs`, local, 'utf8');
1693
+ fs.writeFileSync(`${destBase}.server.xs`, content.trim(), 'utf8');
1694
+ }
1695
+ catch (error) {
1696
+ command.warn(`Failed to write writeback-diff for ${filePath}: ${error.message}`);
1697
+ }
1698
+ }
1699
+ }
1700
+ for (const { content, filePath } of writebacks) {
1701
+ // Same bytes `pull` writes: the trimmed document, no trailing newline.
1702
+ const bytes = content.trim();
1703
+ // Re-check right before writing: the fetch above can take minutes.
1704
+ let current = null;
1705
+ try {
1706
+ current = fs.readFileSync(filePath, 'utf8');
1707
+ }
1708
+ catch {
1709
+ // Treated as changed below.
1710
+ }
1711
+ if (current !== localContents.get(filePath)) {
1712
+ command.warn(`Writeback: skipped ${relPathOf(filePath)}; it changed after it was read for the push`);
1713
+ continue;
1714
+ }
1715
+ try {
1716
+ fs.writeFileSync(filePath, bytes, 'utf8');
1717
+ written.push({ content: bytes, filePath });
1718
+ }
1719
+ catch (error) {
1720
+ command.warn(`Failed to write formatted document to ${filePath}: ${error.message}`);
1721
+ }
1722
+ }
1723
+ if (written.length > 0) {
1724
+ log(`Wrote ${written.length} formatted file${written.length === 1 ? '' : 's'} (${pathLabel}${elapsedLabel()})`);
1725
+ return done();
1726
+ }
1727
+ if (matched.size === 0) {
1728
+ command.warn(`Writeback: fetched ${exportedDocs.length} documents, but none matched the ${pushedFiles.size} pushed file${pushedFiles.size === 1 ? '' : 's'}${elapsedLabel()}`);
1729
+ return done();
1730
+ }
1731
+ log(`Writeback: ${matched.size} pushed file${matched.size === 1 ? '' : 's'} already match the server export${elapsedLabel()}`);
1732
+ return done();
1148
1733
  }
1149
1734
  // ── Error Handlers ──────────────────────────────────────────────────────────
1150
1735
  /**
@@ -1223,10 +1808,37 @@ function formatFailureDuration(elapsedMs) {
1223
1808
  ? ` (failed after ~${human}, near a common ${boundaries.find((b) => Math.abs(totalSeconds - b) <= b * 0.05)}s timeout — likely a server or proxy cutoff)`
1224
1809
  : ` (failed after ${human})`;
1225
1810
  }
1226
- async function handleDryRunError(response, command, flags, target) {
1227
- const log = command.log.bind(command);
1811
+ /** Stop with the target's own guidance when it recognises this refusal. */
1812
+ function refuseIfExplained(command, target, status, body) {
1813
+ let message = body;
1814
+ let payload;
1815
+ try {
1816
+ const parsed = JSON.parse(body);
1817
+ if (typeof parsed?.message === 'string')
1818
+ message = parsed.message;
1819
+ payload = parsed?.payload;
1820
+ }
1821
+ catch {
1822
+ // Not JSON
1823
+ }
1824
+ const refusal = payload;
1825
+ if (status === 403 && refusal?.code === 'policy_gate')
1826
+ throw new PushPolicyGateError(refusal, message === body ? undefined : message);
1827
+ // The plan's policy cap: the platform's message names the plan, its cap and the remedy, and the
1828
+ // push itself would be refused the same way, so the preview stops here rather than skipping.
1829
+ if (status === 403 && refusal?.code === 'policy_plan_limit')
1830
+ command.error(`Push refused (${status}): ${message}`);
1831
+ if (refusal?.code === 'policy_gate_transaction_required') {
1832
+ command.error('Push refused: the live branch policy gate requires a transaction. Remove --no-transaction and retry. Nothing was imported.');
1833
+ }
1834
+ const guidance = target.explainRefusal?.(status, payload);
1835
+ if (guidance)
1836
+ command.error(`Push refused (${status}): ${message}${guidance}`);
1837
+ }
1838
+ async function handleDryRunError(response, command, target, { log, verbose }) {
1839
+ const errorText = await response.text();
1840
+ refuseIfExplained(command, target, response.status, errorText);
1228
1841
  if (response.status === 404) {
1229
- const errorText = await response.text();
1230
1842
  try {
1231
1843
  const errorJson = JSON.parse(errorText);
1232
1844
  if (errorJson.message) {
@@ -1244,7 +1856,6 @@ async function handleDryRunError(response, command, flags, target) {
1244
1856
  log('');
1245
1857
  }
1246
1858
  else {
1247
- const errorText = await response.text();
1248
1859
  // Check if push is disabled
1249
1860
  try {
1250
1861
  const errorJson = JSON.parse(errorText);
@@ -1267,8 +1878,17 @@ async function handleDryRunError(response, command, flags, target) {
1267
1878
  catch {
1268
1879
  // Not JSON, fall through
1269
1880
  }
1270
- command.warn(`Push preview failed (${response.status}). Skipping preview.`);
1271
- if (flags.verbose) {
1881
+ let reason = '';
1882
+ try {
1883
+ const errorJson = JSON.parse(errorText);
1884
+ if (typeof errorJson?.message === 'string' && errorJson.message.trim())
1885
+ reason = `: ${errorJson.message.trim().replace(/\.$/, '')}`;
1886
+ }
1887
+ catch {
1888
+ // Not JSON
1889
+ }
1890
+ command.warn(`Push preview failed (${response.status})${reason}. Skipping preview.`);
1891
+ if (verbose) {
1272
1892
  log(ux.colorize('dim', errorText));
1273
1893
  }
1274
1894
  }
@@ -1323,3 +1943,4 @@ function handlePushError(response, errorText, documentEntries, inputDir, command
1323
1943
  }
1324
1944
  command.error(errorMessage);
1325
1945
  }
1946
+ export { parseExportDocuments, preserveLocalTableRecords, selectWritebacks } from './export-compare.js';