@xano/cli 1.3.2 → 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.
- package/README.md +72 -1
- package/dist/commands/function/run/index.d.ts +1 -0
- package/dist/commands/function/run/index.js +16 -2
- package/dist/commands/sandbox/delete/index.js +8 -0
- package/dist/commands/sandbox/pull/index.d.ts +7 -0
- package/dist/commands/sandbox/pull/index.js +49 -0
- package/dist/commands/sandbox/push/index.d.ts +4 -0
- package/dist/commands/sandbox/push/index.js +53 -1
- package/dist/commands/sandbox/reset/index.js +8 -0
- package/dist/commands/workspace/pull/index.d.ts +7 -0
- package/dist/commands/workspace/pull/index.js +54 -0
- package/dist/commands/workspace/push/index.d.ts +4 -0
- package/dist/commands/workspace/push/index.js +54 -1
- package/dist/utils/export-compare.d.ts +52 -0
- package/dist/utils/export-compare.js +134 -0
- package/dist/utils/local-config.d.ts +23 -0
- package/dist/utils/local-config.js +62 -2
- package/dist/utils/multidoc-push.d.ts +67 -2
- package/dist/utils/multidoc-push.js +577 -25
- package/dist/utils/object-fetch.d.ts +86 -0
- package/dist/utils/object-fetch.js +265 -0
- package/dist/utils/reference-checker.d.ts +3 -0
- package/dist/utils/reference-checker.js +3 -2
- package/dist/utils/sandbox-marker.d.ts +9 -0
- package/dist/utils/sandbox-marker.js +119 -0
- package/dist/utils/sync-state.d.ts +71 -0
- package/dist/utils/sync-state.js +353 -0
- package/oclif.manifest.json +2485 -2393
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
596
|
+
return null;
|
|
478
597
|
}
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
655
|
+
return null;
|
|
531
656
|
}
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
991
|
-
if (
|
|
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 =
|
|
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,
|
|
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';
|