@xano/cli 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +107 -4
  2. package/dist/base-command.d.ts +27 -0
  3. package/dist/base-command.js +124 -1
  4. package/dist/commands/auth/index.d.ts +45 -1
  5. package/dist/commands/auth/index.js +197 -29
  6. package/dist/commands/function/run/index.d.ts +40 -0
  7. package/dist/commands/function/run/index.js +244 -0
  8. package/dist/commands/knowledge/{pull → get}/index.d.ts +6 -2
  9. package/dist/commands/knowledge/get/index.js +126 -0
  10. package/dist/commands/knowledge/{push → list}/index.d.ts +6 -8
  11. package/dist/commands/knowledge/list/index.js +135 -0
  12. package/dist/commands/sandbox/pull/index.js +12 -2
  13. package/dist/commands/sandbox/push/index.d.ts +1 -1
  14. package/dist/commands/sandbox/push/index.js +19 -14
  15. package/dist/commands/sandbox/reset/index.js +12 -0
  16. package/dist/commands/static_host/build/create/index.d.ts +9 -1
  17. package/dist/commands/static_host/build/create/index.js +54 -4
  18. package/dist/commands/static_host/build/delete/index.d.ts +19 -0
  19. package/dist/commands/static_host/build/delete/index.js +114 -0
  20. package/dist/commands/static_host/build/get/index.d.ts +1 -1
  21. package/dist/commands/static_host/build/get/index.js +16 -10
  22. package/dist/commands/static_host/build/pull/index.d.ts +52 -0
  23. package/dist/commands/static_host/build/pull/index.js +300 -0
  24. package/dist/commands/static_host/build/push/index.d.ts +23 -0
  25. package/dist/commands/static_host/build/push/index.js +228 -0
  26. package/dist/commands/static_host/create/index.d.ts +17 -0
  27. package/dist/commands/static_host/create/index.js +86 -0
  28. package/dist/commands/static_host/deploy/index.d.ts +18 -0
  29. package/dist/commands/static_host/deploy/index.js +105 -0
  30. package/dist/commands/static_host/edit/index.d.ts +23 -0
  31. package/dist/commands/static_host/edit/index.js +151 -0
  32. package/dist/commands/static_host/get/index.d.ts +18 -0
  33. package/dist/commands/static_host/get/index.js +94 -0
  34. package/dist/commands/static_host/migrate/index.d.ts +44 -0
  35. package/dist/commands/static_host/migrate/index.js +205 -0
  36. package/dist/commands/tenant/snapshot/create/index.d.ts +17 -0
  37. package/dist/commands/tenant/snapshot/create/index.js +78 -0
  38. package/dist/commands/tenant/snapshot/delete/index.d.ts +19 -0
  39. package/dist/commands/tenant/snapshot/delete/index.js +102 -0
  40. package/dist/commands/tenant/snapshot/list/index.d.ts +16 -0
  41. package/dist/commands/tenant/snapshot/list/index.js +96 -0
  42. package/dist/commands/tenant/snapshot/swap/index.d.ts +19 -0
  43. package/dist/commands/tenant/snapshot/swap/index.js +103 -0
  44. package/dist/commands/tenant/unit_test/run/index.d.ts +1 -0
  45. package/dist/commands/tenant/unit_test/run/index.js +7 -0
  46. package/dist/commands/tenant/unit_test/run_all/index.js +7 -3
  47. package/dist/commands/unit_test/run/index.d.ts +1 -0
  48. package/dist/commands/unit_test/run/index.js +8 -0
  49. package/dist/commands/unit_test/run_all/index.js +7 -3
  50. package/dist/commands/workspace/pull/index.js +13 -2
  51. package/dist/commands/workspace/push/index.d.ts +1 -1
  52. package/dist/commands/workspace/push/index.js +15 -5
  53. package/dist/utils/function-input.d.ts +77 -0
  54. package/dist/utils/function-input.js +148 -0
  55. package/dist/utils/knowledge-sync.d.ts +84 -79
  56. package/dist/utils/knowledge-sync.js +376 -323
  57. package/dist/utils/multidoc-push.d.ts +30 -18
  58. package/dist/utils/multidoc-push.js +284 -148
  59. package/dist/utils/reference-checker.js +2 -2
  60. package/dist/utils/static-host-files.d.ts +21 -0
  61. package/dist/utils/static-host-files.js +41 -0
  62. package/oclif.manifest.json +4574 -3045
  63. package/package.json +7 -1
  64. package/dist/commands/knowledge/pull/index.js +0 -86
  65. package/dist/commands/knowledge/push/index.js +0 -126
@@ -25,35 +25,37 @@ export interface PushTarget {
25
25
  instanceOrigin: string;
26
26
  /** Human-readable label for log messages (e.g., "sandbox environment", "workspace 40") */
27
27
  label: string;
28
+ /**
29
+ * The id of the source workspace being pushed (from the active profile). Sent to the sandbox
30
+ * so the backend can flag a mismatch when it differs from the workspace the sandbox last held.
31
+ */
32
+ sourceWorkspaceId?: string;
28
33
  /** Does this target support branches? */
29
34
  supportsBranches: boolean;
30
35
  /** Does this target support the partial query param? */
31
36
  supportsPartial: boolean;
32
37
  /**
33
- * Warn when the workspace embedded in the local push differs from the workspace currently
34
- * loaded on the target (per dry-run). Used by sandbox push because the sandbox is shared
35
- * across workspaces and pushing onto a different workspace can leave stale state behind.
38
+ * Warn when the sandbox currently holds a different source workspace than the one being pushed
39
+ * (per the dry-run's `source_workspace_mismatch` flag). Used by sandbox push because the sandbox
40
+ * is shared across workspaces and pushing onto a different one can leave stale state behind.
36
41
  */
37
42
  warnOnWorkspaceMismatch?: boolean;
38
43
  }
44
+ export interface KnowledgeConfig {
45
+ /** Build the knowledge list URL (no query params). */
46
+ listUrl: () => string;
47
+ /** Root directory containing the `knowledge/` folder. */
48
+ rootDir: string;
49
+ }
39
50
  export interface PushContext {
40
51
  accessToken: string;
41
52
  branch: string;
42
53
  command: Command;
43
54
  inputDir: string;
55
+ /** Optional knowledge sync config. Only workspace push sets this. */
56
+ knowledge?: KnowledgeConfig;
44
57
  verboseFetch: (url: string, options: RequestInit, verbose: boolean, authToken?: string) => Promise<Response>;
45
58
  }
46
- export declare const WORKSPACE_MISMATCH_THRESHOLD = 10;
47
- /**
48
- * Sum all impactful operations in a dry-run summary. `deleted` is only counted when the
49
- * caller actually intends to apply deletions (sync mode), matching what the user will see.
50
- */
51
- export declare function countSummaryChanges(summary: Record<string, {
52
- created: number;
53
- deleted: number;
54
- truncated: number;
55
- updated: number;
56
- }>, shouldDelete: boolean): number;
57
59
  /**
58
60
  * Filter document entries down to the ones the dry-run preview reports as changed,
59
61
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -97,13 +99,23 @@ export declare function readDocuments(files: string[]): Array<{
97
99
  }>;
98
100
  export declare function renderBadReferences(badRefs: BadReference[], log: (msg: string) => void): void;
99
101
  export declare function renderBadIndexes(badIndexes: BadIndex[], log: (msg: string) => void): void;
100
- export declare function findLocalWorkspaceName(entries: Array<{
101
- content: string;
102
- filePath: string;
103
- }>): null | string;
104
102
  export declare function confirm(message: string): Promise<boolean>;
105
103
  /**
106
104
  * Execute a multidoc push with preview, validation, partial mode, and GUID sync.
107
105
  * Shared by both sandbox:push and workspace:push commands.
108
106
  */
109
107
  export declare function executePush(ctx: PushContext, target: PushTarget, flags: PushFlags): Promise<void>;
108
+ /**
109
+ * Turn a thrown fetch/network error into an actionable message.
110
+ *
111
+ * Node's native fetch throws a TypeError with the unhelpful message "fetch
112
+ * failed" for all transport-level failures (DNS, connection refused, TLS,
113
+ * resets, timeouts). The real reason lives in `error.cause` as a system error
114
+ * with a `code` (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, etc.). This unwraps it so
115
+ * the user sees what actually went wrong and where.
116
+ *
117
+ * `elapsedMs` is appended so the user can see how long the request ran before
118
+ * failing — a failure landing near a round boundary (e.g. ~300s) is a strong
119
+ * signal of a server-side or proxy timeout rather than a local network blip.
120
+ */
121
+ export declare function describeNetworkError(error: unknown, url: string, elapsedMs?: number): string;
@@ -3,17 +3,8 @@ 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
- // Minimum total operations before a workspace mismatch is treated as worth interrupting for.
8
- // Small change sets (e.g., editing a single function) aren't worth a reset prompt.
9
- export const WORKSPACE_MISMATCH_THRESHOLD = 10;
10
- /**
11
- * Sum all impactful operations in a dry-run summary. `deleted` is only counted when the
12
- * caller actually intends to apply deletions (sync mode), matching what the user will see.
13
- */
14
- export function countSummaryChanges(summary, shouldDelete) {
15
- return Object.values(summary).reduce((sum, c) => sum + c.created + c.updated + (shouldDelete ? c.deleted : 0) + c.truncated, 0);
16
- }
17
8
  /**
18
9
  * Filter document entries down to the ones the dry-run preview reports as changed,
19
10
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -140,12 +131,15 @@ export function renderBadIndexes(badIndexes, log) {
140
131
  const TYPE_LABELS = {
141
132
  addon: 'Addons',
142
133
  agent: 'Agents',
134
+ 'agents.md': 'Knowledge: agents.md',
143
135
  api_group: 'API Groups',
136
+ doc: 'Knowledge: Docs',
144
137
  function: 'Functions',
145
138
  mcp_server: 'MCP Servers',
146
139
  middleware: 'Middleware',
147
140
  query: 'API Endpoints',
148
141
  realtime_channel: 'Realtime Channels',
142
+ skill: 'Knowledge: Skills',
149
143
  table: 'Tables',
150
144
  task: 'Tasks',
151
145
  tool: 'Tools',
@@ -259,14 +253,6 @@ function renderPreview(result, willDelete, target, verbose, partial, log) {
259
253
  }
260
254
  log('');
261
255
  }
262
- export function findLocalWorkspaceName(entries) {
263
- for (const entry of entries) {
264
- const parsed = parseDocument(entry.content);
265
- if (parsed?.type === 'workspace')
266
- return parsed.name;
267
- }
268
- return null;
269
- }
270
256
  // ── Confirmation ────────────────────────────────────────────────────────────
271
257
  export async function confirm(message) {
272
258
  const readline = await import('node:readline');
@@ -329,6 +315,47 @@ function syncGuidToFile(filePath, guid) {
329
315
  fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
330
316
  return true;
331
317
  }
318
+ // ── Knowledge Preview Helpers ─────────────────────────────────────────────────
319
+ /**
320
+ * Compute a knowledge change preview: prefer the server's `dry_run` response,
321
+ * and fall back to a client-side diff (fetch current objects → compare) when the
322
+ * server doesn't support it — mirroring how multidoc tolerates a missing dry-run.
323
+ */
324
+ async function computeKnowledgePreview(listUrl, objects, branch, shouldDelete, accessToken, verboseFetch, verbose) {
325
+ const items = toPushItems(objects);
326
+ try {
327
+ const serverResult = await pushKnowledge(listUrl, accessToken, verboseFetch, verbose, {
328
+ branch,
329
+ delete: shouldDelete,
330
+ // eslint-disable-next-line camelcase -- external Metadata API field name
331
+ dry_run: true,
332
+ items,
333
+ });
334
+ if (serverResult.operations && serverResult.summary) {
335
+ return { operations: serverResult.operations, summary: serverResult.summary };
336
+ }
337
+ }
338
+ catch {
339
+ // Server doesn't support dry_run yet; fall through to local diff.
340
+ }
341
+ const serverObjects = await fetchKnowledge(listUrl, branch, accessToken, verboseFetch, verbose);
342
+ return knowledgePreview(items, serverObjects, shouldDelete);
343
+ }
344
+ /** Fold a knowledge preview's summary + operations into the multidoc DryRunResult. */
345
+ function mergeKnowledgePreview(preview, knowledge) {
346
+ for (const [type, counts] of Object.entries(knowledge.summary)) {
347
+ preview.summary[type] = {
348
+ created: counts.created,
349
+ deleted: counts.deleted,
350
+ truncated: 0,
351
+ unchanged: counts.unchanged,
352
+ updated: counts.updated,
353
+ };
354
+ }
355
+ for (const op of knowledge.operations) {
356
+ preview.operations.push({ action: op.action, details: '', name: op.name, type: op.type });
357
+ }
358
+ }
332
359
  // ── Main Push Logic ─────────────────────────────────────────────────────────
333
360
  /**
334
361
  * Execute a multidoc push with preview, validation, partial mode, and GUID sync.
@@ -337,27 +364,37 @@ function syncGuidToFile(filePath, guid) {
337
364
  export async function executePush(ctx, target, flags) {
338
365
  const { accessToken, command, inputDir, verboseFetch } = ctx;
339
366
  const log = command.log.bind(command);
340
- // ── Collect and filter files ──────────────────────────────────────────
367
+ // ── Collect knowledge entries (before file check so knowledge-only push works) ─
368
+ let knowledgeObjects = [];
369
+ if (ctx.knowledge) {
370
+ knowledgeObjects = collectKnowledgeObjects(ctx.knowledge.rootDir, flags.include, flags.exclude);
371
+ }
372
+ // ── Collect and filter .xs files ──────────────────────────────────────
341
373
  const allFiles = collectFiles(inputDir);
342
374
  const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, log);
343
- if (files.length === 0) {
375
+ const knowledgeOnly = files.length === 0 && (knowledgeObjects.length > 0 || ctx.knowledge !== undefined);
376
+ if (files.length === 0 && !knowledgeOnly) {
344
377
  command.error(flags.include || flags.exclude
345
378
  ? `No .xs files remain after ${[flags.include ? `include ${flags.include.join(', ')}` : '', flags.exclude ? `exclude ${flags.exclude.join(', ')}` : ''].filter(Boolean).join(' and ')} in ${inputDir}`
346
379
  : `No .xs files found in ${inputDir}`);
347
380
  }
348
381
  // ── 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) ───────────
382
+ let documentEntries = [];
383
+ let multidoc = '';
355
384
  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);
385
+ if (!knowledgeOnly) {
386
+ documentEntries = readDocuments(files);
387
+ if (documentEntries.length === 0) {
388
+ command.error(`All .xs files in ${inputDir} are empty`);
389
+ }
390
+ multidoc = documentEntries.map((d) => d.content).join('\n---\n');
391
+ // ── Build document key → file path map (for GUID writeback) ─────────
392
+ for (const entry of documentEntries) {
393
+ const parsed = parseDocument(entry.content);
394
+ if (parsed) {
395
+ const key = buildDocumentKey(parsed.type, parsed.name, parsed.verb, parsed.apiGroup);
396
+ documentFileMap.set(key, entry.filePath);
397
+ }
361
398
  }
362
399
  }
363
400
  // ── Resolve push mode ─────────────────────────────────────────────────
@@ -380,6 +417,9 @@ export async function executePush(ctx, target, flags) {
380
417
  if (target.supportsPartial) {
381
418
  queryParams.set('partial', isPartial.toString());
382
419
  }
420
+ if (target.sourceWorkspaceId) {
421
+ queryParams.set('source_workspace_id', target.sourceWorkspaceId);
422
+ }
383
423
  // ── Request headers ───────────────────────────────────────────────────
384
424
  const requestHeaders = {
385
425
  accept: 'application/json',
@@ -388,7 +428,7 @@ export async function executePush(ctx, target, flags) {
388
428
  };
389
429
  // ── Dry-run / Preview ─────────────────────────────────────────────────
390
430
  let dryRunPreview = null;
391
- const dryRunUrl = target.buildDryRunUrl(queryParams);
431
+ const dryRunUrl = knowledgeOnly ? null : target.buildDryRunUrl(queryParams);
392
432
  if (dryRunUrl && (flags['dry-run'] || !flags.force)) {
393
433
  const dryRunParams = new URLSearchParams(queryParams);
394
434
  // Always request delete info in dry-run to show remote-only items
@@ -401,15 +441,16 @@ export async function executePush(ctx, target, flags) {
401
441
  headers: requestHeaders,
402
442
  method: 'POST',
403
443
  }, 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 {
444
+ if (dryRunResponse.ok) {
409
445
  const dryRunText = await dryRunResponse.text();
410
446
  const preview = JSON.parse(dryRunText);
411
447
  dryRunPreview = preview;
412
448
  if (preview && preview.summary) {
449
+ // ── Merge knowledge preview into the combined DryRunResult ──────
450
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
451
+ const knowledgeDryRun = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
452
+ mergeKnowledgePreview(preview, knowledgeDryRun);
453
+ }
413
454
  renderPreview(preview, shouldDelete, target, flags.verbose, isPartial, log);
414
455
  // Check for bad cross-references using dry-run operations to avoid false positives
415
456
  const badRefs = checkReferences(documentEntries, preview.operations);
@@ -443,7 +484,7 @@ export async function executePush(ctx, target, flags) {
443
484
  }
444
485
  log(ux.colorize('yellow', 'Proceeding anyway due to --force flag.'));
445
486
  }
446
- // Check for actual changes
487
+ // Check for actual changes (multidoc + knowledge combined)
447
488
  const hasChanges = Object.values(preview.summary).some((c) => c.created > 0 || c.updated > 0 || (shouldDelete && c.deleted > 0) || c.truncated > 0);
448
489
  // Detect local records
449
490
  const tablesWithRecords = flags.records
@@ -474,49 +515,49 @@ export async function executePush(ctx, target, flags) {
474
515
  if (flags['dry-run']) {
475
516
  return;
476
517
  }
477
- // Warn when the sandbox currently holds a different workspace than the one being
478
- // pushed and the change set is large enough that stale state is a real risk.
479
- if (target.warnOnWorkspaceMismatch && preview.workspace_name) {
480
- const localWorkspaceName = findLocalWorkspaceName(documentEntries);
481
- const totalChanges = countSummaryChanges(preview.summary, shouldDelete);
482
- if (localWorkspaceName &&
483
- localWorkspaceName !== preview.workspace_name &&
484
- totalChanges >= WORKSPACE_MISMATCH_THRESHOLD) {
485
- log('');
486
- log(ux.colorize('yellow', ux.colorize('bold', '=== Workspace Mismatch ===')));
487
- log('');
488
- log(ux.colorize('yellow', `Sandbox currently holds workspace "${preview.workspace_name}", but you're pushing "${localWorkspaceName}" with ${totalChanges} changes.`));
489
- log(ux.colorize('yellow', 'Pushing on top of a different workspace can leave stale data behind. Run `xano sandbox reset` first to start clean.'));
490
- log('');
491
- if (process.stdin.isTTY) {
492
- const proceed = await confirm('Continue with push anyway?');
493
- if (!proceed) {
494
- log('Push cancelled. Run `xano sandbox reset` then retry.');
495
- return;
496
- }
497
- }
498
- else {
499
- command.error('Workspace mismatch detected in non-interactive mode. Run `xano sandbox reset` first to start clean.');
518
+ // Warn when the sandbox currently holds a different source workspace than the one being
519
+ // pushed. The backend compares the source workspace id we sent against the id stored on
520
+ // the sandbox from its last push, so this is reliable regardless of whether the push
521
+ // includes a workspace-settings document (unlike the old name comparison).
522
+ let mismatchConfirmed = false;
523
+ if (target.warnOnWorkspaceMismatch && preview.source_workspace_mismatch) {
524
+ log('');
525
+ log(ux.colorize('yellow', ux.colorize('bold', '=== Workspace Mismatch ===')));
526
+ log('');
527
+ log(ux.colorize('yellow', "This sandbox currently holds a different workspace than the one you're pushing."));
528
+ log(ux.colorize('yellow', 'Pushing on top of it can leave stale data behind. Run `xano sandbox reset` first to start clean.'));
529
+ log('');
530
+ if (process.stdin.isTTY) {
531
+ const proceed = await confirm('Continue with push anyway?');
532
+ if (!proceed) {
533
+ log('Push cancelled. Run `xano sandbox reset` then retry.');
534
+ return;
500
535
  }
536
+ mismatchConfirmed = true;
501
537
  }
502
- }
503
- // Confirm with user
504
- const hasDestructive = preview.operations.some((op) => (shouldDelete && (op.action === 'delete' || op.action === 'cascade_delete')) ||
505
- op.action === 'truncate' ||
506
- op.action === 'drop_field' ||
507
- op.action === 'alter_field');
508
- const message = hasDestructive
509
- ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
510
- : 'Proceed with push?';
511
- if (process.stdin.isTTY) {
512
- const confirmed = await confirm(message);
513
- if (!confirmed) {
514
- log('Push cancelled.');
515
- return;
538
+ else {
539
+ command.error('Workspace mismatch detected in non-interactive mode. Run `xano sandbox reset` first to start clean.');
516
540
  }
517
541
  }
518
- else {
519
- command.error('Non-interactive environment detected. Use --force to skip confirmation.');
542
+ // Confirm with user (skip if workspace mismatch prompt already obtained confirmation)
543
+ if (!mismatchConfirmed) {
544
+ const hasDestructive = preview.operations.some((op) => (shouldDelete && (op.action === 'delete' || op.action === 'cascade_delete')) ||
545
+ op.action === 'truncate' ||
546
+ op.action === 'drop_field' ||
547
+ op.action === 'alter_field');
548
+ const message = hasDestructive
549
+ ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
550
+ : 'Proceed with push?';
551
+ if (process.stdin.isTTY) {
552
+ const confirmed = await confirm(message);
553
+ if (!confirmed) {
554
+ log('Push cancelled.');
555
+ return;
556
+ }
557
+ }
558
+ else {
559
+ command.error('Non-interactive environment detected. Use --force to skip confirmation.');
560
+ }
520
561
  }
521
562
  }
522
563
  else {
@@ -527,6 +568,10 @@ export async function executePush(ctx, target, flags) {
527
568
  await confirmOrAbort(command, log);
528
569
  }
529
570
  }
571
+ else {
572
+ await handleDryRunError(dryRunResponse, command, flags, target);
573
+ // If we get here, the user confirmed to proceed without preview
574
+ }
530
575
  }
531
576
  catch (error) {
532
577
  // Ctrl+C or SIGINT
@@ -548,8 +593,39 @@ export async function executePush(ctx, target, flags) {
548
593
  await confirmOrAbort(command, log);
549
594
  }
550
595
  }
596
+ else if (knowledgeOnly && (flags['dry-run'] || !flags.force)) {
597
+ // ── Knowledge-only dry-run / preview ──────────────────────────────────
598
+ const kPreview = await computeKnowledgePreview(ctx.knowledge.listUrl(), knowledgeObjects, ctx.branch, shouldDelete, accessToken, verboseFetch, flags.verbose);
599
+ const syntheticResult = {
600
+ operations: kPreview.operations.map((op) => ({ action: op.action, details: '', name: op.name, type: op.type })),
601
+ summary: Object.fromEntries(Object.entries(kPreview.summary).map(([type, counts]) => [
602
+ type,
603
+ { created: counts.created, deleted: counts.deleted, truncated: 0, unchanged: counts.unchanged, updated: counts.updated },
604
+ ])),
605
+ };
606
+ renderPreview(syntheticResult, shouldDelete, target, flags.verbose, true, log);
607
+ const hasChanges = Object.values(syntheticResult.summary).some((c) => c.created > 0 || c.updated > 0 || (shouldDelete && c.deleted > 0));
608
+ if (!hasChanges) {
609
+ log('');
610
+ log('No changes to push.');
611
+ return;
612
+ }
613
+ if (flags['dry-run']) {
614
+ return;
615
+ }
616
+ if (process.stdin.isTTY) {
617
+ const confirmed = await confirm('Proceed with push?');
618
+ if (!confirmed) {
619
+ log('Push cancelled.');
620
+ return;
621
+ }
622
+ }
623
+ else {
624
+ command.error('Non-interactive environment detected. Use --force to skip confirmation.');
625
+ }
626
+ }
551
627
  // ── Show bad references in force mode (preview mode shows them inline) ─
552
- if (flags.force) {
628
+ if (flags.force && !knowledgeOnly) {
553
629
  const badRefs = checkReferences(documentEntries);
554
630
  if (badRefs.length > 0) {
555
631
  log('');
@@ -557,96 +633,156 @@ export async function executePush(ctx, target, flags) {
557
633
  }
558
634
  }
559
635
  // ── Partial push: filter to changed documents only ────────────────────
560
- if (isPartial && dryRunPreview) {
636
+ if (!knowledgeOnly && isPartial && dryRunPreview) {
561
637
  const filteredEntries = filterChangedEntries(documentEntries, dryRunPreview.operations, flags.records);
562
- if (filteredEntries.length === 0) {
638
+ if (filteredEntries.length === 0 && knowledgeObjects.length === 0) {
563
639
  log('No changes to push.');
564
640
  return;
565
641
  }
566
- multidoc = filteredEntries.map((d) => d.content).join('\n---\n');
642
+ if (filteredEntries.length > 0) {
643
+ multidoc = filteredEntries.map((d) => d.content).join('\n---\n');
644
+ }
645
+ else {
646
+ multidoc = '';
647
+ }
567
648
  }
568
649
  // ── Execute the actual push ───────────────────────────────────────────
569
- const apiUrl = target.buildPushUrl(queryParams);
570
650
  const startTime = Date.now();
571
- try {
572
- const response = await verboseFetch(apiUrl, {
573
- body: multidoc,
574
- headers: requestHeaders,
575
- method: 'POST',
576
- }, flags.verbose, accessToken);
577
- if (!response.ok) {
578
- handlePushError(response, await response.text(), documentEntries, inputDir, command);
579
- }
580
- // Parse response for GUID map
581
- const responseText = await response.text();
582
- let guidMap = [];
583
- if (responseText && responseText !== 'null') {
584
- try {
585
- const responseJson = JSON.parse(responseText);
586
- if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
587
- guidMap = responseJson.guid_map;
588
- }
589
- }
590
- catch {
591
- if (flags.verbose) {
592
- log('Server response is not JSON; skipping GUID sync');
593
- }
651
+ let pushedDocCount = 0;
652
+ if (!knowledgeOnly && multidoc) {
653
+ const apiUrl = target.buildPushUrl(queryParams);
654
+ try {
655
+ const response = await verboseFetch(apiUrl, {
656
+ body: multidoc,
657
+ headers: requestHeaders,
658
+ method: 'POST',
659
+ }, flags.verbose, accessToken);
660
+ if (!response.ok) {
661
+ handlePushError(response, await response.text(), documentEntries, inputDir, command);
594
662
  }
595
- }
596
- // Write GUIDs back to local files
597
- if (flags.guids && guidMap.length > 0) {
598
- const baseKeyMap = new Map();
599
- for (const [key, fp] of documentFileMap) {
600
- const baseKey = key.split(':').slice(0, 2).join(':');
601
- if (baseKeyMap.has(baseKey)) {
602
- baseKeyMap.set(baseKey, ''); // Mark as ambiguous
663
+ // Parse response for GUID map
664
+ const responseText = await response.text();
665
+ let guidMap = [];
666
+ if (responseText && responseText !== 'null') {
667
+ try {
668
+ const responseJson = JSON.parse(responseText);
669
+ if (responseJson?.guid_map && Array.isArray(responseJson.guid_map)) {
670
+ guidMap = responseJson.guid_map;
671
+ }
603
672
  }
604
- else {
605
- baseKeyMap.set(baseKey, fp);
673
+ catch {
674
+ if (flags.verbose) {
675
+ log('Server response is not JSON; skipping GUID sync');
676
+ }
606
677
  }
607
678
  }
608
- let updatedCount = 0;
609
- for (const entry of guidMap) {
610
- if (!entry.guid)
611
- continue;
612
- const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
613
- let filePath = documentFileMap.get(key);
614
- if (!filePath) {
615
- const baseKey = `${entry.type}:${entry.name}`;
616
- const basePath = baseKeyMap.get(baseKey);
617
- if (basePath) {
618
- filePath = basePath;
679
+ // Write GUIDs back to local files
680
+ if (flags.guids && guidMap.length > 0) {
681
+ const baseKeyMap = new Map();
682
+ for (const [key, fp] of documentFileMap) {
683
+ const baseKey = key.split(':').slice(0, 2).join(':');
684
+ if (baseKeyMap.has(baseKey)) {
685
+ baseKeyMap.set(baseKey, ''); // Mark as ambiguous
619
686
  }
620
- }
621
- if (!filePath) {
622
- if (flags.verbose) {
623
- log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
687
+ else {
688
+ baseKeyMap.set(baseKey, fp);
624
689
  }
625
- continue;
626
690
  }
627
- try {
628
- const updated = syncGuidToFile(filePath, entry.guid);
629
- if (updated)
630
- updatedCount++;
691
+ let updatedCount = 0;
692
+ for (const entry of guidMap) {
693
+ if (!entry.guid)
694
+ continue;
695
+ const key = buildDocumentKey(entry.type, entry.name, entry.verb, entry.api_group);
696
+ let filePath = documentFileMap.get(key);
697
+ if (!filePath) {
698
+ const baseKey = `${entry.type}:${entry.name}`;
699
+ const basePath = baseKeyMap.get(baseKey);
700
+ if (basePath) {
701
+ filePath = basePath;
702
+ }
703
+ }
704
+ if (!filePath) {
705
+ if (flags.verbose) {
706
+ log(` No local file found for ${entry.type} "${entry.name}", skipping GUID sync`);
707
+ }
708
+ continue;
709
+ }
710
+ try {
711
+ const updated = syncGuidToFile(filePath, entry.guid);
712
+ if (updated)
713
+ updatedCount++;
714
+ }
715
+ catch (error) {
716
+ command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
717
+ }
631
718
  }
632
- catch (error) {
633
- command.warn(`Failed to sync GUID to ${filePath}: ${error.message}`);
719
+ if (updatedCount > 0) {
720
+ log(`Synced ${updatedCount} GUIDs to local files`);
634
721
  }
635
722
  }
636
- if (updatedCount > 0) {
637
- log(`Synced ${updatedCount} GUIDs to local files`);
638
- }
723
+ pushedDocCount = multidoc.split('\n---\n').length;
724
+ }
725
+ catch (error) {
726
+ if (error instanceof Error && 'oclif' in error)
727
+ throw error;
728
+ const elapsedMs = Date.now() - startTime;
729
+ command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
639
730
  }
640
731
  }
641
- catch (error) {
642
- if (error instanceof Error && 'oclif' in error)
643
- throw error;
644
- const elapsedMs = Date.now() - startTime;
645
- command.error(`Failed to push multidoc: ${describeNetworkError(error, apiUrl, elapsedMs)}`);
732
+ // ── Push knowledge ────────────────────────────────────────────────────
733
+ let knowledgeImported = 0;
734
+ let knowledgeDeleted = 0;
735
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
736
+ const listUrl = ctx.knowledge.listUrl();
737
+ try {
738
+ const result = await pushKnowledge(listUrl, accessToken, verboseFetch, flags.verbose, {
739
+ branch: ctx.branch,
740
+ delete: shouldDelete,
741
+ force: false,
742
+ items: toPushItems(knowledgeObjects),
743
+ });
744
+ knowledgeImported = result.imported ?? 0;
745
+ knowledgeDeleted = result.deleted ?? 0;
746
+ // Write GUIDs back into local frontmatter, matching server entries by name.
747
+ if (flags.guids && result.guid_map && result.guid_map.length > 0) {
748
+ const fileByName = new Map(knowledgeObjects.map((o) => [o.name, o.filePath]));
749
+ let kGuidCount = 0;
750
+ for (const entry of result.guid_map) {
751
+ const filePath = entry.guid && entry.name ? fileByName.get(entry.name) : undefined;
752
+ if (!filePath)
753
+ continue;
754
+ try {
755
+ const updated = syncGuidToFrontmatter(filePath, entry.guid);
756
+ if (updated)
757
+ kGuidCount++;
758
+ }
759
+ catch (error) {
760
+ command.warn(`Failed to sync knowledge GUID to ${filePath}: ${error.message}`);
761
+ }
762
+ }
763
+ if (kGuidCount > 0) {
764
+ log(`Synced ${kGuidCount} knowledge GUIDs to local files`);
765
+ }
766
+ }
767
+ }
768
+ catch (error) {
769
+ if (error instanceof Error && 'oclif' in error)
770
+ throw error;
771
+ const elapsedMs = Date.now() - startTime;
772
+ command.error(`Failed to push knowledge: ${describeNetworkError(error, listUrl, elapsedMs)}`);
773
+ }
646
774
  }
647
775
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
648
- const pushedCount = multidoc.split('\n---\n').length;
649
- log(`Pushed ${pushedCount} documents to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
776
+ const parts = [];
777
+ if (!knowledgeOnly)
778
+ parts.push(`${pushedDocCount} documents`);
779
+ if (ctx.knowledge && (knowledgeObjects.length > 0 || shouldDelete)) {
780
+ const kParts = [`${knowledgeImported} knowledge file${knowledgeImported === 1 ? '' : 's'}`];
781
+ if (shouldDelete && knowledgeDeleted > 0)
782
+ kParts.push(`${knowledgeDeleted} deleted`);
783
+ parts.push(kParts.join(', '));
784
+ }
785
+ log(`Pushed ${parts.join(' + ')} to ${target.label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s`);
650
786
  }
651
787
  // ── Error Handlers ──────────────────────────────────────────────────────────
652
788
  /**
@@ -662,7 +798,7 @@ export async function executePush(ctx, target, flags) {
662
798
  * failing — a failure landing near a round boundary (e.g. ~300s) is a strong
663
799
  * signal of a server-side or proxy timeout rather than a local network blip.
664
800
  */
665
- function describeNetworkError(error, url, elapsedMs) {
801
+ export function describeNetworkError(error, url, elapsedMs) {
666
802
  if (!(error instanceof Error))
667
803
  return String(error);
668
804
  let host = url;