@xano/cli 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -199,6 +199,7 @@ xano workspace pull -d ./my-workspace # Specify output direct
199
199
  xano workspace pull -b dev # Specific branch
200
200
  xano workspace pull --env --records # Include env vars and table records
201
201
  xano workspace pull --draft # Include draft changes
202
+ xano workspace pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
202
203
 
203
204
  # Push local files to workspace (defaults to current directory, only changed files)
204
205
  xano workspace push
@@ -212,6 +213,11 @@ xano workspace push --env # Include environment v
212
213
  xano workspace push --truncate # Truncate tables before import
213
214
  xano workspace push --no-transaction # Disable database transaction wrapping
214
215
  xano workspace push --no-guids # Skip writing GUIDs back to local files
216
+ xano workspace push --fast # Push only files that changed since last sync
217
+ xano workspace push --fast --refresh # Re-compare against the server export, then push
218
+ xano workspace push --no-fast # Skip --fast even when profile.yaml sets fast: true
219
+ xano workspace push --writeback # Write server-formatted XanoScript back to pushed files
220
+ xano workspace push --writeback --writeback-diff ./diffs # Also save local vs server copies for linter work
215
221
  xano workspace push --force # Skip preview and confirmation (for CI/CD)
216
222
  xano workspace push -i "function/*" # Push only matching files
217
223
  xano workspace push -e "table/*" # Push all files except tables
@@ -244,6 +250,44 @@ directory *adds* a file rather than overwriting, and renaming a workspace leaves
244
250
  the old-name file behind. Delete the stale ones, keeping the single document that
245
251
  matches your target workspace.
246
252
 
253
+ ### `.xano/sync_state.json`
254
+
255
+ `--fast` skips files whose contents still match a local sync
256
+ record at `<push dir>/.xano/sync_state.json`. The file is per-working-copy
257
+ state: it should not be committed. Ignoring it is up to you (the CLI never
258
+ edits `.gitignore` or any other VCS ignore file).
259
+
260
+ The record is created by `workspace pull --sync-state`, `sandbox pull --sync-state`,
261
+ `workspace push --fast`, or `sandbox push --fast`.
262
+ Once it exists, every successful workspace or sandbox push or pull updates it. A successful
263
+ `workspace push --sync --delete` or `sandbox push --sync --delete` also removes entries for
264
+ files deleted locally, which clears the "deleted locally" note. A default push or pull does
265
+ not create it. `--dry-run` never writes it, and neither does `pull --draft`, because
266
+ `--fast` compares against published versions. Push records the exact bytes it sent, so a
267
+ file edited while a push runs is sent again by the next `--fast` push. Delete the file to turn this off; the next
268
+ `--fast` push compares against the server export once (~99 seconds on a
269
+ ~1,100-document workspace) and rebuilds it. If that export fails, the CLI falls
270
+ back to a full dry-run.
271
+
272
+ Sandbox `--fast` also keeps a per-user marker at `sandbox_sync.json` next to your
273
+ credentials file (normally `~/.xano/sandbox_sync.json`). A successful sandbox push
274
+ or `sandbox pull --sync-state` writes a new token there and into the directory's
275
+ record. `sandbox reset` and `sandbox delete` clear the host's entries, as does a
276
+ sandbox push from another directory. If the tokens do not match, `--fast` compares
277
+ against the sandbox export once instead of trusting the record. After a reset or
278
+ push done outside this CLI or on another machine, use `--refresh`.
279
+
280
+ `--refresh` (requires `--fast`) runs that export comparison even when a record
281
+ already exists. Use it after edits in the Xano UI, a teammate's push, or when
282
+ the record looks wrong. `--fast` will not overwrite remote-only changes; a push
283
+ without `--fast` still will.
284
+
285
+ `--fast` cannot be combined with `--sync`. It works with `--force` and still
286
+ sends only the files the record (or export comparison) marked as changed.
287
+ With `--records`, tables that have `items` are always sent, as they are without
288
+ `--fast`. With `--env`, `--fast` compares every file with the dry-run, because the
289
+ record does not track environment variables.
290
+
247
291
  ### Flatten
248
292
 
249
293
  A hand-authored `.xs` file can hold several documents joined by `---`. That is
@@ -820,6 +864,7 @@ xano sandbox get -o json
820
864
  xano sandbox pull
821
865
  xano sandbox pull -d ./my-sandbox
822
866
  xano sandbox pull --env --records
867
+ xano sandbox pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
823
868
 
824
869
  # Push local files to sandbox (defaults to current directory, only changed files)
825
870
  xano sandbox push
@@ -827,6 +872,11 @@ xano sandbox push -d ./my-workspace # Push from a specific
827
872
  xano sandbox push --sync # Full push — send all files
828
873
  xano sandbox push --sync --delete # Full push + delete remote objects not included
829
874
  xano sandbox push --dry-run # Preview changes without pushing
875
+ xano sandbox push --fast # Skip unchanged files using .xano/sync_state.json
876
+ xano sandbox push --fast --refresh # Re-compare against the sandbox export, then push
877
+ xano sandbox push --no-fast # Push without --fast even when profile.yaml sets fast: true
878
+ xano sandbox push --writeback # Write server-formatted XanoScript back after push
879
+ xano sandbox push --writeback --writeback-diff ./diffs # Also save local vs server copies
830
880
  xano sandbox push --records --env # Include records and environment variables
831
881
  xano sandbox push --truncate # Truncate tables before import
832
882
  xano sandbox push --no-guids # Skip writing GUIDs back to local files
@@ -972,11 +1022,28 @@ workspace: 110 # optional override
972
1022
  instance_origin: https://your-instance.xano.io # optional override
973
1023
  account_origin: https://app.xano.com # optional override
974
1024
  branch: main # optional override
1025
+ fast: true # optional: default workspace/sandbox push --fast
1026
+ writeback: true # optional: default workspace/sandbox push --writeback
1027
+ writeback_object_limit: 50 # optional: workspace push --writeback per-object fetch limit (default 25)
975
1028
  ```
976
1029
 
977
1030
  When a `profile.yaml` is in effect, every command prints the active target,
978
1031
  e.g. `Using profile 'staging' (workspace 110) · profile.yaml` (suppressed for
979
- `--output json`).
1032
+ `--output json`). `workspace push` and `sandbox push` also log
1033
+ `Using --fast --writeback · profile.yaml` when those keys turn the flags on.
1034
+ `--no-fast` and `--no-writeback` override them for one run. `--sync` ignores a
1035
+ `fast: true` rather than erroring.
1036
+
1037
+ `workspace push --writeback` fetches each pushed object individually when the push
1038
+ has at most `writeback_object_limit` documents (default 25), and otherwise downloads
1039
+ the full workspace export. Per-object requests grow with the number of documents;
1040
+ the export costs about the same no matter how many you pushed. Raise the limit for a
1041
+ large workspace where the export is slow; `0` always uses the export. If a per-object
1042
+ request fails, writeback falls back to the export. Sandbox push always uses the export.
1043
+ Writeback skips, with a warning, any file that changed on disk after the push read it.
1044
+
1045
+ An explicit `-p/--profile` or `XANO_PROFILE` ignores `profile.yaml` entirely,
1046
+ including `fast`, `writeback`, and `writeback_object_limit`.
980
1047
 
981
1048
  Generate one with `xano profile use`. It writes a self-documenting
982
1049
  `profile.yaml` (every overridable field is included as a commented example, so
@@ -1,5 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { dirname } from 'node:path';
2
3
  import BaseCommand from '../../../base-command.js';
4
+ import { invalidateSandboxMarkerForOrigin } from '../../../utils/sandbox-marker.js';
3
5
  export default class SandboxDelete extends BaseCommand {
4
6
  static description = '[CRITICAL] NEVER run without explicit user confirmation; this destroys all sandbox data. Deletes your sandbox environment completely (debugging only — it will be re-created on next access).';
5
7
  static examples = [
@@ -43,6 +45,12 @@ Sandbox environment deleted.
43
45
  this.error(message);
44
46
  }
45
47
  this.log('Sandbox environment deleted.');
48
+ try {
49
+ invalidateSandboxMarkerForOrigin(dirname(this.getCredentialsPath()), profile.instance_origin);
50
+ }
51
+ catch (error) {
52
+ this.warn(`Skipped invalidating sandbox sync marker: ${error.message}`);
53
+ }
46
54
  }
47
55
  catch (error) {
48
56
  if (error instanceof Error && 'oclif' in error)
@@ -1,4 +1,10 @@
1
1
  import BaseCommand from '../../../base-command.js';
2
+ /**
3
+ * Whether pull should write `.xano/sync_state.json`: only when asked to, or to keep an existing
4
+ * record current. A `--draft` pull never writes it, because `--fast` compares against published
5
+ * versions and recording draft bodies as synced would stop those drafts from being pushed.
6
+ */
7
+ export declare function syncStateAction(flag: boolean, dir: string, draft: boolean): 'draft' | 'skip' | 'write';
2
8
  export default class SandboxPull extends BaseCommand {
3
9
  static description: string;
4
10
  static examples: string[];
@@ -7,6 +13,7 @@ export default class SandboxPull extends BaseCommand {
7
13
  draft: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
14
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
15
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ 'sync-state': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
17
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
18
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
19
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -5,6 +5,18 @@ import path from 'node:path';
5
5
  import BaseCommand from '../../../base-command.js';
6
6
  import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
8
+ import { writeMarkerToken } from '../../../utils/sandbox-marker.js';
9
+ import { instanceHost, recordEntriesForPaths, sandboxTargetIdentity, saveSyncState, syncStateExists, } from '../../../utils/sync-state.js';
10
+ /**
11
+ * Whether pull should write `.xano/sync_state.json`: only when asked to, or to keep an existing
12
+ * record current. A `--draft` pull never writes it, because `--fast` compares against published
13
+ * versions and recording draft bodies as synced would stop those drafts from being pushed.
14
+ */
15
+ export function syncStateAction(flag, dir, draft) {
16
+ if (!flag && !syncStateExists(dir))
17
+ return 'skip';
18
+ return draft ? 'draft' : 'write';
19
+ }
8
20
  export default class SandboxPull extends BaseCommand {
9
21
  static description = 'Pull documents from your sandbox environment and split into individual files';
10
22
  static examples = [
@@ -15,6 +27,9 @@ Pulled 42 documents from sandbox environment to current directory
15
27
  Pulled 42 documents from sandbox environment to ./my-sandbox
16
28
  `,
17
29
  `$ xano sandbox pull --env --records`,
30
+ `$ xano sandbox pull --sync-state
31
+ Write .xano/sync_state.json after pulling; later pulls keep it current while it exists
32
+ `,
18
33
  ];
19
34
  static flags = {
20
35
  ...BaseCommand.baseFlags,
@@ -39,6 +54,11 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
39
54
  description: 'Include records',
40
55
  required: false,
41
56
  }),
57
+ 'sync-state': Flags.boolean({
58
+ default: false,
59
+ description: 'Write .xano/sync_state.json after a successful pull. Needed only the first time; later pulls keep the record current while it exists.',
60
+ required: false,
61
+ }),
42
62
  };
43
63
  async run() {
44
64
  const { flags } = await this.parse(SandboxPull);
@@ -97,6 +117,7 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
97
117
  const getChannelServer = buildChannelServerResolver(documents);
98
118
  const filenameCounters = new Map();
99
119
  let writtenCount = 0;
120
+ const writtenPaths = [];
100
121
  for (const doc of documents) {
101
122
  const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
102
123
  getApiGroupFolder,
@@ -115,6 +136,7 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
115
136
  const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
116
137
  const filePath = path.join(typeDir, filename);
117
138
  fs.writeFileSync(filePath, doc.content, 'utf8');
139
+ writtenPaths.push(filePath);
118
140
  writtenCount++;
119
141
  }
120
142
  // ── Pull knowledge ────────────────────────────────────────────────────
@@ -128,5 +150,32 @@ Pulled 42 documents from sandbox environment to ./my-sandbox
128
150
  if (knowledgeCount > 0)
129
151
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
130
152
  this.log(`Pulled ${parts.join(' + ')} from sandbox environment to ${flags.directory}`);
153
+ const syncState = syncStateAction(flags['sync-state'], outputDir, flags.draft);
154
+ if (syncState === 'skip') {
155
+ return;
156
+ }
157
+ if (syncState === 'draft') {
158
+ this.warn('Skipped writing .xano/sync_state.json: --draft pulls draft versions, which --fast does not compare against');
159
+ return;
160
+ }
161
+ try {
162
+ const tenant = await this.getOrCreateSandbox(profile, flags.verbose);
163
+ if (typeof tenant.created_at !== 'string') {
164
+ this.warn('Could not resolve sandbox fingerprints; skipped writing .xano/sync_state.json');
165
+ return;
166
+ }
167
+ const target = sandboxTargetIdentity(instanceHost(profile.instance_origin), {
168
+ created_at: tenant.created_at,
169
+ id: tenant.id,
170
+ });
171
+ const sandboxToken = writeMarkerToken(path.dirname(this.getCredentialsPath()), target.instance_host, target.branch_created_at);
172
+ saveSyncState(outputDir, target, recordEntriesForPaths(outputDir, writtenPaths), {
173
+ replace: true,
174
+ sandboxToken,
175
+ });
176
+ }
177
+ catch (error) {
178
+ this.warn(`Skipped writing .xano/sync_state.json: ${error.message}`);
179
+ }
131
180
  }
132
181
  }
@@ -7,13 +7,17 @@ export default class SandboxPush extends BaseCommand {
7
7
  directory: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
8
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
9
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ fast: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  guids: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ refresh: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
15
  review: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
16
  sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  transaction: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ writeback: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ 'writeback-diff': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
21
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
22
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
23
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,8 +1,9 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import { resolve } from 'node:path';
3
+ import { basename, dirname, relative, resolve } from 'node:path';
4
4
  import open from 'open';
5
5
  import BaseCommand from '../../../base-command.js';
6
+ import { formatWorkspacePushDefaultsBanner, resolvePushDefaults } from '../../../utils/local-config.js';
6
7
  import { executePush } from '../../../utils/multidoc-push.js';
7
8
  export default class SandboxPush extends BaseCommand {
8
9
  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.';
@@ -24,6 +25,18 @@ Preview changes without pushing
24
25
  `,
25
26
  `$ xano sandbox push --force
26
27
  Skip preview and push immediately
28
+ `,
29
+ `$ xano sandbox push --fast
30
+ Push only files that changed since the last sync, and write .xano/sync_state.json
31
+ `,
32
+ `$ xano sandbox push --fast --refresh
33
+ Re-compare local files against the sandbox export, then push changes
34
+ `,
35
+ `$ xano sandbox push --no-fast
36
+ Push without --fast even when profile.yaml sets fast: true
37
+ `,
38
+ `$ xano sandbox push --writeback
39
+ Push and write server-formatted XanoScript back to local files
27
40
  `,
28
41
  `$ xano sandbox push --records --env`,
29
42
  `$ xano sandbox push --truncate`,
@@ -54,6 +67,11 @@ Push and open sandbox review in the browser
54
67
  description: 'Include environment variables in import',
55
68
  required: false,
56
69
  }),
70
+ fast: Flags.boolean({
71
+ allowNo: true,
72
+ description: 'Skip files whose contents match .xano/sync_state.json, and write or update that record. Without a record, compares against the sandbox export once. Can be set as `fast: true` in profile.yaml.',
73
+ required: false,
74
+ }),
57
75
  force: Flags.boolean({
58
76
  default: false,
59
77
  description: 'Skip preview and confirmation prompt (for CI/CD pipelines)',
@@ -70,6 +88,11 @@ Push and open sandbox review in the browser
70
88
  description: 'Include records in import',
71
89
  required: false,
72
90
  }),
91
+ refresh: Flags.boolean({
92
+ default: false,
93
+ description: 'With --fast, re-compare local files against the sandbox export even when a sync record exists',
94
+ required: false,
95
+ }),
73
96
  review: Flags.boolean({
74
97
  default: false,
75
98
  description: 'Open sandbox review in the browser after pushing',
@@ -91,6 +114,15 @@ Push and open sandbox review in the browser
91
114
  description: '[CRITICAL] STOP and confirm with the user; this truncates live tables before importing.',
92
115
  required: false,
93
116
  }),
117
+ writeback: Flags.boolean({
118
+ allowNo: true,
119
+ description: 'After a successful push, write server-formatted XanoScript back to the files that were pushed. Can be set as `writeback: true` in profile.yaml.',
120
+ required: false,
121
+ }),
122
+ 'writeback-diff': Flags.string({
123
+ description: 'With --writeback, save local vs server copies of rewritten files under this directory',
124
+ required: false,
125
+ }),
94
126
  };
95
127
  async run() {
96
128
  const { flags } = await this.parse(SandboxPush);
@@ -103,6 +135,17 @@ Push and open sandbox review in the browser
103
135
  this.error(`Not a directory: ${inputDir}`);
104
136
  }
105
137
  const baseUrl = `${profile.instance_origin}/api:meta/sandbox`;
138
+ const pushDefaults = resolvePushDefaults({
139
+ flagFast: flags.fast,
140
+ flagWriteback: flags.writeback,
141
+ localFast: this.localProfile?.config.fast,
142
+ localWriteback: this.localProfile?.config.writeback,
143
+ sync: flags.sync,
144
+ });
145
+ if (pushDefaults.fromLocal.length > 0 && this.localProfile) {
146
+ const relativePath = relative(process.cwd(), this.localProfile.path) || basename(this.localProfile.path);
147
+ this.log(formatWorkspacePushDefaultsBanner(pushDefaults.fromLocal, relativePath));
148
+ }
106
149
  const target = {
107
150
  buildDryRunUrl: (params) => `${baseUrl}/multidoc/dry-run?${params.toString()}`,
108
151
  buildPushUrl: (params) => `${baseUrl}/multidoc?${params.toString()}`,
@@ -118,13 +161,17 @@ Push and open sandbox review in the browser
118
161
  delete: flags.delete,
119
162
  'dry-run': flags['dry-run'],
120
163
  env: flags.env,
164
+ fast: pushDefaults.fast,
121
165
  force: flags.force,
122
166
  guids: flags.guids,
123
167
  records: flags.records,
168
+ refresh: flags.refresh,
124
169
  sync: flags.sync,
125
170
  transaction: flags.transaction,
126
171
  truncate: flags.truncate,
127
172
  verbose: flags.verbose,
173
+ writeback: pushDefaults.writeback,
174
+ writebackDiff: flags['writeback-diff'],
128
175
  };
129
176
  await executePush({
130
177
  accessToken: profile.access_token,
@@ -135,6 +182,11 @@ Push and open sandbox review in the browser
135
182
  listUrl: () => `${baseUrl}/knowledge/sync`,
136
183
  rootDir: inputDir,
137
184
  },
185
+ syncTarget: {
186
+ instanceOrigin: profile.instance_origin,
187
+ kind: 'sandbox',
188
+ markerDir: dirname(this.getCredentialsPath()),
189
+ },
138
190
  verboseFetch: this.verboseFetch.bind(this),
139
191
  }, target, pushFlags);
140
192
  if (flags.review) {
@@ -1,6 +1,8 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { dirname } from 'node:path';
2
3
  import BaseCommand from '../../../base-command.js';
3
4
  import { pushKnowledge } from '../../../utils/knowledge-sync.js';
5
+ import { invalidateSandboxMarkerForOrigin } from '../../../utils/sandbox-marker.js';
4
6
  export default class SandboxReset extends BaseCommand {
5
7
  static description = '[CRITICAL] NEVER run without explicit user confirmation; this clears all workspace data and drafts. Resets your sandbox environment.';
6
8
  static examples = [
@@ -55,6 +57,12 @@ Sandbox environment has been reset.
55
57
  this.warn(`Sandbox environment was reset, but clearing knowledge files failed: ${error instanceof Error ? error.message : String(error)}`);
56
58
  }
57
59
  this.log('Sandbox environment has been reset.');
60
+ try {
61
+ invalidateSandboxMarkerForOrigin(dirname(this.getCredentialsPath()), profile.instance_origin);
62
+ }
63
+ catch (error) {
64
+ this.warn(`Skipped invalidating sandbox sync marker: ${error.message}`);
65
+ }
58
66
  }
59
67
  catch (error) {
60
68
  if (error instanceof Error && 'oclif' in error)
@@ -1,4 +1,10 @@
1
1
  import BaseCommand from '../../../base-command.js';
2
+ /**
3
+ * Whether pull should write `.xano/sync_state.json`: only when asked to, or to keep an existing
4
+ * record current. A `--draft` pull never writes it, because `--fast` compares against published
5
+ * versions and recording draft bodies as synced would stop those drafts from being pushed.
6
+ */
7
+ export declare function syncStateAction(flag: boolean, dir: string, draft: boolean): 'draft' | 'skip' | 'write';
2
8
  export default class Pull extends BaseCommand {
3
9
  static description: string;
4
10
  static examples: string[];
@@ -8,6 +14,7 @@ export default class Pull extends BaseCommand {
8
14
  draft: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
15
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
16
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ 'sync-state': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
18
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
19
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
20
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -5,6 +5,17 @@ import path from 'node:path';
5
5
  import BaseCommand from '../../../base-command.js';
6
6
  import { buildApiGroupFolderResolver, buildChannelServerResolver, parseDocument, resolveDocumentPath, } from '../../../utils/document-parser.js';
7
7
  import { fetchKnowledge, writeKnowledge } from '../../../utils/knowledge-sync.js';
8
+ import { recordEntriesForPaths, resolveTarget, saveSyncState, syncStateExists } from '../../../utils/sync-state.js';
9
+ /**
10
+ * Whether pull should write `.xano/sync_state.json`: only when asked to, or to keep an existing
11
+ * record current. A `--draft` pull never writes it, because `--fast` compares against published
12
+ * versions and recording draft bodies as synced would stop those drafts from being pushed.
13
+ */
14
+ export function syncStateAction(flag, dir, draft) {
15
+ if (!flag && !syncStateExists(dir))
16
+ return 'skip';
17
+ return draft ? 'draft' : 'write';
18
+ }
8
19
  export default class Pull extends BaseCommand {
9
20
  static description = 'Pull a workspace multidoc from the Xano Metadata API and split into individual files';
10
21
  static examples = [
@@ -22,6 +33,9 @@ Pulled 58 documents
22
33
  `,
23
34
  `$ xano workspace pull --draft`,
24
35
  `$ xano workspace pull -b dev`,
36
+ `$ xano workspace pull --sync-state
37
+ Write .xano/sync_state.json after pulling; later pulls keep it current while it exists
38
+ `,
25
39
  ];
26
40
  static flags = {
27
41
  ...BaseCommand.baseFlags,
@@ -51,6 +65,11 @@ Pulled 58 documents
51
65
  description: 'Include records',
52
66
  required: false,
53
67
  }),
68
+ 'sync-state': Flags.boolean({
69
+ default: false,
70
+ description: 'Write .xano/sync_state.json after a successful pull. Needed only the first time; later pulls keep the record current while it exists.',
71
+ required: false,
72
+ }),
54
73
  workspace: Flags.string({
55
74
  char: 'w',
56
75
  description: 'Workspace ID (optional if set in profile)',
@@ -140,6 +159,7 @@ Pulled 58 documents
140
159
  // Track filenames per type to handle duplicates
141
160
  const filenameCounters = new Map();
142
161
  let writtenCount = 0;
162
+ const writtenPaths = [];
143
163
  for (const doc of documents) {
144
164
  const { baseName, typeDir } = resolveDocumentPath(doc, outputDir, {
145
165
  getApiGroupFolder,
@@ -160,6 +180,7 @@ Pulled 58 documents
160
180
  const filename = count === 0 ? `${baseName}.xs` : `${baseName}_${count + 1}.xs`;
161
181
  const filePath = path.join(typeDir, filename);
162
182
  fs.writeFileSync(filePath, doc.content, 'utf8');
183
+ writtenPaths.push(filePath);
163
184
  writtenCount++;
164
185
  }
165
186
  // ── Pull knowledge ────────────────────────────────────────────────────
@@ -173,5 +194,38 @@ Pulled 58 documents
173
194
  if (knowledgeCount > 0)
174
195
  parts.push(`${knowledgeCount} knowledge file${knowledgeCount === 1 ? '' : 's'}`);
175
196
  this.log(`Pulled ${parts.join(' + ')} to ${flags.directory}`);
197
+ const syncState = syncStateAction(flags['sync-state'], outputDir, flags.draft);
198
+ if (syncState === 'skip') {
199
+ return;
200
+ }
201
+ if (syncState === 'draft') {
202
+ this.warn('Skipped writing .xano/sync_state.json: --draft pulls draft versions, which --fast does not compare against');
203
+ return;
204
+ }
205
+ const origin = profile.instance_origin.replace(/\/$/, '');
206
+ try {
207
+ const target = await resolveTarget(profile.instance_origin, workspaceId, branch, async (pathname) => {
208
+ const response = await this.verboseFetch(`${origin}/api:meta${pathname}`, {
209
+ headers: {
210
+ accept: 'application/json',
211
+ Authorization: `Bearer ${profile.access_token}`,
212
+ },
213
+ method: 'GET',
214
+ }, flags.verbose, profile.access_token);
215
+ if (!response.ok) {
216
+ throw new Error(`HTTP ${response.status}`);
217
+ }
218
+ return response.json();
219
+ });
220
+ if (!target) {
221
+ this.warn('Could not resolve workspace/branch fingerprints; skipped writing .xano/sync_state.json');
222
+ return;
223
+ }
224
+ const entries = recordEntriesForPaths(outputDir, writtenPaths);
225
+ saveSyncState(outputDir, target, entries, { replace: true });
226
+ }
227
+ catch (error) {
228
+ this.warn(`Skipped writing .xano/sync_state.json: ${error.message}`);
229
+ }
176
230
  }
177
231
  }
@@ -9,14 +9,18 @@ export default class Push extends BaseCommand {
9
9
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  exclude: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ fast: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  guids: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  include: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  records: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ refresh: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
18
  sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  transaction: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
20
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
21
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ writeback: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
+ 'writeback-diff': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
24
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
25
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
26
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,7 +1,8 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import * as fs from 'node:fs';
3
- import { resolve } from 'node:path';
3
+ import { basename, relative, resolve } from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
+ import { formatWorkspacePushDefaultsBanner, resolvePushDefaults } from '../../../utils/local-config.js';
5
6
  import { executePush } from '../../../utils/multidoc-push.js';
6
7
  export default class Push extends BaseCommand {
7
8
  static description = '[IMPORTANT] ALWAYS run --dry-run first and show the user the output before pushing. Push local documents to a workspace. 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.';
@@ -23,6 +24,18 @@ Preview changes without pushing
23
24
  `,
24
25
  `$ xano workspace push --force
25
26
  Skip preview and push immediately (for CI/CD)
27
+ `,
28
+ `$ xano workspace push --fast
29
+ Push only files that changed since the last sync, and write .xano/sync_state.json
30
+ `,
31
+ `$ xano workspace push --fast --refresh
32
+ Re-compare local files against the server export, then push changes
33
+ `,
34
+ `$ xano workspace push --no-fast
35
+ Push without --fast even when profile.yaml sets fast: true
36
+ `,
37
+ `$ xano workspace push --writeback
38
+ Push and write server-formatted XanoScript back to local files
26
39
  `,
27
40
  `$ xano workspace push -d ./output -w 40
28
41
  Pushed 15 documents from ./output
@@ -95,6 +108,11 @@ Full sync including knowledge files; removes server objects not present locally
95
108
  multiple: true,
96
109
  required: false,
97
110
  }),
111
+ fast: Flags.boolean({
112
+ allowNo: true,
113
+ description: 'Skip files whose contents match .xano/sync_state.json, and write or update that record. Without a record, compares against the server export once. Can be set as `fast: true` in profile.yaml.',
114
+ required: false,
115
+ }),
98
116
  force: Flags.boolean({
99
117
  default: false,
100
118
  description: '[CRITICAL] NEVER run without explicit user confirmation. Skips preview and confirmation prompt (for CI/CD pipelines).',
@@ -117,6 +135,11 @@ Full sync including knowledge files; removes server objects not present locally
117
135
  description: '[CRITICAL] STOP and ALWAYS run --dry-run first to show the user a preview before pushing live table records. Includes table records in import.',
118
136
  required: false,
119
137
  }),
138
+ refresh: Flags.boolean({
139
+ default: false,
140
+ description: 'With --fast, re-compare local files against the server export even when a sync record exists',
141
+ required: false,
142
+ }),
120
143
  sync: Flags.boolean({
121
144
  default: false,
122
145
  description: 'Full push — send all files, not just changed ones. Required for --delete.',
@@ -138,6 +161,15 @@ Full sync including knowledge files; removes server objects not present locally
138
161
  description: 'Workspace ID (optional if set in profile)',
139
162
  required: false,
140
163
  }),
164
+ writeback: Flags.boolean({
165
+ allowNo: true,
166
+ description: 'After a successful push, write server-formatted XanoScript back to the files that were pushed. Can be set as `writeback: true` in profile.yaml.',
167
+ required: false,
168
+ }),
169
+ 'writeback-diff': Flags.string({
170
+ description: 'With --writeback, save local vs server copies of rewritten files under this directory',
171
+ required: false,
172
+ }),
141
173
  };
142
174
  async run() {
143
175
  const { flags } = await this.parse(Push);
@@ -164,6 +196,17 @@ Full sync including knowledge files; removes server objects not present locally
164
196
  }
165
197
  const branch = flags.branch || profile.branch || '';
166
198
  const baseUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}`;
199
+ const pushDefaults = resolvePushDefaults({
200
+ flagFast: flags.fast,
201
+ flagWriteback: flags.writeback,
202
+ localFast: this.localProfile?.config.fast,
203
+ localWriteback: this.localProfile?.config.writeback,
204
+ sync: flags.sync,
205
+ });
206
+ if (pushDefaults.fromLocal.length > 0 && this.localProfile) {
207
+ const relativePath = relative(process.cwd(), this.localProfile.path) || basename(this.localProfile.path);
208
+ this.log(formatWorkspacePushDefaultsBanner(pushDefaults.fromLocal, relativePath));
209
+ }
167
210
  const target = {
168
211
  buildDryRunUrl: (params) => `${baseUrl}/multidoc/dry-run?${params.toString()}`,
169
212
  buildPushUrl: (params) => `${baseUrl}/multidoc?${params.toString()}`,
@@ -178,14 +221,19 @@ Full sync including knowledge files; removes server objects not present locally
178
221
  'dry-run': flags['dry-run'],
179
222
  env: flags.env,
180
223
  exclude: flags.exclude,
224
+ fast: pushDefaults.fast,
181
225
  force: flags.force,
182
226
  guids: flags.guids,
183
227
  include: flags.include,
184
228
  records: flags.records,
229
+ refresh: flags.refresh,
185
230
  sync: flags.sync,
186
231
  transaction: flags.transaction,
187
232
  truncate: flags.truncate,
188
233
  verbose: flags.verbose,
234
+ writeback: pushDefaults.writeback,
235
+ writebackDiff: flags['writeback-diff'],
236
+ writebackObjectLimit: this.localProfile?.config.writeback_object_limit,
189
237
  };
190
238
  await executePush({
191
239
  accessToken: profile.access_token,
@@ -196,6 +244,11 @@ Full sync including knowledge files; removes server objects not present locally
196
244
  listUrl: () => `${baseUrl}/knowledge/sync`,
197
245
  rootDir: inputDir,
198
246
  },
247
+ syncTarget: {
248
+ instanceOrigin: profile.instance_origin,
249
+ kind: 'workspace',
250
+ workspace: workspaceId,
251
+ },
199
252
  verboseFetch: this.verboseFetch.bind(this),
200
253
  }, target, pushFlags);
201
254
  }