@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.
- package/README.md +137 -3
- package/dist/base-command.d.ts +12 -1
- package/dist/base-command.js +36 -22
- package/dist/commands/branch/set_live/index.d.ts +14 -0
- package/dist/commands/branch/set_live/index.js +38 -0
- package/dist/commands/ephemeral/push/index.js +6 -1
- package/dist/commands/function/create/index.d.ts +15 -0
- package/dist/commands/function/create/index.js +39 -0
- package/dist/commands/function/edit/index.d.ts +15 -0
- package/dist/commands/function/edit/index.js +39 -0
- package/dist/commands/function/run/index.js +5 -11
- package/dist/commands/policy/catalogue/index.d.ts +16 -0
- package/dist/commands/policy/catalogue/index.js +60 -0
- package/dist/commands/policy/coverage/index.d.ts +17 -0
- package/dist/commands/policy/coverage/index.js +33 -0
- package/dist/commands/policy/create/index.d.ts +18 -0
- package/dist/commands/policy/create/index.js +44 -0
- package/dist/commands/policy/delete/index.d.ts +19 -0
- package/dist/commands/policy/delete/index.js +61 -0
- package/dist/commands/policy/evaluate/index.d.ts +32 -0
- package/dist/commands/policy/evaluate/index.js +71 -0
- package/dist/commands/policy/list/index.d.ts +14 -0
- package/dist/commands/policy/list/index.js +21 -0
- package/dist/commands/policy/parse/index.d.ts +19 -0
- package/dist/commands/policy/parse/index.js +18 -0
- package/dist/commands/policy/publish/index.d.ts +20 -0
- package/dist/commands/policy/publish/index.js +38 -0
- package/dist/commands/policy/runs/index.d.ts +54 -0
- package/dist/commands/policy/runs/index.js +273 -0
- package/dist/commands/policy/status/index.d.ts +21 -0
- package/dist/commands/policy/status/index.js +54 -0
- package/dist/commands/release/create/index.js +4 -0
- package/dist/commands/release/deploy/index.d.ts +19 -0
- package/dist/commands/release/deploy/index.js +68 -6
- package/dist/commands/release/import/index.js +5 -1
- package/dist/commands/release/list/index.d.ts +7 -0
- package/dist/commands/release/list/index.js +46 -9
- package/dist/commands/release/push/index.js +19 -1
- 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 +59 -2
- package/dist/commands/sandbox/reset/index.js +8 -0
- package/dist/commands/skills/pull/index.d.ts +30 -0
- package/dist/commands/skills/pull/index.js +118 -0
- package/dist/commands/tenant/create/index.d.ts +3 -0
- package/dist/commands/tenant/create/index.js +21 -0
- package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
- package/dist/commands/tenant/deploy_release/index.js +182 -4
- package/dist/commands/tenant/edit/index.d.ts +2 -0
- package/dist/commands/tenant/edit/index.js +39 -4
- package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
- package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/create/index.js +108 -0
- package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
- package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
- package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
- package/dist/commands/tenant_deploy_request/get/index.js +113 -0
- package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/list/index.js +122 -0
- package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
- package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
- package/dist/commands/workspace/pull/index.d.ts +17 -0
- package/dist/commands/workspace/pull/index.js +124 -7
- package/dist/commands/workspace/push/index.d.ts +23 -0
- package/dist/commands/workspace/push/index.js +188 -6
- package/dist/policy-command.d.ts +91 -0
- package/dist/policy-command.js +95 -0
- package/dist/utils/document-parser.d.ts +10 -0
- package/dist/utils/document-parser.js +21 -0
- 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 +124 -4
- package/dist/utils/multidoc-push.js +670 -49
- package/dist/utils/object-fetch.d.ts +86 -0
- package/dist/utils/object-fetch.js +265 -0
- package/dist/utils/policy/catalogue.d.ts +85 -0
- package/dist/utils/policy/catalogue.js +196 -0
- package/dist/utils/policy/coverage.d.ts +19 -0
- package/dist/utils/policy/coverage.js +93 -0
- package/dist/utils/policy/errors.d.ts +20 -0
- package/dist/utils/policy/errors.js +93 -0
- package/dist/utils/policy/feedback.d.ts +90 -0
- package/dist/utils/policy/feedback.js +210 -0
- package/dist/utils/policy/findings.d.ts +29 -0
- package/dist/utils/policy/findings.js +82 -0
- package/dist/utils/policy/gate.d.ts +138 -0
- package/dist/utils/policy/gate.js +193 -0
- package/dist/utils/policy/permission.d.ts +7 -0
- package/dist/utils/policy/permission.js +55 -0
- package/dist/utils/policy/push-gate.d.ts +5 -0
- package/dist/utils/policy/push-gate.js +43 -0
- package/dist/utils/policy/release.d.ts +85 -0
- package/dist/utils/policy/release.js +117 -0
- package/dist/utils/policy/request.d.ts +55 -0
- package/dist/utils/policy/request.js +119 -0
- package/dist/utils/policy/runs.d.ts +30 -0
- package/dist/utils/policy/runs.js +101 -0
- package/dist/utils/policy/status.d.ts +55 -0
- package/dist/utils/policy/status.js +127 -0
- package/dist/utils/policy/template.d.ts +66 -0
- package/dist/utils/policy/template.js +152 -0
- package/dist/utils/policy/types.d.ts +295 -0
- package/dist/utils/policy/types.js +1 -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 +6472 -3181
- 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
|
|
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
|
|
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
|
|
608
|
+
return null;
|
|
478
609
|
}
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
667
|
+
return null;
|
|
531
668
|
}
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
991
|
-
if (
|
|
1316
|
+
sent = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
|
|
1317
|
+
if (sent.length === 0 && knowledgeObjects.length === 0) {
|
|
992
1318
|
log('No changes to push.');
|
|
993
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1271
|
-
|
|
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';
|