@xano/cli 1.3.2 → 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
@@ -369,6 +413,7 @@ xano function run <name> --json @payload.json --data env=staging # base payloa
369
413
  echo '{"email":"jo@x.com"}' | xano function run <name> --stdin -o json | jq .result
370
414
  xano function run <name> --branch dev --logs # run on a branch, show execution logs
371
415
  xano function run <name> --datasource test # run against the 'test' data source
416
+ xano function run <name> --tenant my-tenant # run on a tenant instead of the workspace
372
417
  ```
373
418
 
374
419
  Input flexibility for `function run` (assembled into one JSON `input` object):
@@ -386,6 +431,9 @@ Input flexibility for `function run` (assembled into one JSON `input` object):
386
431
  to run against `live`; an unknown label is rejected by the server with
387
432
  `Invalid data source.`
388
433
 
434
+ `-t/--tenant <name>` runs the function on that tenant instead of the workspace by sending
435
+ the `X-Tenant` header (also used when reading the function's declared inputs).
436
+
389
437
  Merge order is JSON base first, then `--data` overrides. Missing required inputs are
390
438
  prompted for on an interactive terminal; in a non-TTY (CI) context the command fails
391
439
  listing them. Output defaults to the raw `result` (`-o json`); the exit code is non-zero
@@ -816,6 +864,7 @@ xano sandbox get -o json
816
864
  xano sandbox pull
817
865
  xano sandbox pull -d ./my-sandbox
818
866
  xano sandbox pull --env --records
867
+ xano sandbox pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
819
868
 
820
869
  # Push local files to sandbox (defaults to current directory, only changed files)
821
870
  xano sandbox push
@@ -823,6 +872,11 @@ xano sandbox push -d ./my-workspace # Push from a specific
823
872
  xano sandbox push --sync # Full push — send all files
824
873
  xano sandbox push --sync --delete # Full push + delete remote objects not included
825
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
826
880
  xano sandbox push --records --env # Include records and environment variables
827
881
  xano sandbox push --truncate # Truncate tables before import
828
882
  xano sandbox push --no-guids # Skip writing GUIDs back to local files
@@ -968,11 +1022,28 @@ workspace: 110 # optional override
968
1022
  instance_origin: https://your-instance.xano.io # optional override
969
1023
  account_origin: https://app.xano.com # optional override
970
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)
971
1028
  ```
972
1029
 
973
1030
  When a `profile.yaml` is in effect, every command prints the active target,
974
1031
  e.g. `Using profile 'staging' (workspace 110) · profile.yaml` (suppressed for
975
- `--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`.
976
1047
 
977
1048
  Generate one with `xano profile use`. It writes a self-documenting
978
1049
  `profile.yaml` (every overridable field is included as a commented example, so
@@ -15,6 +15,7 @@ export default class FunctionRun extends BaseCommand {
15
15
  'no-input-check': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
16
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
17
  stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ tenant: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
19
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
20
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
21
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -19,6 +19,8 @@ export default class FunctionRun extends BaseCommand {
19
19
  `$ xano function:run calcScore --branch dev --logs`,
20
20
  `$ xano function:run calcScore --datasource test
21
21
  # Runs against the 'test' data source instead of 'live'`,
22
+ `$ xano function:run calcScore --tenant my-tenant
23
+ # Runs the function on the tenant instead of the workspace`,
22
24
  ];
23
25
  static flags = {
24
26
  ...BaseCommand.baseFlags,
@@ -70,6 +72,11 @@ export default class FunctionRun extends BaseCommand {
70
72
  exclusive: ['json'],
71
73
  required: false,
72
74
  }),
75
+ tenant: Flags.string({
76
+ char: 't',
77
+ description: 'Tenant name to run the function on (defaults to the workspace)',
78
+ required: false,
79
+ }),
73
80
  workspace: Flags.string({
74
81
  char: 'w',
75
82
  description: 'Workspace ID (optional if set in profile)',
@@ -96,6 +103,7 @@ export default class FunctionRun extends BaseCommand {
96
103
  branch,
97
104
  name: functionName,
98
105
  profile,
106
+ tenant: flags.tenant,
99
107
  verbose: flags.verbose,
100
108
  workspaceId,
101
109
  });
@@ -124,6 +132,9 @@ export default class FunctionRun extends BaseCommand {
124
132
  const dataSource = flags.datasource?.trim();
125
133
  if (dataSource)
126
134
  headers['X-Data-Source'] = dataSource;
135
+ // X-Tenant runs the function on that tenant instead of the workspace.
136
+ if (flags.tenant)
137
+ headers['X-Tenant'] = flags.tenant;
127
138
  const response = await this.verboseFetch(apiUrl, {
128
139
  body: JSON.stringify(body),
129
140
  headers,
@@ -166,13 +177,16 @@ export default class FunctionRun extends BaseCommand {
166
177
  * transient introspection problem never blocks an otherwise-valid run.
167
178
  */
168
179
  async fetchFunctionByName(opts) {
169
- const { branch, name, profile, verbose, workspaceId } = opts;
180
+ const { branch, name, profile, tenant, verbose, workspaceId } = opts;
170
181
  const params = new URLSearchParams({ per_page: '100', search: name });
171
182
  if (branch)
172
183
  params.set('branch', branch);
173
184
  const url = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function?${params.toString()}`;
185
+ const headers = { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` };
186
+ if (tenant)
187
+ headers['X-Tenant'] = tenant;
174
188
  try {
175
- const response = await this.verboseFetch(url, { headers: { accept: 'application/json', Authorization: `Bearer ${profile.access_token}` }, method: 'GET' }, verbose, profile.access_token);
189
+ const response = await this.verboseFetch(url, { headers, method: 'GET' }, verbose, profile.access_token);
176
190
  if (!response.ok)
177
191
  return undefined;
178
192
  const page = (await response.json());
@@ -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>;