@xano/cli 0.0.68 → 0.0.69-beta.1

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.
@@ -14,10 +14,13 @@ export default class Push extends BaseCommand {
14
14
  'sync-guids': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  truncate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
16
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
18
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
19
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  };
20
21
  run(): Promise<void>;
22
+ private confirm;
23
+ private renderPreview;
21
24
  /**
22
25
  * Recursively collect all .xs files from a directory, sorted by
23
26
  * type subdirectory name then filename for deterministic ordering.
@@ -1,4 +1,4 @@
1
- import { Args, Flags } from '@oclif/core';
1
+ import { Args, Flags, ux } from '@oclif/core';
2
2
  import * as yaml from 'js-yaml';
3
3
  import * as fs from 'node:fs';
4
4
  import * as os from 'node:os';
@@ -12,10 +12,16 @@ export default class Push extends BaseCommand {
12
12
  required: true,
13
13
  }),
14
14
  };
15
- static description = 'Push local documents to a workspace via the Xano Metadata API multidoc endpoint';
15
+ static description = 'Push local documents to a workspace. Shows a preview of changes before pushing unless --yes is specified.';
16
16
  static examples = [
17
17
  `$ xano workspace push ./my-workspace
18
- Pushed 42 documents from ./my-workspace
18
+ Shows preview of changes, requires confirmation
19
+ `,
20
+ `$ xano workspace push ./my-workspace --force
21
+ Skip preview and push immediately (for CI/CD)
22
+ `,
23
+ `$ xano workspace push ./my-workspace --delete
24
+ Shows preview including deletions, requires confirmation
19
25
  `,
20
26
  `$ xano workspace push ./output -w 40
21
27
  Pushed 15 documents from ./output
@@ -28,9 +34,6 @@ Pushed 42 documents from ./my-workspace
28
34
  `,
29
35
  `$ xano workspace push ./my-functions --partial
30
36
  Push some files without a workspace block (implies --no-delete)
31
- `,
32
- `$ xano workspace push ./my-workspace --no-delete
33
- Patch files without deleting existing workspace objects
34
37
  `,
35
38
  `$ xano workspace push ./my-workspace --no-records
36
39
  Push schema only, skip importing table records
@@ -40,12 +43,6 @@ Push without overwriting environment variables
40
43
  `,
41
44
  `$ xano workspace push ./my-workspace --truncate
42
45
  Truncate all table records before importing
43
- `,
44
- `$ xano workspace push ./my-workspace --truncate --no-records
45
- Truncate all table records without importing new ones
46
- `,
47
- `$ xano workspace push ./my-workspace --no-records --no-env
48
- Push schema only, skip records and environment variables
49
46
  `,
50
47
  ];
51
48
  static flags = {
@@ -94,6 +91,11 @@ Push schema only, skip records and environment variables
94
91
  description: 'Workspace ID (optional if set in profile)',
95
92
  required: false,
96
93
  }),
94
+ force: Flags.boolean({
95
+ default: false,
96
+ description: 'Skip preview and confirmation prompt (for CI/CD pipelines)',
97
+ required: false,
98
+ }),
97
99
  };
98
100
  async run() {
99
101
  const { args, flags } = await this.parse(Push);
@@ -174,13 +176,117 @@ Push schema only, skip records and environment variables
174
176
  records: flags.records.toString(),
175
177
  truncate: flags.truncate.toString(),
176
178
  });
177
- const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/multidoc?${queryParams.toString()}`;
178
179
  // POST the multidoc to the API
179
180
  const requestHeaders = {
180
181
  accept: 'application/json',
181
182
  Authorization: `Bearer ${profile.access_token}`,
182
183
  'Content-Type': 'text/x-xanoscript',
183
184
  };
185
+ // Preview mode: show what would change before pushing
186
+ if (!flags.force) {
187
+ const dryRunParams = new URLSearchParams(queryParams);
188
+ // Always request delete info in dry-run so we can show remote-only items
189
+ dryRunParams.set('delete', 'true');
190
+ const dryRunUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/multidoc/dry-run?${dryRunParams.toString()}`;
191
+ try {
192
+ const dryRunResponse = await this.verboseFetch(dryRunUrl, {
193
+ body: multidoc,
194
+ headers: requestHeaders,
195
+ method: 'POST',
196
+ }, flags.verbose, profile.access_token);
197
+ if (!dryRunResponse.ok) {
198
+ if (dryRunResponse.status === 404) {
199
+ // Dry-run endpoint not available on this instance
200
+ this.log('');
201
+ this.log(ux.colorize('dim', 'Push preview not yet available on this instance.'));
202
+ this.log('');
203
+ }
204
+ else {
205
+ const errorText = await dryRunResponse.text();
206
+ this.warn(`Push preview failed (${dryRunResponse.status}). Skipping preview.`);
207
+ if (flags.verbose) {
208
+ this.log(ux.colorize('dim', errorText));
209
+ }
210
+ }
211
+ if (process.stdin.isTTY) {
212
+ const confirmed = await this.confirm('Proceed with push?');
213
+ if (!confirmed) {
214
+ this.log('Push cancelled.');
215
+ return;
216
+ }
217
+ }
218
+ // Skip the rest of preview logic
219
+ }
220
+ else {
221
+ const dryRunText = await dryRunResponse.text();
222
+ const preview = JSON.parse(dryRunText);
223
+ // Check if the server returned a valid dry-run response
224
+ if (preview && preview.summary) {
225
+ this.renderPreview(preview, shouldDelete, workspaceId);
226
+ // Check if there are any actual changes (exclude deletes when --delete is off)
227
+ const hasChanges = Object.values(preview.summary).some((c) => c.created > 0 || c.updated > 0 || (shouldDelete && c.deleted > 0) || c.truncated > 0);
228
+ if (!hasChanges) {
229
+ this.log('');
230
+ this.log('No changes to push.');
231
+ return;
232
+ }
233
+ const hasDestructive = preview.operations.some((op) => (shouldDelete && (op.action === 'delete' || op.action === 'cascade_delete')) ||
234
+ op.action === 'truncate' ||
235
+ op.action === 'drop_field' ||
236
+ op.action === 'alter_field');
237
+ const message = hasDestructive
238
+ ? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
239
+ : 'Proceed with push?';
240
+ if (process.stdin.isTTY) {
241
+ const confirmed = await this.confirm(message);
242
+ if (!confirmed) {
243
+ this.log('Push cancelled.');
244
+ return;
245
+ }
246
+ }
247
+ else {
248
+ // Non-interactive: warn and proceed
249
+ this.warn('Non-interactive environment detected, proceeding without confirmation.');
250
+ }
251
+ }
252
+ else {
253
+ // Server returned unexpected response (older version)
254
+ this.log('');
255
+ this.log(ux.colorize('dim', 'Push preview not yet available on this instance.'));
256
+ this.log('');
257
+ if (process.stdin.isTTY) {
258
+ const confirmed = await this.confirm('Proceed with push?');
259
+ if (!confirmed) {
260
+ this.log('Push cancelled.');
261
+ return;
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ catch (error) {
268
+ // Ctrl+C or SIGINT — exit cleanly
269
+ if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') {
270
+ this.log('\nPush cancelled.');
271
+ return;
272
+ }
273
+ // If dry-run fails unexpectedly, proceed without preview
274
+ this.log('');
275
+ this.log(ux.colorize('dim', 'Push preview not yet available on this instance.'));
276
+ if (flags.verbose) {
277
+ this.log(ux.colorize('dim', ` ${error.message}`));
278
+ }
279
+ this.log('');
280
+ if (process.stdin.isTTY) {
281
+ const confirmed = await this.confirm('Proceed with push?');
282
+ if (!confirmed) {
283
+ this.log('Push cancelled.');
284
+ return;
285
+ }
286
+ }
287
+ }
288
+ }
289
+ const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/multidoc?${queryParams.toString()}`;
184
290
  const startTime = Date.now();
185
291
  try {
186
292
  const response = await this.verboseFetch(apiUrl, {
@@ -289,6 +395,129 @@ Push schema only, skip records and environment variables
289
395
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
290
396
  this.log(`Pushed ${documentEntries.length} documents from ${args.directory} in ${elapsed}s`);
291
397
  }
398
+ async confirm(message) {
399
+ const readline = await import('node:readline');
400
+ const rl = readline.createInterface({
401
+ input: process.stdin,
402
+ output: process.stdout,
403
+ });
404
+ return new Promise((resolve) => {
405
+ let answered = false;
406
+ rl.on('close', () => {
407
+ if (!answered)
408
+ resolve(false);
409
+ });
410
+ rl.question(`${message} (y/N) `, (answer) => {
411
+ answered = true;
412
+ rl.close();
413
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
414
+ });
415
+ });
416
+ }
417
+ renderPreview(result, willDelete, workspaceId) {
418
+ const typeLabels = {
419
+ addon: 'Addons',
420
+ agent: 'Agents',
421
+ api_group: 'API Groups',
422
+ function: 'Functions',
423
+ mcp_server: 'MCP Servers',
424
+ middleware: 'Middleware',
425
+ query: 'API Endpoints',
426
+ realtime_channel: 'Realtime Channels',
427
+ table: 'Tables',
428
+ task: 'Tasks',
429
+ tool: 'Tools',
430
+ toolset: 'Toolsets',
431
+ trigger: 'Triggers',
432
+ workflow_test: 'Workflow Tests',
433
+ };
434
+ this.log('');
435
+ const wsLabel = result.workspace_name ? `${result.workspace_name} (${workspaceId})` : `Workspace ${workspaceId}`;
436
+ this.log(ux.colorize('bold', `=== Push Preview: ${wsLabel} ===`));
437
+ this.log('');
438
+ for (const [type, counts] of Object.entries(result.summary)) {
439
+ const label = typeLabels[type] || type;
440
+ const parts = [];
441
+ if (counts.created > 0) {
442
+ parts.push(ux.colorize('green', `+${counts.created} created`));
443
+ }
444
+ if (counts.updated > 0) {
445
+ parts.push(ux.colorize('yellow', `~${counts.updated} updated`));
446
+ }
447
+ if (willDelete && counts.deleted > 0) {
448
+ parts.push(ux.colorize('red', `-${counts.deleted} deleted`));
449
+ }
450
+ if (counts.truncated > 0) {
451
+ parts.push(ux.colorize('yellow', `${counts.truncated} truncated`));
452
+ }
453
+ if (parts.length > 0) {
454
+ this.log(` ${label.padEnd(20)} ${parts.join(' ')}`);
455
+ }
456
+ }
457
+ const changes = result.operations.filter((op) => op.action === 'create' || op.action === 'update' || op.action === 'add_field' || op.action === 'update_field');
458
+ const destructive = result.operations.filter((op) => op.action === 'delete' ||
459
+ op.action === 'cascade_delete' ||
460
+ op.action === 'truncate' ||
461
+ op.action === 'drop_field' ||
462
+ op.action === 'alter_field');
463
+ if (changes.length > 0) {
464
+ this.log('');
465
+ this.log(ux.colorize('bold', '--- Changes ---'));
466
+ this.log('');
467
+ for (const op of changes) {
468
+ const color = op.action === 'update' || op.action === 'update_field' ? 'yellow' : 'green';
469
+ const actionLabel = op.action.toUpperCase();
470
+ this.log(` ${ux.colorize(color, actionLabel.padEnd(16))} ${op.type.padEnd(18)} ${op.name}`);
471
+ if ((op.action === 'add_field' || op.action === 'update_field') && op.details) {
472
+ this.log(` ${' '.repeat(16)} ${' '.repeat(18)} ${ux.colorize('dim', op.details)}`);
473
+ }
474
+ }
475
+ }
476
+ // Split destructive ops by category
477
+ const deleteOps = destructive.filter((op) => op.action === 'delete' || op.action === 'cascade_delete');
478
+ const alwaysDestructive = destructive.filter((op) => op.action === 'truncate' || op.action === 'drop_field' || op.action === 'alter_field');
479
+ // Show destructive operations (deletes only when --delete, truncates/drop_field always)
480
+ const shownDestructive = [...(willDelete ? deleteOps : []), ...alwaysDestructive];
481
+ if (shownDestructive.length > 0) {
482
+ this.log('');
483
+ this.log(ux.colorize('bold', '--- Destructive Operations ---'));
484
+ this.log('');
485
+ for (const op of shownDestructive) {
486
+ const color = op.action === 'truncate' || op.action === 'alter_field' ? 'yellow' : 'red';
487
+ const actionLabel = op.action.toUpperCase();
488
+ this.log(` ${ux.colorize(color, actionLabel.padEnd(16))} ${op.type.padEnd(18)} ${op.name}`);
489
+ if (op.details) {
490
+ this.log(` ${' '.repeat(16)} ${' '.repeat(18)} ${ux.colorize('dim', op.details)}`);
491
+ }
492
+ }
493
+ }
494
+ // Warn about potential field renames (add + drop on same table)
495
+ const addFieldTables = new Set(result.operations
496
+ .filter((op) => op.action === 'add_field')
497
+ .map((op) => op.name));
498
+ const dropFieldTables = new Set(result.operations
499
+ .filter((op) => op.action === 'drop_field')
500
+ .map((op) => op.name));
501
+ const renameCandidates = [...addFieldTables].filter((t) => dropFieldTables.has(t));
502
+ if (renameCandidates.length > 0) {
503
+ this.log('');
504
+ this.log(ux.colorize('yellow', ` Note: Table(s) ${renameCandidates.map((t) => `"${t}"`).join(', ')} have both added and dropped fields.`));
505
+ this.log(ux.colorize('yellow', ' If this is intended to be a field rename, use the Xano Admin — renaming is not'));
506
+ this.log(ux.colorize('yellow', ' currently available through the CLI or Metadata API.'));
507
+ }
508
+ // Show remote-only items when not using --delete
509
+ if (!willDelete && deleteOps.length > 0) {
510
+ this.log('');
511
+ this.log(ux.colorize('dim', '--- Remote Only (not included in push) ---'));
512
+ this.log('');
513
+ for (const op of deleteOps) {
514
+ this.log(ux.colorize('dim', ` ${op.type.padEnd(18)} ${op.name}`));
515
+ }
516
+ this.log('');
517
+ this.log(ux.colorize('dim', ` Use --delete to remove these ${deleteOps.length} item(s) from remote.`));
518
+ }
519
+ this.log('');
520
+ }
292
521
  /**
293
522
  * Recursively collect all .xs files from a directory, sorted by
294
523
  * type subdirectory name then filename for deterministic ordering.