@xano/cli 0.0.67 → 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.
|
@@ -44,7 +44,6 @@ Opening browser for Xano login at https://custom.xano.com...`,
|
|
|
44
44
|
// Step 1: Get token via browser auth
|
|
45
45
|
this.log('Starting authentication flow...');
|
|
46
46
|
const token = await this.startAuthServer(flags.origin);
|
|
47
|
-
this.log(`Received token: ${token}`);
|
|
48
47
|
// Step 2: Validate token and get user info
|
|
49
48
|
this.log('');
|
|
50
49
|
this.log('Validating authentication...');
|
|
@@ -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
|
|
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
|
-
|
|
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.
|