@xano/cli 1.3.3 → 1.3.4

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.
@@ -3,9 +3,13 @@ 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';
8
10
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
11
+ import { invalidateSandboxMarkerForOrigin, readMarkerToken, sandboxMarkerExists, writeMarkerToken, } from './sandbox-marker.js';
12
+ import { hashBytes, hashFile, instanceHost, loadSyncState, recordEntriesForContents, resolveTarget, sandboxTargetIdentity, saveSyncState, syncStateExists, toRelPath, } from './sync-state.js';
9
13
  /**
10
14
  * Filter document entries down to the ones the dry-run preview reports as changed,
11
15
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -44,6 +48,10 @@ export function filterChangedEntries(entries, operations, includeRecords) {
44
48
  return false;
45
49
  });
46
50
  }
51
+ /** True for a table document carrying an `items = [...]` block (records). */
52
+ function isTableWithRecords(content) {
53
+ return /^table\s+/m.test(content) && /\bitems\s*=\s*\[/m.test(content);
54
+ }
47
55
  // ── File Collection ─────────────────────────────────────────────────────────
48
56
  /**
49
57
  * Recursively collect all .xs files from a directory, sorted for deterministic ordering.
@@ -211,15 +219,117 @@ export function deletesHittingFilteredOut(operations, filteredOut) {
211
219
  }
212
220
  return hits;
213
221
  }
222
+ export function pathMatchesFilters(relPath, inputDir, include, exclude) {
223
+ if (include && include.length > 0) {
224
+ const patterns = include.map((pattern) => normalizeFilterPattern(pattern, inputDir));
225
+ if (!patterns.some((pattern) => minimatch(relPath, pattern, { matchBase: true }))) {
226
+ return false;
227
+ }
228
+ }
229
+ if (exclude && exclude.length > 0) {
230
+ const patterns = exclude.map((pattern) => normalizeFilterPattern(pattern, inputDir));
231
+ if (patterns.some((pattern) => minimatch(relPath, pattern, { matchBase: true }))) {
232
+ return false;
233
+ }
234
+ }
235
+ return true;
236
+ }
237
+ function commitSyncRecordWithSandbox(ctx, inputDir, target, entries, options, warn) {
238
+ if (!target) {
239
+ warn('Skipped writing sync record; could not resolve sandbox/workspace identity');
240
+ return;
241
+ }
242
+ let sandboxToken;
243
+ if (ctx.syncTarget?.kind === 'sandbox') {
244
+ try {
245
+ sandboxToken = writeMarkerToken(ctx.syncTarget.markerDir, target.instance_host, target.branch_created_at);
246
+ }
247
+ catch (error) {
248
+ warn(`Skipped writing sandbox sync marker: ${error.message}`);
249
+ }
250
+ }
251
+ trySaveSyncState(inputDir, target, entries, { ...options, sandboxToken }, warn);
252
+ }
253
+ function trySaveSyncState(inputDir, target, entries, options, warn) {
254
+ try {
255
+ saveSyncState(inputDir, target, entries, options);
256
+ }
257
+ catch (error) {
258
+ warn(`Skipped writing .xano/sync_state.json: ${error.message}`);
259
+ }
260
+ }
261
+ async function resolveSyncTarget(ctx, flags) {
262
+ if (!ctx.syncTarget) {
263
+ return { isLiveBranch: !ctx.branch, liveBranch: ctx.branch, target: null };
264
+ }
265
+ const origin = ctx.syncTarget.instanceOrigin.replace(/\/$/, '');
266
+ const fetchJson = async (pathname) => {
267
+ const response = await ctx.verboseFetch(`${origin}/api:meta${pathname}`, {
268
+ headers: {
269
+ accept: 'application/json',
270
+ Authorization: `Bearer ${ctx.accessToken}`,
271
+ },
272
+ method: 'GET',
273
+ }, flags.verbose, ctx.accessToken);
274
+ if (!response.ok) {
275
+ throw new Error(`HTTP ${response.status}`);
276
+ }
277
+ return response.json();
278
+ };
279
+ if (ctx.syncTarget.kind === 'sandbox') {
280
+ try {
281
+ const me = await fetchJson('/sandbox/me');
282
+ if (!me ||
283
+ typeof me !== 'object' ||
284
+ Array.isArray(me) ||
285
+ typeof me.created_at !== 'string' ||
286
+ typeof me.id !== 'number') {
287
+ return { isLiveBranch: true, liveBranch: '', target: null };
288
+ }
289
+ return {
290
+ isLiveBranch: true,
291
+ liveBranch: '',
292
+ target: sandboxTargetIdentity(instanceHost(ctx.syncTarget.instanceOrigin), {
293
+ created_at: me.created_at,
294
+ id: me.id,
295
+ }),
296
+ };
297
+ }
298
+ catch {
299
+ return { isLiveBranch: true, liveBranch: '', target: null };
300
+ }
301
+ }
302
+ const target = await resolveTarget(ctx.syncTarget.instanceOrigin, ctx.syncTarget.workspace, ctx.branch, fetchJson);
303
+ return {
304
+ isLiveBranch: Boolean(target && target.branch === target.live_branch),
305
+ liveBranch: target?.live_branch ?? ctx.branch,
306
+ target,
307
+ };
308
+ }
309
+ function maybeInvalidateSandboxMarker(ctx, persistRecord, warn) {
310
+ if (ctx.syncTarget?.kind !== 'sandbox')
311
+ return;
312
+ if (!persistRecord && !sandboxMarkerExists(ctx.syncTarget.markerDir))
313
+ return;
314
+ try {
315
+ invalidateSandboxMarkerForOrigin(ctx.syncTarget.markerDir, ctx.syncTarget.instanceOrigin);
316
+ }
317
+ catch (error) {
318
+ warn(`Skipped invalidating sandbox sync marker: ${error.message}`);
319
+ }
320
+ }
214
321
  /**
215
- * Read .xs files into document entries, skipping empty files.
322
+ * Read .xs files into document entries, skipping empty files. When `raw` is given, the
323
+ * untrimmed bytes of each returned file are stored in it, keyed by file path.
216
324
  */
217
- export function readDocuments(files) {
325
+ export function readDocuments(files, raw) {
218
326
  const entries = [];
219
327
  for (const filePath of files) {
220
- const content = fs.readFileSync(filePath, 'utf8').trim();
328
+ const bytes = fs.readFileSync(filePath, 'utf8');
329
+ const content = bytes.trim();
221
330
  if (content) {
222
331
  entries.push({ content, filePath });
332
+ raw?.set(filePath, bytes);
223
333
  }
224
334
  }
225
335
  return entries;
@@ -470,15 +580,22 @@ const GUID_REGEX = /guid\s*=\s*(["'])([^"']*)\1/;
470
580
  * Sync a GUID into a local .xs file. Returns true if the file was modified.
471
581
  */
472
582
  function syncGuidToFile(filePath, guid) {
473
- const content = fs.readFileSync(filePath, 'utf8');
583
+ const updated = withGuid(fs.readFileSync(filePath, 'utf8'), guid);
584
+ if (updated === null)
585
+ return false;
586
+ fs.writeFileSync(filePath, updated, 'utf8');
587
+ return true;
588
+ }
589
+ /**
590
+ * Return `content` with its GUID set to `guid`, or null when nothing changes.
591
+ */
592
+ function withGuid(content, guid) {
474
593
  const existingMatch = content.match(GUID_REGEX);
475
594
  if (existingMatch) {
476
595
  if (existingMatch[2] === guid) {
477
- return false;
596
+ return null;
478
597
  }
479
- const updated = content.replace(GUID_REGEX, `guid = "${guid}"`);
480
- fs.writeFileSync(filePath, updated, 'utf8');
481
- return true;
598
+ return content.replace(GUID_REGEX, `guid = "${guid}"`);
482
599
  }
483
600
  // No GUID line exists — insert before the final closing brace
484
601
  const lines = content.split('\n');
@@ -490,7 +607,7 @@ function syncGuidToFile(filePath, guid) {
490
607
  }
491
608
  }
492
609
  if (insertIndex === -1) {
493
- return false;
610
+ return null;
494
611
  }
495
612
  let indent = ' ';
496
613
  for (let i = insertIndex - 1; i >= 0; i--) {
@@ -503,8 +620,7 @@ function syncGuidToFile(filePath, guid) {
503
620
  }
504
621
  }
505
622
  lines.splice(insertIndex, 0, `${indent}guid = "${guid}"`);
506
- fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
507
- return true;
623
+ return lines.join('\n');
508
624
  }
509
625
  const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
510
626
  /**
@@ -524,13 +640,30 @@ const CANONICAL_REGEX = /canonical\s*=\s*(["'])([^"']*)\1/;
524
640
  * has no canonical" rather than "not yet assigned".
525
641
  */
526
642
  function syncCanonicalToFile(filePath, canonical) {
527
- const content = fs.readFileSync(filePath, 'utf8');
643
+ const updated = withCanonical(fs.readFileSync(filePath, 'utf8'), canonical);
644
+ if (updated === null)
645
+ return false;
646
+ fs.writeFileSync(filePath, updated, 'utf8');
647
+ return true;
648
+ }
649
+ /**
650
+ * Return `content` with its canonical set to `canonical`, or null when nothing changes.
651
+ */
652
+ function withCanonical(content, canonical) {
528
653
  const existingMatch = content.match(CANONICAL_REGEX);
529
654
  if (!existingMatch || existingMatch[2] === canonical) {
530
- return false;
655
+ return null;
531
656
  }
532
- fs.writeFileSync(filePath, content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`), 'utf8');
533
- return true;
657
+ return content.replace(CANONICAL_REGEX, `canonical = "${canonical}"`);
658
+ }
659
+ /** Apply a GUID/canonical rewrite to the in-memory copy of what was pushed. */
660
+ function updateSnapshot(snapshots, filePath, rewrite) {
661
+ const content = snapshots.get(filePath);
662
+ if (content === undefined)
663
+ return;
664
+ const updated = rewrite(content);
665
+ if (updated !== null)
666
+ snapshots.set(filePath, updated);
534
667
  }
535
668
  // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
536
669
  /**
@@ -580,6 +713,12 @@ function mergeKnowledgePreview(preview, knowledge) {
580
713
  export async function executePush(ctx, target, flags) {
581
714
  const { accessToken, command, inputDir, verboseFetch } = ctx;
582
715
  const log = command.log.bind(command);
716
+ if (flags.fast && flags.sync) {
717
+ command.error('--fast cannot be used with --sync');
718
+ }
719
+ if (flags.refresh && !flags.fast) {
720
+ command.error('--refresh requires --fast');
721
+ }
583
722
  // ── Collect knowledge entries (before file check so knowledge-only push works) ─
584
723
  let knowledgeObjects = [];
585
724
  if (ctx.knowledge) {
@@ -600,8 +739,12 @@ export async function executePush(ctx, target, flags) {
600
739
  let documentEntries = [];
601
740
  let multidoc = '';
602
741
  const documentFileMap = new Map();
742
+ // The exact bytes of each document as read for this push (then as rewritten by GUID sync
743
+ // and writeback). The sync record hashes these, never a later re-read of the file, so an
744
+ // edit made while the push runs is not recorded as synced.
745
+ const pushedBytes = new Map();
603
746
  if (!knowledgeOnly) {
604
- documentEntries = readDocuments(files);
747
+ documentEntries = readDocuments(files, pushedBytes);
605
748
  if (documentEntries.length === 0) {
606
749
  command.error(`All .xs files in ${inputDir} are empty`);
607
750
  }
@@ -657,7 +800,8 @@ export async function executePush(ctx, target, flags) {
657
800
  log('');
658
801
  // Re-collect and re-read: the bundle files are gone, replaced by the split
659
802
  // 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, () => { }));
803
+ pushedBytes.clear();
804
+ const refreshed = readDocuments(applyFilters(collectFiles(inputDir), inputDir, flags.include, flags.exclude, () => { }), pushedBytes);
661
805
  documentEntries = refreshed;
662
806
  if (documentEntries.length === 0) {
663
807
  command.error('After flattening, no .xs documents remain to push.');
@@ -700,12 +844,175 @@ export async function executePush(ctx, target, flags) {
700
844
  }
701
845
  }
702
846
  }
847
+ const allDocumentEntries = [...documentEntries];
848
+ let recordSourceEntries = [...documentEntries];
849
+ let exportRebuild = false;
850
+ const pendingRecordEntries = {};
851
+ // Filled after writeback; the second persist() picks up the ids through recordEntriesFor.
852
+ const writebackIds = {};
853
+ const recordEntriesFor = (filePaths) => {
854
+ const snapshots = filePaths.flatMap((filePath) => {
855
+ const content = pushedBytes.get(filePath);
856
+ return content === undefined ? [] : [{ content, filePath }];
857
+ });
858
+ const entries = { ...pendingRecordEntries, ...recordEntriesForContents(inputDir, snapshots) };
859
+ for (const [relPath, found] of Object.entries(writebackIds)) {
860
+ const entry = entries[relPath];
861
+ if (entry && entry.key === found.key && found.id !== undefined) {
862
+ entries[relPath] = { ...entry, id: found.id };
863
+ }
864
+ }
865
+ return entries;
866
+ };
867
+ // An export rebuild re-derives the whole record, unless -i/-e limited it to part of the tree.
868
+ // An untrusted sandbox record is always replaced: entries outside the filter describe a
869
+ // sandbox that may since have been reset, and the new token would vouch for them.
870
+ const replaceRecord = () => exportRebuild && (sandboxUntrusted || (!flags.include && !flags.exclude));
871
+ // Under --records a table with items is always sent, as filterChangedEntries does without
872
+ // --fast: the record does not say whether its rows were imported.
873
+ const alwaysSend = (content) => flags.records && isTableWithRecords(content);
874
+ const persistRecord = !flags['dry-run'] && Boolean(ctx.syncTarget) && (Boolean(flags.fast) || syncStateExists(inputDir));
875
+ const resolved = flags.fast || flags.writeback || persistRecord
876
+ ? await resolveSyncTarget(ctx, flags)
877
+ : { isLiveBranch: !ctx.branch, liveBranch: ctx.branch, target: null };
878
+ const resolvedTarget = resolved.target;
879
+ let loadedState = null;
880
+ if (resolvedTarget) {
881
+ loadedState = loadSyncState(inputDir, resolvedTarget, (message) => command.warn(message));
882
+ }
883
+ else if (ctx.syncTarget && flags.fast) {
884
+ command.warn('Could not resolve sync target fingerprints; comparing against the server export');
885
+ }
886
+ let sandboxUntrusted = false;
887
+ if (ctx.syncTarget?.kind === 'sandbox' && resolvedTarget && loadedState) {
888
+ const markerToken = readMarkerToken(ctx.syncTarget.markerDir, resolvedTarget.instance_host, resolvedTarget.branch_created_at);
889
+ if (!loadedState.sandbox_token || loadedState.sandbox_token !== markerToken) {
890
+ sandboxUntrusted = true;
891
+ loadedState = null;
892
+ }
893
+ }
894
+ if (flags.fast && flags.env && ctx.syncTarget && !knowledgeOnly) {
895
+ log(ux.colorize('dim', '--env: the sync record does not track environment variables; comparing every file with the dry-run'));
896
+ }
897
+ if (flags.fast && !flags.env && ctx.syncTarget && !knowledgeOnly) {
898
+ const useExport = Boolean(flags.refresh) || !resolvedTarget || !loadedState;
899
+ if (useExport) {
900
+ let exportReason = 'No sync record for this workspace/branch; comparing against the server export';
901
+ if (flags.refresh) {
902
+ exportReason = 'Refreshing sync record from the server export';
903
+ }
904
+ else if (sandboxUntrusted) {
905
+ exportReason =
906
+ 'Sandbox changed since this directory\'s last push (reset, delete, or another directory); comparing against the sandbox export';
907
+ }
908
+ log(exportReason);
909
+ const exportParams = new URLSearchParams({
910
+ env: 'false',
911
+ include_draft: 'false',
912
+ records: flags.records.toString(),
913
+ });
914
+ if (target.supportsBranches) {
915
+ exportParams.set('branch', ctx.branch);
916
+ }
917
+ try {
918
+ const exportResponse = await verboseFetch(target.buildPushUrl(exportParams), {
919
+ headers: {
920
+ accept: 'application/json',
921
+ Authorization: `Bearer ${accessToken}`,
922
+ },
923
+ method: 'GET',
924
+ }, flags.verbose, accessToken);
925
+ if (!exportResponse.ok) {
926
+ throw new Error(`HTTP ${exportResponse.status}`);
927
+ }
928
+ const exported = parseExportDocuments(await exportResponse.text());
929
+ const compared = compareLocalToExport(allDocumentEntries.map((entry) => ({
930
+ ...entry,
931
+ relPath: toRelPath(inputDir, entry.filePath),
932
+ })), exported);
933
+ if (compared.serverOnly.length > 0) {
934
+ 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`));
935
+ }
936
+ exportRebuild = true;
937
+ const candidates = [...compared.candidates];
938
+ const identical = [];
939
+ for (const entry of compared.identical) {
940
+ if (alwaysSend(entry.content))
941
+ candidates.push(entry);
942
+ else
943
+ identical.push(entry);
944
+ }
945
+ Object.assign(pendingRecordEntries, recordEntriesFor(identical.map((entry) => entry.filePath)));
946
+ if (candidates.length === 0 && knowledgeObjects.length === 0) {
947
+ if (resolvedTarget && !flags['dry-run']) {
948
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, pendingRecordEntries, { replace: replaceRecord() }, (message) => command.warn(message));
949
+ }
950
+ log('');
951
+ log('No changes to push.');
952
+ return;
953
+ }
954
+ documentEntries = candidates;
955
+ recordSourceEntries = [...documentEntries];
956
+ multidoc = documentEntries.map((entry) => entry.content).join('\n---\n');
957
+ documentFileMap.clear();
958
+ for (const entry of documentEntries) {
959
+ const parsed = parseDocument(entry.content);
960
+ if (parsed) {
961
+ documentFileMap.set(buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup), entry.filePath);
962
+ }
963
+ }
964
+ }
965
+ catch (error) {
966
+ command.warn(`Failed to download the server export for --fast (${error.message}); falling back to a full dry-run`);
967
+ exportRebuild = false;
968
+ }
969
+ }
970
+ else if (resolvedTarget && loadedState) {
971
+ const allHashes = collectFiles(inputDir).map((filePath) => {
972
+ const content = pushedBytes.get(filePath);
973
+ return {
974
+ alwaysSend: content !== undefined && alwaysSend(content),
975
+ relPath: toRelPath(inputDir, filePath),
976
+ sha256: content === undefined ? hashFile(filePath) : hashBytes(content),
977
+ };
978
+ });
979
+ const { candidates, deletedLocally } = {
980
+ candidates: allHashes.filter((file) => {
981
+ const recorded = loadedState.files[file.relPath];
982
+ return file.alwaysSend || !recorded || recorded.sha256 !== file.sha256;
983
+ }),
984
+ deletedLocally: Object.entries(loadedState.files)
985
+ .filter(([relPath]) => !allHashes.some((file) => file.relPath === relPath))
986
+ .filter(([relPath]) => pathMatchesFilters(relPath, inputDir, flags.include, flags.exclude))
987
+ .map(([relPath, entry]) => ({ key: entry.key, relPath })),
988
+ };
989
+ if (deletedLocally.length > 0) {
990
+ log(ux.colorize('dim', `${deletedLocally.length} file${deletedLocally.length === 1 ? '' : 's'} deleted locally since last sync; use --sync --delete to remove them remotely`));
991
+ }
992
+ const candidatePaths = new Set(candidates.map((file) => file.relPath));
993
+ documentEntries = allDocumentEntries.filter((entry) => candidatePaths.has(toRelPath(inputDir, entry.filePath)));
994
+ recordSourceEntries = [...documentEntries];
995
+ if (documentEntries.length === 0 && knowledgeObjects.length === 0) {
996
+ log('No local changes since last sync');
997
+ return;
998
+ }
999
+ multidoc = documentEntries.map((entry) => entry.content).join('\n---\n');
1000
+ documentFileMap.clear();
1001
+ for (const entry of documentEntries) {
1002
+ const parsed = parseDocument(entry.content);
1003
+ if (parsed) {
1004
+ documentFileMap.set(buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup), entry.filePath);
1005
+ }
1006
+ }
1007
+ }
1008
+ }
703
1009
  // ── Resolve push mode ─────────────────────────────────────────────────
704
1010
  const isPartial = !flags.sync;
705
1011
  if (flags.delete && isPartial) {
706
1012
  command.error('Cannot use --delete without --sync');
707
1013
  }
708
1014
  const shouldDelete = isPartial ? false : flags.delete;
1015
+ const pruneMissing = shouldDelete && !knowledgeOnly;
709
1016
  // ── Build query params ────────────────────────────────────────────────
710
1017
  const queryParams = new URLSearchParams({
711
1018
  delete: shouldDelete.toString(),
@@ -806,7 +1113,7 @@ export async function executePush(ctx, target, flags) {
806
1113
  }
807
1114
  }
808
1115
  // Check for bad cross-references using dry-run operations to avoid false positives
809
- const badRefs = checkReferences(documentEntries, preview.operations);
1116
+ const badRefs = checkReferences(allDocumentEntries, preview.operations, documentEntries);
810
1117
  if (badRefs.length > 0) {
811
1118
  renderBadReferences(badRefs, log);
812
1119
  }
@@ -842,7 +1149,7 @@ export async function executePush(ctx, target, flags) {
842
1149
  // Detect local records
843
1150
  const tablesWithRecords = flags.records
844
1151
  ? documentEntries
845
- .filter((d) => /^table\s+/m.test(d.content) && /\bitems\s*=\s*\[/m.test(d.content))
1152
+ .filter((d) => isTableWithRecords(d.content))
846
1153
  .map((d) => {
847
1154
  const nameMatch = d.content.match(/^table\s+(\S+)/m);
848
1155
  const itemsMatch = d.content.match(/\bitems\s*=\s*\[([\s\S]*?)\n\s*\]/);
@@ -863,6 +1170,9 @@ export async function executePush(ctx, target, flags) {
863
1170
  if (!hasChanges && !hasLocalRecords) {
864
1171
  log('');
865
1172
  log('No changes to push.');
1173
+ if (persistRecord) {
1174
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(recordSourceEntries.map((entry) => entry.filePath)), { pruneMissing, replace: replaceRecord() }, (message) => command.warn(message));
1175
+ }
866
1176
  return;
867
1177
  }
868
1178
  if (flags['dry-run']) {
@@ -979,22 +1289,40 @@ export async function executePush(ctx, target, flags) {
979
1289
  }
980
1290
  // ── Show bad references in force mode (preview mode shows them inline) ─
981
1291
  if (flags.force && !knowledgeOnly) {
982
- const badRefs = checkReferences(documentEntries);
1292
+ const badRefs = checkReferences(allDocumentEntries, undefined, documentEntries);
983
1293
  if (badRefs.length > 0) {
984
1294
  log('');
985
1295
  renderBadReferences(badRefs, log);
986
1296
  }
987
1297
  }
988
1298
  // ── Partial push: filter to changed documents only ────────────────────
1299
+ let pushedEntries = documentEntries;
989
1300
  if (!knowledgeOnly && isPartial && dryRunPreview) {
990
- const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
991
- if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
1301
+ pushedEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
1302
+ if (pushedEntries.length === 0 && knowledgeObjects.length === 0) {
992
1303
  log('No changes to push.');
1304
+ if (persistRecord) {
1305
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(recordSourceEntries.map((entry) => entry.filePath)), { pruneMissing: false, replace: replaceRecord() }, (message) => command.warn(message));
1306
+ }
993
1307
  return;
994
1308
  }
995
- multidoc = filteredEntries.length > 0 ? filteredEntries.map((d) => d.content).join('\n---\n') : '';
1309
+ multidoc = pushedEntries.length > 0 ? pushedEntries.map((d) => d.content).join('\n---\n') : '';
996
1310
  }
1311
+ const persist = () => {
1312
+ if (!persistRecord || !ctx.syncTarget)
1313
+ return;
1314
+ const paths = [
1315
+ ...recordSourceEntries.map((entry) => entry.filePath),
1316
+ ...pushedEntries.map((entry) => entry.filePath),
1317
+ ];
1318
+ commitSyncRecordWithSandbox(ctx, inputDir, resolvedTarget, recordEntriesFor(paths), { pruneMissing, replace: replaceRecord() }, (message) => command.warn(message));
1319
+ };
997
1320
  // ── Execute the actual push ───────────────────────────────────────────
1321
+ const willMutate = (!knowledgeOnly && Boolean(multidoc)) ||
1322
+ Boolean(ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete));
1323
+ if (willMutate && !flags['dry-run']) {
1324
+ maybeInvalidateSandboxMarker(ctx, persistRecord, (message) => command.warn(message));
1325
+ }
998
1326
  const startTime = Date.now();
999
1327
  let pushedDocCount = 0;
1000
1328
  if (!knowledgeOnly && multidoc) {
@@ -1060,15 +1388,18 @@ export async function executePush(ctx, target, flags) {
1060
1388
  const updated = syncGuidToFile(filePath, entry.guid);
1061
1389
  if (updated)
1062
1390
  updatedCount++;
1391
+ updateSnapshot(pushedBytes, filePath, (content) => withGuid(content, entry.guid));
1063
1392
  }
1064
1393
  catch (error) {
1065
1394
  command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
1066
1395
  }
1067
1396
  if (entry.canonical) {
1397
+ const { canonical } = entry;
1068
1398
  try {
1069
- const updated = syncCanonicalToFile(filePath, entry.canonical);
1399
+ const updated = syncCanonicalToFile(filePath, canonical);
1070
1400
  if (updated)
1071
1401
  canonicalCount++;
1402
+ updateSnapshot(pushedBytes, filePath, (content) => withCanonical(content, canonical));
1072
1403
  }
1073
1404
  catch (error) {
1074
1405
  command.warn(`Failed to sync canonical to ${filePath}: ${error.message}`);
@@ -1091,6 +1422,36 @@ export async function executePush(ctx, target, flags) {
1091
1422
  command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
1092
1423
  }
1093
1424
  }
1425
+ // Write the marker/record before writeback. Writeback is another full export, and
1426
+ // a failure or Ctrl+C there must not drop --fast state from an already-successful push.
1427
+ persist();
1428
+ let writebackMs = 0;
1429
+ if (flags.writeback && pushedDocCount > 0) {
1430
+ const writeback = await writeBackFormattedDocuments({
1431
+ accessToken,
1432
+ branch: ctx.branch,
1433
+ command,
1434
+ includeEnv: flags.env,
1435
+ includeRecords: flags.records,
1436
+ inputDir,
1437
+ isLiveBranch: resolved.isLiveBranch,
1438
+ pushedBytes,
1439
+ pushedEntries,
1440
+ resolvedTarget,
1441
+ syncState: loadedState,
1442
+ target,
1443
+ verbose: flags.verbose,
1444
+ verboseFetch,
1445
+ workspaceId: ctx.syncTarget?.kind === 'workspace' ? ctx.syncTarget.workspace : undefined,
1446
+ writebackDiff: flags.writebackDiff,
1447
+ writebackObjectLimit: flags.writebackObjectLimit,
1448
+ });
1449
+ writebackMs = writeback.elapsedMs;
1450
+ Object.assign(writebackIds, writeback.ids);
1451
+ for (const { content, filePath } of writeback.written) {
1452
+ pushedBytes.set(filePath, content);
1453
+ }
1454
+ }
1094
1455
  // ── Push knowledge ────────────────────────────────────────────────────
1095
1456
  let knowledgeImported = 0;
1096
1457
  let knowledgeDeleted = 0;
@@ -1134,7 +1495,7 @@ export async function executePush(ctx, target, flags) {
1134
1495
  command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
1135
1496
  }
1136
1497
  }
1137
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1498
+ const elapsed = (Math.max(0, Date.now() - startTime - writebackMs) / 1000).toFixed(1);
1138
1499
  const parts = [];
1139
1500
  if (!knowledgeOnly)
1140
1501
  parts.push(`${pushedDocCount} documents`);
@@ -1144,8 +1505,198 @@ export async function executePush(ctx, target, flags) {
1144
1505
  kParts.push(`${knowledgeDeleted} deleted`);
1145
1506
  parts.push(kParts.join(', '));
1146
1507
  }
1508
+ persist();
1147
1509
  log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
1148
1510
  }
1511
+ export async function writeBackFormattedDocuments(args) {
1512
+ const started = Date.now();
1513
+ const { accessToken, branch, command, includeEnv, includeRecords, inputDir, isLiveBranch = !branch, pushedBytes, resolvedTarget, syncState, target, verbose, verboseFetch, workspaceId, writebackDiff, writebackObjectLimit, } = args;
1514
+ const log = command.log.bind(command);
1515
+ const elapsedLabel = () => ` (${((Date.now() - started) / 1000).toFixed(1)}s)`;
1516
+ const ids = {};
1517
+ const written = [];
1518
+ const done = () => ({ elapsedMs: Date.now() - started, ids, written });
1519
+ const relPathOf = (filePath) => (inputDir ? toRelPath(inputDir, filePath) : filePath);
1520
+ const localContents = new Map();
1521
+ const pushedFiles = new Map();
1522
+ const unchangedEntries = [];
1523
+ for (const entry of args.pushedEntries) {
1524
+ const parsed = parseDocument(entry.content);
1525
+ if (!parsed)
1526
+ continue;
1527
+ let local;
1528
+ try {
1529
+ local = fs.readFileSync(entry.filePath, 'utf8');
1530
+ }
1531
+ catch {
1532
+ local = entry.content;
1533
+ }
1534
+ const sent = pushedBytes?.get(entry.filePath);
1535
+ if (sent !== undefined && local !== sent) {
1536
+ command.warn(`Writeback: skipped ${relPathOf(entry.filePath)}; it changed after it was read for the push`);
1537
+ continue;
1538
+ }
1539
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
1540
+ pushedFiles.set(key, entry.filePath);
1541
+ localContents.set(entry.filePath, local);
1542
+ unchangedEntries.push(entry);
1543
+ }
1544
+ if (unchangedEntries.length === 0)
1545
+ return done();
1546
+ let exportedDocs = [];
1547
+ let pathLabel = 'export';
1548
+ if (resolvedTarget && workspaceId && writebackObjectLimit !== 0) {
1549
+ const get = async (pathname, query = {}) => {
1550
+ const params = new URLSearchParams(query);
1551
+ const origin = target.instanceOrigin.replace(/\/$/, '');
1552
+ const url = `${origin}/api:meta${pathname}${params.toString() ? `?${params.toString()}` : ''}`;
1553
+ const response = await verboseFetch(url, {
1554
+ headers: {
1555
+ accept: 'application/json',
1556
+ Authorization: `Bearer ${accessToken}`,
1557
+ },
1558
+ method: 'GET',
1559
+ }, verbose, accessToken);
1560
+ const text = await response.text();
1561
+ let json = text;
1562
+ try {
1563
+ json = JSON.parse(text);
1564
+ }
1565
+ catch {
1566
+ // keep text
1567
+ }
1568
+ return { json, ok: response.ok, status: response.status };
1569
+ };
1570
+ let fetched;
1571
+ try {
1572
+ fetched = await fetchFormattedObjects({
1573
+ get,
1574
+ isLiveBranch,
1575
+ limit: writebackObjectLimit,
1576
+ pushed: unchangedEntries.map((entry) => ({
1577
+ content: localContents.get(entry.filePath) ?? entry.content,
1578
+ filePath: entry.filePath,
1579
+ relPath: relPathOf(entry.filePath),
1580
+ })),
1581
+ state: syncState ?? null,
1582
+ target: resolvedTarget,
1583
+ workspaceId,
1584
+ });
1585
+ }
1586
+ catch (error) {
1587
+ fetched = { ok: false, reason: error.message };
1588
+ }
1589
+ if (fetched.ok) {
1590
+ Object.assign(ids, fetched.ids);
1591
+ exportedDocs = [];
1592
+ for (const result of fetched.results) {
1593
+ const parsed = parseDocument(result.content);
1594
+ if (!parsed) {
1595
+ command.warn(`Writeback: skipped ${result.filePath}; fetched document did not parse`);
1596
+ continue;
1597
+ }
1598
+ exportedDocs.push({ ...parsed, content: result.content });
1599
+ }
1600
+ pathLabel = 'per-object';
1601
+ }
1602
+ else {
1603
+ command.warn(`Per-object writeback unavailable (${fetched.reason}); using full export`);
1604
+ }
1605
+ }
1606
+ if (exportedDocs.length === 0) {
1607
+ const exportParams = new URLSearchParams({
1608
+ env: includeEnv.toString(),
1609
+ records: includeRecords.toString(),
1610
+ });
1611
+ exportParams.set('include_draft', 'false');
1612
+ if (target.supportsBranches) {
1613
+ exportParams.set('branch', branch);
1614
+ }
1615
+ const exportUrl = target.buildPushUrl(exportParams);
1616
+ let response;
1617
+ try {
1618
+ response = await verboseFetch(exportUrl, {
1619
+ headers: {
1620
+ accept: 'application/json',
1621
+ Authorization: `Bearer ${accessToken}`,
1622
+ },
1623
+ method: 'GET',
1624
+ }, verbose, accessToken);
1625
+ }
1626
+ catch (error) {
1627
+ command.warn(`Failed to fetch server-formatted documents for writeback: ${error.message}`);
1628
+ return done();
1629
+ }
1630
+ if (!response.ok) {
1631
+ command.warn(`Failed to fetch server-formatted documents for writeback (${response.status})`);
1632
+ return done();
1633
+ }
1634
+ const responseText = await response.text();
1635
+ exportedDocs = parseExportDocuments(responseText);
1636
+ pathLabel = 'export';
1637
+ if (exportedDocs.length === 0) {
1638
+ const preview = responseText.trim().slice(0, 200).replaceAll('\n', String.raw `\n`);
1639
+ command.warn(`Writeback: export contained no XanoScript documents (${pushedFiles.size} pushed). Response starts with: ${preview || '(empty)'}`);
1640
+ return done();
1641
+ }
1642
+ }
1643
+ const matcher = indexLocalDocuments(pushedFiles, localContents);
1644
+ const matched = new Set();
1645
+ for (const doc of exportedDocs) {
1646
+ const filePath = resolveMatchedPath(doc, matcher);
1647
+ if (filePath)
1648
+ matched.add(filePath);
1649
+ }
1650
+ const writebacks = selectWritebacks(exportedDocs, pushedFiles, localContents, { includeRecords });
1651
+ if (writebackDiff) {
1652
+ for (const { content, filePath } of writebacks) {
1653
+ const local = localContents.get(filePath) ?? '';
1654
+ const rel = inputDir ? relPathOf(filePath) : filePath.split(/[\\/]/).pop() || 'file.xs';
1655
+ const destBase = join(writebackDiff, rel);
1656
+ try {
1657
+ fs.mkdirSync(join(destBase, '..'), { recursive: true });
1658
+ fs.writeFileSync(`${destBase}.local.xs`, local, 'utf8');
1659
+ fs.writeFileSync(`${destBase}.server.xs`, content.trim(), 'utf8');
1660
+ }
1661
+ catch (error) {
1662
+ command.warn(`Failed to write writeback-diff for ${filePath}: ${error.message}`);
1663
+ }
1664
+ }
1665
+ }
1666
+ for (const { content, filePath } of writebacks) {
1667
+ // Same bytes `pull` writes: the trimmed document, no trailing newline.
1668
+ const bytes = content.trim();
1669
+ // Re-check right before writing: the fetch above can take minutes.
1670
+ let current = null;
1671
+ try {
1672
+ current = fs.readFileSync(filePath, 'utf8');
1673
+ }
1674
+ catch {
1675
+ // Treated as changed below.
1676
+ }
1677
+ if (current !== localContents.get(filePath)) {
1678
+ command.warn(`Writeback: skipped ${relPathOf(filePath)}; it changed after it was read for the push`);
1679
+ continue;
1680
+ }
1681
+ try {
1682
+ fs.writeFileSync(filePath, bytes, 'utf8');
1683
+ written.push({ content: bytes, filePath });
1684
+ }
1685
+ catch (error) {
1686
+ command.warn(`Failed to write formatted document to ${filePath}: ${error.message}`);
1687
+ }
1688
+ }
1689
+ if (written.length > 0) {
1690
+ log(`Wrote ${written.length} formatted file${written.length === 1 ? '' : 's'} (${pathLabel}${elapsedLabel()})`);
1691
+ return done();
1692
+ }
1693
+ if (matched.size === 0) {
1694
+ command.warn(`Writeback: fetched ${exportedDocs.length} documents, but none matched the ${pushedFiles.size} pushed file${pushedFiles.size === 1 ? '' : 's'}${elapsedLabel()}`);
1695
+ return done();
1696
+ }
1697
+ log(`Writeback: ${matched.size} pushed file${matched.size === 1 ? '' : 's'} already match the server export${elapsedLabel()}`);
1698
+ return done();
1699
+ }
1149
1700
  // ── Error Handlers ──────────────────────────────────────────────────────────
1150
1701
  /**
1151
1702
  * Turn a thrown fetch/network error into an actionable message.
@@ -1323,3 +1874,4 @@ function handlePushError(response, errorText, documentEntries, inputDir, command
1323
1874
  }
1324
1875
  command.error(errorMessage);
1325
1876
  }
1877
+ export { parseExportDocuments, preserveLocalTableRecords, selectWritebacks } from './export-compare.js';