@xano/cli 1.0.4-beta.2 → 1.0.4-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ 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 { collectKnowledgeObjects, fetchKnowledge, knowledgePreview, pushKnowledge, syncGuidToFrontmatter, toPushItems, } from './knowledge-sync.js';
6
7
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
7
8
  // Minimum total operations before a workspace mismatch is treated as worth interrupting for.
8
9
  // Small change sets (e.g., editing a single function) aren't worth a reset prompt.
@@ -140,12 +141,15 @@ export function renderBadIndexes(badIndexes, log) {
140
141
  const TYPE_LABELS = {
141
142
  addon: 'Addons',
142
143
  agent: 'Agents',
144
+ 'agents.md': 'Knowledge: agents.md',
143
145
  api_group: 'API Groups',
146
+ doc: 'Knowledge: Docs',
144
147
  function: 'Functions',
145
148
  mcp_server: 'MCP Servers',
146
149
  middleware: 'Middleware',
147
150
  query: 'API Endpoints',
148
151
  realtime_channel: 'Realtime Channels',
152
+ skill: 'Knowledge: Skills',
149
153
  table: 'Tables',
150
154
  task: 'Tasks',
151
155
  tool: 'Tools',
@@ -329,6 +333,47 @@ function syncGuidToFile(filePath, guid) {
329
333
  fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
330
334
  return true;
331
335
  }
336
+ // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
337
+ /**
338
+ * Compute a knowledge change preview: prefer the server's `dry_run` response,
339
+ * and fall back to a client-side diff (fetch current objects → compare) when the
340
+ * server doesn't support it — mirroring how multidoc tolerates a missing dry-run.
341
+ */
342
+ async function computeKnowledgePreview(listUrl, objects, branch, shouldDelete, accessToken, verboseFetch, verbose) {
343
+ const items = toPushItems(objects);
344
+ try {
345
+ const serverResult = await pushKnowledge(listUrl, accessToken, verboseFetch, verbose, {
346
+ branch,
347
+ delete: shouldDelete,
348
+ // eslint-disable-next-line camelcase -- external Metadata API field name
349
+ dry_run: true,
350
+ items,
351
+ });
352
+ if (serverResult.operations && serverResult.summary) {
353
+ return { operations: serverResult.operations, summary: serverResult.summary };
354
+ }
355
+ }
356
+ catch {
357
+ // Server doesn't support dry_run yet; fall through to local diff.
358
+ }
359
+ const serverObjects = await fetchKnowledge(listUrl, branch, accessToken, verboseFetch, verbose);
360
+ return knowledgePreview(items, serverObjects, shouldDelete);
361
+ }
362
+ /** Fold a knowledge preview's summary + operations into the multidoc DryRunResult. */
363
+ function mergeKnowledgePreview(preview, knowledge) {
364
+ for (const [type, counts] of Object.entries(knowledge.summary)) {
365
+ preview.summary[type] = {
366
+ created: counts.created,
367
+ deleted: counts.deleted,
368
+ truncated: 0,
369
+ unchanged: counts.unchanged,
370
+ updated: counts.updated,
371
+ };
372
+ }
373
+ for (const op of knowledge.operations) {
374
+ preview.operations.push({ action: op.action, details: '', name: op.name, type: op.type });
375
+ }
376
+ }
332
377
  // ── Main Push Logic ─────────────────────────────────────────────────────────
333
378
  /**
334
379
  * Execute a multidoc push with preview, validation, partial mode, and GUID sync.
@@ -337,27 +382,37 @@ function syncGuidToFile(filePath, guid) {
337
382
  export async function executePush(ctx, target, flags) {
338
383
  const { accessToken, command, inputDir, verboseFetch } = ctx;
339
384
  const log = command.log.bind(command);
340
- // ── Collect and filter files ──────────────────────────────────────────
385
+ // ── Collect knowledge entries (before file check so knowledge-only push works) ─
386
+ let knowledgeObjects = [];
387
+ if (ctx.knowledge) {
388
+ knowledgeObjects = collectKnowledgeObjects(ctx.knowledge.rootDir, flags.include, flags.exclude);
389
+ }
390
+ // ── Collect and filter .xs files ──────────────────────────────────────
341
391
  const allFiles = collectFiles(inputDir);
342
392
  const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, log);
343
- if (files.length === 0) {
393
+ const knowledgeOnly = files.length === 0 && (knowledgeObjects.length > 0 || ctx.knowledge !== undefined);
394
+ if (files.length === 0 && !knowledgeOnly) {
344
395
  command.error(flags.include || flags.exclude
345
396
  ? `No .xs files remain after ${[flags.include ? `include ${flags.include.join(', ')}` : '', flags.exclude ? `exclude ${flags.exclude.join(', ')}` : ''].filter(Boolean).join(' and ')} in ${inputDir}`
346
397
  : `No .xs files found in ${inputDir}`);
347
398
  }
348
399
  // ── Read documents ────────────────────────────────────────────────────
349
- const documentEntries = readDocuments(files);
350
- if (documentEntries.length === 0) {
351
- command.error(`All .xs files in ${inputDir} are empty`);
352
- }
353
- let multidoc = documentEntries.map((d) => d.content).join('\n---\n');
354
- // ── Build document key → file path map (for GUID writeback) ───────────
400
+ let documentEntries = [];
401
+ let multidoc = '';
355
402
  const documentFileMap = new Map();
356
- for (const entry of documentEntries) {
357
- const parsed = parseDocument(entry.content);
358
- if (parsed) {
359
- const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
360
- documentFileMap.set(key, entry.filePath);
403
+ if (!knowledgeOnly) {
404
+ documentEntries = readDocuments(files);
405
+ if (documentEntries.length === 0) {
406
+ command.error(`All .xs files in ${inputDir} are empty`);
407
+ }
408
+ multidoc = documentEntries.map((d) => d.content).join('\n---\n');
409
+ // ── Build document key → file path map (for GUID writeback) ─────────
410
+ for (const entry of documentEntries) {
411
+ const parsed = parseDocument(entry.content);
412
+ if (parsed) {
413
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
414
+ documentFileMap.set(key, entry.filePath);
415
+ }
361
416
  }
362
417
  }
363
418
  // ── Resolve push mode ─────────────────────────────────────────────────
@@ -388,7 +443,7 @@ export async function executePush(ctx, target, flags) {
388
443
  };
389
444
  // ── Dry-run / Preview ─────────────────────────────────────────────────
390
445
  let dryRunPreview = null;
391
- const dryRunUrl = target.buildDryRunUrl(queryParams);
446
+ const dryRunUrl = knowledgeOnly ? null : target.buildDryRunUrl(queryParams);
392
447
  if (dryRunUrl && (flags['dry-run'] || !flags.force)) {
393
448
  const dryRunParams = new URLSearchParams(queryParams);
394
449
  // Always request delete info in dry-run to show remote-only items
@@ -401,15 +456,16 @@ export async function executePush(ctx, target, flags) {
401
456
  headers: requestHeaders,
402
457
  method: 'POST',
403
458
  }, flags.verbose, accessToken);
404
- if (!dryRunResponse.ok) {
405
- await handleDryRunError(dryRunResponse, command, flags, target);
406
- // If we get here, the user confirmed to proceed without preview
407
- }
408
- else {
459
+ if (dryRunResponse.ok) {
409
460
  const dryRunText = await dryRunResponse.text();
410
461
  const preview = JSON.parse(dryRunText);
411
462
  dryRunPreview = preview;
412
463
  if (preview && preview.summary) {
464
+ // ── Merge knowledge preview into the combined DryRunResult ──────
465
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
466
+ const knowledgeDryRun = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
467
+ mergeKnowledgePreview(preview, knowledgeDryRun);
468
+ }
413
469
  renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log);
414
470
  // Check for bad cross-references using dry-run operations to avoid false positives
415
471
  const badRefs = checkReferences(documentEntries, preview.operations);
@@ -443,7 +499,7 @@ export async function executePush(ctx, target, flags) {
443
499
  }
444
500
  log(ux.colorize('yellow', 'Proceeding anyway due to --force flag.'));
445
501
  }
446
- // Check for actual changes
502
+ // Check for actual changes (multidoc + knowledge combined)
447
503
  const hasChanges = Object.values(preview.summary).some((c) => c.created > 0 || c.updated > 0 || (shouldDelete && c.deleted > 0) || c.truncated > 0);
448
504
  // Detect local records
449
505
  const tablesWithRecords = flags.records
@@ -531,6 +587,10 @@ export async function executePush(ctx, target, flags) {
531
587
  await confirmOrAbort(command, log);
532
588
  }
533
589
  }
590
+ else {
591
+ await handleDryRunError(dryRunResponse, command, flags, target);
592
+ // If we get here, the user confirmed to proceed without preview
593
+ }
534
594
  }
535
595
  catch (error) {
536
596
  // Ctrl+C or SIGINT
@@ -552,8 +612,39 @@ export async function executePush(ctx, target, flags) {
552
612
  await confirmOrAbort(command, log);
553
613
  }
554
614
  }
615
+ else if (knowledgeOnly && (flags['dry-run'] || !flags.force)) {
616
+ // ── Knowledge-only dry-run / preview ──────────────────────────────────
617
+ const kPreview = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
618
+ const syntheticResult = {
619
+ operations: kPreview.operations.map((op) => ({ action: op.action, details: '', name: op.name, type: op.type })),
620
+ summary: Object.fromEntries(Object.entries(kPreview.summary).map(([type, counts]) => [
621
+ type,
622
+ { created: counts.created, deleted: counts.deleted, truncated: 0, unchanged: counts.unchanged, updated: counts.updated },
623
+ ])),
624
+ };
625
+ renderPreview(syntheticResult, shouldDelete, target, flags.verbose, true, log);
626
+ const hasChanges = Object.values(syntheticResult.summary).some((c) => c.created > 0 || c.updated > 0 || (shouldDelete && c.deleted > 0));
627
+ if (!hasChanges) {
628
+ log('');
629
+ log('No changes to push.');
630
+ return;
631
+ }
632
+ if (flags['dry-run']) {
633
+ return;
634
+ }
635
+ if (process.stdin.isTTY) {
636
+ const confirmed = await confirm('Proceed with push?');
637
+ if (!confirmed) {
638
+ log('Push cancelled.');
639
+ return;
640
+ }
641
+ }
642
+ else {
643
+ command.error('Non-interactive environment detected. Use --force to skip confirmation.');
644
+ }
645
+ }
555
646
  // ── Show bad references in force mode (preview mode shows them inline) ─
556
- if (flags.force) {
647
+ if (flags.force && !knowledgeOnly) {
557
648
  const badRefs = checkReferences(documentEntries);
558
649
  if (badRefs.length > 0) {
559
650
  log('');
@@ -561,96 +652,156 @@ export async function executePush(ctx, target, flags) {
561
652
  }
562
653
  }
563
654
  // ── Partial push: filter to changed documents only ────────────────────
564
- if (isPartial && dryRunPreview) {
655
+ if (!knowledgeOnly && isPartial && dryRunPreview) {
565
656
  const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
566
- if (filteredEntries.length === 0) {
657
+ if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
567
658
  log('No changes to push.');
568
659
  return;
569
660
  }
570
- multidoc = filteredEntries.map((d) => d.content).join('\n---\n');
661
+ if (filteredEntries.length > 0) {
662
+ multidoc = filteredEntries.map((d) => d.content).join('\n---\n');
663
+ }
664
+ else {
665
+ multidoc = '';
666
+ }
571
667
  }
572
668
  // ── Execute the actual push ───────────────────────────────────────────
573
- const apiUrl = target.buildPushUrl(queryParams);
574
669
  const startTime = Date.now();
575
- try {
576
- const response = await verboseFetch(apiUrl, {
577
- body: multidoc,
578
- headers: requestHeaders,
579
- method: 'POST',
580
- }, flags.verbose, accessToken);
581
- if (!response.ok) {
582
- handlePushError(response, await response.text(), documentEntries, inputDir, command);
583
- }
584
- // Parse response for GUID map
585
- const responseText = await response.text();
586
- let guidMap = [];
587
- if (responseText && responseText !== 'null') {
588
- try {
589
- const responseJson = JSON.parse(responseText);
590
- if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
591
- guidMap = responseJson.guid_map;
592
- }
593
- }
594
- catch {
595
- if (flags.verbose) {
596
- log(`Server response is not JSON; skipping GUID sync\n${responseText}`);
597
- }
670
+ let pushedDocCount = 0;
671
+ if (!knowledgeOnly && multidoc) {
672
+ const apiUrl = target.buildPushUrl(queryParams);
673
+ try {
674
+ const response = await verboseFetch(apiUrl, {
675
+ body: multidoc,
676
+ headers: requestHeaders,
677
+ method: 'POST',
678
+ }, flags.verbose, accessToken);
679
+ if (!response.ok) {
680
+ handlePushError(response, await response.text(), documentEntries, inputDir, command);
598
681
  }
599
- }
600
- // Write GUIDs back to local files
601
- if (flags.guids && guidMap.length > 0) {
602
- const baseKeyMap = new Map();
603
- for (const [key, fp] of documentFileMap) {
604
- const baseKey = key.split(':').slice(0, 2).join(':');
605
- if (baseKeyMap.has(baseKey)) {
606
- baseKeyMap.set(baseKey, ''); // Mark as ambiguous
682
+ // Parse response for GUID map
683
+ const responseText = await response.text();
684
+ let guidMap = [];
685
+ if (responseText && responseText !== 'null') {
686
+ try {
687
+ const responseJson = JSON.parse(responseText);
688
+ if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
689
+ guidMap = responseJson.guid_map;
690
+ }
607
691
  }
608
- else {
609
- baseKeyMap.set(baseKey, fp);
692
+ catch {
693
+ if (flags.verbose) {
694
+ log('Server response is not JSON; skipping GUID sync');
695
+ }
610
696
  }
611
697
  }
612
- let updatedCount = 0;
613
- for (const entry of guidMap) {
614
- if (!entry.guid)
615
- continue;
616
- const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
617
- let filePath = documentFileMap.get(key);
618
- if (!filePath) {
619
- const baseKey = `${entry.type}:${entry.name}`;
620
- const basePath = baseKeyMap.get(baseKey);
621
- if (basePath) {
622
- filePath = basePath;
698
+ // Write GUIDs back to local files
699
+ if (flags.guids && guidMap.length > 0) {
700
+ const baseKeyMap = new Map();
701
+ for (const [key, fp] of documentFileMap) {
702
+ const baseKey = key.split(':').slice(0, 2).join(':');
703
+ if (baseKeyMap.has(baseKey)) {
704
+ baseKeyMap.set(baseKey, ''); // Mark as ambiguous
623
705
  }
624
- }
625
- if (!filePath) {
626
- if (flags.verbose) {
627
- log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
706
+ else {
707
+ baseKeyMap.set(baseKey, fp);
628
708
  }
629
- continue;
630
709
  }
631
- try {
632
- const updated = syncGuidToFile(filePath, entry.guid);
633
- if (updated)
634
- updatedCount++;
710
+ let updatedCount = 0;
711
+ for (const entry of guidMap) {
712
+ if (!entry.guid)
713
+ continue;
714
+ const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
715
+ let filePath = documentFileMap.get(key);
716
+ if (!filePath) {
717
+ const baseKey = `${entry.type}:${entry.name}`;
718
+ const basePath = baseKeyMap.get(baseKey);
719
+ if (basePath) {
720
+ filePath = basePath;
721
+ }
722
+ }
723
+ if (!filePath) {
724
+ if (flags.verbose) {
725
+ log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
726
+ }
727
+ continue;
728
+ }
729
+ try {
730
+ const updated = syncGuidToFile(filePath, entry.guid);
731
+ if (updated)
732
+ updatedCount++;
733
+ }
734
+ catch (error) {
735
+ command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
736
+ }
635
737
  }
636
- catch (error) {
637
- command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
738
+ if (updatedCount > 0) {
739
+ log(`Synced ${updatedCount} GUIDs to local files`);
638
740
  }
639
741
  }
640
- if (updatedCount > 0) {
641
- log(`Synced ${updatedCount} GUIDs to local files`);
642
- }
742
+ pushedDocCount = multidoc.split('\n---\n').length;
743
+ }
744
+ catch (error) {
745
+ if (error instanceof Error && 'oclif' in error)
746
+ throw error;
747
+ const elapsedMs = Date.now() - startTime;
748
+ command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
643
749
  }
644
750
  }
645
- catch (error) {
646
- if (error instanceof Error && 'oclif' in error)
647
- throw error;
648
- const elapsedMs = Date.now() - startTime;
649
- command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
751
+ // ── Push knowledge ────────────────────────────────────────────────────
752
+ let knowledgeImported = 0;
753
+ let knowledgeDeleted = 0;
754
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
755
+ const listUrl = ctx.knowledge.listUrl();
756
+ try {
757
+ const result = await pushKnowledge(listUrl, accessToken, verboseFetch, flags.verbose, {
758
+ branch: ctx.branch,
759
+ delete: shouldDelete,
760
+ force: false,
761
+ items: toPushItems(knowledgeObjects),
762
+ });
763
+ knowledgeImported = result.imported ?? 0;
764
+ knowledgeDeleted = result.deleted ?? 0;
765
+ // Write GUIDs back into local frontmatter, matching server entries by name.
766
+ if (flags.guids && result.guid_map && result.guid_map.length > 0) {
767
+ const fileByName = new Map(knowledgeObjects.map((o) => [o.name, o.filePath]));
768
+ let kGuidCount = 0;
769
+ for (const entry of result.guid_map) {
770
+ const filePath = entry.guid && entry.name ? fileByName.get(entry.name) : undefined;
771
+ if (!filePath)
772
+ continue;
773
+ try {
774
+ const updated = syncGuidToFrontmatter(filePath, entry.guid);
775
+ if (updated)
776
+ kGuidCount++;
777
+ }
778
+ catch (error) {
779
+ command.warn(`Failed to sync knowledge GUID to ${filePath}: ${error.message}`);
780
+ }
781
+ }
782
+ if (kGuidCount > 0) {
783
+ log(`Synced ${kGuidCount} knowledge GUIDs to local files`);
784
+ }
785
+ }
786
+ }
787
+ catch (error) {
788
+ if (error instanceof Error && 'oclif' in error)
789
+ throw error;
790
+ const elapsedMs = Date.now() - startTime;
791
+ command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
792
+ }
650
793
  }
651
794
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
652
- const pushedCount = multidoc.split('\n---\n').length;
653
- log(`Pushed ${pushedCount} documents to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
795
+ const parts = [];
796
+ if (!knowledgeOnly)
797
+ parts.push(`${pushedDocCount} documents`);
798
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
799
+ const kParts = [`${knowledgeImported} knowledge file${knowledgeImported === 1 ? '' : 's'}`];
800
+ if (shouldDelete && knowledgeDeleted > 0)
801
+ kParts.push(`${knowledgeDeleted} deleted`);
802
+ parts.push(kParts.join(', '));
803
+ }
804
+ log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
654
805
  }
655
806
  // ── Error Handlers ──────────────────────────────────────────────────────────
656
807
  /**
@@ -666,7 +817,7 @@ export async function executePush(ctx, target, flags) {
666
817
  * failing — a failure landing near a round boundary (e.g. ~300s) is a strong
667
818
  * signal of a server-side or proxy timeout rather than a local network blip.
668
819
  */
669
- function describeNetworkError(error, url, elapsedMs) {
820
+ export function describeNetworkError(error, url, elapsedMs) {
670
821
  if (!(error instanceof Error))
671
822
  return String(error);
672
823
  let host = url;