@xano/cli 1.0.4-beta.9 → 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.
package/README.md CHANGED
@@ -284,6 +284,31 @@ when the function returns an error status.
284
284
  > denied with an access error — grant run/debug to the role (or use a token whose role has
285
285
  > it). `function list`/`get`/`create`/`edit` only need the Function permission.
286
286
 
287
+ ### Knowledge
288
+
289
+ ```bash
290
+ # List all enabled knowledge as markdown (always-on items show full content, on-demand show name + description)
291
+ xano knowledge list -w 40
292
+
293
+ # List as JSON
294
+ xano knowledge list -w 40 --output json
295
+
296
+ # Filter by knowledge type (skill, doc, agents.md)
297
+ xano knowledge list -w 40 --type skill
298
+
299
+ # Include disabled items
300
+ xano knowledge list --no-enabled-only
301
+
302
+ # Get a specific knowledge item's content by name
303
+ xano knowledge get "deploy-runbook" -w 40
304
+
305
+ # Get as JSON (includes all metadata)
306
+ xano knowledge get "deploy-runbook" -w 40 --output json
307
+
308
+ # Get a reference file attached to a knowledge item
309
+ xano knowledge get "deploy-runbook" -w 40 --file checklist.md
310
+ ```
311
+
287
312
  ### Releases
288
313
 
289
314
  All release commands use **release names** (e.g., `v1.0`), not IDs.
@@ -0,0 +1,18 @@
1
+ import BaseCommand from '../../../base-command.js';
2
+ export default class KnowledgeGet extends BaseCommand {
3
+ static args: {
4
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,126 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import BaseCommand from '../../../base-command.js';
3
+ export default class KnowledgeGet extends BaseCommand {
4
+ static args = {
5
+ name: Args.string({
6
+ description: 'Knowledge item name',
7
+ required: true,
8
+ }),
9
+ };
10
+ static description = 'Get a knowledge item by name, or one of its reference files';
11
+ static examples = [
12
+ '$ xano knowledge get "deploy-runbook" -w 40',
13
+ '$ xano knowledge get "deploy-runbook" -w 40 --output json',
14
+ '$ xano knowledge get "deploy-runbook" -w 40 --file checklist.md',
15
+ ];
16
+ static flags = {
17
+ ...BaseCommand.baseFlags,
18
+ branch: Flags.string({
19
+ char: 'b',
20
+ description: 'Branch ID',
21
+ required: false,
22
+ }),
23
+ file: Flags.string({
24
+ char: 'f',
25
+ description: 'Path of a reference file to fetch instead of the main content',
26
+ required: false,
27
+ }),
28
+ output: Flags.string({
29
+ char: 'o',
30
+ default: 'text',
31
+ description: 'Output format',
32
+ options: ['text', 'json'],
33
+ required: false,
34
+ }),
35
+ workspace: Flags.string({
36
+ char: 'w',
37
+ description: 'Workspace ID (optional if set in profile)',
38
+ required: false,
39
+ }),
40
+ };
41
+ async run() {
42
+ const { args, flags } = await this.parse(KnowledgeGet);
43
+ const { profile, profileName } = this.resolveProfile(flags);
44
+ let workspaceId;
45
+ if (flags.workspace) {
46
+ workspaceId = flags.workspace;
47
+ }
48
+ else if (profile.workspace) {
49
+ workspaceId = profile.workspace;
50
+ }
51
+ else {
52
+ this.error(`Workspace ID is required. Either:\n` +
53
+ ` 1. Provide it as a flag: xano knowledge get <name> -w <workspace_id>\n` +
54
+ ` 2. Set it in your profile using: xano profile edit ${profileName} -w <workspace_id>`);
55
+ }
56
+ const queryParams = new URLSearchParams();
57
+ queryParams.set('enabled_only', 'false');
58
+ queryParams.set('include_content', 'true');
59
+ if (flags.branch) {
60
+ queryParams.set('branch_id', flags.branch);
61
+ }
62
+ const listUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/knowledge?${queryParams.toString()}`;
63
+ try {
64
+ const listResponse = await this.verboseFetch(listUrl, {
65
+ headers: {
66
+ 'accept': 'application/json',
67
+ 'Authorization': `Bearer ${profile.access_token}`,
68
+ },
69
+ method: 'GET',
70
+ }, flags.verbose, profile.access_token);
71
+ if (!listResponse.ok) {
72
+ const errorText = await listResponse.text();
73
+ this.error(`API request failed with status ${listResponse.status}: ${listResponse.statusText}\n${errorText}`);
74
+ }
75
+ const data = await listResponse.json();
76
+ const items = Array.isArray(data) ? data : (data.items ?? []);
77
+ const targetName = args.name.toLowerCase();
78
+ const item = items.find((i) => i.name?.toLowerCase() === targetName);
79
+ if (!item) {
80
+ const available = items.map((i) => i.name).filter(Boolean).join(', ');
81
+ this.error(`Knowledge item not found: "${args.name}"\n` +
82
+ (available ? `Available items: ${available}` : 'No knowledge items exist in this workspace.'));
83
+ }
84
+ if (flags.file) {
85
+ const fileParams = new URLSearchParams({ path: flags.file });
86
+ const fileUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/knowledge/${item.id}/file?${fileParams.toString()}`;
87
+ const fileResponse = await this.verboseFetch(fileUrl, {
88
+ headers: {
89
+ 'accept': 'application/json',
90
+ 'Authorization': `Bearer ${profile.access_token}`,
91
+ },
92
+ method: 'GET',
93
+ }, flags.verbose, profile.access_token);
94
+ if (!fileResponse.ok) {
95
+ const errorText = await fileResponse.text();
96
+ this.error(`Failed to fetch file "${flags.file}" from knowledge item "${item.name}": ${fileResponse.status} ${fileResponse.statusText}\n${errorText}`);
97
+ }
98
+ const fileJson = await fileResponse.json();
99
+ const fileContent = fileJson.file?.content ?? '';
100
+ if (flags.output === 'json') {
101
+ this.log(JSON.stringify({ content: fileContent, name: item.name, path: flags.file }, null, 2));
102
+ }
103
+ else {
104
+ this.log(fileContent);
105
+ }
106
+ return;
107
+ }
108
+ if (flags.output === 'json') {
109
+ this.log(JSON.stringify(item, null, 2));
110
+ }
111
+ else {
112
+ this.log(item.content ?? '');
113
+ }
114
+ }
115
+ catch (error) {
116
+ if (error instanceof Error && 'oclif' in error)
117
+ throw error;
118
+ if (error instanceof Error) {
119
+ this.error(`Failed to fetch knowledge: ${error.message}`);
120
+ }
121
+ else {
122
+ this.error(`Failed to fetch knowledge: ${String(error)}`);
123
+ }
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,18 @@
1
+ import BaseCommand from '../../../base-command.js';
2
+ export default class KnowledgeList extends BaseCommand {
3
+ static args: {};
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ branch: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'enabled-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ type: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ };
16
+ run(): Promise<void>;
17
+ private formatMarkdown;
18
+ }
@@ -0,0 +1,135 @@
1
+ import { Flags } from '@oclif/core';
2
+ import BaseCommand from '../../../base-command.js';
3
+ export default class KnowledgeList extends BaseCommand {
4
+ static args = {};
5
+ static description = 'List workspace knowledge and skills as plain-text markdown';
6
+ static examples = [
7
+ `$ xano knowledge list -w 40
8
+ # Always-on Knowledge
9
+
10
+ ## deploy-runbook
11
+
12
+ # Deploy Runbook
13
+ ...
14
+
15
+ # On-demand Knowledge
16
+
17
+ - **code-style**: Our team coding conventions
18
+ `,
19
+ '$ xano knowledge list -w 40 --output json',
20
+ '$ xano knowledge list -w 40 --type skill',
21
+ '$ xano knowledge list --no-enabled-only',
22
+ ];
23
+ static flags = {
24
+ ...BaseCommand.baseFlags,
25
+ branch: Flags.string({
26
+ char: 'b',
27
+ description: 'Branch ID',
28
+ required: false,
29
+ }),
30
+ 'enabled-only': Flags.boolean({
31
+ allowNo: true,
32
+ default: true,
33
+ description: 'Only show enabled knowledge (use --no-enabled-only to include disabled)',
34
+ required: false,
35
+ }),
36
+ output: Flags.string({
37
+ char: 'o',
38
+ default: 'markdown',
39
+ description: 'Output format',
40
+ options: ['markdown', 'json'],
41
+ required: false,
42
+ }),
43
+ type: Flags.string({
44
+ char: 't',
45
+ description: 'Filter by knowledge type',
46
+ options: ['skill', 'doc', 'agents.md'],
47
+ required: false,
48
+ }),
49
+ workspace: Flags.string({
50
+ char: 'w',
51
+ description: 'Workspace ID (optional if set in profile)',
52
+ required: false,
53
+ }),
54
+ };
55
+ async run() {
56
+ const { flags } = await this.parse(KnowledgeList);
57
+ const { profile, profileName } = this.resolveProfile(flags);
58
+ let workspaceId;
59
+ if (flags.workspace) {
60
+ workspaceId = flags.workspace;
61
+ }
62
+ else if (profile.workspace) {
63
+ workspaceId = profile.workspace;
64
+ }
65
+ else {
66
+ this.error(`Workspace ID is required. Either:\n` +
67
+ ` 1. Provide it as a flag: xano knowledge list -w <workspace_id>\n` +
68
+ ` 2. Set it in your profile using: xano profile edit ${profileName} -w <workspace_id>`);
69
+ }
70
+ const queryParams = new URLSearchParams();
71
+ queryParams.set('enabled_only', flags['enabled-only'].toString());
72
+ queryParams.set('include_content', 'true');
73
+ if (flags.branch) {
74
+ queryParams.set('branch_id', flags.branch);
75
+ }
76
+ if (flags.type) {
77
+ queryParams.set('type', flags.type);
78
+ }
79
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/knowledge?${queryParams.toString()}`;
80
+ try {
81
+ const response = await this.verboseFetch(apiUrl, {
82
+ headers: {
83
+ 'accept': 'application/json',
84
+ 'Authorization': `Bearer ${profile.access_token}`,
85
+ },
86
+ method: 'GET',
87
+ }, flags.verbose, profile.access_token);
88
+ if (!response.ok) {
89
+ const errorText = await response.text();
90
+ this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
91
+ }
92
+ const data = await response.json();
93
+ const items = Array.isArray(data) ? data : (data.items ?? []);
94
+ if (flags.output === 'json') {
95
+ this.log(JSON.stringify(items, null, 2));
96
+ return;
97
+ }
98
+ this.log(this.formatMarkdown(items));
99
+ }
100
+ catch (error) {
101
+ if (error instanceof Error && 'oclif' in error)
102
+ throw error;
103
+ if (error instanceof Error) {
104
+ this.error(`Failed to fetch knowledge: ${error.message}`);
105
+ }
106
+ else {
107
+ this.error(`Failed to fetch knowledge: ${String(error)}`);
108
+ }
109
+ }
110
+ }
111
+ formatMarkdown(items) {
112
+ if (items.length === 0) {
113
+ return 'No knowledge items found.';
114
+ }
115
+ const alwaysOn = items.filter((item) => item.mode === 'always');
116
+ const onDemand = items.filter((item) => item.mode !== 'always');
117
+ const lines = [];
118
+ if (alwaysOn.length > 0) {
119
+ lines.push('# Always-on Knowledge');
120
+ for (const item of alwaysOn) {
121
+ lines.push('', `## ${item.name ?? '(unnamed)'}`, '', item.content ?? '', '', '---');
122
+ }
123
+ }
124
+ if (onDemand.length > 0) {
125
+ if (lines.length > 0)
126
+ lines.push('');
127
+ lines.push('# On-demand Knowledge', '');
128
+ for (const item of onDemand) {
129
+ const desc = item.description || '(no description)';
130
+ lines.push(`- **${item.name ?? '(unnamed)'}**: ${desc}`);
131
+ }
132
+ }
133
+ return lines.join('\n');
134
+ }
135
+ }
@@ -5,7 +5,7 @@ import open from 'open';
5
5
  import BaseCommand from '../../../base-command.js';
6
6
  import { executePush } from '../../../utils/multidoc-push.js';
7
7
  export default class SandboxPush extends BaseCommand {
8
- static description = '[IMPORTANT] ALWAYS run --dry-run first and show the user the output before pushing. Push local documents to your sandbox environment via multidoc import. By default, only changed files are pushed (partial mode). Use --sync to push all files. Shows a preview of changes before pushing unless --force is specified. Use --dry-run to preview only. Include/exclude glob filters are intentionally not supported on sandbox push — partial pushes can hide deletions during review and lead to data loss when promoted to the workspace. Large pushes against a sandbox loaded with a different workspace will prompt for confirmation; run `xano sandbox reset` first to start clean.';
8
+ static description = '[IMPORTANT] ALWAYS run --dry-run first and show the user the output before pushing. Push local documents to your sandbox environment via multidoc import. By default, only changed files are pushed (partial mode). Use --sync to push all files. Shows a preview of changes before pushing unless --force is specified. Use --dry-run to preview only. Include/exclude glob filters are intentionally not supported on sandbox push — partial pushes can hide deletions during review and lead to data loss when promoted to the workspace. Pushing into a sandbox that currently holds a different workspace will prompt for confirmation; run `xano sandbox reset` first to start clean.';
9
9
  static examples = [
10
10
  `$ xano sandbox push
11
11
  Push from current directory (default partial mode)
@@ -109,6 +109,7 @@ Push and open sandbox review in the browser
109
109
  cliVersion: this.config.version,
110
110
  instanceOrigin: profile.instance_origin,
111
111
  label: 'sandbox environment',
112
+ sourceWorkspaceId: profile.workspace,
112
113
  supportsBranches: false,
113
114
  supportsPartial: true,
114
115
  warnOnWorkspaceMismatch: true,
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { pushKnowledge } from '../../../utils/knowledge-sync.js';
3
4
  export default class SandboxReset extends BaseCommand {
4
5
  static description = '[CRITICAL] NEVER run without explicit user confirmation; this clears all workspace data and drafts. Resets your sandbox environment.';
5
6
  static examples = [
@@ -42,6 +43,17 @@ Sandbox environment has been reset.
42
43
  const message = await this.parseApiError(response, 'API request failed');
43
44
  this.error(message);
44
45
  }
46
+ try {
47
+ const knowledgeUrl = `${profile.instance_origin}/api:meta/sandbox/knowledge/sync`;
48
+ await pushKnowledge(knowledgeUrl, profile.access_token, this.verboseFetch.bind(this), flags.verbose, {
49
+ delete: true,
50
+ force: true,
51
+ items: [],
52
+ });
53
+ }
54
+ catch (error) {
55
+ this.warn(`Sandbox environment was reset, but clearing knowledge files failed: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
45
57
  this.log('Sandbox environment has been reset.');
46
58
  }
47
59
  catch (error) {
@@ -12,7 +12,7 @@ import { dirname, join, relative, sep } from 'node:path';
12
12
  // let the server dictate file paths.
13
13
  export const KNOWLEDGE_DIR = 'knowledge';
14
14
  // ── Frontmatter ──────────────────────────────────────────────────────────────
15
- const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'scope', 'mode', 'enabled', 'guid'];
15
+ const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'scope', 'inclusion', 'enabled', 'guid'];
16
16
  /**
17
17
  * Build a primary `.md` file body: YAML frontmatter (built from the object's
18
18
  * structured fields) followed by the markdown content.
@@ -26,7 +26,7 @@ export function buildPrimaryContent(obj) {
26
26
  if (obj.scope !== undefined)
27
27
  meta.scope = obj.scope;
28
28
  if (obj.mode !== undefined)
29
- meta.mode = obj.mode;
29
+ meta.inclusion = modeToInclusion(obj.mode);
30
30
  if (obj.enabled !== undefined)
31
31
  meta.enabled = obj.enabled;
32
32
  if (obj.guid !== undefined)
@@ -79,6 +79,34 @@ export function parseFrontmatter(content) {
79
79
  result.name = meta.name;
80
80
  return result;
81
81
  }
82
+ // ── Mode normalization ───────────────────────────────────────────────────────
83
+ /**
84
+ * The API only accepts the backend `mode` enum (`auto` | `referenced` | `always`),
85
+ * but on disk (and in the Xano UI) this is the `inclusion` field with values
86
+ * "on demand" / "manual" / "always" — see `modeToInclusion` for the pull-side
87
+ * mapping. Hand-authored frontmatter (or content copied from the UI) commonly
88
+ * uses the display label instead, which the API rejects with e.g. `Input
89
+ * "on demand" is not one of the allowable values.` (DEV-7380/DEV-7382).
90
+ * Normalize known aliases before every push so the backend enum value is
91
+ * always what's sent.
92
+ */
93
+ const MODE_ALIASES = {
94
+ 'always included': 'always',
95
+ 'manual': 'referenced',
96
+ 'on demand': 'auto',
97
+ };
98
+ function normalizeMode(mode) {
99
+ return MODE_ALIASES[mode.trim().toLowerCase()] ?? mode;
100
+ }
101
+ /** Map the backend `mode` enum to the `inclusion:` frontmatter value shown in the Xano UI. */
102
+ const MODE_TO_INCLUSION = {
103
+ always: 'always',
104
+ auto: 'on demand',
105
+ referenced: 'manual',
106
+ };
107
+ function modeToInclusion(mode) {
108
+ return MODE_TO_INCLUSION[mode] ?? mode;
109
+ }
82
110
  // ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
83
111
  /**
84
112
  * Allocate a unique snake_case folder/file segment per call, suffixing
@@ -205,13 +233,14 @@ export function collectKnowledgeObjects(inputDir, include, exclude) {
205
233
  : inferType(posixPath);
206
234
  // The API requires description/scope/mode/enabled on every item, so default
207
235
  // them when a hand-authored file omits them (pulled files always carry them).
236
+ // `mode` is read from the on-disk `inclusion:` field (see modeToInclusion).
208
237
  const obj = {
209
238
  content: body,
210
239
  description: typeof meta.description === 'string' ? meta.description : '',
211
240
  enabled: typeof meta.enabled === 'boolean' ? meta.enabled : true,
212
241
  filePath: absPath,
213
242
  knowledge_type: knowledgeType,
214
- mode: typeof meta.mode === 'string' ? meta.mode : 'auto',
243
+ mode: typeof meta.inclusion === 'string' ? normalizeMode(meta.inclusion) : 'auto',
215
244
  name: typeof meta.name === 'string' ? meta.name : deriveName(posixPath),
216
245
  scope: typeof meta.scope === 'string' ? meta.scope : 'workspace',
217
246
  };
@@ -25,14 +25,19 @@ 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
  }
@@ -51,17 +56,6 @@ export interface PushContext {
51
56
  knowledge?: KnowledgeConfig;
52
57
  verboseFetch: (url: string, options: RequestInit, verbose: boolean, authToken?: string) => Promise<Response>;
53
58
  }
54
- export declare const WORKSPACE_MISMATCH_THRESHOLD = 10;
55
- /**
56
- * Sum all impactful operations in a dry-run summary. `deleted` is only counted when the
57
- * caller actually intends to apply deletions (sync mode), matching what the user will see.
58
- */
59
- export declare function countSummaryChanges(summary: Record<string, {
60
- created: number;
61
- deleted: number;
62
- truncated: number;
63
- updated: number;
64
- }>, shouldDelete: boolean): number;
65
59
  /**
66
60
  * Filter document entries down to the ones the dry-run preview reports as changed,
67
61
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -105,10 +99,6 @@ export declare function readDocuments(files: string[]): Array<{
105
99
  }>;
106
100
  export declare function renderBadReferences(badRefs: BadReference[], log: (msg: string) => void): void;
107
101
  export declare function renderBadIndexes(badIndexes: BadIndex[], log: (msg: string) => void): void;
108
- export declare function findLocalWorkspaceName(entries: Array<{
109
- content: string;
110
- filePath: string;
111
- }>): null | string;
112
102
  export declare function confirm(message: string): Promise<boolean>;
113
103
  /**
114
104
  * Execute a multidoc push with preview, validation, partial mode, and GUID sync.
@@ -5,16 +5,6 @@ import { join, relative } from 'node:path';
5
5
  import { buildDocumentKey, findFilesWithGuid, parseDocument } from './document-parser.js';
6
6
  import { collectKnowledgeObjects, fetchKnowledge, knowledgePreview, pushKnowledge, syncGuidToFrontmatter, toPushItems, } from './knowledge-sync.js';
7
7
  import { checkReferences, checkTableIndexes } from './reference-checker.js';
8
- // Minimum total operations before a workspace mismatch is treated as worth interrupting for.
9
- // Small change sets (e.g., editing a single function) aren't worth a reset prompt.
10
- export const WORKSPACE_MISMATCH_THRESHOLD = 10;
11
- /**
12
- * Sum all impactful operations in a dry-run summary. `deleted` is only counted when the
13
- * caller actually intends to apply deletions (sync mode), matching what the user will see.
14
- */
15
- export function countSummaryChanges(summary, shouldDelete) {
16
- return Object.values(summary).reduce((sum, c) => sum + c.created + c.updated + (shouldDelete ? c.deleted : 0) + c.truncated, 0);
17
- }
18
8
  /**
19
9
  * Filter document entries down to the ones the dry-run preview reports as changed,
20
10
  * used to send only the changed documents during a partial (non-`--sync`) push.
@@ -263,14 +253,6 @@ function renderPreview(result, willDelete, target, verbose, partial, log) {
263
253
  }
264
254
  log('');
265
255
  }
266
- export function findLocalWorkspaceName(entries) {
267
- for (const entry of entries) {
268
- const parsed = parseDocument(entry.content);
269
- if (parsed?.type === 'workspace')
270
- return parsed.name;
271
- }
272
- return null;
273
- }
274
256
  // ── Confirmation ────────────────────────────────────────────────────────────
275
257
  export async function confirm(message) {
276
258
  const readline = await import('node:readline');
@@ -435,6 +417,9 @@ export async function executePush(ctx, target, flags) {
435
417
  if (target.supportsPartial) {
436
418
  queryParams.set('partial', isPartial.toString());
437
419
  }
420
+ if (target.sourceWorkspaceId) {
421
+ queryParams.set('source_workspace_id', target.sourceWorkspaceId);
422
+ }
438
423
  // ── Request headers ───────────────────────────────────────────────────
439
424
  const requestHeaders = {
440
425
  accept: 'application/json',
@@ -530,32 +515,28 @@ export async function executePush(ctx, target, flags) {
530
515
  if (flags['dry-run']) {
531
516
  return;
532
517
  }
533
- // Warn when the sandbox currently holds a different workspace than the one being
534
- // pushed and the change set is large enough that stale state is a real risk.
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).
535
522
  let mismatchConfirmed = false;
536
- if (target.warnOnWorkspaceMismatch && preview.workspace_name) {
537
- const localWorkspaceName = findLocalWorkspaceName(documentEntries);
538
- const totalChanges = countSummaryChanges(preview.summary, shouldDelete);
539
- if (localWorkspaceName &&
540
- localWorkspaceName !== preview.workspace_name &&
541
- totalChanges >= WORKSPACE_MISMATCH_THRESHOLD) {
542
- log('');
543
- log(ux.colorize('yellow', ux.colorize('bold', '=== Workspace Mismatch ===')));
544
- log('');
545
- log(ux.colorize('yellow', `Sandbox currently holds workspace "${preview.workspace_name}", but you're pushing "${localWorkspaceName}" with ${totalChanges} changes.`));
546
- log(ux.colorize('yellow', 'Pushing on top of a different workspace can leave stale data behind. Run `xano sandbox reset` first to start clean.'));
547
- log('');
548
- if (process.stdin.isTTY) {
549
- const proceed = await confirm('Continue with push anyway?');
550
- if (!proceed) {
551
- log('Push cancelled. Run `xano sandbox reset` then retry.');
552
- return;
553
- }
554
- mismatchConfirmed = true;
555
- }
556
- else {
557
- command.error('Workspace mismatch detected in non-interactive mode. Run `xano sandbox reset` first to start clean.');
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;
558
535
  }
536
+ mismatchConfirmed = true;
537
+ }
538
+ else {
539
+ command.error('Workspace mismatch detected in non-interactive mode. Run `xano sandbox reset` first to start clean.');
559
540
  }
560
541
  }
561
542
  // Confirm with user (skip if workspace mismatch prompt already obtained confirmation)